feat(core): Add siteConfig.markdown.hooks, deprecate siteConfig.onBrokenMarkdownLinks (#11283)

This commit is contained in:
Sébastien Lorber 2025-06-24 15:51:33 +02:00 committed by GitHub
parent ef71ddf937
commit 96c38d5fdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1580 additions and 381 deletions

View file

@ -26,7 +26,6 @@ const config: Config = {
projectName: 'docusaurus', // Usually your repo name. projectName: 'docusaurus', // Usually your repo name.
onBrokenLinks: 'throw', onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
// Even if you don't use internationalization, you can use this field to set // Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you // useful metadata like html lang. For example, if your site is Chinese, you

View file

@ -31,7 +31,6 @@ const config = {
projectName: 'docusaurus', // Usually your repo name. projectName: 'docusaurus', // Usually your repo name.
onBrokenLinks: 'throw', onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
// Even if you don't use internationalization, you can use this field to set // Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you // useful metadata like html lang. For example, if your site is Chinese, you

View file

@ -22,6 +22,9 @@ import type {WebpackCompilerName} from '@docusaurus/utils';
import type {MDXFrontMatter} from './frontMatter'; import type {MDXFrontMatter} from './frontMatter';
import type {Options} from './options'; import type {Options} from './options';
import type {AdmonitionOptions} from './remark/admonitions'; import type {AdmonitionOptions} from './remark/admonitions';
import type {PluginOptions as ResolveMarkdownLinksOptions} from './remark/resolveMarkdownLinks';
import type {PluginOptions as TransformLinksOptions} from './remark/transformLinks';
import type {PluginOptions as TransformImageOptions} from './remark/transformImage';
import type {ProcessorOptions} from '@mdx-js/mdx'; import type {ProcessorOptions} from '@mdx-js/mdx';
// TODO as of April 2023, no way to import/re-export this ESM type easily :/ // TODO as of April 2023, no way to import/re-export this ESM type easily :/
@ -121,13 +124,19 @@ async function createProcessorFactory() {
{ {
staticDirs: options.staticDirs, staticDirs: options.staticDirs,
siteDir: options.siteDir, siteDir: options.siteDir,
}, onBrokenMarkdownImages:
options.markdownConfig.hooks.onBrokenMarkdownImages,
} satisfies TransformImageOptions,
], ],
// TODO merge this with transformLinks? // TODO merge this with transformLinks?
options.resolveMarkdownLink options.resolveMarkdownLink
? [ ? [
resolveMarkdownLinks, resolveMarkdownLinks,
{resolveMarkdownLink: options.resolveMarkdownLink}, {
resolveMarkdownLink: options.resolveMarkdownLink,
onBrokenMarkdownLinks:
options.markdownConfig.hooks.onBrokenMarkdownLinks,
} satisfies ResolveMarkdownLinksOptions,
] ]
: undefined, : undefined,
[ [
@ -135,7 +144,9 @@ async function createProcessorFactory() {
{ {
staticDirs: options.staticDirs, staticDirs: options.staticDirs,
siteDir: options.siteDir, siteDir: options.siteDir,
}, onBrokenMarkdownLinks:
options.markdownConfig.hooks.onBrokenMarkdownLinks,
} satisfies TransformLinksOptions,
], ],
gfm, gfm,
options.markdownConfig.mdx1Compat.comments ? comment : null, options.markdownConfig.mdx1Compat.comments ? comment : null,

View file

@ -5,22 +5,47 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {jest} from '@jest/globals';
import * as path from 'path';
import plugin from '..'; import plugin from '..';
import type {PluginOptions} from '../index'; import type {PluginOptions} from '../index';
async function process(content: string) { const siteDir = __dirname;
const {remark} = await import('remark');
const options: PluginOptions = { const DefaultTestOptions: PluginOptions = {
resolveMarkdownLink: ({linkPathname}) => `/RESOLVED---${linkPathname}`, resolveMarkdownLink: ({linkPathname}) => `/RESOLVED---${linkPathname}`,
onBrokenMarkdownLinks: 'throw',
};
async function process(content: string, optionsInput?: Partial<PluginOptions>) {
const options = {
...DefaultTestOptions,
...optionsInput,
}; };
const result = await remark().use(plugin, options).process(content); const {remark} = await import('remark');
const result = await remark()
.use(plugin, options)
.process({
value: content,
path: path.posix.join(siteDir, 'docs', 'myFile.mdx'),
});
return result.value; return result.value;
} }
describe('resolveMarkdownLinks remark plugin', () => { describe('resolveMarkdownLinks remark plugin', () => {
it('accepts non-md link', async () => {
/* language=markdown */
const content = `[link1](link1)`;
const result = await process(content);
expect(result).toMatchInlineSnapshot(`
"[link1](link1)
"
`);
});
it('resolves Markdown and MDX links', async () => { it('resolves Markdown and MDX links', async () => {
/* language=markdown */ /* language=markdown */
const content = `[link1](link1.mdx) const content = `[link1](link1.mdx)
@ -157,4 +182,212 @@ this is a code block
" "
`); `);
}); });
describe('onBrokenMarkdownLinks', () => {
const warnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
beforeEach(() => {
warnMock.mockClear();
});
async function processResolutionErrors(
content: string,
onBrokenMarkdownLinks: PluginOptions['onBrokenMarkdownLinks'] = 'throw',
) {
return process(content, {
resolveMarkdownLink: () => null,
onBrokenMarkdownLinks,
});
}
describe('throws', () => {
it('for unresolvable mdx link', async () => {
/* language=markdown */
const content = `[link1](link1.mdx)`;
await expect(() => processResolutionErrors(content)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown link with URL \`link1.mdx\` in source file "packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/docs/myFile.mdx" (1:1) couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownLinks\` option, or apply the \`pathname://\` protocol to the broken link URLs."
`);
});
it('for unresolvable md link', async () => {
/* language=markdown */
const content = `[link1](link1.md)`;
await expect(() => processResolutionErrors(content)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown link with URL \`link1.md\` in source file "packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/docs/myFile.mdx" (1:1) couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownLinks\` option, or apply the \`pathname://\` protocol to the broken link URLs."
`);
});
});
describe('warns', () => {
it('for unresolvable md and mdx link', async () => {
/* language=markdown */
const content = `
[link1](link1.mdx)
[link2](link2)
[link3](dir/link3.md)
[link 4](/link/4)
`;
const result = await processResolutionErrors(content, 'warn');
expect(result).toMatchInlineSnapshot(`
"[link1](link1.mdx)
[link2](link2)
[link3](dir/link3.md)
[link 4](/link/4)
"
`);
expect(warnMock).toHaveBeenCalledTimes(2);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown link with URL \`link1.mdx\` in source file "packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/docs/myFile.mdx" (2:1) couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.",
],
[
"[WARNING] Markdown link with URL \`dir/link3.md\` in source file "packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/docs/myFile.mdx" (6:1) couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.",
],
]
`);
});
it('for unresolvable md and mdx link - with recovery', async () => {
/* language=markdown */
const content = `
[link1](link1.mdx)
[link2](link2)
[link3](dir/link3.md?query#hash)
[link 4](/link/4)
`;
const result = await processResolutionErrors(content, (params) => {
console.warn(`onBrokenMarkdownLinks called with`, params);
// We can alter the AST Node
params.node.title = 'fixed link title';
params.node.url = 'ignored, less important than returned value';
// Or return a new URL
return `/recovered-link`;
});
expect(result).toMatchInlineSnapshot(`
"[link1](/recovered-link "fixed link title")
[link2](link2)
[link3](/recovered-link "fixed link title")
[link 4](/link/4)
"
`);
expect(warnMock).toHaveBeenCalledTimes(2);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownLinks called with",
{
"node": {
"children": [
{
"position": {
"end": {
"column": 7,
"line": 2,
"offset": 7,
},
"start": {
"column": 2,
"line": 2,
"offset": 2,
},
},
"type": "text",
"value": "link1",
},
],
"position": {
"end": {
"column": 19,
"line": 2,
"offset": 19,
},
"start": {
"column": 1,
"line": 2,
"offset": 1,
},
},
"title": "fixed link title",
"type": "link",
"url": "/recovered-link",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/docs/myFile.mdx",
"url": "link1.mdx",
},
],
[
"onBrokenMarkdownLinks called with",
{
"node": {
"children": [
{
"position": {
"end": {
"column": 7,
"line": 6,
"offset": 43,
},
"start": {
"column": 2,
"line": 6,
"offset": 38,
},
},
"type": "text",
"value": "link3",
},
],
"position": {
"end": {
"column": 33,
"line": 6,
"offset": 69,
},
"start": {
"column": 1,
"line": 6,
"offset": 37,
},
},
"title": "fixed link title",
"type": "link",
"url": "/recovered-link",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/docs/myFile.mdx",
"url": "dir/link3.md?query#hash",
},
],
]
`);
});
});
});
}); });

View file

@ -8,11 +8,18 @@
import { import {
parseLocalURLPath, parseLocalURLPath,
serializeURLPath, serializeURLPath,
toMessageRelativeFilePath,
type URLPath, type URLPath,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import logger from '@docusaurus/logger';
import {formatNodePositionExtraMessage} from '../utils';
import type {Plugin, Transformer} from 'unified'; import type {Plugin, Transformer} from 'unified';
import type {Definition, Link, Root} from 'mdast'; import type {Definition, Link, Root} from 'mdast';
import type {
MarkdownConfig,
OnBrokenMarkdownLinksFunction,
} from '@docusaurus/types';
type ResolveMarkdownLinkParams = { type ResolveMarkdownLinkParams = {
/** /**
@ -32,6 +39,33 @@ export type ResolveMarkdownLink = (
export interface PluginOptions { export interface PluginOptions {
resolveMarkdownLink: ResolveMarkdownLink; resolveMarkdownLink: ResolveMarkdownLink;
onBrokenMarkdownLinks: MarkdownConfig['hooks']['onBrokenMarkdownLinks'];
}
function asFunction(
onBrokenMarkdownLinks: PluginOptions['onBrokenMarkdownLinks'],
): OnBrokenMarkdownLinksFunction {
if (typeof onBrokenMarkdownLinks === 'string') {
const extraHelp =
onBrokenMarkdownLinks === 'throw'
? logger.interpolate`\nTo ignore this error, use the code=${'siteConfig.markdown.hooks.onBrokenMarkdownLinks'} option, or apply the code=${'pathname://'} protocol to the broken link URLs.`
: '';
return ({sourceFilePath, url: linkUrl, node}) => {
const relativePath = toMessageRelativeFilePath(sourceFilePath);
logger.report(
onBrokenMarkdownLinks,
)`Markdown link with URL code=${linkUrl} in source file path=${relativePath}${formatNodePositionExtraMessage(
node,
)} couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.${extraHelp}`;
};
} else {
return (params) =>
onBrokenMarkdownLinks({
...params,
sourceFilePath: toMessageRelativeFilePath(params.sourceFilePath),
});
}
} }
const HAS_MARKDOWN_EXTENSION = /\.mdx?$/i; const HAS_MARKDOWN_EXTENSION = /\.mdx?$/i;
@ -57,10 +91,15 @@ function parseMarkdownLinkURLPath(link: string): URLPath | null {
* This is exposed as "data.contentTitle" to the processed vfile * This is exposed as "data.contentTitle" to the processed vfile
* Also gives the ability to strip that content title (used for the blog plugin) * Also gives the ability to strip that content title (used for the blog plugin)
*/ */
// TODO merge this plugin with "transformLinks"
// in general we'd want to avoid traversing multiple times the same AST
const plugin: Plugin<PluginOptions[], Root> = function plugin( const plugin: Plugin<PluginOptions[], Root> = function plugin(
options, options,
): Transformer<Root> { ): Transformer<Root> {
const {resolveMarkdownLink} = options; const {resolveMarkdownLink} = options;
const onBrokenMarkdownLinks = asFunction(options.onBrokenMarkdownLinks);
return async (root, file) => { return async (root, file) => {
const {visit} = await import('unist-util-visit'); const {visit} = await import('unist-util-visit');
@ -71,18 +110,26 @@ const plugin: Plugin<PluginOptions[], Root> = function plugin(
return; return;
} }
const sourceFilePath = file.path;
const permalink = resolveMarkdownLink({ const permalink = resolveMarkdownLink({
sourceFilePath: file.path, sourceFilePath,
linkPathname: linkURLPath.pathname, linkPathname: linkURLPath.pathname,
}); });
if (permalink) { if (permalink) {
// This reapplies the link ?qs#hash part to the resolved pathname // This reapplies the link ?qs#hash part to the resolved pathname
const resolvedUrl = serializeURLPath({ link.url = serializeURLPath({
...linkURLPath, ...linkURLPath,
pathname: permalink, pathname: permalink,
}); });
link.url = resolvedUrl; } else {
link.url =
onBrokenMarkdownLinks({
url: link.url,
sourceFilePath,
node: link,
}) ?? link.url;
} }
}); });
}; };

View file

@ -1 +0,0 @@
![img](/img/doesNotExist.png)

View file

@ -1 +0,0 @@
![img](./notFound.png)

View file

@ -1 +0,0 @@
![invalid image](/invalid.png)

View file

@ -1 +0,0 @@
![img](pathname:///img/unchecked.png)

View file

@ -1,16 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`transformImage plugin does not choke on invalid image 1`] = ` exports[`transformImage plugin does not choke on invalid image 1`] = `
"<img alt="invalid image" 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/invalid.png").default} /> "<img alt="invalid image" 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/invalid.png").default} />
" "
`; `;
exports[`transformImage plugin fail if image does not exist 1`] = `"Image packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static/img/doesNotExist.png or packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static2/img/doesNotExist.png used in packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail.md not found."`;
exports[`transformImage plugin fail if image relative path does not exist 1`] = `"Image packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/notFound.png used in packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail2.md not found."`;
exports[`transformImage plugin fail if image url is absent 1`] = `"Markdown image URL is mandatory in "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/noUrl.md" file"`;
exports[`transformImage plugin pathname protocol 1`] = ` exports[`transformImage plugin pathname protocol 1`] = `
"![img](/img/unchecked.png) "![img](/img/unchecked.png)
" "

View file

@ -6,65 +6,361 @@
*/ */
import {jest} from '@jest/globals'; import {jest} from '@jest/globals';
import path from 'path'; import * as path from 'path';
import vfile from 'to-vfile'; import vfile from 'to-vfile';
import plugin, {type PluginOptions} from '../index'; import plugin, {type PluginOptions} from '../index';
const processFixture = async ( const siteDir = path.join(__dirname, '__fixtures__');
name: string,
options: Partial<PluginOptions>,
) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const filePath = path.join(__dirname, `__fixtures__/${name}.md`);
const file = await vfile.read(filePath);
const result = await remark()
.use(mdx)
.use(plugin, {siteDir: __dirname, staticDirs: [], ...options})
.process(file);
return result.value;
};
const staticDirs = [ const staticDirs = [
path.join(__dirname, '__fixtures__/static'), path.join(__dirname, '__fixtures__/static'),
path.join(__dirname, '__fixtures__/static2'), path.join(__dirname, '__fixtures__/static2'),
]; ];
const siteDir = path.join(__dirname, '__fixtures__'); const getProcessor = async (options?: Partial<PluginOptions>) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
return remark()
.use(mdx)
.use(plugin, {
siteDir,
staticDirs,
onBrokenMarkdownImages: 'throw',
...options,
});
};
const processFixture = async (
name: string,
options?: Partial<PluginOptions>,
) => {
const filePath = path.join(__dirname, `__fixtures__/${name}.md`);
const file = await vfile.read(filePath);
const processor = await getProcessor(options);
const result = await processor.process(file);
return result.value;
};
const processContent = async (
content: string,
options?: Partial<PluginOptions>,
) => {
const processor = await getProcessor(options);
const result = await processor.process({
value: content,
path: path.posix.join(siteDir, 'docs', 'myFile.mdx'),
});
return result.value.toString();
};
describe('transformImage plugin', () => { describe('transformImage plugin', () => {
it('fail if image does not exist', async () => {
await expect(
processFixture('fail', {staticDirs}),
).rejects.toThrowErrorMatchingSnapshot();
});
it('fail if image relative path does not exist', async () => {
await expect(
processFixture('fail2', {staticDirs}),
).rejects.toThrowErrorMatchingSnapshot();
});
it('fail if image url is absent', async () => {
await expect(
processFixture('noUrl', {staticDirs}),
).rejects.toThrowErrorMatchingSnapshot();
});
it('transform md images to <img />', async () => { it('transform md images to <img />', async () => {
const result = await processFixture('img', {staticDirs, siteDir}); // TODO split that large fixture into many smaller test cases?
const result = await processFixture('img');
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
it('pathname protocol', async () => { it('pathname protocol', async () => {
const result = await processFixture('pathname', {staticDirs}); const result = await processContent(
`![img](pathname:///img/unchecked.png)`,
);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
it('does not choke on invalid image', async () => { it('does not choke on invalid image', async () => {
const errorMock = jest.spyOn(console, 'warn').mockImplementation(() => {}); const errorMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = await processFixture('invalid-img', {staticDirs}); const result = await processContent(`![invalid image](/invalid.png)`);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
expect(errorMock).toHaveBeenCalledTimes(1); expect(errorMock).toHaveBeenCalledTimes(1);
}); });
describe('onBrokenMarkdownImages', () => {
const fixtures = {
doesNotExistAbsolute: `![img](/img/doesNotExist.png)`,
doesNotExistRelative: `![img](./doesNotExist.png)`,
doesNotExistSiteAlias: `![img](@site/doesNotExist.png)`,
urlEmpty: `![img]()`,
};
describe('throws', () => {
it('if image absolute path does not exist', async () => {
await expect(processContent(fixtures.doesNotExistAbsolute)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown image with URL \`/img/doesNotExist.png\` in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved to an existing local image file.
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownImages\` option, or apply the \`pathname://\` protocol to the broken image URLs."
`);
});
it('if image relative path does not exist', async () => {
await expect(processContent(fixtures.doesNotExistRelative)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown image with URL \`./doesNotExist.png\` in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved to an existing local image file.
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownImages\` option, or apply the \`pathname://\` protocol to the broken image URLs."
`);
});
it('if image @site path does not exist', async () => {
await expect(processContent(fixtures.doesNotExistSiteAlias)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown image with URL \`@site/doesNotExist.png\` in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved to an existing local image file.
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownImages\` option, or apply the \`pathname://\` protocol to the broken image URLs."
`);
});
it('if image url empty', async () => {
await expect(processContent(fixtures.urlEmpty)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown image with empty URL found in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1).
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownImages\` option, or apply the \`pathname://\` protocol to the broken image URLs."
`);
});
});
describe('warns', () => {
function processWarn(content: string) {
return processContent(content, {onBrokenMarkdownImages: 'warn'});
}
const warnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
beforeEach(() => {
warnMock.mockClear();
});
it('if image absolute path does not exist', async () => {
const result = await processWarn(fixtures.doesNotExistAbsolute);
expect(result).toMatchInlineSnapshot(`
"![img](/img/doesNotExist.png)
"
`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown image with URL \`/img/doesNotExist.png\` in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved to an existing local image file.",
],
]
`);
});
it('if image relative path does not exist', async () => {
const result = await processWarn(fixtures.doesNotExistRelative);
expect(result).toMatchInlineSnapshot(`
"![img](./doesNotExist.png)
"
`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown image with URL \`./doesNotExist.png\` in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved to an existing local image file.",
],
]
`);
});
it('if image @site path does not exist', async () => {
const result = await processWarn(fixtures.doesNotExistSiteAlias);
expect(result).toMatchInlineSnapshot(`
"![img](@site/doesNotExist.png)
"
`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown image with URL \`@site/doesNotExist.png\` in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved to an existing local image file.",
],
]
`);
});
it('if image url empty', async () => {
const result = await processWarn(fixtures.urlEmpty);
expect(result).toMatchInlineSnapshot(`
"![img]()
"
`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown image with empty URL found in source file "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx" (1:1).",
],
]
`);
});
});
describe('function form', () => {
function processWarn(content: string) {
return processContent(content, {
onBrokenMarkdownImages: (params) => {
console.log('onBrokenMarkdownImages called for ', params);
// We can alter the AST Node
params.node.alt = 'new 404 alt';
params.node.url = 'ignored, less important than returned value';
// Or return a new URL
return '/404.png';
},
});
}
const logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
beforeEach(() => {
logMock.mockClear();
});
it('if image absolute path does not exist', async () => {
const result = await processWarn(fixtures.doesNotExistAbsolute);
expect(result).toMatchInlineSnapshot(`
"![new 404 alt](/404.png)
"
`);
expect(logMock).toHaveBeenCalledTimes(1);
expect(logMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownImages called for ",
{
"node": {
"alt": "new 404 alt",
"position": {
"end": {
"column": 30,
"line": 1,
"offset": 29,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "image",
"url": "/404.png",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx",
"url": "/img/doesNotExist.png",
},
],
]
`);
});
it('if image relative path does not exist', async () => {
const result = await processWarn(fixtures.doesNotExistRelative);
expect(result).toMatchInlineSnapshot(`
"![new 404 alt](/404.png)
"
`);
expect(logMock).toHaveBeenCalledTimes(1);
expect(logMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownImages called for ",
{
"node": {
"alt": "new 404 alt",
"position": {
"end": {
"column": 27,
"line": 1,
"offset": 26,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "image",
"url": "/404.png",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx",
"url": "./doesNotExist.png",
},
],
]
`);
});
it('if image @site path does not exist', async () => {
const result = await processWarn(fixtures.doesNotExistSiteAlias);
expect(result).toMatchInlineSnapshot(`
"![new 404 alt](/404.png)
"
`);
expect(logMock).toHaveBeenCalledTimes(1);
expect(logMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownImages called for ",
{
"node": {
"alt": "new 404 alt",
"position": {
"end": {
"column": 31,
"line": 1,
"offset": 30,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "image",
"url": "/404.png",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx",
"url": "@site/doesNotExist.png",
},
],
]
`);
});
it('if image url empty', async () => {
const result = await processWarn(fixtures.urlEmpty);
expect(result).toMatchInlineSnapshot(`
"![new 404 alt](/404.png)
"
`);
expect(logMock).toHaveBeenCalledTimes(1);
expect(logMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownImages called for ",
{
"node": {
"alt": "new 404 alt",
"position": {
"end": {
"column": 9,
"line": 1,
"offset": 8,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "image",
"url": "/404.png",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/docs/myFile.mdx",
"url": "",
},
],
]
`);
});
});
});
}); });

View file

@ -19,22 +19,67 @@ import {
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import {imageSizeFromFile} from 'image-size/fromFile'; import {imageSizeFromFile} from 'image-size/fromFile';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import {assetRequireAttributeValue, transformNode} from '../utils'; import {
assetRequireAttributeValue,
formatNodePositionExtraMessage,
transformNode,
} from '../utils';
import type {Plugin, Transformer} from 'unified'; import type {Plugin, Transformer} from 'unified';
import type {MdxJsxTextElement} from 'mdast-util-mdx'; import type {MdxJsxTextElement} from 'mdast-util-mdx';
import type {Image, Root} from 'mdast'; import type {Image, Root} from 'mdast';
import type {Parent} from 'unist'; import type {Parent} from 'unist';
import type {
MarkdownConfig,
OnBrokenMarkdownImagesFunction,
} from '@docusaurus/types';
type PluginOptions = { export type PluginOptions = {
staticDirs: string[]; staticDirs: string[];
siteDir: string; siteDir: string;
onBrokenMarkdownImages: MarkdownConfig['hooks']['onBrokenMarkdownImages'];
}; };
type Context = PluginOptions & { type Context = {
staticDirs: PluginOptions['staticDirs'];
siteDir: PluginOptions['siteDir'];
onBrokenMarkdownImages: OnBrokenMarkdownImagesFunction;
filePath: string; filePath: string;
inlineMarkdownImageFileLoader: string; inlineMarkdownImageFileLoader: string;
}; };
function asFunction(
onBrokenMarkdownImages: PluginOptions['onBrokenMarkdownImages'],
): OnBrokenMarkdownImagesFunction {
if (typeof onBrokenMarkdownImages === 'string') {
const extraHelp =
onBrokenMarkdownImages === 'throw'
? logger.interpolate`\nTo ignore this error, use the code=${'siteConfig.markdown.hooks.onBrokenMarkdownImages'} option, or apply the code=${'pathname://'} protocol to the broken image URLs.`
: '';
return ({sourceFilePath, url: imageUrl, node}) => {
const relativePath = toMessageRelativeFilePath(sourceFilePath);
if (imageUrl) {
logger.report(
onBrokenMarkdownImages,
)`Markdown image with URL code=${imageUrl} in source file path=${relativePath}${formatNodePositionExtraMessage(
node,
)} couldn't be resolved to an existing local image file.${extraHelp}`;
} else {
logger.report(
onBrokenMarkdownImages,
)`Markdown image with empty URL found in source file path=${relativePath}${formatNodePositionExtraMessage(
node,
)}.${extraHelp}`;
}
};
} else {
return (params) =>
onBrokenMarkdownImages({
...params,
sourceFilePath: toMessageRelativeFilePath(params.sourceFilePath),
});
}
}
type Target = [node: Image, index: number, parent: Parent]; type Target = [node: Image, index: number, parent: Parent];
async function toImageRequireNode( async function toImageRequireNode(
@ -51,7 +96,7 @@ async function toImageRequireNode(
); );
relativeImagePath = `./${relativeImagePath}`; relativeImagePath = `./${relativeImagePath}`;
const parsedUrl = parseURLOrPath(node.url, 'https://example.com'); const parsedUrl = parseURLOrPath(node.url);
const hash = parsedUrl.hash ?? ''; const hash = parsedUrl.hash ?? '';
const search = parsedUrl.search ?? ''; const search = parsedUrl.search ?? '';
const requireString = `${context.inlineMarkdownImageFileLoader}${ const requireString = `${context.inlineMarkdownImageFileLoader}${
@ -113,57 +158,53 @@ ${(err as Error).message}`;
}); });
} }
async function ensureImageFileExist(imagePath: string, sourceFilePath: string) { async function getLocalImageAbsolutePath(
const imageExists = await fs.pathExists(imagePath); originalImagePath: string,
if (!imageExists) {
throw new Error(
`Image ${toMessageRelativeFilePath(
imagePath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
);
}
}
async function getImageAbsolutePath(
imagePath: string,
{siteDir, filePath, staticDirs}: Context, {siteDir, filePath, staticDirs}: Context,
) { ) {
if (imagePath.startsWith('@site/')) { if (originalImagePath.startsWith('@site/')) {
const imageFilePath = path.join(siteDir, imagePath.replace('@site/', '')); const imageFilePath = path.join(
await ensureImageFileExist(imageFilePath, filePath); siteDir,
originalImagePath.replace('@site/', ''),
);
if (!(await fs.pathExists(imageFilePath))) {
return null;
}
return imageFilePath; return imageFilePath;
} else if (path.isAbsolute(imagePath)) { } else if (path.isAbsolute(originalImagePath)) {
// Absolute paths are expected to exist in the static folder. // Absolute paths are expected to exist in the static folder.
const possiblePaths = staticDirs.map((dir) => path.join(dir, imagePath)); const possiblePaths = staticDirs.map((dir) =>
path.join(dir, originalImagePath),
);
const imageFilePath = await findAsyncSequential( const imageFilePath = await findAsyncSequential(
possiblePaths, possiblePaths,
fs.pathExists, fs.pathExists,
); );
if (!imageFilePath) { if (!imageFilePath) {
throw new Error( return null;
`Image ${possiblePaths }
.map((p) => toMessageRelativeFilePath(p)) return imageFilePath;
.join(' or ')} used in ${toMessageRelativeFilePath( } else {
filePath, // relative paths are resolved against the source file's folder
)} not found.`, const imageFilePath = path.join(path.dirname(filePath), originalImagePath);
); if (!(await fs.pathExists(imageFilePath))) {
return null;
} }
return imageFilePath; return imageFilePath;
} }
// relative paths are resolved against the source file's folder
const imageFilePath = path.join(path.dirname(filePath), imagePath);
await ensureImageFileExist(imageFilePath, filePath);
return imageFilePath;
} }
async function processImageNode(target: Target, context: Context) { async function processImageNode(target: Target, context: Context) {
const [node] = target; const [node] = target;
if (!node.url) { if (!node.url) {
throw new Error( node.url =
`Markdown image URL is mandatory in "${toMessageRelativeFilePath( context.onBrokenMarkdownImages({
context.filePath, url: node.url,
)}" file`, sourceFilePath: context.filePath,
); node,
}) ?? node.url;
return;
} }
const parsedUrl = url.parse(node.url); const parsedUrl = url.parse(node.url);
@ -183,13 +224,27 @@ async function processImageNode(target: Target, context: Context) {
// We try to convert image urls without protocol to images with require calls // We try to convert image urls without protocol to images with require calls
// going through webpack ensures that image assets exist at build time // going through webpack ensures that image assets exist at build time
const imagePath = await getImageAbsolutePath(decodedPathname, context); const localImagePath = await getLocalImageAbsolutePath(
await toImageRequireNode(target, imagePath, context); decodedPathname,
context,
);
if (localImagePath === null) {
node.url =
context.onBrokenMarkdownImages({
url: node.url,
sourceFilePath: context.filePath,
node,
}) ?? node.url;
} else {
await toImageRequireNode(target, localImagePath, context);
}
} }
const plugin: Plugin<PluginOptions[], Root> = function plugin( const plugin: Plugin<PluginOptions[], Root> = function plugin(
options, options,
): Transformer<Root> { ): Transformer<Root> {
const onBrokenMarkdownImages = asFunction(options.onBrokenMarkdownImages);
return async (root, vfile) => { return async (root, vfile) => {
const {visit} = await import('unist-util-visit'); const {visit} = await import('unist-util-visit');
@ -201,6 +256,7 @@ const plugin: Plugin<PluginOptions[], Root> = function plugin(
filePath: vfile.path!, filePath: vfile.path!,
inlineMarkdownImageFileLoader: inlineMarkdownImageFileLoader:
fileLoaderUtils.loaders.inlineMarkdownImageFileLoader, fileLoaderUtils.loaders.inlineMarkdownImageFileLoader,
onBrokenMarkdownImages,
}; };
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];

View file

@ -1 +0,0 @@
[asset](pathname:///asset/unchecked.pdf)

View file

@ -1,15 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`transformAsset plugin fail if asset url is absent 1`] = `"Markdown link URL is mandatory in "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/noUrl.md" file (title: asset, line: 1)."`; exports[`transformLinks plugin transform md links to <a /> 1`] = `
exports[`transformAsset plugin fail if asset with site alias does not exist 1`] = `"Asset packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/foo.pdf used in packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/nonexistentSiteAlias.md not found."`;
exports[`transformAsset plugin pathname protocol 1`] = `
"[asset](pathname:///asset/unchecked.pdf)
"
`;
exports[`transformAsset plugin transform md links to <a /> 1`] = `
"[asset](https://example.com/asset.pdf) "[asset](https://example.com/asset.pdf)
<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" data-noBrokenLinkCheck={true} href={require("!<PROJECT_ROOT>/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} />
@ -54,6 +45,5 @@ in paragraph <a target="_blank" data-noBrokenLinkCheck={true} href={require("!<P
<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" 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" 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> <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

@ -5,53 +5,270 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import path from 'path'; import {jest} from '@jest/globals';
import * as path from 'path';
import vfile from 'to-vfile'; import vfile from 'to-vfile';
import plugin from '..'; import plugin, {type PluginOptions} from '..';
import transformImage, {type PluginOptions} from '../../transformImage'; import transformImage from '../../transformImage';
const processFixture = async (name: string, options?: PluginOptions) => { const siteDir = path.join(__dirname, `__fixtures__`);
const staticDirs = [
path.join(siteDir, 'static'),
path.join(siteDir, 'static2'),
];
const getProcessor = async (options?: Partial<PluginOptions>) => {
const {remark} = await import('remark'); const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx'); const {default: mdx} = await import('remark-mdx');
const siteDir = path.join(__dirname, `__fixtures__`); return remark()
const staticDirs = [
path.join(siteDir, 'static'),
path.join(siteDir, 'static2'),
];
const file = await vfile.read(path.join(siteDir, `${name}.md`));
const result = await remark()
.use(mdx) .use(mdx)
.use(transformImage, {...options, siteDir, staticDirs}) .use(transformImage, {
.use(plugin, { siteDir,
...options,
staticDirs, staticDirs,
siteDir: path.join(__dirname, '__fixtures__'), onBrokenMarkdownImages: 'throw',
}) })
.process(file); .use(plugin, {
staticDirs,
return result.value; siteDir,
onBrokenMarkdownLinks: 'throw',
...options,
});
}; };
describe('transformAsset plugin', () => { const processFixture = async (
it('fail if asset url is absent', async () => { name: string,
await expect( options?: Partial<PluginOptions>,
processFixture('noUrl'), ) => {
).rejects.toThrowErrorMatchingSnapshot(); const processor = await getProcessor(options);
}); const file = await vfile.read(path.join(siteDir, `${name}.md`));
const result = await processor.process(file);
return result.value.toString().trim();
};
it('fail if asset with site alias does not exist', async () => { const processContent = async (
await expect( content: string,
processFixture('nonexistentSiteAlias'), options?: Partial<PluginOptions>,
).rejects.toThrowErrorMatchingSnapshot(); ) => {
const processor = await getProcessor(options);
const result = await processor.process({
value: content,
path: path.posix.join(siteDir, 'docs', 'myFile.mdx'),
}); });
return result.value.toString().trim();
};
describe('transformLinks plugin', () => {
it('transform md links to <a />', async () => { it('transform md links to <a />', async () => {
// TODO split fixture in many smaller test cases
const result = await processFixture('asset'); const result = await processFixture('asset');
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
it('pathname protocol', async () => { it('pathname protocol', async () => {
const result = await processFixture('pathname'); const result = await processContent(`pathname:///unchecked.pdf)`);
expect(result).toMatchSnapshot(); expect(result).toMatchInlineSnapshot(`"pathname:///unchecked.pdf)"`);
});
it('accepts absolute file that does not exist', async () => {
const result = await processContent(`[file](/dir/file.zip)`);
expect(result).toMatchInlineSnapshot(`"[file](/dir/file.zip)"`);
});
it('accepts relative file that does not exist', async () => {
const result = await processContent(`[file](dir/file.zip)`);
expect(result).toMatchInlineSnapshot(`"[file](dir/file.zip)"`);
});
describe('onBrokenMarkdownLinks', () => {
const fixtures = {
urlEmpty: `[empty]()`,
fileDoesNotExistSiteAlias: `[file](@site/file.zip)`,
};
describe('throws', () => {
it('if url is empty', async () => {
await expect(processContent(fixtures.urlEmpty)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown link with empty URL found in source file "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/docs/myFile.mdx" (1:1).
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownLinks\` option, or apply the \`pathname://\` protocol to the broken link URLs."
`);
});
it('if file with site alias does not exist', async () => {
await expect(processContent(fixtures.fileDoesNotExistSiteAlias)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"Markdown link with URL \`@site/file.zip\` in source file "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.
To ignore this error, use the \`siteConfig.markdown.hooks.onBrokenMarkdownLinks\` option, or apply the \`pathname://\` protocol to the broken link URLs."
`);
});
});
describe('warns', () => {
function processWarn(content: string) {
return processContent(content, {onBrokenMarkdownLinks: 'warn'});
}
const warnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
beforeEach(() => {
warnMock.mockClear();
});
it('if url is empty', async () => {
const result = await processWarn(fixtures.urlEmpty);
expect(result).toMatchInlineSnapshot(`"[empty]()"`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown link with empty URL found in source file "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/docs/myFile.mdx" (1:1).",
],
]
`);
});
it('if file with site alias does not exist', async () => {
const result = await processWarn(fixtures.fileDoesNotExistSiteAlias);
expect(result).toMatchInlineSnapshot(`"[file](@site/file.zip)"`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls).toMatchInlineSnapshot(`
[
[
"[WARNING] Markdown link with URL \`@site/file.zip\` in source file "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/docs/myFile.mdx" (1:1) couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.",
],
]
`);
});
});
describe('function form', () => {
function processWarn(content: string) {
return processContent(content, {
onBrokenMarkdownLinks: (params) => {
console.log('onBrokenMarkdownLinks called with', params);
// We can alter the AST Node
params.node.title = 'fixed link title';
params.node.url = 'ignored, less important than returned value';
// Or return a new URL
return '/404';
},
});
}
const logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
beforeEach(() => {
logMock.mockClear();
});
it('if url is empty', async () => {
const result = await processWarn(fixtures.urlEmpty);
expect(result).toMatchInlineSnapshot(
`"[empty](/404 "fixed link title")"`,
);
expect(logMock).toHaveBeenCalledTimes(1);
expect(logMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownLinks called with",
{
"node": {
"children": [
{
"position": {
"end": {
"column": 7,
"line": 1,
"offset": 6,
},
"start": {
"column": 2,
"line": 1,
"offset": 1,
},
},
"type": "text",
"value": "empty",
},
],
"position": {
"end": {
"column": 10,
"line": 1,
"offset": 9,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": "fixed link title",
"type": "link",
"url": "/404",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/docs/myFile.mdx",
"url": "",
},
],
]
`);
});
it('if file with site alias does not exist', async () => {
const result = await processWarn(fixtures.fileDoesNotExistSiteAlias);
expect(result).toMatchInlineSnapshot(
`"[file](/404 "fixed link title")"`,
);
expect(logMock).toHaveBeenCalledTimes(1);
expect(logMock.mock.calls).toMatchInlineSnapshot(`
[
[
"onBrokenMarkdownLinks called with",
{
"node": {
"children": [
{
"position": {
"end": {
"column": 6,
"line": 1,
"offset": 5,
},
"start": {
"column": 2,
"line": 1,
"offset": 1,
},
},
"type": "text",
"value": "file",
},
],
"position": {
"end": {
"column": 23,
"line": 1,
"offset": 22,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": "fixed link title",
"type": "link",
"url": "/404",
},
"sourceFilePath": "packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/docs/myFile.mdx",
"url": "@site/file.zip",
},
],
]
`);
});
});
}); });
}); });

View file

@ -17,24 +17,72 @@ import {
parseURLOrPath, parseURLOrPath,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import {assetRequireAttributeValue, transformNode} from '../utils'; import logger from '@docusaurus/logger';
import {
assetRequireAttributeValue,
formatNodePositionExtraMessage,
transformNode,
} from '../utils';
import type {Plugin, Transformer} from 'unified'; import type {Plugin, Transformer} from 'unified';
import type {MdxJsxTextElement} from 'mdast-util-mdx'; import type {MdxJsxTextElement} from 'mdast-util-mdx';
import type {Parent} from 'unist'; import type {Parent} from 'unist';
import type {Link, Literal, Root} from 'mdast'; import type {Link, Root} from 'mdast';
import type {
MarkdownConfig,
OnBrokenMarkdownLinksFunction,
} from '@docusaurus/types';
type PluginOptions = { export type PluginOptions = {
staticDirs: string[]; staticDirs: string[];
siteDir: string; siteDir: string;
onBrokenMarkdownLinks: MarkdownConfig['hooks']['onBrokenMarkdownLinks'];
}; };
type Context = PluginOptions & { type Context = PluginOptions & {
staticDirs: string[];
siteDir: string;
onBrokenMarkdownLinks: OnBrokenMarkdownLinksFunction;
filePath: string; filePath: string;
inlineMarkdownLinkFileLoader: string; inlineMarkdownLinkFileLoader: string;
}; };
type Target = [node: Link, index: number, parent: Parent]; type Target = [node: Link, index: number, parent: Parent];
function asFunction(
onBrokenMarkdownLinks: PluginOptions['onBrokenMarkdownLinks'],
): OnBrokenMarkdownLinksFunction {
if (typeof onBrokenMarkdownLinks === 'string') {
const extraHelp =
onBrokenMarkdownLinks === 'throw'
? logger.interpolate`\nTo ignore this error, use the code=${'siteConfig.markdown.hooks.onBrokenMarkdownLinks'} option, or apply the code=${'pathname://'} protocol to the broken link URLs.`
: '';
return ({sourceFilePath, url: linkUrl, node}) => {
const relativePath = toMessageRelativeFilePath(sourceFilePath);
if (linkUrl) {
logger.report(
onBrokenMarkdownLinks,
)`Markdown link with URL code=${linkUrl} in source file path=${relativePath}${formatNodePositionExtraMessage(
node,
)} couldn't be resolved.
Make sure it references a local Markdown file that exists within the current plugin.${extraHelp}`;
} else {
logger.report(
onBrokenMarkdownLinks,
)`Markdown link with empty URL found in source file path=${relativePath}${formatNodePositionExtraMessage(
node,
)}.${extraHelp}`;
}
};
} else {
return (params) =>
onBrokenMarkdownLinks({
...params,
sourceFilePath: toMessageRelativeFilePath(params.sourceFilePath),
});
}
}
/** /**
* Transforms the link node to a JSX `<a>` element with a `require()` call. * Transforms the link node to a JSX `<a>` element with a `require()` call.
*/ */
@ -123,27 +171,15 @@ async function toAssetRequireNode(
}); });
} }
async function ensureAssetFileExist(assetPath: string, sourceFilePath: string) { async function getLocalFileAbsolutePath(
const assetExists = await fs.pathExists(assetPath);
if (!assetExists) {
throw new Error(
`Asset ${toMessageRelativeFilePath(
assetPath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
);
}
}
async function getAssetAbsolutePath(
assetPath: string, assetPath: string,
{siteDir, filePath, staticDirs}: Context, {siteDir, filePath, staticDirs}: Context,
) { ) {
if (assetPath.startsWith('@site/')) { if (assetPath.startsWith('@site/')) {
const assetFilePath = path.join(siteDir, assetPath.replace('@site/', '')); const assetFilePath = path.join(siteDir, assetPath.replace('@site/', ''));
// The @site alias is the only way to believe that the user wants an asset. if (await fs.pathExists(assetFilePath)) {
// Everything else can just be a link URL return assetFilePath;
await ensureAssetFileExist(assetFilePath, filePath); }
return assetFilePath;
} else if (path.isAbsolute(assetPath)) { } else if (path.isAbsolute(assetPath)) {
const assetFilePath = await findAsyncSequential( const assetFilePath = await findAsyncSequential(
staticDirs.map((dir) => path.join(dir, assetPath)), staticDirs.map((dir) => path.join(dir, assetPath)),
@ -164,16 +200,13 @@ async function getAssetAbsolutePath(
async function processLinkNode(target: Target, context: Context) { async function processLinkNode(target: Target, context: Context) {
const [node] = target; const [node] = target;
if (!node.url) { if (!node.url) {
// Try to improve error feedback node.url =
// see https://github.com/facebook/docusaurus/issues/3309#issuecomment-690371675 context.onBrokenMarkdownLinks({
const title = url: node.url,
node.title ?? (node.children[0] as Literal | undefined)?.value ?? '?'; sourceFilePath: context.filePath,
const line = node.position?.start.line ?? '?'; node,
throw new Error( }) ?? node.url;
`Markdown link URL is mandatory in "${toMessageRelativeFilePath( return;
context.filePath,
)}" file (title: ${title}, line: ${line}).`,
);
} }
const parsedUrl = url.parse(node.url); const parsedUrl = url.parse(node.url);
@ -189,29 +222,48 @@ async function processLinkNode(target: Target, context: Context) {
return; return;
} }
const assetPath = await getAssetAbsolutePath( const localFilePath = await getLocalFileAbsolutePath(
decodeURIComponent(parsedUrl.pathname), decodeURIComponent(parsedUrl.pathname),
context, context,
); );
if (assetPath) {
await toAssetRequireNode(target, assetPath, context); if (localFilePath) {
await toAssetRequireNode(target, localFilePath, context);
} else {
// The @site alias is the only way to believe that the user wants an asset.
if (hasSiteAlias) {
node.url =
context.onBrokenMarkdownLinks({
url: node.url,
sourceFilePath: context.filePath,
node,
}) ?? node.url;
} else {
// Even if the url has a dot, and it looks like a file extension
// it can be risky to throw and fail fast by default
// It's perfectly valid for a route path segment to look like a filename
}
} }
} }
const plugin: Plugin<PluginOptions[], Root> = function plugin( const plugin: Plugin<PluginOptions[], Root> = function plugin(
options, options,
): Transformer<Root> { ): Transformer<Root> {
const onBrokenMarkdownLinks = asFunction(options.onBrokenMarkdownLinks);
return async (root, vfile) => { return async (root, vfile) => {
const {visit} = await import('unist-util-visit'); const {visit} = await import('unist-util-visit');
const fileLoaderUtils = getFileLoaderUtils( const fileLoaderUtils = getFileLoaderUtils(
vfile.data.compilerName === 'server', vfile.data.compilerName === 'server',
); );
const context: Context = { const context: Context = {
...options, ...options,
filePath: vfile.path!, filePath: vfile.path!,
inlineMarkdownLinkFileLoader: inlineMarkdownLinkFileLoader:
fileLoaderUtils.loaders.inlineMarkdownLinkFileLoader, fileLoaderUtils.loaders.inlineMarkdownLinkFileLoader,
onBrokenMarkdownLinks,
}; };
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];

View file

@ -8,7 +8,7 @@ import path from 'path';
import process from 'process'; import process from 'process';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import {posixPath} from '@docusaurus/utils'; import {posixPath} from '@docusaurus/utils';
import {transformNode} from '../utils'; import {formatNodePositionExtraMessage, transformNode} from '../utils';
import type {Root} from 'mdast'; import type {Root} from 'mdast';
import type {Parent} from 'unist'; import type {Parent} from 'unist';
import type {Transformer, Processor, Plugin} from 'unified'; import type {Transformer, Processor, Plugin} from 'unified';
@ -39,17 +39,9 @@ function formatDirectiveName(directive: Directives) {
return `${prefix}${directive.name}`; return `${prefix}${directive.name}`;
} }
function formatDirectivePosition(directive: Directives): string | undefined {
return directive.position?.start
? logger.interpolate`number=${directive.position.start.line}:number=${directive.position.start.column}`
: undefined;
}
function formatUnusedDirectiveMessage(directive: Directives) { function formatUnusedDirectiveMessage(directive: Directives) {
const name = formatDirectiveName(directive); const name = formatDirectiveName(directive);
const position = formatDirectivePosition(directive); return `- ${name}${formatNodePositionExtraMessage(directive)}`;
return `- ${name} ${position ? `(${position})` : ''}`;
} }
function formatUnusedDirectivesMessage({ function formatUnusedDirectivesMessage({

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import logger from '@docusaurus/logger';
import type {Node} from 'unist'; import type {Node} from 'unist';
import type {MdxJsxAttributeValueExpression} from 'mdast-util-mdx'; import type {MdxJsxAttributeValueExpression} from 'mdast-util-mdx';
@ -83,3 +84,16 @@ export function assetRequireAttributeValue(
}, },
}; };
} }
function formatNodePosition(node: Node): string | undefined {
return node.position?.start
? logger.interpolate`number=${node.position.start.line}:number=${node.position.start.column}`
: undefined;
}
// Returns " (line:column)" when position info is available
// The initial space is useful to append easily to any existing message
export function formatNodePositionExtraMessage(node: Node): string {
const position = formatNodePosition(node);
return `${position ? ` (${position})` : ''}`;
}

View file

@ -71,7 +71,7 @@ export default async function pluginContentBlog(
); );
} }
const {onBrokenMarkdownLinks, baseUrl} = siteConfig; const {baseUrl} = siteConfig;
const contentPaths: BlogContentPaths = { const contentPaths: BlogContentPaths = {
contentPath: path.resolve(siteDir, options.path), contentPath: path.resolve(siteDir, options.path),
@ -154,18 +154,12 @@ export default async function pluginContentBlog(
}, },
markdownConfig: siteConfig.markdown, markdownConfig: siteConfig.markdown,
resolveMarkdownLink: ({linkPathname, sourceFilePath}) => { resolveMarkdownLink: ({linkPathname, sourceFilePath}) => {
const permalink = resolveMarkdownLinkPathname(linkPathname, { return resolveMarkdownLinkPathname(linkPathname, {
sourceFilePath, sourceFilePath,
sourceToPermalink: contentHelpers.sourceToPermalink, sourceToPermalink: contentHelpers.sourceToPermalink,
siteDir, siteDir,
contentPaths, contentPaths,
}); });
if (permalink === null) {
logger.report(
onBrokenMarkdownLinks,
)`Blog markdown link couldn't be resolved: (url=${linkPathname}) in source file path=${sourceFilePath}`;
}
return permalink;
}, },
}); });

View file

@ -7,7 +7,6 @@
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import logger from '@docusaurus/logger';
import { import {
normalizeUrl, normalizeUrl,
docuHash, docuHash,
@ -158,18 +157,12 @@ export default async function pluginContentDocs(
sourceFilePath, sourceFilePath,
versionsMetadata, versionsMetadata,
); );
const permalink = resolveMarkdownLinkPathname(linkPathname, { return resolveMarkdownLinkPathname(linkPathname, {
sourceFilePath, sourceFilePath,
sourceToPermalink: contentHelpers.sourceToPermalink, sourceToPermalink: contentHelpers.sourceToPermalink,
siteDir, siteDir,
contentPaths: version, contentPaths: version,
}); });
if (permalink === null) {
logger.report(
siteConfig.onBrokenMarkdownLinks,
)`Docs markdown link couldn't be resolved: (url=${linkPathname}) in source file path=${sourceFilePath} for version number=${version.versionName}`;
}
return permalink;
}, },
}, },
}); });

