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.
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
// 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

View file

@ -31,7 +31,6 @@ const config = {
projectName: 'docusaurus', // Usually your repo name.
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
// 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

View file

@ -22,6 +22,9 @@ import type {WebpackCompilerName} from '@docusaurus/utils';
import type {MDXFrontMatter} from './frontMatter';
import type {Options} from './options';
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';
// 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,
siteDir: options.siteDir,
},
onBrokenMarkdownImages:
options.markdownConfig.hooks.onBrokenMarkdownImages,
} satisfies TransformImageOptions,
],
// TODO merge this with transformLinks?
options.resolveMarkdownLink
? [
resolveMarkdownLinks,
{resolveMarkdownLink: options.resolveMarkdownLink},
{
resolveMarkdownLink: options.resolveMarkdownLink,
onBrokenMarkdownLinks:
options.markdownConfig.hooks.onBrokenMarkdownLinks,
} satisfies ResolveMarkdownLinksOptions,
]
: undefined,
[
@ -135,7 +144,9 @@ async function createProcessorFactory() {
{
staticDirs: options.staticDirs,
siteDir: options.siteDir,
},
onBrokenMarkdownLinks:
options.markdownConfig.hooks.onBrokenMarkdownLinks,
} satisfies TransformLinksOptions,
],
gfm,
options.markdownConfig.mdx1Compat.comments ? comment : null,

View file

