Skip to content

Commit dc01b24

Browse files
authored
Show capability subsections in page navigation (#117)
1 parent 34e50e6 commit dc01b24

4 files changed

Lines changed: 211 additions & 15 deletions

File tree

‎docs-app/components/OnThisPage.tsx‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,44 @@ export function OnThisPage({ headings }: OnThisPageProps) {
99
return null;
1010
}
1111

12+
const sections: { heading: PageHeading; children: PageHeading[] }[] = [];
13+
for (const heading of headings) {
14+
if (heading.level === 3 && sections.length > 0) {
15+
sections[sections.length - 1].children.push(heading);
16+
} else {
17+
sections.push({ heading, children: [] });
18+
}
19+
}
20+
1221
return (
1322
<nav aria-label="On this page" className="hidden w-48 shrink-0 xl:block">
1423
<div className="xl:sticky xl:top-[calc(var(--header-height)+2rem)] xl:-m-1 xl:max-h-[calc(100vh-var(--header-height)-4rem)] xl:overflow-y-auto xl:p-1 xl:[scrollbar-gutter:stable]">
1524
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
1625
On this page
1726
</p>
18-
<ul className="flex flex-col gap-1.5 border-l border-[var(--border-color)] pl-3">
19-
{headings.map((heading) => (
20-
<li key={heading.anchor}>
27+
<ul className="flex min-w-0 flex-col gap-1.5 border-l border-[var(--border-color)] pl-3">
28+
{sections.map(({ heading, children }) => (
29+
<li key={heading.anchor} className="min-w-0">
2130
<a
2231
href={`#${heading.anchor}`}
23-
className="text-sm text-[var(--text-secondary)] no-underline hover:text-[var(--text-primary)] hover:underline"
32+
className="block break-words text-sm text-[var(--text-secondary)] no-underline hover:text-[var(--text-primary)] hover:underline"
2433
>
2534
{heading.title}
2635
</a>
36+
{children.length > 0 ? (
37+
<ul className="mt-1 flex min-w-0 flex-col gap-1 border-l border-[var(--border-color)] pl-3">
38+
{children.map((child) => (
39+
<li key={child.anchor} className="min-w-0">
40+
<a
41+
href={`#${child.anchor}`}
42+
className="block break-words text-sm text-[var(--text-secondary)] no-underline hover:text-[var(--text-primary)] hover:underline"
43+
>
44+
{child.title}
45+
</a>
46+
</li>
47+
))}
48+
</ul>
49+
) : null}
2750
</li>
2851
))}
2952
</ul>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import assert from 'node:assert/strict';
2+
import { execFileSync } from 'node:child_process';
3+
import { createRequire } from 'node:module';
4+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import test from 'node:test';
8+
import React from 'react';
9+
import { renderToStaticMarkup } from 'react-dom/server';
10+
11+
const libDirectory = path.dirname(fileURLToPath(import.meta.url));
12+
const appDirectory = path.resolve(libDirectory, '..');
13+
const buildDirectory = mkdtempSync(path.join(appDirectory, '.test-navigation-'));
14+
let getPageHeadings;
15+
let getMigrationReleasePage;
16+
let OnThisPage;
17+
18+
try {
19+
// Compile only the modules under test so Node can exercise the actual TS and
20+
// TSX implementation without adding a separate test stack.
21+
const outputDirectory = path.join(buildDirectory, 'out');
22+
const configPath = path.join(buildDirectory, 'tsconfig.json');
23+
writeFileSync(configPath, JSON.stringify({
24+
extends: path.join(appDirectory, 'tsconfig.json'),
25+
compilerOptions: {
26+
incremental: false,
27+
module: 'Node16',
28+
moduleResolution: 'Node16',
29+
noEmit: false,
30+
outDir: outputDirectory,
31+
rootDir: appDirectory,
32+
typeRoots: [path.join(appDirectory, 'node_modules', '@types')],
33+
types: ['node', 'react', 'react-dom'],
34+
},
35+
files: [
36+
path.join(appDirectory, 'lib', 'content.ts'),
37+
path.join(appDirectory, 'components', 'OnThisPage.tsx'),
38+
],
39+
include: [],
40+
}));
41+
execFileSync('tsc', ['--project', configPath], { cwd: appDirectory, stdio: 'inherit' });
42+
43+
const requireFromApp = createRequire(import.meta.url);
44+
({ getPageHeadings, getMigrationReleasePage } = requireFromApp(
45+
path.join(outputDirectory, 'lib', 'content.js'),
46+
));
47+
({ OnThisPage } = requireFromApp(
48+
path.join(outputDirectory, 'components', 'OnThisPage.js'),
49+
));
50+
} finally {
51+
rmSync(buildDirectory, { recursive: true, force: true });
52+
}
53+
54+
test('getPageHeadings keeps H2-only pages unchanged', () => {
55+
assert.deepEqual(getPageHeadings('# Page title\n\n## Overview\n\n## API Reference'), [
56+
{ title: 'Overview', anchor: 'overview', level: 2 },
57+
{ title: 'API Reference', anchor: 'api-reference', level: 2 },
58+
]);
59+
});
60+
61+
test('getPageHeadings includes ordered H3 headings only under Capabilities', () => {
62+
const content = [
63+
'## Overview',
64+
'### Intro detail',
65+
'## Capabilities',
66+
'### Multiple Series',
67+
'### Fixed Value Range',
68+
'## API Reference',
69+
'### Internal Detail',
70+
].join('\n');
71+
72+
assert.deepEqual(getPageHeadings(content), [
73+
{ title: 'Overview', anchor: 'overview', level: 2 },
74+
{ title: 'Capabilities', anchor: 'capabilities', level: 2 },
75+
{ title: 'Multiple Series', anchor: 'multiple-series', level: 3 },
76+
{ title: 'Fixed Value Range', anchor: 'fixed-value-range', level: 3 },
77+
{ title: 'API Reference', anchor: 'api-reference', level: 2 },
78+
]);
79+
});
80+
81+
test('getPageHeadings ignores heading-like lines inside fenced code blocks', () => {
82+
const content = [
83+
'## Capabilities',
84+
'```markdown',
85+
'## Fake section',
86+
'### Fake capability',
87+
'```',
88+
'### Real capability',
89+
'~~~',
90+
'### Also code',
91+
'~~~',
92+
].join('\n');
93+
94+
assert.deepEqual(getPageHeadings(content), [
95+
{ title: 'Capabilities', anchor: 'capabilities', level: 2 },
96+
{ title: 'Real capability', anchor: 'real-capability', level: 3 },
97+
]);
98+
});
99+
100+
test('migration-style repeated H3 headings stay excluded while H2 links remain', () => {
101+
const content = [
102+
'## charts-core',
103+
'### Before',
104+
'### After',
105+
'## charts-ui',
106+
'### Before',
107+
'### After',
108+
].join('\n');
109+
110+
assert.deepEqual(getPageHeadings(content), [
111+
{ title: 'charts-core', anchor: 'charts-core', level: 2 },
112+
{ title: 'charts-ui', anchor: 'charts-ui', level: 2 },
113+
]);
114+
});
115+
116+
test('generated migration release pages continue excluding demoted H3 headings', () => {
117+
const migrationPage = getMigrationReleasePage('2.3.0', '2.3.0');
118+
assert.ok(migrationPage, 'expected the 2.3.0 migration page to exist');
119+
assert.match(migrationPage.content, /^### /m, 'expected migration subsections to use H3');
120+
121+
assert.deepEqual(getPageHeadings(migrationPage.content), []);
122+
});
123+
124+
test('OnThisPage nests capability links in order with their generated anchors', () => {
125+
const headings = getPageHeadings([
126+
'## Overview',
127+
'## Capabilities',
128+
'### Multiple Series',
129+
'### Fixed Value Range',
130+
'## API Reference',
131+
].join('\n'));
132+
133+
const html = renderToStaticMarkup(React.createElement(OnThisPage, { headings }));
134+
const overview = html.indexOf('href="#overview"');
135+
const capabilities = html.indexOf('href="#capabilities"');
136+
const multipleSeries = html.indexOf('href="#multiple-series"');
137+
const fixedRange = html.indexOf('href="#fixed-value-range"');
138+
const apiReference = html.indexOf('href="#api-reference"');
139+
140+
assert.ok(overview < capabilities);
141+
assert.ok(capabilities < multipleSeries);
142+
assert.ok(multipleSeries < fixedRange);
143+
assert.ok(fixedRange < apiReference);
144+
assert.match(html, /<li class="min-w-0"><a href="#capabilities"[^>]*>Capabilities<\/a><ul/);
145+
assert.match(html, /href="#multiple-series"[^>]*>Multiple Series<\/a>/);
146+
assert.match(html, /href="#fixed-value-range"[^>]*>Fixed Value Range<\/a>/);
147+
});
148+
149+
test('OnThisPage renders an H2-only page without an empty child list', () => {
150+
const headings = getPageHeadings('## Overview\n\n## API Reference');
151+
const html = renderToStaticMarkup(React.createElement(OnThisPage, { headings }));
152+
153+
assert.equal((html.match(/<ul/g) ?? []).length, 1);
154+
assert.match(html, /href="#overview"/);
155+
assert.match(html, /href="#api-reference"/);
156+
});
157+
158+
test('OnThisPage wraps long labels within the existing sidebar width', () => {
159+
const headings = getPageHeadings('## Capabilities\n### A Capability Heading With A Long Name');
160+
const html = renderToStaticMarkup(React.createElement(OnThisPage, { headings }));
161+
162+
assert.match(html, /break-words/);
163+
assert.match(html, /href="#a-capability-heading-with-a-long-name"/);
164+
});

‎docs-app/lib/content.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -533,30 +533,39 @@ function linesOutsideCodeFences(content: string): string[] {
533533
export interface PageHeading {
534534
title: string;
535535
anchor: string;
536+
level?: 2 | 3;
536537
}
537538

538539
/**
539-
* H2 headings in a page's own markdown body, for an "on this page" list.
540+
* H2 headings and capability H3s in a page's own markdown body, for an
541+
* "on this page" list.
540542
* Anchors are generated the same way (plain slugifyHeading, no dedup) that
541543
* the markdown renderer generates heading ids, so links here always match.
542544
*
543-
* Limited to H2 rather than H2/H3: migration release pages concatenate every
544-
* migration doc for that release, each demoted by one level, so H3 there is
545-
* "Before"/"After" repeated per doc — same anchor, no way to tell them apart
546-
* without slug-level dedup. H2 (each doc's own, unique, title) stays safe,
547-
* and no current page uses H3 for anything else.
545+
* H3s are limited to Capabilities to keep the list focused and avoid clutter
546+
* on pages with many minor subsections. Migration pages concatenate multiple
547+
* documents with repeated H3 headings, so those remain excluded.
548548
*/
549549
export function getPageHeadings(content: string): PageHeading[] {
550550
const headings: PageHeading[] = [];
551+
let inCapabilities = false;
551552

552553
for (const rawLine of linesOutsideCodeFences(content)) {
553-
const match = rawLine.match(/^##\s+(.+)$/);
554-
if (!match) {
554+
const h2Match = rawLine.match(/^##\s+(.+)$/);
555+
if (h2Match) {
556+
const title = h2Match[1].trim().replace(/\s+#+\s*$/, '');
557+
inCapabilities = title.toLowerCase() === 'capabilities';
558+
headings.push({ title, anchor: slugifyHeading(title), level: 2 });
555559
continue;
556560
}
557561

558-
const title = match[1].trim().replace(/\s+#+\s*$/, '');
559-
headings.push({ title, anchor: slugifyHeading(title) });
562+
if (inCapabilities) {
563+
const h3Match = rawLine.match(/^###\s+(.+)$/);
564+
if (h3Match) {
565+
const title = h3Match[1].trim().replace(/\s+#+\s*$/, '');
566+
headings.push({ title, anchor: slugifyHeading(title), level: 3 });
567+
}
568+
}
560569
}
561570

562571
return headings;

‎docs-app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"dev:turbo": "next dev",
99
"build": "next build",
1010
"start": "next start",
11-
"test": "node --test lib/remark-wiki-example-markers.test.mjs",
11+
"test": "node --test lib/*.test.mjs",
1212
"lint": "eslint"
1313
},
1414
"dependencies": {

0 commit comments

Comments
 (0)