From 2ec40922952d119b2399654a7e0a95d003d2060d Mon Sep 17 00:00:00 2001 From: ozakione <29860391+OzakIOne@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:04:23 +0100 Subject: [PATCH] wip: refactor --- .../src/index.d.ts | 1 + .../src/theme/Heading/index.tsx | 7 +- packages/docusaurus-types/src/config.d.ts | 7 + .../docusaurus/src/client/serverEntry.tsx | 26 --- packages/docusaurus/src/commands/build.ts | 2 + .../src/server/__tests__/brokenLinks.test.ts | 165 +++++++++++------- packages/docusaurus/src/server/brokenLinks.ts | 59 ++++++- .../docusaurus/src/server/configValidation.ts | 5 + 8 files changed, 167 insertions(+), 105 deletions(-) diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index 0c44d0382bb9..64a4c3e8b012 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -269,6 +269,7 @@ declare module '@docusaurus/useAnchor' { getCollectedAnchors: () => string[]; }; + // useAnchorCollector export default function useAnchor(): [ AnchorsCollector, () => StatefulAnchorsCollector, diff --git a/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx b/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx index 38e42543abc6..fb2e6d1f69c9 100644 --- a/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx @@ -27,12 +27,11 @@ export default function Heading({as: As, id, ...props}: Props): JSX.Element { const list = createAnchorList(); + // ! should not be called 2 times, not a problem because we use + // Set but still must be removed anchorsCollector.collectAnchor(id); - // console.log('Heading id:'); - // console.log(id); + list.collectAnchor(id); - // console.log('Heading anchor list:'); - // console.log(list.getCollectedAnchors()); const anchorTitle = translate( { diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 3a7bb99ae743..e95c6768f10b 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -143,6 +143,13 @@ export type DocusaurusConfig = { * @default "throw" */ onBrokenLinks: ReportingSeverity; + /** + * The behavior of Docusaurus when it detects any broken link. + * + * @see // TODO + * @default "warn" + */ + onBrokenAnchors: ReportingSeverity; /** * The behavior of Docusaurus when it detects any broken markdown link. * diff --git a/packages/docusaurus/src/client/serverEntry.tsx b/packages/docusaurus/src/client/serverEntry.tsx index 1d2c60099c03..1778f464fbe6 100644 --- a/packages/docusaurus/src/client/serverEntry.tsx +++ b/packages/docusaurus/src/client/serverEntry.tsx @@ -126,32 +126,6 @@ async function doRender(locals: Locals & {path: string}) { anchorsCollector.getCollectedAnchors(), ); - // console.log('Collected anchors'); - // console.log(anchorsCollector.getCollectedAnchors()); - // fs.writeFile( - // 'anchors.json', - // JSON.stringify(anchorsCollector.getCollectedAnchors()), - // (err) => { - // if (err) { - // throw err; - // } - // console.log('Saved!'); - // }, - // ); - - // console.log('Collected links'); - // console.log(linksCollector.getCollectedLinks()); - // fs.writeFile( - // 'links.json', - // JSON.stringify(linksCollector.getCollectedLinks()), - // (err) => { - // if (err) { - // throw err; - // } - // console.log('Saved!'); - // }, - // ); - const {helmet} = helmetContext as FilledContext; const htmlAttributes = helmet.htmlAttributes.toString(); const bodyAttributes = helmet.bodyAttributes.toString(); diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index 8fce0b0c642f..e2fc00197a6a 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -154,6 +154,7 @@ async function buildLocale({ siteConfig: { baseUrl, onBrokenLinks, + onBrokenAnchors, staticDirectories: staticDirectoriesOption, }, routes, @@ -293,6 +294,7 @@ async function buildLocale({ allCollectedLinks, routes, onBrokenLinks, + onBrokenAnchors, outDir, baseUrl, }); diff --git a/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts b/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts index 40e76ed45b7a..af8cbbfe6e18 100644 --- a/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts +++ b/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts @@ -50,93 +50,121 @@ describe('handleBrokenLinks', () => { const linkToEmptyFolder1 = '/emptyFolder'; const linkToEmptyFolder2 = '/emptyFolder/'; const allCollectedLinks = { - '/docs/good doc with space': [ - // Good - valid file with spaces in name - './another%20good%20doc%20with%20space', - // Good - valid file with percent-20 in its name - './weird%20but%20good', - // Bad - non-existent file with spaces in name - './some%20other%20non-existent%20doc1', - // Evil - trying to use ../../ but '/' won't get decoded - // cSpell:ignore Fout - './break%2F..%2F..%2Fout2', - ], - '/docs/goodDoc': [ - // Good links - './anotherGoodDoc#someHash', - '/docs/anotherGoodDoc?someQueryString=true#someHash', - '../docs/anotherGoodDoc?someQueryString=true', - '../docs/anotherGoodDoc#someHash', - // Bad links - '../anotherGoodDoc#reported-because-of-bad-relative-path1', - './docThatDoesNotExist2', - './badRelativeLink3', - '../badRelativeLink4', - ], - '/community': [ - // Good links - '/docs/goodDoc', - '/docs/anotherGoodDoc#someHash', - './docs/goodDoc#someHash', - './docs/anotherGoodDoc', - // Bad links - '/someNonExistentDoc1', - '/badLink2', - './badLink3', - ], - '/page1': [ - link1, - linkToHtmlFile1, - linkToJavadoc1, - linkToHtmlFile2, - linkToJavadoc3, - linkToJavadoc4, - linkToEmptyFolder1, // Not filtered! - ], - '/page2': [ - link2, - linkToEmptyFolder2, // Not filtered! - linkToJavadoc2, - link3, - linkToJavadoc3, - linkToZipFile, - ], - }; - - const outDir = path.resolve(__dirname, '__fixtures__/brokenLinks/outDir'); - - it('do not report anything for correct paths', async () => { - const consoleMock = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const allCollectedCorrectLinks = { - '/docs/good doc with space': [ + '/docs/good doc with space': { + links: [ + // Good - valid file with spaces in name './another%20good%20doc%20with%20space', + // Good - valid file with percent-20 in its name './weird%20but%20good', + // Bad - non-existent file with spaces in name + './some%20other%20non-existent%20doc1', + // Evil - trying to use ../../ but '/' won't get decoded + // cSpell:ignore Fout + './break%2F..%2F..%2Fout2', ], - '/docs/goodDoc': [ + anchors: [], + }, + '/docs/goodDoc': { + links: [ + // Good links './anotherGoodDoc#someHash', '/docs/anotherGoodDoc?someQueryString=true#someHash', '../docs/anotherGoodDoc?someQueryString=true', '../docs/anotherGoodDoc#someHash', + // Bad links + '../anotherGoodDoc#reported-because-of-bad-relative-path1', + './docThatDoesNotExist2', + './badRelativeLink3', + '../badRelativeLink4', ], - '/community': [ + anchors: [], + }, + '/community': { + links: [ + // Good links '/docs/goodDoc', '/docs/anotherGoodDoc#someHash', './docs/goodDoc#someHash', './docs/anotherGoodDoc', + // Bad links + '/someNonExistentDoc1', + '/badLink2', + './badLink3', ], - '/page1': [ + anchors: [], + }, + '/page1': { + links: [ + link1, linkToHtmlFile1, linkToJavadoc1, linkToHtmlFile2, linkToJavadoc3, linkToJavadoc4, + linkToEmptyFolder1, // Not filtered! + ], + anchors: [], + }, + '/page2': { + links: [ + link2, + linkToEmptyFolder2, // Not filtered! + linkToJavadoc2, + link3, + linkToJavadoc3, + linkToZipFile, ], + anchors: [], + }, + }; + + const outDir = path.resolve(__dirname, '__fixtures__/brokenLinks/outDir'); + + it('do not report anything for correct paths', async () => { + const consoleMock = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const allCollectedCorrectLinks = { + '/docs/good doc with space': { + links: [ + './another%20good%20doc%20with%20space', + './weird%20but%20good', + ], + anchors: [], + }, + '/docs/goodDoc': { + links: [ + './anotherGoodDoc#someHash', + '/docs/anotherGoodDoc?someQueryString=true#someHash', + '../docs/anotherGoodDoc?someQueryString=true', + '../docs/anotherGoodDoc#someHash', + ], + anchors: ['someHash'], + }, + '/community': { + links: [ + '/docs/goodDoc', + '/docs/anotherGoodDoc#someHash', + './docs/goodDoc#someHash', + './docs/anotherGoodDoc', + ], + anchors: [], + }, + '/page1': { + links: [ + linkToHtmlFile1, + linkToJavadoc1, + linkToHtmlFile2, + linkToJavadoc3, + linkToJavadoc4, + ], + anchors: [], + }, }; await handleBrokenLinks({ allCollectedLinks: allCollectedCorrectLinks, onBrokenLinks: 'warn', + onBrokenAnchors: 'throw', routes, baseUrl: '/', outDir, @@ -149,6 +177,7 @@ describe('handleBrokenLinks', () => { handleBrokenLinks({ allCollectedLinks, onBrokenLinks: 'throw', + onBrokenAnchors: 'throw', routes, baseUrl: '/', outDir, @@ -163,6 +192,7 @@ describe('handleBrokenLinks', () => { await handleBrokenLinks({ allCollectedLinks, onBrokenLinks: 'ignore', + onBrokenAnchors: 'throw', routes, baseUrl: '/', outDir, @@ -172,20 +202,21 @@ describe('handleBrokenLinks', () => { }); it('reports frequent broken links', async () => { - Object.values(allCollectedLinks).forEach((links) => + Object.values(allCollectedLinks).forEach(({links}) => { links.push( '/frequent', // This is in the gray area of what should be reported. Relative paths // may be resolved to different slugs on different locations. But if // this comes from a layout link, it should be reported anyways './maybe-not', - ), - ); + ); + }); await expect(() => handleBrokenLinks({ allCollectedLinks, onBrokenLinks: 'throw', + onBrokenAnchors: 'throw', routes, baseUrl: '/', outDir, diff --git a/packages/docusaurus/src/server/brokenLinks.ts b/packages/docusaurus/src/server/brokenLinks.ts index 7605bb43168a..4745c517f516 100644 --- a/packages/docusaurus/src/server/brokenLinks.ts +++ b/packages/docusaurus/src/server/brokenLinks.ts @@ -29,10 +29,12 @@ function onlyPathname(link: string) { function getPageBrokenLinks({ pagePath, pageLinks, + pageAnchors, routes, }: { pagePath: string; pageLinks: string[]; + pageAnchors: string[]; routes: RouteConfig[]; }): BrokenLink[] { // ReactRouter is able to support links like ./../somePath but `matchRoutes` @@ -40,11 +42,10 @@ function getPageBrokenLinks({ // using `matchRoutes`. `resolvePathname` is used internally by React Router function resolveLink(link: string) { const resolvedLink = resolvePathname(onlyPathname(link), pagePath); - // TODO change anchor value - return {link, resolvedLink, anchor: false}; + return resolvedLink; } - function isBrokenLink(link: string) { + function isPathBrokenLink(link: string) { const matchedRoutes = [link, decodeURI(link)] // @ts-expect-error: React router types RouteConfig with an actual React // component, but we load route components with string paths. @@ -54,7 +55,25 @@ function getPageBrokenLinks({ return matchedRoutes.length === 0; } - return pageLinks.map(resolveLink).filter((l) => isBrokenLink(l.resolvedLink)); + function isAnchorBrokenLink(link: string) { + console.log('link', link); + console.log('pageAnchors', pageAnchors); + const urlHash = link.split('#')[1] ?? ''; + + return !pageAnchors.includes(urlHash); + } + + const brokenLinks = pageLinks.flatMap((pageLink) => { + const resolvedLink = resolveLink(pageLink); + if (isPathBrokenLink(resolvedLink)) { + return [{link: pageLink, resolvedLink, anchor: false}]; + } + if (isAnchorBrokenLink(pageLink)) { + return [{link: pageLink, resolvedLink, anchor: true}]; + } + return []; + }); + return brokenLinks; } /** @@ -82,6 +101,7 @@ function getAllBrokenLinks({ (pageCollectedData, pagePath) => getPageBrokenLinks({ pageLinks: pageCollectedData.links, + pageAnchors: pageCollectedData.anchors, pagePath, routes: filteredRoutes, }), @@ -108,11 +128,28 @@ function getBrokenLinksErrorMessage(allBrokenLinks: { pagePath: string, brokenLinks: BrokenLink[], ): string { - return ` -- On source page path = ${pagePath}: - -> linking to ${brokenLinks + const [pathBrokenLinks, anchorBrokenLinks] = _.partition( + brokenLinks, + 'anchor', + ); + + const pathMessage = + pathBrokenLinks.length > 0 + ? `- On source page path = ${pagePath}: + -> linking to ${pathBrokenLinks + .map(brokenLinkMessage) + .join('\n -> linking to ')}` + : ''; + + const anchorMessage = + anchorBrokenLinks.length > 0 + ? `- Anchor On source page path = ${pagePath}: + -> linking to ${anchorBrokenLinks .map(brokenLinkMessage) - .join('\n -> linking to ')}`; + .join('\n -> linking to ')}` + : ''; + + return `${pathMessage}${anchorMessage}`; } /** @@ -165,6 +202,7 @@ ${Object.entries(allBrokenLinks) export async function handleBrokenLinks(params: { allCollectedLinks: {[location: string]: {links: string[]; anchors: string[]}}; onBrokenLinks: ReportingSeverity; + onBrokenAnchors: ReportingSeverity; routes: RouteConfig[]; baseUrl: string; outDir: string; @@ -175,12 +213,14 @@ export async function handleBrokenLinks(params: { async function handlePathBrokenLinks({ allCollectedLinks, onBrokenLinks, + onBrokenAnchors, routes, baseUrl, outDir, }: { allCollectedLinks: {[location: string]: {links: string[]; anchors: string[]}}; onBrokenLinks: ReportingSeverity; + onBrokenAnchors: ReportingSeverity; routes: RouteConfig[]; baseUrl: string; outDir: string; @@ -188,6 +228,9 @@ async function handlePathBrokenLinks({ if (onBrokenLinks === 'ignore') { return; } + if (onBrokenAnchors === 'ignore') { + return; + } const allBrokenLinks = getAllBrokenLinks({ allCollectedLinks, diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 3f9de2ce6807..99ade2ffa59c 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -28,6 +28,7 @@ export const DEFAULT_CONFIG: Pick< DocusaurusConfig, | 'i18n' | 'onBrokenLinks' + | 'onBrokenAnchors' | 'onBrokenMarkdownLinks' | 'onDuplicateRoutes' | 'plugins' @@ -48,6 +49,7 @@ export const DEFAULT_CONFIG: Pick< > = { i18n: DEFAULT_I18N_CONFIG, onBrokenLinks: 'throw', + onBrokenAnchors: 'warn', onBrokenMarkdownLinks: 'warn', onDuplicateRoutes: 'warn', plugins: [], @@ -202,6 +204,9 @@ export const ConfigSchema = Joi.object({ onBrokenLinks: Joi.string() .equal('ignore', 'log', 'warn', 'throw') .default(DEFAULT_CONFIG.onBrokenLinks), + onBrokenAnchors: Joi.string() + .equal('ignore', 'log', 'warn', 'throw') + .default(DEFAULT_CONFIG.onBrokenAnchors), onBrokenMarkdownLinks: Joi.string() .equal('ignore', 'log', 'warn', 'throw') .default(DEFAULT_CONFIG.onBrokenMarkdownLinks),