TechDocs: Access entity techdocs from service catalog (#1835)
* feature(techdocs): JIT generation of techdocs * Fix linting issues * Add Techdocs tab to entity page * Added missing dep * Added missing dep * Removed duplicate dep * Better tab navigation * Update label on entity page to say docs * Fix lint issue * Move building of docs from entity to a function * feature(techdocs): JIT generation of techdocs * Fix linting issues * Add Techdocs tab to entity page * Added missing dep * Added missing dep * Removed duplicate dep * Better tab navigation * Update label on entity page to say docs * Fix lint issue * Move building of docs from entity to a function * attempt to add back react as a dep * Fixed failing test * fix(techdocs): Hopefully fixed tests * Fixed another failing test * Initial apiRef for techdocs storage * Cleaned up some code in reader * test(headertabs): add tests to HeaderTabs component * fix(headertabs): change tab mock id * fix(techdocs-generator): fix problem with macOS symlink to tmp folder * fix(lint): remove unused configApiRef * wip * Ongoing cleanups * Cleanups and fix tests * WIP cleanups * Clean up some things * Clean up some things * Added missing notice header * ts issue fixed * Add show contition to api tab Co-authored-by: Fredrik Adelöw <freben@gmail.com> Co-authored-by: Emma Indal <emmai@spotify.com>
This commit is contained in:
committed by
GitHub
parent
774a000729
commit
13908d69c2
@@ -27,6 +27,9 @@ describe('App', () => {
|
||||
data: {
|
||||
app: { title: 'Test' },
|
||||
backend: { baseUrl: 'http://localhost:7000' },
|
||||
techdocs: {
|
||||
storageUrl: 'http://localhost:7000/techdocs/static/docs',
|
||||
},
|
||||
},
|
||||
context: 'test',
|
||||
},
|
||||
|
||||
@@ -58,6 +58,10 @@ import {
|
||||
GraphQLEndpoints,
|
||||
} from '@backstage/plugin-graphiql';
|
||||
import { scaffolderApiRef, ScaffolderApi } from '@backstage/plugin-scaffolder';
|
||||
import {
|
||||
techdocsStorageApiRef,
|
||||
TechDocsStorageApi,
|
||||
} from '@backstage/plugin-techdocs';
|
||||
|
||||
import { rollbarApiRef, RollbarClient } from '@backstage/plugin-rollbar';
|
||||
import {
|
||||
@@ -66,13 +70,17 @@ import {
|
||||
} from '@backstage/plugin-github-actions';
|
||||
import { jenkinsApiRef, JenkinsApi } from '@backstage/plugin-jenkins';
|
||||
|
||||
import { TravisCIApi, travisCIApiRef } from '@roadiehq/backstage-plugin-travis-ci';
|
||||
import {
|
||||
TravisCIApi,
|
||||
travisCIApiRef,
|
||||
} from '@roadiehq/backstage-plugin-travis-ci';
|
||||
|
||||
export const apis = (config: ConfigApi) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Creating APIs for ${config.getString('app.title')}`);
|
||||
|
||||
const backendUrl = config.getString('backend.baseUrl');
|
||||
const techdocsUrl = config.getString('techdocs.storageUrl');
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
@@ -138,7 +146,7 @@ export const apis = (config: ConfigApi) => {
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
builder.add(
|
||||
auth0AuthApiRef,
|
||||
Auth0Auth.create({
|
||||
@@ -208,5 +216,12 @@ export const apis = (config: ConfigApi) => {
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
techdocsStorageApiRef,
|
||||
new TechDocsStorageApi({
|
||||
apiOrigin: techdocsUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
return builder.build();
|
||||
};
|
||||
|
||||
@@ -270,7 +270,9 @@ export const gitlabAuthApiRef = createApiRef<
|
||||
* See https://auth0.com/docs/scopes/current/oidc-scopes
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const auth0AuthApiRef = createApiRef<OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi>({
|
||||
export const auth0AuthApiRef = createApiRef<
|
||||
OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi
|
||||
>({
|
||||
id: 'core.auth.auth0',
|
||||
description: 'Provides authentication towards Auth0 APIs',
|
||||
});
|
||||
|
||||
@@ -96,11 +96,7 @@ class Auth0Auth
|
||||
|
||||
const sessionManager = new RefreshingAuthSessionManager({
|
||||
connector,
|
||||
defaultScopes: new Set([
|
||||
'openid',
|
||||
`email`,
|
||||
`profile`,
|
||||
]),
|
||||
defaultScopes: new Set(['openid', `email`, `profile`]),
|
||||
sessionScopes: (session: Auth0Session) => session.providerInfo.scopes,
|
||||
sessionShouldRefresh: (session: Auth0Session) => {
|
||||
const expiresInSec =
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { HeaderTabs } from './';
|
||||
|
||||
const mockTabs = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'docs', label: 'Docs' },
|
||||
];
|
||||
|
||||
describe('<HeaderTabs />', () => {
|
||||
it('should render tabs', () => {
|
||||
const rendered = render(wrapInTestApp(<HeaderTabs tabs={mockTabs} />));
|
||||
|
||||
expect(rendered.getByText('Overview')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Docs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct selected tab', () => {
|
||||
const rendered = render(wrapInTestApp(<HeaderTabs tabs={mockTabs} />));
|
||||
|
||||
expect(rendered.getByText('Docs').parentElement).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
|
||||
rendered.getByText('Docs').click();
|
||||
|
||||
expect(rendered.getByText('Docs').parentElement).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
// TODO(blam): Remove this implementation when the Tabs are ready
|
||||
// This is just a temporary solution to implementing tabs for now
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { makeStyles, Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -46,15 +46,24 @@ export type Tab = {
|
||||
export const HeaderTabs: React.FC<{
|
||||
tabs: Tab[];
|
||||
onChange?: (index: number) => void;
|
||||
}> = ({ tabs, onChange }) => {
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
selectedIndex?: number;
|
||||
}> = ({ tabs, onChange, selectedIndex }) => {
|
||||
const [selectedTab, setSelectedTab] = useState<number>(selectedIndex ?? 0);
|
||||
const styles = useStyles();
|
||||
|
||||
const handleChange = (_: React.ChangeEvent<{}>, index: number) => {
|
||||
setSelectedTab(index);
|
||||
if (selectedIndex === undefined) {
|
||||
setSelectedTab(index);
|
||||
}
|
||||
if (onChange) onChange(index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex !== undefined) {
|
||||
setSelectedTab(selectedIndex);
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<div className={styles.tabsWrapper}>
|
||||
<Tabs
|
||||
|
||||
@@ -38,7 +38,7 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
type ItemCardProps = {
|
||||
description: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
title: string;
|
||||
type?: string;
|
||||
|
||||
@@ -78,8 +78,7 @@ const loader: ProviderLoader = async apis => {
|
||||
return {
|
||||
userId: identity.id,
|
||||
profile: profile!,
|
||||
getIdToken: () =>
|
||||
auth0AuthApi.getBackstageIdentity().then(i => i!.idToken),
|
||||
getIdToken: () => auth0AuthApi.getBackstageIdentity().then(i => i!.idToken),
|
||||
logout: async () => {
|
||||
await auth0AuthApi.logout();
|
||||
},
|
||||
|
||||
@@ -16,22 +16,25 @@
|
||||
import OAuth2Strategy from 'passport-oauth2';
|
||||
|
||||
export interface Auth0StrategyOptionsWithRequest {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
callbackURL: string;
|
||||
domain: string;
|
||||
passReqToCallback: true;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
callbackURL: string;
|
||||
domain: string;
|
||||
passReqToCallback: true;
|
||||
}
|
||||
|
||||
export default class Auth0Strategy extends OAuth2Strategy {
|
||||
constructor(options: Auth0StrategyOptionsWithRequest, verify: OAuth2Strategy.VerifyFunctionWithRequest) {
|
||||
const optionsWithURLs = {
|
||||
...options,
|
||||
authorizationURL: `https://${options.domain}/authorize`,
|
||||
tokenURL: `https://${options.domain}/oauth/token`,
|
||||
userInfoURL: `https://${options.domain}/userinfo`,
|
||||
apiUrl: `https://${options.domain}/api`,
|
||||
}
|
||||
super(optionsWithURLs, verify)
|
||||
}
|
||||
constructor(
|
||||
options: Auth0StrategyOptionsWithRequest,
|
||||
verify: OAuth2Strategy.VerifyFunctionWithRequest,
|
||||
) {
|
||||
const optionsWithURLs = {
|
||||
...options,
|
||||
authorizationURL: `https://${options.domain}/authorize`,
|
||||
tokenURL: `https://${options.domain}/oauth/token`,
|
||||
userInfoURL: `https://${options.domain}/userinfo`,
|
||||
apiUrl: `https://${options.domain}/api`,
|
||||
};
|
||||
super(optionsWithURLs, verify);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createGoogleProvider } from './google';
|
||||
import { createOAuth2Provider } from './oauth2';
|
||||
import { createOktaProvider } from './okta';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { createAuth0Provider } from './auth0'
|
||||
import { createAuth0Provider } from './auth0';
|
||||
import {
|
||||
AuthProviderConfig,
|
||||
AuthProviderFactory,
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@backstage/plugin-jenkins": "^0.1.1-alpha.18",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.18",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.18",
|
||||
"@backstage/plugin-techdocs": "^0.1.1-alpha.18",
|
||||
"@backstage/theme": "^0.1.1-alpha.18",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -45,6 +45,7 @@ const columns: TableColumn<Entity>[] = [
|
||||
.filter(Boolean)
|
||||
.join(':'),
|
||||
kind: entity.kind,
|
||||
selectedTabId: 'overview',
|
||||
})}
|
||||
>
|
||||
{entity.metadata.name}
|
||||
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
errorApiRef,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
HeaderTabs,
|
||||
Page,
|
||||
pageTheme,
|
||||
PageTheme,
|
||||
Progress,
|
||||
useApi,
|
||||
HeaderTabs,
|
||||
} from '@backstage/core';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
@@ -34,6 +34,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
|
||||
import { EntityPageDocs } from '../EntityPageDocs/EntityDocsPage';
|
||||
import { EntityPageApi } from '../EntityPageApi/EntityPageApi';
|
||||
import { EntityPageOverview } from '../EntityPageOverview/EntityPageOverview';
|
||||
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
|
||||
@@ -75,11 +76,17 @@ const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
|
||||
);
|
||||
|
||||
export const EntityPage: FC<{}> = () => {
|
||||
const { optionalNamespaceAndName, kind } = useParams() as {
|
||||
const {
|
||||
optionalNamespaceAndName,
|
||||
kind,
|
||||
selectedTabId = 'overview',
|
||||
} = useParams() as {
|
||||
optionalNamespaceAndName: string;
|
||||
kind: string;
|
||||
selectedTabId: string;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
|
||||
|
||||
const errorApi = useApi(errorApiRef);
|
||||
@@ -100,8 +107,6 @@ export const EntityPage: FC<{}> = () => {
|
||||
}
|
||||
}, [errorApi, navigate, error, loading, entity]);
|
||||
|
||||
const [selectedTabId, setSelectedTabId] = useState('');
|
||||
|
||||
if (!name) {
|
||||
navigate('/catalog');
|
||||
return null;
|
||||
@@ -132,6 +137,7 @@ export const EntityPage: FC<{}> = () => {
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API',
|
||||
show: (e: Entity) => !!e?.spec?.implementsApis,
|
||||
content: (e: Entity) => <EntityPageApi entity={e} />,
|
||||
},
|
||||
{
|
||||
@@ -142,6 +148,13 @@ export const EntityPage: FC<{}> = () => {
|
||||
id: 'quality',
|
||||
label: 'Quality',
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
show: (e: Entity) =>
|
||||
!!e.metadata.annotations?.['backstage.io/techdocs-ref'],
|
||||
content: (e: Entity) => <EntityPageDocs entity={e} />,
|
||||
},
|
||||
];
|
||||
|
||||
const { headerTitle, headerType } = headerProps(
|
||||
@@ -151,7 +164,11 @@ export const EntityPage: FC<{}> = () => {
|
||||
entity,
|
||||
);
|
||||
|
||||
const selectedTab = tabs.find(tab => tab.id === selectedTabId) || tabs[0];
|
||||
const selectedTab = tabs.find(tab => tab.id === selectedTabId);
|
||||
|
||||
const filteredHeaderTabs = entity
|
||||
? tabs.filter(tab => (tab.show ? tab.show(entity) : true))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Page theme={getPageTheme(entity)}>
|
||||
@@ -186,17 +203,18 @@ export const EntityPage: FC<{}> = () => {
|
||||
{entity && (
|
||||
<>
|
||||
<HeaderTabs
|
||||
tabs={tabs}
|
||||
tabs={filteredHeaderTabs}
|
||||
onChange={idx => {
|
||||
setSelectedTabId(tabs[idx].id);
|
||||
navigate(
|
||||
`/catalog/${kind}/${optionalNamespaceAndName}/${tabs[idx].id}`,
|
||||
);
|
||||
}}
|
||||
selectedIndex={tabs.findIndex(tab => tab.id === selectedTabId)}
|
||||
/>
|
||||
|
||||
<Content>
|
||||
{selectedTab && selectedTab.content
|
||||
? selectedTab.content(entity)
|
||||
: null}
|
||||
</Content>
|
||||
{selectedTab && selectedTab.content
|
||||
? selectedTab.content(entity)
|
||||
: null}
|
||||
|
||||
<UnregisterEntityDialog
|
||||
open={confirmationDialogOpen}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Reader } from '@backstage/plugin-techdocs';
|
||||
import { Content } from '@backstage/core';
|
||||
|
||||
export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
|
||||
return (
|
||||
<Content>
|
||||
<Reader
|
||||
entityId={{
|
||||
kind: entity.kind,
|
||||
namespace: entity.metadata.namespace,
|
||||
name: entity.metadata.name,
|
||||
}}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
@@ -17,12 +17,13 @@
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { CatalogPage } from './components/CatalogPage/CatalogPage';
|
||||
import { EntityPage } from './components/EntityPage/EntityPage';
|
||||
import { entityRoute, rootRoute } from './routes';
|
||||
import { entityRoute, rootRoute, entityRouteDefault } from './routes';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'catalog',
|
||||
register({ router }) {
|
||||
router.addRoute(rootRoute, CatalogPage);
|
||||
router.addRoute(entityRoute, EntityPage);
|
||||
router.addRoute(entityRouteDefault, EntityPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ export const rootRoute = createRouteRef({
|
||||
});
|
||||
export const entityRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/catalog/:kind/:optionalNamespaceAndName/',
|
||||
path: '/catalog/:kind/:optionalNamespaceAndName/:selectedTabId/*',
|
||||
title: 'Entity',
|
||||
});
|
||||
export const entityRouteDefault = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/catalog/:kind/:optionalNamespaceAndName',
|
||||
title: 'Entity',
|
||||
});
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ export const RegisterComponentResultDialog: FC<Props> = ({
|
||||
.filter(Boolean)
|
||||
.join(':'),
|
||||
kind: entity.kind,
|
||||
selectedTabId: 'overview',
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
ignorePatterns: ['static/**'],
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Another page in our documentation
|
||||
@@ -2,6 +2,7 @@ site_name: 'example-docs'
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- SubPage: sub-page.md
|
||||
|
||||
plugins:
|
||||
- techdocs-core
|
||||
|
||||
@@ -48,6 +48,21 @@ export async function createRouter({
|
||||
}: RouterOptions): Promise<express.Router> {
|
||||
const router = Router();
|
||||
|
||||
const buildDocsForEntity = async (entity: Entity) => {
|
||||
const preparer = preparers.get(entity);
|
||||
const generator = generators.get(entity);
|
||||
|
||||
const { resultDir } = await generator.run({
|
||||
directory: await preparer.prepare(entity),
|
||||
dockerClient,
|
||||
});
|
||||
|
||||
await publisher.publish({
|
||||
entity,
|
||||
directory: resultDir,
|
||||
});
|
||||
};
|
||||
|
||||
router.get('/', async (_, res) => {
|
||||
res.status(200).send('Hello TechDocs Backend');
|
||||
});
|
||||
@@ -64,18 +79,7 @@ export async function createRouter({
|
||||
);
|
||||
|
||||
entitiesWithDocs.forEach(async entity => {
|
||||
const preparer = preparers.get(entity);
|
||||
const generator = generators.get(entity);
|
||||
|
||||
const { resultDir } = await generator.run({
|
||||
directory: await preparer.prepare(entity),
|
||||
dockerClient,
|
||||
});
|
||||
|
||||
publisher.publish({
|
||||
entity,
|
||||
directory: resultDir,
|
||||
});
|
||||
await buildDocsForEntity(entity);
|
||||
});
|
||||
|
||||
res.send('Successfully generated documentation');
|
||||
@@ -86,6 +90,23 @@ export async function createRouter({
|
||||
'/static/docs/',
|
||||
express.static(path.resolve(__dirname, `../../static/docs`)),
|
||||
);
|
||||
router.use(
|
||||
'/static/docs/:kind/:namespace/:name',
|
||||
async (req, res, next) => {
|
||||
const baseUrl = config.getString('backend.baseUrl');
|
||||
const { kind, namespace, name } = req.params;
|
||||
|
||||
const entityResponse = await fetch(
|
||||
`${baseUrl}/catalog/entities/by-name/${kind}/${namespace}/${name}`,
|
||||
);
|
||||
if (!entityResponse.ok) next();
|
||||
const entity = (await entityResponse.json()) as Entity;
|
||||
|
||||
await buildDocsForEntity(entity);
|
||||
|
||||
res.redirect(req.originalUrl);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return router;
|
||||
|
||||
@@ -47,6 +47,16 @@ export async function runDockerContainer({
|
||||
dockerClient,
|
||||
createOptions,
|
||||
}: RunDockerContainerOptions) {
|
||||
await new Promise((resolve, reject) => {
|
||||
dockerClient.pull(imageName, {}, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
stream.pipe(logStream, { end: false });
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (error: Error) => reject(error));
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run(
|
||||
imageName,
|
||||
args,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
GeneratorRunResult,
|
||||
} from './types';
|
||||
import { runDockerContainer } from './helpers';
|
||||
import fs from 'fs';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
@@ -29,7 +29,12 @@ export class TechdocsGenerator implements GeneratorBase {
|
||||
logStream,
|
||||
dockerClient,
|
||||
}: GeneratorRunOptions): Promise<GeneratorRunResult> {
|
||||
const resultDir = fs.mkdtempSync(path.join(os.tmpdir(), `techdocs-tmp-`));
|
||||
const tmpdirPath = os.tmpdir();
|
||||
// Fixes a problem with macOS returning a path that is a symlink
|
||||
const tmpdirResolvedPath = fs.realpathSync(tmpdirPath);
|
||||
const resultDir = fs.mkdtempSync(
|
||||
path.join(tmpdirResolvedPath, 'techdocs-tmp-'),
|
||||
);
|
||||
|
||||
await runDockerContainer({
|
||||
imageName: 'spotify/techdocs',
|
||||
@@ -43,6 +48,7 @@ export class TechdocsGenerator implements GeneratorBase {
|
||||
console.log(
|
||||
`[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`,
|
||||
);
|
||||
|
||||
return { resultDir };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ describe('local publisher', () => {
|
||||
`../../../../static/docs/${mockEntity.metadata.name}`,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(publishDir)).toBeTruthy();
|
||||
expect(fs.existsSync(path.join(publishDir, '/mock-file'))).toBeTruthy();
|
||||
const resultDir = path.resolve(
|
||||
__dirname,
|
||||
`../../../../static/docs/${mockEntity.kind}/default/${mockEntity.metadata.name}`,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(resultDir)).toBeTruthy();
|
||||
expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy();
|
||||
|
||||
fs.removeSync(publishDir);
|
||||
fs.removeSync(tempDir);
|
||||
|
||||
@@ -30,9 +30,14 @@ export class LocalPublish implements PublisherBase {
|
||||
remoteUrl: string;
|
||||
}>
|
||||
| { remoteUrl: string } {
|
||||
const entityNamespace = entity.metadata.namespace ?? 'default';
|
||||
|
||||
const publishDir = path.resolve(
|
||||
__dirname,
|
||||
`../../../../static/docs/${entity.metadata.name}`,
|
||||
'../../../../static/docs/',
|
||||
entity.kind,
|
||||
entityNamespace,
|
||||
entity.metadata.name
|
||||
);
|
||||
|
||||
if (!fs.existsSync(publishDir)) {
|
||||
|
||||
@@ -22,12 +22,16 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.18",
|
||||
"@backstage/core": "^0.1.1-alpha.18",
|
||||
"@backstage/core-api": "^0.1.1-alpha.18",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.18",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.18",
|
||||
"@backstage/theme": "^0.1.1-alpha.18",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@types/react": "^16.9",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
@@ -43,6 +47,7 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"canvas": "^2.6.1",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TechDocsStorageApi } from './api';
|
||||
|
||||
const DOC_STORAGE_URL = 'https://example-storage.com';
|
||||
|
||||
const mockEntity = {
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'test-component',
|
||||
};
|
||||
|
||||
describe('TechDocsStorageApi', () => {
|
||||
it('should return correct base url based on defined storage', () => {
|
||||
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
|
||||
|
||||
expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual(
|
||||
`${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return base url with correct entity structure', () => {
|
||||
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
|
||||
|
||||
expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual(
|
||||
`${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
|
||||
import { ParsedEntityId } from './types';
|
||||
|
||||
export const techdocsStorageApiRef = createApiRef<TechDocsStorageApi>({
|
||||
id: 'plugin.techdocs.storageservice',
|
||||
description: 'Used to make requests towards the techdocs storage',
|
||||
});
|
||||
|
||||
export interface TechDocsStorage {
|
||||
getEntityDocs(entityId: ParsedEntityId, path: string): Promise<string>;
|
||||
getBaseUrl(
|
||||
oldBaseUrl: string,
|
||||
entityId: ParsedEntityId,
|
||||
path: string,
|
||||
): string;
|
||||
}
|
||||
|
||||
export class TechDocsStorageApi implements TechDocsStorage {
|
||||
public apiOrigin: string;
|
||||
|
||||
constructor({ apiOrigin }: { apiOrigin: string }) {
|
||||
this.apiOrigin = apiOrigin;
|
||||
}
|
||||
|
||||
async getEntityDocs(entityId: ParsedEntityId, path: string) {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const url = `${this.apiOrigin}/${kind}/${namespace ? namespace : 'default'}/${name}/${path}`;
|
||||
|
||||
const request = await fetch(
|
||||
`${url.endsWith('/') ? url : `${url}/`}index.html`,
|
||||
);
|
||||
|
||||
if (request.status === 404) {
|
||||
throw new Error('Page not found');
|
||||
}
|
||||
|
||||
return request.text();
|
||||
}
|
||||
|
||||
getBaseUrl(
|
||||
oldBaseUrl: string,
|
||||
entityId: ParsedEntityId,
|
||||
path: string,
|
||||
): string {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
return new URL(
|
||||
oldBaseUrl,
|
||||
`${this.apiOrigin}/${kind}/${namespace ? namespace : 'default'}/${name}/${path}`,
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,5 @@
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export * from './reader';
|
||||
export * from './api';
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import { TechDocsHome } from './reader/components/TechDocsHome';
|
||||
import { Reader } from './reader/components/Reader';
|
||||
import { TechDocsPage } from './reader/components/TechDocsPage';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '/docs',
|
||||
@@ -39,7 +39,7 @@ export const rootRouteRef = createRouteRef({
|
||||
});
|
||||
|
||||
export const rootDocsRouteRef = createRouteRef({
|
||||
path: '/docs/:componentId/*',
|
||||
path: '/docs/:entityId/*',
|
||||
title: 'Docs',
|
||||
});
|
||||
|
||||
@@ -47,6 +47,6 @@ export const plugin = createPlugin({
|
||||
id: 'techdocs',
|
||||
register({ router }) {
|
||||
router.addRoute(rootRouteRef, TechDocsHome);
|
||||
router.addRoute(rootDocsRouteRef, Reader);
|
||||
router.addRoute(rootDocsRouteRef, TechDocsPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useApi, configApiRef } from '@backstage/core';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { useShadowDom } from '..';
|
||||
import { useAsync } from 'react-use';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ParsedEntityId } from '../../types';
|
||||
|
||||
import transformer, {
|
||||
addBaseUrl,
|
||||
@@ -31,76 +31,35 @@ import transformer, {
|
||||
onCssReady,
|
||||
sanitizeDOM,
|
||||
} from '../transformers';
|
||||
import URLFormatter from '../urlFormatter';
|
||||
import { TechDocsNotFound } from './TechDocsNotFound';
|
||||
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
|
||||
const useFetch = (url: string): AsyncState<string | Error> => {
|
||||
const state = useAsync(async () => {
|
||||
const request = await fetch(url);
|
||||
if (request.status === 404) {
|
||||
return [request.url, new Error('Page not found')];
|
||||
}
|
||||
const response = await request.text();
|
||||
return [request.url, response];
|
||||
}, [url]);
|
||||
|
||||
const [fetchedUrl, fetchedValue] = state.value ?? [];
|
||||
|
||||
if (url !== fetchedUrl) {
|
||||
// Fixes a race condition between two pages
|
||||
return { loading: true };
|
||||
}
|
||||
|
||||
return Object.assign(state, fetchedValue ? { value: fetchedValue } : {});
|
||||
type Props = {
|
||||
entityId: ParsedEntityId;
|
||||
};
|
||||
|
||||
const useEnforcedTrailingSlash = (): void => {
|
||||
React.useEffect(() => {
|
||||
const actualUrl = window.location.href;
|
||||
const expectedUrl = new URLFormatter(window.location.href).formatBaseURL();
|
||||
export const Reader = ({ entityId }: Props) => {
|
||||
const { kind, namespace, name } = entityId;
|
||||
const { '*': path } = useParams();
|
||||
|
||||
if (actualUrl !== expectedUrl) {
|
||||
window.history.replaceState({}, document.title, expectedUrl);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const Reader = () => {
|
||||
useEnforcedTrailingSlash();
|
||||
|
||||
const docStorageUrl =
|
||||
useApi(configApiRef).getOptionalString('techdocs.storageUrl') ??
|
||||
'https://techdocs-mock-sites.storage.googleapis.com';
|
||||
|
||||
const location = useLocation();
|
||||
const { componentId, '*': path } = useParams();
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
const [shadowDomRef, shadowRoot] = useShadowDom();
|
||||
const navigate = useNavigate();
|
||||
const normalizedUrl = new URLFormatter(
|
||||
`${docStorageUrl}${location.pathname.replace('/docs', '')}`,
|
||||
).formatBaseURL();
|
||||
const state = useFetch(`${normalizedUrl}index.html`);
|
||||
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
return techdocsStorageApi.getEntityDocs({ kind, namespace, name }, path);
|
||||
}, [techdocsStorageApi, kind, namespace, name, path]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shadowRoot) {
|
||||
return; // Shadow DOM isn't ready
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
return; // Page isn't ready
|
||||
}
|
||||
|
||||
if (state.value instanceof Error) {
|
||||
return; // Docs not found
|
||||
if (!shadowRoot || loading || error) {
|
||||
return; // Shadow DOM isn't ready / It's not ready / Docs was not found
|
||||
}
|
||||
|
||||
// Pre-render
|
||||
const transformedElement = transformer(state.value as string, [
|
||||
const transformedElement = transformer(value as string, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
docStorageUrl,
|
||||
componentId,
|
||||
techdocsStorageApi,
|
||||
entityId: entityId,
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
@@ -145,7 +104,7 @@ export const Reader = () => {
|
||||
},
|
||||
}),
|
||||
onCssReady({
|
||||
docStorageUrl,
|
||||
docStorageUrl: techdocsStorageApi.apiOrigin,
|
||||
onLoading: (dom: Element) => {
|
||||
(dom as HTMLElement).style.setProperty('opacity', '0');
|
||||
},
|
||||
@@ -154,15 +113,23 @@ export const Reader = () => {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}, [componentId, path, shadowRoot, state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
name,
|
||||
path,
|
||||
shadowRoot,
|
||||
value,
|
||||
error,
|
||||
loading,
|
||||
namespace,
|
||||
kind,
|
||||
entityId,
|
||||
navigate,
|
||||
techdocsStorageApi,
|
||||
]);
|
||||
|
||||
if (state.value instanceof Error) {
|
||||
if (error) {
|
||||
return <TechDocsNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TechDocsPageWrapper title={componentId} subtitle={componentId}>
|
||||
<div ref={shadowDomRef} />
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
return <div ref={shadowDomRef} />;
|
||||
};
|
||||
|
||||
@@ -17,20 +17,34 @@ import { TechDocsHome } from './TechDocsHome';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiRegistry, ApiProvider } from '@backstage/core-api';
|
||||
|
||||
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
describe('TechDocs Home', () => {
|
||||
it('should render a TechDocs home page', () => {
|
||||
const { getByTestId, queryByText } = render(
|
||||
wrapInTestApp(<TechDocsHome />),
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () => Promise.resolve([] as Entity[]),
|
||||
};
|
||||
|
||||
const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]);
|
||||
|
||||
it('should render a TechDocs home page', async () => {
|
||||
const { findByTestId, findByText } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsHome />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Header
|
||||
expect(queryByText('Documentation')).toBeInTheDocument();
|
||||
expect(await findByText('Documentation')).toBeInTheDocument();
|
||||
expect(
|
||||
queryByText(/Documentation available in Backstage/i),
|
||||
await findByText(/Documentation available in Backstage/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Explore Content
|
||||
expect(getByTestId('docs-explore')).toBeDefined();
|
||||
expect(await findByTestId('docs-explore')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,59 +15,71 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { ItemCard } from '@backstage/core';
|
||||
import { ItemCard, Progress, useApi } from '@backstage/core';
|
||||
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
|
||||
type DocumentationSite = {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: Array<string>;
|
||||
path: string;
|
||||
btnLabel: string;
|
||||
};
|
||||
|
||||
const documentationSites: Array<DocumentationSite> = [
|
||||
{
|
||||
title: 'MkDocs',
|
||||
description:
|
||||
"MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. ",
|
||||
tags: ['Developer Tool'],
|
||||
path: '/docs/mkdocs',
|
||||
btnLabel: 'Read Docs',
|
||||
},
|
||||
{
|
||||
title: 'Backstage Docs',
|
||||
description: 'Main documentation for Backstage features and platform APIs.',
|
||||
tags: ['Service'],
|
||||
path: '/docs/backstage',
|
||||
btnLabel: 'Read Docs',
|
||||
},
|
||||
];
|
||||
export const TechDocsHome = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
const entities = await catalogApi.getEntities();
|
||||
return entities.filter(entity => {
|
||||
return !!entity.metadata.annotations?.['backstage.io/techdocs-ref'];
|
||||
});
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<TechDocsPageWrapper
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
>
|
||||
<Grid container data-testid="docs-explore">
|
||||
{documentationSites.map((site: DocumentationSite, index: number) => (
|
||||
<Grid key={index} item xs={12} sm={6} md={3}>
|
||||
<ItemCard
|
||||
onClick={() => navigate(site.path)}
|
||||
tags={site.tags}
|
||||
title={site.title}
|
||||
label={site.btnLabel}
|
||||
description={site.description}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Progress />
|
||||
</TechDocsPageWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TechDocsPageWrapper
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
>
|
||||
<p>{error.message}</p>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TechDocsPageWrapper
|
||||
title="Documentation"
|
||||
subtitle="Documentation available in Backstage"
|
||||
>
|
||||
<Grid container data-testid="docs-explore">
|
||||
{value?.length
|
||||
? value.map((entity, index: number) => (
|
||||
<Grid key={index} item xs={12} sm={6} md={3}>
|
||||
<ItemCard
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/docs/${entity.kind}:${
|
||||
entity.metadata.namespace ?? ''
|
||||
}:${entity.metadata.name}`,
|
||||
)
|
||||
}
|
||||
title={entity.metadata.name}
|
||||
label="Read Docs"
|
||||
description={entity.metadata.description}
|
||||
/>
|
||||
</Grid>
|
||||
))
|
||||
: null}
|
||||
</Grid>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
import { Reader } from './Reader';
|
||||
|
||||
export const TechDocsPage = () => {
|
||||
const { entityId } = useParams();
|
||||
|
||||
const [kind, namespace, name] = entityId.split(':');
|
||||
|
||||
return (
|
||||
<TechDocsPageWrapper title={name} subtitle={name}>
|
||||
<Reader
|
||||
entityId={{
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
}}
|
||||
/>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
import { TechDocsHome } from './TechDocsHome';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
@@ -24,7 +23,7 @@ describe('TechDocs Page Wrapper', () => {
|
||||
const { queryByText } = render(
|
||||
wrapInTestApp(
|
||||
<TechDocsPageWrapper title="test-title" subtitle="test-subtitle">
|
||||
<TechDocsHome />
|
||||
Test
|
||||
</TechDocsPageWrapper>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './Reader';
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export * from './hooks';
|
||||
export * from './components';
|
||||
|
||||
@@ -14,124 +14,65 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createTestShadowDom, FIXTURES, getSample } from '../../test-utils';
|
||||
import { createTestShadowDom } from '../../test-utils';
|
||||
import { addBaseUrl } from '../transformers';
|
||||
import { TechDocsStorage } from '../../api';
|
||||
|
||||
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
|
||||
|
||||
const techdocsStorageApi: TechDocsStorage = {
|
||||
getBaseUrl: jest.fn(() => DOC_STORAGE_URL),
|
||||
getEntityDocs: () => new Promise(resolve => resolve('yes!')),
|
||||
};
|
||||
|
||||
const fixture = `
|
||||
<html>
|
||||
<head>
|
||||
<link type="stylesheet" href="astyle.css" />
|
||||
</head>
|
||||
<body>
|
||||
<img src="test.jpg" />
|
||||
<script type="javascript" src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const mockEntityId = {
|
||||
kind: '',
|
||||
namespace: '',
|
||||
name: '',
|
||||
};
|
||||
|
||||
describe('addBaseUrl', () => {
|
||||
it('contains relative paths', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'img/win-py-install.png',
|
||||
'img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('contains transformed absolute paths', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
createTestShadowDom(fixture, {
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
docStorageUrl: DOC_STORAGE_URL,
|
||||
componentId: 'example-docs',
|
||||
techdocsStorageApi,
|
||||
entityId: mockEntityId,
|
||||
path: '',
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png',
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes path option without slash', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`
|
||||
<img src="../img/win-py-install.png" />
|
||||
<img src="../img/initial-layout.png" />
|
||||
<link href="https://www.mkdocs.org/" />
|
||||
<link href="../assets/images/favicon.png" />
|
||||
<script src="https://www.google-analytics.com/analytics.js"></script>
|
||||
<script src="../assets/javascripts/vendor.d710d30a.min.js"></script>
|
||||
`,
|
||||
{
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
docStorageUrl: DOC_STORAGE_URL,
|
||||
componentId: 'example-docs',
|
||||
path: 'examplepath',
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
},
|
||||
expect(techdocsStorageApi.getBaseUrl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'test.jpg',
|
||||
mockEntityId,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png',
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes path option with slash', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`
|
||||
<img src="../img/win-py-install.png" />
|
||||
<img src="../img/initial-layout.png" />
|
||||
<link href="https://www.mkdocs.org/" />
|
||||
<link href="../assets/images/favicon.png" />
|
||||
<script src="https://www.google-analytics.com/analytics.js"></script>
|
||||
<script src="../assets/javascripts/vendor.d710d30a.min.js"></script>
|
||||
`,
|
||||
{
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
docStorageUrl: DOC_STORAGE_URL,
|
||||
componentId: 'example-docs',
|
||||
path: 'examplepath/',
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
},
|
||||
expect(techdocsStorageApi.getBaseUrl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'script.js',
|
||||
mockEntityId,
|
||||
'',
|
||||
);
|
||||
expect(techdocsStorageApi.getBaseUrl).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'astyle.css',
|
||||
mockEntityId,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(getSample(shadowDom, 'img', 'src')).toEqual([
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png',
|
||||
'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'link', 'href')).toEqual([
|
||||
'https://www.mkdocs.org/',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png',
|
||||
]);
|
||||
expect(getSample(shadowDom, 'script', 'src')).toEqual([
|
||||
'https://www.google-analytics.com/analytics.js',
|
||||
'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,18 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import URLFormatter from '../urlFormatter';
|
||||
import type { Transformer } from './index';
|
||||
import { TechDocsStorage } from '../../api';
|
||||
import { ParsedEntityId } from '../../types';
|
||||
|
||||
type AddBaseUrlOptions = {
|
||||
docStorageUrl: string;
|
||||
componentId: string;
|
||||
techdocsStorageApi: TechDocsStorage;
|
||||
entityId: ParsedEntityId;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const addBaseUrl = ({
|
||||
docStorageUrl,
|
||||
componentId,
|
||||
techdocsStorageApi,
|
||||
entityId,
|
||||
path,
|
||||
}: AddBaseUrlOptions): Transformer => {
|
||||
return dom => {
|
||||
@@ -36,15 +37,11 @@ export const addBaseUrl = ({
|
||||
Array.from(list)
|
||||
.filter(elem => !!elem.getAttribute(attributeName))
|
||||
.forEach((elem: T) => {
|
||||
const urlFormatter = new URLFormatter(
|
||||
path.length < 1 || path.endsWith('/')
|
||||
? `${docStorageUrl}/${componentId}/${path}`
|
||||
: `${docStorageUrl}/${componentId}/${path}/`,
|
||||
);
|
||||
|
||||
const elemAttribute = elem.getAttribute(attributeName);
|
||||
if (!elemAttribute) return;
|
||||
elem.setAttribute(
|
||||
attributeName,
|
||||
urlFormatter.formatURL(elem.getAttribute(attributeName)!),
|
||||
techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,19 +15,23 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
FIXTURES,
|
||||
createTestShadowDom,
|
||||
mockStylesheetEventListener,
|
||||
executeStylesheetEventListeners,
|
||||
clearStylesheetEventListeners,
|
||||
} from '../../test-utils';
|
||||
import { addBaseUrl, onCssReady } from '../transformers';
|
||||
import { onCssReady } from '../transformers';
|
||||
|
||||
const docStorageUrl: string =
|
||||
'https://techdocs-mock-sites.storage.googleapis.com';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const fixture = `
|
||||
<link rel="stylesheet" href="${docStorageUrl}/test.css" />
|
||||
<link rel="stylesheet" href="http://example.com/test.css" />
|
||||
`;
|
||||
|
||||
describe('onCssReady', () => {
|
||||
beforeEach(() => {
|
||||
mockStylesheetEventListener(100);
|
||||
@@ -37,19 +41,13 @@ describe('onCssReady', () => {
|
||||
clearStylesheetEventListeners();
|
||||
});
|
||||
|
||||
it('does not call onLoading and onLoaded without the addBaseUrl transformer', () => {
|
||||
it('does not call onLoading and onLoaded without the onCssReady transformer', () => {
|
||||
const onLoading = jest.fn();
|
||||
const onLoaded = jest.fn();
|
||||
|
||||
createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
createTestShadowDom(fixture, {
|
||||
preTransformers: [],
|
||||
postTransformers: [
|
||||
onCssReady({
|
||||
docStorageUrl,
|
||||
onLoading,
|
||||
onLoaded,
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(onLoading).not.toHaveBeenCalled();
|
||||
@@ -61,14 +59,9 @@ describe('onCssReady', () => {
|
||||
const onLoading = jest.fn();
|
||||
const onLoaded = jest.fn();
|
||||
|
||||
createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
createTestShadowDom(fixture, {
|
||||
preTransformers: [],
|
||||
postTransformers: [
|
||||
addBaseUrl({
|
||||
docStorageUrl,
|
||||
componentId: 'mkdocs',
|
||||
path: '',
|
||||
}),
|
||||
onCssReady({
|
||||
docStorageUrl,
|
||||
onLoading,
|
||||
|
||||
@@ -50,9 +50,9 @@ describe('rewriteDocLinks', () => {
|
||||
|
||||
expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([
|
||||
'http://example.org/',
|
||||
'http://localhost/example/',
|
||||
'http://localhost/example-docs/',
|
||||
'http://localhost/example-docs/example-page/',
|
||||
'http://localhost/example',
|
||||
'http://localhost/example-docs',
|
||||
'http://localhost/example-docs/example-page',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import URLFormatter from '../urlFormatter';
|
||||
import type { Transformer } from './index';
|
||||
|
||||
export const rewriteDocLinks = (): Transformer => {
|
||||
@@ -26,11 +25,17 @@ export const rewriteDocLinks = (): Transformer => {
|
||||
Array.from(list)
|
||||
.filter(elem => elem.hasAttribute(attributeName))
|
||||
.forEach((elem: T) => {
|
||||
const urlFormatter = new URLFormatter(window.location.href);
|
||||
elem.setAttribute(
|
||||
attributeName,
|
||||
urlFormatter.formatURL(elem.getAttribute(attributeName)!),
|
||||
);
|
||||
const elemAttribute = elem.getAttribute(attributeName);
|
||||
if (elemAttribute) {
|
||||
const normalizedWindowLocation = window.location.href.endsWith('/')
|
||||
? window.location.href
|
||||
: `${window.location.href}/`;
|
||||
|
||||
elem.setAttribute(
|
||||
attributeName,
|
||||
new URL(elemAttribute, normalizedWindowLocation).toString(),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import URLFormatter from './urlFormatter';
|
||||
|
||||
describe('URLFormatter', () => {
|
||||
describe('formatURL', () => {
|
||||
it('should not change an absolute url', () => {
|
||||
const formatter = new URLFormatter('https://www.google.com/');
|
||||
expect(formatter.formatURL('https://www.mkdocs.org/')).toEqual(
|
||||
'https://www.mkdocs.org/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert a relative url to an absolute url', () => {
|
||||
const formatter = new URLFormatter(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
);
|
||||
expect(formatter.formatURL('../../support/installing/')).toEqual(
|
||||
'https://www.mkdocs.org/support/installing/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a trailing slash', () => {
|
||||
const formatter = new URLFormatter(
|
||||
'https://www.mkdocs.org/user-guide/getting-started',
|
||||
);
|
||||
expect(formatter.formatURL('./getting-started')).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add a trailing slash', () => {
|
||||
const formatter = new URLFormatter(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
);
|
||||
expect(formatter.formatURL('.')).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add multiple hashes', () => {
|
||||
const formatter = new URLFormatter(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/#hash1',
|
||||
);
|
||||
expect(formatter.formatURL('./#hash2')).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/#hash2',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBaseURL', () => {
|
||||
it('should keep query params in URL', () => {
|
||||
const formatter = new URLFormatter(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world',
|
||||
);
|
||||
expect(formatter.formatBaseURL()).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep hash in URL', () => {
|
||||
const formatter = new URLFormatter(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/#hash',
|
||||
);
|
||||
expect(formatter.formatBaseURL()).toEqual(
|
||||
'https://www.mkdocs.org/user-guide/getting-started/#hash',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export default class URLFormatter {
|
||||
constructor(public baseURL: string) {}
|
||||
|
||||
formatBaseURL(): string {
|
||||
return this.normalizeURL(this.baseURL);
|
||||
}
|
||||
|
||||
formatURL(pathname: string): string {
|
||||
return this.normalizeURL(new URL(pathname, this.baseURL).toString());
|
||||
}
|
||||
|
||||
private normalizeURL(urlString: string): string {
|
||||
const url = new URL(urlString);
|
||||
const filename: string = url.pathname.split('/').pop() ?? url.pathname;
|
||||
const isDir: boolean = filename.includes('.') === false;
|
||||
|
||||
if (isDir) {
|
||||
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type ParsedEntityId = {
|
||||
kind: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -7515,6 +7515,15 @@ caniuse-lite@^1.0.30001109:
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001113.tgz#22016ab55b5a8b04fa00ca342d9ee1b98df48065"
|
||||
integrity sha512-qMvjHiKH21zzM/VDZr6oosO6Ri3U0V2tC015jRXjOecwQCJtsU5zklTNTk31jQbIOP8gha0h1ccM/g0ECP+4BA==
|
||||
|
||||
canvas@^2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.npmjs.org/canvas/-/canvas-2.6.1.tgz#0d087dd4d60f5a5a9efa202757270abea8bef89e"
|
||||
integrity sha512-S98rKsPcuhfTcYbtF53UIJhcbgIAK533d1kJKMwsMwAIFgfd58MOyxRud3kktlzWiEkFliaJtvyZCBtud/XVEA==
|
||||
dependencies:
|
||||
nan "^2.14.0"
|
||||
node-pre-gyp "^0.11.0"
|
||||
simple-get "^3.0.3"
|
||||
|
||||
canvg@1.5.3:
|
||||
version "1.5.3"
|
||||
resolved "https://registry.npmjs.org/canvg/-/canvg-1.5.3.tgz#aad17915f33368bf8eb80b25d129e3ae922ddc5f"
|
||||
@@ -9061,6 +9070,13 @@ decompress-response@^3.2.0, decompress-response@^3.3.0:
|
||||
dependencies:
|
||||
mimic-response "^1.0.0"
|
||||
|
||||
decompress-response@^4.2.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986"
|
||||
integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==
|
||||
dependencies:
|
||||
mimic-response "^2.0.0"
|
||||
|
||||
decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1"
|
||||
@@ -15694,6 +15710,11 @@ mimic-response@^1.0.0, mimic-response@^1.0.1:
|
||||
resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
|
||||
integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
|
||||
|
||||
mimic-response@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
|
||||
integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
|
||||
|
||||
min-document@^2.19.0:
|
||||
version "2.19.0"
|
||||
resolved "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
|
||||
@@ -20346,6 +20367,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
|
||||
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
|
||||
integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
|
||||
|
||||
simple-concat@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
|
||||
integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
|
||||
|
||||
simple-get@^3.0.3:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3"
|
||||
integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==
|
||||
dependencies:
|
||||
decompress-response "^4.2.0"
|
||||
once "^1.3.1"
|
||||
simple-concat "^1.0.0"
|
||||
|
||||
simple-swizzle@^0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
|
||||
|
||||
Reference in New Issue
Block a user