Skip to content

Commit 4388267

Browse files
authored
fix(core): various broken anchor link fixes (#9732)
1 parent d94adf6 commit 4388267

File tree

63 files changed

+345
-115
lines changed

Some content is hidden

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

63 files changed

+345
-115
lines changed

.eslintrc.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ module.exports = {
203203
})),
204204
],
205205
'no-template-curly-in-string': WARNING,
206-
'no-unused-expressions': [WARNING, {allowTaggedTemplates: true}],
206+
'no-unused-expressions': [
207+
WARNING,
208+
{allowTaggedTemplates: true, allowShortCircuit: true},
209+
],
207210
'no-useless-escape': WARNING,
208211
'no-void': [ERROR, {allowAsStatement: true}],
209212
'prefer-destructuring': WARNING,

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,8 @@ declare module '@docusaurus/useRouteContext' {
262262

263263
declare module '@docusaurus/useBrokenLinks' {
264264
export type BrokenLinks = {
265-
collectLink: (link: string) => void;
266-
collectAnchor: (anchor: string) => void;
265+
collectLink: (link: string | undefined) => void;
266+
collectAnchor: (anchor: string | undefined) => void;
267267
};
268268

269269
export default function useBrokenLinks(): BrokenLinks;

packages/docusaurus-theme-classic/src/theme-classic.d.ts

+8
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,14 @@ declare module '@theme/MDXComponents/Ul' {
867867
export default function MDXUl(props: Props): JSX.Element;
868868
}
869869

870+
declare module '@theme/MDXComponents/Li' {
871+
import type {ComponentProps} from 'react';
872+
873+
export interface Props extends ComponentProps<'li'> {}
874+
875+
export default function MDXLi(props: Props): JSX.Element;
876+
}
877+
870878
declare module '@theme/MDXComponents/Img' {
871879
import type {ComponentProps} from 'react';
872880

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import React, {type ReactNode} from 'react';
9+
import useBrokenLinks from '@docusaurus/useBrokenLinks';
10+
import type {Props} from '@theme/MDXComponents/Li';
11+
12+
export default function MDXLi(props: Props): ReactNode | undefined {
13+
// MDX Footnotes have ids such as <li id="user-content-fn-1-953011">
14+
useBrokenLinks().collectAnchor(props.id);
15+
16+
return <li {...props} />;
17+
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import MDXPre from '@theme/MDXComponents/Pre';
1313
import MDXDetails from '@theme/MDXComponents/Details';
1414
import MDXHeading from '@theme/MDXComponents/Heading';
1515
import MDXUl from '@theme/MDXComponents/Ul';
16+
import MDXLi from '@theme/MDXComponents/Li';
1617
import MDXImg from '@theme/MDXComponents/Img';
1718
import Admonition from '@theme/Admonition';
1819
import Mermaid from '@theme/Mermaid';
@@ -27,6 +28,7 @@ const MDXComponents: MDXComponentsObject = {
2728
a: MDXA,
2829
pre: MDXPre,
2930
ul: MDXUl,
31+
li: MDXLi,
3032
img: MDXImg,
3133
h1: (props: ComponentProps<'h1'>) => <MDXHeading as="h1" {...props} />,
3234
h2: (props: ComponentProps<'h2'>) => <MDXHeading as="h2" {...props} />,

packages/docusaurus-theme-classic/src/theme/NavbarItem/DropdownNavbarItem/index.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ function DropdownNavbarItemDesktop({
8989
aria-haspopup="true"
9090
aria-expanded={showDropdown}
9191
role="button"
92+
// # hash permits to make the <a> tag focusable in case no link target
93+
// See https://github.com/facebook/docusaurus/pull/6003
94+
// There's probably a better solution though...
9295
href={props.to ? undefined : '#'}
9396
className={clsx('navbar__link', className)}
9497
{...props}

packages/docusaurus-theme-common/src/components/Details/index.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React, {
1212
type ReactElement,
1313
} from 'react';
1414
import clsx from 'clsx';
15+
import useBrokenLinks from '@docusaurus/useBrokenLinks';
1516
import useIsBrowser from '@docusaurus/useIsBrowser';
1617
import {useCollapsible, Collapsible} from '../Collapsible';
1718
import styles from './styles.module.css';
@@ -47,6 +48,8 @@ export function Details({
4748
children,
4849
...props
4950
}: DetailsProps): JSX.Element {
51+
useBrokenLinks().collectAnchor(props.id);
52+
5053
const isBrowser = useIsBrowser();
5154
const detailsRef = useRef<HTMLDetailsElement>(null);
5255

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

+23
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,29 @@ describe('parseURLPath', () => {
301301
});
302302
});
303303

304+
it('parse anchor', () => {
305+
expect(parseURLPath('#anchor')).toEqual({
306+
pathname: '/',
307+
search: undefined,
308+
hash: 'anchor',
309+
});
310+
expect(parseURLPath('#anchor', '/page')).toEqual({
311+
pathname: '/page',
312+
search: undefined,
313+
hash: 'anchor',
314+
});
315+
expect(parseURLPath('#')).toEqual({
316+
pathname: '/',
317+
search: undefined,
318+
hash: '',
319+
});
320+
expect(parseURLPath('#', '/page')).toEqual({
321+
pathname: '/page',
322+
search: undefined,
323+
hash: '',
324+
});
325+
});
326+
304327
it('parse hash', () => {
305328
expect(parseURLPath('/page')).toEqual({
306329
pathname: '/page',

packages/docusaurus/src/client/BrokenLinksContext.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ export const createStatefulBrokenLinks = (): StatefulBrokenLinks => {
1818
const allAnchors = new Set<string>();
1919
const allLinks = new Set<string>();
2020
return {
21-
collectAnchor: (anchor: string): void => {
22-
allAnchors.add(anchor);
21+
collectAnchor: (anchor: string | undefined): void => {
22+
typeof anchor !== 'undefined' && allAnchors.add(anchor);
2323
},
24-
collectLink: (link: string): void => {
25-
allLinks.add(link);
24+
collectLink: (link: string | undefined): void => {
25+
typeof link !== 'undefined' && allLinks.add(link);
2626
},
2727
getCollectedAnchors: (): string[] => [...allAnchors],
2828
getCollectedLinks: (): string[] => [...allLinks],

packages/docusaurus/src/client/exports/Link.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,20 @@ function Link(
140140
};
141141
}, [ioRef, targetLink, IOSupported, isInternal]);
142142

143+
// It is simple local anchor link targeting current page?
143144
const isAnchorLink = targetLink?.startsWith('#') ?? false;
145+
146+
// Should we use a regular <a> tag instead of React-Router Link component?
144147
const isRegularHtmlLink = !targetLink || !isInternal || isAnchorLink;
145148

146-
if (!isRegularHtmlLink && !noBrokenLinkCheck) {
149+
if (!noBrokenLinkCheck && (isAnchorLink || !isRegularHtmlLink)) {
147150
brokenLinks.collectLink(targetLink!);
148151
}
149152

153+
if (props.id) {
154+
brokenLinks.collectAnchor(props.id);
155+
}
156+
150157
return isRegularHtmlLink ? (
151158
// eslint-disable-next-line jsx-a11y/anchor-has-content, @docusaurus/no-html-links
152159
<a

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

+130-22
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,71 @@ describe('handleBrokenLinks', () => {
8686
});
8787
});
8888

89+
it('accepts valid link with anchor reported with hash prefix', async () => {
90+
await testBrokenLinks({
91+
routes: [{path: '/page1'}, {path: '/page2'}],
92+
collectedLinks: {
93+
'/page1': {links: ['/page2#page2anchor'], anchors: []},
94+
'/page2': {links: [], anchors: ['#page2anchor']},
95+
},
96+
});
97+
});
98+
99+
it('accepts valid links and anchors, sparse arrays', async () => {
100+
await testBrokenLinks({
101+
routes: [{path: '/page1'}, {path: '/page2'}],
102+
collectedLinks: {
103+
'/page1': {
104+
links: [
105+
'/page1',
106+
// @ts-expect-error: invalid type on purpose
107+
undefined,
108+
// @ts-expect-error: invalid type on purpose
109+
null,
110+
// @ts-expect-error: invalid type on purpose
111+
42,
112+
'/page2',
113+
'/page2#page2anchor1',
114+
'/page2#page2anchor2',
115+
],
116+
anchors: [],
117+
},
118+
'/page2': {
119+
links: [],
120+
anchors: [
121+
'page2anchor1',
122+
// @ts-expect-error: invalid type on purpose
123+
undefined,
124+
// @ts-expect-error: invalid type on purpose
125+
null,
126+
// @ts-expect-error: invalid type on purpose
127+
42,
128+
'page2anchor2',
129+
],
130+
},
131+
},
132+
});
133+
});
134+
135+
it('accepts valid link and anchor to collected pages that are not in routes', async () => {
136+
// This tests the edge-case of the 404 page:
137+
// We don't have a {path: '404.html'} route
138+
// But yet we collect links/anchors to it and allow linking to it
139+
await testBrokenLinks({
140+
routes: [],
141+
collectedLinks: {
142+
'/page 1': {
143+
links: ['/page 2#anchor-page-2'],
144+
anchors: ['anchor-page-1'],
145+
},
146+
'/page 2': {
147+
links: ['/page 1#anchor-page-1', '/page%201#anchor-page-1'],
148+
anchors: ['anchor-page-2'],
149+
},
150+
},
151+
});
152+
});
153+
89154
it('accepts valid link with querystring + anchor', async () => {
90155
await testBrokenLinks({
91156
routes: [{path: '/page1'}, {path: '/page2'}],
@@ -132,10 +197,75 @@ describe('handleBrokenLinks', () => {
132197
'/page%202',
133198
'/page%202?age=42',
134199
'/page%202?age=42#page2anchor',
200+
201+
'/some dir/page 3',
202+
'/some dir/page 3#page3anchor',
203+
'/some%20dir/page%203',
204+
'/some%20dir/page%203#page3anchor',
205+
'/some%20dir/page 3',
206+
'/some dir/page%203',
207+
'/some dir/page%203#page3anchor',
135208
],
136209
anchors: [],
137210
},
138211
'/page 2': {links: [], anchors: ['page2anchor']},
212+
'/some dir/page 3': {links: [], anchors: ['page3anchor']},
213+
},
214+
});
215+
});
216+
217+
it('accepts valid link with anchor with spaces and encoding', async () => {
218+
await testBrokenLinks({
219+
routes: [{path: '/page 1'}, {path: '/page 2'}],
220+
collectedLinks: {
221+
'/page 1': {
222+
links: [
223+
'/page 1#a b',
224+
'#a b',
225+
'#a%20b',
226+
'#c d',
227+
'#c%20d',
228+
229+
'/page 2#你好',
230+
'/page%202#你好',
231+
'/page 2#%E4%BD%A0%E5%A5%BD',
232+
'/page%202#%E4%BD%A0%E5%A5%BD',
233+
234+
'/page 2#schrödingers-cat-principle',
235+
'/page%202#schrödingers-cat-principle',
236+
'/page 2#schr%C3%B6dingers-cat-principle',
237+
'/page%202#schr%C3%B6dingers-cat-principle',
238+
],
239+
anchors: ['a b', 'c%20d'],
240+
},
241+
'/page 2': {
242+
links: ['/page 1#a b', '/page%201#c d'],
243+
anchors: ['你好', '#schr%C3%B6dingers-cat-principle'],
244+
},
245+
},
246+
});
247+
});
248+
249+
it('accepts valid link with empty anchor', async () => {
250+
await testBrokenLinks({
251+
routes: [{path: '/page 1'}, {path: '/page 2'}],
252+
collectedLinks: {
253+
'/page 1': {
254+
links: [
255+
'/page 1',
256+
'/page 2',
257+
'/page 1#',
258+
'/page 2#',
259+
'/page 1?age=42#',
260+
'/page 2?age=42#',
261+
'#',
262+
'#',
263+
'./page 1#',
264+
'./page 2#',
265+
],
266+
anchors: [],
267+
},
268+
'/page 2': {links: [], anchors: []},
139269
},
140270
});
141271
});
@@ -225,28 +355,6 @@ describe('handleBrokenLinks', () => {
225355
`);
226356
});
227357

228-
it('rejects valid link with empty broken anchor', async () => {
229-
await expect(() =>
230-
testBrokenLinks({
231-
routes: [{path: '/page1'}, {path: '/page2'}],
232-
collectedLinks: {
233-
'/page1': {links: ['/page2#'], anchors: []},
234-
'/page2': {links: [], anchors: []},
235-
},
236-
}),
237-
).rejects.toThrowErrorMatchingInlineSnapshot(`
238-
"Docusaurus found broken anchors!
239-
240-
Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist.
241-
Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass.
242-
243-
Exhaustive list of all broken anchors found:
244-
- Broken anchor on source page path = /page1:
245-
-> linking to /page2#
246-
"
247-
`);
248-
});
249-
250358
it('rejects valid link with broken anchor + query-string', async () => {
251359
await expect(() =>
252360
testBrokenLinks({

0 commit comments

Comments
 (0)