diff --git a/__tests__/plugins/toc.test.tsx b/__tests__/plugins/toc.test.tsx index a6367bd29..1b044abe6 100644 --- a/__tests__/plugins/toc.test.tsx +++ b/__tests__/plugins/toc.test.tsx @@ -175,4 +175,40 @@ export const toc = [ " `); }); + + it('preserves nesting when component names differ from exported elements', () => { + const md = ` +# Title + +## SubHeading + + + First + +`; + + const components = { + Comp: 'export const Comp = ({ children }) => { return children; }', + }; + + const compModule = run(compile(components.Comp)); + const { Toc } = run(compile(md, { components }), { + components: { + // 👇🏼 this is what we're guarding against + CompDoesNotMatchExportedModule: compModule, + }, + }); + + const html = renderToString(); + expect(html).toMatchInlineSnapshot(` + "" + `); + }); }); diff --git a/processor/plugin/toc.ts b/processor/plugin/toc.ts index b6d947d58..134a51dc4 100644 --- a/processor/plugin/toc.ts +++ b/processor/plugin/toc.ts @@ -58,14 +58,31 @@ export const rehypeToc = ({ components = {} }: Options): Transformer }; const MAX_DEPTH = 2; -const getDepth = (el: HastHeading) => parseInt(el.tagName?.match(/^h(\d)/)[1], 10); + +/** + * Get the depth of a heading element based on its tag name. + * + * ⚠️ Be extra defensive to non-heading elements somehow making it here. This + * should not happen, but if it does, we avoid breaking TOC depth calculations + * by returning `Infinity`, thus removing them from depth considerations. + * @link https://linear.app/readme-io/issue/CX-2543/tabapay-toc-does-not-respect-headers-nesting + * + * @example + * getDepth({ tagName: 'h1' }) // 1 + * getDepth({ tagName: 'h2' }) // 2 + */ +const getDepth = (el: HastHeading) => { + if (!el.tagName) return Infinity; + return parseInt(el.tagName?.match(/^h(\d)/)[1], 10); +}; /* * `tocToHast` consumes the list generated by `rehypeToc` and produces a hast * of nested lists to be rendered as a table of contents. */ const tocToHast = (headings: HastHeading[] = []): TocList => { - const min = Math.min(...headings.map(getDepth)); + const headingDepths = headings.map(getDepth); + const min = Math.min(...headingDepths); const ast = h('ul') as TocList; const stack: TocList[] = [ast]; @@ -105,7 +122,10 @@ export const tocHastToMdx = (toc: IndexableElements[] | undefined, components: R if (typeof toc === 'undefined') return ''; const injected = toc.flatMap(node => { - return node.type === 'mdxJsxFlowElement' && node.name in components ? components[node.name] || [] : node; + if (node.type === 'mdxJsxFlowElement') { + return components[node.name] || []; + } + return node; }); const tocHast = tocToHast(injected as HastHeading[]);