@ -5,22 +5,47 @@
* LICENSE file in the root directory of this source tree.
*/
import {jest} from '@jest/globals';
import * as path from 'path';
import plugin from '..';
import type {PluginOptions} from '../index';
async function process(content: string) {
const {remark} = await import('remark');
const siteDir = __dirname;
const options: PluginOptions = {
const DefaultTestOptions: PluginOptions = {
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;
}
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 () => {
/* language=markdown */
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 {
parseLocalURLPath,
serializeURLPath,
toMessageRelativeFilePath,
type URLPath,
} from '@docusaurus/utils';
import logger from '@docusaurus/logger';
import {formatNodePositionExtraMessage} from '../utils';
import type {Plugin, Transformer} from 'unified';
import type {Definition, Link, Root} from 'mdast';
import type {
MarkdownConfig,
OnBrokenMarkdownLinksFunction,
} from '@docusaurus/types';
type ResolveMarkdownLinkParams = {
/**
@ -32,6 +39,33 @@ export type ResolveMarkdownLink = (
export interface PluginOptions {
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;
@ -57,10 +91,15 @@ function parseMarkdownLinkURLPath(link: string): URLPath | null {
* This is exposed as "data.contentTitle" to the processed vfile
* 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(
options,
): Transformer<Root> {
const {resolveMarkdownLink} = options;
const onBrokenMarkdownLinks = asFunction(options.onBrokenMarkdownLinks);
return async (root, file) => {
const {visit} = await import('unist-util-visit');
@ -71,18 +110,26 @@ const plugin: Plugin<PluginOptions[], Root> = function plugin(
return;
}
const sourceFilePath = file.path;
const permalink = resolveMarkdownLink({
sourceFilePath: file.path,
sourceFilePath,
linkPathname: linkURLPath.pathname,
});
if (permalink) {
// This reapplies the link ?qs#hash part to the resolved pathname
const resolvedUrl = serializeURLPath({
link.url = serializeURLPath({
...linkURLPath,
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
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`] = `
"![img](/img/unchecked.png)
"

View file

@ -6,65 +6,361 @@
*/
import {jest} from '@jest/globals';
import path from 'path';
import * as path from 'path';
import vfile from 'to-vfile';
import plugin, {type PluginOptions} from '../index';
const processFixture = async (
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 siteDir = path.join(__dirname, '__fixtures__');
const staticDirs = [
path.join(__dirname, '__fixtures__/static'),
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', () => {
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 () => {
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();
});
it('pathname protocol', async () => {
const result = await processFixture('pathname', {staticDirs});
const result = await processContent(
`![img](pathname:///img/unchecked.png)`,
);
expect(result).toMatchSnapshot();
});
it('does not choke on invalid image', async () => {
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(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 {imageSizeFromFile} from 'image-size/fromFile';
import logger from '@docusaurus/logger';
import {assetRequireAttributeValue, transformNode} from '../utils';
import {
assetRequireAttributeValue,
formatNodePositionExtraMessage,
transformNode,
} from '../utils';
import type {Plugin, Transformer} from 'unified';
import type {MdxJsxTextElement} from 'mdast-util-mdx';
import type {Image, Root} from 'mdast';
import type {Parent} from 'unist';
import type {
MarkdownConfig,
OnBrokenMarkdownImagesFunction,
} from '@docusaurus/types';
type PluginOptions = {
export type PluginOptions = {
staticDirs: string[];
siteDir: string;
onBrokenMarkdownImages: MarkdownConfig['hooks']['onBrokenMarkdownImages'];
};
type Context = PluginOptions & {
type Context = {
staticDirs: PluginOptions['staticDirs'];
siteDir: PluginOptions['siteDir'];
onBrokenMarkdownImages: OnBrokenMarkdownImagesFunction;
filePath: 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];
async function toImageRequireNode(
@ -51,7 +96,7 @@ async function toImageRequireNode(
);
relativeImagePath = `./${relativeImagePath}`;
const parsedUrl = parseURLOrPath(node.url, 'https://example.com');
const parsedUrl = parseURLOrPath(node.url);
const hash = parsedUrl.hash ?? '';
const search = parsedUrl.search ?? '';
const requireString = `${context.inlineMarkdownImageFileLoader}${
@ -113,57 +158,53 @@ ${(err as Error).message}`;
});
}
async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
const imageExists = await fs.pathExists(imagePath);
if (!imageExists) {
throw new Error(
`Image ${toMessageRelativeFilePath(
imagePath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
);
}
}
async function getImageAbsolutePath(
imagePath: string,
async function getLocalImageAbsolutePath(
originalImagePath: string,
{siteDir, filePath, staticDirs}: Context,
) {
if (imagePath.startsWith('@site/')) {
const imageFilePath = path.join(siteDir, imagePath.replace('@site/', ''));
await ensureImageFileExist(imageFilePath, filePath);
if (originalImagePath.startsWith('@site/')) {
const imageFilePath = path.join(
siteDir,
originalImagePath.replace('@site/', ''),
);
if (!(await fs.pathExists(imageFilePath))) {
return null;
}
return imageFilePath;
} else if (path.isAbsolute(imagePath)) {
} else if (path.isAbsolute(originalImagePath)) {
// 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(
possiblePaths,
fs.pathExists,
);
if (!imageFilePath) {
throw new Error(
`Image ${possiblePaths
.map((p) => toMessageRelativeFilePath(p))
.join(' or ')} used in ${toMessageRelativeFilePath(
filePath,
)} not found.`,
);
return null;
}
return imageFilePath;
}
} else {
// relative paths are resolved against the source file's folder
const imageFilePath = path.join(path.dirname(filePath), imagePath);
await ensureImageFileExist(imageFilePath, filePath);
const imageFilePath = path.join(path.dirname(filePath), originalImagePath);
if (!(await fs.pathExists(imageFilePath))) {
return null;
}
return imageFilePath;
}
}
async function processImageNode(target: Target, context: Context) {
const [node] = target;
if (!node.url) {
throw new Error(
`Markdown image URL is mandatory in "${toMessageRelativeFilePath(
context.filePath,
)}" file`,
);
node.url =
context.onBrokenMarkdownImages({
url: node.url,
sourceFilePath: context.filePath,
node,
}) ?? node.url;
return;
}
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
// going through webpack ensures that image assets exist at build time
const imagePath = await getImageAbsolutePath(decodedPathname, context);
await toImageRequireNode(target, imagePath, context);
const localImagePath = await getLocalImageAbsolutePath(
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(
options,
): Transformer<Root> {
const onBrokenMarkdownImages = asFunction(options.onBrokenMarkdownImages);
return async (root, vfile) => {
const {visit} = await import('unist-util-visit');
@ -201,6 +256,7 @@ const plugin: Plugin<PluginOptions[], Root> = function plugin(
filePath: vfile.path!,
inlineMarkdownImageFileLoader:
fileLoaderUtils.loaders.inlineMarkdownImageFileLoader,
onBrokenMarkdownImages,
};
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
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[`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`] = `
exports[`transformLinks plugin transform md links to <a /> 1`] = `
"[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} />
@ -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("./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.
*/
import path from 'path';
import {jest} from '@jest/globals';
import * as path from 'path';
import vfile from 'to-vfile';
import plugin from '..';
import transformImage, {type PluginOptions} from '../../transformImage';
import plugin, {type PluginOptions} from '..';
import transformImage from '../../transformImage';
const processFixture = async (name: string, options?: PluginOptions) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
const siteDir = path.join(__dirname, `__fixtures__`);
const staticDirs = [
const siteDir = path.join(__dirname, `__fixtures__`);
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(transformImage, {...options, siteDir, staticDirs})
.use(plugin, {
...options,
staticDirs,
siteDir: path.join(__dirname, '__fixtures__'),
})
.process(file);
];
return result.value;
const getProcessor = async (options?: Partial<PluginOptions>) => {
const {remark} = await import('remark');
const {default: mdx} = await import('remark-mdx');
return remark()
.use(mdx)
.use(transformImage, {
siteDir,
staticDirs,
onBrokenMarkdownImages: 'throw',
})
.use(plugin, {
staticDirs,
siteDir,
onBrokenMarkdownLinks: 'throw',
...options,
});
};
describe('transformAsset plugin', () => {
it('fail if asset url is absent', async () => {
await expect(
processFixture('noUrl'),
).rejects.toThrowErrorMatchingSnapshot();
});
const processFixture = async (
name: string,
options?: Partial<PluginOptions>,
) => {
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 () => {
await expect(
processFixture('nonexistentSiteAlias'),
).rejects.toThrowErrorMatchingSnapshot();
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().trim();
};
describe('transformLinks plugin', () => {
it('transform md links to <a />', async () => {
// TODO split fixture in many smaller test cases
const result = await processFixture('asset');
expect(result).toMatchSnapshot();
});
it('pathname protocol', async () => {
const result = await processFixture('pathname');
expect(result).toMatchSnapshot();
const result = await processContent(`pathname:///unchecked.pdf)`);
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,
} from '@docusaurus/utils';
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 {MdxJsxTextElement} from 'mdast-util-mdx';
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[];
siteDir: string;
onBrokenMarkdownLinks: MarkdownConfig['hooks']['onBrokenMarkdownLinks'];
};
type Context = PluginOptions & {
staticDirs: string[];
siteDir: string;
onBrokenMarkdownLinks: OnBrokenMarkdownLinksFunction;
filePath: string;
inlineMarkdownLinkFileLoader: string;
};
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.
*/
@ -123,27 +171,15 @@ async function toAssetRequireNode(
});
}
async function ensureAssetFileExist(assetPath: string, sourceFilePath: string) {
const assetExists = await fs.pathExists(assetPath);
if (!assetExists) {
throw new Error(
`Asset ${toMessageRelativeFilePath(
assetPath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
);
}
}
async function getAssetAbsolutePath(
async function getLocalFileAbsolutePath(
assetPath: string,
{siteDir, filePath, staticDirs}: Context,
) {
if (assetPath.startsWith('@site/')) {
const assetFilePath = path.join(siteDir, assetPath.replace('@site/', ''));
// The @site alias is the only way to believe that the user wants an asset.
// Everything else can just be a link URL
await ensureAssetFileExist(assetFilePath, filePath);
if (await fs.pathExists(assetFilePath)) {
return assetFilePath;
}
} else if (path.isAbsolute(assetPath)) {
const assetFilePath = await findAsyncSequential(
staticDirs.map((dir) => path.join(dir, assetPath)),
@ -164,16 +200,13 @@ async function getAssetAbsolutePath(
async function processLinkNode(target: Target, context: Context) {
const [node] = target;
if (!node.url) {
// Try to improve error feedback
// see https://github.com/facebook/docusaurus/issues/3309#issuecomment-690371675
const title =
node.title ?? (node.children[0] as Literal | undefined)?.value ?? '?';
const line = node.position?.start.line ?? '?';
throw new Error(
`Markdown link URL is mandatory in "${toMessageRelativeFilePath(
context.filePath,
)}" file (title: ${title}, line: ${line}).`,
);
node.url =
context.onBrokenMarkdownLinks({
url: node.url,
sourceFilePath: context.filePath,
node,
}) ?? node.url;
return;
}
const parsedUrl = url.parse(node.url);
@ -189,29 +222,48 @@ async function processLinkNode(target: Target, context: Context) {
return;
}
const assetPath = await getAssetAbsolutePath(
const localFilePath = await getLocalFileAbsolutePath(
decodeURIComponent(parsedUrl.pathname),
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(
options,
): Transformer<Root> {
const onBrokenMarkdownLinks = asFunction(options.onBrokenMarkdownLinks);
return async (root, vfile) => {
const {visit} = await import('unist-util-visit');
const fileLoaderUtils = getFileLoaderUtils(
vfile.data.compilerName === 'server',
);
const context: Context = {
...options,
filePath: vfile.path!,
inlineMarkdownLinkFileLoader:
fileLoaderUtils.loaders.inlineMarkdownLinkFileLoader,
onBrokenMarkdownLinks,
};
const promises: Promise<void>[] = [];

View file

@ -8,7 +8,7 @@ import path from 'path';
import process from 'process';
import logger from '@docusaurus/logger';
import {posixPath} from '@docusaurus/utils';
import {transformNode} from '../utils';
import {formatNodePositionExtraMessage, transformNode} from '../utils';
import type {Root} from 'mdast';
import type {Parent} from 'unist';
import type {Transformer, Processor, Plugin} from 'unified';
@ -39,17 +39,9 @@ function formatDirectiveName(directive: Directives) {
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) {
const name = formatDirectiveName(directive);
const position = formatDirectivePosition(directive);
return `- ${name} ${position ? `(${position})` : ''}`;
return `- ${name}${formatNodePositionExtraMessage(directive)}`;
}
function formatUnusedDirectivesMessage({

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import logger from '@docusaurus/logger';
import type {Node} from 'unist';
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 = {
contentPath: path.resolve(siteDir, options.path),
@ -154,18 +154,12 @@ export default async function pluginContentBlog(
},
markdownConfig: siteConfig.markdown,
resolveMarkdownLink: ({linkPathname, sourceFilePath}) => {
const permalink = resolveMarkdownLinkPathname(linkPathname, {
return resolveMarkdownLinkPathname(linkPathname, {
sourceFilePath,
sourceToPermalink: contentHelpers.sourceToPermalink,
siteDir,
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 fs from 'fs-extra';
import logger from '@docusaurus/logger';
import {
normalizeUrl,
docuHash,
@ -158,18 +157,12 @@ export default async function pluginContentDocs(
sourceFilePath,
versionsMetadata,
);
const permalink = resolveMarkdownLinkPathname(linkPathname, {
return resolveMarkdownLinkPathname(linkPathname, {
sourceFilePath,
sourceToPermalink: contentHelpers.sourceToPermalink,
siteDir,
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": {
"@mdx-js/mdx": "^3.0.0",
"@types/history": "^4.7.11",
"@types/mdast": "^4.0.2",
"@types/react": "*",
"commander": "^5.1.0",
"joi": "^17.9.2",

View file

@ -10,12 +10,8 @@ import type {RuleSetRule} from 'webpack';
import type {DeepPartial, Overwrite} from 'utility-types';
import type {I18nConfig} from './i18n';
import type {PluginConfig, PresetConfig, HtmlTagObject} from './plugin';
import type {ProcessorOptions} from '@mdx-js/mdx';
export type RemarkRehypeOptions = ProcessorOptions['remarkRehypeOptions'];
export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw';
import type {ReportingSeverity} from './reporting';
import type {MarkdownConfig} from './markdown';
export type RouterType = 'browser' | 'hash';
@ -23,101 +19,6 @@ export type ThemeConfig = {
[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 = {
type: SiteStorage['type'];
namespace: boolean | string;
@ -258,7 +159,8 @@ export type DocusaurusConfig = {
* @see https://docusaurus.io/docs/api/docusaurus-config#onBrokenMarkdownLinks
* @default "warn"
*/
onBrokenMarkdownLinks: ReportingSeverity;
// TODO Docusaurus v4 remove
onBrokenMarkdownLinks: ReportingSeverity | undefined;
/**
* The behavior of Docusaurus when it detects any [duplicate
* routes](https://docusaurus.io/docs/creating-pages#duplicate-routes).

View file

@ -6,19 +6,27 @@
*/
export {
ReportingSeverity,
RouterType,
ThemeConfig,
MarkdownConfig,
DefaultParseFrontMatter,
ParseFrontMatter,
DocusaurusConfig,
FutureConfig,
FutureV4Config,
FasterConfig,
StorageConfig,
Config,
} from './config';
export {
MarkdownConfig,
MarkdownHooks,
DefaultParseFrontMatter,
ParseFrontMatter,
OnBrokenMarkdownLinksFunction,
OnBrokenMarkdownImagesFunction,
} from './markdown';
export {ReportingSeverity} from './reporting';
export {
SiteMetadata,
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,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -55,7 +59,6 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
@ -117,6 +120,10 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -130,7 +137,6 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
@ -192,6 +198,10 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -205,7 +215,6 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
@ -267,6 +276,10 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -280,7 +293,6 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
@ -342,6 +354,10 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -355,7 +371,6 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
@ -417,6 +432,10 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -430,7 +449,6 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
@ -492,6 +510,10 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -505,7 +527,6 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"organizationName": "endiliey",
"plugins": [],
@ -569,6 +590,10 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -582,7 +607,6 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"organizationName": "endiliey",
"plugins": [],
@ -646,6 +670,10 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -659,7 +687,6 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"organizationName": "endiliey",
"plugins": [],
@ -726,6 +753,10 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"maintainCase": false,
},
"format": "mdx",
"hooks": {
"onBrokenMarkdownImages": "throw",
"onBrokenMarkdownLinks": "warn",
},
"mdx1Compat": {
"admonitions": true,
"comments": true,
@ -739,7 +770,6 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"noIndex": false,
"onBrokenAnchors": "warn",
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"organizationName": "endiliey",
"plugins": [

View file

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

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {jest} from '@jest/globals';
import {
ConfigSchema,
DEFAULT_CONFIG,
@ -16,6 +17,10 @@ import {
DEFAULT_STORAGE_CONFIG,
validateConfig,
} from '../configValidation';
import type {
MarkdownConfig,
MarkdownHooks,
} from '@docusaurus/types/src/markdown';
import type {
FasterConfig,
FutureConfig,
@ -36,7 +41,7 @@ const normalizeConfig = (config: DeepPartial<Config>) =>
describe('normalizeConfig', () => {
it('normalizes empty config', () => {
const value = normalizeConfig({});
const value = normalizeConfig({markdown: {}});
expect(value).toEqual({
...DEFAULT_CONFIG,
...baseConfig,
@ -108,6 +113,10 @@ describe('normalizeConfig', () => {
remarkRehypeOptions: {
footnoteLabel: 'Pied de page',
},
hooks: {
onBrokenMarkdownLinks: 'log',
onBrokenMarkdownImages: 'log',
},
},
};
const normalizedConfig = normalizeConfig(userConfig);
@ -357,20 +366,15 @@ describe('onBrokenLinks', () => {
});
describe('markdown', () => {
function normalizeMarkdown(markdown: DeepPartial<MarkdownConfig>) {
return normalizeConfig({markdown}).markdown;
}
it('accepts undefined object', () => {
expect(
normalizeConfig({
markdown: undefined,
}),
).toEqual(expect.objectContaining({markdown: DEFAULT_CONFIG.markdown}));
expect(normalizeMarkdown(undefined)).toEqual(DEFAULT_CONFIG.markdown);
});
it('accepts empty object', () => {
expect(
normalizeConfig({
markdown: {},
}),
).toEqual(expect.objectContaining({markdown: DEFAULT_CONFIG.markdown}));
expect(normalizeMarkdown({})).toEqual(DEFAULT_CONFIG.markdown);
});
it('accepts valid markdown object', () => {
@ -393,12 +397,12 @@ describe('markdown', () => {
// @ts-expect-error: we don't validate it on purpose
anyKey: 'heck we accept it on purpose',
},
hooks: {
onBrokenMarkdownLinks: 'log',
onBrokenMarkdownImages: 'warn',
},
};
expect(
normalizeConfig({
markdown,
}),
).toEqual(expect.objectContaining({markdown}));
expect(normalizeMarkdown(markdown)).toEqual(markdown);
});
it('accepts partial markdown object', () => {
@ -408,22 +412,14 @@ describe('markdown', () => {
headingIds: false,
},
};
expect(
normalizeConfig({
markdown,
}),
).toEqual(
expect.objectContaining({
markdown: {
expect(normalizeMarkdown(markdown)).toEqual({
...DEFAULT_CONFIG.markdown,
...markdown,
mdx1Compat: {
...DEFAULT_CONFIG.markdown.mdx1Compat,
...markdown.mdx1Compat,
},
},
}),
);
});
});
it('throw for preprocessor bad arity', () => {
@ -436,10 +432,10 @@ describe('markdown', () => {
"
`);
expect(() =>
normalizeConfig({
normalizeMarkdown(
// @ts-expect-error: types forbid this
markdown: {preprocessor: (arg1, arg2) => String(arg1) + String(arg2)},
}),
{preprocessor: (arg1, arg2) => String(arg1) + String(arg2)},
),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.preprocessor" must have an arity of 1
"
@ -447,18 +443,13 @@ describe('markdown', () => {
});
it('accepts undefined markdown format', () => {
expect(
normalizeConfig({markdown: {format: undefined}}).markdown.format,
).toBe('mdx');
expect(normalizeMarkdown({format: undefined}).format).toBe('mdx');
});
it('throw for bad markdown format', () => {
expect(() =>
normalizeConfig({
markdown: {
// @ts-expect-error: bad value
normalizeMarkdown({
format: null,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.format" must be one of [mdx, md, detect]
@ -466,9 +457,9 @@ describe('markdown', () => {
"
`);
expect(() =>
normalizeConfig(
normalizeMarkdown(
// @ts-expect-error: bad value
{markdown: {format: 'xyz'}},
{format: 'xyz'},
),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.format" must be one of [mdx, md, detect]
@ -478,15 +469,165 @@ describe('markdown', () => {
it('throw for null object', () => {
expect(() => {
normalizeConfig({
// @ts-expect-error: bad value
markdown: null,
});
normalizeMarkdown(null);
}).toThrowErrorMatchingInlineSnapshot(`
""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', () => {
@ -846,7 +987,6 @@ describe('future', () => {
});
it('rejects router - null', () => {
// @ts-expect-error: bad value
const router: Config['future']['experimental_router'] = null;
expect(() =>
normalizeConfig({
@ -1055,7 +1195,6 @@ describe('future', () => {
});
it('rejects namespace - null', () => {
// @ts-expect-error: bad value
const storage: Partial<StorageConfig> = {namespace: null};
expect(() =>
normalizeConfig({

View file

@ -22,11 +22,10 @@ import type {
FutureConfig,
FutureV4Config,
StorageConfig,
} from '@docusaurus/types/src/config';
import type {
DocusaurusConfig,
I18nConfig,
MarkdownConfig,
MarkdownHooks,
} from '@docusaurus/types';
const DEFAULT_I18N_LOCALE = 'en';
@ -84,6 +83,11 @@ export const DEFAULT_FUTURE_CONFIG: FutureConfig = {
experimental_router: 'browser',
};
export const DEFAULT_MARKDOWN_HOOKS: MarkdownHooks = {
onBrokenMarkdownLinks: 'warn',
onBrokenMarkdownImages: 'throw',
};
export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
format: 'mdx', // TODO change this to "detect" in Docusaurus v4?
mermaid: false,
@ -98,6 +102,7 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
maintainCase: false,
},
remarkRehypeOptions: undefined,
hooks: DEFAULT_MARKDOWN_HOOKS,
};
export const DEFAULT_CONFIG: Pick<
@ -128,7 +133,7 @@ export const DEFAULT_CONFIG: Pick<
future: DEFAULT_FUTURE_CONFIG,
onBrokenLinks: 'throw',
onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw
onBrokenMarkdownLinks: 'warn',
onBrokenMarkdownLinks: undefined,
onDuplicateRoutes: 'warn',
plugins: [],
themes: [],
@ -350,7 +355,7 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
.default(DEFAULT_CONFIG.onBrokenAnchors),
onBrokenMarkdownLinks: Joi.string()
.equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_CONFIG.onBrokenMarkdownLinks),
.default(() => DEFAULT_CONFIG.onBrokenMarkdownLinks),
onDuplicateRoutes: Joi.string()
.equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_CONFIG.onDuplicateRoutes),
@ -455,6 +460,20 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
DEFAULT_CONFIG.markdown.anchors.maintainCase,
),
}).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),
}).messages({
'docusaurus.configValidationWarning':
@ -463,7 +482,16 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
// Expressing this kind of logic in Joi is a pain
// 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 (
config.future.experimental_faster.ssgWorkerThreads &&
!config.future.v4.removeLegacyPostBuildHeadAttribute
@ -528,7 +556,7 @@ export function validateConfig(
throw new Error(formattedError);
}
ensureDocusaurusConfigConsistency(value);
postProcessDocusaurusConfig(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}
:::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'`
The behavior of Docusaurus when it detects any broken Markdown link.
@ -511,6 +519,25 @@ type MarkdownAnchorsConfig = {
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 = {
format: 'mdx' | 'md' | 'detect';
mermaid: boolean;
@ -519,6 +546,7 @@ type MarkdownConfig = {
mdx1Compat: MDX1CompatOptions;
remarkRehypeOptions: object; // see https://github.com/remarkjs/remark-rehype#options
anchors: MarkdownAnchorsConfig;
hooks: MarkdownHooks;
};
```
@ -546,6 +574,10 @@ export default {
anchors: {
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+. |
| `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). |
| `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
</APITable>

View file

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

View file

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