Skip to content

Commit fcb5f7d

Browse files
authored
fix(toc): prevent misnamed mdx components from breaking TOC (#1242)
| [![PR App][icn]][demo] | Fix CX-2543 | | :--------------------: | :---------: | ## 🧰 Changes Discovered through [this ticket](https://linear.app/readme-io/issue/CX-2543/tabapay-toc-does-not-respect-headers-nesting) that the TOC generated by the MDX renderer would become broken with miscalculated nesting depths and exposing `h3`s or other heading depths that shouldn't be included. This happened when a jsx element was embedded inside the MDX body and the custom component name did not match the exported jsx elements. For example: <img width="2130" height="1016" alt="image" src="https://github.com/user-attachments/assets/27d9cc8e-cfdd-4b9c-bd55-968df40624a8" /> I'm not entirely sure whether this is an edge-case we should be guarding against from inside our custom component editor. Like, adding validation to ensure that the component name matches what's being exported from it. My gut tells me it'd be really hard to enforce that. So, the fix (for now) is to simply take this edge-case into account and add protections for the cases where this happens. When a component name doesn't match the jsx element name, it previously was making its way into the `plugin/toc` flow. But b/c it has no `tagName`, the `getDepth()` calculation would break and return a `NaN` instead of a valid integer. This would break the nesting logic in `toctoHast()` and cause all headers to render at the first level. Fixed this by: 1. filtering away these mismatched components that make it through 1. additionally updating the `getDepth()` function to be more fail-proof and handle the case where `tagName` is undefined | before | after | |--------|--------| | <img width="246" height="544" alt="image" src="https://github.com/user-attachments/assets/d1fe2919-d0be-4c55-b829-44d89e035a6c" /> | <img width="218" height="396" alt="image" src="https://github.com/user-attachments/assets/53c6a079-7c41-4450-a428-a37fe5f87512" /> | ## 🧬 QA & Testing Tests should pass - [Broken on production][prod]. - [Working in this PR app][demo]. [demo]: https://markdown-pr-PR_NUMBER.herokuapp.com [prod]: https://SUBDOMAIN.readme.io [icn]: https://user-images.githubusercontent.com/886627/160426047-1bee9488-305a-4145-bb2b-09d8b757d38a.svg
1 parent ce55fd3 commit fcb5f7d

File tree

2 files changed

+59
-3
lines changed

2 files changed

+59
-3
lines changed

__tests__/plugins/toc.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,40 @@ export const toc = [
175175
</ul></li></ul></nav>"
176176
`);
177177
});
178+
179+
it('preserves nesting when component names differ from exported elements', () => {
180+
const md = `
181+
# Title
182+
183+
## SubHeading
184+
185+
<Comp>
186+
First
187+
</Comp>
188+
`;
189+
190+
const components = {
191+
Comp: 'export const Comp = ({ children }) => { return children; }',
192+
};
193+
194+
const compModule = run(compile(components.Comp));
195+
const { Toc } = run(compile(md, { components }), {
196+
components: {
197+
// 👇🏼 this is what we're guarding against
198+
CompDoesNotMatchExportedModule: compModule,
199+
},
200+
});
201+
202+
const html = renderToString(<Toc />);
203+
expect(html).toMatchInlineSnapshot(`
204+
"<nav aria-label="Table of contents" role="navigation"><ul class="toc-list"><li><a class="tocHeader" href="#"><i class="icon icon-text-align-left"></i>Table of Contents</a></li><li class="toc-children"><ul>
205+
<li><a href="#title">Title</a></li>
206+
<li>
207+
<ul>
208+
<li><a href="#subheading">SubHeading</a></li>
209+
</ul>
210+
</li>
211+
</ul></li></ul></nav>"
212+
`);
213+
});
178214
});

processor/plugin/toc.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,31 @@ export const rehypeToc = ({ components = {} }: Options): Transformer<Root, Root>
5858
};
5959

6060
const MAX_DEPTH = 2;
61-
const getDepth = (el: HastHeading) => parseInt(el.tagName?.match(/^h(\d)/)[1], 10);
61+
62+
/**
63+
* Get the depth of a heading element based on its tag name.
64+
*
65+
* ⚠️ Be extra defensive to non-heading elements somehow making it here. This
66+
* should not happen, but if it does, we avoid breaking TOC depth calculations
67+
* by returning `Infinity`, thus removing them from depth considerations.
68+
* @link https://linear.app/readme-io/issue/CX-2543/tabapay-toc-does-not-respect-headers-nesting
69+
*
70+
* @example
71+
* getDepth({ tagName: 'h1' }) // 1
72+
* getDepth({ tagName: 'h2' }) // 2
73+
*/
74+
const getDepth = (el: HastHeading) => {
75+
if (!el.tagName) return Infinity;
76+
return parseInt(el.tagName?.match(/^h(\d)/)[1], 10);
77+
};
6278

6379
/*
6480
* `tocToHast` consumes the list generated by `rehypeToc` and produces a hast
6581
* of nested lists to be rendered as a table of contents.
6682
*/
6783
const tocToHast = (headings: HastHeading[] = []): TocList => {
68-
const min = Math.min(...headings.map(getDepth));
84+
const headingDepths = headings.map(getDepth);
85+
const min = Math.min(...headingDepths);
6986
const ast = h('ul') as TocList;
7087
const stack: TocList[] = [ast];
7188

@@ -105,7 +122,10 @@ export const tocHastToMdx = (toc: IndexableElements[] | undefined, components: R
105122
if (typeof toc === 'undefined') return '';
106123

107124
const injected = toc.flatMap(node => {
108-
return node.type === 'mdxJsxFlowElement' && node.name in components ? components[node.name] || [] : node;
125+
if (node.type === 'mdxJsxFlowElement') {
126+
return components[node.name] || [];
127+
}
128+
return node;
109129
});
110130

111131
const tocHast = tocToHast(injected as HastHeading[]);

0 commit comments

Comments
 (0)