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