diff --git a/.changeset/techdocs-spotty-tables-beam.md b/.changeset/techdocs-spotty-tables-beam.md new file mode 100644 index 0000000000..397817b196 --- /dev/null +++ b/.changeset/techdocs-spotty-tables-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Refactor the techdocs transformers to return `Promise`s and await all transformations. diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 282f0d3447..ce72da4bf6 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -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,'); --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); @@ -252,97 +243,129 @@ export const Reader = ({ entityId, onReady }: Props) => { --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); } `, - }), - ]); + }), + ]), + [ + 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 ( <> diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index f04c9963f0..11e8375420 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -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('', { + const root = await createTestShadowDom('', { preTransformers: [ addBaseUrl({ techdocsStorageApi, @@ -137,7 +137,7 @@ describe('addBaseUrl', () => { text: jest.fn().mockResolvedValue(svgContent), }); - const root = createTestShadowDom( + const root = await createTestShadowDom( ``, { preTransformers: [ @@ -162,16 +162,19 @@ describe('addBaseUrl', () => { it('does not inline external svgs', async () => { const expectedSrc = 'https://example.com/test.svg'; - const root = createTestShadowDom(``, { - preTransformers: [ - addBaseUrl({ - techdocsStorageApi, - entityId: mockEntityId, - path: '', - }), - ], - postTransformers: [], - }); + const root = await createTestShadowDom( + ``, + { + preTransformers: [ + addBaseUrl({ + techdocsStorageApi, + entityId: mockEntityId, + path: '', + }), + ], + postTransformers: [], + }, + ); await new Promise(done => { process.nextTick(() => { diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index f21cb8c47a..8f08ec83a6 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -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 = ( + return async dom => { + const apiOrigin = await techdocsStorageApi.getApiOrigin(); + + const updateDom = async ( list: HTMLCollectionOf | NodeListOf, 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(dom.querySelectorAll('img'), 'src'); - updateDom(dom.querySelectorAll('script'), 'src'); - updateDom(dom.querySelectorAll('link'), 'href'); - updateDom(dom.querySelectorAll('a[download]'), 'href'); + await Promise.all([ + updateDom(dom.querySelectorAll('img'), 'src'), + updateDom(dom.querySelectorAll('script'), 'src'), + updateDom(dom.querySelectorAll('link'), 'href'), + updateDom(dom.querySelectorAll('a[download]'), 'href'), + ]); return dom; }; diff --git a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts index 2165afac98..0b8e431781 100644 --- a/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts +++ b/plugins/techdocs/src/reader/transformers/addGitFeedbackLink.test.ts @@ -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( ` @@ -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( ` @@ -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( ` @@ -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( ` @@ -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( ` diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts index 2d2d54a31c..fe3a6e557c 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts @@ -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( ` @@ -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( ` diff --git a/plugins/techdocs/src/reader/transformers/index.test.ts b/plugins/techdocs/src/reader/transformers/index.test.ts index 30607aedcd..16b42266df 100644 --- a/plugins/techdocs/src/reader/transformers/index.test.ts +++ b/plugins/techdocs/src/reader/transformers/index.test.ts @@ -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('', [mockTransformer()]); + await transform('', [mockTransformer()]); expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledWith(expect.any(Element)); diff --git a/plugins/techdocs/src/reader/transformers/injectCss.test.ts b/plugins/techdocs/src/reader/transformers/injectCss.test.ts index 368d077b43..6d0eb8daa9 100644 --- a/plugins/techdocs/src/reader/transformers/injectCss.test.ts +++ b/plugins/techdocs/src/reader/transformers/injectCss.test.ts @@ -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 = ` @@ -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: [], }); diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index 3174e23f5c..b2a5aa9760 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -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 = Promise.resolve( - 'https://techdocs-mock-sites.storage.googleapis.com', -); +const docStorageUrl: string = + 'https://techdocs-mock-sites.storage.googleapis.com'; const fixture = ` @@ -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({ diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.ts b/plugins/techdocs/src/reader/transformers/onCssReady.ts index 936c4306a7..e9406ba632 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.ts @@ -17,7 +17,7 @@ import type { Transformer } from './transformer'; type OnCssReadyOptions = { - docStorageUrl: Promise; + 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; diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts index 70d6b2fa4a..60f0b9ca30 100644 --- a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts @@ -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(); }); diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts index 62b80cd514..66fc5f29c1 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -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(` Test Test Test @@ -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( ` Test Test @@ -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( `${expectedText}`, { preTransformers: [rewriteDocLinks()], diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts index 8456cf7c23..6296793e2a 100644 --- a/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.test.ts @@ -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 = ` - `; - 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 = ` @@ -94,7 +103,7 @@ describe('sanitizeDOM', () => { `; - const shadowDom = createTestShadowDom(html, { + const shadowDom = await createTestShadowDom(html, { preTransformers: [sanitizeDOM()], postTransformers: [], }); diff --git a/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts index dbc3761e80..020befd522 100644 --- a/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts +++ b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts @@ -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(); }); diff --git a/plugins/techdocs/src/reader/transformers/transformer.ts b/plugins/techdocs/src/reader/transformers/transformer.ts index 7b440befbf..fc52f42b5c 100644 --- a/plugins/techdocs/src/reader/transformers/transformer.ts +++ b/plugins/techdocs/src/reader/transformers/transformer.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -export type Transformer = (dom: Element) => Element; +export type Transformer = (dom: Element) => Element | Promise; -export const transform = ( +export const transform = async ( html: string | Element, transformers: Transformer[], -): Element => { +): Promise => { 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; }; diff --git a/plugins/techdocs/src/test-utils/shadowDom.ts b/plugins/techdocs/src/test-utils/shadowDom.ts index 61055a87bd..f8289bf6d9 100644 --- a/plugins/techdocs/src/test-utils/shadowDom.ts +++ b/plugins/techdocs/src/test-utils/shadowDom.ts @@ -22,13 +22,13 @@ export type CreateTestShadowDomOptions = { postTransformers: Transformer[]; }; -export const createTestShadowDom = ( +export const createTestShadowDom = async ( fixture: string, opts: CreateTestShadowDomOptions = { preTransformers: [], postTransformers: [], }, -): ShadowRoot => { +): Promise => { 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!;