diff --git a/RELEASE-NOTES-v4.md b/RELEASE-NOTES-v4.md new file mode 100644 index 000000000000..612f525a3c69 --- /dev/null +++ b/RELEASE-NOTES-v4.md @@ -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 `` 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. diff --git a/packages/docusaurus-mdx-loader/package.json b/packages/docusaurus-mdx-loader/package.json index d27294e06c81..e9e43e98f219 100644 --- a/packages/docusaurus-mdx-loader/package.json +++ b/packages/docusaurus-mdx-loader/package.json @@ -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", @@ -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", diff --git a/packages/docusaurus-mdx-loader/src/__tests__/__snapshots__/processor.test.ts.snap b/packages/docusaurus-mdx-loader/src/__tests__/__snapshots__/processor.test.ts.snap index 999ad810b99c..3cdeaec4d9c8 100644 --- a/packages/docusaurus-mdx-loader/src/__tests__/__snapshots__/processor.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/__tests__/__snapshots__/processor.test.ts.snap @@ -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"; @@ -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, { @@ -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."); +} " `; diff --git a/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts b/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts index e879e3a42cbe..de7ae23fc97e 100644 --- a/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts +++ b/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts @@ -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'; @@ -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( + '', + ); + }); + + 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** {1 + 1}]\nBody\n:::', + createOptions(), + ); + expect(result.content).toMatchSnapshot(); }); it.each([true, false])('supports emoji=%s', async (emoji) => { diff --git a/packages/docusaurus-mdx-loader/src/processor.ts b/packages/docusaurus-mdx-loader/src/processor.ts index b669796a647c..68a0dbd55a20 100644 --- a/packages/docusaurus-mdx-loader/src/processor.ts +++ b/packages/docusaurus-mdx-loader/src/processor.ts @@ -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'; @@ -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? diff --git a/packages/docusaurus-mdx-loader/src/rehype/admonitionTitle/index.ts b/packages/docusaurus-mdx-loader/src/rehype/admonitionTitle/index.ts new file mode 100644 index 000000000000..b1b8071b7500 --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/rehype/admonitionTitle/index.ts @@ -0,0 +1,45 @@ +/** + * 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 {visit} from 'unist-util-visit'; +import {toEstree} from 'hast-util-to-estree'; +import type {Root} from 'hast'; +import type {Plugin} from 'unified'; + +// Run after user rehype plugins so rich title content receives the same +// transformations as the body before it becomes a JSX attribute expression. +const plugin: Plugin<[], Root> = function plugin() { + return (root) => { + visit(root, 'mdxJsxFlowElement', (node) => { + if (node.name !== 'Admonition') { + return; + } + + const titleIndex = node.children.findIndex( + (child) => + child.type === 'mdxJsxTextElement' && + child.data?.admonitionTitle === true, + ); + if (titleIndex === -1) { + return; + } + + const [title] = node.children.splice(titleIndex, 1); + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'title', + value: { + type: 'mdxJsxAttributeValueExpression', + value: '', + data: {estree: toEstree(title!)}, + }, + }); + }); + }; +}; + +export default plugin; diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/README.md b/packages/docusaurus-mdx-loader/src/remark/admonitions/README.md index d28f68a1e78a..0b43798127f1 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/README.md +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/README.md @@ -1,3 +1,7 @@ # Docusaurus admonitions Code from [remark-admonitions](https://github.com/elviswolcott/remark-admonitions) (MIT license) has been copied to this folder, and highly customized for Docusaurus needs. + +Admonitions compile to `...`. The theme provides `Admonition` through `@theme/MDXComponents`. Custom MDX component mappings should use `Admonition` instead of the legacy `admonition` key. Markdown syntax and the `@theme/Admonition` props remain unchanged. + +Rich titles temporarily remain in the syntax tree as marked JSX fragments so that remark and rehype plugins can process their content. The final `rehype/admonitionTitle` plugin moves each fragment into the `title` prop, after user plugins have run. No title wrapper element is emitted at runtime. diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap index fc4f364d0e8e..0f5a752d6d36 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap @@ -1,64 +1,154 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`admonitions remark plugin > add custom keyword 1`] = ` -"

