1- import type { Root } from 'mdast' ;
1+ import type { PhrasingContent , Root } from 'mdast' ;
22import type { VFile } from 'vfile' ;
3- import visit from 'unist-util-visit' ;
43
54export interface ExtractedTocItem {
65 url : string ;
76 depth : number ;
8- source ?: string ;
7+ /**
8+ * Heading content as already-processed mdast (custom IDs, smartypants,
9+ * etc. applied). Compiled separately by compileMDX so the TOC can render
10+ * inline code and badges. Mutually exclusive with `text`.
11+ */
12+ children ?: PhrasingContent [ ] ;
913 text ?: string ;
1014}
1115
12- function sourceForChildren (
13- source : string ,
14- children : Array < {
15- position ?: { start : { offset ?: number } ; end : { offset ?: number } } ;
16- } >
17- ) {
18- const start = children [ 0 ] ?. position ?. start . offset ;
19- const end = children . at ( - 1 ) ?. position ?. end . offset ;
20- return start == null || end == null ? '' : source . slice ( start , end ) . trim ( ) ;
16+ // Heading children without the trailing custom ID comment expression
17+ // (`{/*custom-id*/}` in the source) and any whitespace that preceded it.
18+ function headingContent ( children : PhrasingContent [ ] ) : PhrasingContent [ ] {
19+ const content = [ ...children ] ;
20+ const last = content [ content . length - 1 ] as { type : string } | undefined ;
21+ if ( last ?. type === 'mdxTextExpression' ) {
22+ content . pop ( ) ;
23+ }
24+ const lastText = content [ content . length - 1 ] ;
25+ if ( lastText ?. type === 'text' ) {
26+ content [ content . length - 1 ] = {
27+ ...lastText ,
28+ value : lastText . value . replace ( / \s + $ / , '' ) ,
29+ } ;
30+ }
31+ return content ;
2132}
2233
34+ /**
35+ * Collects the table of contents from the top-level nodes of the document,
36+ * matching the Pages Router build: headings nested inside other components
37+ * (<Note>, <DeepDive>, ...) are not part of the TOC. Must run before
38+ * MaxWidthWrapperPlugin, which moves those nodes into <MaxWidth> wrappers.
39+ */
2340export function TOCExtractorPlugin ( { maxDepth = 3 } = { } ) {
2441 return ( tree : Root , file : VFile ) => {
2542 const toc : ExtractedTocItem [ ] = [ ] ;
26- const source = String ( file . value ) ;
2743
28- visit ( tree , ( node : any ) => {
29- if ( node . type === 'heading' && node . depth <= maxDepth ) {
44+ for ( const node of tree . children as any [ ] ) {
45+ if ( node . type === 'heading' ) {
3046 const id = node . data ?. hProperties ?. id ;
31- if ( id ) {
47+ if ( node . depth <= maxDepth && id ) {
3248 toc . push ( {
3349 url : `#${ id } ` ,
3450 depth : node . depth ,
35- source : sourceForChildren ( source , node . children ) ,
51+ children : headingContent ( node . children ) ,
3652 } ) ;
3753 }
38- return ;
54+ continue ;
3955 }
4056
4157 if ( node . type !== 'mdxJsxFlowElement' ) {
42- return ;
58+ continue ;
4359 }
4460
4561 if ( node . name === 'Challenges' || node . name === 'Recap' ) {
@@ -59,7 +75,7 @@ export function TOCExtractorPlugin({maxDepth = 3} = {}) {
5975 const permalink = String ( attributes . get ( 'permalink' ) ?? 'team-member' ) ;
6076 toc . push ( { url : `#${ permalink } ` , depth : 3 , text : name } ) ;
6177 }
62- } ) ;
78+ }
6379
6480 if ( toc . length > 0 ) {
6581 toc . unshift ( { url : '#' , depth : 2 , text : 'Overview' } ) ;
0 commit comments