Skip to content

Commit 474b1e9

Browse files
gaearonclaude
andcommitted
Match the Pages Router table of contents
- Only collect top-level headings (run the extractor before the MaxWidth wrapper). Headings nested in <Note> etc. were never in the TOC before. - Compile TOC entries from the already-processed mdast heading children instead of re-parsing the heading source. Re-parsing dropped smartypants (straight quotes on 43 pages) and turned headings like "1. Install" into an <ol> inside the TOC link. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 56aa6db commit 474b1e9

2 files changed

Lines changed: 54 additions & 28 deletions

File tree

src/utils/compileMDX.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77

88
import {Fragment} from 'react';
99
import type {ReactNode} from 'react';
10-
import {compile, run} from '@mdx-js/mdx';
10+
import {compile, createProcessor, run} from '@mdx-js/mdx';
1111
import * as runtime from 'react/jsx-runtime';
1212
import matter from 'gray-matter';
1313
import remarkGfm from 'remark-gfm';
1414
import remarkFrontmatter from 'remark-frontmatter';
15+
import type {Root} from 'mdast';
1516
import {remarkPlugins} from '../../plugins/markdownToHtml';
1617
import {createMDXComponents} from 'components/MDX/MDXComponents';
1718
import type {LanguageItem} from 'components/MDX/LanguagesContext';
@@ -43,24 +44,33 @@ function compileOptions() {
4344
...remarkPlugins,
4445
remarkGfm,
4546
remarkFrontmatter,
46-
MaxWidthWrapperPlugin,
47+
// Order matters: the TOC only includes top-level headings, so it has
48+
// to be collected before they get wrapped into <MaxWidth> elements.
4749
TOCExtractorPlugin,
50+
MaxWidthWrapperPlugin,
4851
],
4952
rehypePlugins: [MetaAttributesPlugin],
5053
outputFormat: 'function-body' as const,
5154
};
5255
}
5356

57+
// Compiles a heading's already-processed mdast children (so typography and
58+
// inline components match the heading itself) without re-parsing source
59+
// text, which would turn e.g. "1. Install" into an ordered list.
60+
const tocProcessor = createProcessor({outputFormat: 'function-body'});
61+
5462
async function compileToc(toc: ExtractedTocItem[]) {
5563
return Promise.all(
56-
toc.map(async ({source, ...item}): Promise<CompiledTocItem> => {
57-
if (!source) {
64+
toc.map(async ({children, ...item}): Promise<CompiledTocItem> => {
65+
if (!children) {
5866
return item;
5967
}
60-
const code = await compile(source, {
61-
remarkPlugins: [remarkGfm],
62-
outputFormat: 'function-body',
63-
});
68+
const tree: Root = {
69+
type: 'root',
70+
children: [{type: 'paragraph', children}],
71+
};
72+
const transformed = await tocProcessor.run(tree);
73+
const code = tocProcessor.stringify(transformed as any);
6474
return {...item, code: String(code)};
6575
})
6676
);

src/utils/mdx/TOCExtractorPlugin.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,61 @@
1-
import type {Root} from 'mdast';
1+
import type {PhrasingContent, Root} from 'mdast';
22
import type {VFile} from 'vfile';
3-
import visit from 'unist-util-visit';
43

54
export interface ExtractedTocItem {
65
url: string;
76
depth: number;
8-
source?: string;
7+
/**
8+
* Heading content as already-processed mdast (custom IDs, smartypants,
9+
* etc. applied). Compiled separately by compileMDX so the TOC can render
10+
* inline code and badges. Mutually exclusive with `text`.
11+
*/
12+
children?: PhrasingContent[];
913
text?: string;
1014
}
1115

12-
function sourceForChildren(
13-
source: string,
14-
children: Array<{
15-
position?: {start: {offset?: number}; end: {offset?: number}};
16-
}>
17-
) {
18-
const start = children[0]?.position?.start.offset;
19-
const end = children.at(-1)?.position?.end.offset;
20-
return start == null || end == null ? '' : source.slice(start, end).trim();
16+
// Heading children without the trailing custom ID comment expression
17+
// (`{/*custom-id*/}` in the source) and any whitespace that preceded it.
18+
function headingContent(children: PhrasingContent[]): PhrasingContent[] {
19+
const content = [...children];
20+
const last = content[content.length - 1] as {type: string} | undefined;
21+
if (last?.type === 'mdxTextExpression') {
22+
content.pop();
23+
}
24+
const lastText = content[content.length - 1];
25+
if (lastText?.type === 'text') {
26+
content[content.length - 1] = {
27+
...lastText,
28+
value: lastText.value.replace(/\s+$/, ''),
29+
};
30+
}
31+
return content;
2132
}
2233

34+
/**
35+
* Collects the table of contents from the top-level nodes of the document,
36+
* matching the Pages Router build: headings nested inside other components
37+
* (<Note>, <DeepDive>, ...) are not part of the TOC. Must run before
38+
* MaxWidthWrapperPlugin, which moves those nodes into <MaxWidth> wrappers.
39+
*/
2340
export function TOCExtractorPlugin({maxDepth = 3} = {}) {
2441
return (tree: Root, file: VFile) => {
2542
const toc: ExtractedTocItem[] = [];
26-
const source = String(file.value);
2743

28-
visit(tree, (node: any) => {
29-
if (node.type === 'heading' && node.depth <= maxDepth) {
44+
for (const node of tree.children as any[]) {
45+
if (node.type === 'heading') {
3046
const id = node.data?.hProperties?.id;
31-
if (id) {
47+
if (node.depth <= maxDepth && id) {
3248
toc.push({
3349
url: `#${id}`,
3450
depth: node.depth,
35-
source: sourceForChildren(source, node.children),
51+
children: headingContent(node.children),
3652
});
3753
}
38-
return;
54+
continue;
3955
}
4056

4157
if (node.type !== 'mdxJsxFlowElement') {
42-
return;
58+
continue;
4359
}
4460

4561
if (node.name === 'Challenges' || node.name === 'Recap') {
@@ -59,7 +75,7 @@ export function TOCExtractorPlugin({maxDepth = 3} = {}) {
5975
const permalink = String(attributes.get('permalink') ?? 'team-member');
6076
toc.push({url: `#${permalink}`, depth: 3, text: name});
6177
}
62-
});
78+
}
6379

6480
if (toc.length > 0) {
6581
toc.unshift({url: '#', depth: 2, text: 'Overview'});

0 commit comments

Comments
 (0)