Transform meta redirect tags (#25766)

* Add TechDocs support for the mkdocs-redirects plugin.
---------

Signed-off-by: Sydney Achinger <sydneynicoleachinger@spotify.com>
This commit is contained in:
Sydney Achinger
2024-08-03 20:36:13 -04:00
committed by GitHub
parent 66b68a151c
commit 67e76f2bb9
6 changed files with 216 additions and 2 deletions
+5
View File
@@ -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.
@@ -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(() => {
@@ -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(
`<meta http-equiv="refresh" content="0; url=../anotherPage">`,
'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(
`<meta http-equiv="refresh" content="0; url=http://external.com/test">`,
'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(
`<meta http-equiv="refresh" content="0; url=http://localhost/test">`,
'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(
`<meta name="keywords" content="TechDocs, Example">`,
'http://localhost/docs/default/component/testEntity/subpath',
'/docs/default/component/testEntity/subpath',
);
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -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;
};
};
@@ -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 = `
<body>
<meta http-equiv="refresh" content="0;url=https://test.com">
</body>
`;
const cleanDom = await result.current(dirtyDom); // calling html transformer
const metaTags = Array.from(
cleanDom.querySelectorAll<HTMLMetaElement>('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 = `
<body>
<meta name="keywords" content="TechDocs, Example">
</body>
`;
const cleanDom = await result.current(dirtyDom); // calling html transformer
const metaTags = Array.from(
cleanDom.querySelectorAll<HTMLMetaElement>('meta'),
);
expect(metaTags).toHaveLength(0);
});
});
@@ -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,
});