Skip to content

Commit 760a5ae

Browse files
OzakIOneslorber
andcommitted
feat(core): make broken link checker detect broken anchors - add onBrokenAnchors config (#9528)
Co-authored-by: sebastienlorber <[email protected]>
1 parent 6d1897d commit 760a5ae

File tree

52 files changed

+1221
-520
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1221
-520
lines changed

packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap

+16-16
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ exports[`transformAsset plugin pathname protocol 1`] = `
1212
exports[`transformAsset plugin transform md links to <a /> 1`] = `
1313
"[asset](https://example.com/asset.pdf)
1414
15-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} />
15+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} />
1616
17-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
17+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
1818
19-
in paragraph <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
19+
in paragraph <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
2020
21-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars</a>
21+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars</a>
2222
23-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash</a>
23+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash</a>
2424
25-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset</a>
25+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset</a>
2626
2727
[page](noUrl.md)
2828
@@ -36,24 +36,24 @@ in paragraph <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file
3636
3737
[assets](/github/!file-loader!/assets.pdf)
3838
39-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
39+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
4040
41-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2</a>
41+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2</a>
4242
43-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf</a>
43+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf</a>
4444
45-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf</a>
45+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf</a>
4646
47-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default + '#page=2'} title="Title">@site/static/staticAsset.pdf</a>
47+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default + '#page=2'} title="Title">@site/static/staticAsset.pdf</a>
4848
49-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf</a>, and <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>**awesome** staticAsset 2.pdf 'It is really "AWESOME"'</a>, but also <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\`</a>
49+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf</a>, and <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>**awesome** staticAsset 2.pdf 'It is really "AWESOME"'</a>, but also <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\`</a>
5050
51-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}><img alt="Clickable Docusaurus logo" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /></a>
51+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}><img alt="Clickable Docusaurus logo" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /></a>
5252
53-
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}><span style={{color: "red"}}>Stylized link to asset file</span></a>
53+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}><span style={{color: "red"}}>Stylized link to asset file</span></a>
5454
55-
<a target="_blank" href={require("./data.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON</a>
55+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("./data.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON</a>
5656
57-
<a target="_blank" href={require("./static/static-json.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON</a>
57+
<a target="_blank" data-noBrokenLinkCheck={true} href={require("./static/static-json.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON</a>
5858
"
5959
`;

packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts

+28
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@ async function toAssetRequireNode(
7373
value: '_blank',
7474
});
7575

