mirror of
https://github.com/facebook/docusaurus.git
synced 2025-07-14 07:18:02 +02:00
feat(core): Add siteConfig.markdown.hooks
, deprecate siteConfig.onBrokenMarkdownLinks
(#11283)
This commit is contained in:
parent
ef71ddf937
commit
96c38d5fdd
35 changed files with 1580 additions and 381 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1 +0,0 @@
|
|||

|
|
@ -1 +0,0 @@
|
|||

|
|
@ -1 +0,0 @@
|
|||

|
|
@ -1 +0,0 @@
|
|||
![img]()
|
|
@ -1 +0,0 @@
|
|||

|
|
@ -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`] = `
|
||||
"
|
||||
"
|
||||
|
|
|
@ -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(
|
||||
``,
|
||||
);
|
||||
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(``);
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(errorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('onBrokenMarkdownImages', () => {
|
||||
const fixtures = {
|
||||
doesNotExistAbsolute: ``,
|
||||
doesNotExistRelative: ``,
|
||||
doesNotExistSiteAlias: ``,
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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(`
|
||||
"
|
||||
"
|
||||
`);
|
||||
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": "",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>[] = [];
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
[asset]()
|
|
@ -1 +0,0 @@
|
|||
[nonexistent](@site/foo.pdf)
|
|
@ -1 +0,0 @@
|
|||
[asset](pathname:///asset/unchecked.pdf)
|
|
@ -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>"
|
||||
`;
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>[] = [];
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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})` : ''}`;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
106
packages/docusaurus-types/src/config.d.ts
vendored
106
packages/docusaurus-types/src/config.d.ts
vendored
|
@ -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).
|
||||
|
|
16
packages/docusaurus-types/src/index.d.ts
vendored
16
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -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,
|
||||
|
|
165
packages/docusaurus-types/src/markdown.d.ts
vendored
Normal file
165
packages/docusaurus-types/src/markdown.d.ts
vendored
Normal 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;
|
||||
};
|
8
packages/docusaurus-types/src/reporting.d.ts
vendored
Normal file
8
packages/docusaurus-types/src/reporting.d.ts
vendored
Normal 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';
|
|
@ -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": [
|
||||
|
|
|
@ -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": [],
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue