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/5] 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/5] 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; } From 727ce08845de7a4fc34a1c1c901f8a2e349127a0 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:35:35 +0530 Subject: [PATCH 3/5] fix(mdx-loader): handle nested text nodes and trim whitespace for classic heading ids Signed-off-by: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> --- .../remark/headings/__tests__/index.test.ts | 26 +++++++ .../src/remark/headings/index.ts | 74 ++++++++++++------- 2 files changed, 75 insertions(+), 25 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 8e9c3aa29dd9..e270a37e427a 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 @@ -388,6 +388,32 @@ describe('headings remark plugin', () => { ), ]), ); + + 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')]), + ], + ), + ]), + ); } 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 fab3e5e60876..4216e23167d6 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -63,9 +63,8 @@ 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; + const commentContent = (lastChild as unknown as {commentValue?: string}) + .commentValue; if (commentContent) { return getCommentContentHeadingId(commentContent); } @@ -90,34 +89,59 @@ function extractCommentId(heading: Heading) { return undefined; } +// Recursively finds the last text node contributing to visible heading text +function findLastTextNode(parent: { + children?: unknown[]; +}): {node: Text; parent: {children: unknown[]}; index: number} | undefined { + if (!parent.children || parent.children.length === 0) { + return undefined; + } + for (let i = parent.children.length - 1; i >= 0; i--) { + const child = parent.children[i] as {type: string; children?: unknown[]}; + if (child.type === 'text') { + return { + node: child as unknown as Text, + parent: parent as {children: unknown[]}, + index: i, + }; + } + if (!['html', 'jsx', 'comment'].includes(child.type) && child.children) { + const nested = findLastTextNode(child); + if (nested) { + return nested; + } + } + } + return undefined; +} + // 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, 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); - } + // Locate the text node containing the classic syntax (even if nested or followed by comments) + const targetInfo = findLastTextNode(heading); + if (targetInfo) { + const {node: targetNode, parent: targetParent, index} = targetInfo; + const targetNodeText = parseMarkdownHeadingId( + targetNode.value, + 'classic', + ).text; + // When the node contains text + id, remove the id + if (targetNodeText) { + targetNode.value = targetNodeText; + } + // When the node contains only the id: completely remove that node + else { + targetParent.children.splice(index, 1); + // Trim trailing whitespace from preceding text node if present + const prevNode = targetParent.children[index - 1] as + | {type?: string; value?: string} + | undefined; + if (prevNode?.type === 'text' && typeof prevNode.value === 'string') { + prevNode.value = prevNode.value.trimEnd(); } - } else { - targetNode.value = parsedHeading.text; } } return parsedHeading.id; From 0558cf4ad565a7ca1e6b3ad21096e2e72dbac0de Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:17:37 +0530 Subject: [PATCH 4/5] fix(mdx-loader): remove classic id suffix across multiple contributing text nodes Signed-off-by: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> --- .../remark/headings/__tests__/index.test.ts | 34 ++++++++ .../src/remark/headings/index.ts | 84 ++++++++++--------- 2 files changed, 77 insertions(+), 41 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 e270a37e427a..c5d1d658ce4c 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 @@ -414,6 +414,40 @@ describe('headings remark plugin', () => { ), ]), ); + + 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')], + ), + ]), + ); } 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 4216e23167d6..254c4dd87f16 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -89,30 +89,41 @@ function extractCommentId(heading: Heading) { return undefined; } -// Recursively finds the last text node contributing to visible heading text -function findLastTextNode(parent: { - children?: unknown[]; -}): {node: Text; parent: {children: unknown[]}; index: number} | undefined { - if (!parent.children || parent.children.length === 0) { - return undefined; - } - for (let i = parent.children.length - 1; i >= 0; i--) { - const child = parent.children[i] as {type: string; children?: unknown[]}; - if (child.type === 'text') { - return { - node: child as unknown as Text, - parent: parent as {children: unknown[]}, - index: i, - }; +// Recursively finds all text nodes contributing to visible heading text +function findAllTextNodes(parent: {children?: unknown[]}): { + node: Text; + parent: {children: unknown[]}; + index: number; +}[] { + const result: { + node: Text; + parent: {children: unknown[]}; + index: number; + }[] = []; + + function walk(node: {children?: unknown[]}) { + if (!node.children || !Array.isArray(node.children)) { + return; } - if (!['html', 'jsx', 'comment'].includes(child.type) && child.children) { - const nested = findLastTextNode(child); - if (nested) { - return nested; + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] as {type: string; children?: unknown[]}; + if (child.type === 'text') { + result.push({ + node: child as unknown as Text, + parent: node as {children: unknown[]}, + index: i, + }); + } else if ( + !['html', 'jsx', 'comment'].includes(child.type) && + child.children + ) { + walk(child); } } } - return undefined; + + walk(parent); + return result; } // Try to find an explicit id in the heading text (classic {#id} syntax) @@ -120,27 +131,18 @@ function extractClassicSyntaxHeadingId(heading: Heading, headingText: string) { const parsedHeading = parseMarkdownHeadingId(headingText, 'classic'); // Remove the heading text from its id (legacy syntax) if (parsedHeading.id) { - // Locate the text node containing the classic syntax (even if nested or followed by comments) - const targetInfo = findLastTextNode(heading); - if (targetInfo) { - const {node: targetNode, parent: targetParent, index} = targetInfo; - const targetNodeText = parseMarkdownHeadingId( - targetNode.value, - 'classic', - ).text; - // When the node contains text + id, remove the id - if (targetNodeText) { - targetNode.value = targetNodeText; - } - // When the node contains only the id: completely remove that node - else { - targetParent.children.splice(index, 1); - // Trim trailing whitespace from preceding text node if present - const prevNode = targetParent.children[index - 1] as - | {type?: string; value?: string} - | undefined; - if (prevNode?.type === 'text' && typeof prevNode.value === 'string') { - prevNode.value = prevNode.value.trimEnd(); + 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; } } } From c9875429746f259cdae0f0edb77e5d094e1ce361 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:50:28 +0530 Subject: [PATCH 5/5] fix(mdx-loader): include all text-bearing leaf nodes when stripping heading id suffix Signed-off-by: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> --- .../remark/headings/__tests__/index.test.ts | 53 +++++++++++++++++++ .../src/remark/headings/index.ts | 24 +++++---- 2 files changed, 67 insertions(+), 10 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 c5d1d658ce4c..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 @@ -448,6 +448,59 @@ describe('headings remark plugin', () => { ), ]), ); + + 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'); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 254c4dd87f16..e9f97d43c5e3 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -89,14 +89,14 @@ function extractCommentId(heading: Heading) { return undefined; } -// Recursively finds all text nodes contributing to visible heading text +// Recursively finds all text-bearing leaf nodes contributing to visible heading text function findAllTextNodes(parent: {children?: unknown[]}): { - node: Text; + node: {value: string}; parent: {children: unknown[]}; index: number; }[] { const result: { - node: Text; + node: {value: string}; parent: {children: unknown[]}; index: number; }[] = []; @@ -106,17 +106,21 @@ function findAllTextNodes(parent: {children?: unknown[]}): { return; } for (let i = 0; i < node.children.length; i++) { - const child = node.children[i] as {type: string; children?: unknown[]}; - if (child.type === 'text') { + 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 unknown as Text, + node: child as {value: string}, parent: node as {children: unknown[]}, index: i, }); - } else if ( - !['html', 'jsx', 'comment'].includes(child.type) && - child.children - ) { + } else if (child.children) { walk(child); } }