76+
// Assets are not routes, and are required by Webpack already
77+
// They should not trigger the broken link checker
78+
attributes.push({
79+
type: 'mdxJsxAttribute',
80+
name: 'data-noBrokenLinkCheck',
81+
value: {
82+
type: 'mdxJsxAttributeValueExpression',
83+
value: 'true',
84+
data: {
85+
estree: {
86+
type: 'Program',
87+
body: [
88+
{
89+
type: 'ExpressionStatement',
90+
expression: {
91+
type: 'Literal',
92+
value: true,
93+
raw: 'true',
94+
},
95+
},
96+
],
97+
sourceType: 'module',
98+
comments: [],
99+
},
100+
},
101+
},
102+
});
103+
76104
attributes.push({
77105
type: 'mdxJsxAttribute',
78106
name: 'href',

packages/docusaurus-module-type-aliases/src/index.d.ts

+9
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,15 @@ declare module '@docusaurus/useRouteContext' {
260260
export default function useRouteContext(): PluginRouteContext;
261261
}
262262

263+
declare module '@docusaurus/useBrokenLinks' {
264+
export type BrokenLinks = {
265+
collectLink: (link: string) => void;
266+
collectAnchor: (anchor: string) => void;
267+
};
268+
269+
export default function useBrokenLinks(): BrokenLinks;
270+
}
271+
263272
declare module '@docusaurus/useIsBrowser' {
264273
export default function useIsBrowser(): boolean;
265274
}

packages/docusaurus-theme-classic/src/theme/Heading/index.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import clsx from 'clsx';
1010
import {translate} from '@docusaurus/Translate';
1111
import {useThemeConfig} from '@docusaurus/theme-common';
1212
import Link from '@docusaurus/Link';
13+
import useBrokenLinks from '@docusaurus/useBrokenLinks';
1314
import type {Props} from '@theme/Heading';
1415

1516
import styles from './styles.module.css';
1617

1718
export default function Heading({as: As, id, ...props}: Props): JSX.Element {
19+
const brokenLinks = useBrokenLinks();
1820
const {
1921
navbar: {hideOnScroll},
2022
} = useThemeConfig();
@@ -23,6 +25,8 @@ export default function Heading({as: As, id, ...props}: Props): JSX.Element {
2325
return <As {...props} id={undefined} />;
2426
}
2527

28+
brokenLinks.collectAnchor(id);
29+
2630
const anchorTitle = translate(
2731
{
2832
id: 'theme.common.headingLinkTitle',

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

+7
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ export type DocusaurusConfig = {
175175
* @default "throw"
176176
*/
177177
onBrokenLinks: ReportingSeverity;
178+
/**
179+
* The behavior of Docusaurus when it detects any broken link.
180+
*
181+
* @see https://docusaurus.io/docs/api/docusaurus-config#onBrokenAnchors
182+
* @default "warn"
183+
*/
184+
onBrokenAnchors: ReportingSeverity;
178185
/**
179186
* The behavior of Docusaurus when it detects any broken markdown link.
180187
*

packages/docusaurus-utils/src/__tests__/urlUtils.test.ts

+133
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
buildSshUrl,
1919
buildHttpsUrl,
2020
hasSSHProtocol,
21+
parseURLPath,
22+
serializeURLPath,
2123
} from '../urlUtils';
2224

2325
describe('normalizeUrl', () => {
@@ -232,6 +234,137 @@ describe('removeTrailingSlash', () => {
232234
});
233235
});
234236

237+
describe('parseURLPath', () => {
238+
it('parse and resolve pathname', () => {
239+
expect(parseURLPath('')).toEqual({
240+
pathname: '/',
241+
search: undefined,
242+
hash: undefined,
243+
});
244+
expect(parseURLPath('/')).toEqual({
245+
pathname: '/',
246+
search: undefined,
247+
hash: undefined,
248+
});
249+
expect(parseURLPath('/page')).toEqual({
250+
pathname: '/page',
251+
search: undefined,
252+
hash: undefined,
253+
});
254+
expect(parseURLPath('/dir1/page')).toEqual({
255+
pathname: '/dir1/page',
256+
search: undefined,
257+
hash: undefined,
258+
});
259+
expect(parseURLPath('/dir1/dir2/./../page')).toEqual({
260+
pathname: '/dir1/page',
261+
search: undefined,
262+
hash: undefined,
263+
});
264+
expect(parseURLPath('/dir1/dir2/../..')).toEqual({
265+
pathname: '/',
266+
search: undefined,
267+
hash: undefined,
268+
});
269+
expect(parseURLPath('/dir1/dir2/../../..')).toEqual({
270+
pathname: '/',
271+
search: undefined,
272+
hash: undefined,
273+
});
274+
expect(parseURLPath('./dir1/dir2./../page', '/dir3/dir4/page2')).toEqual({
275+
pathname: '/dir3/dir4/dir1/page',
276+
search: undefined,
277+
hash: undefined,
278+
});
279+
});
280+
281+
it('parse query string', () => {
282+
expect(parseURLPath('/page')).toEqual({
283+
pathname: '/page',
284+
search: undefined,
285+
hash: undefined,
286+
});
287+
expect(parseURLPath('/page?')).toEqual({
288+
pathname: '/page',
289+
search: '',
290+
hash: undefined,
291+
});
292+
expect(parseURLPath('/page?test')).toEqual({
293+
pathname: '/page',
294+
search: 'test',
295+
hash: undefined,
296+
});
297+
expect(parseURLPath('/page?age=42&great=true')).toEqual({
298+
pathname: '/page',
299+
search: 'age=42&great=true',
300+
hash: undefined,
301+
});
302+
});
303+
304+
it('parse hash', () => {
305+
expect(parseURLPath('/page')).toEqual({
306+
pathname: '/page',
307+
search: undefined,
308+
hash: undefined,
309+
});
310+
expect(parseURLPath('/page#')).toEqual({
311+
pathname: '/page',
312+
search: undefined,
313+
hash: '',
314+
});
315+
expect(parseURLPath('/page#anchor')).toEqual({
316+
pathname: '/page',
317+
search: undefined,
318+
hash: 'anchor',
319+
});
320+
});
321+
322+
it('parse fancy real-world edge cases', () => {
323+
expect(parseURLPath('/page?#')).toEqual({
324+
pathname: '/page',
325+
search: '',
326+
hash: '',
327+
});
328+
expect(
329+
parseURLPath('dir1/dir2/../page?age=42#anchor', '/dir3/page2'),
330+
).toEqual({
331+
pathname: '/dir3/dir1/page',
332+
search: 'age=42',
333+
hash: 'anchor',
334+
});
335+
});
336+
});
337+
338+
describe('serializeURLPath', () => {
339+
function test(input: string, base?: string, expectedOutput?: string) {
340+
expect(serializeURLPath(parseURLPath(input, base))).toEqual(
341+
expectedOutput ?? input,
342+
);
343+
}
344+
345+
it('works for already resolved paths', () => {
346+
test('/');
347+
test('/dir1/page');
348+
test('/dir1/page?');
349+
test('/dir1/page#');
350+
test('/dir1/page?#');
351+
test('/dir1/page?age=42#anchor');
352+
});
353+
354+
it('works for relative paths', () => {
355+
test('', undefined, '/');
356+
test('', '/dir1/dir2/page2', '/dir1/dir2/page2');
357+
test('page', '/dir1/dir2/page2', '/dir1/dir2/page');
358+
test('../page', '/dir1/dir2/page2', '/dir1/page');
359+
test('/dir1/dir2/../page', undefined, '/dir1/page');
360+
test(
361+
'/dir1/dir2/../page?age=42#anchor',
362+
undefined,
363+
'/dir1/page?age=42#anchor',
364+
);
365+
});
366+
});
367+
235368
describe('resolvePathname', () => {
236369
it('works', () => {
237370
// These tests are directly copied from https://github.com/mjackson/resolve-pathname/blob/master/modules/__tests__/resolvePathname-test.js

packages/docusaurus-utils/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@ export {
4848
encodePath,
4949
isValidPathname,
5050
resolvePathname,
51+
parseURLPath,
52+
serializeURLPath,
5153
addLeadingSlash,
5254
addTrailingSlash,
5355
removeTrailingSlash,
5456
hasSSHProtocol,
5557
buildHttpsUrl,
5658
buildSshUrl,
5759
} from './urlUtils';
60+
export type {URLPath} from './urlUtils';
5861
export {
5962
type Tag,
6063
type TagsListItem,

packages/docusaurus-utils/src/urlUtils.ts

+59
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,73 @@ export function isValidPathname(str: string): boolean {
165165
}
166166
}
167167

168+
export type URLPath = {pathname: string; search?: string; hash?: string};
169+
170+
// Let's name the concept of (pathname + search + hash) as URLPath
171+
// See also https://twitter.com/kettanaito/status/1741768992866308120
172+
// Note: this function also resolves relative pathnames while parsing!
173+
export function parseURLPath(urlPath: string, fromPath?: string): URLPath {
174+
function parseURL(url: string, base?: string | URL): URL {
175+
try {
176+
// A possible alternative? https://github.com/unjs/ufo#url
177+
return new URL(url, base ?? 'https://example.com');
178+
} catch (e) {
179+
throw new Error(
180+
`Can't parse URL ${url}${base ? ` with base ${base}` : ''}`,
181+
{cause: e},
182+
);
183+
}
184+
}
185+
186+
const base = fromPath ? parseURL(fromPath) : undefined;
187+
const url = parseURL(urlPath, base);
188+
189+
const {pathname} = url;
190+
191+
// Fixes annoying url.search behavior
192+
// "" => undefined
193+
// "?" => ""
194+
// "?param => "param"
195+
const search = url.search
196+
? url.search.slice(1)
197+
: urlPath.includes('?')
198+
? ''
199+
: undefined;
200+
201+
// Fixes annoying url.hash behavior
202+
// "" => undefined
203+
// "#" => ""
204+
// "?param => "param"
205+
const hash = url.hash
206+
? url.hash.slice(1)
207+
: urlPath.includes('#')
208+
? ''
209+
: undefined;
210+
211+
return {
212+
pathname,
213+
search,
214+
hash,
215+
};
216+
}
217+
218+
export function serializeURLPath(urlPath: URLPath): string {
219+
const search = urlPath.search === undefined ? '' : `?${urlPath.search}`;
220+
const hash = urlPath.hash === undefined ? '' : `#${urlPath.hash}`;
221+
return `${urlPath.pathname}${search}${hash}`;
222+
}
223+
168224
/**
169225
* Resolve pathnames and fail-fast if resolution fails. Uses standard URL
170226
* semantics (provided by `resolve-pathname` which is used internally by React
171227
* router)
172228
*/
173229
export function resolvePathname(to: string, from?: string): string {
230+
// TODO do we really need resolve-pathname lib anymore?
231+
// possible alternative: decodeURI(parseURLPath(to, from).pathname);
174232
return resolvePathnameUnsafe(to, from);
175233
}
234+
176235
/** Appends a leading slash to `str`, if one doesn't exist. */
177236
export function addLeadingSlash(str: string): string {
178237
return addPrefix(str, '/');

0 commit comments

Comments
 (0)