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..8e9c3aa29dd9 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,36 @@ 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 ', + }, + ], + ), + ]), + ); } it('works for format CommonMark', async () => { await testHeadingIds('md'); @@ -434,6 +465,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..fab3e5e60876 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -61,6 +61,16 @@ 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; } @@ -85,23 +95,30 @@ 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(); + // When there's an id, locate the last text node containing the classic syntax + const targetNode = heading.children + .filter((node): node is Text => node.type === 'text') + .at(-1); + if (targetNode) { + if (heading.children.length > 1) { + const targetNodeText = parseMarkdownHeadingId( + targetNode.value, + 'classic', + ).text; + // When the last part contains text + id, remove the id + if (targetNodeText) { + targetNode.value = targetNodeText; + } + // When last part contains only the id: completely remove that node + else { + const index = heading.children.indexOf(targetNode); + if (index !== -1) { + heading.children.splice(index, 1); + } + } + } else { + targetNode.value = parsedHeading.text; } - } else { - lastNode.value = parsedHeading.text; } return parsedHeading.id; } @@ -114,7 +131,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); }