From 2da34337a6e9d10939b40efb29b964e3a787c96a Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Sat, 11 Jun 2022 17:35:57 +0800 Subject: [PATCH 1/2] feat: report orphan pages --- packages/docusaurus-types/src/index.d.ts | 4 ++ packages/docusaurus/src/commands/build.ts | 15 +----- packages/docusaurus/src/server/brokenLinks.ts | 46 +++++++++++++++---- .../docusaurus/src/server/configValidation.ts | 6 +++ website/docusaurus.config.js | 4 ++ 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 2cae26753d5b..a629167ad996 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -146,6 +146,10 @@ export type DocusaurusConfig = { * @default false */ noIndex: boolean; + orphanPages?: { + onOrphanPage: ReportingSeverity; + entryPoints: string[]; + }; /** * The behavior of Docusaurus when it detects any broken link. * diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index d13d7039fec5..fe3dde5a2aae 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -132,12 +132,7 @@ async function buildLocale({ outDir, generatedFilesDir, plugins, - siteConfig: { - baseUrl, - onBrokenLinks, - staticDirectories: staticDirectoriesOption, - }, - routes, + siteConfig: {staticDirectories: staticDirectoriesOption}, } = props; const clientManifestPath = path.join( @@ -264,13 +259,7 @@ async function buildLocale({ }), ); - await handleBrokenLinks({ - allCollectedLinks, - routes, - onBrokenLinks, - outDir, - baseUrl, - }); + await handleBrokenLinks({allCollectedLinks, props}); logger.success`Generated static files in path=${path.relative( process.cwd(), diff --git a/packages/docusaurus/src/server/brokenLinks.ts b/packages/docusaurus/src/server/brokenLinks.ts index 22f466772de5..3a0ce7eb2bf0 100644 --- a/packages/docusaurus/src/server/brokenLinks.ts +++ b/packages/docusaurus/src/server/brokenLinks.ts @@ -18,7 +18,7 @@ import { resolvePathname, } from '@docusaurus/utils'; import {getAllFinalRoutes} from './utils'; -import type {RouteConfig, ReportingSeverity} from '@docusaurus/types'; +import type {RouteConfig, Props, DocusaurusConfig} from '@docusaurus/types'; type BrokenLink = { link: string; @@ -214,19 +214,47 @@ async function filterExistingFileLinks({ ); } +function findOrphanLinks({ + allCollectedLinks, + orphanPages, +}: { + allCollectedLinks: {[location: string]: string[]}; + orphanPages: DocusaurusConfig['orphanPages']; +}) { + if (!orphanPages || orphanPages.onOrphanPage === 'ignore') { + return; + } + const visited = new Set(); + function dfs(link: string) { + if (visited.has(link)) { + return; + } + visited.add(link); + allCollectedLinks[link]?.forEach((l) => dfs(resolvePathname(l, link))); + } + orphanPages.entryPoints.forEach(dfs); + const orphaned = new Set(Object.keys(allCollectedLinks)); + visited.forEach((l) => orphaned.delete(l)); + reportMessage( + logger.interpolate`Orphan pages found: url=${Array.from(orphaned)}`, + orphanPages.onOrphanPage, + ); +} + export async function handleBrokenLinks({ allCollectedLinks, - onBrokenLinks, - routes, - baseUrl, - outDir, + props: { + routes, + baseUrl, + outDir, + siteConfig: {onBrokenLinks, orphanPages}, + }, }: { allCollectedLinks: {[location: string]: string[]}; - onBrokenLinks: ReportingSeverity; - routes: RouteConfig[]; - baseUrl: string; - outDir: string; + props: Props; }): Promise { + findOrphanLinks({allCollectedLinks, orphanPages}); + if (onBrokenLinks === 'ignore') { return; } diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 4027f84ca4d9..d83d873da85a 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -227,6 +227,12 @@ export const ConfigSchema = Joi.object({ clientModules: Joi.array() .items(Joi.string()) .default(DEFAULT_CONFIG.clientModules), + orphanPages: Joi.object({ + onOrphanPage: Joi.string() + .equal('ignore', 'log', 'warn', 'error', 'throw') + .default('warn'), + entryPoints: Joi.array().items(Joi.string()).default([]), + }), tagline: Joi.string().allow('').default(DEFAULT_CONFIG.tagline), titleDelimiter: Joi.string().default(DEFAULT_CONFIG.titleDelimiter), noIndex: Joi.bool().default(DEFAULT_CONFIG.noIndex), diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 6885a87f8667..c8392eef67e3 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -117,6 +117,10 @@ const config = { description: 'An optimized site generator in React. Docusaurus helps you to move fast and write content. Build documentation websites, blogs, marketing pages, and more.', }, + orphanPages: { + onOrphanPage: 'warn', + entryPoints: ['/', '/tests'], + }, staticDirectories: [ 'static', path.join(__dirname, '_dogfooding/_asset-tests'), From ef937e402c4af8eb412567e51b2df59ebed4a940 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Sat, 11 Jun 2022 17:43:17 +0800 Subject: [PATCH 2/2] fix test --- .../src/server/__tests__/brokenLinks.test.ts | 50 ++++++++++++------- packages/docusaurus/src/server/brokenLinks.ts | 27 +++++----- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts b/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts index 1226142f972a..341953c63592 100644 --- a/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts +++ b/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts @@ -9,7 +9,7 @@ import {jest} from '@jest/globals'; import path from 'path'; import _ from 'lodash'; import {handleBrokenLinks} from '../brokenLinks'; -import type {RouteConfig} from '@docusaurus/types'; +import type {DocusaurusConfig, Props, RouteConfig} from '@docusaurus/types'; describe('handleBrokenLinks', () => { const routes: RouteConfig[] = [ @@ -136,10 +136,14 @@ describe('handleBrokenLinks', () => { }; await handleBrokenLinks({ allCollectedLinks: allCollectedCorrectLinks, - onBrokenLinks: 'error', - routes, - baseUrl: '/', - outDir, + props: { + routes, + baseUrl: '/', + outDir, + siteConfig: { + onBrokenLinks: 'error', + } as DocusaurusConfig, + } as Props, }); expect(consoleMock).toBeCalledTimes(0); }); @@ -148,10 +152,14 @@ describe('handleBrokenLinks', () => { await expect(() => handleBrokenLinks({ allCollectedLinks, - onBrokenLinks: 'throw', - routes, - baseUrl: '/', - outDir, + props: { + routes, + baseUrl: '/', + outDir, + siteConfig: { + onBrokenLinks: 'throw', + } as DocusaurusConfig, + } as Props, }), ).rejects.toThrowErrorMatchingSnapshot(); }); @@ -162,10 +170,14 @@ describe('handleBrokenLinks', () => { const lodashMock = jest.spyOn(_, 'mapValues'); await handleBrokenLinks({ allCollectedLinks, - onBrokenLinks: 'ignore', - routes, - baseUrl: '/', - outDir, + props: { + routes, + baseUrl: '/', + outDir, + siteConfig: { + onBrokenLinks: 'ignore', + } as DocusaurusConfig, + } as Props, }); expect(lodashMock).toBeCalledTimes(0); lodashMock.mockRestore(); @@ -185,10 +197,14 @@ describe('handleBrokenLinks', () => { await expect(() => handleBrokenLinks({ allCollectedLinks, - onBrokenLinks: 'throw', - routes, - baseUrl: '/', - outDir, + props: { + routes, + baseUrl: '/', + outDir, + siteConfig: { + onBrokenLinks: 'throw', + } as DocusaurusConfig, + } as Props, }), ).rejects.toThrowErrorMatchingSnapshot(); }); diff --git a/packages/docusaurus/src/server/brokenLinks.ts b/packages/docusaurus/src/server/brokenLinks.ts index 3a0ce7eb2bf0..113e449e01a7 100644 --- a/packages/docusaurus/src/server/brokenLinks.ts +++ b/packages/docusaurus/src/server/brokenLinks.ts @@ -52,8 +52,7 @@ function getPageBrokenLinks({ // @ts-expect-error: React router types RouteConfig with an actual React // component, but we load route components with string paths. // We don't actually access component here, so it's fine. - .map((l) => matchRoutes(routes, l)) - .flat(); + .flatMap((l) => matchRoutes(routes, l)); return matchedRoutes.length === 0; } @@ -78,10 +77,8 @@ function getAllBrokenLinks({ allCollectedLinks: {[location: string]: string[]}; routes: RouteConfig[]; }): {[location: string]: BrokenLink[]} { - const filteredRoutes = filterIntermediateRoutes(routes); - const allBrokenLinks = _.mapValues(allCollectedLinks, (pageLinks, pagePath) => - getPageBrokenLinks({pageLinks, pagePath, routes: filteredRoutes}), + getPageBrokenLinks({pageLinks, pagePath, routes}), ); return _.pickBy(allBrokenLinks, (brokenLinks) => brokenLinks.length > 0); @@ -217,24 +214,29 @@ async function filterExistingFileLinks({ function findOrphanLinks({ allCollectedLinks, orphanPages, + routes, }: { allCollectedLinks: {[location: string]: string[]}; orphanPages: DocusaurusConfig['orphanPages']; + routes: RouteConfig[]; }) { if (!orphanPages || orphanPages.onOrphanPage === 'ignore') { return; } const visited = new Set(); function dfs(link: string) { - if (visited.has(link)) { + // @ts-expect-error: see comment above + const normalLink = matchRoutes(routes, link)[0]?.match.path; + if (!normalLink || visited.has(normalLink)) { return; } - visited.add(link); - allCollectedLinks[link]?.forEach((l) => dfs(resolvePathname(l, link))); + visited.add(normalLink); + allCollectedLinks[normalLink]?.forEach((l) => + dfs(resolvePathname(l, link)), + ); } orphanPages.entryPoints.forEach(dfs); - const orphaned = new Set(Object.keys(allCollectedLinks)); - visited.forEach((l) => orphaned.delete(l)); + const orphaned = routes.map((r) => r.path).filter((l) => !visited.has(l)); reportMessage( logger.interpolate`Orphan pages found: url=${Array.from(orphaned)}`, orphanPages.onOrphanPage, @@ -244,7 +246,7 @@ function findOrphanLinks({ export async function handleBrokenLinks({ allCollectedLinks, props: { - routes, + routes: allRoutes, baseUrl, outDir, siteConfig: {onBrokenLinks, orphanPages}, @@ -253,7 +255,8 @@ export async function handleBrokenLinks({ allCollectedLinks: {[location: string]: string[]}; props: Props; }): Promise { - findOrphanLinks({allCollectedLinks, orphanPages}); + const routes = filterIntermediateRoutes(allRoutes); + findOrphanLinks({allCollectedLinks, orphanPages, routes}); if (onBrokenLinks === 'ignore') { return;