99
1010import u from 'unist-builder' ;
1111import { removePosition } from 'unist-util-remove-position' ;
12- import { toString } from 'mdast-util-to-string' ;
1312import { visit } from 'unist-util-visit' ;
1413import { escapeMarkdownHeadingIds } from '@docusaurus/utils' ;
1514import plugin from '../index' ;
1615import type { PluginOptions } from '../index' ;
1716import type { Plugin } from 'unified' ;
1817import type { Parent } from 'unist' ;
19- import type { Root } from 'mdast' ;
18+ import type { Heading , Root } from 'mdast' ;
2019
2120async 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
5756describe ( '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