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:
Sebastian Qvarfordt
2020-08-20 14:06:09 +02:00
committed by GitHub
parent 774a000729
commit 13908d69c2
46 changed files with 672 additions and 447 deletions
+3
View File
@@ -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',
},
+17 -2
View File
@@ -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',
);
});
});
+13 -4
View File
@@ -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,
+1
View File
@@ -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>
);
};
+2 -1
View File
@@ -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);
},
});
+6 -1
View File
@@ -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',
});
@@ -63,6 +63,7 @@ export const RegisterComponentResultDialog: FC<Props> = ({
.filter(Boolean)
.join(':'),
kind: entity.kind,
selectedTabId: 'overview',
});
return (
+1
View File
@@ -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
+33 -12
View File
@@ -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)) {
+5
View File
@@ -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": [
+42
View File
@@ -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/`,
);
});
});
+70
View File
@@ -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();
}
}
+2
View File
@@ -15,3 +15,5 @@
*/
export { plugin } from './plugin';
export * from './reader';
export * from './api';
+3 -3
View File
@@ -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';
+1
View File
@@ -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();
}
}
+21
View File
@@ -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;
};
+35
View File
@@ -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"