View file

@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@mdx-js/mdx": "^3.0.0", "@mdx-js/mdx": "^3.0.0",
"@types/history": "^4.7.11", "@types/history": "^4.7.11",
"@types/mdast": "^4.0.2",
"@types/react": "*", "@types/react": "*",
"commander": "^5.1.0", "commander": "^5.1.0",
"joi": "^17.9.2", "joi": "^17.9.2",

View file

@ -10,12 +10,8 @@ import type {RuleSetRule} from 'webpack';
import type {DeepPartial, Overwrite} from 'utility-types'; import type {DeepPartial, Overwrite} from 'utility-types';
import type {I18nConfig} from './i18n'; import type {I18nConfig} from './i18n';
import type {PluginConfig, PresetConfig, HtmlTagObject} from './plugin'; import type {PluginConfig, PresetConfig, HtmlTagObject} from './plugin';
import type {ReportingSeverity} from './reporting';
import type {ProcessorOptions} from '@mdx-js/mdx'; import type {MarkdownConfig} from './markdown';
export type RemarkRehypeOptions = ProcessorOptions['remarkRehypeOptions'];
export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw';
export type RouterType = 'browser' | 'hash'; export type RouterType = 'browser' | 'hash';
@ -23,101 +19,6 @@ export type ThemeConfig = {
[key: string]: unknown; [key: string]: unknown;
}; };
export type MarkdownPreprocessor = (args: {
filePath: string;
fileContent: string;
}) => string;
export type MDX1CompatOptions = {
comments: boolean;
admonitions: boolean;
headingIds: boolean;
};
export type ParseFrontMatterParams = {filePath: string; fileContent: string};
export type ParseFrontMatterResult = {
frontMatter: {[key: string]: unknown};
content: string;
};
export type DefaultParseFrontMatter = (
params: ParseFrontMatterParams,
) => Promise<ParseFrontMatterResult>;
export type ParseFrontMatter = (
params: ParseFrontMatterParams & {
defaultParseFrontMatter: DefaultParseFrontMatter;
},
) => Promise<ParseFrontMatterResult>;
export type MarkdownAnchorsConfig = {
/**
* Preserves the case of the heading text when generating anchor ids.
*/
maintainCase: boolean;
};
export type MarkdownConfig = {
/**
* The Markdown format to use by default.
*
* This is the format passed down to the MDX compiler, impacting the way the
* content is parsed.
*
* Possible values:
* - `'mdx'`: use the MDX format (JSX support)
* - `'md'`: use the CommonMark format (no JSX support)
* - `'detect'`: select the format based on file extension (.md / .mdx)
*
* @see https://mdxjs.com/packages/mdx/#optionsformat
* @default 'mdx'
*/
format: 'mdx' | 'md' | 'detect';
/**
* A function callback that lets users parse the front matter themselves.
* Gives the opportunity to read it from a different source, or process it.
*
* @see https://github.com/facebook/docusaurus/issues/5568
*/
parseFrontMatter: ParseFrontMatter;
/**
* Allow mermaid language code blocks to be rendered into Mermaid diagrams:
*
* - `true`: code blocks with language mermaid will be rendered.
* - `false` | `undefined` (default): code blocks with language mermaid
* will be left as code blocks.
*
* @see https://docusaurus.io/docs/markdown-features/diagrams/
* @default false
*/
mermaid: boolean;
/**
* Gives opportunity to preprocess the MDX string content before compiling.
* A good escape hatch that can be used to handle edge cases.
*
* @param args
*/
preprocessor?: MarkdownPreprocessor;
/**
* Set of flags make it easier to upgrade from MDX 1 to MDX 2
* See also https://github.com/facebook/docusaurus/issues/4029
*/
mdx1Compat: MDX1CompatOptions;
/**
* Ability to provide custom remark-rehype options
* See also https://github.com/remarkjs/remark-rehype#options
*/
remarkRehypeOptions: RemarkRehypeOptions;
/**
* Options to control the behavior of anchors generated from Markdown headings
*/
anchors: MarkdownAnchorsConfig;
};
export type StorageConfig = { export type StorageConfig = {
type: SiteStorage['type']; type: SiteStorage['type'];
namespace: boolean | string; namespace: boolean | string;
@ -258,7 +159,8 @@ export type DocusaurusConfig = {
* @see https://docusaurus.io/docs/api/docusaurus-config#onBrokenMarkdownLinks * @see https://docusaurus.io/docs/api/docusaurus-config#onBrokenMarkdownLinks
* @default "warn" * @default "warn"
*/ */
onBrokenMarkdownLinks: ReportingSeverity; // TODO Docusaurus v4 remove
onBrokenMarkdownLinks: ReportingSeverity | undefined;
/** /**
* The behavior of Docusaurus when it detects any [duplicate * The behavior of Docusaurus when it detects any [duplicate
* routes](https://docusaurus.io/docs/creating-pages#duplicate-routes). * routes](https://docusaurus.io/docs/creating-pages#duplicate-routes).

View file

@ -6,19 +6,27 @@
*/ */
export { export {
ReportingSeverity,
RouterType, RouterType,
ThemeConfig, ThemeConfig,
MarkdownConfig,
DefaultParseFrontMatter,
ParseFrontMatter,
DocusaurusConfig, DocusaurusConfig,
FutureConfig, FutureConfig,
FutureV4Config,
FasterConfig, FasterConfig,
StorageConfig, StorageConfig,
Config, Config,
} from './config'; } from './config';
export {
MarkdownConfig,
MarkdownHooks,
DefaultParseFrontMatter,
ParseFrontMatter,
OnBrokenMarkdownLinksFunction,
OnBrokenMarkdownImagesFunction,
} from './markdown';
export {ReportingSeverity} from './reporting';
export { export {
SiteMetadata, SiteMetadata,
DocusaurusContext, DocusaurusContext,

View file

@ -0,0 +1,165 @@
/**
* 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 type {ProcessorOptions} from '@mdx-js/mdx';
import type {Image, Definition, Link} from 'mdast';
import type {ReportingSeverity} from './reporting';
export type RemarkRehypeOptions = ProcessorOptions['remarkRehypeOptions'];
export type MarkdownPreprocessor = (args: {
filePath: string;
fileContent: string;
}) => string;
export type MDX1CompatOptions = {
comments: boolean;
admonitions: boolean;
headingIds: boolean;
};
export type ParseFrontMatterParams = {filePath: string; fileContent: string};
export type ParseFrontMatterResult = {
frontMatter: {[key: string]: unknown};
content: string;
};
export type DefaultParseFrontMatter = (
params: ParseFrontMatterParams,
) => Promise<ParseFrontMatterResult>;
export type ParseFrontMatter = (
params: ParseFrontMatterParams & {
defaultParseFrontMatter: DefaultParseFrontMatter;
},
) => Promise<ParseFrontMatterResult>;
export type MarkdownAnchorsConfig = {
/**
* Preserves the case of the heading text when generating anchor ids.
*/
maintainCase: boolean;
};
export type OnBrokenMarkdownLinksFunction = (params: {
/**
* Path of the source file on which the broken link was found
* Relative to the site dir.
* Example: "docs/category/myDoc.mdx"
*/
sourceFilePath: string;
/**
* The Markdown link url that couldn't be resolved.
* Technically, in this context, it's more a "relative file path", but let's
* name it url for consistency with usual Markdown names and the MDX AST
* Example: "relative/dir/myTargetDoc.mdx?query#hash"
*/
url: string;
/**
* The Markdown Link AST node.
*/
node: Link | Definition;
}) => void | string;
export type OnBrokenMarkdownImagesFunction = (params: {
/**
* Path of the source file on which the broken image was found
* Relative to the site dir.
* Example: "docs/category/myDoc.mdx"
*/
sourceFilePath: string;
/**
* The Markdown image url that couldn't be resolved.
* Technically, in this context, it's more a "relative file path", but let's
* name it url for consistency with usual Markdown names and the MDX AST
* Example: "relative/dir/myImage.png"
*/
url: string;
/**
* The Markdown Image AST node.
*/
node: Image;
}) => void | string;
export type MarkdownHooks = {
/**
* The behavior of Docusaurus when it detects any broken Markdown link.
*
* // TODO refactor doc links!
* @see https://docusaurus.io/docs/api/docusaurus-config#onBrokenMarkdownLinks
* @default "warn"
*/
onBrokenMarkdownLinks: ReportingSeverity | OnBrokenMarkdownLinksFunction;
onBrokenMarkdownImages: ReportingSeverity | OnBrokenMarkdownImagesFunction;
};
export type MarkdownConfig = {
/**
* The Markdown format to use by default.
*
* This is the format passed down to the MDX compiler, impacting the way the
* content is parsed.
*
* Possible values:
* - `'mdx'`: use the MDX format (JSX support)
* - `'md'`: use the CommonMark format (no JSX support)
* - `'detect'`: select the format based on file extension (.md / .mdx)
*
* @see https://mdxjs.com/packages/mdx/#optionsformat
* @default 'mdx'
*/
format: 'mdx' | 'md' | 'detect';
/**
* A function callback that lets users parse the front matter themselves.
* Gives the opportunity to read it from a different source, or process it.
*
* @see https://github.com/facebook/docusaurus/issues/5568
*/
parseFrontMatter: ParseFrontMatter;
/**
* Allow mermaid language code blocks to be rendered into Mermaid diagrams:
*
* - `true`: code blocks with language mermaid will be rendered.
* - `false` | `undefined` (default): code blocks with language mermaid
* will be left as code blocks.
*
* @see https://docusaurus.io/docs/markdown-features/diagrams/
* @default false
*/
mermaid: boolean;
/**
* Gives opportunity to preprocess the MDX string content before compiling.
* A good escape hatch that can be used to handle edge cases.
*
* @param args
*/
preprocessor?: MarkdownPreprocessor;
/**
* Set of flags make it easier to upgrade from MDX 1 to MDX 2
* See also https://github.com/facebook/docusaurus/issues/4029
*/
mdx1Compat: MDX1CompatOptions;
/**
* Ability to provide custom remark-rehype options
* See also https://github.com/remarkjs/remark-rehype#options
*/
remarkRehypeOptions: RemarkRehypeOptions;
/**
* Options to control the behavior of anchors generated from Markdown headings
*/
anchors: MarkdownAnchorsConfig;
hooks: MarkdownHooks;
};

View file

@ -0,0 +1,8 @@
/**
* 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.
*/
export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw';

View file

@ -42,6 +42,10 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -55,7 +59,6 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],
@ -117,6 +120,10 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -130,7 +137,6 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],
@ -192,6 +198,10 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -205,7 +215,6 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],
@ -267,6 +276,10 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -280,7 +293,6 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],
@ -342,6 +354,10 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -355,7 +371,6 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],
@ -417,6 +432,10 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -430,7 +449,6 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],
@ -492,6 +510,10 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -505,7 +527,6 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"organizationName": "endiliey", "organizationName": "endiliey",
"plugins": [], "plugins": [],
@ -569,6 +590,10 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -582,7 +607,6 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"organizationName": "endiliey", "organizationName": "endiliey",
"plugins": [], "plugins": [],
@ -646,6 +670,10 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -659,7 +687,6 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"organizationName": "endiliey", "organizationName": "endiliey",
"plugins": [], "plugins": [],
@ -726,6 +753,10 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -739,7 +770,6 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"organizationName": "endiliey", "organizationName": "endiliey",
"plugins": [ "plugins": [

View file

@ -126,6 +126,10 @@ exports[`load loads props for site with custom i18n path 1`] = `
"maintainCase": false, "maintainCase": false,
}, },
"format": "mdx", "format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": { "mdx1Compat": {
"admonitions": true, "admonitions": true,
"comments": true, "comments": true,
@ -139,7 +143,6 @@ exports[`load loads props for site with custom i18n path 1`] = `
"noIndex": false, "noIndex": false,
"onBrokenAnchors": "warn", "onBrokenAnchors": "warn",
"onBrokenLinks": "throw", "onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn", "onDuplicateRoutes": "warn",
"plugins": [], "plugins": [],
"presets": [], "presets": [],

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {jest} from '@jest/globals';
import { import {
ConfigSchema, ConfigSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
@ -16,6 +17,10 @@ import {
DEFAULT_STORAGE_CONFIG, DEFAULT_STORAGE_CONFIG,
validateConfig, validateConfig,
} from '../configValidation'; } from '../configValidation';
import type {
MarkdownConfig,
MarkdownHooks,
} from '@docusaurus/types/src/markdown';
import type { import type {
FasterConfig, FasterConfig,
FutureConfig, FutureConfig,
@ -36,7 +41,7 @@ const normalizeConfig = (config: DeepPartial<Config>) =>
describe('normalizeConfig', () => { describe('normalizeConfig', () => {
it('normalizes empty config', () => { it('normalizes empty config', () => {
const value = normalizeConfig({}); const value = normalizeConfig({markdown: {}});
expect(value).toEqual({ expect(value).toEqual({
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...baseConfig, ...baseConfig,
@ -108,6 +113,10 @@ describe('normalizeConfig', () => {
remarkRehypeOptions: { remarkRehypeOptions: {
footnoteLabel: 'Pied de page', footnoteLabel: 'Pied de page',
}, },
hooks: {
onBrokenMarkdownLinks: 'log',
onBrokenMarkdownImages: 'log',
},
}, },
}; };
const normalizedConfig = normalizeConfig(userConfig); const normalizedConfig = normalizeConfig(userConfig);
@ -357,20 +366,15 @@ describe('onBrokenLinks', () => {
}); });
describe('markdown', () => { describe('markdown', () => {
function normalizeMarkdown(markdown: DeepPartial<MarkdownConfig>) {
return normalizeConfig({markdown}).markdown;
}
it('accepts undefined object', () => { it('accepts undefined object', () => {
expect( expect(normalizeMarkdown(undefined)).toEqual(DEFAULT_CONFIG.markdown);
normalizeConfig({
markdown: undefined,
}),
).toEqual(expect.objectContaining({markdown: DEFAULT_CONFIG.markdown}));
}); });
it('accepts empty object', () => { it('accepts empty object', () => {
expect( expect(normalizeMarkdown({})).toEqual(DEFAULT_CONFIG.markdown);
normalizeConfig({
markdown: {},
}),
).toEqual(expect.objectContaining({markdown: DEFAULT_CONFIG.markdown}));
}); });
it('accepts valid markdown object', () => { it('accepts valid markdown object', () => {
@ -393,12 +397,12 @@ describe('markdown', () => {
// @ts-expect-error: we don't validate it on purpose // @ts-expect-error: we don't validate it on purpose
anyKey: 'heck we accept it on purpose', anyKey: 'heck we accept it on purpose',
}, },
hooks: {
onBrokenMarkdownLinks: 'log',
onBrokenMarkdownImages: 'warn',
},
}; };
expect( expect(normalizeMarkdown(markdown)).toEqual(markdown);
normalizeConfig({
markdown,
}),
).toEqual(expect.objectContaining({markdown}));
}); });
it('accepts partial markdown object', () => { it('accepts partial markdown object', () => {
@ -408,22 +412,14 @@ describe('markdown', () => {
headingIds: false, headingIds: false,
}, },
}; };
expect( expect(normalizeMarkdown(markdown)).toEqual({
normalizeConfig({ ...DEFAULT_CONFIG.markdown,
markdown, ...markdown,
}), mdx1Compat: {
).toEqual( ...DEFAULT_CONFIG.markdown.mdx1Compat,
expect.objectContaining({ ...markdown.mdx1Compat,
markdown: { },
...DEFAULT_CONFIG.markdown, });
...markdown,
mdx1Compat: {
...DEFAULT_CONFIG.markdown.mdx1Compat,
...markdown.mdx1Compat,
},
},
}),
);
}); });
it('throw for preprocessor bad arity', () => { it('throw for preprocessor bad arity', () => {
@ -436,10 +432,10 @@ describe('markdown', () => {
" "
`); `);
expect(() => expect(() =>
normalizeConfig({ normalizeMarkdown(
// @ts-expect-error: types forbid this // @ts-expect-error: types forbid this
markdown: {preprocessor: (arg1, arg2) => String(arg1) + String(arg2)}, {preprocessor: (arg1, arg2) => String(arg1) + String(arg2)},
}), ),
).toThrowErrorMatchingInlineSnapshot(` ).toThrowErrorMatchingInlineSnapshot(`
""markdown.preprocessor" must have an arity of 1 ""markdown.preprocessor" must have an arity of 1
" "
@ -447,18 +443,13 @@ describe('markdown', () => {
}); });
it('accepts undefined markdown format', () => { it('accepts undefined markdown format', () => {
expect( expect(normalizeMarkdown({format: undefined}).format).toBe('mdx');
normalizeConfig({markdown: {format: undefined}}).markdown.format,
).toBe('mdx');
}); });
it('throw for bad markdown format', () => { it('throw for bad markdown format', () => {
expect(() => expect(() =>
normalizeConfig({ normalizeMarkdown({
markdown: { format: null,
// @ts-expect-error: bad value
format: null,
},
}), }),
).toThrowErrorMatchingInlineSnapshot(` ).toThrowErrorMatchingInlineSnapshot(`
""markdown.format" must be one of [mdx, md, detect] ""markdown.format" must be one of [mdx, md, detect]
@ -466,9 +457,9 @@ describe('markdown', () => {
" "
`); `);
expect(() => expect(() =>
normalizeConfig( normalizeMarkdown(
// @ts-expect-error: bad value // @ts-expect-error: bad value
{markdown: {format: 'xyz'}}, {format: 'xyz'},
), ),
).toThrowErrorMatchingInlineSnapshot(` ).toThrowErrorMatchingInlineSnapshot(`
""markdown.format" must be one of [mdx, md, detect] ""markdown.format" must be one of [mdx, md, detect]
@ -478,15 +469,165 @@ describe('markdown', () => {
it('throw for null object', () => { it('throw for null object', () => {
expect(() => { expect(() => {
normalizeConfig({ normalizeMarkdown(null);
// @ts-expect-error: bad value
markdown: null,
});
}).toThrowErrorMatchingInlineSnapshot(` }).toThrowErrorMatchingInlineSnapshot(`
""markdown" must be of type object ""markdown" must be of type object
" "
`); `);
}); });
describe('hooks', () => {
function normalizeHooks(hooks: DeepPartial<MarkdownHooks>): MarkdownHooks {
return normalizeMarkdown({
hooks,
}).hooks;
}
describe('onBrokenMarkdownLinks', () => {
function normalizeValue(
onBrokenMarkdownLinks?: MarkdownHooks['onBrokenMarkdownLinks'],
) {
return normalizeHooks({
onBrokenMarkdownLinks,
}).onBrokenMarkdownLinks;
}
it('accepts undefined', () => {
expect(normalizeValue(undefined)).toBe('warn');
});
it('accepts severity level', () => {
expect(normalizeValue('log')).toBe('log');
});
it('rejects number', () => {
expect(() =>
normalizeValue(
// @ts-expect-error: bad value
42,
),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.hooks.onBrokenMarkdownLinks" does not match any of the allowed types
"
`);
});
it('accepts function', () => {
expect(normalizeValue(() => {})).toBeInstanceOf(Function);
});
it('rejects null', () => {
expect(() => normalizeValue(null)).toThrowErrorMatchingInlineSnapshot(`
""markdown.hooks.onBrokenMarkdownLinks" does not match any of the allowed types
"
`);
});
describe('onBrokenMarkdownLinks migration', () => {
const warnMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
beforeEach(() => {
warnMock.mockClear();
});
it('accepts migrated v3 config', () => {
expect(
normalizeConfig({
onBrokenMarkdownLinks: undefined,
markdown: {
hooks: {
onBrokenMarkdownLinks: 'throw',
},
},
}),
).toEqual(
expect.objectContaining({
onBrokenMarkdownLinks: undefined,
markdown: expect.objectContaining({
hooks: expect.objectContaining({
onBrokenMarkdownLinks: 'throw',
}),
}),
}),
);
expect(warnMock).not.toHaveBeenCalled();
});
it('accepts deprecated v3 config with migration warning', () => {
expect(
normalizeConfig({
onBrokenMarkdownLinks: 'log',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'throw',
},
},
}),
).toEqual(
expect.objectContaining({
onBrokenMarkdownLinks: undefined,
markdown: expect.objectContaining({
hooks: expect.objectContaining({
onBrokenMarkdownLinks: 'log',
}),
}),
}),
);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls[0]).toMatchInlineSnapshot(`
[
"[WARNING] The \`siteConfig.onBrokenMarkdownLinks\` config option is deprecated and will be removed in Docusaurus v4.
Please migrate and move this option to \`siteConfig.markdown.hooks.onBrokenMarkdownLinks\` instead.",
]
`);
});
});
});
describe('onBrokenMarkdownImages', () => {
function normalizeValue(
onBrokenMarkdownImages?: MarkdownHooks['onBrokenMarkdownImages'],
) {
return normalizeHooks({
onBrokenMarkdownImages,
}).onBrokenMarkdownImages;
}
it('accepts undefined', () => {
expect(normalizeValue(undefined)).toBe('throw');
});
it('accepts severity level', () => {
expect(normalizeValue('log')).toBe('log');
});
it('rejects number', () => {
expect(() =>
normalizeValue(
// @ts-expect-error: bad value
42,
),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.hooks.onBrokenMarkdownImages" does not match any of the allowed types
"
`);
});
it('accepts function', () => {
expect(normalizeValue(() => {})).toBeInstanceOf(Function);
});
it('rejects null', () => {
expect(() => normalizeValue(null)).toThrowErrorMatchingInlineSnapshot(`
""markdown.hooks.onBrokenMarkdownImages" does not match any of the allowed types
"
`);
});
});
});
}); });
describe('plugins', () => { describe('plugins', () => {
@ -846,7 +987,6 @@ describe('future', () => {
}); });
it('rejects router - null', () => { it('rejects router - null', () => {
// @ts-expect-error: bad value
const router: Config['future']['experimental_router'] = null; const router: Config['future']['experimental_router'] = null;
expect(() => expect(() =>
normalizeConfig({ normalizeConfig({
@ -1055,7 +1195,6 @@ describe('future', () => {
}); });
it('rejects namespace - null', () => { it('rejects namespace - null', () => {
// @ts-expect-error: bad value
const storage: Partial<StorageConfig> = {namespace: null}; const storage: Partial<StorageConfig> = {namespace: null};
expect(() => expect(() =>
normalizeConfig({ normalizeConfig({

View file

@ -22,11 +22,10 @@ import type {
FutureConfig, FutureConfig,
FutureV4Config, FutureV4Config,
StorageConfig, StorageConfig,
} from '@docusaurus/types/src/config';
import type {
DocusaurusConfig, DocusaurusConfig,
I18nConfig, I18nConfig,
MarkdownConfig, MarkdownConfig,
MarkdownHooks,
} from '@docusaurus/types'; } from '@docusaurus/types';
const DEFAULT_I18N_LOCALE = 'en'; const DEFAULT_I18N_LOCALE = 'en';
@ -84,6 +83,11 @@ export const DEFAULT_FUTURE_CONFIG: FutureConfig = {
experimental_router: 'browser', experimental_router: 'browser',
}; };
export const DEFAULT_MARKDOWN_HOOKS: MarkdownHooks = {
onBrokenMarkdownLinks: 'warn',
onBrokenMarkdownImages: 'throw',
};
export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = { export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
format: 'mdx', // TODO change this to "detect" in Docusaurus v4? format: 'mdx', // TODO change this to "detect" in Docusaurus v4?
mermaid: false, mermaid: false,
@ -98,6 +102,7 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
maintainCase: false, maintainCase: false,
}, },
remarkRehypeOptions: undefined, remarkRehypeOptions: undefined,
hooks: DEFAULT_MARKDOWN_HOOKS,
}; };
export const DEFAULT_CONFIG: Pick< export const DEFAULT_CONFIG: Pick<
@ -128,7 +133,7 @@ export const DEFAULT_CONFIG: Pick<
future: DEFAULT_FUTURE_CONFIG, future: DEFAULT_FUTURE_CONFIG,
onBrokenLinks: 'throw', onBrokenLinks: 'throw',
onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw
onBrokenMarkdownLinks: 'warn', onBrokenMarkdownLinks: undefined,
onDuplicateRoutes: 'warn', onDuplicateRoutes: 'warn',
plugins: [], plugins: [],
themes: [], themes: [],
@ -350,7 +355,7 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
.default(DEFAULT_CONFIG.onBrokenAnchors), .default(DEFAULT_CONFIG.onBrokenAnchors),
onBrokenMarkdownLinks: Joi.string() onBrokenMarkdownLinks: Joi.string()
.equal('ignore', 'log', 'warn', 'throw') .equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_CONFIG.onBrokenMarkdownLinks), .default(() => DEFAULT_CONFIG.onBrokenMarkdownLinks),
onDuplicateRoutes: Joi.string() onDuplicateRoutes: Joi.string()
.equal('ignore', 'log', 'warn', 'throw') .equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_CONFIG.onDuplicateRoutes), .default(DEFAULT_CONFIG.onDuplicateRoutes),
@ -455,6 +460,20 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
DEFAULT_CONFIG.markdown.anchors.maintainCase, DEFAULT_CONFIG.markdown.anchors.maintainCase,
), ),
}).default(DEFAULT_CONFIG.markdown.anchors), }).default(DEFAULT_CONFIG.markdown.anchors),
hooks: Joi.object<MarkdownHooks>({
onBrokenMarkdownLinks: Joi.alternatives()
.try(
Joi.string().equal('ignore', 'log', 'warn', 'throw'),
Joi.function(),
)
.default(DEFAULT_CONFIG.markdown.hooks.onBrokenMarkdownLinks),
onBrokenMarkdownImages: Joi.alternatives()
.try(
Joi.string().equal('ignore', 'log', 'warn', 'throw'),
Joi.function(),
)
.default(DEFAULT_CONFIG.markdown.hooks.onBrokenMarkdownImages),
}).default(DEFAULT_CONFIG.markdown.hooks),
}).default(DEFAULT_CONFIG.markdown), }).default(DEFAULT_CONFIG.markdown),
}).messages({ }).messages({
'docusaurus.configValidationWarning': 'docusaurus.configValidationWarning':
@ -463,7 +482,16 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
// Expressing this kind of logic in Joi is a pain // Expressing this kind of logic in Joi is a pain
// We also want to decouple logic from Joi: easier to remove it later! // We also want to decouple logic from Joi: easier to remove it later!
function ensureDocusaurusConfigConsistency(config: DocusaurusConfig) { function postProcessDocusaurusConfig(config: DocusaurusConfig) {
if (config.onBrokenMarkdownLinks) {
logger.warn`The code=${'siteConfig.onBrokenMarkdownLinks'} config option is deprecated and will be removed in Docusaurus v4.
Please migrate and move this option to code=${'siteConfig.markdown.hooks.onBrokenMarkdownLinks'} instead.`;
// For v3 retro compatibility we use the old attribute over the new one
config.markdown.hooks.onBrokenMarkdownLinks = config.onBrokenMarkdownLinks;
// We erase the former one to ensure we don't use it anywhere
config.onBrokenMarkdownLinks = undefined;
}
if ( if (
config.future.experimental_faster.ssgWorkerThreads && config.future.experimental_faster.ssgWorkerThreads &&
!config.future.v4.removeLegacyPostBuildHeadAttribute !config.future.v4.removeLegacyPostBuildHeadAttribute
@ -528,7 +556,7 @@ export function validateConfig(
throw new Error(formattedError); throw new Error(formattedError);
} }
ensureDocusaurusConfigConsistency(value); postProcessDocusaurusConfig(value);
return value; return value;
} }

View file

@ -275,6 +275,14 @@ By default, it prints a warning, to let you know about your broken anchors.
### `onBrokenMarkdownLinks` {#onBrokenMarkdownLinks} ### `onBrokenMarkdownLinks` {#onBrokenMarkdownLinks}
:::warning Deprecated
Deprecated in Docusaurus v3.9, and will be removed in Docusaurus v4.
Replaced by [`siteConfig.markdown.hooks.onBrokenMarkdownLinks`](#hooks.onBrokenMarkdownLinks)
:::
- Type: `'ignore' | 'log' | 'warn' | 'throw'` - Type: `'ignore' | 'log' | 'warn' | 'throw'`
The behavior of Docusaurus when it detects any broken Markdown link. The behavior of Docusaurus when it detects any broken Markdown link.
@ -511,6 +519,25 @@ type MarkdownAnchorsConfig = {
maintainCase: boolean; maintainCase: boolean;
}; };
type OnBrokenMarkdownLinksFunction = (params: {
sourceFilePath: string; // MD/MDX source file relative to cwd
url: string; // Link url
node: Link | Definition; // mdast Node
}) => void | string;
type OnBrokenMarkdownImagesFunction = (params: {
sourceFilePath: string; // MD/MDX source file relative to cwd
url: string; // Image url
node: Image; // mdast node
}) => void | string;
type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw';
type MarkdownHooks = {
onBrokenMarkdownLinks: ReportingSeverity | OnBrokenMarkdownLinksFunction;
onBrokenMarkdownImages: ReportingSeverity | OnBrokenMarkdownImagesFunction;
};
type MarkdownConfig = { type MarkdownConfig = {
format: 'mdx' | 'md' | 'detect'; format: 'mdx' | 'md' | 'detect';
mermaid: boolean; mermaid: boolean;
@ -519,6 +546,7 @@ type MarkdownConfig = {
mdx1Compat: MDX1CompatOptions; mdx1Compat: MDX1CompatOptions;
remarkRehypeOptions: object; // see https://github.com/remarkjs/remark-rehype#options remarkRehypeOptions: object; // see https://github.com/remarkjs/remark-rehype#options
anchors: MarkdownAnchorsConfig; anchors: MarkdownAnchorsConfig;
hooks: MarkdownHooks;
}; };
``` ```
@ -546,6 +574,10 @@ export default {
anchors: { anchors: {
maintainCase: true, maintainCase: true,
}, },
hooks: {
onBrokenMarkdownLinks: 'warn',
onBrokenMarkdownImages: 'throw',
},
}, },
}; };
``` ```
@ -563,6 +595,9 @@ export default {
| `mdx1Compat` | `MDX1CompatOptions` | `{comments: true, admonitions: true, headingIds: true}` | Compatibility options to make it easier to upgrade to Docusaurus v3+. | | `mdx1Compat` | `MDX1CompatOptions` | `{comments: true, admonitions: true, headingIds: true}` | Compatibility options to make it easier to upgrade to Docusaurus v3+. |
| `anchors` | `MarkdownAnchorsConfig` | `{maintainCase: false}` | Options to control the behavior of anchors generated from Markdown headings | | `anchors` | `MarkdownAnchorsConfig` | `{maintainCase: false}` | Options to control the behavior of anchors generated from Markdown headings |
| `remarkRehypeOptions` | `object` | `undefined` | Makes it possible to pass custom [`remark-rehype` options](https://github.com/remarkjs/remark-rehype#options). | | `remarkRehypeOptions` | `object` | `undefined` | Makes it possible to pass custom [`remark-rehype` options](https://github.com/remarkjs/remark-rehype#options). |
| `hooks` | `MarkdownHooks` | `object` | Make it possible to customize the MDX loader behavior with callbacks or built-in options. |
| `hooks.onBrokenMarkdownLinks` | `ReportingSeverity \| OnBrokenMarkdownLinksFunction` | `'warn'` | Hook to customize the behavior when encountering a broken Markdown link URL. With the callback function, you can return a new link URL, or alter the link [mdast node](https://github.com/syntax-tree/mdast). |
| `hooks.onBrokenMarkdownLinks` | `ReportingSeverity \| OnBrokenMarkdownImagesFunction` | `'throw'` | Hook to customize the behavior when encountering a broken Markdown image URL. With the callback function, you can return a new image URL, or alter the image [mdast node](https://github.com/syntax-tree/mdast). |
```mdx-code-block ```mdx-code-block
</APITable> </APITable>

View file

@ -15,7 +15,6 @@ export default {
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
// We can only warn now, since we have blog pages linking to non-blog pages... // We can only warn now, since we have blog pages linking to non-blog pages...
onBrokenLinks: 'warn', onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'warn',
favicon: 'img/docusaurus.ico', favicon: 'img/docusaurus.ico',
themes: ['live-codeblock'], themes: ['live-codeblock'],
plugins: ['ideal-image'], plugins: ['ideal-image'],

View file

@ -217,6 +217,9 @@ export default async function createConfigAsync() {
markdown: { markdown: {
format: 'detect', format: 'detect',
mermaid: true, mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'warn',
},
mdx1Compat: { mdx1Compat: {
// comments: false, // comments: false,
}, },
@ -265,7 +268,6 @@ export default async function createConfigAsync() {
process.env.DOCUSAURUS_CURRENT_LOCALE !== defaultLocale process.env.DOCUSAURUS_CURRENT_LOCALE !== defaultLocale
? 'warn' ? 'warn'
: 'throw', : 'throw',
onBrokenMarkdownLinks: 'warn',
favicon: 'img/docusaurus.ico', favicon: 'img/docusaurus.ico',
customFields: { customFields: {
crashTest, crashTest,