The blog feature enables you to deploy in no time a full-featured blog.

-

Check the Blog Plugin API Reference documentation for an exhaustive list of options.

-

Initial setup {#initial-setup}

-

To set up your site's blog, start by creating a blog directory.

-

Use the Fast Track to understand Docusaurus in 5 minutes ⏱!

Use docusaurus.new to test Docusaurus immediately in your browser!

-

++++tip

-

Admonition with different syntax

-

++++

" +"The blog feature enables you to deploy in no time a full-featured blog. + + + Check the [Blog Plugin API Reference documentation](./api/plugins/plugin-content-blog.md) for an exhaustive list of options. + + +## Initial setup /{#initial-setup} + +To set up your site's blog, start by creating a \`blog\` directory. + + + Use the **[Fast Track](introduction.md#fast-track)** to understand Docusaurus in **5 minutes ⏱**! + + Use **[docusaurus.new](https://docusaurus.new)** to test Docusaurus immediately in your browser! + + +++++tip + +Admonition with different syntax + +++++ +" `; exports[`admonitions remark plugin > attributes 1`] = ` -"

Admonitions with attributes

-

An info admonition with a className attribute.

-

An info admonition with multiple className attributes.

-

An info admonition with a custom id attribute.

-

An info admonition with both id and className attributes.

-

Arbitrary attributes are ignored.

" +"Admonitions with attributes + + + An info admonition with a className attribute. + + + + An info admonition with multiple className attributes. + + + + An info admonition with a custom id attribute. + + + + An info admonition with both id and className attributes. + + + + Arbitrary attributes are ignored. + +" `; exports[`admonitions remark plugin > base 1`] = ` -"

The blog feature enables you to deploy in no time a full-featured blog.

-

Check the Blog Plugin API Reference documentation for an exhaustive list of options.

-

Initial setup {#initial-setup}

-

To set up your site's blog, start by creating a blog directory.

-

Use the Fast Track to understand Docusaurus in 5 minutes ⏱!

Use docusaurus.new to test Docusaurus immediately in your browser!

-

++++tip

-

Admonition with different syntax

-

++++

" +"The blog feature enables you to deploy in no time a full-featured blog. + + + Check the [Blog Plugin API Reference documentation](./api/plugins/plugin-content-blog.md) for an exhaustive list of options. + + +## Initial setup /{#initial-setup} + +To set up your site's blog, start by creating a \`blog\` directory. + + + Use the **[Fast Track](introduction.md#fast-track)** to understand Docusaurus in **5 minutes ⏱**! + + Use **[docusaurus.new](https://docusaurus.new)** to test Docusaurus immediately in your browser! + + +++++tip + +Admonition with different syntax + +++++ +" `; exports[`admonitions remark plugin > default behavior for custom keyword 1`] = ` -"

The blog feature enables you to deploy in no time a full-featured blog.

-

Sample Title

Check the Blog Plugin API Reference documentation for an exhaustive list of options.

-

Initial setup {#initial-setup}

-

To set up your site's blog, start by creating a blog directory.

-

Use the Fast Track to understand Docusaurus in 5 minutes ⏱!

Use docusaurus.new to test Docusaurus immediately in your browser!

-

++++tip

-

Admonition with different syntax

-

++++

" +"The blog feature enables you to deploy in no time a full-featured blog. + +:::info[Sample Title] +Check the [Blog Plugin API Reference documentation](./api/plugins/plugin-content-blog.md) for an exhaustive list of options. +::: + +## Initial setup /{#initial-setup} + +To set up your site's blog, start by creating a \`blog\` directory. + + + Use the **[Fast Track](introduction.md#fast-track)** to understand Docusaurus in **5 minutes ⏱**! + + Use **[docusaurus.new](https://docusaurus.new)** to test Docusaurus immediately in your browser! + + +++++tip + +Admonition with different syntax + +++++ +" `; exports[`admonitions remark plugin > interpolation 1`] = ` -"

Test admonition with interpolated title/body

-My interpolated title <button style={{color: "red"}} onClick={() => alert("click")}>test

body interpolated content

" +"Test admonition with interpolated title/body + + + <>My \`interpolated\` **title** + + \`body\` **interpolated** + +" `; exports[`admonitions remark plugin > nesting 1`] = ` -"

Test nested Admonitions

-Weather

On nice days, you can enjoy skiing in the mountains.

Storms

Take care of snowstorms...

" +"Test nested Admonitions + + + <>**Weather** + + On nice days, you can enjoy skiing in the mountains. + + + <>*Storms* + + Take care of snowstorms... + + +" `; exports[`admonitions remark plugin > replace custom keyword 1`] = ` -"

The blog feature enables you to deploy in no time a full-featured blog.

-

Sample Title

Check the Blog Plugin API Reference documentation for an exhaustive list of options.

-

Initial setup {#initial-setup}

-

To set up your site's blog, start by creating a blog directory.

-

Use the Fast Track to understand Docusaurus in 5 minutes ⏱!

Use docusaurus.new to test Docusaurus immediately in your browser!

-

++++tip

-

Admonition with different syntax

-

++++

" +"The blog feature enables you to deploy in no time a full-featured blog. + +:::info[Sample Title] +Check the [Blog Plugin API Reference documentation](./api/plugins/plugin-content-blog.md) for an exhaustive list of options. +::: + +## Initial setup /{#initial-setup} + +To set up your site's blog, start by creating a \`blog\` directory. + + + Use the **[Fast Track](introduction.md#fast-track)** to understand Docusaurus in **5 minutes ⏱**! + + Use **[docusaurus.new](https://docusaurus.new)** to test Docusaurus immediately in your browser! + + +++++tip + +Admonition with different syntax + +++++ +" `; diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts index 363e1b526390..4d359d014a79 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts @@ -9,9 +9,9 @@ import {describe, expect, it} from 'vitest'; import path from 'path'; import {remark} from 'remark'; import directives from 'remark-directive'; -import remark2rehype from 'remark-rehype'; -import stringify from 'rehype-stringify'; +import mdx from 'remark-mdx'; import {read} from 'to-vfile'; +import {visit} from 'unist-util-visit'; import preprocessor from '../../../preprocessor'; import plugin, {DefaultAdmonitionOptions} from '../index'; import type {AdmonitionOptions} from '../index'; @@ -31,34 +31,41 @@ const processFixture = async ( mdx1Compat: { admonitions: true, comments: false, - headingIds: false, + headingIds: true, }, }, }); - /* - // TODO we shouldn't use rehype in these tests - // this requires to re-implement admonitions with mdxJsxFlowElement - const {default: mdx} = await import('remark-mdx'); - const result = await remark() - .use(directives) - .use(plugin) - .use(mdx) - .process(fileContentPreprocessed); - return result.value; - */ - const result = await remark() .use(directives) .use(plugin, options) - .use(remark2rehype) - .use(stringify) + .use(mdx) .process(fileContentPreprocessed); return result.value; }; describe('admonitions remark plugin', () => { + it('exposes standard JSX nodes to subsequent remark plugins', async () => { + const processor = remark().use(directives).use(plugin).use(mdx); + const tree = await processor.run( + processor.parse(':::note[**Title**]\nBody\n:::'), + ); + const nodeTypes: string[] = []; + visit(tree, (node) => { + nodeTypes.push(node.type); + }); + expect(nodeTypes).toEqual([ + 'root', + 'mdxJsxFlowElement', + 'mdxJsxTextElement', + 'strong', + 'text', + 'paragraph', + 'text', + ]); + }); + it('base', async () => { const result = await processFixture('base'); await expect(result).toMatchSnapshot(); diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts b/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts index 7a0d62826cc9..f55e04cafb52 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts @@ -5,9 +5,11 @@ * LICENSE file in the root directory of this source tree. */ import {visit} from 'unist-util-visit'; +import {transformNode} from '../utils'; import type {Transformer, Plugin} from 'unified'; import type {ContainerDirective} from 'mdast-util-directive'; -import type {Parent, Root} from 'mdast'; +import type {Paragraph, Root} from 'mdast'; +import type {MdxJsxFlowElement, MdxJsxTextElement} from 'mdast-util-mdx'; export type AdmonitionOptions = { keywords: string[]; @@ -50,7 +52,7 @@ export function normalizeAdmonitionOptions( return options; } -type DirectiveLabel = Parent; +type DirectiveLabel = Paragraph; type DirectiveContent = ContainerDirective['children']; function parseDirective(directive: ContainerDirective): { @@ -68,12 +70,9 @@ function parseDirective(directive: ContainerDirective): { } function getTextOnlyTitle(directiveLabel: DirectiveLabel): string | undefined { - const isTextOnlyTitle = - directiveLabel?.children?.length === 1 && - directiveLabel?.children?.[0]?.type === 'text'; - return isTextOnlyTitle - ? // @ts-expect-error: todo type - (directiveLabel?.children?.[0].value as string) + const [child] = directiveLabel.children; + return directiveLabel.children.length === 1 && child?.type === 'text' + ? child.value : undefined; } @@ -98,38 +97,41 @@ const plugin: Plugin[], Root> = function plugin( node.attributes?.title ?? (directiveLabel ? getTextOnlyTitle(directiveLabel) : undefined); - // Transform the mdast directive node to a hast admonition node - // See https://github.com/syntax-tree/mdast-util-to-hast#fields-on-nodes - // TODO in MDX v2 we should transform the whole directive to - // mdxJsxFlowElement instead of using hast - node.data = { - hName: 'admonition', - hProperties: { - ...(textOnlyTitle && {title: textOnlyTitle}), - ...(node.attributes?.class && { - className: node.attributes.class.split(' '), - }), - ...(node.attributes?.id && {id: node.attributes.id}), - type: node.name, - }, + const properties = { + ...(textOnlyTitle && {title: textOnlyTitle}), + ...(node.attributes?.class && {className: node.attributes.class}), + ...(node.attributes?.id && {id: node.attributes.id}), + type: node.name, }; - node.children = contentNodes; - - // TODO legacy MDX v1 workaround - // v1: not possible to inject complex JSX elements as props - // v2: now possible: use a mdxJsxFlowElement element - if (directiveLabel && !textOnlyTitle) { - const complexTitleNode = { - type: 'mdxAdmonitionTitle', - data: { - hName: 'mdxAdmonitionTitle', - hProperties: {}, - }, + const admonition: MdxJsxFlowElement = { + type: 'mdxJsxFlowElement', + name: 'Admonition', + attributes: Object.entries(properties).map(([name, value]) => ({ + type: 'mdxJsxAttribute', + name, + value, + })), + children: contentNodes, + position: node.position, + }; + + // Keep rich titles in the tree for subsequent remark/rehype plugins. + // The final rehype plugin moves this fragment into the title prop. + if (directiveLabel?.children.length && !textOnlyTitle) { + const complexTitleNode: MdxJsxTextElement = { + type: 'mdxJsxTextElement', + name: null, + attributes: [], + data: {admonitionTitle: true}, children: directiveLabel.children, + position: directiveLabel.position, }; - // @ts-expect-error: invented node type - node.children.unshift(complexTitleNode); + // MDX supports inline JSX here without an extra paragraph wrapper. + // @ts-expect-error: JSX flow children are typed as block content only + admonition.children.unshift(complexTitleNode); } + + transformNode(node, admonition); } }); }; diff --git a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/__snapshots__/index.test.ts.snap index 9d8cd750bfe0..a43a2d63903d 100644 --- a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/__snapshots__/index.test.ts.snap @@ -11,12 +11,22 @@ Your content might render in an unexpected way. Visit https://github.com/faceboo `; exports[`directives remark plugin - client compiler > default behavior for container directives > result 1`] = ` -"

Take care of snowstorms...

-

unused directive content

-

:::NotAContainerDirective with a phrase after

-

:::

-

Phrase before :::NotAContainerDirective

-

:::

" +" + Take care of snowstorms... + + +:::unusedDirective +unused directive content +::: + +/:::NotAContainerDirective with a phrase after + +/::: + +Phrase before :::NotAContainerDirective + +/::: +" `; exports[`directives remark plugin - client compiler > default behavior for leaf directives > console 1`] = ` @@ -30,9 +40,12 @@ Your content might render in an unexpected way. Visit https://github.com/faceboo `; exports[`directives remark plugin - client compiler > default behavior for leaf directives > result 1`] = ` -"
-

Leaf directive in a phrase ::NotALeafDirective

-

::NotALeafDirective with a phrase after

" +"::unusedLeafDirective + +Leaf directive in a phrase ::NotALeafDirective + +/::NotALeafDirective with a phrase after +" `; exports[`directives remark plugin - client compiler > default behavior for text directives > console 1`] = ` @@ -47,94 +60,168 @@ Your content might render in an unexpected way. Visit https://github.com/faceboo `; exports[`directives remark plugin - client compiler > default behavior for text directives > result 1`] = ` -"

Simple: textDirective1

-
Simple: textDirectiveCode
-
-

Simple:textDirective2

-

Simple

label

-

Simple

-

Simple:textDirective5

-
Simple:textDirectiveCode
-
" +"Simple: textDirective1 + +\`\`\`sh +Simple: textDirectiveCode +\`\`\` + +Simple/:textDirective2 + +Simple:textDirective3[label] + +Simple:textDirective4{age="42"} + +Simple/:textDirective5 + +\`\`\`sh +Simple:textDirectiveCode +\`\`\` +" `; exports[`directives remark plugin - client compiler > onUnusedMarkdownDirectives > function form > if file contains unused container directive > result 1`] = ` -"

Take care of snowstorms...

-
-

:::NotAContainerDirective with a phrase after

-

:::

-

Phrase before :::NotAContainerDirective

-

:::

" +" + Take care of snowstorms... + + +:::fixed-unusedDirective +::: + +/:::NotAContainerDirective with a phrase after + +/::: + +Phrase before :::NotAContainerDirective + +/::: +" `; exports[`directives remark plugin - client compiler > onUnusedMarkdownDirectives > function form > if file contains unused leaf directive > result 1`] = ` -"
-

Leaf directive in a phrase ::NotALeafDirective

-

::NotALeafDirective with a phrase after

" +"::fixed-unusedLeafDirective + +Leaf directive in a phrase ::NotALeafDirective + +/::NotALeafDirective with a phrase after +" `; exports[`directives remark plugin - client compiler > onUnusedMarkdownDirectives > function form > if file contains unused text directive > result 1`] = ` -"

Simple: textDirective1

-
Simple: textDirectiveCode
-
-

Simple:textDirective2

-

Simple

-

Simple

-

Simple:textDirective5

-
Simple:textDirectiveCode
-
" +"Simple: textDirective1 + +\`\`\`sh +Simple: textDirectiveCode +\`\`\` + +Simple/:textDirective2 + +Simple:fixed-textDirective3 + +Simple:fixed-textDirective4{age="42"} + +Simple/:textDirective5 + +\`\`\`sh +Simple:textDirectiveCode +\`\`\` +" `; exports[`directives remark plugin - client compiler > onUnusedMarkdownDirectives > ignore > if file contains unused container directive > result 1`] = ` -"

Take care of snowstorms...

-

unused directive content

-

:::NotAContainerDirective with a phrase after

-

:::

-

Phrase before :::NotAContainerDirective

-

:::

" +" + Take care of snowstorms... + + +:::unusedDirective +unused directive content +::: + +/:::NotAContainerDirective with a phrase after + +/::: + +Phrase before :::NotAContainerDirective + +/::: +" `; exports[`directives remark plugin - client compiler > onUnusedMarkdownDirectives > ignore > if file contains unused leaf directive > result 1`] = ` -"
-

Leaf directive in a phrase ::NotALeafDirective

-

::NotALeafDirective with a phrase after

" +"::unusedLeafDirective + +Leaf directive in a phrase ::NotALeafDirective + +/::NotALeafDirective with a phrase after +" `; exports[`directives remark plugin - client compiler > onUnusedMarkdownDirectives > ignore > if file contains unused text directive > result 1`] = ` -"

Simple: textDirective1

-
Simple: textDirectiveCode
-
-

Simple:textDirective2

-

Simple

label

-

Simple

-

Simple:textDirective5

-
Simple:textDirectiveCode
-
" +"Simple: textDirective1 + +\`\`\`sh +Simple: textDirectiveCode +\`\`\` + +Simple/:textDirective2 + +Simple:textDirective3[label] + +Simple:textDirective4{age="42"} + +Simple/:textDirective5 + +\`\`\`sh +Simple:textDirectiveCode +\`\`\` +" `; exports[`directives remark plugin - server compiler > default behavior for container directives > result 1`] = ` -"

Take care of snowstorms...

-

unused directive content

-

:::NotAContainerDirective with a phrase after

-

:::

-

Phrase before :::NotAContainerDirective

-

:::

" +" + Take care of snowstorms... + + +:::unusedDirective +unused directive content +::: + +/:::NotAContainerDirective with a phrase after + +/::: + +Phrase before :::NotAContainerDirective + +/::: +" `; exports[`directives remark plugin - server compiler > default behavior for leaf directives > result 1`] = ` -"
-

Leaf directive in a phrase ::NotALeafDirective

-

::NotALeafDirective with a phrase after

" +"::unusedLeafDirective + +Leaf directive in a phrase ::NotALeafDirective + +/::NotALeafDirective with a phrase after +" `; exports[`directives remark plugin - server compiler > default behavior for text directives > result 1`] = ` -"

Simple: textDirective1

-
Simple: textDirectiveCode
-
-

Simple:textDirective2

-

Simple

label

-

Simple

-

Simple:textDirective5

-
Simple:textDirectiveCode
-
" +"Simple: textDirective1 + +\`\`\`sh +Simple: textDirectiveCode +\`\`\` + +Simple/:textDirective2 + +Simple:textDirective3[label] + +Simple:textDirective4{age="42"} + +Simple/:textDirective5 + +\`\`\`sh +Simple:textDirectiveCode +\`\`\` +" `; diff --git a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/index.test.ts index 5b79a23a5018..3a1da3537b99 100644 --- a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/__tests__/index.test.ts @@ -9,8 +9,7 @@ import {describe, expect, it, vi} from 'vitest'; import path from 'path'; import {remark} from 'remark'; import directives from 'remark-directive'; -import remark2rehype from 'remark-rehype'; -import stringify from 'rehype-stringify'; +import mdx from 'remark-mdx'; import {read} from 'to-vfile'; import plugin, {type PluginOptions} from '../index'; import admonition from '../../admonitions'; @@ -24,8 +23,7 @@ const getProcessor = (options?: Partial) => { onUnusedMarkdownDirectives: 'warn', ...options, }) - .use(remark2rehype) - .use(stringify); + .use(mdx); }; const processFixture = async ( diff --git a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts index be0820be7650..4296287f053f 100644 --- a/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/unusedDirectives/index.ts @@ -126,7 +126,7 @@ function transformSimpleTextDirectiveToString(textDirective: Directives) { } function isUnusedDirective(directive: Directives) { - // If directive data is set (notably hName/hProperties set by admonitions) + // If directive data is set (notably hName/hProperties) // this usually means the directive has been handled by another plugin return !directive.data; } @@ -144,7 +144,7 @@ const plugin: Plugin = function plugin( // @ts-expect-error: TODO fix type visit(tree, directiveTypes, (directive: Directives) => { - // If directive data is set (hName/hProperties set by admonitions) + // If directive data is set (hName/hProperties) // this usually means the directive has been handled by another plugin if (isUnusedDirective(directive)) { if (isSimpleTextDirective(directive)) { diff --git a/packages/docusaurus-mdx-loader/src/types.d.mts b/packages/docusaurus-mdx-loader/src/types.d.mts index 012c32ec73ef..118c121f2123 100644 --- a/packages/docusaurus-mdx-loader/src/types.d.mts +++ b/packages/docusaurus-mdx-loader/src/types.d.mts @@ -22,6 +22,7 @@ declare module 'vfile' { declare module 'unist' { interface Data { + admonitionTitle?: boolean; hName?: string; hProperties?: Record; } diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index d1d80a5289a3..617c4c7d67f3 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -1203,6 +1203,8 @@ declare module '@theme/MDXComponents' { readonly h4: (props: ComponentProps<'h4'>) => ReactNode; readonly h5: (props: ComponentProps<'h5'>) => ReactNode; readonly h6: (props: ComponentProps<'h6'>) => ReactNode; + readonly Admonition: typeof Admonition; + /** @deprecated Use Admonition instead. */ readonly admonition: typeof Admonition; readonly mermaid: typeof Mermaid; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/docusaurus-theme-classic/src/theme/Admonition/__tests__/index.test.tsx b/packages/docusaurus-theme-classic/src/theme/Admonition/__tests__/index.test.tsx new file mode 100644 index 000000000000..3653d12ed1e3 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Admonition/__tests__/index.test.tsx @@ -0,0 +1,54 @@ +/** + * 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 {describe, expect, it} from 'vitest'; +import React from 'react'; +import {renderToStaticMarkup} from 'react-dom/server'; +import Admonition from '../index'; +import MDXComponents from '../../MDXComponents'; + +describe('Admonition', () => { + it('registers the component under its emitted MDX name', () => { + expect(MDXComponents.Admonition).toBe(Admonition); + }); + + it('renders a rich title separately from the body', () => { + const result = renderToStaticMarkup( + Custom title} + icon="💡" + id="custom-id" + className="custom-class"> +

Body

+
, + ); + expect(result).toContain('Custom title'); + expect(result).toContain('

Body

'); + expect(result).toContain('💡'); + expect(result).toContain('id="custom-id"'); + expect(result).toContain('custom-class'); + }); + + it('keeps the default title when no title prop is provided', () => { + const result = renderToStaticMarkup( + +

Body

+
, + ); + expect(result).toContain('note'); + }); + + it('does not render a content container for title-only admonitions', () => { + const result = renderToStaticMarkup( + Title only}> + {null} + , + ); + expect(result).toMatch(/Title only<\/strong><\/div><\/div>$/); + }); +}); diff --git a/packages/docusaurus-theme-classic/src/theme/Admonition/index.tsx b/packages/docusaurus-theme-classic/src/theme/Admonition/index.tsx index a306721ed0a7..e5944bfa0ded 100644 --- a/packages/docusaurus-theme-classic/src/theme/Admonition/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Admonition/index.tsx @@ -6,7 +6,6 @@ */ import React, {type ComponentType, type ReactNode} from 'react'; -import {processAdmonitionProps} from '@docusaurus/theme-common'; import type {Props} from '@theme/Admonition'; import AdmonitionTypes from '@theme/Admonition/Types'; @@ -21,8 +20,7 @@ function getAdmonitionTypeComponent(type: string): ComponentType { return AdmonitionTypes.info!; } -export default function Admonition(unprocessedProps: Props): ReactNode { - const props = processAdmonitionProps(unprocessedProps); +export default function Admonition(props: Props): ReactNode { const AdmonitionTypeComponent = getAdmonitionTypeComponent(props.type); // eslint-disable-next-line react-hooks/static-components return ; diff --git a/packages/docusaurus-theme-classic/src/theme/MDXComponents/index.tsx b/packages/docusaurus-theme-classic/src/theme/MDXComponents/index.tsx index 49d438bc5f68..a028cf77fcdc 100644 --- a/packages/docusaurus-theme-classic/src/theme/MDXComponents/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/MDXComponents/index.tsx @@ -36,7 +36,8 @@ const MDXComponents: MDXComponentsObject = { h4: (props: ComponentProps<'h4'>) => , h5: (props: ComponentProps<'h5'>) => , h6: (props: ComponentProps<'h6'>) => , - admonition: Admonition, + Admonition, + admonition: Admonition, // Legacy MDX component name mermaid: Mermaid, }; diff --git a/packages/docusaurus-theme-common/src/utils/admonitionUtils.tsx b/packages/docusaurus-theme-common/src/utils/admonitionUtils.tsx index 38022009e33f..644c0eff4244 100644 --- a/packages/docusaurus-theme-common/src/utils/admonitionUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/admonitionUtils.tsx @@ -28,6 +28,10 @@ function extractMDXAdmonitionTitle(children: ReactNode): { }; } +/** + * @deprecated Rich admonition titles are now passed through the title prop. + * Kept for compatibility with previously swizzled Admonition components. + */ export function processAdmonitionProps< Props extends {readonly children: ReactNode; readonly title?: ReactNode}, >(props: Props): Props { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 612f91a38860..65c520d37450 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -841,6 +841,9 @@ importers: fs-extra: specifier: ^11.4.0 version: 11.4.0 + hast-util-to-estree: + specifier: ^3.1.3 + version: 3.1.3(supports-color@10.2.2) image-size: specifier: ^2.0.4 version: 2.0.4 @@ -902,6 +905,9 @@ importers: '@types/estree': specifier: ^1.0.9 version: 1.0.9 + '@types/hast': + specifier: ^3.0.5 + version: 3.0.5 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 diff --git a/vitest.config.ts b/vitest.config.ts index d0aca6592d66..efa2c8ae0371 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -55,7 +55,7 @@ const resolveAliases: AliasOptions = [ // This won't always be true, but good enough for now { find: /^@theme\/(.*)$/, - replacement: '@docusaurus/theme-classic/src/theme/$1', + replacement: `${rootDir}packages/docusaurus-theme-classic/src/theme/$1`, }, // Resolve `@site/*` aliases against our own website diff --git a/website/_dogfooding/_docs tests/tests/admonitions.mdx b/website/_dogfooding/_docs tests/tests/admonitions.mdx index 4a80c46b2734..14f625b4acfd 100644 --- a/website/_dogfooding/_docs tests/tests/admonitions.mdx +++ b/website/_dogfooding/_docs tests/tests/admonitions.mdx @@ -14,6 +14,40 @@ ::: +## Rich titles + +:::tip[**Rich** title with `code`, a [link](/docs/markdown-features/admonitions), and :smile:] + +The title formatting should match the formatting in the body: **bold**, `code`, a [link](/docs/markdown-features/admonitions), and :smile:. + +::: + +:::info[JSX title: red and {1 + 1}] + +JSX and expressions also work in the title. + +::: + +:::note[**Title only**] + +::: + +:::note[] + +An empty label keeps the default title. + +::: + +::::info[**Outer title**] + +:::warning[*Nested title*] + +Nested admonitions keep their own titles. + +::: + +:::: + ## Large font icon import Admonition from '@theme/Admonition';