diff --git a/.changeset/silly-candles-sin.md b/.changeset/silly-candles-sin.md new file mode 100644 index 0000000000..b1d55113fd --- /dev/null +++ b/.changeset/silly-candles-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +TechDocs now supports the `mkdocs-redirects` plugin. Redirects defined using the `mkdocs-redirect` plugin will be handled automatically in TechDocs. Redirecting to external urls is not supported. In the case that an external redirect url is provided, TechDocs will redirect to the current documentation site home. diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index de2460a543..c686827255 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -46,6 +46,7 @@ import { useStylesTransformer, } from '../../transformers'; import { useNavigateUrl } from './useNavigateUrl'; +import { handleMetaRedirects } from '../../transformers/handleMetaRedirects'; const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)'; @@ -186,6 +187,7 @@ export const useTechDocsReaderDom = ( const postRender = useCallback( async (transformedElement: Element) => transformer(transformedElement, [ + handleMetaRedirects(navigate, entityRef.name), scrollIntoNavigation(), copyToClipboard(theme), addLinkClickListener({ @@ -243,7 +245,7 @@ export const useTechDocsReaderDom = ( onLoaded: () => {}, }), ]), - [theme, navigate, analytics], + [theme, navigate, analytics, entityRef.name], ); useEffect(() => { diff --git a/plugins/techdocs/src/reader/transformers/handleMetaRedirects.test.ts b/plugins/techdocs/src/reader/transformers/handleMetaRedirects.test.ts new file mode 100644 index 0000000000..8fbd63428d --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/handleMetaRedirects.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { handleMetaRedirects } from './handleMetaRedirects'; +import { createTestShadowDom } from '../../test-utils'; + +describe('handleMetaRedirects', () => { + const navigate = jest.fn(); + + const setUpNewTestShadowDom = async ( + html: string, + rootHref: string, + rootPath: string, + ) => { + const entityName = 'testEntity'; + // Mock window.location.href for each test + Object.defineProperty(window, 'location', { + value: { + href: rootHref, + pathname: rootPath, + hostname: 'localhost', + }, + writable: true, + }); + + return await createTestShadowDom(html, { + preTransformers: [], + postTransformers: [handleMetaRedirects(navigate, entityName)], + }); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should navigate to relative URL if meta redirect tag is present', async () => { + await setUpNewTestShadowDom( + ``, + 'http://localhost/docs/default/component/testEntity/subpath', + '/docs/default/component/testEntity/subpath', + ); + expect(navigate).toHaveBeenCalledWith( + 'http://localhost/docs/default/component/testEntity/anotherPage', + ); + }); + + it('should navigate to site home if meta redirect tag is present and external', async () => { + await setUpNewTestShadowDom( + ``, + 'http://localhost/docs/default/component/testEntity/subpath', + '/docs/default/component/testEntity/subpath', + ); + expect(navigate).toHaveBeenCalledWith('/docs/default/component/testEntity'); + }); + + it('should navigate to absolute URL if meta redirect tag is present and not external', async () => { + await setUpNewTestShadowDom( + ``, + 'http://localhost/docs/default/component/testEntity/subpath', + '/docs/default/component/testEntity/subpath', + ); + expect(navigate).toHaveBeenCalledWith('http://localhost/test'); + }); + + it('should not navigate if meta redirect tag is not present', async () => { + await setUpNewTestShadowDom( + ``, + 'http://localhost/docs/default/component/testEntity/subpath', + '/docs/default/component/testEntity/subpath', + ); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/handleMetaRedirects.ts b/plugins/techdocs/src/reader/transformers/handleMetaRedirects.ts new file mode 100644 index 0000000000..4c56d4d363 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/handleMetaRedirects.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { Transformer } from './transformer'; +import { normalizeUrl } from './rewriteDocLinks'; + +export const handleMetaRedirects = ( + navigate: (to: string) => void, + entityName: string, +): Transformer => { + return dom => { + for (const elem of Array.from(dom.querySelectorAll('meta'))) { + if (elem.getAttribute('http-equiv') === 'refresh') { + const metaContentParameters = elem + .getAttribute('content') + ?.split('url='); + if (!metaContentParameters || metaContentParameters.length < 2) { + continue; + } + + const metaUrl = metaContentParameters[1]; + const normalizedCurrentUrl = normalizeUrl(window.location.href); + // If metaUrl is relative, it will be resolved with base href. If it is absolute, it will replace the base href when creating URL object. + const absoluteRedirectObj = new URL(metaUrl, normalizedCurrentUrl); + const isExternalRedirect = + absoluteRedirectObj.hostname !== window.location.hostname; + + if (isExternalRedirect) { + // If the redirect is external, navigate to the documentation site home instead of the external url. + const currentTechDocPath = window.location.pathname; + const indexOfSiteHome = currentTechDocPath.indexOf(entityName); + const siteHomePath = currentTechDocPath.slice( + 0, + indexOfSiteHome + entityName.length, + ); + navigate(siteHomePath); + } else { + // The navigate function from dom.tsx is a wrapper around react-router navigate function that helps absolute url redirects. + navigate(absoluteRedirectObj.href); + } + return dom; + } + } + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx b/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx index ed5fbba085..e45d66c676 100644 --- a/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx +++ b/plugins/techdocs/src/reader/transformers/html/transformer.test.tsx @@ -81,4 +81,44 @@ describe('Transformers > Html', () => { expect(iframes).toHaveLength(1); expect(iframes[0].src).toMatch('docs.google.com'); }); + + it('should return a function that allows refresh meta tags', async () => { + const { result } = renderHook(() => useSanitizerTransformer(), { wrapper }); + + const dirtyDom = document.createElement('html'); + dirtyDom.innerHTML = ` + + + + `; + const cleanDom = await result.current(dirtyDom); // calling html transformer + + const metaTags = Array.from( + cleanDom.querySelectorAll('meta'), + ); + + expect(metaTags).toHaveLength(1); + expect(metaTags[0].getAttribute('http-equiv')).toEqual('refresh'); + expect(metaTags[0].getAttribute('content')).toEqual( + '0;url=https://test.com', + ); + }); + + it('should return a function that does not allow non-refresh meta tags', async () => { + const { result } = renderHook(() => useSanitizerTransformer(), { wrapper }); + + const dirtyDom = document.createElement('html'); + dirtyDom.innerHTML = ` + + + + `; + const cleanDom = await result.current(dirtyDom); // calling html transformer + + const metaTags = Array.from( + cleanDom.querySelectorAll('meta'), + ); + + expect(metaTags).toHaveLength(0); + }); }); diff --git a/plugins/techdocs/src/reader/transformers/html/transformer.ts b/plugins/techdocs/src/reader/transformers/html/transformer.ts index 61498a37b0..4938a36648 100644 --- a/plugins/techdocs/src/reader/transformers/html/transformer.ts +++ b/plugins/techdocs/src/reader/transformers/html/transformer.ts @@ -44,17 +44,39 @@ export const useSanitizerTransformer = (): Transformer => { const hosts = config?.getOptionalStringArray('allowedIframeHosts'); DOMPurify.addHook('beforeSanitizeElements', removeUnsafeLinks); - const tags = ['link']; + const tags = ['link', 'meta']; if (hosts) { tags.push('iframe'); DOMPurify.addHook('beforeSanitizeElements', removeUnsafeIframes(hosts)); } + // Only allow meta tags if they are used for refreshing the page. They are required for the redirect feature. + DOMPurify.addHook('uponSanitizeElement', (currNode, data) => { + if (data.tagName === 'meta') { + const isMetaRefreshTag = + currNode.getAttribute('http-equiv') === 'refresh' && + currNode.getAttribute('content')?.includes('url='); + if (!isMetaRefreshTag) { + currNode.parentNode?.removeChild(currNode); + } + } + }); + + // Only allow http-equiv and content attributes on meta tags. They are required for the redirect feature. + DOMPurify.addHook('uponSanitizeAttribute', (currNode, data) => { + if (currNode.tagName !== 'meta') { + if (data.attrName === 'http-equiv' || data.attrName === 'content') { + currNode.removeAttribute(data.attrName); + } + } + }); + // using outerHTML as we want to preserve the html tag attributes (lang) return DOMPurify.sanitize(dom.outerHTML, { ADD_TAGS: tags, FORBID_TAGS: ['style'], + ADD_ATTR: ['http-equiv', 'content'], WHOLE_DOCUMENT: true, RETURN_DOM: true, });