Skip to content

Commit cc4a22e

Browse files
committed
feat(shell-docs): add table of contents (TOC) support
1 parent 1644fb8 commit cc4a22e

5 files changed

Lines changed: 214 additions & 1 deletion

File tree

showcase/shell-docs/src/components/docs-page-view.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SidebarNav } from "@/components/sidebar-nav";
1616
import { SidebarLink } from "@/components/sidebar-link";
1717
import { SidebarFrameworkSelector } from "@/components/sidebar-framework-selector";
1818
import { Snippet } from "@/components/snippet";
19+
import { DocsToc } from "@/components/docs-toc";
1920
import { docsComponents } from "@/lib/mdx-registry";
2021
import {
2122
NavNode,
@@ -26,6 +27,7 @@ import {
2627
loadDoc,
2728
CONTENT_DIR,
2829
} from "@/lib/docs-render";
30+
import { childrenToText, extractHeadings, slugify } from "@/lib/toc";
2931

3032
export interface DocsPageViewProps {
3133
/** Slug path relative to `CONTENT_DIR` (no leading slash). */
@@ -86,6 +88,12 @@ export async function DocsPageView({
8688
const inlined = inlineSnippets(rawContent, slugPath);
8789
const content = convertTablesInJSX(inlined);
8890

91+
// Extract H2/H3 headings for the right-rail TOC. Run on the final
92+
// content (post-snippet-inlining) so a page like threads.mdx whose
93+
// body comes from a shared snippet still surfaces its sections.
94+
const tocHeadings =
95+
hideBody || doc.fm.hideTOC ? [] : extractHeadings(content);
96+
8997
const defaultFramework = frameworkOverride ?? doc.fm.defaultFramework;
9098
const defaultCell = doc.fm.defaultCell;
9199

@@ -226,6 +234,26 @@ export async function DocsPageView({
226234
/>
227235
);
228236
},
237+
// Inject stable IDs on H2/H3 so the right-rail TOC's
238+
// #anchor links resolve. Slugify the child text with the
239+
// same algorithm used by extractHeadings() so IDs line up
240+
// with the TOC entries.
241+
h2: ({
242+
children,
243+
...rest
244+
}: React.HTMLAttributes<HTMLHeadingElement>) => (
245+
<h2 id={slugify(childrenToText(children))} {...rest}>
246+
{children}
247+
</h2>
248+
),
249+
h3: ({
250+
children,
251+
...rest
252+
}: React.HTMLAttributes<HTMLHeadingElement>) => (
253+
<h3 id={slugify(childrenToText(children))} {...rest}>
254+
{children}
255+
</h3>
256+
),
229257
// When rendering under a framework-scoped route, rewrite
230258
// root-relative MDX links (/quickstart, /shared-state, …)
231259
// to the framework-scoped equivalent so clicks never land
@@ -264,6 +292,8 @@ export async function DocsPageView({
264292
return body;
265293
})()}
266294
</main>
295+
296+
<DocsToc headings={tocHeadings} />
267297
</div>
268298
);
269299
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import type { TocHeading } from "@/lib/toc";
5+
6+
export interface DocsTocProps {
7+
headings: TocHeading[];
8+
}
9+
10+
// Right-rail TOC. Hidden below xl (1280px) because the main column
11+
// already fills most of the viewport at laptop widths. Above that, it
12+
// sits beside the content with a scrollspy-highlighted active link.
13+
export function DocsToc({ headings }: DocsTocProps) {
14+
const [activeSlug, setActiveSlug] = useState<string | null>(
15+
headings[0]?.slug ?? null,
16+
);
17+
18+
useEffect(() => {
19+
if (headings.length === 0) return;
20+
21+
const targets = headings
22+
.map((h) => document.getElementById(h.slug))
23+
.filter((el): el is HTMLElement => el !== null);
24+
if (targets.length === 0) return;
25+
26+
// Mark a heading active once its top crosses ~20% from the top of
27+
// the viewport. `-20% 0px -70% 0px` creates a narrow "active band"
28+
// near the top so a heading activates as it scrolls into reading
29+
// position, not when it merely enters the viewport from the bottom.
30+
const observer = new IntersectionObserver(
31+
(entries) => {
32+
const intersecting = entries.filter((e) => e.isIntersecting);
33+
if (intersecting.length === 0) return;
34+
intersecting.sort(
35+
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
36+
);
37+
setActiveSlug(intersecting[0].target.id);
38+
},
39+
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 },
40+
);
41+
42+
targets.forEach((el) => observer.observe(el));
43+
return () => observer.disconnect();
44+
}, [headings]);
45+
46+
if (headings.length === 0) return null;
47+
48+
return (
49+
<aside className="hidden xl:block w-[200px] shrink-0 sticky top-0 self-start max-h-screen overflow-y-auto py-8 pl-6 pr-4">
50+
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-faint)] mb-3">
51+
On this page
52+
</div>
53+
<nav className="flex flex-col gap-1 text-[12px] leading-relaxed">
54+
{headings.map((h) => {
55+
const isActive = activeSlug === h.slug;
56+
return (
57+
<a
58+
key={h.slug}
59+
href={`#${h.slug}`}
60+
// Sync the highlight immediately on click. The
61+
// IntersectionObserver can't take over here because the
62+
// anchor jump lands the target above the active band
63+
// (which starts ~20% from the top of the viewport), so
64+
// no intersection fires and the last-active slug would
65+
// otherwise stay selected.
66+
onClick={() => setActiveSlug(h.slug)}
67+
className={`block transition-colors ${
68+
h.depth === 3 ? "pl-3" : ""
69+
} ${
70+
isActive
71+
? "text-[var(--accent)] font-medium"
72+
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
73+
}`}
74+
>
75+
{h.text}
76+
</a>
77+
);
78+
})}
79+
</nav>
80+
</aside>
81+
);
82+
}

