From d8b5725f46c0c5ad014aa59bbe77170a92c8e4b5 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 15:41:23 +0100 Subject: [PATCH 01/14] refactor tests --- .../src/remark/headings/__tests__/index.test.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 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 929058980f75..c1c2cfdca6ce 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 @@ -21,7 +21,7 @@ import type {Root} from 'mdast'; async function process( input: string, plugins: Plugin[] = [], - options: PluginOptions = {anchorsMaintainCase: false}, + options: Partial = {anchorsMaintainCase: false}, format: 'md' | 'mdx' = 'mdx', ): Promise { const {remark} = await import('remark'); @@ -278,14 +278,9 @@ describe('headings remark plugin', () => { expect(result).toEqual(expected); }); - describe('creates custom headings ids', () => { + describe('headings ids', () => { async function headingIdFor(input: string, format: 'md' | 'mdx' = 'mdx') { - const result = await process( - input, - [], - {anchorsMaintainCase: false}, - format, - ); + const result = await process(input, [], {}, format); const headers: {text: string; id: string}[] = []; visit(result, 'heading', (node) => { headers.push({ From 1a9704d5619f4e17fcca7db35246eb03d6744e14 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 15:43:10 +0100 Subject: [PATCH 02/14] add claude todos --- .../src/remark/headings/__tests__/index.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 c1c2cfdca6ce..4452a1c4096c 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 @@ -342,6 +342,16 @@ describe('headings remark plugin', () => { await testHeadingIds('mdx'); }); }); + + describe('comment syntax', () => { + it('works for format CommonMark', async () => { + // TODO claude implement comment syntax support for md + }); + + it('works for format MDX', async () => { + // TODO claude implement comment syntax support for mdx + }); + }); }); it('preserve anchors case then "anchorsMaintainCase" option is set', async () => { From f611e5a0215624390ec7a5ae016bdf2f4678dbd0 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 17:04:22 +0100 Subject: [PATCH 03/14] stable impl --- .eslintrc.js | 1 + .../remark/headings/__tests__/index.test.ts | 150 +++++++++++++++++- .../src/remark/headings/index.ts | 84 ++++++---- 3 files changed, 204 insertions(+), 31 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 41859a9031f7..eaf0a094f643 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -213,6 +213,7 @@ module.exports = { {allowTaggedTemplates: true, allowShortCircuit: true}, ], 'no-useless-escape': WARNING, + 'no-useless-return': WARNING, 'no-void': [ERROR, {allowAsStatement: true}], 'prefer-destructuring': OFF, 'prefer-named-capture-group': WARNING, 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 4452a1c4096c..8f749ce83e3a 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 @@ -344,12 +344,154 @@ describe('headings remark plugin', () => { }); describe('comment syntax', () => { - it('works for format CommonMark', async () => { - // TODO claude implement comment syntax support for md + describe('works for format CommonMark', () => { + it('extracts id from HTML comment at end of heading', async () => { + await expect( + headingIdFor('# Heading One ', 'md'), + ).resolves.toEqual('custom_h1'); + + await expect( + headingIdFor('## Heading Two ', 'md'), + ).resolves.toEqual('custom-heading-two'); + + await expect( + headingIdFor('# Snake-cased ', 'md'), + ).resolves.toEqual('this_is_custom_id'); + }); + + it('extracts id when comment is the only heading content', async () => { + await expect( + headingIdFor('# ', 'md'), + ).resolves.toEqual('id-only'); + }); + + it('extracts id when heading has inline markup before comment', async () => { + await expect( + headingIdFor('# With *Bold* ', 'md'), + ).resolves.toEqual('custom-with-bold'); + }); + + it('does NOT extract id when HTML comment is not the last node', async () => { + await expect( + headingIdFor('# some text', 'md'), + ).resolves.not.toEqual('custom-id'); + }); + + it('removes the comment node from heading AST', async () => { + const result = await process( + '## Heading ', + [], + {}, + 'md', + ); + expect(result).toEqual( + u('root', [ + u( + 'heading', + {depth: 2, data: {id: 'my-id', hProperties: {id: 'my-id'}}}, + [u('text', 'Heading')], + ), + ]), + ); + }); + + it('removes the comment node when it is the only heading content', async () => { + const result = await process('## ', [], {}, 'md'); + expect(result).toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: {id: 'id-only', hProperties: {id: 'id-only'}}, + }, + [], + ), + ]), + ); + }); + + it('does NOT support MDX comment syntax {/* id */} in CommonMark', async () => { + // In CommonMark (no remark-mdx), {/* id */} is plain text + // so the id falls back to the slug of the raw text content + const id = await headingIdFor('# Heading {/* my-id */}', 'md'); + expect(id).not.toEqual('my-id'); + }); }); - it('works for format MDX', async () => { - // TODO claude implement comment syntax support for mdx + describe('works for format MDX', () => { + it('extracts id from MDX comment at end of heading', async () => { + await expect( + headingIdFor('# Heading One {/* custom_h1 */}', 'mdx'), + ).resolves.toEqual('custom_h1'); + + await expect( + headingIdFor('## Heading Two {/* custom-heading-two */}', 'mdx'), + ).resolves.toEqual('custom-heading-two'); + + await expect( + headingIdFor('# Snake-cased {/* this_is_custom_id */}', 'mdx'), + ).resolves.toEqual('this_is_custom_id'); + }); + + it('extracts id when comment is the only heading content', async () => { + await expect( + headingIdFor('# {/* id-only */}', 'mdx'), + ).resolves.toEqual('id-only'); + }); + + it('extracts id when heading has inline markup before comment', async () => { + await expect( + headingIdFor('# With *Bold* {/* custom-with-bold */}', 'mdx'), + ).resolves.toEqual('custom-with-bold'); + }); + + it('does NOT extract id when MDX comment is not the last node', async () => { + await expect( + headingIdFor('# {/* custom-id */} some text', 'mdx'), + ).resolves.not.toEqual('custom-id'); + }); + + it('removes the comment node from heading AST', async () => { + const result = await process( + '## Heading {/* my-id */}', + [], + {}, + 'mdx', + ); + expect(result).toEqual( + u('root', [ + u( + 'heading', + {depth: 2, data: {id: 'my-id', hProperties: {id: 'my-id'}}}, + [u('text', 'Heading')], + ), + ]), + ); + }); + + it('removes the comment node when it is the only heading content', async () => { + const result = await process('## {/* id-only */}', [], {}, 'mdx'); + expect(result).toEqual( + u('root', [ + u( + 'heading', + { + depth: 2, + data: {id: 'id-only', hProperties: {id: 'id-only'}}, + }, + [], + ), + ]), + ); + }); + + it('does NOT support HTML comment syntax in MDX', async () => { + // MDX throws a parse error for HTML comments inside headings + await expect( + process('## Heading ', [], {}, 'mdx'), + ).rejects.toThrow(); + }); }); }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 64d1b8123371..a4b9534a5713 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -9,12 +9,20 @@ import {parseMarkdownHeadingId, createSlugger} from '@docusaurus/utils'; import type {Plugin, Transformer} from 'unified'; -import type {Root, Text} from 'mdast'; +import type {Heading, Root, Text} from 'mdast'; export interface PluginOptions { anchorsMaintainCase: boolean; } +function getCommentHeadingId(heading: Heading): string | undefined { + const lastChild = heading.children.at(-1); + + console.log('Last child of heading:', lastChild); + + return undefined; +} + const plugin: Plugin = function plugin({ anchorsMaintainCase, }): Transformer { @@ -22,43 +30,61 @@ const plugin: Plugin = function plugin({ const {toString} = await import('mdast-util-to-string'); const {visit} = await import('unist-util-visit'); + function getHeadingText(heading: Heading) { + const headingTextNodes = heading.children.filter( + ({type}) => !['html', 'jsx'].includes(type), + ); + return toString(headingTextNodes.length > 0 ? headingTextNodes : heading); + } + const slugs = createSlugger(); visit(root, 'heading', (headingNode) => { const data = headingNode.data ?? (headingNode.data = {}); const properties = (data.hProperties || (data.hProperties = {})) as { id: string; }; - let {id} = properties; - if (id) { - id = slugs.slug(id, {maintainCase: true}); - } else { - const headingTextNodes = headingNode.children.filter( - ({type}) => !['html', 'jsx'].includes(type), - ); - const heading = toString( - headingTextNodes.length > 0 ? headingTextNodes : headingNode, - ); + function setId(newId: string) { + data.id = newId; + properties.id = newId; + } - // Support explicit heading IDs - const parsedHeading = parseMarkdownHeadingId(heading); + // properties.id already set? Not sure when this happens, historical code + if (properties.id) { + const id = slugs.slug(properties.id, {maintainCase: true}); + setId(id); + + } + // No id set + else { + // Try to find an explicit id in MD/MDX comments + const commentId = getCommentHeadingId(headingNode); + if (commentId) { + // Remove the comment node + headingNode.children.pop(); + // Trim the trailing space from the last text node ("txt " → "txt") + const newLast = headingNode.children.at(-1); + if (newLast?.type === 'text') { + newLast.value = newLast.value.trimEnd(); + } - id = - parsedHeading.id ?? - slugs.slug(heading, {maintainCase: anchorsMaintainCase}); + setId(commentId); + return; + } + + const headingText = getHeadingText(headingNode); + // Try to find an explicit id in the heading text (legacy syntax) + const parsedHeading = parseMarkdownHeadingId( + getHeadingText(headingNode), + ); + // 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 - // Sometimes heading is in multiple "parts" (** syntax creates a child - // node): - // ## part1 *part2* part3 {#id} - const lastNode = headingNode.children[ - headingNode.children.length - 1 - ] as Text; - + const lastNode = headingNode.children.at(-1) as Text; if (headingNode.children.length > 1) { const lastNodeText = parseMarkdownHeadingId(lastNode.value).text; - // When last part contains test+id, remove the id + // When the last part contains text + id, remove the id if (lastNodeText) { lastNode.value = lastNodeText; } @@ -70,10 +96,14 @@ const plugin: Plugin = function plugin({ lastNode.value = parsedHeading.text; } } - } - data.id = id; - properties.id = id; + const id = + parsedHeading.id ?? + slugs.slug(headingText, {maintainCase: anchorsMaintainCase}); + + setId(id); + + } }); }; }; From 4f81d1f5f39b4abe2bd203b619bf905051ebc816 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 17:47:59 +0100 Subject: [PATCH 04/14] revert eslint --- .eslintrc.js | 1 - 1 file changed, 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index eaf0a094f643..41859a9031f7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -213,7 +213,6 @@ module.exports = { {allowTaggedTemplates: true, allowShortCircuit: true}, ], 'no-useless-escape': WARNING, - 'no-useless-return': WARNING, 'no-void': [ERROR, {allowAsStatement: true}], 'prefer-destructuring': OFF, 'prefer-named-capture-group': WARNING, From 40a3fd6eeac9450766ea0626f091d095eab60eac Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 17:48:24 +0100 Subject: [PATCH 05/14] improve test --- .../src/remark/headings/__tests__/index.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 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 8f749ce83e3a..394d35bfc14f 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 @@ -133,9 +133,13 @@ describe('headings remark plugin', () => { '## Something also', ].join('\n\n'), [ - () => (root) => { - (root as Parent).children[1]!.data = {hProperties: {id: 'here'}}; - (root as Parent).children[3]!.data = {hProperties: {id: 'something'}}; + function customIdPlugin() { + return (root) => { + (root as Parent).children[1]!.data = {hProperties: {id: 'here'}}; + (root as Parent).children[3]!.data = { + hProperties: {id: 'something'}, + }; + }; }, ], ); From 1e6442241fed6e5a6cacd4d5e4aad22f3d3cc1bd Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 17:48:31 +0100 Subject: [PATCH 06/14] improve type --- packages/docusaurus-mdx-loader/src/types.d.mts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/docusaurus-mdx-loader/src/types.d.mts b/packages/docusaurus-mdx-loader/src/types.d.mts index 84d1c03cef60..012c32ec73ef 100644 --- a/packages/docusaurus-mdx-loader/src/types.d.mts +++ b/packages/docusaurus-mdx-loader/src/types.d.mts @@ -33,4 +33,8 @@ declare module 'mdast' { hName?: string; hProperties?: Record; } + + interface HeadingData { + hProperties?: {id?: string}; + } } From 8a2c276a21ddceba51f97c441dce5190247cf22b Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 17:48:54 +0100 Subject: [PATCH 07/14] improve impl logic --- .../src/remark/headings/index.ts | 129 +++++++++--------- 1 file changed, 68 insertions(+), 61 deletions(-) diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index a4b9534a5713..eb545db82cdb 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -23,6 +23,48 @@ function getCommentHeadingId(heading: Heading): string | undefined { return undefined; } +// Try to find an explicit id in MD/MDX comments + +function extractCommentId(heading: Heading) { + const commentId = getCommentHeadingId(heading); + if (commentId) { + // Remove the last comment node + heading.children.pop(); + // Trim the trailing space from the last text node ("text " → "text") + const newLast = heading.children.at(-1); + if (newLast?.type === 'text') { + newLast.value = newLast.value.trimEnd(); + } + return commentId; + } + return undefined; +} + +// Try to find an explicit id in the heading text (legacy {#id} syntax) +function extractLegacySyntaxId(heading: Heading, headingText: string) { + const parsedHeading = parseMarkdownHeadingId(headingText); + // 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).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(); + } + } else { + lastNode.value = parsedHeading.text; + } + return parsedHeading.id; + } + return undefined; +} + const plugin: Plugin = function plugin({ anchorsMaintainCase, }): Transformer { @@ -38,72 +80,37 @@ const plugin: Plugin = function plugin({ } const slugs = createSlugger(); - visit(root, 'heading', (headingNode) => { - const data = headingNode.data ?? (headingNode.data = {}); - const properties = (data.hProperties || (data.hProperties = {})) as { - id: string; - }; - - function setId(newId: string) { - data.id = newId; - properties.id = newId; - } - - // properties.id already set? Not sure when this happens, historical code - if (properties.id) { - const id = slugs.slug(properties.id, {maintainCase: true}); - setId(id); - - } - // No id set - else { - // Try to find an explicit id in MD/MDX comments - const commentId = getCommentHeadingId(headingNode); - if (commentId) { - // Remove the comment node - headingNode.children.pop(); - // Trim the trailing space from the last text node ("txt " → "txt") - const newLast = headingNode.children.at(-1); - if (newLast?.type === 'text') { - newLast.value = newLast.value.trimEnd(); - } - - setId(commentId); - return; + visit(root, 'heading', (heading) => { + const data = heading.data ?? (heading.data = {}); + const properties = data.hProperties ?? (data.hProperties = {}); + + // Gives the ability to provide/write a remark plugin that sets an id + // When an id is already set, we use it instead of running our own plugin + function extractAlreadyExistingId() { + if (properties.id) { + // Not sure why we need to slugify here, historical code + return slugs.slug(properties.id, {maintainCase: true}); } + return undefined; + } - const headingText = getHeadingText(headingNode); - - // Try to find an explicit id in the heading text (legacy syntax) - const parsedHeading = parseMarkdownHeadingId( - getHeadingText(headingNode), + function extractIdFromText() { + const headingText = getHeadingText(heading); + return ( + extractLegacySyntaxId(heading, headingText) ?? + slugs.slug(headingText, {maintainCase: anchorsMaintainCase}) ); - // 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 = headingNode.children.at(-1) as Text; - if (headingNode.children.length > 1) { - const lastNodeText = parseMarkdownHeadingId(lastNode.value).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 { - headingNode.children.pop(); - } - } else { - lastNode.value = parsedHeading.text; - } - } + } - const id = - parsedHeading.id ?? - slugs.slug(headingText, {maintainCase: anchorsMaintainCase}); + // All the ways we can extract an id, ordered by priority + // /!\ the extraction methods can perform AST cleanup side effects + const id = + extractAlreadyExistingId() ?? + extractCommentId(heading) ?? + extractIdFromText(); - setId(id); - - } + data.id = id; + properties.id = id; }); }; }; From 6de9c80cdf0ea98b24bc24f69700d9a1a135f61f Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 18:25:44 +0100 Subject: [PATCH 08/14] working and tested implementation --- .../remark/headings/__tests__/index.test.ts | 146 +++++++++--------- .../src/remark/headings/index.ts | 30 +++- 2 files changed, 98 insertions(+), 78 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 394d35bfc14f..4929dcb27652 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 @@ -9,14 +9,13 @@ import u from 'unist-builder'; import {removePosition} from 'unist-util-remove-position'; -import {toString} from 'mdast-util-to-string'; import {visit} from 'unist-util-visit'; import {escapeMarkdownHeadingIds} from '@docusaurus/utils'; import plugin from '../index'; import type {PluginOptions} from '../index'; import type {Plugin} from 'unified'; import type {Parent} from 'unist'; -import type {Root} from 'mdast'; +import type {Heading, Root} from 'mdast'; async function process( input: string, @@ -46,11 +45,11 @@ async function process( return result as unknown as Root; } -function heading(label: string | null, id: string) { +function h(text: string | null, depth: number, id: string) { return u( 'heading', - {depth: 2, data: {id, hProperties: {id}}}, - label ? [u('text', label)] : [], + {depth, data: {id, hProperties: {id}}}, + text ? [u('text', text)] : [], ); } @@ -58,11 +57,7 @@ describe('headings remark plugin', () => { it('patches `id`s and `data.hProperties.id', async () => { const result = await process('# Normal\n\n## Table of Contents\n\n# Baz\n'); const expected = u('root', [ - u( - 'heading', - {depth: 1, data: {hProperties: {id: 'normal'}, id: 'normal'}}, - [u('text', 'Normal')], - ), + h('Normal', 1, 'normal'), u( 'heading', { @@ -220,6 +215,15 @@ describe('headings remark plugin', () => { '', ].join('\n'), ); + + function heading(label: string | null, id: string) { + return u( + 'heading', + {depth: 2, data: {id, hProperties: {id}}}, + label ? [u('text', label)] : [], + ); + } + const expected = u('root', [ heading('I ♥ unicode', 'i--unicode'), heading('Dash-dash', 'dash-dash'), @@ -283,17 +287,25 @@ describe('headings remark plugin', () => { }); describe('headings ids', () => { - async function headingIdFor(input: string, format: 'md' | 'mdx' = 'mdx') { + async function processHeading( + input: string, + format: 'md' | 'mdx' = 'mdx', + ): Promise { const result = await process(input, [], {}, format); - const headers: {text: string; id: string}[] = []; + const headings: Heading[] = []; visit(result, 'heading', (node) => { - headers.push({ - text: toString(node), - id: (node.data! as {id: string}).id, - }); + headings.push(node); }); - expect(headers).toHaveLength(1); - return headers[0]!.id; + expect(headings).toHaveLength(1); + return headings[0]!; + } + + async function headingIdFor( + input: string, + format: 'md' | 'mdx' = 'mdx', + ): Promise { + const {data} = await processHeading(input, format); + return (data! as {id: string}).id; } describe('historical syntax', () => { @@ -355,11 +367,11 @@ describe('headings remark plugin', () => { ).resolves.toEqual('custom_h1'); await expect( - headingIdFor('## Heading Two ', 'md'), + headingIdFor('## Heading Two ', 'md'), ).resolves.toEqual('custom-heading-two'); await expect( - headingIdFor('# Snake-cased ', 'md'), + headingIdFor('# Snake-cased ', 'md'), ).resolves.toEqual('this_is_custom_id'); }); @@ -382,42 +394,20 @@ describe('headings remark plugin', () => { }); it('removes the comment node from heading AST', async () => { - const result = await process( + const heading = await processHeading( '## Heading ', - [], - {}, 'md', ); - expect(result).toEqual( - u('root', [ - u( - 'heading', - {depth: 2, data: {id: 'my-id', hProperties: {id: 'my-id'}}}, - [u('text', 'Heading')], - ), - ]), - ); + expect(heading).toEqual(h('Heading', 2, 'my-id')); }); it('removes the comment node when it is the only heading content', async () => { - const result = await process('## ', [], {}, 'md'); - expect(result).toEqual( - u('root', [ - u( - 'heading', - { - depth: 2, - data: {id: 'id-only', hProperties: {id: 'id-only'}}, - }, - [], - ), - ]), - ); + const heading = await processHeading('## ', 'md'); + expect(heading).toEqual(h(null, 2, 'id-only')); }); it('does NOT support MDX comment syntax {/* id */} in CommonMark', async () => { - // In CommonMark (no remark-mdx), {/* id */} is plain text - // so the id falls back to the slug of the raw text content + // In CommonMark (no remark-mdx), {/* id */} is regular text const id = await headingIdFor('# Heading {/* my-id */}', 'md'); expect(id).not.toEqual('my-id'); }); @@ -451,50 +441,52 @@ describe('headings remark plugin', () => { }); it('does NOT extract id when MDX comment is not the last node', async () => { - await expect( - headingIdFor('# {/* custom-id */} some text', 'mdx'), - ).resolves.not.toEqual('custom-id'); + const id = await headingIdFor('# {/* custom-id */} some text', 'mdx'); + expect(id).not.toEqual('custom-id'); + expect(id).toMatchInlineSnapshot(`"-custom-id--some-text"`); + }); + + it('does NOT extract id when MDX comment is not the only part of the expression', async () => { + const id = await headingIdFor( + '# some text {someExpression /* custom-id */}', + 'mdx', + ); + expect(id).not.toEqual('custom-id'); + expect(id).toMatchInlineSnapshot( + `"some-text-someexpression--custom-id-"`, + ); + }); + + it('does NOT extract id when MDX expression has multiple comments', async () => { + const id = await headingIdFor( + '# some text {/* id1 *//* id2 */}', + 'mdx', + ); + expect(id).not.toEqual('id1'); + expect(id).not.toEqual('id2'); + expect(id).toMatchInlineSnapshot(`"some-text--id1--id2-"`); }); it('removes the comment node from heading AST', async () => { - const result = await process( + const heading = await processHeading( '## Heading {/* my-id */}', - [], - {}, 'mdx', ); - expect(result).toEqual( - u('root', [ - u( - 'heading', - {depth: 2, data: {id: 'my-id', hProperties: {id: 'my-id'}}}, - [u('text', 'Heading')], - ), - ]), - ); + expect(heading).toEqual(h('Heading', 2, 'my-id')); }); it('removes the comment node when it is the only heading content', async () => { - const result = await process('## {/* id-only */}', [], {}, 'mdx'); - expect(result).toEqual( - u('root', [ - u( - 'heading', - { - depth: 2, - data: {id: 'id-only', hProperties: {id: 'id-only'}}, - }, - [], - ), - ]), - ); + const heading = await processHeading('## {/* id-only */}', 'mdx'); + expect(heading).toEqual(h(null, 2, 'id-only')); }); it('does NOT support HTML comment syntax in MDX', async () => { // MDX throws a parse error for HTML comments inside headings await expect( - process('## Heading ', [], {}, 'mdx'), - ).rejects.toThrow(); + processHeading('## Heading ', 'mdx'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unexpected character \`!\` (U+0021) before name, expected a character that can start a name, such as a letter, \`$\`, or \`_\` (note: to create a comment in MDX, use \`{/* text */}\`)"`, + ); }); }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index eb545db82cdb..0aba0a1b7085 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -18,7 +18,35 @@ export interface PluginOptions { function getCommentHeadingId(heading: Heading): string | undefined { const lastChild = heading.children.at(-1); - console.log('Last child of heading:', lastChild); + // MDX comment: {/* my-id */} + if ( + lastChild && + lastChild.type === 'mdxTextExpression' && + lastChild.data?.estree + ) { + const program = lastChild.data.estree; + // We only extract the id from single-comment MDX expressions + // ✅ {/* my-id */} + // ❌ {/* my-id */ /* my-id2 */} + // ❌ {someExpression /* my-id */} + if (program.body.length === 0 && program.comments?.length === 1) { + const singleComment = program.comments[0]!; + return singleComment.value.trim(); + } + + /* + const match = /^\/\*(?[\s\S]*)\*\/$/.exec(lastChild.value); + return match?.groups?.id?.trim() || undefined; + */ + } + + // HTML comment: + if (lastChild?.type === 'html') { + const match = /^$/.exec( + (lastChild as unknown as {value: string}).value, + ); + return match?.groups?.id?.trim() || undefined; + } return undefined; } From 0b567e82acf6a3a237444c6686533d5589cdd874 Mon Sep 17 00:00:00 2001 From: slorber <749374+slorber@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:35:43 +0000 Subject: [PATCH 09/14] refactor: apply lint autofix --- project-words.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/project-words.txt b/project-words.txt index d72ecff0aca2..a72e9528bc69 100644 --- a/project-words.txt +++ b/project-words.txt @@ -297,6 +297,7 @@ sluggify Smoosh Solana solana +someexpression spâce stackblitz stackoverflow From e3d7c9bcf986510aaaf750c468947299061f84b8 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 20 Feb 2026 18:38:42 +0100 Subject: [PATCH 10/14] empty From 4ccc4014638d30e33d5092c5cb5641df1c7c8455 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 27 Feb 2026 15:40:24 +0100 Subject: [PATCH 11/14] remove comments --- packages/docusaurus-mdx-loader/src/remark/headings/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 0aba0a1b7085..1718db62fa4b 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -33,11 +33,6 @@ function getCommentHeadingId(heading: Heading): string | undefined { const singleComment = program.comments[0]!; return singleComment.value.trim(); } - - /* - const match = /^\/\*(?[\s\S]*)\*\/$/.exec(lastChild.value); - return match?.groups?.id?.trim() || undefined; - */ } // HTML comment: @@ -52,7 +47,6 @@ function getCommentHeadingId(heading: Heading): string | undefined { } // Try to find an explicit id in MD/MDX comments - function extractCommentId(heading: Heading) { const commentId = getCommentHeadingId(heading); if (commentId) { From 9cc09cf23950ed74bd9d89b64300d5465a4efd81 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 27 Feb 2026 17:49:40 +0100 Subject: [PATCH 12/14] force usage of # in comment content --- .../src/remark/headings/index.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 1718db62fa4b..161453fc8dcd 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -15,10 +15,24 @@ export interface PluginOptions { anchorsMaintainCase: boolean; } +function getCommentContentHeadingId(comment: string): string | undefined { + const trimmed = comment.trim(); + + // We ignore comments that don't start with # on purpose + // Forcing users to use a leading # is more explicit + // In the future it's possible we'd want to allow other types of comments + // For example class comments like {/* .my-class */} + if (trimmed.startsWith('#')) { + return trimmed.slice(1); + } + + return undefined; +} + function getCommentHeadingId(heading: Heading): string | undefined { const lastChild = heading.children.at(-1); - // MDX comment: {/* my-id */} + // MDX comment: {/* my-id */} or {/* #my-id */} if ( lastChild && lastChild.type === 'mdxTextExpression' && @@ -26,21 +40,24 @@ function getCommentHeadingId(heading: Heading): string | undefined { ) { const program = lastChild.data.estree; // We only extract the id from single-comment MDX expressions - // ✅ {/* my-id */} + // ✅ {/* #my-id */} // ❌ {/* my-id */ /* my-id2 */} // ❌ {someExpression /* my-id */} if (program.body.length === 0 && program.comments?.length === 1) { - const singleComment = program.comments[0]!; - return singleComment.value.trim(); + const commentContent = program.comments[0]!.value; + return getCommentContentHeadingId(commentContent); } } - // HTML comment: + // HTML comment: or if (lastChild?.type === 'html') { - const match = /^$/.exec( + const match = /^$/.exec( (lastChild as unknown as {value: string}).value, ); - return match?.groups?.id?.trim() || undefined; + if (match?.groups?.comment) { + const commentContent = match.groups.comment; + return getCommentContentHeadingId(commentContent); + } } return undefined; From 8019a0233e0a415b20849b5a4b9594f6cb0fc5d7 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 27 Feb 2026 18:16:30 +0100 Subject: [PATCH 13/14] improve the code + test edge cases --- .../remark/headings/__tests__/index.test.ts | 93 ++++++++++++++----- .../src/remark/headings/index.ts | 13 ++- 2 files changed, 74 insertions(+), 32 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 4929dcb27652..58ee24c26edd 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 @@ -361,94 +361,117 @@ describe('headings remark plugin', () => { describe('comment syntax', () => { describe('works for format CommonMark', () => { - it('extracts id from HTML comment at end of heading', async () => { + it('extracts id from HTML comment with # prefix at end of heading', async () => { await expect( - headingIdFor('# Heading One ', 'md'), + headingIdFor('# Heading One ', 'md'), ).resolves.toEqual('custom_h1'); await expect( - headingIdFor('## Heading Two ', 'md'), + headingIdFor('## Heading Two ', 'md'), ).resolves.toEqual('custom-heading-two'); await expect( - headingIdFor('# Snake-cased ', 'md'), + headingIdFor('# Snake-cased ', 'md'), ).resolves.toEqual('this_is_custom_id'); }); it('extracts id when comment is the only heading content', async () => { await expect( - headingIdFor('# ', 'md'), + headingIdFor('# ', 'md'), ).resolves.toEqual('id-only'); }); it('extracts id when heading has inline markup before comment', async () => { await expect( - headingIdFor('# With *Bold* ', 'md'), + headingIdFor('# With *Bold* ', 'md'), ).resolves.toEqual('custom-with-bold'); }); it('does NOT extract id when HTML comment is not the last node', async () => { await expect( - headingIdFor('# some text', 'md'), + headingIdFor('# some text', 'md'), ).resolves.not.toEqual('custom-id'); }); + it('does NOT extract id when HTML comment has no # prefix', async () => { + const id = await headingIdFor('# Heading ', 'md'); + expect(id).not.toEqual('my-id'); + expect(id).toMatchInlineSnapshot(`"heading-"`); + }); + + it('does NOT extract id when HTML comment is just #', async () => { + const id = await headingIdFor('## Heading ', 'md'); + expect(id).not.toEqual(''); + expect(id).toMatchInlineSnapshot(`"heading-"`); + }); + + it('extracts id when MDX comment has spaces', async () => { + const id = await headingIdFor( + '## Heading ', + 'md', + ); + expect(id).toEqual('id1'); + }); + it('removes the comment node from heading AST', async () => { const heading = await processHeading( - '## Heading ', + '## Heading ', 'md', ); expect(heading).toEqual(h('Heading', 2, 'my-id')); }); it('removes the comment node when it is the only heading content', async () => { - const heading = await processHeading('## ', 'md'); + const heading = await processHeading('## ', 'md'); expect(heading).toEqual(h(null, 2, 'id-only')); }); - 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'); + 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'); expect(id).not.toEqual('my-id'); }); }); describe('works for format MDX', () => { - it('extracts id from MDX comment at end of heading', async () => { + it('extracts id from MDX comment with # prefix at end of heading', async () => { await expect( - headingIdFor('# Heading One {/* custom_h1 */}', 'mdx'), + headingIdFor('# Heading One {/* #custom_h1 */}', 'mdx'), ).resolves.toEqual('custom_h1'); await expect( - headingIdFor('## Heading Two {/* custom-heading-two */}', 'mdx'), + headingIdFor('## Heading Two {/* #custom-heading-two */}', 'mdx'), ).resolves.toEqual('custom-heading-two'); await expect( - headingIdFor('# Snake-cased {/* this_is_custom_id */}', 'mdx'), + headingIdFor('# Snake-cased {/* #this_is_custom_id */}', 'mdx'), ).resolves.toEqual('this_is_custom_id'); }); it('extracts id when comment is the only heading content', async () => { await expect( - headingIdFor('# {/* id-only */}', 'mdx'), + headingIdFor('# {/* #id-only */}', 'mdx'), ).resolves.toEqual('id-only'); }); it('extracts id when heading has inline markup before comment', async () => { await expect( - headingIdFor('# With *Bold* {/* custom-with-bold */}', 'mdx'), + headingIdFor('# With *Bold* {/* #custom-with-bold */}', 'mdx'), ).resolves.toEqual('custom-with-bold'); }); it('does NOT extract id when MDX comment is not the last node', async () => { - const id = await headingIdFor('# {/* custom-id */} some text', 'mdx'); + const id = await headingIdFor( + '# {/* #custom-id */} some text', + 'mdx', + ); expect(id).not.toEqual('custom-id'); expect(id).toMatchInlineSnapshot(`"-custom-id--some-text"`); }); it('does NOT extract id when MDX comment is not the only part of the expression', async () => { const id = await headingIdFor( - '# some text {someExpression /* custom-id */}', + '# some text {someExpression /* #custom-id */}', 'mdx', ); expect(id).not.toEqual('custom-id'); @@ -459,7 +482,7 @@ describe('headings remark plugin', () => { it('does NOT extract id when MDX expression has multiple comments', async () => { const id = await headingIdFor( - '# some text {/* id1 *//* id2 */}', + '# some text {/* #id1 *//* #id2 */}', 'mdx', ); expect(id).not.toEqual('id1'); @@ -467,23 +490,43 @@ describe('headings remark plugin', () => { expect(id).toMatchInlineSnapshot(`"some-text--id1--id2-"`); }); + it('does NOT extract id when MDX comment has no # prefix', async () => { + const id = await headingIdFor('## Heading {/* my-id */}', 'mdx'); + expect(id).not.toEqual('my-id'); + expect(id).toMatchInlineSnapshot(`"heading--my-id-"`); + }); + + it('does NOT extract id when MDX comment is just #', async () => { + const id = await headingIdFor('## Heading {/* # */}', 'mdx'); + expect(id).not.toEqual(''); + expect(id).toMatchInlineSnapshot(`"heading---"`); + }); + + it('extracts id when MDX comment has spaces', async () => { + const id = await headingIdFor( + '## Heading {/* #id1 whatever comment #id2 */}', + 'mdx', + ); + expect(id).toEqual('id1'); + }); + it('removes the comment node from heading AST', async () => { const heading = await processHeading( - '## Heading {/* my-id */}', + '## Heading {/* #my-id */}', 'mdx', ); expect(heading).toEqual(h('Heading', 2, 'my-id')); }); it('removes the comment node when it is the only heading content', async () => { - const heading = await processHeading('## {/* id-only */}', 'mdx'); + const heading = await processHeading('## {/* #id-only */}', 'mdx'); expect(heading).toEqual(h(null, 2, 'id-only')); }); - it('does NOT support HTML comment syntax in MDX', async () => { + it('does NOT support HTML comment syntax in MDX', async () => { // MDX throws a parse error for HTML comments inside headings await expect( - processHeading('## Heading ', 'mdx'), + processHeading('## Heading ', 'mdx'), ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unexpected character \`!\` (U+0021) before name, expected a character that can start a name, such as a letter, \`$\`, or \`_\` (note: to create a comment in MDX, use \`{/* text */}\`)"`, ); diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts index 161453fc8dcd..ad5a3db0c47f 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.ts @@ -16,16 +16,15 @@ export interface PluginOptions { } function getCommentContentHeadingId(comment: string): string | undefined { - const trimmed = comment.trim(); - + // If the comment has spaces, we only consider the first part + const firstPart = comment.trim().split(' ')[0]; // We ignore comments that don't start with # on purpose // Forcing users to use a leading # is more explicit // In the future it's possible we'd want to allow other types of comments // For example class comments like {/* .my-class */} - if (trimmed.startsWith('#')) { - return trimmed.slice(1); + if (firstPart?.startsWith('#')) { + return firstPart.slice(1) || undefined; } - return undefined; } @@ -41,8 +40,8 @@ function getCommentHeadingId(heading: Heading): string | undefined { const program = lastChild.data.estree; // We only extract the id from single-comment MDX expressions // ✅ {/* #my-id */} - // ❌ {/* my-id */ /* my-id2 */} - // ❌ {someExpression /* my-id */} + // ❌ {/* #my-id */ /* #my-id2 */} + // ❌ {someExpression /* #my-id */} if (program.body.length === 0 && program.comments?.length === 1) { const commentContent = program.comments[0]!.value; return getCommentContentHeadingId(commentContent); From e2f558b0f5df8d0adfd4d3068728739f9b09a714 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 27 Feb 2026 18:43:02 +0100 Subject: [PATCH 14/14] add docs --- .../markdown-features-toc.mdx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/website/docs/guides/markdown-features/markdown-features-toc.mdx b/website/docs/guides/markdown-features/markdown-features-toc.mdx index 8b73297a9077..58ab892a79fb 100644 --- a/website/docs/guides/markdown-features/markdown-features-toc.mdx +++ b/website/docs/guides/markdown-features/markdown-features-toc.mdx @@ -5,6 +5,8 @@ slug: /markdown-features/toc --- import BrowserWindow from '@site/src/components/BrowserWindow'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # Headings and Table of contents @@ -39,14 +41,41 @@ By default, Docusaurus will generate heading IDs for you, based on the heading t Generated IDs have **some limitations**: - The ID might not look good -- You might want to **change or translate** the text without updating the existing ID +- You might want to **change or translate** the text without updating the existing ID to avoid breaking links -A special Markdown syntax lets you set an **explicit heading id**: +A special syntax lets you set an **explicit heading id**. + + + + +```mdx-code-block +{ + '### Hello World {/* #my-explicit-id */}\n\n' + + '### Hello World \u007B#my-explicit-id}\n' +} +``` + + + ```mdx-code-block -{'### Hello World \u007B#my-explicit-id}\n'} +{ + '### Hello World \n\n' + + '### Hello World \u007B#my-explicit-id}\n' +} ``` + + + +The heading id comment must start with `#`, be placed at the **end** of the heading and will be stripped from the rendered output. + +:::warning Legacy `{#id}` syntax for MDX files + +For MDX files, the `{#id}` syntax should be avoided. Since Docusaurus v3 and MDX v2, it is **not valid MDX syntax anymore**. It can break external tools that support MDX (IDEs and linters). It is only supported in Docusaurus for backward compatibility, thanks to the `markdown.mdx1Compat.headingIds` config option. The comment-based syntax should be preferred for MDX documents. + +::: + :::tip Use the **[`write-heading-ids`](../../cli.mdx#docusaurus-write-heading-ids-sitedir)** CLI command to add explicit IDs to all your Markdown documents.