feat(core): make broken link checker detect broken anchors - add onBrokenAnchors config (#9528)

Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
ozaki 2024-01-04 12:56:20 +01:00 committed by GitHub
parent 332a466893
commit fd49301a45
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1220 additions and 519 deletions

View file

@ -12,17 +12,17 @@ exports[`transformAsset plugin pathname protocol 1`] = `
exports[`transformAsset plugin transform md links to <a /> 1`] = `
"[asset](https://example.com/asset.pdf)
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} />
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} />
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
in paragraph <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
in paragraph <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset (2).pdf").default}>asset with URL encoded chars</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default + '#page=2'}>asset with hash</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} title="Title">asset</a>
[page](noUrl.md)
@ -36,24 +36,24 @@ in paragraph <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file
[assets](/github/!file-loader!/assets.pdf)
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}>asset</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static2/asset2.pdf").default}>asset2</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>staticAsset.pdf</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>@site/static/staticAsset.pdf</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default + '#page=2'} title="Title">@site/static/staticAsset.pdf</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default + '#page=2'} title="Title">@site/static/staticAsset.pdf</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf</a>, and <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>**awesome** staticAsset 2.pdf 'It is really "AWESOME"'</a>, but also <a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\`</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>Just staticAsset.pdf</a>, and <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>**awesome** staticAsset 2.pdf 'It is really "AWESOME"'</a>, but also <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAsset.pdf").default}>coded \`staticAsset 3.pdf\`</a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}><img alt="Clickable Docusaurus logo" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /></a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/staticAssetImage.png").default}><img alt="Clickable Docusaurus logo" src={require("!<PROJECT_ROOT>/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js!./static/staticAssetImage.png").default} width="200" height="200" /></a>
<a target="_blank" href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}><span style={{color: "red"}}>Stylized link to asset file</span></a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default}><span style={{color: "red"}}>Stylized link to asset file</span></a>
<a target="_blank" href={require("./data.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("./data.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON</a>
<a target="_blank" href={require("./static/static-json.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON</a>
<a target="_blank" data-noBrokenLinkCheck={true} href={require("./static/static-json.raw!=!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON</a>
"
`;

View file

@ -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',

View file

@ -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;
}

View file

@ -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 <As {...props} id={undefined} />;
}
brokenLinks.collectAnchor(id);
const anchorTitle = translate(
{
id: 'theme.common.headingLinkTitle',

View file

@ -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.
*

View file

@ -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

View file

@ -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,

View file

@ -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, '/');

View file

@ -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<string>();
const allLinks = new Set<string>();
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<BrokenLinks>({
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 <Context.Provider value={brokenLinks}>{children}</Context.Provider>;
}

View file

@ -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<string>();
return {
collectLink: (link: string): void => {
allLinks.add(link);
},
getCollectedLinks: (): string[] => [...allLinks],
};
};
const Context = React.createContext<LinksCollector>({
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 <Context.Provider value={linksCollector}>{children}</Context.Provider>;
}

View file

@ -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<HTMLAnchorElement | null>(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 ? (

View file

@ -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();
}

View file

@ -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
<Loadable.Capture report={(moduleName) => modules.add(moduleName)}>
<HelmetProvider context={helmetContext}>
<StaticRouter location={location} context={routerContext}>
<LinksCollectorProvider linksCollector={linksCollector}>
<BrokenLinksProvider brokenLinks={statefulBrokenLinks}>
<App />
</LinksCollectorProvider>
</BrokenLinksProvider>
</StaticRouter>
</HelmetProvider>
</Loadable.Capture>
);
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();

View file

@ -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(

View file

@ -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,

View file

@ -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)
"
`;

View file

@ -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",

View file

@ -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",

View file

@ -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<typeof handleBrokenLinks>[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: '/page1'},
{path: '/nested/', routes: [{path: '/nested/page2'}]},
],
},
{
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',
],
'/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.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.
Exhaustive list of all broken links found:
- Broken link on source page path = /page1:
-> linking to /brokenLink
"
`);
});
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('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.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('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)
"
`);
});
});

View file

@ -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[]};
function getPageBrokenLinks({
// 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 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,
}),
);
return _.pickBy(allBrokenLinks, (brokenLinks) => brokenLinks.length > 0);
}
function getBrokenLinksErrorMessage(allBrokenLinks: {
[location: string]: BrokenLink[];
}): string | undefined {
if (Object.keys(allBrokenLinks).length === 0) {
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';
const anchorMessage =
brokenLinks.length > 0
? `- Broken ${type} on source page path = ${pagePath}:
-> linking to ${brokenLinks
.map(brokenLinkMessage)
.join('\n -> linking to ')}`
: '';
return `${anchorMessage}`;
}
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;
}
if (anchorBrokenLinks.length > 0) {
brokenAnchors[pathname] = anchorBrokenLinks;
}
});
for (const file of filePathsToTry) {
if (await isExistingFile(file)) {
return true;
}
}
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<void> {
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});
}

View file

@ -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<DocusaurusConfig>({
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),

View file

@ -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",

View file

@ -16,6 +16,7 @@ exports[`getDocusaurusAliases returns appropriate webpack aliases 1`] = `
"@docusaurus/renderRoutes": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/renderRoutes.ts",
"@docusaurus/router": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/router.ts",
"@docusaurus/useBaseUrl": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/useBaseUrl.ts",
"@docusaurus/useBrokenLinks": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/useBrokenLinks.ts",
"@docusaurus/useDocusaurusContext": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/useDocusaurusContext.ts",
"@docusaurus/useGlobalData": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/useGlobalData.ts",
"@docusaurus/useIsBrowser": "<PROJECT_ROOT>/packages/docusaurus/src/client/exports/useIsBrowser.ts",

