Merge pull request #6492 from SDA-SE/feat/techdocs-async-transformer
[TechDocs] Refactor the techdocs transformers to return `Promise`s and await all transformations
This commit is contained in:
@@ -109,35 +109,26 @@ 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(() => {
|
||||
if (!rawPage || !shadowDomRef.current) {
|
||||
// clear the shadow dom if no content is available
|
||||
if (shadowDomRef.current?.shadowRoot) {
|
||||
shadowDomRef.current.shadowRoot.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
// Pre-render
|
||||
const transformedElement = transformer(rawPage, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: {
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
removeMkdocsHeader(),
|
||||
simplifyMkdocsFooter(),
|
||||
addGitFeedbackLink(scmIntegrationsApi),
|
||||
injectCss({
|
||||
css: `
|
||||
// a function that performs transformations that are executed prior to adding it to the DOM
|
||||
const preRender = useCallback(
|
||||
(rawContent: string, contentPath: string) =>
|
||||
transformer(rawContent, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: {
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
path: contentPath,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
removeMkdocsHeader(),
|
||||
simplifyMkdocsFooter(),
|
||||
addGitFeedbackLink(scmIntegrationsApi),
|
||||
injectCss({
|
||||
css: `
|
||||
body {
|
||||
font-family: ${theme.typography.fontFamily};
|
||||
--md-text-color: ${theme.palette.text.primary};
|
||||
@@ -194,21 +185,21 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
injectCss({
|
||||
// Disable CSS animations on link colors as they lead to issues in dark
|
||||
// mode. The dark mode color theme is applied later and theirfore there
|
||||
// is always an animation from light to dark mode when navigation
|
||||
// between pages.
|
||||
css: `
|
||||
}),
|
||||
injectCss({
|
||||
// Disable CSS animations on link colors as they lead to issues in dark
|
||||
// mode. The dark mode color theme is applied later and theirfore there
|
||||
// is always an animation from light to dark mode when navigation
|
||||
// between pages.
|
||||
css: `
|
||||
.md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink {
|
||||
transition: none;
|
||||
}
|
||||
`,
|
||||
}),
|
||||
injectCss({
|
||||
// Properly style code blocks.
|
||||
css: `
|
||||
}),
|
||||
injectCss({
|
||||
// Properly style code blocks.
|
||||
css: `
|
||||
.md-typeset pre > code::-webkit-scrollbar-thumb {
|
||||
background-color: hsla(0, 0%, 0%, 0.32);
|
||||
}
|
||||
@@ -216,17 +207,17 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
background-color: hsla(0, 0%, 0%, 0.87);
|
||||
}
|
||||
`,
|
||||
}),
|
||||
injectCss({
|
||||
// Admonitions and others are using SVG masks to define icons. These
|
||||
// masks are defined as CSS variables.
|
||||
// As the MkDocs output is rendered in shadow DOM, the CSS variable
|
||||
// definitions on the root selector are not applied. Instead, the have
|
||||
// to be applied on :host.
|
||||
// As there is no way to transform the served main*.css yet (for
|
||||
// example in the backend), we have to copy from main*.css and modify
|
||||
// them.
|
||||
css: `
|
||||
}),
|
||||
injectCss({
|
||||
// Admonitions and others are using SVG masks to define icons. These
|
||||
// masks are defined as CSS variables.
|
||||
// As the MkDocs output is rendered in shadow DOM, the CSS variable
|
||||
// definitions on the root selector are not applied. Instead, the have
|
||||
// to be applied on :host.
|
||||
// As there is no way to transform the served main*.css yet (for
|
||||
// example in the backend), we have to copy from main*.css and modify
|
||||
// them.
|
||||
css: `
|
||||
:host {
|
||||
--md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"/></svg>');
|
||||
--md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 5h16v2H4V5m0 4h16v2H4V9m0 4h16v2H4v-2m0 4h10v2H4v-2z"/></svg>');
|
||||
@@ -252,97 +243,129 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
--md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2m-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
|
||||
}
|
||||
`,
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
]),
|
||||
[
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
scmIntegrationsApi,
|
||||
techdocsStorageApi,
|
||||
theme.palette.background.default,
|
||||
theme.palette.background.paper,
|
||||
theme.palette.primary.main,
|
||||
theme.palette.text.primary,
|
||||
theme.typography.fontFamily,
|
||||
],
|
||||
);
|
||||
|
||||
if (!transformedElement) {
|
||||
return; // An unexpected error occurred
|
||||
// a function that performs transformations that are executed after adding it to the DOM
|
||||
const postRender = useCallback(
|
||||
async (shadowRoot: ShadowRoot) =>
|
||||
transformer(shadowRoot.children[0], [
|
||||
dom => {
|
||||
setTimeout(() => {
|
||||
// Scoll to the desired anchor on initial navigation
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
shadowRoot?.getElementById(hash)?.scrollIntoView();
|
||||
}
|
||||
}, 200);
|
||||
return dom;
|
||||
},
|
||||
addLinkClickListener({
|
||||
baseUrl: window.location.origin,
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (parsedUrl.hash) {
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
|
||||
// Scroll to hash if it's on the current page
|
||||
shadowRoot
|
||||
?.getElementById(parsedUrl.hash.slice(1))
|
||||
?.scrollIntoView();
|
||||
} else {
|
||||
navigate(parsedUrl.pathname);
|
||||
}
|
||||
},
|
||||
}),
|
||||
onCssReady({
|
||||
docStorageUrl: await techdocsStorageApi.getApiOrigin(),
|
||||
onLoading: (dom: Element) => {
|
||||
(dom as HTMLElement).style.setProperty('opacity', '0');
|
||||
},
|
||||
onLoaded: (dom: Element) => {
|
||||
(dom as HTMLElement).style.removeProperty('opacity');
|
||||
// disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
|
||||
(dom as HTMLElement)
|
||||
.querySelector('.md-nav__title')
|
||||
?.removeAttribute('for');
|
||||
const sideDivs: HTMLElement[] = Array.from(
|
||||
shadowRoot!.querySelectorAll('.md-sidebar'),
|
||||
);
|
||||
setSidebars(sideDivs);
|
||||
// set sidebar height so they don't initially render in wrong position
|
||||
const docTopPosition = (dom as HTMLElement).getBoundingClientRect()
|
||||
.top;
|
||||
const mdTabs = dom.querySelector('.md-container > .md-tabs');
|
||||
sideDivs!.forEach(sidebar => {
|
||||
sidebar.style.top = mdTabs
|
||||
? `${docTopPosition + mdTabs.getBoundingClientRect().height}px`
|
||||
: `${docTopPosition}px`;
|
||||
});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
[navigate, techdocsStorageApi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawPage || !shadowDomRef.current) {
|
||||
// clear the shadow dom if no content is available
|
||||
if (shadowDomRef.current?.shadowRoot) {
|
||||
shadowDomRef.current.shadowRoot.innerHTML = '';
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
|
||||
const shadowDiv: HTMLElement = shadowDomRef.current!;
|
||||
const shadowRoot =
|
||||
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
|
||||
Array.from(shadowRoot.children).forEach(child =>
|
||||
shadowRoot.removeChild(child),
|
||||
);
|
||||
shadowRoot.appendChild(transformedElement);
|
||||
// if false, there is already a newer execution of this effect
|
||||
let shouldReplaceContent = true;
|
||||
|
||||
// Scroll to top after render
|
||||
window.scroll({ top: 0 });
|
||||
// Pre-render
|
||||
preRender(rawPage, path).then(async transformedElement => {
|
||||
if (!transformedElement?.innerHTML) {
|
||||
return; // An unexpected error occurred
|
||||
}
|
||||
|
||||
// Post-render
|
||||
transformer(shadowRoot.children[0], [
|
||||
dom => {
|
||||
setTimeout(() => {
|
||||
// Scoll to the desired anchor on initial navigation
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
shadowRoot?.getElementById(hash)?.scrollIntoView();
|
||||
}
|
||||
}, 200);
|
||||
return dom;
|
||||
},
|
||||
addLinkClickListener({
|
||||
baseUrl: window.location.origin,
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
// don't manipulate the shadow dom if this isn't the latest effect execution
|
||||
if (!shouldReplaceContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedUrl.hash) {
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
const shadowDiv: HTMLElement = shadowDomRef.current!;
|
||||
const shadowRoot =
|
||||
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
|
||||
Array.from(shadowRoot.children).forEach(child =>
|
||||
shadowRoot.removeChild(child),
|
||||
);
|
||||
shadowRoot.appendChild(transformedElement);
|
||||
|
||||
// Scroll to hash if it's on the current page
|
||||
shadowRoot
|
||||
?.getElementById(parsedUrl.hash.slice(1))
|
||||
?.scrollIntoView();
|
||||
} else {
|
||||
navigate(parsedUrl.pathname);
|
||||
}
|
||||
},
|
||||
}),
|
||||
onCssReady({
|
||||
docStorageUrl: techdocsStorageApi.getApiOrigin(),
|
||||
onLoading: (dom: Element) => {
|
||||
(dom as HTMLElement).style.setProperty('opacity', '0');
|
||||
},
|
||||
onLoaded: (dom: Element) => {
|
||||
(dom as HTMLElement).style.removeProperty('opacity');
|
||||
// disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
|
||||
(dom as HTMLElement)
|
||||
.querySelector('.md-nav__title')
|
||||
?.removeAttribute('for');
|
||||
const sideDivs: HTMLElement[] = Array.from(
|
||||
shadowRoot!.querySelectorAll('.md-sidebar'),
|
||||
);
|
||||
setSidebars(sideDivs);
|
||||
// set sidebar height so they don't initially render in wrong position
|
||||
const docTopPosition = (dom as HTMLElement).getBoundingClientRect()
|
||||
.top;
|
||||
const mdTabs = dom.querySelector('.md-container > .md-tabs');
|
||||
sideDivs!.forEach(sidebar => {
|
||||
sidebar.style.top = mdTabs
|
||||
? `${docTopPosition + mdTabs.getBoundingClientRect().height}px`
|
||||
: `${docTopPosition}px`;
|
||||
});
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}, [
|
||||
path,
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
rawPage,
|
||||
navigate,
|
||||
onReady,
|
||||
shadowDomRef,
|
||||
techdocsStorageApi,
|
||||
theme.typography.fontFamily,
|
||||
theme.palette.text.primary,
|
||||
theme.palette.primary.main,
|
||||
theme.palette.background.paper,
|
||||
theme.palette.background.default,
|
||||
scmIntegrationsApi,
|
||||
]);
|
||||
// Scroll to top after render
|
||||
window.scroll({ top: 0 });
|
||||
|
||||
// Post-render
|
||||
await postRender(shadowRoot);
|
||||
});
|
||||
|
||||
// cancel this execution
|
||||
return () => {
|
||||
shouldReplaceContent = false;
|
||||
};
|
||||
}, [onReady, path, postRender, preRender, rawPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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!;
|
||||
|
||||
Reference in New Issue
Block a user