1- import { load } from 'cheerio'
2- import type { Element } from 'domhandler'
3- import { range } from 'lodash-es'
4-
51import { renderContent } from '@/content-render/index'
62import type { Context } from '@/types'
73
4+ export interface CollectedHeading {
5+ href : string
6+ title : string
7+ headingLevel : number
8+ platform : string
9+ }
10+
811interface MiniTocContents {
912 href : string
1013 title : string
@@ -24,81 +27,38 @@ interface FlatTocItem {
2427 items ?: FlatTocItem [ ]
2528}
2629
27- // Keep maxHeadingLevel=2 for accessibility reasons, see docs-engineering#2701 for more info
28- export default function getMiniTocItems (
29- html : string ,
30+ // Build MiniTocItems from pre-collected heading data (from the collect-mini-toc
31+ // rehype plugin). This is the only path for generating mini-TOC items — headings
32+ // are collected directly from the AST during rendering, avoiding any HTML
33+ // re-parsing.
34+ // Keep maxHeadingLevel=2 for accessibility reasons, see docs-engineering#2701
35+ export function buildMiniTocFromCollected (
36+ collected : CollectedHeading [ ] ,
3037 maxHeadingLevel = 2 ,
31- headingScope = '' ,
3238) : MiniTocItem [ ] {
33- const $ = load ( html , { xmlMode : true } )
34-
35- // eg `h2, h3` or `h2, h3, h4` depending on maxHeadingLevel
36- const selector = range ( 2 , maxHeadingLevel + 1 )
37- . map ( ( num ) => `${ headingScope } h${ num } ` )
38- . join ( ', ' )
39- const headings = $ ( selector )
40-
41- // return an array of objects containing each heading's contents, level, and optional platform.
42- // Article layout uses these as follows:
43- // - `title` and `link` to render the mini TOC headings
44- // - `headingLevel` the `2` in `h2`; used for determining required indentation
45- // - `platform` to show or hide platform-specific headings via client JS
39+ const effectiveMax = maxHeadingLevel > 0 ? maxHeadingLevel : 2
40+ const headings = collected . filter ( ( h ) => h . headingLevel >= 2 && h . headingLevel <= effectiveMax )
4641
47- // H1 = highest importance, H6 = lowest importance
4842 let mostImportantHeadingLevel : number | undefined
49- const flatToc = headings
50- . get ( )
51- . filter ( ( item ) => {
52- const parent = item . parent as Element | null
53- if ( ! parent || ! parent . attribs ) return true
54- const { attribs } = parent
55- return ! ( 'hidden' in attribs )
56- } )
57- . map ( ( item ) => {
58- // remove any <span> tags including their content
59- $ ( 'span' , item ) . remove ( )
60-
61- // Capture the anchor tag nested within the header, get its href and remove it
62- const anchor = $ ( 'a.heading-link' , item )
63- const href = anchor . attr ( 'href' )
64- if ( ! href ) {
65- // Can happen if the, for example, `<h2>` tag was put there
66- // manually with HTML into the Markdown content. Then it wouldn't
67- // be rendered with an expected `<a class="heading-link" href="#..."`
68- // link in front of it.
69- // The `return null` will be filtered after the `.map()`
70- return null
71- }
72-
73- // remove any <strong> tags but leave content
74- $ ( 'strong' , item ) . map ( ( i , el ) => $ ( el ) . replaceWith ( $ ( el ) . contents ( ) ) )
7543
76- const contents : MiniTocContents = { href, title : $ ( item ) . text ( ) . trim ( ) }
77- const element = $ ( item ) [ 0 ] as Element
78- const headingLevel = parseInt ( element . name . match ( / \d + / ) ! [ 0 ] , 10 ) || 0 // the `2` from `h2`
79-
80- const platform = $ ( item ) . parent ( '.ghd-tool' ) . attr ( 'class' ) || ''
81-
82- // track the most important heading level while we're looping through the items
83- if ( headingLevel < mostImportantHeadingLevel ! || mostImportantHeadingLevel === undefined ) {
84- mostImportantHeadingLevel = headingLevel
85- }
44+ const flatToc : FlatTocItem [ ] = headings . map ( ( h ) => {
45+ if ( mostImportantHeadingLevel === undefined || h . headingLevel < mostImportantHeadingLevel ) {
46+ mostImportantHeadingLevel = h . headingLevel
47+ }
48+ return {
49+ contents : { href : h . href , title : h . title } ,
50+ headingLevel : h . headingLevel ,
51+ platform : h . platform ,
52+ indentationLevel : 0 ,
53+ }
54+ } )
8655
87- return { contents, headingLevel, platform }
88- } )
89- . filter ( Boolean )
90- . map ( ( item ) => {
91- // set the indentation level for each item based on the most important
92- // heading level in the current article
93- return {
94- ...item ! ,
95- indentationLevel : item ! . headingLevel - mostImportantHeadingLevel ! ,
96- }
97- } )
56+ // Set indentation relative to the most important heading
57+ for ( const item of flatToc ) {
58+ item . indentationLevel = item . headingLevel - ( mostImportantHeadingLevel ?? item . headingLevel )
59+ }
9860
99- // convert the flatToc to a nested structure to simplify semantic rendering on the client
10061 const nestedToc = buildNestedToc ( flatToc )
101-
10262 return minimalMiniToc ( nestedToc )
10363}
10464
@@ -179,6 +139,10 @@ export async function getAutomatedPageMiniTocItems(
179139 } )
180140 . join ( '' )
181141
182- const toc = await renderContent ( titles , context )
183- return getMiniTocItems ( toc , depth , '' )
142+ // Collect headings during render via the rehype plugin
143+ const collectMiniToc : CollectedHeading [ ] = [ ]
144+ const renderContext = { ...context , collectMiniToc }
145+ await renderContent ( titles , renderContext )
146+
147+ return buildMiniTocFromCollected ( collectMiniToc , depth )
184148}
0 commit comments