Skip to content

Commit 3dbd395

Browse files
authored
feat(mdx-loader): add support for explicit headingId based on MD/MDX comments (#11755)
* refactor tests * add claude todos * stable impl * revert eslint * improve test * improve type * improve impl logic * working and tested implementation * refactor: apply lint autofix * empty * remove comments * force usage of # in comment content * improve the code + test edge cases * add docs --------- Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
1 parent 00ee8a4 commit 3dbd395

File tree

5 files changed

+373
-78
lines changed

5 files changed

+373
-78
lines changed

packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.ts

Lines changed: 215 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,18 @@
99

1010
import u from 'unist-builder';
1111
import {removePosition} from 'unist-util-remove-position';
12-
import {toString} from 'mdast-util-to-string';
1312
import {visit} from 'unist-util-visit';
1413
import {escapeMarkdownHeadingIds} from '@docusaurus/utils';
1514
import plugin from '../index';
1615
import type {PluginOptions} from '../index';
1716
import type {Plugin} from 'unified';
1817
import type {Parent} from 'unist';
19-
import type {Root} from 'mdast';
18+
import type {Heading, Root} from 'mdast';
2019

2120
async function process(
2221
input: string,
2322
plugins: Plugin[] = [],
24-
options: PluginOptions = {anchorsMaintainCase: false},
23+
options: Partial<PluginOptions> = {anchorsMaintainCase: false},
2524
format: 'md' | 'mdx' = 'mdx',
2625
): Promise<Root> {
2726
const {remark} = await import('remark');
@@ -46,23 +45,19 @@ async function process(
4645
return result as unknown as Root;
4746
}
4847

49-
function heading(label: string | null, id: string) {
48+
function h(text: string | null, depth: number, id: string) {
5049
return u(
5150
'heading',
52-
{depth: 2, data: {id, hProperties: {id}}},
53-
label ? [u('text', label)] : [],
51+
{depth, data: {id, hProperties: {id}}},
52+
text ? [u('text', text)] : [],
5453
);
5554
}
5655

5756
describe('headings remark plugin', () => {
5857
it('patches `id`s and `data.hProperties.id', async () => {
5958
const result = await process('# Normal\n\n## Table of Contents\n\n# Baz\n');
6059
const expected = u('root', [
61-
u(
62-
'heading',
63-
{depth: 1, data: {hProperties: {id: 'normal'}, id: 'normal'}},
64-
[u('text', 'Normal')],
65-
),
60+
h('Normal', 1, 'normal'),
6661
u(
6762
'heading',
6863
{
@@ -133,9 +128,13 @@ describe('headings remark plugin', () => {
133128
'## Something also',
134129
].join('\n\n'),
135130
[
136-
() => (root) => {
137-
(root as Parent).children[1]!.data = {hProperties: {id: 'here'}};
138-
(root as Parent).children[3]!.data = {hProperties: {id: 'something'}};
131+
function customIdPlugin() {
132+
return (root) => {
133+
(root as Parent).children[1]!.data = {hProperties: {id: 'here'}};
134+
(root as Parent).children[3]!.data = {
135+
hProperties: {id: 'something'},
136+
};
137+
};
139138
},
140139
],
141140
);
@@ -216,6 +215,15 @@ describe('headings remark plugin', () => {
216215
'',
217216
].join('\n'),
218217
);
218+
219+
function heading(label: string | null, id: string) {
220+
return u(
221+
'heading',
222+
{depth: 2, data: {id, hProperties: {id}}},
223+
label ? [u('text', label)] : [],
224+
);
225+
}
226+
219227
const expected = u('root', [
220228
heading('I ♥ unicode', 'i--unicode'),
221229
heading('Dash-dash', 'dash-dash'),
@@ -278,23 +286,26 @@ describe('headings remark plugin', () => {
278286
expect(result).toEqual(expected);
279287
});
280288

281-
describe('creates custom headings ids', () => {
282-
async function headingIdFor(input: string, format: 'md' | 'mdx' = 'mdx') {
283-
const result = await process(
284-
input,
285-
[],
286-
{anchorsMaintainCase: false},
287-
format,
288-
);
289-
const headers: {text: string; id: string}[] = [];
289+
describe('headings ids', () => {
290+
async function processHeading(
291+
input: string,
292+
format: 'md' | 'mdx' = 'mdx',
293+
): Promise<Heading> {
294+
const result = await process(input, [], {}, format);
295+
const headings: Heading[] = [];
290296
visit(result, 'heading', (node) => {
291-
headers.push({
292-
text: toString(node),
293-
id: (node.data! as {id: string}).id,
294-
});
297+
headings.push(node);
295298
});
296-
expect(headers).toHaveLength(1);
297-
return headers[0]!.id;
299+
expect(headings).toHaveLength(1);
300+
return headings[0]!;
301+
}
302+
303+
async function headingIdFor(
304+
input: string,
305+
format: 'md' | 'mdx' = 'mdx',
306+
): Promise<string> {
307+
const {data} = await processHeading(input, format);
308+
return (data! as {id: string}).id;
298309
}
299310

300311
describe('historical syntax', () => {
@@ -347,6 +358,181 @@ describe('headings remark plugin', () => {
347358
await testHeadingIds('mdx');
348359
});
349360
});
361+
362+
describe('comment syntax', () => {
363+
describe('works for format CommonMark', () => {
364+
it('extracts id from HTML comment with # prefix at end of heading', async () => {
365+
await expect(
366+
headingIdFor('# Heading One <!-- #custom_h1 -->', 'md'),
367+
).resolves.toEqual('custom_h1');
368+
369+
await expect(
370+
headingIdFor('## Heading Two <!-- #custom-heading-two -->', 'md'),
371+
).resolves.toEqual('custom-heading-two');
372+
373+
await expect(
374+
headingIdFor('# Snake-cased <!-- #this_is_custom_id -->', 'md'),
375+
).resolves.toEqual('this_is_custom_id');
376+
});
377+
378+
it('extracts id when comment is the only heading content', async () => {
379+
await expect(
380+
headingIdFor('# <!-- #id-only -->', 'md'),
381+
).resolves.toEqual('id-only');
382+
});
383+
384+
it('extracts id when heading has inline markup before comment', async () => {
385+
await expect(
386+
headingIdFor('# With *Bold* <!-- #custom-with-bold -->', 'md'),
387+
).resolves.toEqual('custom-with-bold');
388+
});
389+
390+
it('does NOT extract id when HTML comment is not the last node', async () => {
391+
await expect(
392+
headingIdFor('# <!-- #custom-id --> some text', 'md'),
393+
).resolves.not.toEqual('custom-id');
394+
});
395+
396+
it('does NOT extract id when HTML comment has no # prefix', async () => {
397+
const id = await headingIdFor('# Heading <!-- my-id -->', 'md');
398+
expect(id).not.toEqual('my-id');
399+
expect(id).toMatchInlineSnapshot(`"heading-"`);
400+
});
401+
402+
it('does NOT extract id when HTML comment is just #', async () => {
403+
const id = await headingIdFor('## Heading <!-- # -->', 'md');
404+
expect(id).not.toEqual('');
405+
expect(id).toMatchInlineSnapshot(`"heading-"`);
406+
});
407+
408+
it('extracts id when MDX comment has spaces', async () => {
409+
const id = await headingIdFor(
410+
'## Heading <!-- #id1 whatever comment #id2 -->',
411+
'md',
412+
);
413+
expect(id).toEqual('id1');
414+
});
415+
416+
it('removes the comment node from heading AST', async () => {
417+
const heading = await processHeading(
418+
'## Heading <!-- #my-id -->',
419+
'md',
420+
);
421+
expect(heading).toEqual(h('Heading', 2, 'my-id'));
422+
});
423+
424+
it('removes the comment node when it is the only heading content', async () => {
425+
const heading = await processHeading('## <!-- #id-only -->', 'md');
426+
expect(heading).toEqual(h(null, 2, 'id-only'));
427+
});
428+
429+
it('does NOT support MDX comment syntax {/* #id */} in CommonMark', async () => {
430+
// In CommonMark (no remark-mdx), {/* #id */} is regular text
431+
const id = await headingIdFor('# Heading {/* #my-id */}', 'md');
432+
expect(id).not.toEqual('my-id');
433+
});
434+
});
435+
436+
describe('works for format MDX', () => {
437+
it('extracts id from MDX comment with # prefix at end of heading', async () => {
438+
await expect(
439+
headingIdFor('# Heading One {/* #custom_h1 */}', 'mdx'),
440+
).resolves.toEqual('custom_h1');
441+
442+
await expect(
443+
headingIdFor('## Heading Two {/* #custom-heading-two */}', 'mdx'),
444+
).resolves.toEqual('custom-heading-two');
445+
446+
await expect(
447+
headingIdFor('# Snake-cased {/* #this_is_custom_id */}', 'mdx'),
448+
).resolves.toEqual('this_is_custom_id');
449+
});
450+
451+
it('extracts id when comment is the only heading content', async () => {
452+
await expect(
453+
headingIdFor('# {/* #id-only */}', 'mdx'),
454+
).resolves.toEqual('id-only');
455+
});
456+
457+
it('extracts id when heading has inline markup before comment', async () => {
458+
await expect(
459+
headingIdFor('# With *Bold* {/* #custom-with-bold */}', 'mdx'),
460+
).resolves.toEqual('custom-with-bold');
461+
});
462+
463+
it('does NOT extract id when MDX comment is not the last node', async () => {
464+
const id = await headingIdFor(
465+
'# {/* #custom-id */} some text',
466+
'mdx',
467+
);
468+
expect(id).not.toEqual('custom-id');
469+
expect(id).toMatchInlineSnapshot(`"-custom-id--some-text"`);
470+
});
471+
472+
it('does NOT extract id when MDX comment is not the only part of the expression', async () => {
473+
const id = await headingIdFor(
474+
'# some text {someExpression /* #custom-id */}',
475+
'mdx',
476+
);
477+
expect(id).not.toEqual('custom-id');
478+
expect(id).toMatchInlineSnapshot(
479+
`"some-text-someexpression--custom-id-"`,
480+
);
481+
});
482+
483+
it('does NOT extract id when MDX expression has multiple comments', async () => {
484+
const id = await headingIdFor(
485+
'# some text {/* #id1 *//* #id2 */}',
486+
'mdx',
487+
);
488+
expect(id).not.toEqual('id1');
489+
expect(id).not.toEqual('id2');
490+
expect(id).toMatchInlineSnapshot(`"some-text--id1--id2-"`);
491+
});
492+
493+
it('does NOT extract id when MDX comment has no # prefix', async () => {
494+
const id = await headingIdFor('## Heading {/* my-id */}', 'mdx');
495+
expect(id).not.toEqual('my-id');
496+
expect(id).toMatchInlineSnapshot(`"heading--my-id-"`);
497+
});
498+
499+
it('does NOT extract id when MDX comment is just #', async () => {
500+
const id = await headingIdFor('## Heading {/* # */}', 'mdx');
501+
expect(id).not.toEqual('');
502+
expect(id).toMatchInlineSnapshot(`"heading---"`);
503+
});
504+
505+
it('extracts id when MDX comment has spaces', async () => {
506+
const id = await headingIdFor(
507+
'## Heading {/* #id1 whatever comment #id2 */}',
508+
'mdx',
509+
);
510+
expect(id).toEqual('id1');
511+
});
512+
513+
it('removes the comment node from heading AST', async () => {
514+
const heading = await processHeading(
515+
'## Heading {/* #my-id */}',
516+
'mdx',
517+
);
518+
expect(heading).toEqual(h('Heading', 2, 'my-id'));
519+
});
520+
521+
it('removes the comment node when it is the only heading content', async () => {
522+
const heading = await processHeading('## {/* #id-only */}', 'mdx');
523+
expect(heading).toEqual(h(null, 2, 'id-only'));
524+
});
525+
526+
it('does NOT support HTML comment syntax <!-- #id --> in MDX', async () => {
527+
// MDX throws a parse error for HTML comments inside headings
528+
await expect(
529+
processHeading('## Heading <!-- #my-id -->', 'mdx'),
530+
).rejects.toThrowErrorMatchingInlineSnapshot(
531+
`"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 */}\`)"`,
532+
);
533+
});
534+
});
535+
});
350536
});
351537

352538
it('preserve anchors case then "anchorsMaintainCase" option is set', async () => {

0 commit comments

Comments
 (0)