View file

@ -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)
```
<BrowserWindow>
- [`/pure-html`](/pure-html)
- [`pathname:///pure-html`](pathname:///pure-html)
</BrowserWindow>
:::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"

View file

@ -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}

View file

@ -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` | <code>false \| string</code> | `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` | <a href="#SidebarGenerator"><code>SidebarGenerator</code></a> | _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` | <code>boolean \|</code> <a href="#PrefixParser"><code>PrefixParser</code></a> | _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` | <a href="#SidebarGenerator"><code>SidebarGenerator</code></a> | _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` | <code>boolean \|</code> <a href="#PrefixParser"><code>PrefixParser</code></a> | _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` | <code>string \| null</code> | 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` | <code>string \| null</code> | 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` | <code>string \| null</code> | 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 `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. |

View file

@ -180,7 +180,7 @@ By default, the files are written in `website/i18n/<defaultLocale>/...`.
### `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 |
| --- | --- | --- |

View file

@ -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>`](#link) component will collect links for you
- the `@theme/Heading` (used for Markdown headings) will collect anchors
Use `useBrokenLinks()` if you implement your own `<Heading>` or `<Link>` 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 <h2 id={id}>Heading</h2>;
}
```
```js title="MyLink.js"
import useBrokenLinks from '@docusaurus/useBrokenLinks';
export default function MyLink({targetLink, ...props}): JSX.Element {
const brokenLinks = useBrokenLinks();
brokenLinks.collectLink(targetLink);
return <a href={targetLink}>Link</a>;
}
```
## Functions {#functions}
### `interpolate` {#interpolate-1}

View file

@ -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.

View file

@ -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}

View file

@ -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)**.
:::

View file

@ -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}

View file

@ -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

View file

@ -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'

View file

@ -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)
```
<BrowserWindow>
- [`/pure-html`](/pure-html)
- [`pathname:///pure-html`](pathname:///pure-html)
</BrowserWindow>
:::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"

View file

@ -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` | <code>false \| string</code> | `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` | <a href="#SidebarGenerator"><code>SidebarGenerator</code></a> | _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` | <code>boolean \|</code> <a href="#PrefixParser"><code>PrefixParser</code></a> | _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` | <a href="#SidebarGenerator"><code>SidebarGenerator</code></a> | _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` | <code>boolean \|</code> <a href="#PrefixParser"><code>PrefixParser</code></a> | _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` | <code>string \| null</code> | 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` | <code>string \| null</code> | 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 `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. |

View file

@ -176,7 +176,7 @@ By default, the files are written in `website/i18n/<defaultLocale>/...`.
### `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 |
| --- | --- | --- |

View file

@ -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)

View file

@ -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.

View file

@ -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}

View file

@ -84,7 +84,7 @@ Since all doc files are parsed using MDX, anything that looks like HTML is actua
<span style={{backgroundColor: 'red'}}>Foo</span>
```
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.

View file

@ -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)**.
:::

View file

@ -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}

View file

@ -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

View file

@ -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)
```
<BrowserWindow>
- [`/pure-html`](/pure-html)
- [`pathname:///pure-html`](pathname:///pure-html)
</BrowserWindow>
:::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"

View file

@ -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` | <code>false \| string</code> | `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` | <a href="#SidebarGenerator"><code>SidebarGenerator</code></a> | _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` | <code>boolean \|</code> <a href="#PrefixParser"><code>PrefixParser</code></a> | _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` | <a href="#SidebarGenerator"><code>SidebarGenerator</code></a> | _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` | <code>boolean \|</code> <a href="#PrefixParser"><code>PrefixParser</code></a> | _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` | <code>string \| null</code> | 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` | <code>string \| null</code> | 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` | <code>string \| null</code> | 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 `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. |

View file

@ -177,7 +177,7 @@ By default, the files are written in `website/i18n/<defaultLocale>/...`.
### `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 |
| --- | --- | --- |

View file

@ -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.

View file

@ -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}

View file

@ -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)**.
:::

View file

@ -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}

View file

@ -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