Refactor the techdocs transformers to return Promises and await all transformations

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-07-07 10:43:54 +02:00
parent ffae1bb6e4
commit 214e7c52d1
16 changed files with 159 additions and 127 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Refactor the techdocs transformers to return `Promise`s and await all transformations.
@@ -23,6 +23,7 @@ import { Button, CircularProgress, useTheme } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { techdocsStorageApiRef } from '../../api';
import {
addBaseUrl,
@@ -94,15 +95,16 @@ export const Reader = ({ entityId, onReady }: Props) => {
// an update to "state" might lead to an updated UI so we include it as a trigger
}, [updateSidebarPosition, state]);
useEffect(() => {
useAsync(async () => {
if (!rawPage || !shadowDomRef.current) {
return;
}
if (onReady) {
onReady();
}
// Pre-render
const transformedElement = transformer(rawPage, [
const transformedElement = await transformer(rawPage, [
sanitizeDOM(),
addBaseUrl({
techdocsStorageApi,
@@ -236,7 +238,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
}),
]);
if (!transformedElement) {
if (!transformedElement?.innerHTML) {
return; // An unexpected error occurred
}
@@ -252,7 +254,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
window.scroll({ top: 0 });
// Post-render
transformer(shadowRoot.children[0], [
await transformer(shadowRoot.children[0], [
dom => {
setTimeout(() => {
// Scoll to the desired anchor on initial navigation
@@ -281,7 +283,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
},
}),
onCssReady({
docStorageUrl: techdocsStorageApi.getApiOrigin(),
docStorageUrl: await techdocsStorageApi.getApiOrigin(),
onLoading: (dom: Element) => {
(dom as HTMLElement).style.setProperty('opacity', '0');
},
@@ -15,9 +15,9 @@
*/
import { waitFor } from '@testing-library/react';
import { createTestShadowDom } from '../../test-utils';
import { addBaseUrl } from '../transformers';
import { TechDocsStorageApi } from '../../api';
import { createTestShadowDom } from '../../test-utils';
import { addBaseUrl } from './addBaseUrl';
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
const API_ORIGIN_URL = 'https://backstage.example.com/api/techdocs';
@@ -62,8 +62,8 @@ describe('addBaseUrl', () => {
global.fetch = originalFetch;
});
it('contains relative paths', () => {
createTestShadowDom(fixture, {
it('contains relative paths', async () => {
await createTestShadowDom(fixture, {
preTransformers: [
addBaseUrl({
techdocsStorageApi,
@@ -110,7 +110,7 @@ describe('addBaseUrl', () => {
text: jest.fn().mockResolvedValue(svgContent),
});
const root = createTestShadowDom('<img id="x" src="test.svg" />', {
const root = await createTestShadowDom('<img id="x" src="test.svg" />', {
preTransformers: [
addBaseUrl({
techdocsStorageApi,
@@ -137,7 +137,7 @@ describe('addBaseUrl', () => {
text: jest.fn().mockResolvedValue(svgContent),
});
const root = createTestShadowDom(
const root = await createTestShadowDom(
`<img id="x" src="${API_ORIGIN_URL}/test.svg" />`,
{
preTransformers: [
@@ -162,16 +162,19 @@ describe('addBaseUrl', () => {
it('does not inline external svgs', async () => {
const expectedSrc = 'https://example.com/test.svg';
const root = createTestShadowDom(`<img id="x" src="${expectedSrc}" />`, {
preTransformers: [
addBaseUrl({
techdocsStorageApi,
entityId: mockEntityId,
path: '',
}),
],
postTransformers: [],
});
const root = await createTestShadowDom(
`<img id="x" src="${expectedSrc}" />`,
{
preTransformers: [
addBaseUrl({
techdocsStorageApi,
entityId: mockEntityId,
path: '',
}),
],
postTransformers: [],
},
);
await new Promise<void>(done => {
process.nextTick(() => {
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import type { Transformer } from './transformer';
import { TechDocsStorageApi } from '../../api';
import type { Transformer } from './transformer';
type AddBaseUrlOptions = {
techdocsStorageApi: TechDocsStorageApi;
@@ -44,14 +44,15 @@ export const addBaseUrl = ({
entityId,
path,
}: AddBaseUrlOptions): Transformer => {
return dom => {
const updateDom = <T extends Element>(
return async dom => {
const apiOrigin = await techdocsStorageApi.getApiOrigin();
const updateDom = async <T extends Element>(
list: HTMLCollectionOf<T> | NodeListOf<T>,
attributeName: string,
): void => {
Array.from(list)
.filter(elem => !!elem.getAttribute(attributeName))
.forEach(async (elem: T) => {
) => {
for (const elem of list) {
if (elem.hasAttribute(attributeName)) {
const elemAttribute = elem.getAttribute(attributeName);
if (!elemAttribute) return;
@@ -61,7 +62,7 @@ export const addBaseUrl = ({
entityId,
path,
);
const apiOrigin = await techdocsStorageApi.getApiOrigin();
if (isSvgNeedingInlining(attributeName, elemAttribute, apiOrigin)) {
try {
const svg = await fetch(newValue, { credentials: 'include' });
@@ -76,13 +77,16 @@ export const addBaseUrl = ({
} else {
elem.setAttribute(attributeName, newValue);
}
});
}
}
};
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src');
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src');
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href');
updateDom<HTMLAnchorElement>(dom.querySelectorAll('a[download]'), 'href');
await Promise.all([
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src'),
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src'),
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href'),
updateDom<HTMLAnchorElement>(dom.querySelectorAll('a[download]'), 'href'),
]);
return dom;
};
@@ -28,8 +28,8 @@ const integrations = ScmIntegrations.fromConfig(
);
describe('addGitFeedbackLink', () => {
it('adds a feedback link when a Gitlab source edit link is available', () => {
const shadowDom = createTestShadowDom(
it('adds a feedback link when a Gitlab source edit link is available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -53,8 +53,8 @@ describe('addGitFeedbackLink', () => {
);
});
it('adds a feedback link when a Github source edit link is available', () => {
const shadowDom = createTestShadowDom(
it('adds a feedback link when a Github source edit link is available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -78,8 +78,8 @@ describe('addGitFeedbackLink', () => {
);
});
it('does not add a feedback link when no source edit link is available', () => {
const shadowDom = createTestShadowDom(
it('does not add a feedback link when no source edit link is available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -97,8 +97,8 @@ describe('addGitFeedbackLink', () => {
expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
});
it('does not add a feedback link when a Gitlab or Github source edit link is not available', () => {
const shadowDom = createTestShadowDom(
it('does not add a feedback link when a Gitlab or Github source edit link is not available', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -117,8 +117,8 @@ describe('addGitFeedbackLink', () => {
expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
});
it('adds a feedback link when a Gitlab or Github source edit link is not available but hostname matches an integrations host', () => {
const shadowDom = createTestShadowDom(
it('adds a feedback link when a Gitlab or Github source edit link is not available but hostname matches an integrations host', async () => {
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -18,9 +18,9 @@ import { createTestShadowDom } from '../../test-utils';
import { addLinkClickListener } from './addLinkClickListener';
describe('addLinkClickListener', () => {
it('calls onClick when a link has been clicked', () => {
it('calls onClick when a link has been clicked', async () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -45,9 +45,9 @@ describe('addLinkClickListener', () => {
expect(fn).toHaveBeenCalledTimes(1);
});
it('does not call onClick when a link links to another baseUrl', () => {
it('does not call onClick when a link links to another baseUrl', async () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(
const shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
@@ -17,14 +17,14 @@
import { Transformer, transform } from './transformer';
describe('transform', () => {
it('calls the transformers', () => {
it('calls the transformers', async () => {
const fn = jest.fn();
const mockTransformer = (): Transformer => (dom: Element) => {
fn(dom);
return dom;
};
transform('<html></html>', [mockTransformer()]);
await transform('<html></html>', [mockTransformer()]);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith(expect.any(Element));
@@ -15,10 +15,10 @@
*/
import { createTestShadowDom } from '../../test-utils';
import { injectCss } from '../transformers';
import { injectCss } from './injectCss';
describe('injectCss', () => {
it('should inject style with passed css in head', () => {
it('should inject style with passed css in head', async () => {
const html = `
<html>
<head></head>
@@ -27,7 +27,7 @@ describe('injectCss', () => {
`;
const injectedCss = '* {background-color: #fff}';
const shadowDom = createTestShadowDom(html, {
const shadowDom = await createTestShadowDom(html, {
preTransformers: [injectCss({ css: injectedCss })],
postTransformers: [],
});
@@ -15,16 +15,15 @@
*/
import {
createTestShadowDom,
mockStylesheetEventListener,
executeStylesheetEventListeners,
clearStylesheetEventListeners,
createTestShadowDom,
executeStylesheetEventListeners,
mockStylesheetEventListener,
} from '../../test-utils';
import { onCssReady } from '../transformers';
import { onCssReady } from './onCssReady';
const docStorageUrl: Promise<string> = Promise.resolve(
'https://techdocs-mock-sites.storage.googleapis.com',
);
const docStorageUrl: string =
'https://techdocs-mock-sites.storage.googleapis.com';
const fixture = `
<link rel="stylesheet" href="${docStorageUrl}/test.css" />
@@ -48,11 +47,11 @@ describe('onCssReady', () => {
clearStylesheetEventListeners();
});
it('does not call onLoading and onLoaded without the onCssReady transformer', () => {
it('does not call onLoading and onLoaded without the onCssReady transformer', async () => {
const onLoading = jest.fn();
const onLoaded = jest.fn();
createTestShadowDom(fixture, {
await createTestShadowDom(fixture, {
preTransformers: [],
postTransformers: [],
});
@@ -62,11 +61,11 @@ describe('onCssReady', () => {
expect(onLoaded).not.toHaveBeenCalled();
});
it('calls the onLoading and onLoaded correctly', () => {
it('calls the onLoading and onLoaded correctly', async () => {
const onLoading = jest.fn();
const onLoaded = jest.fn();
createTestShadowDom(fixture, {
await createTestShadowDom(fixture, {
preTransformers: [],
postTransformers: [
onCssReady({
@@ -17,7 +17,7 @@
import type { Transformer } from './transformer';
type OnCssReadyOptions = {
docStorageUrl: Promise<string>;
docStorageUrl: string;
onLoading: (dom: Element) => void;
onLoaded: (dom: Element) => void;
};
@@ -30,9 +30,7 @@ export const onCssReady = ({
return dom => {
const cssPages = Array.from(
dom.querySelectorAll('head > link[rel="stylesheet"]'),
).filter(async elem =>
elem.getAttribute('href')?.startsWith(await docStorageUrl),
);
).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl));
let count = cssPages.length;
@@ -18,20 +18,26 @@ import { createTestShadowDom, FIXTURES } from '../../test-utils';
import { removeMkdocsHeader } from '../transformers';
describe('removeMkdocsHeader', () => {
it('does not remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [],
postTransformers: [],
});
it('does not remove mkdocs header', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-header')).toBeTruthy();
});
it('does remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [removeMkdocsHeader()],
postTransformers: [],
});
it('does remove mkdocs header', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [removeMkdocsHeader()],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-header')).toBeFalsy();
});
@@ -19,8 +19,8 @@ import { rewriteDocLinks } from '../transformers';
import { normalizeUrl } from './rewriteDocLinks';
describe('rewriteDocLinks', () => {
it('should not do anything', () => {
const shadowDom = createTestShadowDom(`
it('should not do anything', async () => {
const shadowDom = await createTestShadowDom(`
<a href="http://example.org/">Test</a>
<a href="../example">Test</a>
<a href="example-docs">Test</a>
@@ -35,8 +35,8 @@ describe('rewriteDocLinks', () => {
]);
});
it('should transform a href with localhost as baseUrl', () => {
const shadowDom = createTestShadowDom(
it('should transform a href with localhost as baseUrl', async () => {
const shadowDom = await createTestShadowDom(
`
<a href="http://example.org/">Test</a>
<a href="../example">Test</a>
@@ -57,9 +57,9 @@ describe('rewriteDocLinks', () => {
]);
});
it('should rewrite non-parseable URLs as text', () => {
it('should rewrite non-parseable URLs as text', async () => {
const expectedText = `www.my-internet.[top-level-domain]/pathname/[URLkey]`;
const shadowDom = createTestShadowDom(
const shadowDom = await createTestShadowDom(
`<a href="http://${expectedText}">${expectedText}</a>`,
{
preTransformers: [rewriteDocLinks()],
@@ -16,7 +16,7 @@
import { createTestShadowDom, FIXTURES } from '../../../test-utils';
import { Transformer } from '../index';
import { sanitizeDOM } from '../sanitizeDOM';
import { sanitizeDOM } from './index';
const injectMaliciousLink = (): Transformer => dom => {
const link = document.createElement('a');
@@ -27,55 +27,64 @@ const injectMaliciousLink = (): Transformer => dom => {
};
describe('sanitizeDOM', () => {
it('contains a script tag', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
it('contains a script tag', async () => {
const shadowDom = await createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
expect(shadowDom.querySelectorAll('script').length).toBeGreaterThan(0);
});
it('does not contain a script tag', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
it('does not contain a script tag', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [sanitizeDOM()],
postTransformers: [],
},
);
expect(shadowDom.querySelectorAll('script').length).toBe(0);
});
it('contains link with a onClick attribute', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [injectMaliciousLink()],
postTransformers: [],
});
it('contains link with a onClick attribute', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [injectMaliciousLink()],
postTransformers: [],
},
);
expect(
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
).toBeTruthy();
});
it('does not contain link with a onClick attribute', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
it('does not contain link with a onClick attribute', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [sanitizeDOM()],
postTransformers: [],
},
);
expect(
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
).toBeFalsy();
});
it('removes style tags', () => {
it('removes style tags', async () => {
const html = `
<html>
<head>
<style>* {color: #f0f;}<style>
<style>* {color: #f0f;}</style>
</head>
<body>
</body>
</html>
`;
const shadowDom = createTestShadowDom(html, {
const shadowDom = await createTestShadowDom(html, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
@@ -83,7 +92,7 @@ describe('sanitizeDOM', () => {
expect(shadowDom.querySelectorAll('style').length).toEqual(0);
});
it('does not remove link tags', () => {
it('does not remove link tags', async () => {
const html = `
<html>
<head>
@@ -94,7 +103,7 @@ describe('sanitizeDOM', () => {
</html>
`;
const shadowDom = createTestShadowDom(html, {
const shadowDom = await createTestShadowDom(html, {
preTransformers: [sanitizeDOM()],
postTransformers: [],
});
@@ -18,20 +18,26 @@ import { createTestShadowDom, FIXTURES } from '../../test-utils';
import { simplifyMkdocsFooter } from './simplifyMkdocsFooter';
describe('simplifyMkdocsFooter', () => {
it('does not remove mkdocs copyright', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [],
postTransformers: [],
});
it('does not remove mkdocs copyright', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-footer-copyright')).toBeTruthy();
});
it('does remove mkdocs copyright', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
preTransformers: [simplifyMkdocsFooter()],
postTransformers: [],
});
it('does remove mkdocs copyright', async () => {
const shadowDom = await createTestShadowDom(
FIXTURES.FIXTURE_STANDARD_PAGE,
{
preTransformers: [simplifyMkdocsFooter()],
postTransformers: [],
},
);
expect(shadowDom.querySelector('.md-footer-copyright')).toBeFalsy();
});
@@ -14,12 +14,12 @@
* limitations under the License.
*/
export type Transformer = (dom: Element) => Element;
export type Transformer = (dom: Element) => Element | Promise<Element>;
export const transform = (
export const transform = async (
html: string | Element,
transformers: Transformer[],
): Element => {
): Promise<Element> => {
let dom: Element;
if (typeof html === 'string') {
@@ -30,9 +30,9 @@ export const transform = (
throw new Error('dom is not a recognized type');
}
transformers.forEach(transformer => {
dom = transformer(dom);
});
for (const transformer of transformers) {
dom = await transformer(dom);
}
return dom;
};
+4 -4
View File
@@ -22,13 +22,13 @@ export type CreateTestShadowDomOptions = {
postTransformers: Transformer[];
};
export const createTestShadowDom = (
export const createTestShadowDom = async (
fixture: string,
opts: CreateTestShadowDomOptions = {
preTransformers: [],
postTransformers: [],
},
): ShadowRoot => {
): Promise<ShadowRoot> => {
const divElement = document.createElement('div');
divElement.attachShadow({ mode: 'open' });
document.body.appendChild(divElement);
@@ -39,7 +39,7 @@ export const createTestShadowDom = (
'text/html',
).documentElement;
if (opts.preTransformers) {
dom = transformer(dom, opts.preTransformers);
dom = await transformer(dom, opts.preTransformers);
}
// Mount the UI
@@ -47,7 +47,7 @@ export const createTestShadowDom = (
// Transformers after the UI is rendered
if (opts.postTransformers) {
transformer(dom, opts.postTransformers);
await transformer(dom, opts.postTransformers);
}
return divElement.shadowRoot!;