From a3aaa6faf03774008f7f0ee4cbc8e5ab5c1706fa Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:09:58 +0530 Subject: [PATCH 1/2] fix(mdx-loader): support explicit heading IDs when mdx1Compat.comments is enabled When markdown.mdx1Compat.comments is enabled, remarkComment stripped HTML comments during micromark parsing, leaving behind the trailing space on the heading text and preventing extractCommentId from reading the comment node. Configure remarkComment with {ast: true} so comment nodes are retained in the AST, and teach headings remark plugin to recognize comment nodes to extract the explicit heading ID and clean up trailing spaces. Fixes #12927 Signed-off-by: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> --- .../src/__tests__/processor.test.ts | 16 ++++++++++++++++ packages/docusaurus-mdx-loader/src/processor.ts | 4 +++- .../src/remark/headings/__tests__/index.test.ts | 12 ++++++++++++ .../src/remark/headings/index.ts | 12 +++++++++++- 4 files changed, 42 insertions(+), 2 deletions(-) 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..df3d24be8582 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'; @@ -434,6 +435,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..b1ffd00a9930 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; } @@ -114,7 +124,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); } From 04f1fc5f2dde45723cc6a86d6a34864bf023df5d Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:31:08 +0530 Subject: [PATCH 2/2] fix(mdx-loader): target text node when extracting classic heading id with comments present Signed-off-by: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> --- .../remark/headings/__tests__/index.test.ts | 30 ++++++++++++++ .../src/remark/headings/index.ts | 39 +++++++++++-------- 2 files changed, 53 insertions(+), 16 deletions(-) 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 df3d24be8582..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 @@ -358,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'); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index b1ffd00a9930..fab3e5e60876 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -95,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; }