diff --git a/packages/create-docusaurus/templates/classic-typescript/docusaurus.config.ts b/packages/create-docusaurus/templates/classic-typescript/docusaurus.config.ts index 95cb36ccd7..1ef4506097 100644 --- a/packages/create-docusaurus/templates/classic-typescript/docusaurus.config.ts +++ b/packages/create-docusaurus/templates/classic-typescript/docusaurus.config.ts @@ -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 diff --git a/packages/create-docusaurus/templates/classic/docusaurus.config.js b/packages/create-docusaurus/templates/classic/docusaurus.config.js index c1d553465e..124755d17f 100644 --- a/packages/create-docusaurus/templates/classic/docusaurus.config.js +++ b/packages/create-docusaurus/templates/classic/docusaurus.config.js @@ -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 diff --git a/packages/docusaurus-mdx-loader/src/processor.ts b/packages/docusaurus-mdx-loader/src/processor.ts index 30a2143c9d..29b0f8170a 100644 --- a/packages/docusaurus-mdx-loader/src/processor.ts +++ b/packages/docusaurus-mdx-loader/src/processor.ts @@ -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, diff --git a/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/index.test.ts index 00a76da679..903ac9bf12 100644 --- a/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/__tests__/index.test.ts @@ -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 = { - resolveMarkdownLink: ({linkPathname}) => `/RESOLVED---${linkPathname}`, +const DefaultTestOptions: PluginOptions = { + resolveMarkdownLink: ({linkPathname}) => `/RESOLVED---${linkPathname}`, + onBrokenMarkdownLinks: 'throw', +}; + +async function process(content: string, optionsInput?: Partial) { + 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", + }, + ], + ] + `); + }); + }); + }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/index.ts b/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/index.ts index 7903ac3b18..326c0d831d 100644 --- a/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/resolveMarkdownLinks/index.ts @@ -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 = function plugin( options, ): Transformer { 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 = 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; } }); }; diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail.md b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail.md deleted file mode 100644 index d23f36ad8d..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail.md +++ /dev/null @@ -1 +0,0 @@ -![img](/img/doesNotExist.png) diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail2.md b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail2.md deleted file mode 100644 index 1779d93e04..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/fail2.md +++ /dev/null @@ -1 +0,0 @@ -![img](./notFound.png) \ No newline at end of file diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/invalid-img.md b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/invalid-img.md deleted file mode 100644 index a41a28b708..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/invalid-img.md +++ /dev/null @@ -1 +0,0 @@ -![invalid image](/invalid.png) diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/noUrl.md b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/noUrl.md deleted file mode 100644 index 4763436bab..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/noUrl.md +++ /dev/null @@ -1 +0,0 @@ -![img]() diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/pathname.md b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/pathname.md deleted file mode 100644 index 73f37cbd51..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/pathname.md +++ /dev/null @@ -1 +0,0 @@ -![img](pathname:///img/unchecked.png) diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap index 8572403706..1620934872 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap @@ -1,16 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`transformImage plugin does not choke on invalid image 1`] = ` -"invalid image/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=/node_modules/file-loader/dist/cjs.js!./static/invalid.png").default} /> +"invalid image/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[contenthash].[ext]&fallback=/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) " diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts index f491fd7ac3..1b6b82c413 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts @@ -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, -) => { - 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) => { + 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, +) => { + 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, +) => { + 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 ', 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": "", + }, + ], + ] + `); + }); + }); + }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts index 8cc4af821d..1dc00aa894 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts @@ -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), originalImagePath); + if (!(await fs.pathExists(imageFilePath))) { + return null; } 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) { 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 = function plugin( options, ): Transformer { + const onBrokenMarkdownImages = asFunction(options.onBrokenMarkdownImages); + return async (root, vfile) => { const {visit} = await import('unist-util-visit'); @@ -201,6 +256,7 @@ const plugin: Plugin = function plugin( filePath: vfile.path!, inlineMarkdownImageFileLoader: fileLoaderUtils.loaders.inlineMarkdownImageFileLoader, + onBrokenMarkdownImages, }; const promises: Promise[] = []; diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/noUrl.md b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/noUrl.md deleted file mode 100644 index a35d39ef45..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/noUrl.md +++ /dev/null @@ -1 +0,0 @@ -[asset]() diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/nonexistentSiteAlias.md b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/nonexistentSiteAlias.md deleted file mode 100644 index 34247e1440..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/nonexistentSiteAlias.md +++ /dev/null @@ -1 +0,0 @@ -[nonexistent](@site/foo.pdf) diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/pathname.md b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/pathname.md deleted file mode 100644 index 6e20bcf3d3..0000000000 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/pathname.md +++ /dev/null @@ -1 +0,0 @@ -[asset](pathname:///asset/unchecked.pdf) diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap index a0186db7b1..9d18044775 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__snapshots__/index.test.ts.snap @@ -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 1`] = ` +exports[`transformLinks plugin transform md links to 1`] = ` "[asset](https://example.com/asset.pdf) /node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./asset.pdf").default} /> @@ -54,6 +45,5 @@ in paragraph /node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./data.json").default}>JSON -/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON -" +/node_modules/file-loader/dist/cjs.js?name=assets/files/[name]-[contenthash].[ext]!./static/static-json.json").default}>static JSON" `; diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/index.test.ts index 346c46e599..7dee411e40 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/index.test.ts @@ -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 siteDir = path.join(__dirname, `__fixtures__`); + +const staticDirs = [ + path.join(siteDir, 'static'), + path.join(siteDir, 'static2'), +]; + +const getProcessor = async (options?: Partial) => { const {remark} = await import('remark'); const {default: mdx} = await import('remark-mdx'); - 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() + return remark() .use(mdx) - .use(transformImage, {...options, siteDir, staticDirs}) - .use(plugin, { - ...options, + .use(transformImage, { + siteDir, staticDirs, - siteDir: path.join(__dirname, '__fixtures__'), + onBrokenMarkdownImages: 'throw', }) - .process(file); - - return result.value; + .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, +) => { + 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, +) => { + 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 ', 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", + }, + ], + ] + `); + }); + }); }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts b/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts index 625d838433..5d2d2d2d95 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts @@ -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 `` 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); - return assetFilePath; + 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 = function plugin( options, ): Transformer { + 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[] = []; diff --git a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts index 0e0afa14fb..29b458b19c 100644 --- a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts @@ -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({ diff --git a/packages/docusaurus-mdx-loader/src/remark/utils/index.ts b/packages/docusaurus-mdx-loader/src/remark/utils/index.ts index 898f0617a4..3b0c215ec9 100644 --- a/packages/docusaurus-mdx-loader/src/remark/utils/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/utils/index.ts @@ -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})` : ''}`; +} diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index e22f2e54f2..fe38ec471c 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -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; }, }); diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index a740b7f5e2..d20a88185a 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -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; }, }, }); diff --git a/packages/docusaurus-types/package.json b/packages/docusaurus-types/package.json index db8872ca42..6636f2592d 100644 --- a/packages/docusaurus-types/package.json +++ b/packages/docusaurus-types/package.json @@ -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", diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 684aa3992e..ec1c8aee7e 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -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; -export type ParseFrontMatter = ( - params: ParseFrontMatterParams & { - defaultParseFrontMatter: DefaultParseFrontMatter; - }, -) => Promise; - -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). diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index a6cb0b00b4..d7e61f569d 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -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, diff --git a/packages/docusaurus-types/src/markdown.d.ts b/packages/docusaurus-types/src/markdown.d.ts new file mode 100644 index 0000000000..fe5e1c3694 --- /dev/null +++ b/packages/docusaurus-types/src/markdown.d.ts @@ -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; +export type ParseFrontMatter = ( + params: ParseFrontMatterParams & { + defaultParseFrontMatter: DefaultParseFrontMatter; + }, +) => Promise; + +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; +}; diff --git a/packages/docusaurus-types/src/reporting.d.ts b/packages/docusaurus-types/src/reporting.d.ts new file mode 100644 index 0000000000..0b3ef3d426 --- /dev/null +++ b/packages/docusaurus-types/src/reporting.d.ts @@ -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'; diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index bc5e437767..29b20ea9e9 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -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": [ diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap index 3a85ce8cb2..696ef93444 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -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": [], diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index 861faa8cd5..7a56519fe1 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -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) => 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) { + 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: { - ...DEFAULT_CONFIG.markdown, - ...markdown, - mdx1Compat: { - ...DEFAULT_CONFIG.markdown.mdx1Compat, - ...markdown.mdx1Compat, - }, - }, - }), - ); + 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 - format: null, - }, + 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 { + 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 = {namespace: null}; expect(() => normalizeConfig({ diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 67fe593f22..1731068e08 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -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({ .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({ DEFAULT_CONFIG.markdown.anchors.maintainCase, ), }).default(DEFAULT_CONFIG.markdown.anchors), + hooks: Joi.object({ + 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({ // 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; } diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index e746377ba7..8cdbe3080e 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -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 diff --git a/website/docusaurus.config-blog-only.js b/website/docusaurus.config-blog-only.js index af7a53bd00..fb434ffa7b 100644 --- a/website/docusaurus.config-blog-only.js +++ b/website/docusaurus.config-blog-only.js @@ -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'], diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index daf01f83f4..47eddc851a 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -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,