Skip to content

Commit 12fcfd9

Browse files
committed
fix(core): broken links optimization behaves differently than non-optimized logic (#9791)
1 parent 37ceeeb commit 12fcfd9

File tree

3 files changed

+168
-10
lines changed

3 files changed

+168
-10
lines changed

packages/docusaurus-types/src/routing.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export type RouteConfig = {
6060
routes?: RouteConfig[];
6161
/** React router config option: `exact` routes would not match subroutes. */
6262
exact?: boolean;
63+
/**
64+
* React router config option: `strict` routes are sensitive to the presence
65+
* of a trailing slash.
66+
*/
67+
strict?: boolean;
6368
/** Used to sort routes. Higher-priority routes will be placed first. */
6469
priority?: number;
6570
/** Extra props; will be copied to routes.js. */

packages/docusaurus/src/server/__tests__/brokenLinks.test.ts

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import type {RouteConfig} from '@docusaurus/types';
1313
type Params = Parameters<typeof handleBrokenLinks>[0];
1414

1515
// We don't need all the routes attributes for our tests
16-
type SimpleRoute = {path: string; routes?: SimpleRoute[]};
16+
type SimpleRoute = {
17+
path: string;
18+
routes?: SimpleRoute[];
19+
exact?: boolean;
20+
strict?: boolean;
21+
};
1722

1823
// Conveniently apply defaults to function under test
1924
async function testBrokenLinks(params: {
@@ -43,6 +48,52 @@ describe('handleBrokenLinks', () => {
4348
});
4449
});
4550

51+
it('accepts valid non-exact link', async () => {
52+
await testBrokenLinks({
53+
routes: [{path: '/page1', exact: false}, {path: '/page2/'}],
54+
collectedLinks: {
55+
'/page1': {
56+
links: [
57+
'/page1',
58+
'/page1/',
59+
'/page2',
60+
'/page2/',
61+
'/page1/subPath',
62+
'/page2/subPath',
63+
],
64+
anchors: [],
65+
},
66+
'/page2/': {
67+
links: [
68+
'/page1',
69+
'/page1/',
70+
'/page2',
71+
'/page2/',
72+
'/page1/subPath',
73+
'/page2/subPath',
74+
],
75+
anchors: [],
76+
},
77+
},
78+
});
79+
});
80+
81+
it('accepts valid non-strict link', async () => {
82+
await testBrokenLinks({
83+
routes: [{path: '/page1', strict: false}, {path: '/page2/'}],
84+
collectedLinks: {
85+
'/page1': {
86+
links: ['/page1', '/page1/', '/page2', '/page2/'],
87+
anchors: [],
88+
},
89+
'/page2/': {
90+
links: ['/page1', '/page1/', '/page2', '/page2/'],
91+
anchors: [],
92+
},
93+
},
94+
});
95+
});
96+
4697
it('accepts valid link to uncollected page', async () => {
4798
await testBrokenLinks({
4899
routes: [{path: '/page1'}, {path: '/page2'}],
@@ -292,6 +343,76 @@ describe('handleBrokenLinks', () => {
292343
`);
293344
});
294345

346+
it('rejects broken link due to strict matching', async () => {
347+
await expect(() =>
348+
testBrokenLinks({
349+
routes: [
350+
{path: '/page1', strict: true},
351+
{path: '/page2/', strict: true},
352+
],
353+
354+
collectedLinks: {
355+
'/page1': {
356+
links: ['/page1', '/page1/', '/page2', '/page2/'],
357+
anchors: [],
358+
},
359+
'/page2/': {
360+
links: ['/page1', '/page1/', '/page2', '/page2/'],
361+
anchors: [],
362+
},
363+
},
364+
}),
365+
).rejects.toThrowErrorMatchingInlineSnapshot(`
366+
"Docusaurus found broken links!
367+
368+
Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist.
369+
Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass.
370+
371+
Exhaustive list of all broken links found:
372+
- Broken link on source page path = /page1:
373+
-> linking to /page2
374+
- Broken link on source page path = /page2/:
375+
-> linking to /page2
376+
"
377+
`);
378+
});
379+
380+
it('rejects broken link due to strict exact matching', async () => {
381+
await expect(() =>
382+
testBrokenLinks({
383+
routes: [
384+
{path: '/page1', exact: true, strict: true},
385+
{path: '/page2/', exact: true, strict: true},
386+
],
387+
388+
collectedLinks: {
389+
'/page1': {
390+
links: ['/page1', '/page1/', '/page2', '/page2/'],
391+
anchors: [],
392+
},
393+
'/page2/': {
394+
links: ['/page1', '/page1/', '/page2', '/page2/'],
395+
anchors: [],
396+
},
397+
},
398+
}),
399+
).rejects.toThrowErrorMatchingInlineSnapshot(`
400+
"Docusaurus found broken links!
401+
402+
Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist.
403+
Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass.
404+
405+
Exhaustive list of all broken links found:
406+
- Broken link on source page path = /page1:
407+
-> linking to /page1/
408+
-> linking to /page2
409+
- Broken link on source page path = /page2/:
410+
-> linking to /page1/
411+
-> linking to /page2
412+
"
413+
`);
414+
});
415+
295416
it('rejects broken link with anchor', async () => {
296417
await expect(() =>
297418
testBrokenLinks({
@@ -728,6 +849,10 @@ describe('handleBrokenLinks', () => {
728849
const routes: SimpleRoute[] = [
729850
...Array.from<SimpleRoute>({length: scale}).map((_, i) => ({
730851
path: `/page${i}`,
852+
exact: true,
853+
})),
854+
...Array.from<SimpleRoute>({length: scale}).map((_, i) => ({
855+
path: `/page/nonExact/${i}`,
731856
})),
732857
...Array.from<SimpleRoute>({length: scale}).fill({
733858
path: '/pageDynamic/:subpath1',
@@ -741,6 +866,7 @@ describe('handleBrokenLinks', () => {
741866
links: [
742867
...Array.from<SimpleRoute>({length: scale}).flatMap((_2, j) => [
743868
`/page${j}`,
869+
`/page/nonExact/${j}`,
744870
`/page${j}?age=42`,
745871
`/page${j}#anchor${j}`,
746872
`/page${j}?age=42#anchor${j}`,
@@ -770,9 +896,9 @@ describe('handleBrokenLinks', () => {
770896
// See https://github.com/facebook/docusaurus/issues/9754
771897
// See https://twitter.com/sebastienlorber/status/1749392773415858587
772898
// We expect no more matchRoutes calls than number of dynamic route links
773-
expect(matchRoutesMock).toHaveBeenCalledTimes(scale);
899+
expect(matchRoutesMock).toHaveBeenCalledTimes(scale * 2);
774900
// We expect matchRoutes to be called with a reduced number of routes
775-
expect(routes).toHaveLength(scale * 2);
776-
expect(matchRoutesMock.mock.calls[0]![0]).toHaveLength(scale);
901+
expect(routes).toHaveLength(scale * 3);
902+
expect(matchRoutesMock.mock.calls[0]![0]).toHaveLength(scale * 2);
777903
});
778904
});

packages/docusaurus/src/server/brokenLinks.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
import _ from 'lodash';
99
import logger from '@docusaurus/logger';
1010
import {matchRoutes as reactRouterMatchRoutes} from 'react-router-config';
11-
import {parseURLPath, serializeURLPath, type URLPath} from '@docusaurus/utils';
11+
import {
12+
addTrailingSlash,
13+
parseURLPath,
14+
removeTrailingSlash,
15+
serializeURLPath,
16+
type URLPath,
17+
} from '@docusaurus/utils';
1218
import {getAllFinalRoutes} from './utils';
1319
import type {RouteConfig, ReportingSeverity} from '@docusaurus/types';
1420

@@ -55,16 +61,37 @@ function createBrokenLinksHelper({
5561
}): BrokenLinksHelper {
5662
const validPathnames = new Set(collectedLinks.keys());
5763

64+
// IMPORTANT: this is an optimization
65+
// See https://github.com/facebook/docusaurus/issues/9754
5866
// Matching against the route array can be expensive
5967
// If the route is already in the valid pathnames,
60-
// we can avoid matching against it as an optimization
61-
const remainingRoutes = routes.filter(
62-
(route) => !validPathnames.has(route.path),
63-
);
68+
// we can avoid matching against it
69+
const remainingRoutes = (function filterRoutes() {
70+
// Goal: unit tests should behave the same with this enabled or disabled
71+
const disableOptimization = false;
72+
if (disableOptimization) {
73+
return routes;
74+
}
75+
// We must consider the "exact" and "strict" match attribute
76+
// We can only infer pre-validated pathnames from a route from exact routes
77+
const [validPathnameRoutes, otherRoutes] = _.partition(
78+
routes,
79+
(route) => route.exact && validPathnames.has(route.path),
80+
);
81+
// If a route is non-strict (non-sensitive to trailing slashes)
82+
// We must pre-validate all possible paths
83+
validPathnameRoutes.forEach((validPathnameRoute) => {
84+
if (!validPathnameRoute.strict) {
85+
validPathnames.add(addTrailingSlash(validPathnameRoute.path));
86+
validPathnames.add(removeTrailingSlash(validPathnameRoute.path));
87+
}
88+
});
89+
return otherRoutes;
90+
})();
6491

6592
function isPathnameMatchingAnyRoute(pathname: string): boolean {
6693
if (matchRoutes(remainingRoutes, pathname).length > 0) {
67-
// IMPORTANT: this is an optimization here
94+
// IMPORTANT: this is an optimization
6895
// See https://github.com/facebook/docusaurus/issues/9754
6996
// Large Docusaurus sites have many routes!
7097
// We try to minimize calls to a possibly expensive matchRoutes function

0 commit comments

Comments
 (0)