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
+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;
};