Skip to content

Commit 4515bd4

Browse files
committed
feat(typedoc): add extractToc remark plugin for mobile table of contents
Add a remark plugin that extracts structured TOC data from markdown headings and injects it into YAML frontmatter. This enables the docs rendering engine to access pre-computed TOC data for rendering an expandable mobile table of contents. The plugin: - Extracts h2-h4 headings with configurable depth range - Supports custom heading IDs from MDX annotations ({{ id: 'custom' }}) - Generates URL-safe slugs for anchor links - Injects structured toc array into frontmatter Ref: DOCS-11605
1 parent e85de19 commit 4515bd4

4 files changed

Lines changed: 758 additions & 0 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { remark } from 'remark';
2+
import remarkFrontmatter from 'remark-frontmatter';
3+
import remarkMdx from 'remark-mdx';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { buildTocFromHeadings, extractToc, slugify } from '../extract-toc.mjs';
7+
8+
describe('slugify', () => {
9+
it('converts text to a URL-safe slug', () => {
10+
expect(slugify('Getting Started')).toBe('getting-started');
11+
});
12+
13+
it('handles special characters', () => {
14+
expect(slugify('`useAuth()` hook')).toBe('useauth-hook');
15+
});
16+
17+
it('collapses multiple hyphens', () => {
18+
expect(slugify('foo---bar')).toBe('foo-bar');
19+
});
20+
21+
it('trims leading and trailing hyphens', () => {
22+
expect(slugify('--hello-world--')).toBe('hello-world');
23+
});
24+
25+
it('handles empty string', () => {
26+
expect(slugify('')).toBe('');
27+
});
28+
});
29+
30+
describe('buildTocFromHeadings', () => {
31+
it('extracts headings at levels 2-4', () => {
32+
const headings = [
33+
{ node: mockTextNode('Overview'), depth: 2 },
34+
{ node: mockTextNode('Installation'), depth: 3 },
35+
{ node: mockTextNode('Deep Detail'), depth: 4 },
36+
];
37+
38+
const toc = buildTocFromHeadings(headings);
39+
40+
expect(toc).toEqual([
41+
{ title: 'Overview', slug: 'overview', level: 2 },
42+
{ title: 'Installation', slug: 'installation', level: 3 },
43+
{ title: 'Deep Detail', slug: 'deep-detail', level: 4 },
44+
]);
45+
});
46+
47+
it('filters out headings outside the 2-4 range', () => {
48+
const headings = [
49+
{ node: mockTextNode('Page Title'), depth: 1 },
50+
{ node: mockTextNode('Section'), depth: 2 },
51+
{ node: mockTextNode('Too Deep'), depth: 5 },
52+
];
53+
54+
const toc = buildTocFromHeadings(headings);
55+
56+
expect(toc).toEqual([{ title: 'Section', slug: 'section', level: 2 }]);
57+
});
58+
59+
it('filters out empty headings', () => {
60+
const headings = [
61+
{ node: mockTextNode(''), depth: 2 },
62+
{ node: mockTextNode('Valid'), depth: 2 },
63+
];
64+
65+
const toc = buildTocFromHeadings(headings);
66+
67+
expect(toc).toEqual([{ title: 'Valid', slug: 'valid', level: 2 }]);
68+
});
69+
70+
it('uses custom heading IDs from MDX annotations', () => {
71+
const headings = [
72+
{
73+
node: {
74+
type: 'heading',
75+
children: [
76+
{ type: 'text', value: 'My Heading' },
77+
{
78+
type: 'mdxTextExpression',
79+
data: {
80+
estree: {
81+
body: [
82+
{
83+
type: 'ExpressionStatement',
84+
expression: {
85+
properties: [
86+
{
87+
key: { name: 'id' },
88+
value: { value: 'custom-anchor' },
89+
},
90+
],
91+
},
92+
},
93+
],
94+
},
95+
},
96+
},
97+
],
98+
},
99+
depth: 2,
100+
},
101+
];
102+
103+
const toc = buildTocFromHeadings(headings);
104+
105+
expect(toc).toEqual([{ title: 'My Heading', slug: 'custom-anchor', level: 2 }]);
106+
});
107+
});
108+
109+
describe('extractToc remark plugin', () => {
110+
it('adds toc to frontmatter for a document with headings', async () => {
111+
const input = `---
112+
title: Test Page
113+
description: A test page
114+
---
115+
116+
## Getting Started
117+
118+
Some intro text.
119+
120+
### Installation
121+
122+
Install the package.
123+
124+
### Configuration
125+
126+
Configure the package.
127+
128+
## API Reference
129+
130+
API details here.
131+
`;
132+
133+
const result = await remark().use(remarkFrontmatter).use(extractToc()).process(input);
134+
135+
const output = String(result);
136+
137+
expect(output).toContain('toc:');
138+
expect(output).toContain('title: "Getting Started"');
139+
expect(output).toContain('slug: "getting-started"');
140+
expect(output).toContain('level: 2');
141+
expect(output).toContain('title: "Installation"');
142+
expect(output).toContain('slug: "installation"');
143+
expect(output).toContain('level: 3');
144+
expect(output).toContain('title: "Configuration"');
145+
expect(output).toContain('title: "API Reference"');
146+
});
147+
148+
it('does not modify frontmatter when there are no headings', async () => {
149+
const input = `---
150+
title: Empty Page
151+
---
152+
153+
Just some text without headings.
154+
`;
155+
156+
const result = await remark().use(remarkFrontmatter).use(extractToc()).process(input);
157+
158+
const output = String(result);
159+
160+
expect(output).not.toContain('toc:');
161+
});
162+
163+
it('skips h1 headings (only includes h2-h4)', async () => {
164+
const input = `---
165+
title: Test
166+
---
167+
168+
# Main Title
169+
170+
## Section One
171+
172+
### Subsection
173+
174+
#### Detail
175+
`;
176+
177+
const result = await remark().use(remarkFrontmatter).use(extractToc()).process(input);
178+
179+
const output = String(result);
180+
181+
expect(output).toContain('title: "Section One"');
182+
expect(output).toContain('title: "Subsection"');
183+
expect(output).toContain('title: "Detail"');
184+
expect(output).not.toContain('title: "Main Title"');
185+
});
186+
187+
it('respects custom minDepth and maxDepth options', async () => {
188+
const input = `---
189+
title: Test
190+
---
191+
192+
## Level 2
193+
194+
### Level 3
195+
196+
#### Level 4
197+
`;
198+
199+
const result = await remark()
200+
.use(remarkFrontmatter)
201+
.use(extractToc({ minDepth: 2, maxDepth: 3 }))
202+
.process(input);
203+
204+
const output = String(result);
205+
206+
expect(output).toContain('title: "Level 2"');
207+
expect(output).toContain('title: "Level 3"');
208+
expect(output).not.toContain('title: "Level 4"');
209+
});
210+
211+
it('works with MDX content', async () => {
212+
const input = `---
213+
title: MDX Test
214+
---
215+
216+
## Overview
217+
218+
<MyComponent />
219+
220+
### Props
221+
222+
Some props description.
223+
`;
224+
225+
const result = await remark().use(remarkFrontmatter).use(remarkMdx).use(extractToc()).process(input);
226+
227+
const output = String(result);
228+
229+
expect(output).toContain('title: "Overview"');
230+
expect(output).toContain('title: "Props"');
231+
});
232+
233+
it('escapes double quotes in heading titles', async () => {
234+
const input = `---
235+
title: Test
236+
---
237+
238+
## The "best" approach
239+
`;
240+
241+
const result = await remark().use(remarkFrontmatter).use(extractToc()).process(input);
242+
243+
const output = String(result);
244+
245+
expect(output).toContain('title: "The \\"best\\" approach"');
246+
});
247+
248+
it('does not add toc when frontmatter is missing', async () => {
249+
const input = `## Heading Without Frontmatter
250+
251+
Some content.
252+
`;
253+
254+
const result = await remark().use(remarkFrontmatter).use(extractToc()).process(input);
255+
256+
const output = String(result);
257+
258+
expect(output).not.toContain('toc:');
259+
});
260+
});
261+
262+
function mockTextNode(text: string) {
263+
return {
264+
type: 'heading',
265+
children: [{ type: 'text', value: text }],
266+
};
267+
}

0 commit comments

Comments
 (0)