1import GithubSlugger from 'github-slugger'; 2import * as React from 'react'; 3 4import { ElementType, PageMetadata, RemarkHeading } from '../types/common'; 5import * as Utilities from './utilities'; 6 7/** 8 * These types directly correspond to MDAST node types 9 */ 10export enum HeadingType { 11 Text = 'text', 12 InlineCode = 'inlineCode', 13} 14 15/** 16 * Minimum heading level to display in sidebar. 17 * Example: When set to 2, the `H1` headers are unlisted, 18 * `H2`s are root level, and `H3`, `H4`... are nested. 19 * 20 * NOTE: Changing this needs additional adjustments in `translate-markdown.js`! 21 */ 22export const BASE_HEADING_LEVEL = 2; 23 24/** 25 * How deeply nested headings to display 26 * 0 - means only root headings 27 * 28 * Can be overriden in `.md` pages by setting 29 * `maxHeadingDepth` attribute 30 */ 31const DEFAULT_NESTING_LIMIT = 1; 32 33/** 34 * Those properties can be customized 35 * from markdown pages using heading components 36 * from `plugins/Headings.tsx` 37 */ 38export type AdditionalProps = { 39 hideInSidebar?: boolean; 40 sidebarTitle?: string; 41 sidebarDepth?: number; 42 sidebarType?: HeadingType; 43}; 44 45type Metadata = Partial<PageMetadata> & { headings: (RemarkHeading & { _processed?: boolean })[] }; 46 47/** 48 * Single heading entry 49 */ 50export type Heading = { 51 title: string; 52 slug: string; 53 level: number; 54 type: HeadingType; 55 ref: React.RefObject<any>; 56 metadata?: ElementType<Metadata['headings']>; 57}; 58 59/** 60 * Manages heading entries. Each entry corresponds to one markdown heading with specified level (#, ##, ### etc) 61 * 62 * This class uses Slugger instance to generate and manage unique slugs 63 */ 64export class HeadingManager { 65 private slugger: GithubSlugger; 66 private _headings: Heading[]; 67 private readonly _meta: Metadata; 68 private readonly _maxNestingLevel: number; 69 70 public get headings() { 71 return this._headings; 72 } 73 74 public get maxNestingLevel() { 75 return this._maxNestingLevel; 76 } 77 78 public get metadata() { 79 return this._meta; 80 } 81 82 /** 83 * @param slugger A _GithubSlugger_ instance 84 * @param meta Document metadata gathered by `headingsMdPlugin`. 85 */ 86 constructor(slugger: GithubSlugger, meta: Metadata) { 87 this.slugger = slugger; 88 this._meta = meta; 89 this._headings = []; 90 91 const maxHeadingDepth = meta.maxHeadingDepth ?? DEFAULT_NESTING_LIMIT; 92 this._maxNestingLevel = maxHeadingDepth + BASE_HEADING_LEVEL; 93 } 94 95 /** 96 * Creates heading object instance and stores it 97 * @param {string | Object} title Heading display title or `<code/>` element 98 * @param {number|undefined} nestingLevel Override metadata heading nesting level. 99 * @param {*} additionalProps Additional properties passed to heading component 100 * @returns {Object} Newly created heading instance 101 */ 102 addHeading( 103 title: string | object, 104 nestingLevel?: number, 105 additionalProps?: AdditionalProps, 106 id?: string 107 ): Heading { 108 // NOTE (barthap): workaround for complex titles containing both normal text and inline code 109 // changing this needs also change in `headingsMdPlugin.js` to make metadata loading correctly 110 title = Array.isArray(title) ? title.map(Utilities.toString).join(' ') : title; 111 112 const { hideInSidebar, sidebarTitle, sidebarDepth, sidebarType } = additionalProps ?? {}; 113 const levelOverride = sidebarDepth != null ? BASE_HEADING_LEVEL + sidebarDepth : undefined; 114 115 const slug = id ?? Utilities.generateSlug(this.slugger, title); 116 const realTitle = Utilities.toString(title); 117 const meta = this.findMetaForTitle(realTitle); 118 const level = levelOverride ?? nestingLevel ?? meta?.depth ?? BASE_HEADING_LEVEL; 119 const type = sidebarType || (this.isCode(title) ? HeadingType.InlineCode : HeadingType.Text); 120 121 const heading = { 122 title: sidebarTitle ?? realTitle, 123 slug, 124 level, 125 type, 126 ref: React.createRef(), 127 metadata: meta, 128 }; 129 130 // levels out of range are unlisted 131 if (!hideInSidebar && level >= BASE_HEADING_LEVEL && level <= this.maxNestingLevel) { 132 this._headings.push(heading); 133 } 134 135 return heading; 136 } 137 138 /** 139 * Finds MDX-plugin metadata for specified title. Once found, it's marked as processed 140 * and will not be returned again. 141 * @param {string} realTitle Title to find metadata for 142 */ 143 private findMetaForTitle(realTitle: string) { 144 const entry = this._meta.headings.find( 145 heading => heading.title === realTitle && !heading._processed 146 ); 147 if (!entry) { 148 return; 149 } 150 entry._processed = true; 151 return entry; 152 } 153 154 /** 155 * Checks if header title is an inline code block. 156 * @param {any} title Heading object to check 157 * @returns {boolean} true if header is a code block 158 */ 159 private isCode(title: any): boolean { 160 if (!title.props) { 161 return false; 162 } 163 const { name, originalType, mdxType } = title.props; 164 return [name, originalType, mdxType].some(it => it === HeadingType.InlineCode); 165 } 166} 167