xref: /expo/docs/common/headingManager.ts (revision a20d767e)
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