From 13908d69c2ddc24b852319c3ff8e09d06cb5ff8f Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Thu, 20 Aug 2020 14:06:09 +0200 Subject: [PATCH] TechDocs: Access entity techdocs from service catalog (#1835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Emma Indal --- packages/app/src/App.test.tsx | 3 + packages/app/src/apis.ts | 19 ++- .../core-api/src/apis/definitions/auth.ts | 4 +- .../implementations/auth/auth0/Auth0Auth.ts | 6 +- .../src/layout/HeaderTabs/HeaderTabs.test.tsx | 50 ++++++ packages/core/src/layout/HeaderTabs/index.tsx | 17 +- .../core/src/layout/ItemCard/ItemCard.tsx | 2 +- .../src/layout/SignInPage/auth0Provider.tsx | 3 +- .../src/providers/auth0/strategy.ts | 33 ++-- .../auth-backend/src/providers/factories.ts | 2 +- plugins/catalog/package.json | 1 + .../components/CatalogTable/CatalogTable.tsx | 1 + .../src/components/EntityPage/EntityPage.tsx | 42 +++-- .../EntityPageDocs/EntityDocsPage.tsx | 34 ++++ plugins/catalog/src/plugin.ts | 3 +- plugins/catalog/src/routes.ts | 7 +- .../RegisterComponentResultDialog.tsx | 1 + plugins/techdocs-backend/.eslintrc.js | 1 + .../documented-component/docs/sub-page.md | 1 + .../examples/documented-component/mkdocs.yml | 1 + .../techdocs-backend/src/service/router.ts | 45 ++++-- .../src/techdocs/stages/generate/helpers.ts | 10 ++ .../src/techdocs/stages/generate/techdocs.ts | 10 +- .../src/techdocs/stages/publish/local.test.ts | 9 +- .../src/techdocs/stages/publish/local.ts | 7 +- plugins/techdocs/package.json | 5 + plugins/techdocs/src/api.test.ts | 42 +++++ plugins/techdocs/src/api.ts | 70 +++++++++ plugins/techdocs/src/index.ts | 2 + plugins/techdocs/src/plugin.ts | 6 +- .../techdocs/src/reader/components/Reader.tsx | 103 +++++------- .../reader/components/TechDocsHome.test.tsx | 26 +++- .../src/reader/components/TechDocsHome.tsx | 96 +++++++----- .../src/reader/components/TechDocsPage.tsx | 39 +++++ .../components/TechDocsPageWrapper.test.tsx | 3 +- .../techdocs/src/reader/components/index.ts | 17 ++ plugins/techdocs/src/reader/index.tsx | 1 + .../reader/transformers/addBaseUrl.test.ts | 147 ++++++------------ .../src/reader/transformers/addBaseUrl.ts | 21 ++- .../reader/transformers/onCssReady.test.ts | 27 ++-- .../transformers/rewriteDocLinks.test.ts | 6 +- .../reader/transformers/rewriteDocLinks.ts | 17 +- .../techdocs/src/reader/urlFormatter.test.ts | 84 ---------- plugins/techdocs/src/reader/urlFormatter.ts | 39 ----- plugins/techdocs/src/types.ts | 21 +++ yarn.lock | 35 +++++ 46 files changed, 672 insertions(+), 447 deletions(-) create mode 100644 packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx create mode 100644 plugins/catalog/src/components/EntityPageDocs/EntityDocsPage.tsx create mode 100644 plugins/techdocs-backend/examples/documented-component/docs/sub-page.md create mode 100644 plugins/techdocs/src/api.test.ts create mode 100644 plugins/techdocs/src/api.ts create mode 100644 plugins/techdocs/src/reader/components/TechDocsPage.tsx create mode 100644 plugins/techdocs/src/reader/components/index.ts delete mode 100644 plugins/techdocs/src/reader/urlFormatter.test.ts delete mode 100644 plugins/techdocs/src/reader/urlFormatter.ts create mode 100644 plugins/techdocs/src/types.ts diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 26c3466ba1..ec291a752b 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -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', }, diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 3bc0c82b31..a8e5485727 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -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(); }; diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 05c4ed48af..a1a408fece 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -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({ +export const auth0AuthApiRef = createApiRef< + OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi +>({ id: 'core.auth.auth0', description: 'Provides authentication towards Auth0 APIs', }); diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts index 3d8a42a4cc..99529b8d2a 100644 --- a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -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 = diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx new file mode 100644 index 0000000000..a007bf9113 --- /dev/null +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.test.tsx @@ -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('', () => { + it('should render tabs', () => { + const rendered = render(wrapInTestApp()); + + expect(rendered.getByText('Overview')).toBeInTheDocument(); + expect(rendered.getByText('Docs')).toBeInTheDocument(); + }); + + it('should render correct selected tab', () => { + const rendered = render(wrapInTestApp()); + + expect(rendered.getByText('Docs').parentElement).toHaveAttribute( + 'aria-selected', + 'false', + ); + + rendered.getByText('Docs').click(); + + expect(rendered.getByText('Docs').parentElement).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); +}); diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 838b126569..b509cae14f 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -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(0); + selectedIndex?: number; +}> = ({ tabs, onChange, selectedIndex }) => { + const [selectedTab, setSelectedTab] = useState(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 (
({ })); type ItemCardProps = { - description: string; + description?: string; tags?: string[]; title: string; type?: string; diff --git a/packages/core/src/layout/SignInPage/auth0Provider.tsx b/packages/core/src/layout/SignInPage/auth0Provider.tsx index cfc44ef7d3..ae4e5f8b82 100644 --- a/packages/core/src/layout/SignInPage/auth0Provider.tsx +++ b/packages/core/src/layout/SignInPage/auth0Provider.tsx @@ -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(); }, diff --git a/plugins/auth-backend/src/providers/auth0/strategy.ts b/plugins/auth-backend/src/providers/auth0/strategy.ts index 4dd7440448..6ac06ec4e9 100644 --- a/plugins/auth-backend/src/providers/auth0/strategy.ts +++ b/plugins/auth-backend/src/providers/auth0/strategy.ts @@ -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); + } } diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 8443a7928d..bae5bd5d43 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -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, diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 22085d5184..44b3a7b7ee 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 00dacdc0fe..eed8998fb7 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -45,6 +45,7 @@ const columns: TableColumn[] = [ .filter(Boolean) .join(':'), kind: entity.kind, + selectedTabId: 'overview', })} > {entity.metadata.name} diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx index 8a375b8d6e..a36ac32a07 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -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) => , }, { @@ -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) => , + }, ]; 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 ( @@ -186,17 +203,18 @@ export const EntityPage: FC<{}> = () => { {entity && ( <> { - setSelectedTabId(tabs[idx].id); + navigate( + `/catalog/${kind}/${optionalNamespaceAndName}/${tabs[idx].id}`, + ); }} + selectedIndex={tabs.findIndex(tab => tab.id === selectedTabId)} /> - - {selectedTab && selectedTab.content - ? selectedTab.content(entity) - : null} - + {selectedTab && selectedTab.content + ? selectedTab.content(entity) + : null} { + return ( + + + + ); +}; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 8f4ac80f48..483e1f8464 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -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); }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index 69c4e651b4..a8ff8df685 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -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', }); diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index 68b06d1751..15d0b9ced6 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -63,6 +63,7 @@ export const RegisterComponentResultDialog: FC = ({ .filter(Boolean) .join(':'), kind: entity.kind, + selectedTabId: 'overview', }); return ( diff --git a/plugins/techdocs-backend/.eslintrc.js b/plugins/techdocs-backend/.eslintrc.js index 16a033dbc6..595853b48b 100644 --- a/plugins/techdocs-backend/.eslintrc.js +++ b/plugins/techdocs-backend/.eslintrc.js @@ -1,3 +1,4 @@ module.exports = { extends: [require.resolve('@backstage/cli/config/eslint.backend')], + ignorePatterns: ['static/**'], }; diff --git a/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md b/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md new file mode 100644 index 0000000000..bbd98558e0 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md @@ -0,0 +1 @@ +# Another page in our documentation diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml index 6a3f85d020..3b48e8edff 100644 --- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -2,6 +2,7 @@ site_name: 'example-docs' nav: - Home: index.md + - SubPage: sub-page.md plugins: - techdocs-core diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 874ab7c89c..e11e3bb57f 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -48,6 +48,21 @@ export async function createRouter({ }: RouterOptions): Promise { 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; diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts index 7dc4403977..88667f9903 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/helpers.ts @@ -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, diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts index d6650fb744..32b811af78 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts @@ -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 { - 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 }; } } diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts index b5a96e4feb..1ed8be3898 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts @@ -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); diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts index 227ba445ae..73a7d5a758 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts @@ -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)) { diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index a1b7ddb589..03f1d6053b 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -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": [ diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts new file mode 100644 index 0000000000..0ab05d5ecd --- /dev/null +++ b/plugins/techdocs/src/api.test.ts @@ -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/`, + ); + }); +}); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts new file mode 100644 index 0000000000..951ad5e16c --- /dev/null +++ b/plugins/techdocs/src/api.ts @@ -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({ + id: 'plugin.techdocs.storageservice', + description: 'Used to make requests towards the techdocs storage', +}); + +export interface TechDocsStorage { + getEntityDocs(entityId: ParsedEntityId, path: string): Promise; + 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(); + } +} diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 3a0a0fe2d3..2a412d1b20 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -15,3 +15,5 @@ */ export { plugin } from './plugin'; +export * from './reader'; +export * from './api'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index a96f7e5502..9b09544427 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -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); }, }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 8e11df428e..8e1e36595a 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -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 => { - 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 ; } - return ( - -
- - ); + return
; }; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index dd31e6a318..7754f227fc 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -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(), + const catalogApi: Partial = { + getEntities: () => Promise.resolve([] as Entity[]), + }; + + const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); + + it('should render a TechDocs home page', async () => { + const { findByTestId, findByText } = render( + wrapInTestApp( + + + , + ), ); // 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(); }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index f8f1e89b62..17b9fd36f7 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -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; - path: string; - btnLabel: string; -}; - -const documentationSites: Array = [ - { - 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 ( - - {documentationSites.map((site: DocumentationSite, index: number) => ( - - navigate(site.path)} - tags={site.tags} - title={site.title} - label={site.btnLabel} - description={site.description} - /> - - ))} - + - + ); + } + + if (error) { + return ( + +

{error.message}

+
+ ); + } + + return ( + + + {value?.length + ? value.map((entity, index: number) => ( + + + navigate( + `/docs/${entity.kind}:${ + entity.metadata.namespace ?? '' + }:${entity.metadata.name}`, + ) + } + title={entity.metadata.name} + label="Read Docs" + description={entity.metadata.description} + /> + + )) + : null} + + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx new file mode 100644 index 0000000000..1328d13ee9 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -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 ( + + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx index d4cc49c919..5265492fb3 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx @@ -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( - + Test , ), ); diff --git a/plugins/techdocs/src/reader/components/index.ts b/plugins/techdocs/src/reader/components/index.ts new file mode 100644 index 0000000000..b14a6c5b84 --- /dev/null +++ b/plugins/techdocs/src/reader/components/index.ts @@ -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'; diff --git a/plugins/techdocs/src/reader/index.tsx b/plugins/techdocs/src/reader/index.tsx index 72b51bd0dc..a3ca8f394b 100644 --- a/plugins/techdocs/src/reader/index.tsx +++ b/plugins/techdocs/src/reader/index.tsx @@ -15,3 +15,4 @@ */ export * from './hooks'; +export * from './components'; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index ce903c5d2f..728c39826d 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -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 = ` + + + + + + + + + +`; + +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( - ` - - - - - - - `, - { - 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( - ` - - - - - - - `, - { - 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', - ]); }); }); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 42ed531aea..7346ca7ce2 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -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), ); }); }; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index 13965bcf4b..c83a94f35d 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -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 = ` + + +`; + 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, diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts index 20ec3ccb64..06da8a447c 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -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', ]); }); }); diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 4bf4ab3898..856bb40f0f 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -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(), + ); + } }); }; diff --git a/plugins/techdocs/src/reader/urlFormatter.test.ts b/plugins/techdocs/src/reader/urlFormatter.test.ts deleted file mode 100644 index 2659f9f4dc..0000000000 --- a/plugins/techdocs/src/reader/urlFormatter.test.ts +++ /dev/null @@ -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', - ); - }); - }); -}); diff --git a/plugins/techdocs/src/reader/urlFormatter.ts b/plugins/techdocs/src/reader/urlFormatter.ts deleted file mode 100644 index 166f0d9963..0000000000 --- a/plugins/techdocs/src/reader/urlFormatter.ts +++ /dev/null @@ -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(); - } -} diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts new file mode 100644 index 0000000000..ec907cc6fa --- /dev/null +++ b/plugins/techdocs/src/types.ts @@ -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; +}; diff --git a/yarn.lock b/yarn.lock index e119146c68..63a429b79d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"