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
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user