Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions __tests__/plugins/toc.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,40 @@ export const toc = [
</ul></li></ul></nav>"
`);
});

it('preserves nesting when component names differ from exported elements', () => {
const md = `
# Title

## SubHeading

<Comp>
First
</Comp>
`;

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,
Comment on lines +197 to +198
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the edge-case that was causing the malformed TOCs in some projects.

when they have custom components that exports a JSX element that differs from the saved file name, this is what happens. the components hash keys are something different than the exported elements.

},
});

const html = renderToString(<Toc />);
expect(html).toMatchInlineSnapshot(`
"<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>
<li><a href="#title">Title</a></li>
<li>
<ul>
<li><a href="#subheading">SubHeading</a></li>
</ul>
Comment on lines +207 to +209
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when everything works properly, the subheading should be indented one level like this

</li>
</ul></li></ul></nav>"
`);
});
});
26 changes: 23 additions & 3 deletions processor/plugin/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,31 @@ export const rehypeToc = ({ components = {} }: Options): Transformer<Root, Root>
};

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];

Expand Down Expand Up @@ -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] || [];
}
Comment on lines +125 to +127
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the exported JSX elements are the nodes (i.e. node.name) and the components is a hash of custom component files. previously, it was matching node.name in components which would be false when the filename didn't match the exported module. otherwise, it'd return the mdxJsxFlowElement node, which would then make its way into the getDepth() function above on L74.

now, if the node type is an mdx jsx element, we'll return an empty array instead, which essentially filters it out completely. i think this is the desired result, but i put in a patch in the getDepth() function just in case something other than a heading element makes it in

return node;
});

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