diff --git a/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts b/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts index 46286ad41502..8ff5b26f4477 100644 --- a/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts +++ b/packages/docusaurus-mdx-loader/src/__tests__/processor.test.ts @@ -290,6 +290,22 @@ Body expect(result.content).toMatchSnapshot(); }); + it('extracts explicit heading ID from HTML comments when mdx1Compat.comments is enabled', async () => { + const mdResult = await processContent( + '## Hello World ', + createOptions({markdownConfig: {format: 'md'}}), + ); + expect(mdResult.content).toContain('id: "my-explicit-id"'); + expect(mdResult.content).toContain('children: "Hello World"'); + + const mdxResult = await processContent( + '## Hello World ', + createOptions({markdownConfig: {format: 'mdx'}}), + ); + expect(mdxResult.content).toContain('id: "my-explicit-id"'); + expect(mdxResult.content).toContain('children: "Hello World"'); + }); + it('preserves legacy heading IDs', async () => { const options = createOptions(); const result = await processContent('## Heading {#custom-id}', options); diff --git a/packages/docusaurus-mdx-loader/src/processor.ts b/packages/docusaurus-mdx-loader/src/processor.ts index aa1497600367..f7257d4105cd 100644 --- a/packages/docusaurus-mdx-loader/src/processor.ts +++ b/packages/docusaurus-mdx-loader/src/processor.ts @@ -169,7 +169,9 @@ function createProcessorUncached({ } satisfies TransformLinksOptions, ], gfm, - options.markdownConfig.mdx1Compat.comments ? remarkComment : null, + options.markdownConfig.mdx1Compat.comments + ? [remarkComment, {ast: true}] + : null, ...(options.remarkPlugins ?? []), [ unusedDirectives, diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts index 883d40021c3b..0bc4c1d8913f 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts @@ -10,6 +10,7 @@ import {describe, expect, it} from 'vitest'; import {remark} from 'remark'; import mdx from 'remark-mdx'; +import remarkComment from '@slorber/remark-comment'; import {u} from 'unist-builder'; import {visit} from 'unist-util-visit'; import {escapeMarkdownHeadingIds} from '@docusaurus/utils'; @@ -357,6 +358,149 @@ describe('headings remark plugin', () => { await expect( headingIdFor('# {#text-after} custom ID', format), ).resolves.toEqual('text-after-custom-id'); + + await expect( + process( + '## Heading {#legacy}', + [[remarkComment, {ast: true}]], + {}, + format, + ), + ).resolves.toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: { + hProperties: {id: 'legacy'}, + id: 'legacy', + }, + }, + [ + u('text', 'Heading'), + { + type: 'comment', + value: '', + commentValue: ' note ', + }, + ], + ), + ]), + ); + + await expect( + process( + '## Heading *with emphasis {#nested-legacy}*', + [], + {}, + format, + ), + ).resolves.toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: { + hProperties: {id: 'nested-legacy'}, + id: 'nested-legacy', + }, + }, + [ + u('text', 'Heading '), + u('emphasis', [u('text', 'with emphasis')]), + ], + ), + ]), + ); + + await expect( + process( + '## Heading {#custom-id}', + [ + function splitTextPlugin() { + return (root) => { + const heading = (root as Parent).children[0] as Heading; + heading.children = [ + u('text', 'Heading '), + u('text', '{#custom'), + u('text', '-id}'), + ]; + }; + }, + ], + {}, + format, + ), + ).resolves.toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: { + hProperties: {id: 'custom-id'}, + id: 'custom-id', + }, + }, + [u('text', 'Heading')], + ), + ]), + ); + + await expect( + process('## Title `code` {#custom-id}', [], {}, format), + ).resolves.toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: { + hProperties: {id: 'custom-id'}, + id: 'custom-id', + }, + }, + [u('text', 'Title '), u('inlineCode', 'code')], + ), + ]), + ); + + await expect( + process( + '## Title {#`custom`}', + [ + function inlineCodeIdPlugin() { + return (root) => { + const heading = (root as Parent).children[0] as Heading; + heading.children = [ + u('text', 'Title '), + u('text', '{#'), + u('inlineCode', 'custom'), + u('text', '}'), + ]; + }; + }, + ], + {}, + format, + ), + ).resolves.toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: { + hProperties: {id: 'custom'}, + id: 'custom', + }, + }, + [u('text', 'Title')], + ), + ]), + ); } it('works for format CommonMark', async () => { await testHeadingIds('md'); @@ -434,6 +578,17 @@ describe('headings remark plugin', () => { expect(heading).toEqual(h(null, 2, 'id-only')); }); + it('supports HTML comment syntax when remark-comment is enabled', async () => { + const result = await process( + '## Heading ', + [[remarkComment, {ast: true}]], + {}, + 'md', + ); + const heading = result.children[0] as Heading; + expect(heading).toEqual(h('Heading', 2, 'my-id')); + }); + it('does NOT support MDX comment syntax {/* #id */} in CommonMark', async () => { // In CommonMark (no remark-mdx), {/* #id */} is regular text const id = await headingIdFor('# Heading {/* #my-id */}', 'md'); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 531a1a599b65..e9f97d43c5e3 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -61,6 +61,15 @@ function getCommentHeadingId(heading: Heading): string | undefined { } } + // HTML comment parsed by @slorber/remark-comment when mdx1Compat.comments is enabled + if ((lastChild?.type as string) === 'comment') { + const commentContent = (lastChild as unknown as {commentValue?: string}) + .commentValue; + if (commentContent) { + return getCommentContentHeadingId(commentContent); + } + } + return undefined; } @@ -80,28 +89,66 @@ function extractCommentId(heading: Heading) { return undefined; } +// Recursively finds all text-bearing leaf nodes contributing to visible heading text +function findAllTextNodes(parent: {children?: unknown[]}): { + node: {value: string}; + parent: {children: unknown[]}; + index: number; +}[] { + const result: { + node: {value: string}; + parent: {children: unknown[]}; + index: number; + }[] = []; + + function walk(node: {children?: unknown[]}) { + if (!node.children || !Array.isArray(node.children)) { + return; + } + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] as { + type: string; + value?: string; + children?: unknown[]; + }; + if (['html', 'jsx', 'comment'].includes(child.type)) { + continue; + } + if (typeof child.value === 'string') { + result.push({ + node: child as {value: string}, + parent: node as {children: unknown[]}, + index: i, + }); + } else if (child.children) { + walk(child); + } + } + } + + walk(parent); + return result; +} + // Try to find an explicit id in the heading text (classic {#id} syntax) function extractClassicSyntaxHeadingId(heading: Heading, headingText: string) { const parsedHeading = parseMarkdownHeadingId(headingText, 'classic'); // Remove the heading text from its id (legacy syntax) if (parsedHeading.id) { - // When there's an id, it is always in the last child node - const lastNode = heading.children.at(-1) as Text; - if (heading.children.length > 1) { - const lastNodeText = parseMarkdownHeadingId( - lastNode.value, - 'classic', - ).text; - // When the last part contains text + id, remove the id - if (lastNodeText) { - lastNode.value = lastNodeText; - } - // When last part contains only the id: completely remove that node - else { - heading.children.pop(); + const suffixLength = headingText.length - parsedHeading.text.length; + if (suffixLength > 0) { + const textNodes = findAllTextNodes(heading); + let remaining = suffixLength; + for (let i = textNodes.length - 1; i >= 0 && remaining > 0; i--) { + const {node, parent, index} = textNodes[i]!; + if (node.value.length <= remaining) { + remaining -= node.value.length; + parent.children.splice(index, 1); + } else { + node.value = node.value.slice(0, node.value.length - remaining); + remaining = 0; + } } - } else { - lastNode.value = parsedHeading.text; } return parsedHeading.id; } @@ -114,7 +161,7 @@ const plugin: Plugin = function plugin({ return (root) => { function getHeadingText(heading: Heading) { const headingTextNodes = heading.children.filter( - ({type}) => !['html', 'jsx'].includes(type), + ({type}) => !['html', 'jsx', 'comment'].includes(type), ); return toString(headingTextNodes.length > 0 ? headingTextNodes : heading); }