showcase/shell-docs/src/content/docs/generative-ui/tool-rendering.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
title: Tool Rendering
33
icon: "lucide/Server"
44
description: Render your agent's tool calls with custom UI components.
5-
hideTOC: true
65
snippet_cell: tool-rendering
76
---
87

showcase/shell-docs/src/lib/docs-render.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@ export interface DocFrontmatter {
711711
description?: string;
712712
defaultFramework?: string;
713713
defaultCell?: string;
714+
hideTOC?: boolean;
714715
}
715716

716717
/**
@@ -780,6 +781,7 @@ export function loadDoc(
780781
: undefined;
781782
const defaultCell =
782783
typeof data.snippet_cell === "string" ? data.snippet_cell : undefined;
784+
const hideTOC = data.hideTOC === true;
783785

784786
return {
785787
source,
@@ -789,6 +791,7 @@ export function loadDoc(
789791
description,
790792
defaultFramework,
791793
defaultCell,
794+
hideTOC,
792795
},
793796
};
794797
}

showcase/shell-docs/src/lib/toc.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Right-rail "On this page" TOC. Extracts H2/H3 headings from rendered
2+
// MDX source (post-snippet-inlining) so the TOC surfaces the page's
3+
// actual sections, including anything pulled in from shared snippets.
4+
//
5+
// Slug algorithm matches the conventional GitHub/rehype-slug behavior
6+
// closely enough for same-page anchors. Duplicate handling is kept
7+
// intentionally minimal — a scan of showcase/shell-docs content shows
8+
// no H2 collisions today; if that changes, swap in rehype-slug.
9+
10+
export interface TocHeading {
11+
depth: 2 | 3;
12+
text: string;
13+
slug: string;
14+
}
15+
16+
export function slugify(text: string): string {
17+
return text
18+
.toLowerCase()
19+
.trim()
20+
.replace(/[`*_~]/g, "")
21+
.replace(/[^a-z0-9\s-]/g, "")
22+
.replace(/\s+/g, "-")
23+
.replace(/-+/g, "-")
24+
.replace(/^-|-$/g, "");
25+
}
26+
27+
// Strip trivial inline markdown (bold/italic/code spans) from heading
28+
// text so the rendered TOC labels read as plain prose rather than "** &
29+
// `` scattered through them.
30+
function plainHeadingText(raw: string): string {
31+
return raw
32+
.replace(/`([^`]+)`/g, "$1")
33+
.replace(/\*\*([^*]+)\*\*/g, "$1")
34+
.replace(/\*([^*]+)\*/g, "$1")
35+
.replace(/_([^_]+)_/g, "$1")
36+
.trim();
37+
}
38+
39+
export function extractHeadings(source: string): TocHeading[] {
40+
const lines = source.split("\n");
41+
const headings: TocHeading[] = [];
42+
const slugCounts = new Map<string, number>();
43+
let inFence = false;
44+
let fenceChar: "`" | "~" | "" = "";
45+
46+
for (const line of lines) {
47+
// Track fenced code blocks so `# comment` inside python/js samples
48+
// doesn't masquerade as a heading.
49+
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
50+
if (fenceMatch) {
51+
const marker = fenceMatch[1][0] as "`" | "~";
52+
if (!inFence) {
53+
inFence = true;
54+
fenceChar = marker;
55+
} else if (fenceChar === marker) {
56+
inFence = false;
57+
fenceChar = "";
58+
}
59+
continue;
60+
}
61+
if (inFence) continue;
62+
63+
const match = line.match(/^(#{2,3})\s+(.+?)\s*$/);
64+
if (!match) continue;
65+
const depth = match[1].length as 2 | 3;
66+
const text = plainHeadingText(match[2]);
67+
if (!text) continue;
68+
69+
let slug = slugify(text);
70+
if (!slug) continue;
71+
const count = slugCounts.get(slug) ?? 0;
72+
slugCounts.set(slug, count + 1);
73+
if (count > 0) slug = `${slug}-${count}`;
74+
75+
headings.push({ depth, text, slug });
76+
}
77+
78+
return headings;
79+
}
80+
81+
// Flatten React heading children to a plain string so we can reuse the
82+
// slugify algorithm in the MDX `h2`/`h3` component overrides. Must match
83+
// the transforms applied by extractHeadings() so IDs line up with the
84+
// slugs surfaced in the TOC.
85+
export function childrenToText(children: unknown): string {
86+
if (typeof children === "string") return children;
87+
if (typeof children === "number") return String(children);
88+
if (Array.isArray(children)) return children.map(childrenToText).join("");
89+
if (
90+
children &&
91+
typeof children === "object" &&
92+
"props" in children &&
93+
typeof (children as { props: unknown }).props === "object"
94+
) {
95+
const props = (children as { props: { children?: unknown } }).props;
96+
return childrenToText(props?.children);
97+
}
98+
return "";
99+
}

0 commit comments

Comments
 (0)