From fd49301a452ed93b656339bc30582f8618e89f93 Mon Sep 17 00:00:00 2001 From: ozaki <29860391+OzakIOne@users.noreply.github.com> Date: Thu, 4 Jan 2024 12:56:20 +0100 Subject: [PATCH] feat(core): make broken link checker detect broken anchors - add `onBrokenAnchors` config (#9528) Co-authored-by: sebastienlorber --- .../__snapshots__/index.test.ts.snap | 32 +- .../src/remark/transformLinks/index.ts | 28 + .../src/index.d.ts | 9 + .../src/theme/Heading/index.tsx | 4 + packages/docusaurus-types/src/config.d.ts | 7 + .../src/__tests__/urlUtils.test.ts | 133 ++++ packages/docusaurus-utils/src/index.ts | 3 + packages/docusaurus-utils/src/urlUtils.ts | 59 ++ .../src/client/BrokenLinksContext.tsx | 51 ++ .../docusaurus/src/client/LinksCollector.tsx | 45 -- .../docusaurus/src/client/exports/Link.tsx | 6 +- .../src/client/exports/useBrokenLinks.ts | 13 + .../docusaurus/src/client/serverEntry.tsx | 18 +- packages/docusaurus/src/commands/build.ts | 15 +- packages/docusaurus/src/deps.d.ts | 6 +- .../__snapshots__/brokenLinks.test.ts.snap | 86 -- .../__snapshots__/config.test.ts.snap | 10 + .../__snapshots__/index.test.ts.snap | 1 + .../src/server/__tests__/brokenLinks.test.ts | 748 ++++++++++++++---- packages/docusaurus/src/server/brokenLinks.ts | 293 ++++--- .../docusaurus/src/server/configValidation.ts | 5 + .../__tests__/__snapshots__/base.test.ts.snap | 1 + .../__snapshots__/index.test.ts.snap | 1 + website/docs/advanced/routing.mdx | 10 +- website/docs/api/docusaurus.config.js.mdx | 12 +- .../docs/api/plugins/plugin-content-docs.mdx | 12 +- website/docs/cli.mdx | 2 +- website/docs/docusaurus-core.mdx | 43 + .../guides/docs/sidebar/autogenerated.mdx | 2 +- website/docs/guides/docs/versioning.mdx | 2 +- website/docs/i18n/i18n-tutorial.mdx | 2 +- website/docs/seo.mdx | 2 +- website/docs/using-plugins.mdx | 2 +- website/docusaurus.config.ts | 6 +- .../version-2.x/advanced/routing.mdx | 10 +- .../api/plugins/plugin-content-docs.mdx | 12 +- website/versioned_docs/version-2.x/cli.mdx | 2 +- .../versioned_docs/version-2.x/deployment.mdx | 2 +- .../guides/docs/sidebar/autogenerated.mdx | 2 +- .../version-2.x/guides/docs/versioning.mdx | 2 +- .../markdown-features-react.mdx | 2 +- .../version-2.x/i18n/i18n-tutorial.mdx | 2 +- website/versioned_docs/version-2.x/seo.mdx | 2 +- .../version-2.x/using-plugins.mdx | 2 +- .../version-3.0.1/advanced/routing.mdx | 10 +- .../api/plugins/plugin-content-docs.mdx | 12 +- website/versioned_docs/version-3.0.1/cli.mdx | 2 +- .../guides/docs/sidebar/autogenerated.mdx | 2 +- .../version-3.0.1/guides/docs/versioning.mdx | 2 +- .../version-3.0.1/i18n/i18n-tutorial.mdx | 2 +- website/versioned_docs/version-3.0.1/seo.mdx | 2 +- .../version-3.0.1/using-plugins.mdx | 2 +- 52 files changed, 1221 insertions(+), 520 deletions(-) create mode 100644 packages/docusaurus/src/client/BrokenLinksContext.tsx delete mode 100644 packages/docusaurus/src/client/LinksCollector.tsx create mode 100644 packages/docusaurus/src/client/exports/useBrokenLinks.ts delete mode 100644 packages/docusaurus/src/server/__tests__/__snapshots__/brokenLinks.test.ts.snap diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap index 06f8fcbcabe7..a0186db7b10b 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap @@ -12,17 +12,17 @@ exports[`transformAsset plugin pathname protocol 1`] = ` exports[`transformAsset plugin transform md links to 1`] = ` "[asset](https://example.com/asset.pdf) -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} /> +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} /> -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset -in paragraph /node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset +in paragraph /node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset [page](noUrl.md) @@ -36,24 +36,24 @@ in paragraph /node_modules/file [assets](/github/!file-loader!/assets.pdf) -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2 +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2 -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf -/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 +/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 -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf, and /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"', but also /node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\` +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf, and /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"', but also /node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\` -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}>Clickable Docusaurus logo/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /> +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}>Clickable Docusaurus logo/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /> -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>Stylized link to asset file +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>Stylized link to asset file -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON " `; diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts b/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts index 252289f97452..179bcadb770d 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts @@ -73,6 +73,34 @@ async function toAssetRequireNode( value: '_blank', }); + // Assets are not routes, and are required by Webpack already + // They should not trigger the broken link checker + attributes.push({ + type: 'mdxJsxAttribute', + name: 'data-noBrokenLinkCheck', + value: { + type: 'mdxJsxAttributeValueExpression', + value: 'true', + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'Literal', + value: true, + raw: 'true', + }, + }, + ], + sourceType: 'module', + comments: [], + }, + }, + }, + }); + attributes.push({ type: 'mdxJsxAttribute', name: 'href', diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index 1f0e7679420a..5cb44f06e402 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -260,6 +260,15 @@ declare module '@docusaurus/useRouteContext' { export default function useRouteContext(): PluginRouteContext; } +declare module '@docusaurus/useBrokenLinks' { + export type BrokenLinks = { + collectLink: (link: string) => void; + collectAnchor: (anchor: string) => void; + }; + + export default function useBrokenLinks(): BrokenLinks; +} + declare module '@docusaurus/useIsBrowser' { export default function useIsBrowser(): boolean; } diff --git a/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx b/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx index 9ca1bc3f0db9..b17cd12ec3b1 100644 --- a/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Heading/index.tsx @@ -10,11 +10,13 @@ import clsx from 'clsx'; import {translate} from '@docusaurus/Translate'; import {useThemeConfig} from '@docusaurus/theme-common'; import Link from '@docusaurus/Link'; +import useBrokenLinks from '@docusaurus/useBrokenLinks'; import type {Props} from '@theme/Heading'; import styles from './styles.module.css'; export default function Heading({as: As, id, ...props}: Props): JSX.Element { + const brokenLinks = useBrokenLinks(); const { navbar: {hideOnScroll}, } = useThemeConfig(); @@ -23,6 +25,8 @@ export default function Heading({as: As, id, ...props}: Props): JSX.Element { return ; } + brokenLinks.collectAnchor(id); + const anchorTitle = translate( { id: 'theme.common.headingLinkTitle', diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 2efea9280b69..47bfde898d44 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -175,6 +175,13 @@ export type DocusaurusConfig = { * @default "throw" */ onBrokenLinks: ReportingSeverity; + /** + * The behavior of Docusaurus when it detects any broken link. + * + * @see https://docusaurus.io/docs/api/docusaurus-config#onBrokenAnchors + * @default "warn" + */ + onBrokenAnchors: ReportingSeverity; /** * The behavior of Docusaurus when it detects any broken markdown link. * diff --git a/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts b/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts index 30625e400554..301a91ae3224 100644 --- a/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts @@ -18,6 +18,8 @@ import { buildSshUrl, buildHttpsUrl, hasSSHProtocol, + parseURLPath, + serializeURLPath, } from '../urlUtils'; describe('normalizeUrl', () => { @@ -232,6 +234,137 @@ describe('removeTrailingSlash', () => { }); }); +describe('parseURLPath', () => { + it('parse and resolve pathname', () => { + expect(parseURLPath('')).toEqual({ + pathname: '/', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/')).toEqual({ + pathname: '/', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/page')).toEqual({ + pathname: '/page', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/dir1/page')).toEqual({ + pathname: '/dir1/page', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/dir1/dir2/./../page')).toEqual({ + pathname: '/dir1/page', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/dir1/dir2/../..')).toEqual({ + pathname: '/', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/dir1/dir2/../../..')).toEqual({ + pathname: '/', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('./dir1/dir2./../page', '/dir3/dir4/page2')).toEqual({ + pathname: '/dir3/dir4/dir1/page', + search: undefined, + hash: undefined, + }); + }); + + it('parse query string', () => { + expect(parseURLPath('/page')).toEqual({ + pathname: '/page', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/page?')).toEqual({ + pathname: '/page', + search: '', + hash: undefined, + }); + expect(parseURLPath('/page?test')).toEqual({ + pathname: '/page', + search: 'test', + hash: undefined, + }); + expect(parseURLPath('/page?age=42&great=true')).toEqual({ + pathname: '/page', + search: 'age=42&great=true', + hash: undefined, + }); + }); + + it('parse hash', () => { + expect(parseURLPath('/page')).toEqual({ + pathname: '/page', + search: undefined, + hash: undefined, + }); + expect(parseURLPath('/page#')).toEqual({ + pathname: '/page', + search: undefined, + hash: '', + }); + expect(parseURLPath('/page#anchor')).toEqual({ + pathname: '/page', + search: undefined, + hash: 'anchor', + }); + }); + + it('parse fancy real-world edge cases', () => { + expect(parseURLPath('/page?#')).toEqual({ + pathname: '/page', + search: '', + hash: '', + }); + expect( + parseURLPath('dir1/dir2/../page?age=42#anchor', '/dir3/page2'), + ).toEqual({ + pathname: '/dir3/dir1/page', + search: 'age=42', + hash: 'anchor', + }); + }); +}); + +describe('serializeURLPath', () => { + function test(input: string, base?: string, expectedOutput?: string) { + expect(serializeURLPath(parseURLPath(input, base))).toEqual( + expectedOutput ?? input, + ); + } + + it('works for already resolved paths', () => { + test('/'); + test('/dir1/page'); + test('/dir1/page?'); + test('/dir1/page#'); + test('/dir1/page?#'); + test('/dir1/page?age=42#anchor'); + }); + + it('works for relative paths', () => { + test('', undefined, '/'); + test('', '/dir1/dir2/page2', '/dir1/dir2/page2'); + test('page', '/dir1/dir2/page2', '/dir1/dir2/page'); + test('../page', '/dir1/dir2/page2', '/dir1/page'); + test('/dir1/dir2/../page', undefined, '/dir1/page'); + test( + '/dir1/dir2/../page?age=42#anchor', + undefined, + '/dir1/page?age=42#anchor', + ); + }); +}); + describe('resolvePathname', () => { it('works', () => { // These tests are directly copied from https://github.com/mjackson/resolve-pathname/blob/master/modules/__tests__/resolvePathname-test.js diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 5b374898bf62..6db01244d006 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -48,6 +48,8 @@ export { encodePath, isValidPathname, resolvePathname, + parseURLPath, + serializeURLPath, addLeadingSlash, addTrailingSlash, removeTrailingSlash, @@ -55,6 +57,7 @@ export { buildHttpsUrl, buildSshUrl, } from './urlUtils'; +export type {URLPath} from './urlUtils'; export { type Tag, type TagsListItem, diff --git a/packages/docusaurus-utils/src/urlUtils.ts b/packages/docusaurus-utils/src/urlUtils.ts index bb901a291d06..8a7af4aa4b6d 100644 --- a/packages/docusaurus-utils/src/urlUtils.ts +++ b/packages/docusaurus-utils/src/urlUtils.ts @@ -165,14 +165,73 @@ export function isValidPathname(str: string): boolean { } } +export type URLPath = {pathname: string; search?: string; hash?: string}; + +// Let's name the concept of (pathname + search + hash) as URLPath +// See also https://twitter.com/kettanaito/status/1741768992866308120 +// Note: this function also resolves relative pathnames while parsing! +export function parseURLPath(urlPath: string, fromPath?: string): URLPath { + function parseURL(url: string, base?: string | URL): URL { + try { + // A possible alternative? https://github.com/unjs/ufo#url + return new URL(url, base ?? 'https://example.com'); + } catch (e) { + throw new Error( + `Can't parse URL ${url}${base ? ` with base ${base}` : ''}`, + {cause: e}, + ); + } + } + + const base = fromPath ? parseURL(fromPath) : undefined; + const url = parseURL(urlPath, base); + + const {pathname} = url; + + // Fixes annoying url.search behavior + // "" => undefined + // "?" => "" + // "?param => "param" + const search = url.search + ? url.search.slice(1) + : urlPath.includes('?') + ? '' + : undefined; + + // Fixes annoying url.hash behavior + // "" => undefined + // "#" => "" + // "?param => "param" + const hash = url.hash + ? url.hash.slice(1) + : urlPath.includes('#') + ? '' + : undefined; + + return { + pathname, + search, + hash, + }; +} + +export function serializeURLPath(urlPath: URLPath): string { + const search = urlPath.search === undefined ? '' : `?${urlPath.search}`; + const hash = urlPath.hash === undefined ? '' : `#${urlPath.hash}`; + return `${urlPath.pathname}${search}${hash}`; +} + /** * Resolve pathnames and fail-fast if resolution fails. Uses standard URL * semantics (provided by `resolve-pathname` which is used internally by React * router) */ export function resolvePathname(to: string, from?: string): string { + // TODO do we really need resolve-pathname lib anymore? + // possible alternative: decodeURI(parseURLPath(to, from).pathname); return resolvePathnameUnsafe(to, from); } + /** Appends a leading slash to `str`, if one doesn't exist. */ export function addLeadingSlash(str: string): string { return addPrefix(str, '/'); diff --git a/packages/docusaurus/src/client/BrokenLinksContext.tsx b/packages/docusaurus/src/client/BrokenLinksContext.tsx new file mode 100644 index 000000000000..e04e8ab14731 --- /dev/null +++ b/packages/docusaurus/src/client/BrokenLinksContext.tsx @@ -0,0 +1,51 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, {type ReactNode, useContext} from 'react'; +import type {BrokenLinks} from '@docusaurus/useBrokenLinks'; + +export type StatefulBrokenLinks = BrokenLinks & { + getCollectedLinks: () => string[]; + getCollectedAnchors: () => string[]; +}; + +export const createStatefulBrokenLinks = (): StatefulBrokenLinks => { + // Set to dedup, as it's not useful to collect multiple times the same value + const allAnchors = new Set(); + const allLinks = new Set(); + return { + collectAnchor: (anchor: string): void => { + allAnchors.add(anchor); + }, + collectLink: (link: string): void => { + allLinks.add(link); + }, + getCollectedAnchors: (): string[] => [...allAnchors], + getCollectedLinks: (): string[] => [...allLinks], + }; +}; + +const Context = React.createContext({ + collectAnchor: () => { + // No-op for client + }, + collectLink: () => { + // No-op for client + }, +}); + +export const useBrokenLinksContext = (): BrokenLinks => useContext(Context); + +export function BrokenLinksProvider({ + children, + brokenLinks, +}: { + children: ReactNode; + brokenLinks: BrokenLinks; +}): JSX.Element { + return {children}; +} diff --git a/packages/docusaurus/src/client/LinksCollector.tsx b/packages/docusaurus/src/client/LinksCollector.tsx deleted file mode 100644 index d0fb33b9ec03..000000000000 --- a/packages/docusaurus/src/client/LinksCollector.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React, {type ReactNode, useContext} from 'react'; - -type LinksCollector = { - collectLink: (link: string) => void; -}; - -type StatefulLinksCollector = LinksCollector & { - getCollectedLinks: () => string[]; -}; - -export const createStatefulLinksCollector = (): StatefulLinksCollector => { - // Set to dedup, as it's not useful to collect multiple times the same link - const allLinks = new Set(); - return { - collectLink: (link: string): void => { - allLinks.add(link); - }, - getCollectedLinks: (): string[] => [...allLinks], - }; -}; - -const Context = React.createContext({ - collectLink: () => { - // No-op for client. We only use the broken links checker server-side. - }, -}); - -export const useLinksCollector = (): LinksCollector => useContext(Context); - -export function LinksCollectorProvider({ - children, - linksCollector, -}: { - children: ReactNode; - linksCollector: LinksCollector; -}): JSX.Element { - return {children}; -} diff --git a/packages/docusaurus/src/client/exports/Link.tsx b/packages/docusaurus/src/client/exports/Link.tsx index 4a7453dfef8f..8b886c8e7073 100644 --- a/packages/docusaurus/src/client/exports/Link.tsx +++ b/packages/docusaurus/src/client/exports/Link.tsx @@ -16,7 +16,7 @@ import {applyTrailingSlash} from '@docusaurus/utils-common'; import useDocusaurusContext from './useDocusaurusContext'; import isInternalUrl from './isInternalUrl'; import ExecutionEnvironment from './ExecutionEnvironment'; -import {useLinksCollector} from '../LinksCollector'; +import useBrokenLinks from './useBrokenLinks'; import {useBaseUrlUtils} from './useBaseUrl'; import type {Props} from '@docusaurus/Link'; @@ -44,7 +44,7 @@ function Link( siteConfig: {trailingSlash, baseUrl}, } = useDocusaurusContext(); const {withBaseUrl} = useBaseUrlUtils(); - const linksCollector = useLinksCollector(); + const brokenLinks = useBrokenLinks(); const innerRef = useRef(null); useImperativeHandle(forwardedRef, () => innerRef.current!); @@ -144,7 +144,7 @@ function Link( const isRegularHtmlLink = !targetLink || !isInternal || isAnchorLink; if (!isRegularHtmlLink && !noBrokenLinkCheck) { - linksCollector.collectLink(targetLink!); + brokenLinks.collectLink(targetLink!); } return isRegularHtmlLink ? ( diff --git a/packages/docusaurus/src/client/exports/useBrokenLinks.ts b/packages/docusaurus/src/client/exports/useBrokenLinks.ts new file mode 100644 index 000000000000..979aa399cdb8 --- /dev/null +++ b/packages/docusaurus/src/client/exports/useBrokenLinks.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {useBrokenLinksContext} from '../BrokenLinksContext'; +import type {BrokenLinks} from '@docusaurus/useBrokenLinks'; + +export default function useBrokenLinks(): BrokenLinks { + return useBrokenLinksContext(); +} diff --git a/packages/docusaurus/src/client/serverEntry.tsx b/packages/docusaurus/src/client/serverEntry.tsx index 2d67558926e7..c01c4779e904 100644 --- a/packages/docusaurus/src/client/serverEntry.tsx +++ b/packages/docusaurus/src/client/serverEntry.tsx @@ -20,9 +20,9 @@ import {renderStaticApp} from './serverRenderer'; import preload from './preload'; import App from './App'; import { - createStatefulLinksCollector, - LinksCollectorProvider, -} from './LinksCollector'; + createStatefulBrokenLinks, + BrokenLinksProvider, +} from './BrokenLinksContext'; import type {Locals} from '@slorber/static-site-generator-webpack-plugin'; const getCompiledSSRTemplate = _.memoize((template: string) => @@ -96,23 +96,27 @@ async function doRender(locals: Locals & {path: string}) { const routerContext = {}; const helmetContext = {}; - const linksCollector = createStatefulLinksCollector(); + const statefulBrokenLinks = createStatefulBrokenLinks(); const app = ( // @ts-expect-error: we are migrating away from react-loadable anyways modules.add(moduleName)}> - + - + ); const appHtml = await renderStaticApp(app); - onLinksCollected(location, linksCollector.getCollectedLinks()); + onLinksCollected({ + staticPagePath: location, + anchors: statefulBrokenLinks.getCollectedAnchors(), + links: statefulBrokenLinks.getCollectedLinks(), + }); const {helmet} = helmetContext as FilledContext; const htmlAttributes = helmet.htmlAttributes.toString(); diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index 3b20912150ec..c0a38164092a 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -152,8 +152,8 @@ async function buildLocale({ generatedFilesDir, plugins, siteConfig: { - baseUrl, onBrokenLinks, + onBrokenAnchors, staticDirectories: staticDirectoriesOption, }, routes, @@ -180,13 +180,15 @@ async function buildLocale({ }, ); - const allCollectedLinks: {[location: string]: string[]} = {}; + const collectedLinks: { + [pathname: string]: {links: string[]; anchors: string[]}; + } = {}; const headTags: {[location: string]: HelmetServerState} = {}; let serverConfig: Configuration = await createServerConfig({ props, - onLinksCollected: (staticPagePath, links) => { - allCollectedLinks[staticPagePath] = links; + onLinksCollected: ({staticPagePath, links, anchors}) => { + collectedLinks[staticPagePath] = {links, anchors}; }, onHeadTagsCollected: (staticPagePath, tags) => { headTags[staticPagePath] = tags; @@ -288,11 +290,10 @@ async function buildLocale({ ); await handleBrokenLinks({ - allCollectedLinks, + collectedLinks, routes, onBrokenLinks, - outDir, - baseUrl, + onBrokenAnchors, }); logger.success`Generated static files in path=${path.relative( diff --git a/packages/docusaurus/src/deps.d.ts b/packages/docusaurus/src/deps.d.ts index 49bca18d06f9..199f39900970 100644 --- a/packages/docusaurus/src/deps.d.ts +++ b/packages/docusaurus/src/deps.d.ts @@ -42,7 +42,11 @@ declare module '@slorber/static-site-generator-webpack-plugin' { headTags: string; preBodyTags: string; postBodyTags: string; - onLinksCollected: (staticPagePath: string, links: string[]) => void; + onLinksCollected: (params: { + staticPagePath: string; + links: string[]; + anchors: string[]; + }) => void; onHeadTagsCollected: ( staticPagePath: string, tags: HelmetServerState, diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/brokenLinks.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/brokenLinks.test.ts.snap deleted file mode 100644 index 8aa3a3e837dc..000000000000 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/brokenLinks.test.ts.snap +++ /dev/null @@ -1,86 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`handleBrokenLinks reports all broken links 1`] = ` -"Docusaurus found broken links! - -Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. -Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. - -Exhaustive list of all broken links found: - -- On source page path = /docs/good doc with space: - -> linking to ./some%20other%20non-existent%20doc1 (resolved as: /docs/some%20other%20non-existent%20doc1) - -> linking to ./break%2F..%2F..%2Fout2 (resolved as: /docs/break%2F..%2F..%2Fout2) - -- On source page path = /docs/goodDoc: - -> linking to ../anotherGoodDoc#reported-because-of-bad-relative-path1 (resolved as: /anotherGoodDoc) - -> linking to ./docThatDoesNotExist2 (resolved as: /docs/docThatDoesNotExist2) - -> linking to ./badRelativeLink3 (resolved as: /docs/badRelativeLink3) - -> linking to ../badRelativeLink4 (resolved as: /badRelativeLink4) - -- On source page path = /community: - -> linking to /someNonExistentDoc1 - -> linking to /badLink2 - -> linking to ./badLink3 (resolved as: /badLink3) - -- On source page path = /page1: - -> linking to /link1 - -> linking to /emptyFolder - -- On source page path = /page2: - -> linking to /docs/link2 - -> linking to /emptyFolder/ - -> linking to /hey/link3 -" -`; - -exports[`handleBrokenLinks reports frequent broken links 1`] = ` -"Docusaurus found broken links! - -Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. -Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. - -It looks like some of the broken links we found appear in many pages of your site. -Maybe those broken links appear on all pages through your site layout? -We recommend that you check your theme configuration for such links (particularly, theme navbar and footer). -Frequent broken links are linking to: -- /frequent -- ./maybe-not - -Exhaustive list of all broken links found: - -- On source page path = /docs/good doc with space: - -> linking to ./some%20other%20non-existent%20doc1 (resolved as: /docs/some%20other%20non-existent%20doc1) - -> linking to ./break%2F..%2F..%2Fout2 (resolved as: /docs/break%2F..%2F..%2Fout2) - -> linking to /frequent - -> linking to ./maybe-not (resolved as: /docs/maybe-not) - -- On source page path = /docs/goodDoc: - -> linking to ../anotherGoodDoc#reported-because-of-bad-relative-path1 (resolved as: /anotherGoodDoc) - -> linking to ./docThatDoesNotExist2 (resolved as: /docs/docThatDoesNotExist2) - -> linking to ./badRelativeLink3 (resolved as: /docs/badRelativeLink3) - -> linking to ../badRelativeLink4 (resolved as: /badRelativeLink4) - -> linking to /frequent - -> linking to ./maybe-not (resolved as: /docs/maybe-not) - -- On source page path = /community: - -> linking to /someNonExistentDoc1 - -> linking to /badLink2 - -> linking to ./badLink3 (resolved as: /badLink3) - -> linking to /frequent - -> linking to ./maybe-not (resolved as: /maybe-not) - -- On source page path = /page1: - -> linking to /link1 - -> linking to /emptyFolder - -> linking to /frequent - -> linking to ./maybe-not (resolved as: /maybe-not) - -- On source page path = /page2: - -> linking to /docs/link2 - -> linking to /emptyFolder/ - -> linking to /hey/link3 - -> linking to /frequent - -> linking to ./maybe-not (resolved as: /maybe-not) -" -`; diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index 629e5b02fca2..c10c4833901e 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -29,6 +29,7 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -79,6 +80,7 @@ exports[`loadSiteConfig website with ts + js config 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -129,6 +131,7 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -179,6 +182,7 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -229,6 +233,7 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -279,6 +284,7 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -329,6 +335,7 @@ exports[`loadSiteConfig website with valid async config 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -381,6 +388,7 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -433,6 +441,7 @@ exports[`loadSiteConfig website with valid config creator function 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", @@ -488,6 +497,7 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/index.test.ts.snap index d3b6b670cc27..cba9622267ad 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/index.test.ts.snap @@ -103,6 +103,7 @@ exports[`load loads props for site with custom i18n path 1`] = ` "remarkRehypeOptions": undefined, }, "noIndex": false, + "onBrokenAnchors": "warn", "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", "onDuplicateRoutes": "warn", diff --git a/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts b/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts index 40e76ed45b7a..158af9165af7 100644 --- a/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts +++ b/packages/docusaurus/src/server/__tests__/brokenLinks.test.ts @@ -6,190 +6,608 @@ */ import {jest} from '@jest/globals'; -import path from 'path'; -import _ from 'lodash'; import {handleBrokenLinks} from '../brokenLinks'; import type {RouteConfig} from '@docusaurus/types'; +type Params = Parameters[0]; + +// We don't need all the routes attributes for our tests +type SimpleRoute = {path: string; routes?: SimpleRoute[]}; + +// Conveniently apply defaults to function under test +async function testBrokenLinks(params: { + collectedLinks?: Params['collectedLinks']; + onBrokenLinks?: Params['onBrokenLinks']; + onBrokenAnchors?: Params['onBrokenAnchors']; + routes?: SimpleRoute[]; +}) { + await handleBrokenLinks({ + collectedLinks: {}, + onBrokenLinks: 'throw', + onBrokenAnchors: 'throw', + ...params, + // Unsafe but convenient for tests + routes: (params.routes ?? []) as RouteConfig[], + }); +} + describe('handleBrokenLinks', () => { - const routes: RouteConfig[] = [ - { - path: '/community', - component: '', - }, - { - path: '/docs', - component: '', + it('accepts valid link', async () => { + await testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/page2'], anchors: []}, + '/page2': {links: [], anchors: []}, + }, + }); + }); + + it('accepts valid link to uncollected page', async () => { + await testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/page2'], anchors: []}, + // /page2 is absent on purpose: it doesn't contain any link/anchor + }, + }); + }); + + it('accepts valid link to nested route', async () => { + await testBrokenLinks({ routes: [ - {path: '/docs/goodDoc', component: ''}, - {path: '/docs/anotherGoodDoc', component: ''}, - {path: '/docs/good doc with space', component: ''}, - {path: '/docs/another good doc with space', component: ''}, - {path: '/docs/weird%20but%20good', component: ''}, - ], - }, - { - path: '*', - component: '', - }, - ]; - - const link1 = '/link1'; - const link2 = '/docs/link2'; - const link3 = '/hey/link3'; - - const linkToJavadoc1 = '/javadoc'; - const linkToJavadoc2 = '/javadoc/'; - const linkToJavadoc3 = '/javadoc/index.html'; - const linkToJavadoc4 = '/javadoc/index.html#foo'; - - const linkToZipFile = '/files/file.zip'; - const linkToHtmlFile1 = '/files/hey.html'; - const linkToHtmlFile2 = '/files/hey'; - - 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': [ - './another%20good%20doc%20with%20space', - './weird%20but%20good', - ], - '/docs/goodDoc': [ - './anotherGoodDoc#someHash', - '/docs/anotherGoodDoc?someQueryString=true#someHash', - '../docs/anotherGoodDoc?someQueryString=true', - '../docs/anotherGoodDoc#someHash', - ], - '/community': [ - '/docs/goodDoc', - '/docs/anotherGoodDoc#someHash', - './docs/goodDoc#someHash', - './docs/anotherGoodDoc', + {path: '/page1'}, + {path: '/nested/', routes: [{path: '/nested/page2'}]}, ], - '/page1': [ - linkToHtmlFile1, - linkToJavadoc1, - linkToHtmlFile2, - linkToJavadoc3, - linkToJavadoc4, - ], - }; - await handleBrokenLinks({ - allCollectedLinks: allCollectedCorrectLinks, - onBrokenLinks: 'warn', - routes, - baseUrl: '/', - outDir, + collectedLinks: { + '/page1': {links: ['/nested/page2'], anchors: []}, + }, }); - expect(consoleMock).toHaveBeenCalledTimes(0); }); - it('reports all broken links', async () => { + it('accepts valid relative link', async () => { + await testBrokenLinks({ + routes: [{path: '/dir/page1'}, {path: '/dir/page2'}], + collectedLinks: { + '/dir/page1': { + links: ['./page2', '../dir/page2', '/dir/page2'], + anchors: [], + }, + }, + }); + }); + + it('accepts valid link with anchor', async () => { + await testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/page2#page2anchor'], anchors: []}, + '/page2': {links: [], anchors: ['page2anchor']}, + }, + }); + }); + + it('accepts valid link with querystring + anchor', async () => { + await testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': { + links: ['/page2?age=42&theme=dark#page2anchor'], + anchors: [], + }, + '/page2': {links: [], anchors: ['page2anchor']}, + }, + }); + }); + + it('accepts valid link to self', async () => { + await testBrokenLinks({ + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: [ + '/page1', + './page1', + '', + '/page1#anchor1', + '#anchor1', + '/page1?age=42#anchor1', + '?age=42#anchor1', + ], + anchors: ['anchor1'], + }, + }, + }); + }); + + it('accepts valid link with spaces and encoding', async () => { + await testBrokenLinks({ + routes: [{path: '/page 1'}, {path: '/page 2'}], + collectedLinks: { + '/page 1': { + links: [ + '/page 1', + '/page%201', + '/page%201?age=42', + '/page 2', + '/page%202', + '/page%202?age=42', + '/page%202?age=42#page2anchor', + ], + anchors: [], + }, + '/page 2': {links: [], anchors: ['page2anchor']}, + }, + }); + }); + + it('rejects broken link', async () => { await expect(() => - handleBrokenLinks({ - allCollectedLinks, - onBrokenLinks: 'throw', - routes, - baseUrl: '/', - outDir, + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/brokenLink'], anchors: []}, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /brokenLink + " + `); + }); + + it('rejects broken link with anchor', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/brokenLink#anchor'], anchors: []}, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /brokenLink#anchor + " + `); + }); + + it('rejects broken link with querystring + anchor', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/brokenLink?age=42#anchor'], anchors: []}, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /brokenLink?age=42#anchor + " + `); + }); + + it('rejects valid link with broken anchor', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/page2#brokenAnchor'], anchors: []}, + '/page2': {links: [], anchors: []}, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page2#brokenAnchor + " + `); + }); + + it('rejects valid link with empty broken anchor', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/page2#'], anchors: []}, + '/page2': {links: [], anchors: []}, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page2# + " + `); + }); + + it('rejects valid link with broken anchor + query-string', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': { + links: ['/page2?age=42&theme=dark#brokenAnchor'], + anchors: [], + }, + '/page2': {links: [], anchors: []}, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page2?age=42&theme=dark#brokenAnchor + " + `); + }); + + it('rejects valid link with broken anchor to self', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: [ + '/page1', + '', + '#goodAnchor', + '/page1#goodAnchor', + '/page1?age=42#goodAnchor', + '#badAnchor1', + '/page1#badAnchor2', + '/page1?age=42#badAnchor3', + ], + + anchors: ['goodAnchor'], + }, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to #badAnchor1 (resolved as: /page1#badAnchor1) + -> linking to /page1#badAnchor2 + -> linking to /page1?age=42#badAnchor3 + " + `); + }); + + it('rejects valid link with broken anchor to uncollected page', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': {links: ['/page2#brokenAnchor'], anchors: []}, + // /page2 is absent on purpose: it doesn't contain any link/anchor + }, }), - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page2#brokenAnchor + " + `); + }); + + it('rejects broken anchor with query-string to uncollected page', async () => { + await expect(() => + testBrokenLinks({ + routes: [{path: '/page1'}, {path: '/page2'}], + collectedLinks: { + '/page1': { + links: ['/page2?age=42&theme=dark#brokenAnchor'], + anchors: [], + }, + // /page2 is absent on purpose: it doesn't contain any link/anchor + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page2?age=42&theme=dark#brokenAnchor + " + `); }); - it('no-op for ignore', async () => { - // In any case, _.mapValues will always be called, unless handleBrokenLinks - // has already bailed - const lodashMock = jest.spyOn(_, 'mapValues'); - await handleBrokenLinks({ - allCollectedLinks, + it('can ignore broken links', async () => { + await testBrokenLinks({ onBrokenLinks: 'ignore', - routes, - baseUrl: '/', - outDir, + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: ['/page2'], + anchors: [], + }, + }, }); - expect(lodashMock).toHaveBeenCalledTimes(0); - lodashMock.mockRestore(); - }); - - it('reports frequent broken links', async () => { - 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', - ), + }); + + it('can ignore broken anchors', async () => { + await testBrokenLinks({ + onBrokenAnchors: 'ignore', + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: ['/page1#brokenAnchor'], + anchors: [], + }, + }, + }); + }); + + it('can ignore broken anchors but report broken link', async () => { + await expect(() => + testBrokenLinks({ + onBrokenAnchors: 'ignore', + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: ['/page1#brokenAnchor', '/page2'], + anchors: [], + }, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /page2 + " + `); + }); + + it('can ignore broken link but report broken anchors', async () => { + await expect(() => + testBrokenLinks({ + onBrokenLinks: 'ignore', + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: [ + '/page2', + '/page1#brokenAnchor1', + '/page1#brokenAnchor2', + '#brokenAnchor3', + ], + + anchors: [], + }, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page1#brokenAnchor1 + -> linking to /page1#brokenAnchor2 + -> linking to #brokenAnchor3 (resolved as: /page1#brokenAnchor3) + " + `); + }); + + it('can warn for broken links', async () => { + const warnMock = jest.spyOn(console, 'warn'); + + await testBrokenLinks({ + onBrokenLinks: 'warn', + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: ['/page2'], + anchors: [], + }, + }, + }); + + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "[WARNING] Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /page2 + ", + ], + ] + `); + warnMock.mockRestore(); + }); + + it('can warn for broken anchors', async () => { + const warnMock = jest.spyOn(console, 'warn'); + + await testBrokenLinks({ + onBrokenAnchors: 'warn', + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: ['/page1#brokenAnchor'], + anchors: [], + }, + }, + }); + + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "[WARNING] Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page1#brokenAnchor + ", + ], + ] + `); + warnMock.mockRestore(); + }); + + it('can warn for both broken links and anchors', async () => { + const warnMock = jest.spyOn(console, 'warn'); + + await testBrokenLinks({ + onBrokenLinks: 'warn', + onBrokenAnchors: 'warn', + routes: [{path: '/page1'}], + collectedLinks: { + '/page1': { + links: ['/page1#brokenAnchor', '/page2'], + anchors: [], + }, + }, + }); + + expect(warnMock).toHaveBeenCalledTimes(2); + expect(warnMock.mock.calls).toMatchInlineSnapshot(` + [ + [ + "[WARNING] Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /page2 + ", + ], + [ + "[WARNING] Docusaurus found broken anchors! + + Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. + Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + + Exhaustive list of all broken anchors found: + - Broken anchor on source page path = /page1: + -> linking to /page1#brokenAnchor + ", + ], + ] + `); + warnMock.mockRestore(); + }); + + it('reports frequent broken links differently', async () => { + const pagePaths = [ + '/page1', + '/page2', + '/dir/page3', + '/dir/page4', + '/dir/page5', + ]; + + const routes: SimpleRoute[] = pagePaths.map((pagePath) => ({ + path: pagePath, + })); + + const collectedLinks: Params['collectedLinks'] = Object.fromEntries( + pagePaths.map((pagePath) => [ + pagePath, + { + links: ['/frequentBrokenLink', './relativeFrequentBrokenLink'], + anchors: [], + }, + ]), ); await expect(() => - handleBrokenLinks({ - allCollectedLinks, - onBrokenLinks: 'throw', + testBrokenLinks({ routes, - baseUrl: '/', - outDir, + collectedLinks, }), - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Docusaurus found broken links! + + Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist. + Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass. + + It looks like some of the broken links we found appear in many pages of your site. + Maybe those broken links appear on all pages through your site layout? + We recommend that you check your theme configuration for such links (particularly, theme navbar and footer). + Frequent broken links are linking to: + - /frequentBrokenLink + - ./relativeFrequentBrokenLink + + Exhaustive list of all broken links found: + - Broken link on source page path = /page1: + -> linking to /frequentBrokenLink + -> linking to ./relativeFrequentBrokenLink (resolved as: /relativeFrequentBrokenLink) + - Broken link on source page path = /page2: + -> linking to /frequentBrokenLink + -> linking to ./relativeFrequentBrokenLink (resolved as: /relativeFrequentBrokenLink) + - Broken link on source page path = /dir/page3: + -> linking to /frequentBrokenLink + -> linking to ./relativeFrequentBrokenLink (resolved as: /dir/relativeFrequentBrokenLink) + - Broken link on source page path = /dir/page4: + -> linking to /frequentBrokenLink + -> linking to ./relativeFrequentBrokenLink (resolved as: /dir/relativeFrequentBrokenLink) + - Broken link on source page path = /dir/page5: + -> linking to /frequentBrokenLink + -> linking to ./relativeFrequentBrokenLink (resolved as: /dir/relativeFrequentBrokenLink) + " + `); }); }); diff --git a/packages/docusaurus/src/server/brokenLinks.ts b/packages/docusaurus/src/server/brokenLinks.ts index f443a4659cf9..ccbaadcd3ffb 100644 --- a/packages/docusaurus/src/server/brokenLinks.ts +++ b/packages/docusaurus/src/server/brokenLinks.ts @@ -5,45 +5,42 @@ * LICENSE file in the root directory of this source tree. */ -import fs from 'fs-extra'; -import path from 'path'; import _ from 'lodash'; import logger from '@docusaurus/logger'; -import combinePromises from 'combine-promises'; import {matchRoutes} from 'react-router-config'; -import {removePrefix, removeSuffix, resolvePathname} from '@docusaurus/utils'; +import {parseURLPath, serializeURLPath, type URLPath} from '@docusaurus/utils'; import {getAllFinalRoutes} from './utils'; import type {RouteConfig, ReportingSeverity} from '@docusaurus/types'; type BrokenLink = { link: string; resolvedLink: string; + anchor: boolean; }; -// matchRoutes does not support qs/anchors, so we remove it! -function onlyPathname(link: string) { - return link.split('#')[0]!.split('?')[0]!; -} +type BrokenLinksMap = {[pathname: string]: BrokenLink[]}; + +// The linking data that has been collected on Docusaurus pages during SSG +// {rendered page pathname => links and anchors collected on that page} +type CollectedLinks = { + [pathname: string]: {links: string[]; anchors: string[]}; +}; -function getPageBrokenLinks({ +function getBrokenLinksForPage({ + collectedLinks, pagePath, pageLinks, routes, }: { + collectedLinks: CollectedLinks; pagePath: string; pageLinks: string[]; + pageAnchors: string[]; routes: RouteConfig[]; }): BrokenLink[] { - // ReactRouter is able to support links like ./../somePath but `matchRoutes` - // does not do this resolution internally. We must resolve the links before - // using `matchRoutes`. `resolvePathname` is used internally by React Router - function resolveLink(link: string) { - const resolvedLink = resolvePathname(onlyPathname(link), pagePath); - return {link, resolvedLink}; - } - - function isBrokenLink(link: string) { - const matchedRoutes = [link, decodeURI(link)] + // console.log('routes:', routes); + function isPathBrokenLink(linkPath: URLPath) { + const matchedRoutes = [linkPath.pathname, decodeURI(linkPath.pathname)] // @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. @@ -52,7 +49,52 @@ function getPageBrokenLinks({ return matchedRoutes.length === 0; } - return pageLinks.map(resolveLink).filter((l) => isBrokenLink(l.resolvedLink)); + function isAnchorBrokenLink(linkPath: URLPath) { + const {pathname, hash} = linkPath; + + // Link has no hash: it can't be a broken anchor link + if (hash === undefined) { + return false; + } + + const targetPage = + collectedLinks[pathname] || collectedLinks[decodeURI(pathname)]; + + // link with anchor to a page that does not exist (or did not collect any + // link/anchor) is considered as a broken anchor + if (!targetPage) { + return true; + } + + // it's a broken anchor if the target page exists + // but the anchor does not exist on that page + return !targetPage.anchors.includes(hash); + } + + const brokenLinks = pageLinks.flatMap((link) => { + const linkPath = parseURLPath(link, pagePath); + if (isPathBrokenLink(linkPath)) { + return [ + { + link, + resolvedLink: serializeURLPath(linkPath), + anchor: false, + }, + ]; + } + if (isAnchorBrokenLink(linkPath)) { + return [ + { + link, + resolvedLink: serializeURLPath(linkPath), + anchor: true, + }, + ]; + } + return []; + }); + + return brokenLinks; } /** @@ -66,45 +108,76 @@ function filterIntermediateRoutes(routesInput: RouteConfig[]): RouteConfig[] { return getAllFinalRoutes(routesWithout404); } -function getAllBrokenLinks({ - allCollectedLinks, +function getBrokenLinks({ + collectedLinks, routes, }: { - allCollectedLinks: {[location: string]: string[]}; + collectedLinks: CollectedLinks; routes: RouteConfig[]; -}): {[location: string]: BrokenLink[]} { +}): BrokenLinksMap { const filteredRoutes = filterIntermediateRoutes(routes); - const allBrokenLinks = _.mapValues(allCollectedLinks, (pageLinks, pagePath) => - getPageBrokenLinks({pageLinks, pagePath, routes: filteredRoutes}), + return _.mapValues(collectedLinks, (pageCollectedData, pagePath) => + getBrokenLinksForPage({ + collectedLinks, + pageLinks: pageCollectedData.links, + pageAnchors: pageCollectedData.anchors, + pagePath, + routes: filteredRoutes, + }), ); +} + +function brokenLinkMessage(brokenLink: BrokenLink): string { + const showResolvedLink = brokenLink.link !== brokenLink.resolvedLink; + return `${brokenLink.link}${ + showResolvedLink ? ` (resolved as: ${brokenLink.resolvedLink})` : '' + }`; +} + +function createBrokenLinksMessage( + pagePath: string, + brokenLinks: BrokenLink[], +): string { + const type = brokenLinks[0]?.anchor === true ? 'anchor' : 'link'; - return _.pickBy(allBrokenLinks, (brokenLinks) => brokenLinks.length > 0); + const anchorMessage = + brokenLinks.length > 0 + ? `- Broken ${type} on source page path = ${pagePath}: + -> linking to ${brokenLinks + .map(brokenLinkMessage) + .join('\n -> linking to ')}` + : ''; + + return `${anchorMessage}`; } -function getBrokenLinksErrorMessage(allBrokenLinks: { - [location: string]: BrokenLink[]; -}): string | undefined { - if (Object.keys(allBrokenLinks).length === 0) { +function createBrokenAnchorsMessage( + brokenAnchors: BrokenLinksMap, +): string | undefined { + if (Object.keys(brokenAnchors).length === 0) { return undefined; } - function brokenLinkMessage(brokenLink: BrokenLink): string { - const showResolvedLink = brokenLink.link !== brokenLink.resolvedLink; - return `${brokenLink.link}${ - showResolvedLink ? ` (resolved as: ${brokenLink.resolvedLink})` : '' - }`; - } + return `Docusaurus found broken anchors! - function pageBrokenLinksMessage( - pagePath: string, - brokenLinks: BrokenLink[], - ): string { - return ` -- On source page path = ${pagePath}: - -> linking to ${brokenLinks - .map(brokenLinkMessage) - .join('\n -> linking to ')}`; +Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist. +Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass. + +Exhaustive list of all broken anchors found: +${Object.entries(brokenAnchors) + .map(([pagePath, brokenLinks]) => + createBrokenLinksMessage(pagePath, brokenLinks), + ) + .join('\n')} +`; +} + +function createBrokenPathsMessage( + brokenPathsMap: BrokenLinksMap, +): string | undefined { + if (Object.keys(brokenPathsMap).length === 0) { + return undefined; } /** @@ -113,7 +186,7 @@ function getBrokenLinksErrorMessage(allBrokenLinks: { * this out. See https://github.com/facebook/docusaurus/issues/3567#issuecomment-706973805 */ function getLayoutBrokenLinksHelpMessage() { - const flatList = Object.entries(allBrokenLinks).flatMap( + const flatList = Object.entries(brokenPathsMap).flatMap( ([pagePage, brokenLinks]) => brokenLinks.map((brokenLink) => ({pagePage, brokenLink})), ); @@ -146,102 +219,78 @@ Please check the pages of your site in the list below, and make sure you don't r Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass.${getLayoutBrokenLinksHelpMessage()} Exhaustive list of all broken links found: -${Object.entries(allBrokenLinks) - .map(([pagePath, brokenLinks]) => - pageBrokenLinksMessage(pagePath, brokenLinks), +${Object.entries(brokenPathsMap) + .map(([pagePath, brokenPaths]) => + createBrokenLinksMessage(pagePath, brokenPaths), ) .join('\n')} `; } -async function isExistingFile(filePath: string) { - try { - return (await fs.stat(filePath)).isFile(); - } catch { - return false; - } -} +function splitBrokenLinks(brokenLinks: BrokenLinksMap): { + brokenPaths: BrokenLinksMap; + brokenAnchors: BrokenLinksMap; +} { + const brokenPaths: BrokenLinksMap = {}; + const brokenAnchors: BrokenLinksMap = {}; -// If a file actually exist on the file system, we know the link is valid -// even if docusaurus does not know about this file, so we don't report it -async function filterExistingFileLinks({ - baseUrl, - outDir, - allCollectedLinks, -}: { - baseUrl: string; - outDir: string; - allCollectedLinks: {[location: string]: string[]}; -}): Promise<{[location: string]: string[]}> { - async function linkFileExists(link: string) { - // /baseUrl/javadoc/ -> /outDir/javadoc - const baseFilePath = onlyPathname( - removeSuffix(`${outDir}/${removePrefix(link, baseUrl)}`, '/'), + Object.entries(brokenLinks).forEach(([pathname, pageBrokenLinks]) => { + const [anchorBrokenLinks, pathBrokenLinks] = _.partition( + pageBrokenLinks, + (link) => link.anchor, ); - // -> /outDir/javadoc - // -> /outDir/javadoc.html - // -> /outDir/javadoc/index.html - const filePathsToTry: string[] = [baseFilePath]; - if (!path.extname(baseFilePath)) { - filePathsToTry.push( - `${baseFilePath}.html`, - path.join(baseFilePath, 'index.html'), - ); + if (pathBrokenLinks.length > 0) { + brokenPaths[pathname] = pathBrokenLinks; } - - for (const file of filePathsToTry) { - if (await isExistingFile(file)) { - return true; - } + if (anchorBrokenLinks.length > 0) { + brokenAnchors[pathname] = anchorBrokenLinks; } - return false; + }); + + return {brokenPaths, brokenAnchors}; +} + +function reportBrokenLinks({ + brokenLinks, + onBrokenLinks, + onBrokenAnchors, +}: { + brokenLinks: BrokenLinksMap; + onBrokenLinks: ReportingSeverity; + onBrokenAnchors: ReportingSeverity; +}) { + // We need to split the broken links reporting in 2 for better granularity + // This is because we need to report broken path/anchors independently + // For v3.x retro-compatibility, we can't throw by default for broken anchors + // TODO Docusaurus v4: make onBrokenAnchors throw by default? + const {brokenPaths, brokenAnchors} = splitBrokenLinks(brokenLinks); + + const pathErrorMessage = createBrokenPathsMessage(brokenPaths); + if (pathErrorMessage) { + logger.report(onBrokenLinks)(pathErrorMessage); } - return combinePromises( - _.mapValues(allCollectedLinks, async (links) => - ( - await Promise.all( - links.map(async (link) => ((await linkFileExists(link)) ? '' : link)), - ) - ).filter(Boolean), - ), - ); + const anchorErrorMessage = createBrokenAnchorsMessage(brokenAnchors); + if (anchorErrorMessage) { + logger.report(onBrokenAnchors)(anchorErrorMessage); + } } export async function handleBrokenLinks({ - allCollectedLinks, + collectedLinks, onBrokenLinks, + onBrokenAnchors, routes, - baseUrl, - outDir, }: { - allCollectedLinks: {[location: string]: string[]}; + collectedLinks: CollectedLinks; onBrokenLinks: ReportingSeverity; + onBrokenAnchors: ReportingSeverity; routes: RouteConfig[]; - baseUrl: string; - outDir: string; }): Promise { - if (onBrokenLinks === 'ignore') { + if (onBrokenLinks === 'ignore' && onBrokenAnchors === 'ignore') { return; } - - // If we link to a file like /myFile.zip, and the file actually exist for the - // file system. It is not a broken link, it may simply be a link to an - // existing static file... - const allCollectedLinksFiltered = await filterExistingFileLinks({ - allCollectedLinks, - baseUrl, - outDir, - }); - - const allBrokenLinks = getAllBrokenLinks({ - allCollectedLinks: allCollectedLinksFiltered, - routes, - }); - - const errorMessage = getBrokenLinksErrorMessage(allBrokenLinks); - if (errorMessage) { - logger.report(onBrokenLinks)(errorMessage); - } + const brokenLinks = getBrokenLinks({routes, collectedLinks}); + reportBrokenLinks({brokenLinks, onBrokenLinks, onBrokenAnchors}); } diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index c020a09cae9f..eafabc3cedad 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -46,6 +46,7 @@ export const DEFAULT_CONFIG: Pick< DocusaurusConfig, | 'i18n' | 'onBrokenLinks' + | 'onBrokenAnchors' | 'onBrokenMarkdownLinks' | 'onDuplicateRoutes' | 'plugins' @@ -66,6 +67,7 @@ export const DEFAULT_CONFIG: Pick< > = { i18n: DEFAULT_I18N_CONFIG, onBrokenLinks: 'throw', + onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw onBrokenMarkdownLinks: 'warn', onDuplicateRoutes: 'warn', plugins: [], @@ -211,6 +213,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), diff --git a/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap b/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap index 9567720d587f..7299deaf977a 100644 --- a/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap +++ b/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap @@ -16,6 +16,7 @@ exports[`base webpack config creates webpack aliases 1`] = ` "@docusaurus/renderRoutes": "../../../../client/exports/renderRoutes.ts", "@docusaurus/router": "../../../../client/exports/router.ts", "@docusaurus/useBaseUrl": "../../../../client/exports/useBaseUrl.ts", + "@docusaurus/useBrokenLinks": "../../../../client/exports/useBrokenLinks.ts", "@docusaurus/useDocusaurusContext": "../../../../client/exports/useDocusaurusContext.ts", "@docusaurus/useGlobalData": "../../../../client/exports/useGlobalData.ts", "@docusaurus/useIsBrowser": "../../../../client/exports/useIsBrowser.ts", diff --git a/packages/docusaurus/src/webpack/aliases/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus/src/webpack/aliases/__tests__/__snapshots__/index.test.ts.snap index c9738c847d56..46390d21c92d 100644 --- a/packages/docusaurus/src/webpack/aliases/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus/src/webpack/aliases/__tests__/__snapshots__/index.test.ts.snap @@ -16,6 +16,7 @@ exports[`getDocusaurusAliases returns appropriate webpack aliases 1`] = ` "@docusaurus/renderRoutes": "/packages/docusaurus/src/client/exports/renderRoutes.ts", "@docusaurus/router": "/packages/docusaurus/src/client/exports/router.ts", "@docusaurus/useBaseUrl": "/packages/docusaurus/src/client/exports/useBaseUrl.ts", + "@docusaurus/useBrokenLinks": "/packages/docusaurus/src/client/exports/useBrokenLinks.ts", "@docusaurus/useDocusaurusContext": "/packages/docusaurus/src/client/exports/useDocusaurusContext.ts", "@docusaurus/useGlobalData": "/packages/docusaurus/src/client/exports/useGlobalData.ts", "@docusaurus/useIsBrowser": "/packages/docusaurus/src/client/exports/useIsBrowser.ts", diff --git a/website/docs/advanced/routing.mdx b/website/docs/advanced/routing.mdx index 5bf943072ba6..e9e96d4892c7 100644 --- a/website/docs/advanced/routing.mdx +++ b/website/docs/advanced/routing.mdx @@ -265,26 +265,18 @@ export function PageRoute() { Docusaurus builds a [single-page application](https://developer.mozilla.org/en-US/docs/Glossary/SPA), where route transitions are done through the `history.push()` method of React router. This operation is done on the client side. However, the prerequisite for a route transition to happen this way is that the target URL is known to our router. Otherwise, the router catches this path and displays a 404 page instead. -If you put some HTML pages under the `static` folder, they will be copied to the build output and therefore become accessible as part of your website, yet it's not part of the Docusaurus route system. We provide a `pathname://` protocol that allows you to redirect to another part of your domain in a non-SPA fashion, as if this route is an external link. Try the following two links: +If you put some HTML pages under the `static` folder, they will be copied to the build output and therefore become accessible as part of your website, yet it's not part of the Docusaurus route system. We provide a `pathname://` protocol that allows you to redirect to another part of your domain in a non-SPA fashion, as if this route is an external link. ```md -- [/pure-html](/pure-html) - [pathname:///pure-html](pathname:///pure-html) ``` -- [`/pure-html`](/pure-html) - [`pathname:///pure-html`](pathname:///pure-html) -:::tip - -The first link will **not** trigger a "broken links detected" check during the production build, because the respective file actually exists. Nevertheless, when you click on the link, a "page not found" will be displayed until you refresh. - -::: - The `pathname://` protocol is useful for referencing any content in the static folder. For example, Docusaurus would convert [all Markdown static assets to require() calls](../guides/markdown-features/markdown-features-assets.mdx#static-assets). You can use `pathname://` to keep it a regular link instead of being hashed by Webpack. ```md title="my-doc.md" diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 86edf0391644..5a187dfcaaff 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -188,7 +188,7 @@ export default { The behavior of Docusaurus when it detects any broken link. -By default, it throws an error, to ensure you never ship any broken link, but you can lower this security if needed. +By default, it throws an error, to ensure you never ship any broken link. :::note @@ -196,13 +196,21 @@ The broken links detection is only available for a production build (`docusaurus ::: +### `onBrokenAnchors` {#onBrokenAnchors} + +- Type: `'ignore' | 'log' | 'warn' | 'throw'` + +The behavior of Docusaurus when it detects any broken anchor declared with the `Heading` component of Docusaurus. + +By default, it prints a warning, to let you know about your broken anchors. + ### `onBrokenMarkdownLinks` {#onBrokenMarkdownLinks} - Type: `'ignore' | 'log' | 'warn' | 'throw'` The behavior of Docusaurus when it detects any broken Markdown link. -By default, it prints a warning, to let you know about your broken Markdown link, but you can change this security if needed. +By default, it prints a warning, to let you know about your broken Markdown link. ### `onDuplicateRoutes` {#onDuplicateRoutes} diff --git a/website/docs/api/plugins/plugin-content-docs.mdx b/website/docs/api/plugins/plugin-content-docs.mdx index 3613e11fe4a9..916e3a3caa56 100644 --- a/website/docs/api/plugins/plugin-content-docs.mdx +++ b/website/docs/api/plugins/plugin-content-docs.mdx @@ -42,10 +42,10 @@ Accepted fields: | `include` | `string[]` | `['**/*.{md,mdx}']` | Array of glob patterns matching Markdown files to be built, relative to the content path. | | `exclude` | `string[]` | _See example configuration_ | Array of glob patterns matching Markdown files to be excluded. Serves as refinement based on the `include` option. | | `sidebarPath` | false \| string | `undefined` | Path to sidebar configuration. Use `false` to disable sidebars, or `undefined` to create a fully autogenerated sidebar. | -| `sidebarCollapsible` | `boolean` | `true` | Whether sidebar categories are collapsible by default. See also [Collapsible categories](/docs/sidebar#collapsible-categories) | -| `sidebarCollapsed` | `boolean` | `true` | Whether sidebar categories are collapsed by default. See also [Expanded categories by default](/docs/sidebar#expanded-categories-by-default) | -| `sidebarItemsGenerator` | SidebarGenerator | _Omitted_ | Function used to replace the sidebar items of type `'autogenerated'` with real sidebar items (docs, categories, links...). See also [Customize the sidebar items generator](/docs/sidebar#customize-the-sidebar-items-generator) | -| `numberPrefixParser` | boolean \| PrefixParser | _Omitted_ | Custom parsing logic to extract number prefixes from file names. Use `false` to disable this behavior and leave the docs untouched, and `true` to use the default parser. See also [Using number prefixes](/docs/sidebar#using-number-prefixes) | +| `sidebarCollapsible` | `boolean` | `true` | Whether sidebar categories are collapsible by default. See also [Collapsible categories](/docs/sidebar/items#collapsible-categories) | +| `sidebarCollapsed` | `boolean` | `true` | Whether sidebar categories are collapsed by default. See also [Expanded categories by default](/docs/sidebar/items#expanded-categories-by-default) | +| `sidebarItemsGenerator` | SidebarGenerator | _Omitted_ | Function used to replace the sidebar items of type `'autogenerated'` with real sidebar items (docs, categories, links...). See also [Customize the sidebar items generator](/docs/sidebar/autogenerated#customize-the-sidebar-items-generator) | +| `numberPrefixParser` | boolean \| PrefixParser | _Omitted_ | Custom parsing logic to extract number prefixes from file names. Use `false` to disable this behavior and leave the docs untouched, and `true` to use the default parser. See also [Using number prefixes](/docs/sidebar/autogenerated#using-number-prefixes) | | `docsRootComponent` | `string` | `'@theme/DocsRoot'` | Parent component of all the docs plugin pages (including all versions). Stays mounted when navigation between docs pages and versions. | | `docVersionRootComponent` | `string` | `'@theme/DocVersionLayout'` | Parent component of all docs pages of an individual version (doc pages with sidebars, tags pages). Stays mounted when navigation between pages of that specific version. | | `docRootComponent` | `string` | `'@theme/DocPage'` | Parent component of all doc pages with sidebars (regular docs pages, category generated index pages). Stays mounted when navigation between such pages. | @@ -275,7 +275,7 @@ Accepted fields: | `title` | `string` | Markdown title or `id` | The text title of your document. Used for the page metadata and as a fallback value in multiple places (sidebar, next/previous buttons...). Automatically added at the top of your doc if it does not contain any Markdown title. | | `pagination_label` | `string` | `sidebar_label` or `title` | The text used in the document next/previous buttons for this document. | | `sidebar_label` | `string` | `title` | The text shown in the document sidebar for this document. | -| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadata](/docs/sidebar#autogenerated-sidebar-metadata). | +| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadata](/docs/sidebar/autogenerated#autogenerated-sidebar-metadata). | | `sidebar_class_name` | `string` | `undefined` | Gives the corresponding sidebar label a special class name when using autogenerated sidebars. | | `sidebar_custom_props` | `object` | `undefined` | Assign [custom props](../../guides/docs/sidebar/index.mdx#passing-custom-props) to the sidebar item referencing this doc | | `displayed_sidebar` | `string` | `undefined` | Force the display of a given sidebar when browsing the current document. Read the [multiple sidebars guide](../../guides/docs/sidebar/multiple-sidebars.mdx) for details. | @@ -285,7 +285,7 @@ Accepted fields: | `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. | | `pagination_next` | string \| null | Next doc in the sidebar | The ID of the documentation you want the "Next" pagination to link to. Use `null` to disable showing "Next" for this page. | | `pagination_prev` | string \| null | Previous doc in the sidebar | The ID of the documentation you want the "Previous" pagination to link to. Use `null` to disable showing "Previous" for this page. | -| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar#using-number-prefixes). | +| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar/autogenerated#using-number-prefixes). | | `custom_edit_url` | string \| null | Computed using the `editUrl` plugin option | The URL for editing this document. Use `null` to disable showing "Edit this page" for this page. | | `keywords` | `string[]` | `undefined` | Keywords meta tag for the document page, for search engines. | | `description` | `string` | The first line of Markdown content | The description of your document, which will become the `` and `` in ``, used by search engines. | diff --git a/website/docs/cli.mdx b/website/docs/cli.mdx index 2800d7f6d967..369779788afa 100644 --- a/website/docs/cli.mdx +++ b/website/docs/cli.mdx @@ -180,7 +180,7 @@ By default, the files are written in `website/i18n//...`. ### `docusaurus write-heading-ids [siteDir] [files]` {#docusaurus-write-heading-ids-sitedir} -Add [explicit heading IDs](./guides/markdown-features/markdown-features-toc.mdx#explicit-ids) to the Markdown documents of your site. +Add [explicit heading IDs](./guides/markdown-features/markdown-features-toc.mdx#heading-ids) to the Markdown documents of your site. | Name | Default | Description | | --- | --- | --- | diff --git a/website/docs/docusaurus-core.mdx b/website/docs/docusaurus-core.mdx index c14a53185e75..cdb5a6fcf4a1 100644 --- a/website/docs/docusaurus-core.mdx +++ b/website/docs/docusaurus-core.mdx @@ -605,6 +605,49 @@ const MyComponent = () => { }; ``` +### `useBrokenLinks` {#useBrokenLinks} + +React hook to access the Docusaurus broken link checker APIs, exposing a way for a Docusaurus pages to report and collect their links and anchors. + +:::warning + +This is an **advanced** API that **most Docusaurus users don't need to use directly**. + +It is already **built-in** in existing high-level components: + +- the [``](#link) component will collect links for you +- the `@theme/Heading` (used for Markdown headings) will collect anchors + +Use `useBrokenLinks()` if you implement your own `` or `` component. + +::: + +Usage example: + +```js title="MyHeading.js" +import useBrokenLinks from '@docusaurus/useBrokenLinks'; + +export default function MyHeading({id, ...props}): JSX.Element { + const brokenLinks = useBrokenLinks(); + + brokenLinks.collectAnchor(id); + + return

Heading

; +} +``` + +```js title="MyLink.js" +import useBrokenLinks from '@docusaurus/useBrokenLinks'; + +export default function MyLink({targetLink, ...props}): JSX.Element { + const brokenLinks = useBrokenLinks(); + + brokenLinks.collectLink(targetLink); + + return Link; +} +``` + ## Functions {#functions} ### `interpolate` {#interpolate-1} diff --git a/website/docs/guides/docs/sidebar/autogenerated.mdx b/website/docs/guides/docs/sidebar/autogenerated.mdx index 000e1e4cbdcf..7e3bfcf0a005 100644 --- a/website/docs/guides/docs/sidebar/autogenerated.mdx +++ b/website/docs/guides/docs/sidebar/autogenerated.mdx @@ -371,7 +371,7 @@ customProps: :::info -If the `link` is explicitly specified, Docusaurus will not apply any [default conventions](items.mdx#category-index-convention). +If the `link` is explicitly specified, Docusaurus will not apply any [default conventions](#category-index-convention). The doc links can be specified relatively, e.g. if the category is generated with the `guides` directory, `"link": {"type": "doc", "id": "intro"}` will be resolved to the ID `guides/intro`, only falling back to `intro` if a doc with the former ID doesn't exist. diff --git a/website/docs/guides/docs/versioning.mdx b/website/docs/guides/docs/versioning.mdx index 527a71facf02..08fab227b542 100644 --- a/website/docs/guides/docs/versioning.mdx +++ b/website/docs/guides/docs/versioning.mdx @@ -106,7 +106,7 @@ npm run docusaurus docs:version 1.1.0 When tagging a new version, the document versioning mechanism will: - Copy the full `docs/` folder contents into a new `versioned_docs/version-[versionName]/` folder. -- Create a versioned sidebars file based from your current [sidebar](docs-introduction.mdx#sidebar) configuration (if it exists) - saved as `versioned_sidebars/version-[versionName]-sidebars.json`. +- Create a versioned sidebars file based from your current [sidebar](./sidebar/index.mdx) configuration (if it exists) - saved as `versioned_sidebars/version-[versionName]-sidebars.json`. - Append the new version number to `versions.json`. ### Creating new docs {#creating-new-docs} diff --git a/website/docs/i18n/i18n-tutorial.mdx b/website/docs/i18n/i18n-tutorial.mdx index b74896547cd7..a88e2f0a388b 100644 --- a/website/docs/i18n/i18n-tutorial.mdx +++ b/website/docs/i18n/i18n-tutorial.mdx @@ -445,7 +445,7 @@ Generated IDs are not always a good fit for localized sites, as it requires you + [link](#bonjour-le-monde) ``` -For localized sites, it is recommended to use **[explicit heading IDs](../guides/markdown-features/markdown-features-toc.mdx#explicit-ids)**. +For localized sites, it is recommended to use **[explicit heading IDs](../guides/markdown-features/markdown-features-toc.mdx#heading-ids)**. ::: diff --git a/website/docs/seo.mdx b/website/docs/seo.mdx index 147bf99657c0..031ab1ddf340 100644 --- a/website/docs/seo.mdx +++ b/website/docs/seo.mdx @@ -211,7 +211,7 @@ For example, [`/examples/noIndex`](/examples/noIndex) is not included in the [Do ## Human readable links {#human-readable-links} -Docusaurus uses your file names as links, but you can always change that using slugs, see this [tutorial](./guides/docs/docs-introduction.mdx#document-id) for more details. +Docusaurus uses your file names as links, but you can always change that using slugs, see this [tutorial](./guides/docs/docs-create-doc.mdx#document-id) for more details. ## Structured content {#structured-content} diff --git a/website/docs/using-plugins.mdx b/website/docs/using-plugins.mdx index 28e25e491541..92d86097d717 100644 --- a/website/docs/using-plugins.mdx +++ b/website/docs/using-plugins.mdx @@ -114,7 +114,7 @@ At most one plugin instance can be the "default plugin instance", by omitting th ## Using themes {#using-themes} -Themes are loaded in the exact same way as plugins. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./swizzling.mdx#theme-aliases). +Themes are loaded in the exact same way as plugins. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./advanced/client.mdx#theme-aliases). :::tip diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index b141c39df753..99fc07c30732 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -206,7 +206,11 @@ export default async function createConfigAsync() { }, }, onBrokenLinks: - isBuildFast || + isVersioningDisabled || + process.env.DOCUSAURUS_CURRENT_LOCALE !== defaultLocale + ? 'warn' + : 'throw', + onBrokenAnchors: isVersioningDisabled || process.env.DOCUSAURUS_CURRENT_LOCALE !== defaultLocale ? 'warn' diff --git a/website/versioned_docs/version-2.x/advanced/routing.mdx b/website/versioned_docs/version-2.x/advanced/routing.mdx index e6513637e379..9e3551fdd37e 100644 --- a/website/versioned_docs/version-2.x/advanced/routing.mdx +++ b/website/versioned_docs/version-2.x/advanced/routing.mdx @@ -265,26 +265,18 @@ export function PageRoute() { Docusaurus builds a [single-page application](https://developer.mozilla.org/en-US/docs/Glossary/SPA), where route transitions are done through the `history.push()` method of React router. This operation is done on the client side. However, the prerequisite for a route transition to happen this way is that the target URL is known to our router. Otherwise, the router catches this path and displays a 404 page instead. -If you put some HTML pages under the `static` folder, they will be copied to the build output and therefore become accessible as part of your website, yet it's not part of the Docusaurus route system. We provide a `pathname://` protocol that allows you to redirect to another part of your domain in a non-SPA fashion, as if this route is an external link. Try the following two links: +If you put some HTML pages under the `static` folder, they will be copied to the build output and therefore become accessible as part of your website, yet it's not part of the Docusaurus route system. We provide a `pathname://` protocol that allows you to redirect to another part of your domain in a non-SPA fashion, as if this route is an external link. ```md -- [/pure-html](/pure-html) - [pathname:///pure-html](pathname:///pure-html) ``` -- [`/pure-html`](/pure-html) - [`pathname:///pure-html`](pathname:///pure-html) -:::tip - -The first link will **not** trigger a "broken links detected" check during the production build, because the respective file actually exists. Nevertheless, when you click on the link, a "page not found" will be displayed until you refresh. - -::: - The `pathname://` protocol is useful for referencing any content in the static folder. For example, Docusaurus would convert [all Markdown static assets to require() calls](../guides/markdown-features/markdown-features-assets.mdx#static-assets). You can use `pathname://` to keep it a regular link instead of being hashed by Webpack. ```md title="my-doc.md" diff --git a/website/versioned_docs/version-2.x/api/plugins/plugin-content-docs.mdx b/website/versioned_docs/version-2.x/api/plugins/plugin-content-docs.mdx index e8343d9742cc..cd98d4e99e1c 100644 --- a/website/versioned_docs/version-2.x/api/plugins/plugin-content-docs.mdx +++ b/website/versioned_docs/version-2.x/api/plugins/plugin-content-docs.mdx @@ -42,10 +42,10 @@ Accepted fields: | `include` | `string[]` | `['**/*.{md,mdx}']` | Array of glob patterns matching Markdown files to be built, relative to the content path. | | `exclude` | `string[]` | _See example configuration_ | Array of glob patterns matching Markdown files to be excluded. Serves as refinement based on the `include` option. | | `sidebarPath` | false \| string | `undefined` | Path to sidebar configuration. Use `false` to disable sidebars, or `undefined` to create a fully autogenerated sidebar. | -| `sidebarCollapsible` | `boolean` | `true` | Whether sidebar categories are collapsible by default. See also [Collapsible categories](/docs/sidebar#collapsible-categories) | -| `sidebarCollapsed` | `boolean` | `true` | Whether sidebar categories are collapsed by default. See also [Expanded categories by default](/docs/sidebar#expanded-categories-by-default) | -| `sidebarItemsGenerator` | SidebarGenerator | _Omitted_ | Function used to replace the sidebar items of type `'autogenerated'` with real sidebar items (docs, categories, links...). See also [Customize the sidebar items generator](/docs/sidebar#customize-the-sidebar-items-generator) | -| `numberPrefixParser` | boolean \| PrefixParser | _Omitted_ | Custom parsing logic to extract number prefixes from file names. Use `false` to disable this behavior and leave the docs untouched, and `true` to use the default parser. See also [Using number prefixes](/docs/sidebar#using-number-prefixes) | +| `sidebarCollapsible` | `boolean` | `true` | Whether sidebar categories are collapsible by default. See also [Collapsible categories](/docs/sidebar/items#collapsible-categories) | +| `sidebarCollapsed` | `boolean` | `true` | Whether sidebar categories are collapsed by default. See also [Expanded categories by default](/docs/sidebar/items#expanded-categories-by-default) | +| `sidebarItemsGenerator` | SidebarGenerator | _Omitted_ | Function used to replace the sidebar items of type `'autogenerated'` with real sidebar items (docs, categories, links...). See also [Customize the sidebar items generator](/docs/sidebar/autogenerated#customize-the-sidebar-items-generator) | +| `numberPrefixParser` | boolean \| PrefixParser | _Omitted_ | Custom parsing logic to extract number prefixes from file names. Use `false` to disable this behavior and leave the docs untouched, and `true` to use the default parser. See also [Using number prefixes](/docs/sidebar/autogenerated#using-number-prefixes) | | `docLayoutComponent` | `string` | `'@theme/DocPage'` | Root layout component of each doc page. Provides the version data context, and is not unmounted when switching docs. | | `docItemComponent` | `string` | `'@theme/DocItem'` | Main doc container, with TOC, pagination, etc. | | `docTagsListComponent` | `string` | `'@theme/DocTagsListPage'` | Root component of the tags list page | @@ -273,7 +273,7 @@ Accepted fields: | `title` | `string` | Markdown title or `id` | The text title of your document. Used for the page metadata and as a fallback value in multiple places (sidebar, next/previous buttons...). Automatically added at the top of your doc if it does not contain any Markdown title. | | `pagination_label` | `string` | `sidebar_label` or `title` | The text used in the document next/previous buttons for this document. | | `sidebar_label` | `string` | `title` | The text shown in the document sidebar for this document. | -| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadata](/docs/sidebar#autogenerated-sidebar-metadata). | +| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadata](/docs/sidebar/autogenerated#autogenerated-sidebar-metadata). | | `sidebar_class_name` | `string` | `undefined` | Gives the corresponding sidebar label a special class name when using autogenerated sidebars. | | `sidebar_custom_props` | `object` | `undefined` | Assign [custom props](../../guides/docs/sidebar/index.mdx#passing-custom-props) to the sidebar item referencing this doc | | `displayed_sidebar` | `string` | `undefined` | Force the display of a given sidebar when browsing the current document. Read the [multiple sidebars guide](../../guides/docs/sidebar/multiple-sidebars.mdx) for details. | @@ -283,7 +283,7 @@ Accepted fields: | `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. | | `pagination_next` | string \| null | Next doc in the sidebar | The ID of the documentation you want the "Next" pagination to link to. Use `null` to disable showing "Next" for this page. | | `pagination_prev` | string \| null | Previous doc in the sidebar | The ID of the documentation you want the "Previous" pagination to link to. Use `null` to disable showing "Previous" for this page. | -| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar#using-number-prefixes). | +| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar/autogenerated#using-number-prefixes). | | `custom_edit_url` | `string` | Computed using the `editUrl` plugin option | The URL for editing this document. | | `keywords` | `string[]` | `undefined` | Keywords meta tag for the document page, for search engines. | | `description` | `string` | The first line of Markdown content | The description of your document, which will become the `` and `` in ``, used by search engines. | diff --git a/website/versioned_docs/version-2.x/cli.mdx b/website/versioned_docs/version-2.x/cli.mdx index aaa652a48d5a..551b560aef84 100644 --- a/website/versioned_docs/version-2.x/cli.mdx +++ b/website/versioned_docs/version-2.x/cli.mdx @@ -176,7 +176,7 @@ By default, the files are written in `website/i18n//...`. ### `docusaurus write-heading-ids [siteDir] [files]` {#docusaurus-write-heading-ids-sitedir} -Add [explicit heading IDs](./guides/markdown-features/markdown-features-toc.mdx#explicit-ids) to the Markdown documents of your site. +Add [explicit heading IDs](./guides/markdown-features/markdown-features-toc.mdx#heading-ids) to the Markdown documents of your site. | Name | Default | Description | | --- | --- | --- | diff --git a/website/versioned_docs/version-2.x/deployment.mdx b/website/versioned_docs/version-2.x/deployment.mdx index 0c49003366ba..c45e7fc6c10d 100644 --- a/website/versioned_docs/version-2.x/deployment.mdx +++ b/website/versioned_docs/version-2.x/deployment.mdx @@ -57,7 +57,7 @@ Use [slorber/trailing-slash-guide](https://github.com/slorber/trailing-slash-gui ## Using environment variables {#using-environment-variables} -Putting potentially sensitive information in the environment is common practice. However, in a typical Docusaurus website, the `docusaurus.config.js` file is the only interface to the Node.js environment (see [our architecture overview](advanced/architecture.mdx)), while everything else—MDX pages, React components... are client side and do not have direct access to the `process` global. In this case, you can consider using [`customFields`](api/docusaurus.config.js.mdx#customFields) to pass environment variables to the client side. +Putting potentially sensitive information in the environment is common practice. However, in a typical Docusaurus website, the `docusaurus.config.js` file is the only interface to the Node.js environment (see [our architecture overview](advanced/architecture.mdx)), while everything else—MDX pages, React components... are client side and do not have direct access to the `process` global. In this case, you can consider using [`customFields`](api/docusaurus.config.js.mdx#customfields) to pass environment variables to the client side. ```js title="docusaurus.config.js" // If you are using dotenv (https://www.npmjs.com/package/dotenv) diff --git a/website/versioned_docs/version-2.x/guides/docs/sidebar/autogenerated.mdx b/website/versioned_docs/version-2.x/guides/docs/sidebar/autogenerated.mdx index 4ac6fa6e620f..6d9a074f19d6 100644 --- a/website/versioned_docs/version-2.x/guides/docs/sidebar/autogenerated.mdx +++ b/website/versioned_docs/version-2.x/guides/docs/sidebar/autogenerated.mdx @@ -371,7 +371,7 @@ customProps: :::info -If the `link` is explicitly specified, Docusaurus will not apply any [default conventions](items.mdx#category-index-convention). +If the `link` is explicitly specified, Docusaurus will not apply any [default conventions](#category-index-convention). The doc links can be specified relatively, e.g. if the category is generated with the `guides` directory, `"link": {"type": "doc", "id": "intro"}` will be resolved to the ID `guides/intro`, only falling back to `intro` if a doc with the former ID doesn't exist. diff --git a/website/versioned_docs/version-2.x/guides/docs/versioning.mdx b/website/versioned_docs/version-2.x/guides/docs/versioning.mdx index 2b5657f48d81..60e41494d745 100644 --- a/website/versioned_docs/version-2.x/guides/docs/versioning.mdx +++ b/website/versioned_docs/version-2.x/guides/docs/versioning.mdx @@ -106,7 +106,7 @@ npm run docusaurus docs:version 1.1.0 When tagging a new version, the document versioning mechanism will: - Copy the full `docs/` folder contents into a new `versioned_docs/version-[versionName]/` folder. -- Create a versioned sidebars file based from your current [sidebar](docs-introduction.mdx#sidebar) configuration (if it exists) - saved as `versioned_sidebars/version-[versionName]-sidebars.json`. +- Create a versioned sidebars file based from your current [sidebar](./sidebar/index.mdx) configuration (if it exists) - saved as `versioned_sidebars/version-[versionName]-sidebars.json`. - Append the new version number to `versions.json`. ### Creating new docs {#creating-new-docs} diff --git a/website/versioned_docs/version-2.x/guides/markdown-features/markdown-features-react.mdx b/website/versioned_docs/version-2.x/guides/markdown-features/markdown-features-react.mdx index ac2c4a2ebc94..606ddf5e2451 100644 --- a/website/versioned_docs/version-2.x/guides/markdown-features/markdown-features-react.mdx +++ b/website/versioned_docs/version-2.x/guides/markdown-features/markdown-features-react.mdx @@ -84,7 +84,7 @@ Since all doc files are parsed using MDX, anything that looks like HTML is actua Foo ``` -This behavior is different from Docusaurus 1. See also [Migrating from v1 to v2](../../migration/migration-manual.mdx#convert-style-attributes-to-style-objects-in-mdx). +This behavior is different from Docusaurus 1. See also [Migrating from v1 to v2](../../migration/migration-manual.mdx). In addition, MDX is not [100% compatible with CommonMark](https://github.com/facebook/docusaurus/issues/3018). Use the **[MDX playground](https://mdx-git-renovate-babel-monorepo-mdx.vercel.app/playground)** to ensure that your syntax is valid MDX. diff --git a/website/versioned_docs/version-2.x/i18n/i18n-tutorial.mdx b/website/versioned_docs/version-2.x/i18n/i18n-tutorial.mdx index de7c22845af1..6c80b02dcc00 100644 --- a/website/versioned_docs/version-2.x/i18n/i18n-tutorial.mdx +++ b/website/versioned_docs/version-2.x/i18n/i18n-tutorial.mdx @@ -437,7 +437,7 @@ Generated IDs are not always a good fit for localized sites, as it requires you + [link](#bonjour-le-monde) ``` -For localized sites, it is recommended to use **[explicit heading IDs](../guides/markdown-features/markdown-features-toc.mdx#explicit-ids)**. +For localized sites, it is recommended to use **[explicit heading IDs](../guides/markdown-features/markdown-features-toc.mdx#heading-ids)**. ::: diff --git a/website/versioned_docs/version-2.x/seo.mdx b/website/versioned_docs/version-2.x/seo.mdx index ea09f87d4ae8..a8af9c30c75a 100644 --- a/website/versioned_docs/version-2.x/seo.mdx +++ b/website/versioned_docs/version-2.x/seo.mdx @@ -152,7 +152,7 @@ For example, [`/examples/noIndex`](/examples/noIndex) is not included in the [Do ## Human readable links {#human-readable-links} -Docusaurus uses your file names as links, but you can always change that using slugs, see this [tutorial](./guides/docs/docs-introduction.mdx#document-id) for more details. +Docusaurus uses your file names as links, but you can always change that using slugs, see this [tutorial](./guides/docs/docs-create-doc.mdx#document-id) for more details. ## Structured content {#structured-content} diff --git a/website/versioned_docs/version-2.x/using-plugins.mdx b/website/versioned_docs/version-2.x/using-plugins.mdx index c40672830ee6..8d7accf505b5 100644 --- a/website/versioned_docs/version-2.x/using-plugins.mdx +++ b/website/versioned_docs/version-2.x/using-plugins.mdx @@ -114,7 +114,7 @@ At most one plugin instance can be the "default plugin instance", by omitting th ## Using themes {#using-themes} -Themes are loaded in the exact same way as plugins. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./swizzling.mdx#theme-aliases). +Themes are loaded in the exact same way as plugins. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./advanced/client.mdx#theme-aliases). :::tip diff --git a/website/versioned_docs/version-3.0.1/advanced/routing.mdx b/website/versioned_docs/version-3.0.1/advanced/routing.mdx index 5bf943072ba6..e9e96d4892c7 100644 --- a/website/versioned_docs/version-3.0.1/advanced/routing.mdx +++ b/website/versioned_docs/version-3.0.1/advanced/routing.mdx @@ -265,26 +265,18 @@ export function PageRoute() { Docusaurus builds a [single-page application](https://developer.mozilla.org/en-US/docs/Glossary/SPA), where route transitions are done through the `history.push()` method of React router. This operation is done on the client side. However, the prerequisite for a route transition to happen this way is that the target URL is known to our router. Otherwise, the router catches this path and displays a 404 page instead. -If you put some HTML pages under the `static` folder, they will be copied to the build output and therefore become accessible as part of your website, yet it's not part of the Docusaurus route system. We provide a `pathname://` protocol that allows you to redirect to another part of your domain in a non-SPA fashion, as if this route is an external link. Try the following two links: +If you put some HTML pages under the `static` folder, they will be copied to the build output and therefore become accessible as part of your website, yet it's not part of the Docusaurus route system. We provide a `pathname://` protocol that allows you to redirect to another part of your domain in a non-SPA fashion, as if this route is an external link. ```md -- [/pure-html](/pure-html) - [pathname:///pure-html](pathname:///pure-html) ``` -- [`/pure-html`](/pure-html) - [`pathname:///pure-html`](pathname:///pure-html) -:::tip - -The first link will **not** trigger a "broken links detected" check during the production build, because the respective file actually exists. Nevertheless, when you click on the link, a "page not found" will be displayed until you refresh. - -::: - The `pathname://` protocol is useful for referencing any content in the static folder. For example, Docusaurus would convert [all Markdown static assets to require() calls](../guides/markdown-features/markdown-features-assets.mdx#static-assets). You can use `pathname://` to keep it a regular link instead of being hashed by Webpack. ```md title="my-doc.md" diff --git a/website/versioned_docs/version-3.0.1/api/plugins/plugin-content-docs.mdx b/website/versioned_docs/version-3.0.1/api/plugins/plugin-content-docs.mdx index d9238f65bb36..754a56d293d9 100644 --- a/website/versioned_docs/version-3.0.1/api/plugins/plugin-content-docs.mdx +++ b/website/versioned_docs/version-3.0.1/api/plugins/plugin-content-docs.mdx @@ -42,10 +42,10 @@ Accepted fields: | `include` | `string[]` | `['**/*.{md,mdx}']` | Array of glob patterns matching Markdown files to be built, relative to the content path. | | `exclude` | `string[]` | _See example configuration_ | Array of glob patterns matching Markdown files to be excluded. Serves as refinement based on the `include` option. | | `sidebarPath` | false \| string | `undefined` | Path to sidebar configuration. Use `false` to disable sidebars, or `undefined` to create a fully autogenerated sidebar. | -| `sidebarCollapsible` | `boolean` | `true` | Whether sidebar categories are collapsible by default. See also [Collapsible categories](/docs/sidebar#collapsible-categories) | -| `sidebarCollapsed` | `boolean` | `true` | Whether sidebar categories are collapsed by default. See also [Expanded categories by default](/docs/sidebar#expanded-categories-by-default) | -| `sidebarItemsGenerator` | SidebarGenerator | _Omitted_ | Function used to replace the sidebar items of type `'autogenerated'` with real sidebar items (docs, categories, links...). See also [Customize the sidebar items generator](/docs/sidebar#customize-the-sidebar-items-generator) | -| `numberPrefixParser` | boolean \| PrefixParser | _Omitted_ | Custom parsing logic to extract number prefixes from file names. Use `false` to disable this behavior and leave the docs untouched, and `true` to use the default parser. See also [Using number prefixes](/docs/sidebar#using-number-prefixes) | +| `sidebarCollapsible` | `boolean` | `true` | Whether sidebar categories are collapsible by default. See also [Collapsible categories](/docs/sidebar/items#collapsible-categories) | +| `sidebarCollapsed` | `boolean` | `true` | Whether sidebar categories are collapsed by default. See also [Expanded categories by default](/docs/sidebar/items#expanded-categories-by-default) | +| `sidebarItemsGenerator` | SidebarGenerator | _Omitted_ | Function used to replace the sidebar items of type `'autogenerated'` with real sidebar items (docs, categories, links...). See also [Customize the sidebar items generator](/docs/sidebar/autogenerated#customize-the-sidebar-items-generator) | +| `numberPrefixParser` | boolean \| PrefixParser | _Omitted_ | Custom parsing logic to extract number prefixes from file names. Use `false` to disable this behavior and leave the docs untouched, and `true` to use the default parser. See also [Using number prefixes](/docs/sidebar/autogenerated#using-number-prefixes) | | `docsRootComponent` | `string` | `'@theme/DocsRoot'` | Parent component of all the docs plugin pages (including all versions). Stays mounted when navigation between docs pages and versions. | | `docVersionRootComponent` | `string` | `'@theme/DocVersionLayout'` | Parent component of all docs pages of an individual version (doc pages with sidebars, tags pages). Stays mounted when navigation between pages of that specific version. | | `docRootComponent` | `string` | `'@theme/DocPage'` | Parent component of all doc pages with sidebars (regular docs pages, category generated index pages). Stays mounted when navigation between such pages. | @@ -275,7 +275,7 @@ Accepted fields: | `title` | `string` | Markdown title or `id` | The text title of your document. Used for the page metadata and as a fallback value in multiple places (sidebar, next/previous buttons...). Automatically added at the top of your doc if it does not contain any Markdown title. | | `pagination_label` | `string` | `sidebar_label` or `title` | The text used in the document next/previous buttons for this document. | | `sidebar_label` | `string` | `title` | The text shown in the document sidebar for this document. | -| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadata](/docs/sidebar#autogenerated-sidebar-metadata). | +| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadata](/docs/sidebar/autogenerated#autogenerated-sidebar-metadata). | | `sidebar_class_name` | `string` | `undefined` | Gives the corresponding sidebar label a special class name when using autogenerated sidebars. | | `sidebar_custom_props` | `object` | `undefined` | Assign [custom props](../../guides/docs/sidebar/index.mdx#passing-custom-props) to the sidebar item referencing this doc | | `displayed_sidebar` | `string` | `undefined` | Force the display of a given sidebar when browsing the current document. Read the [multiple sidebars guide](../../guides/docs/sidebar/multiple-sidebars.mdx) for details. | @@ -285,7 +285,7 @@ Accepted fields: | `toc_max_heading_level` | `number` | `3` | The max heading level shown in the table of contents. Must be between 2 and 6. | | `pagination_next` | string \| null | Next doc in the sidebar | The ID of the documentation you want the "Next" pagination to link to. Use `null` to disable showing "Next" for this page. | | `pagination_prev` | string \| null | Previous doc in the sidebar | The ID of the documentation you want the "Previous" pagination to link to. Use `null` to disable showing "Previous" for this page. | -| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar#using-number-prefixes). | +| `parse_number_prefixes` | `boolean` | `numberPrefixParser` plugin option | Whether number prefix parsing is disabled on this doc. See also [Using number prefixes](/docs/sidebar/autogenerated#using-number-prefixes). | | `custom_edit_url` | string \| null | Computed using the `editUrl` plugin option | The URL for editing this document. Use `null` to disable showing "Edit this page" for this page. | | `keywords` | `string[]` | `undefined` | Keywords meta tag for the document page, for search engines. | | `description` | `string` | The first line of Markdown content | The description of your document, which will become the `` and `` in ``, used by search engines. | diff --git a/website/versioned_docs/version-3.0.1/cli.mdx b/website/versioned_docs/version-3.0.1/cli.mdx index 0b38c01776f7..bb1f32c91d68 100644 --- a/website/versioned_docs/version-3.0.1/cli.mdx +++ b/website/versioned_docs/version-3.0.1/cli.mdx @@ -177,7 +177,7 @@ By default, the files are written in `website/i18n//...`. ### `docusaurus write-heading-ids [siteDir] [files]` {#docusaurus-write-heading-ids-sitedir} -Add [explicit heading IDs](./guides/markdown-features/markdown-features-toc.mdx#explicit-ids) to the Markdown documents of your site. +Add [explicit heading IDs](./guides/markdown-features/markdown-features-toc.mdx#heading-ids) to the Markdown documents of your site. | Name | Default | Description | | --- | --- | --- | diff --git a/website/versioned_docs/version-3.0.1/guides/docs/sidebar/autogenerated.mdx b/website/versioned_docs/version-3.0.1/guides/docs/sidebar/autogenerated.mdx index 000e1e4cbdcf..7e3bfcf0a005 100644 --- a/website/versioned_docs/version-3.0.1/guides/docs/sidebar/autogenerated.mdx +++ b/website/versioned_docs/version-3.0.1/guides/docs/sidebar/autogenerated.mdx @@ -371,7 +371,7 @@ customProps: :::info -If the `link` is explicitly specified, Docusaurus will not apply any [default conventions](items.mdx#category-index-convention). +If the `link` is explicitly specified, Docusaurus will not apply any [default conventions](#category-index-convention). The doc links can be specified relatively, e.g. if the category is generated with the `guides` directory, `"link": {"type": "doc", "id": "intro"}` will be resolved to the ID `guides/intro`, only falling back to `intro` if a doc with the former ID doesn't exist. diff --git a/website/versioned_docs/version-3.0.1/guides/docs/versioning.mdx b/website/versioned_docs/version-3.0.1/guides/docs/versioning.mdx index b473d69a1268..1fa34fb1c5f4 100644 --- a/website/versioned_docs/version-3.0.1/guides/docs/versioning.mdx +++ b/website/versioned_docs/version-3.0.1/guides/docs/versioning.mdx @@ -106,7 +106,7 @@ npm run docusaurus docs:version 1.1.0 When tagging a new version, the document versioning mechanism will: - Copy the full `docs/` folder contents into a new `versioned_docs/version-[versionName]/` folder. -- Create a versioned sidebars file based from your current [sidebar](docs-introduction.mdx#sidebar) configuration (if it exists) - saved as `versioned_sidebars/version-[versionName]-sidebars.json`. +- Create a versioned sidebars file based from your current [sidebar](./sidebar/index.mdx) configuration (if it exists) - saved as `versioned_sidebars/version-[versionName]-sidebars.json`. - Append the new version number to `versions.json`. ### Creating new docs {#creating-new-docs} diff --git a/website/versioned_docs/version-3.0.1/i18n/i18n-tutorial.mdx b/website/versioned_docs/version-3.0.1/i18n/i18n-tutorial.mdx index b74896547cd7..a88e2f0a388b 100644 --- a/website/versioned_docs/version-3.0.1/i18n/i18n-tutorial.mdx +++ b/website/versioned_docs/version-3.0.1/i18n/i18n-tutorial.mdx @@ -445,7 +445,7 @@ Generated IDs are not always a good fit for localized sites, as it requires you + [link](#bonjour-le-monde) ``` -For localized sites, it is recommended to use **[explicit heading IDs](../guides/markdown-features/markdown-features-toc.mdx#explicit-ids)**. +For localized sites, it is recommended to use **[explicit heading IDs](../guides/markdown-features/markdown-features-toc.mdx#heading-ids)**. ::: diff --git a/website/versioned_docs/version-3.0.1/seo.mdx b/website/versioned_docs/version-3.0.1/seo.mdx index 147bf99657c0..031ab1ddf340 100644 --- a/website/versioned_docs/version-3.0.1/seo.mdx +++ b/website/versioned_docs/version-3.0.1/seo.mdx @@ -211,7 +211,7 @@ For example, [`/examples/noIndex`](/examples/noIndex) is not included in the [Do ## Human readable links {#human-readable-links} -Docusaurus uses your file names as links, but you can always change that using slugs, see this [tutorial](./guides/docs/docs-introduction.mdx#document-id) for more details. +Docusaurus uses your file names as links, but you can always change that using slugs, see this [tutorial](./guides/docs/docs-create-doc.mdx#document-id) for more details. ## Structured content {#structured-content} diff --git a/website/versioned_docs/version-3.0.1/using-plugins.mdx b/website/versioned_docs/version-3.0.1/using-plugins.mdx index 28e25e491541..92d86097d717 100644 --- a/website/versioned_docs/version-3.0.1/using-plugins.mdx +++ b/website/versioned_docs/version-3.0.1/using-plugins.mdx @@ -114,7 +114,7 @@ At most one plugin instance can be the "default plugin instance", by omitting th ## Using themes {#using-themes} -Themes are loaded in the exact same way as plugins. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./swizzling.mdx#theme-aliases). +Themes are loaded in the exact same way as plugins. From the consumer perspective, the `themes` and `plugins` entries are interchangeable when installing and configuring a plugin. The only nuance is that themes are loaded after plugins, and it's possible for [a theme to override a plugin's default theme components](./advanced/client.mdx#theme-aliases). :::tip