From 984b5acccc48bcc5344822d25fac4450dfead90e Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 12:18:52 +0200 Subject: [PATCH 1/5] feat(mkdocs-reader): initial navigation inside docs --- plugins/techdocs/package.json | 1 + plugins/techdocs/src/config.js | 16 +++++ plugins/techdocs/src/plugin.ts | 2 +- .../techdocs/src/reader/components/Reader.tsx | 62 ++++++++----------- .../techdocs/src/transformers/addBaseUrl.ts | 52 ++++++++++++++++ plugins/techdocs/src/transformers/index.ts | 28 +++++++++ .../src/transformers/rewriteDocLinks.ts | 38 ++++++++++++ 7 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 plugins/techdocs/src/config.js create mode 100644 plugins/techdocs/src/transformers/addBaseUrl.ts create mode 100644 plugins/techdocs/src/transformers/index.ts create mode 100644 plugins/techdocs/src/transformers/rewriteDocLinks.ts diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 9ef290fa40..b84b98c745 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,6 +29,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js new file mode 100644 index 0000000000..2baf8b725c --- /dev/null +++ b/plugins/techdocs/src/config.js @@ -0,0 +1,16 @@ +/* + * 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 const baseUrl = 'https://techdocs-mock-sites.storage.googleapis.com'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 23bfa3b714..fec2b1cf74 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -33,7 +33,7 @@ import { createPlugin, createRouteRef } from '@backstage/core'; import { Reader } from './reader/components/Reader'; export const rootRouteRef = createRouteRef({ - path: '/docs', + path: '/docs/:componentId/*', title: 'Docs', }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 5517c0c715..36816fcc9a 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,6 +17,10 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; +import transformer, { addBaseUrl, rewriteDocLinks } from '../../transformers'; +import { baseUrl } from '../../config'; +import { Link } from '@backstage/core'; +import { useLocation, useParams } from 'react-router-dom'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -28,54 +32,42 @@ const useFetch = (url: string) => { return state; }; -const addBaseUrl = (htmlString: string, baseUrl: string): string => { - const domParser = new DOMParser().parseFromString(htmlString, 'text/html'); - - const updateDom = ( - list: Array, - attributeName: string, - ): void => { - Array.from(list).forEach((elem: T) => { - const newUrl = new URL( - elem.getAttribute(attributeName)!, - baseUrl, - ).toString(); - elem.setAttribute(attributeName, newUrl); - }); - }; - - updateDom(Array.from(domParser.images), 'src'); - updateDom( - Array.from(domParser.links), - 'href', - ); - updateDom( - Array.from(domParser.querySelectorAll('link')), - 'href', - ); - - return domParser.body.parentElement?.outerHTML || htmlString; +const normalizeUrl = (path: string) => { + return path.replace(/\/\/index.html$/, '/index.html'); }; export const Reader = () => { + const location = useLocation(); + const { componentId, '*': path } = useParams(); const shadowDomRef = useShadowDom(); const state = useFetch( - 'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/index.html', + normalizeUrl( + `${baseUrl}${location.pathname.replace('/docs', '')}/index.html`, + ), ); - + // https://techdocs-mock-sites.storage.googleapis.com/mkdocs/user-guide/configuration/custom-themes/index.html React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { - divElement.shadowRoot.innerHTML = addBaseUrl( - state.value, - 'https://techdocs-mock-sites.storage.googleapis.com/mkdocs/', - ); + divElement.shadowRoot.innerHTML = transformer(state.value, [ + addBaseUrl({ + baseUrl, + componentId, + path, + }), + rewriteDocLinks({ + componentId, + }), + ]); } - }, [shadowDomRef, state]); + }, [shadowDomRef, state, componentId, path]); return ( <> -

Shadow DOM should be underneath

+
); diff --git a/plugins/techdocs/src/transformers/addBaseUrl.ts b/plugins/techdocs/src/transformers/addBaseUrl.ts new file mode 100644 index 0000000000..674e3ab33b --- /dev/null +++ b/plugins/techdocs/src/transformers/addBaseUrl.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ +type AddBaseUrlOptions = { + baseUrl: string; + componentId: string; + path: string; +}; + +export const addBaseUrl = ({ + baseUrl, + componentId, + path, +}: AddBaseUrlOptions) => { + return (dom: Document): Document => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = new URL( + elem.getAttribute(attributeName)!, + `${baseUrl}/${componentId}/${path}`, + ).toString(); + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(dom.images), 'src'); + updateDom(Array.from(dom.scripts), 'src'); + updateDom( + Array.from(dom.querySelectorAll('link')), + 'href', + ); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/transformers/index.ts b/plugins/techdocs/src/transformers/index.ts new file mode 100644 index 0000000000..b530840d08 --- /dev/null +++ b/plugins/techdocs/src/transformers/index.ts @@ -0,0 +1,28 @@ +/* + * 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 './addBaseUrl'; +export * from './rewriteDocLinks'; + +type Transformer = (dom: Document) => Document; + +export default (html: string, transformers: Transformer[]): string => { + const dom = new DOMParser().parseFromString(html, 'text/html'); + + transformers.forEach(transformer => transformer(dom)); + + return dom.body.parentElement?.outerHTML ?? ''; +}; diff --git a/plugins/techdocs/src/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/transformers/rewriteDocLinks.ts new file mode 100644 index 0000000000..459b7eda3c --- /dev/null +++ b/plugins/techdocs/src/transformers/rewriteDocLinks.ts @@ -0,0 +1,38 @@ +/* + * 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. + */ +type AddBaseUrlOptions = { + componentId: string; +}; + +export const rewriteDocLinks = ({ componentId }: AddBaseUrlOptions) => { + return (dom: Document): Document => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = `${componentId}/${elem.getAttribute(attributeName)}`; + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(Array.from(dom.getElementsByTagName('a')), 'href'); + + return dom; + }; +}; From d1f9c51d11d3379bc9563ed9db228039cdeaa5aa Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 13:27:37 +0200 Subject: [PATCH 2/5] fix react-router version --- plugins/techdocs/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b84b98c745..ca8e957a46 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,7 +29,8 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router": "^6.0.0-alpha.5", + "react-router-dom": "^6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { From 674d14241870318b8178602b09f01c8f98d64ec5 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 14:53:19 +0200 Subject: [PATCH 3/5] Update URLS better --- plugins/techdocs/src/config.js | 3 +- .../techdocs/src/reader/components/Reader.tsx | 20 +++++++----- .../{ => reader}/transformers/addBaseUrl.ts | 13 +++++--- .../src/{ => reader}/transformers/index.ts | 7 +++-- .../transformers/rewriteDocLinks.ts | 20 +++++++----- plugins/techdocs/src/reader/urlParser.ts | 31 +++++++++++++++++++ 6 files changed, 71 insertions(+), 23 deletions(-) rename plugins/techdocs/src/{ => reader}/transformers/addBaseUrl.ts (87%) rename plugins/techdocs/src/{ => reader}/transformers/index.ts (85%) rename plugins/techdocs/src/{ => reader}/transformers/rewriteDocLinks.ts (69%) create mode 100644 plugins/techdocs/src/reader/urlParser.ts diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js index 2baf8b725c..296b9fc14c 100644 --- a/plugins/techdocs/src/config.js +++ b/plugins/techdocs/src/config.js @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const baseUrl = 'https://techdocs-mock-sites.storage.googleapis.com'; +export const docStorageURL = + 'https://techdocs-mock-sites.storage.googleapis.com'; diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 36816fcc9a..0a0bb88421 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { useShadowDom } from '..'; import { useAsync } from 'react-use'; -import transformer, { addBaseUrl, rewriteDocLinks } from '../../transformers'; -import { baseUrl } from '../../config'; +import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; +import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; import { useLocation, useParams } from 'react-router-dom'; @@ -42,16 +42,16 @@ export const Reader = () => { const shadowDomRef = useShadowDom(); const state = useFetch( normalizeUrl( - `${baseUrl}${location.pathname.replace('/docs', '')}/index.html`, + `${docStorageURL}${location.pathname.replace('/docs', '')}/index.html`, ), ); - // https://techdocs-mock-sites.storage.googleapis.com/mkdocs/user-guide/configuration/custom-themes/index.html + React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { - divElement.shadowRoot.innerHTML = transformer(state.value, [ + const transformedElement = transformer(state.value, [ addBaseUrl({ - baseUrl, + docStorageURL, componentId, path, }), @@ -59,14 +59,18 @@ export const Reader = () => { componentId, }), ]); + + divElement.shadowRoot.innerHTML = ''; + if (transformedElement) + divElement.shadowRoot.appendChild(transformedElement); } }, [shadowDomRef, state, componentId, path]); return ( <>
diff --git a/plugins/techdocs/src/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts similarity index 87% rename from plugins/techdocs/src/transformers/addBaseUrl.ts rename to plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 674e3ab33b..e07a41fc03 100644 --- a/plugins/techdocs/src/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -13,14 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import URLParser from '../urlParser'; + type AddBaseUrlOptions = { - baseUrl: string; + docStorageURL: string; componentId: string; path: string; }; export const addBaseUrl = ({ - baseUrl, + docStorageURL, componentId, path, }: AddBaseUrlOptions) => { @@ -32,10 +35,10 @@ export const addBaseUrl = ({ Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = new URL( + const newUrl = new URLParser( + `${docStorageURL}/${componentId}/${path}`, elem.getAttribute(attributeName)!, - `${baseUrl}/${componentId}/${path}`, - ).toString(); + ).parse(); elem.setAttribute(attributeName, newUrl); }); }; diff --git a/plugins/techdocs/src/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts similarity index 85% rename from plugins/techdocs/src/transformers/index.ts rename to plugins/techdocs/src/reader/transformers/index.ts index b530840d08..008c86e261 100644 --- a/plugins/techdocs/src/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -19,10 +19,13 @@ export * from './rewriteDocLinks'; type Transformer = (dom: Document) => Document; -export default (html: string, transformers: Transformer[]): string => { +export default ( + html: string, + transformers: Transformer[], +): HTMLElement | undefined => { const dom = new DOMParser().parseFromString(html, 'text/html'); transformers.forEach(transformer => transformer(dom)); - return dom.body.parentElement?.outerHTML ?? ''; + return dom.body.parentElement ?? undefined; }; diff --git a/plugins/techdocs/src/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts similarity index 69% rename from plugins/techdocs/src/transformers/rewriteDocLinks.ts rename to plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 459b7eda3c..373db51abd 100644 --- a/plugins/techdocs/src/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -13,21 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -type AddBaseUrlOptions = { - componentId: string; -}; -export const rewriteDocLinks = ({ componentId }: AddBaseUrlOptions) => { +import URLParser from '../urlParser'; + +type AddBaseUrlOptions = {}; + +export const rewriteDocLinks = ({}: AddBaseUrlOptions) => { return (dom: Document): Document => { const updateDom = ( list: Array, attributeName: string, ): void => { Array.from(list) - .filter(elem => !!elem.getAttribute(attributeName)) + .filter(elem => elem.hasAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = `${componentId}/${elem.getAttribute(attributeName)}`; - elem.setAttribute(attributeName, newUrl); + elem.setAttribute( + attributeName, + new URLParser( + window.location.href, + elem.getAttribute(attributeName)!, + ).parse(), + ); }); }; diff --git a/plugins/techdocs/src/reader/urlParser.ts b/plugins/techdocs/src/reader/urlParser.ts new file mode 100644 index 0000000000..b1429b7635 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +const normalizeBaseURL = (baseURL: string): string => { + const url = new URL(baseURL); + url.pathname = url.pathname.replace(/([^/])$/, '$1/'); + return url.toString(); +}; + +export default class URLParser { + constructor(public baseURL: string, public pathname: string) { + this.baseURL = normalizeBaseURL(baseURL); + } + + parse(): string { + return new URL(this.pathname, this.baseURL).toString(); + } +} From d917f72280589547ae026634f78dc52849dc9520 Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 14:59:50 +0200 Subject: [PATCH 4/5] Enforce trailing slash --- plugins/techdocs/src/reader/components/Reader.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 0a0bb88421..d4e84aa8d5 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -21,6 +21,7 @@ import transformer, { addBaseUrl, rewriteDocLinks } from '../transformers'; import { docStorageURL } from '../../config'; import { Link } from '@backstage/core'; import { useLocation, useParams } from 'react-router-dom'; +import URLParser from '../urlParser'; const useFetch = (url: string) => { const state = useAsync(async () => { @@ -36,6 +37,17 @@ const normalizeUrl = (path: string) => { return path.replace(/\/\/index.html$/, '/index.html'); }; +const useEnforcedTrailingSlash = (): void => { + React.useEffect(() => { + const actualUrl = window.location.href; + const expectedUrl = new URLParser(window.location.href, '.').parse(); + + if (actualUrl !== expectedUrl) { + window.history.replaceState({}, document.title, expectedUrl); + } + }, []); +}; + export const Reader = () => { const location = useLocation(); const { componentId, '*': path } = useParams(); @@ -46,6 +58,8 @@ export const Reader = () => { ), ); + useEnforcedTrailingSlash(); + React.useEffect(() => { const divElement = shadowDomRef.current; if (divElement?.shadowRoot && state.value) { From 14bbaf099ebb33a62c0a093f9e93691f382e208f Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Fri, 26 Jun 2020 15:09:51 +0200 Subject: [PATCH 5/5] Add tests for URLParser --- plugins/techdocs/src/reader/urlParser.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 plugins/techdocs/src/reader/urlParser.test.ts diff --git a/plugins/techdocs/src/reader/urlParser.test.ts b/plugins/techdocs/src/reader/urlParser.test.ts new file mode 100644 index 0000000000..4322fc42e1 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.test.ts @@ -0,0 +1,61 @@ +/* + * 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 URLParser from './urlParser'; + +describe('URLParser', () => { + it('should not change an absolute url', () => { + const urlParser = new URLParser( + 'https://www.google.com/', + 'https://www.mkdocs.org/', + ); + + expect(urlParser.parse()).toEqual('https://www.mkdocs.org/'); + }); + + it('should convert a relative url to an absolute url', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started/', + '../../support/installing/', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/support/installing/', + ); + }); + + it('should add a trailing slash', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started', + '.', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); + + it('should not add a trailing slash', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started/', + '.', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); +});