Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions RELEASE-NOTES-v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Docusaurus 4.0 release notes (unreleased)

These notes describe upcoming changes. They are kept separate from the dated changelog entries published on the website.

## Breaking Changes

- Admonitions now compile to `<Admonition title={...} type="...">` using standard MDX JSX nodes. Custom `@theme/MDXComponents` mappings must provide the `Admonition` key instead of `admonition`. Plugins inspecting admonition AST nodes must handle MDX JSX nodes instead of container directives or `mdxAdmonitionTitle` nodes. The documented Markdown syntax and `@theme/Admonition` props are unchanged; rich titles are now passed directly through `title`. The legacy `processAdmonitionProps` helper remains available for swizzled components.
2 changes: 2 additions & 0 deletions packages/docusaurus-mdx-loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"estree-util-value-to-estree": "^3.5.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.4.0",
"hast-util-to-estree": "^3.1.3",
"image-size": "^2.0.4",
"mdast-util-mdx": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
Expand All @@ -47,6 +48,7 @@
"@docusaurus/types": "workspace:*",
"@types/escape-html": "^1.0.4",
"@types/estree": "^1.0.9",
"@types/hast": "^3.0.5",
"@types/mdast": "^4.0.4",
"@types/unist": "^3.0.3",
"lodash": "^4.18.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,66 @@ export default function MDXContent(props = {}) {
"
`;

exports[`MDX processor > md format > preserves nested admonitions and rich titles 1`] = `
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import {useMDXComponents as _provideComponents} from "@mdx-js/react";
export const toc = [];
function _createMdxContent(props) {
const _components = {
a: "a",
code: "code",
em: "em",
p: "p",
strong: "strong",
..._provideComponents(),
...props.components
}, {Admonition} = _components;
if (!Admonition) _missingMdxReference("Admonition", true);
return _jsx(Admonition, {
className: "one two",
id: "outer",
type: "note",
title: _jsxs(_Fragment, {
children: [_jsx(_components.strong, {
children: "Outer"
}), " ", _jsx(_components.code, {
children: "title"
}), " 😄"]
}),
children: _jsx(Admonition, {
type: "tip",
title: _jsxs(_Fragment, {
children: [_jsx(_components.em, {
children: "Inner"
}), " ", _jsx(_components.a, {
href: "/resolved-link",
children: "link"
})]
}),
children: _jsx(_components.p, {
children: "Body"
})
})
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = {
..._provideComponents(),
...props.components
};
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
function _missingMdxReference(id, component) {
throw new Error("Expected " + (component ? "component" : "object") + " \`" + id + "\` to be defined: you likely forgot to import, pass, or provide it.");
}
"
`;

exports[`MDX processor > mdx format > compiles GFM tables 1`] = `
"import {jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import {useMDXComponents as _provideComponents} from "@mdx-js/react";
Expand Down Expand Up @@ -168,18 +228,125 @@ export default function MDXContent(props = {}) {
"
`;

exports[`MDX processor > mdx format > preserves nested admonitions and rich titles 1`] = `
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import {useMDXComponents as _provideComponents} from "@mdx-js/react";
export const toc = [];
function _createMdxContent(props) {
const _components = {
a: "a",
code: "code",
em: "em",
p: "p",
strong: "strong",
..._provideComponents(),
...props.components
}, {Admonition} = _components;
if (!Admonition) _missingMdxReference("Admonition", true);
return _jsx(Admonition, {
className: "one two",
id: "outer",
type: "note",
title: _jsxs(_Fragment, {
children: [_jsx(_components.strong, {
children: "Outer"
}), " ", _jsx(_components.code, {
children: "title"
}), " ", "😄"]
}),
children: _jsx(Admonition, {
type: "tip",
title: _jsxs(_Fragment, {
children: [_jsx(_components.em, {
children: "Inner"
}), " ", _jsx(_components.a, {
href: "/resolved-link",
children: "link"
})]
}),
children: _jsx(_components.p, {
children: "Body"
})
})
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = {
..._provideComponents(),
...props.components
};
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
function _missingMdxReference(id, component) {
throw new Error("Expected " + (component ? "component" : "object") + " \`" + id + "\` to be defined: you likely forgot to import, pass, or provide it.");
}
"
`;

exports[`MDX processor > preserves JSX and expressions in admonition titles 1`] = `
"import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import {useMDXComponents as _provideComponents} from "@mdx-js/react";
export const toc = [];
function _createMdxContent(props) {
const _components = {
p: "p",
strong: "strong",
..._provideComponents(),
...props.components
}, {Admonition} = _components;
if (!Admonition) _missingMdxReference("Admonition", true);
return _jsx(Admonition, {
type: "tip",
title: _jsxs(_Fragment, {
children: [_jsx(_components.strong, {
children: "Title"
}), " ", _jsx("span", {
style: {
color: "red"
},
children: 1 + 1
})]
}),
children: _jsx(_components.p, {
children: "Body"
})
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = {
..._provideComponents(),
...props.components
};
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
function _missingMdxReference(id, component) {
throw new Error("Expected " + (component ? "component" : "object") + " \`" + id + "\` to be defined: you likely forgot to import, pass, or provide it.");
}
"
`;

exports[`MDX processor > preserves legacy admonition titles 1`] = `
"import {jsx as _jsx} from "react/jsx-runtime";
import {useMDXComponents as _provideComponents} from "@mdx-js/react";
export const toc = [];
function _createMdxContent(props) {
const _components = {
admonition: "admonition",
p: "p",
..._provideComponents(),
...props.components
};
return _jsx(_components.admonition, {
}, {Admonition} = _components;
if (!Admonition) _missingMdxReference("Admonition", true);
return _jsx(Admonition, {
title: "Custom title",
type: "note",
children: _jsx(_components.p, {
Expand All @@ -199,6 +366,9 @@ export default function MDXContent(props = {}) {
})
}) : _createMdxContent(props);
}
function _missingMdxReference(id, component) {
throw new Error("Expected " + (component ? "component" : "object") + " \`" + id + "\` to be defined: you likely forgot to import, pass, or provide it.");
}
"
`;

Expand Down
120 changes: 120 additions & 0 deletions packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
*/

import {describe, expect, it} from 'vitest';
import React, {type ReactNode} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import _ from 'lodash';
import * as runtime from 'react/jsx-runtime';
import {run} from '@mdx-js/mdx';
import {visit} from 'unist-util-visit';
import {DEFAULT_PARSE_FRONT_MATTER} from '@docusaurus/utils';
import {compileToJSX} from '../utils';
import type {Options} from '../options';
import type {Root} from 'mdast';
import type {Root as HastRoot} from 'hast';
import type {Plugin} from 'unified';
import type {DeepPartial} from 'utility-types';

Expand Down Expand Up @@ -95,6 +101,120 @@ describe('MDX processor', () => {
'Second document',
]);
});

it('preserves nested admonitions and rich titles', async () => {
const options = createOptions({
markdownConfig: {format},
resolveMarkdownLink: () => '/resolved-link',
});
const result = await processContent(
`::::note[**Outer** \`title\` :smile:]{#outer .one .two}

:::tip[*Inner* [link](./document.md)]
Body
:::

::::`,
options,
);
expect(result.content).toMatchSnapshot();
});

it('gives the title attribute precedence over a rich label', async () => {
const result = await processContent(
':::note[**Ignored**]{title="Explicit title"}\nBody\n:::',
createOptions({markdownConfig: {format}}),
);
expect(result.content).toContain('title: "Explicit title"');
expect(result.content).not.toContain('Ignored');
expect(result.content).not.toContain('mdxAdmonitionTitle');
});

it('keeps title-only admonitions empty', async () => {
const result = await processContent(
':::note Title only\n:::',
createOptions({markdownConfig: {format}}),
);
expect(result.content).toContain('title: "Title only"');
expect(result.content).not.toContain('children: _jsx(_components.p');
});

it('keeps the default title for an empty label', async () => {
const result = await processContent(
':::note[]\nBody\n:::',
createOptions({markdownConfig: {format}}),
);
expect(result.content).not.toContain('title:');
expect(result.content).toContain('children: "Body"');
});

it('passes transformed rich titles to the provided Admonition component', async () => {
const remarkPlugin: Plugin<[], Root> = () => (tree) => {
visit(tree, 'text', (node) => {
node.value = node.value.replace('Title', 'Remark title');
});
};
const rehypePlugin: Plugin<[], HastRoot> = () => (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName === 'strong') {
node.properties.title = 'Rehype title';
}
});
};
const options = {
...createOptions({
markdownConfig: {format},
remarkPlugins: [remarkPlugin],
rehypePlugins: [rehypePlugin],
}),
outputFormat: 'function-body' as const,
};
const result = await processContent(
':::note[**Title** :smile:]\nBody\n:::',
options,
);
const {default: Content} = await run(result.content, {
...runtime,
useMDXComponents: () => ({
Admonition: ({
title,
children,
}: {
title?: ReactNode;
children?: ReactNode;
}) =>
React.createElement(
'aside',
null,
React.createElement('header', null, title),
children,
),
strong: 'b',
}),
});
expect(renderToStaticMarkup(React.createElement(Content))).toBe(
'<aside><header><b title="Rehype title">Remark title</b> 😄</header><p>Body</p></aside>',
);
});

it('transforms local images in rich titles', async () => {
const result = await processContent(
':::note[![Title](../remark/transformImage/__tests__/__fixtures__/static/img.png)]\nBody\n:::',
createOptions({markdownConfig: {format}}),
);
expect(result.content).toContain('title: _jsx(_Fragment');
expect(result.content).toContain('require(');
expect(result.content).toContain('alt: "Title"');
expect(result.content).not.toContain('mdxAdmonitionTitle');
});
});

it('preserves JSX and expressions in admonition titles', async () => {
const result = await processContent(
':::tip[**Title** <span style={{color: "red"}}>{1 + 1}</span>]\nBody\n:::',
createOptions(),
);
expect(result.content).toMatchSnapshot();
});

it.each([true, false])('supports emoji=%s', async (emoji) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/docusaurus-mdx-loader/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import details from './remark/details';
import head from './remark/head';
import mermaid from './remark/mermaid';
import transformAdmonitions from './remark/admonitions';
import admonitionTitle from './rehype/admonitionTitle';
import unusedDirectives from './remark/unusedDirectives';
import codeCompatPlugin from './remark/mdx1Compat/codeCompatPlugin';
import {getFormat} from './format';
Expand Down Expand Up @@ -160,6 +161,8 @@ export function createProcessorUncached({
const rehypePlugins: MDXPlugin[] = [
...(options.beforeDefaultRehypePlugins ?? []),
...(options.rehypePlugins ?? []),
// Rich titles must stay in the tree until user plugins have run.
admonitionTitle,
];

// Maybe we'll want to introduce default recma plugins later?
Expand Down
Loading
Loading