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 tags?: string[]; 44}; 45 46type Metadata = Partial<PageMetadata> & { headings: (RemarkHeading & { _processed?: boolean })[] }; 47 48/** 49 * Single heading entry 50 */ 51export type Heading = { 52 title: string; 53 slug: string; 54 level: number; 55 type: HeadingType; 56 ref: React.RefObject<any>; 57 tags?: string[]; 58 metadata?: ElementType<Metadata['headings']>; 59}; 60 61/** 62 * Manages heading entries. Each entry corresponds to one markdown heading with specified level (#, ##, ### etc) 63 * 64 * This class uses Slugger instance to generate and manage unique slugs 65 */ 66export class HeadingManager { 67 private slugger: GithubSlugger; 68 private _headings: Heading[]; 69 private readonly _meta: Metadata; 70 private readonly _maxNestingLevel: number; 71 72 public get headings() { 73 return this._headings; 74 } 75 76 public get maxNestingLevel() { 77 return this._maxNestingLevel; 78 } 79 80 public get metadata() { 81 return this._meta; 82 } 83 84 /** 85 * @param slugger A _GithubSlugger_ instance 86 * @param meta Document metadata gathered by `headingsMdPlugin`. 87 */ 88 constructor(slugger: GithubSlugger, meta: Metadata) { 89 this.slugger = slugger; 90 this._meta = meta; 91 this._headings = []; 92 93 const maxHeadingDepth = meta.maxHeadingDepth ?? DEFAULT_NESTING_LIMIT; 94 this._maxNestingLevel = maxHeadingDepth + BASE_HEADING_LEVEL; 95 } 96 97 /** 98 * Creates heading object instance and stores it 99 * @param {string | Object} title Heading display title or `<code/>` element 100 * @param {number|undefined} nestingLevel Override metadata heading nesting level. 101 * @param {*} additionalProps Additional properties passed to heading component 102 * @returns {Object} Newly created heading instance 103 */ 104 addHeading( 105 title: React.ReactNode, 106 nestingLevel?: number, 107 additionalProps?: AdditionalProps, 108 id?: string 109 ): Heading { 110 // NOTE (barthap): workaround for complex titles containing both normal text and inline code 111 // changing this needs also change in `headingsMdPlugin.js` to make metadata loading correctly 112 title = Array.isArray(title) ? title.map(Utilities.toString).join(' ') : title; 113 114 const { hideInSidebar, sidebarTitle, sidebarDepth, sidebarType, tags } = additionalProps ?? {}; 115 const levelOverride = sidebarDepth != null ? BASE_HEADING_LEVEL + sidebarDepth : undefined; 116 117 const slug = id ?? Utilities.generateSlug(this.slugger, title); 118 const realTitle = Utilities.toString(title); 119 const meta = this.findMetaForTitle(realTitle); 120 const level = levelOverride ?? nestingLevel ?? meta?.depth ?? BASE_HEADING_LEVEL; 121 const type = sidebarType || (this.isCode(title) ? HeadingType.InlineCode : HeadingType.Text); 122 123 const heading = { 124 title: sidebarTitle ?? realTitle, 125 slug, 126 level, 127 type, 128 tags, 129 ref: React.createRef(), 130 metadata: meta, 131 }; 132 133 // levels out of range are unlisted 134 if (!hideInSidebar && level >= BASE_HEADING_LEVEL && level <= this.maxNestingLevel) { 135 this._headings.push(heading); 136 } 137 138 return heading; 139 } 140 141 /** 142 * Finds MDX-plugin metadata for specified title. Once found, it's marked as processed 143 * and will not be returned again. 144 * @param {string} realTitle Title to find metadata for 145 */ 146 private findMetaForTitle(realTitle: string) { 147 const entry = this._meta.headings.find( 148 heading => heading.title === realTitle && !heading._processed 149 ); 150 if (!entry) { 151 return; 152 } 153 entry._processed = true; 154 return entry; 155 } 156 157 /** 158 * Checks if header title is an inline code block. 159 * @param {any} title Heading object to check 160 * @returns {boolean} true if header is a code block 161 */ 162 private isCode(title: any): boolean { 163 if (!title.props) { 164 return false; 165 } 166 const { name, originalType, mdxType } = title.props; 167 return [name, originalType, mdxType].some(it => it === HeadingType.InlineCode); 168 } 169} 170