Portal docs: add On this page TOC, edge-to-edge nav, centred content

This commit is contained in:
Anthony Stirling
2026-07-12 00:00:14 +01:00
parent c31f47216d
commit 5d5b8ed39d
9 changed files with 279 additions and 14 deletions
@@ -6702,6 +6702,9 @@ eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
title = "Agent skills"
[portal.docs.toc]
title = "On this page"
[portal.docs.webhooks]
codeCaption = "document.processed"
eyebrow = "API REFERENCE"
@@ -6597,6 +6597,9 @@ eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
title = "Agent skills"
[portal.docs.toc]
title = "On this page"
[portal.docs.webhooks]
codeCaption = "document.processed"
eyebrow = "API REFERENCE"
@@ -0,0 +1,80 @@
import { useEffect, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { Heading } from "@portal/docs/headings";
/**
* "On this page" table of contents. Lists the current doc's H2/H3 headings,
* scrolls the reading pane to a heading on click, and highlights the section
* currently in view (scroll-spy against the pane's scroll container).
*/
export function DocsToc({
headings,
scrollRef,
}: {
headings: Heading[];
scrollRef: RefObject<HTMLElement | null>;
}) {
const { t } = useTranslation();
const [active, setActive] = useState<string>(headings[0]?.slug ?? "");
useEffect(() => {
const root = scrollRef.current;
if (!root || headings.length === 0) return;
setActive(headings[0].slug);
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
}
// The topmost heading currently within the active zone wins.
const current = headings.find((h) => visible.has(h.slug));
if (current) setActive(current.slug);
},
// Active zone = the top ~30% of the reading pane.
{ root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
);
const els = headings
.map((h) => root.querySelector(`[id="${h.slug}"]`))
.filter((el): el is Element => el !== null);
els.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [headings, scrollRef]);
const onSelect = (slug: string) => {
scrollRef.current
?.querySelector(`[id="${slug}"]`)
?.scrollIntoView({ block: "start", behavior: "smooth" });
setActive(slug);
};
return (
<nav className="portal-docs__toc" aria-label={t("portal.docs.toc.title")}>
<div className="portal-docs__toc-title">{t("portal.docs.toc.title")}</div>
<ul className="portal-docs__toc-list">
{headings.map((h) => (
<li key={h.slug}>
<a
href={`#${h.slug}`}
className={
"portal-docs__toc-link" +
(h.level === 3 ? " is-sub" : "") +
(active === h.slug ? " is-active" : "")
}
aria-current={active === h.slug ? "location" : undefined}
onClick={(e) => {
e.preventDefault();
onSelect(h.slug);
}}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -1,10 +1,21 @@
import { isValidElement, useState } from "react";
import { isValidElement, useState, type ReactNode } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
} from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@app/ui";
import { slugify } from "@portal/docs/headings";
/** Flatten a heading's React children to plain text for its anchor id. */
function childText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(childText).join("");
if (isValidElement(node)) {
return childText((node.props as { children?: ReactNode }).children);
}
return "";
}
// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown
// would by default (it strips unknown protocols, which would kill doc: links).
@@ -39,6 +50,8 @@ function CopyButton({ text }: { text: string }) {
function buildComponents(onNavigate: (docId: string) => void): Components {
return {
h2: ({ children }) => <h2 id={slugify(childText(children))}>{children}</h2>,
h3: ({ children }) => <h3 id={slugify(childText(children))}>{children}</h3>,
a: ({ href, children }) => {
if (href?.startsWith("doc:")) {
const id = href.slice(4);
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { extractHeadings, slugify } from "@portal/docs/headings";
describe("slugify", () => {
it("lowercases, hyphenates, and trims punctuation", () => {
expect(slugify("How it Works!")).toBe("how-it-works");
expect(slugify(" Trailing & spaces ")).toBe("trailing-spaces");
});
});
describe("extractHeadings", () => {
it("extracts H2/H3 only, with slugs matching the rendered ids", () => {
const md = [
"# Page Title",
"## How it Works",
"text",
"### Sub Section",
"#### Too Deep",
"## Operations",
].join("\n");
expect(extractHeadings(md)).toEqual([
{ level: 2, text: "How it Works", slug: "how-it-works" },
{ level: 3, text: "Sub Section", slug: "sub-section" },
{ level: 2, text: "Operations", slug: "operations" },
]);
});
it("ignores headings inside fenced code and strips inline marks", () => {
const md = ["```", "## not a heading", "```", "## `Code` and *em*"].join(
"\n",
);
expect(extractHeadings(md)).toEqual([
{ level: 2, text: "Code and em", slug: "code-and-em" },
]);
});
});
@@ -0,0 +1,32 @@
/**
* Heading extraction for the "On this page" table of contents. The same
* `slugify` is used here and in MarkdownDoc's heading renderer, so the TOC links
* and the rendered heading ids always match.
*/
export interface Heading {
/** 2 or 3 (H2/H3). */
level: number;
text: string;
slug: string;
}
/** "How it Works!" → "how-it-works" (matches the rendered heading id). */
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** Extract H2/H3 headings from a doc body, skipping fenced code blocks. */
export function extractHeadings(markdown: string): Heading[] {
const noCode = markdown.replace(/^(```|~~~)[\s\S]*?^\1[ \t]*$/gm, "");
const headings: Heading[] = [];
for (const m of noCode.matchAll(/^(#{2,3})[ \t]+(.+?)[ \t]*#*$/gm)) {
const text = m[2].replace(/[`*_]/g, "").trim();
if (text) headings.push({ level: m[1].length, text, slug: slugify(text) });
}
return headings;
}
@@ -9,6 +9,21 @@
overflow: hidden;
}
/* With an "On this page" TOC, add a right-hand column. */
.portal-docs--with-toc {
grid-template-columns: 16rem minmax(0, 1fr) 15rem;
}
/* Not enough room for three columns → drop the TOC. */
@media (max-width: 72rem) {
.portal-docs--with-toc {
grid-template-columns: 16rem minmax(0, 1fr);
}
.portal-docs__toc-col {
display: none;
}
}
.portal-docs--empty {
display: block;
padding: 3rem 1.5rem;
@@ -21,7 +36,7 @@
height: 100%;
overflow-y: auto;
border-right: 1px solid var(--color-border-light);
padding: 1.25rem 0.75rem;
padding: 1.25rem 0;
}
.portal-docs__nav {
@@ -33,6 +48,7 @@
/* Search */
.portal-docs__search {
margin-bottom: 0.75rem;
padding: 0 0.75rem;
}
.portal-docs__search-box {
@@ -67,7 +83,7 @@
.portal-docs__nav-empty {
font-size: 0.8125rem;
color: var(--color-text-4);
padding: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0;
}
@@ -81,7 +97,7 @@
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-4);
padding: 0 0.5rem 0.5rem;
padding: 0 0.75rem 0.5rem;
}
.portal-docs__results-list {
@@ -99,7 +115,7 @@
.portal-docs__result {
height: auto;
padding: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0;
}
@@ -169,7 +185,7 @@
/* Section label: shared typography for the static Overview label and the
collapsible accordion headers below. */
.portal-docs__nav-head {
padding: 0.4rem 0.5rem 0.2rem;
padding: 0.4rem 0.75rem 0.2rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
@@ -233,7 +249,7 @@
.portal-docs__nav-list {
list-style: none;
margin: 0 0 0.375rem;
padding: 0 0 0 0.6rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
@@ -245,10 +261,10 @@
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.35rem 0.5rem;
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
border-radius: 0;
font-size: 0.8125rem;
color: var(--color-text-3);
cursor: pointer;
@@ -291,10 +307,75 @@
}
.portal-docs__content-inner {
max-width: 52rem;
max-width: 46rem;
margin: 0 auto;
}
/* ── On this page (right rail) ─────────────────────────────────────────── */
.portal-docs__toc-col {
min-height: 0;
height: 100%;
overflow-y: auto;
border-left: 1px solid var(--color-border-light);
padding: 1.5rem 0.75rem;
}
.portal-docs__toc {
position: sticky;
top: 0;
}
.portal-docs__toc-title {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-4);
padding: 0 0.5rem 0.5rem;
}
.portal-docs__toc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-docs__toc-link {
display: block;
padding: 0.25rem 0.5rem;
border-left: 2px solid transparent;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--color-text-3);
text-decoration: none;
transition: color var(--motion-fast);
}
.portal-docs__toc-link.is-sub {
padding-left: 1.25rem;
font-size: 0.75rem;
}
.portal-docs__toc-link:hover {
color: var(--color-text-1);
}
.portal-docs__toc-link.is-active {
color: var(--color-blue);
border-left-color: var(--color-blue);
font-weight: 600;
}
/* Scroll-to-heading lands just below the pane top, not flush against it. */
.portal-docs__md h2,
.portal-docs__md h3 {
scroll-margin-top: 1rem;
}
/* ── Small screens: nav collapses into a drawer above the content ──────── */
@media (max-width: 60rem) {
@@ -37,7 +37,9 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () =>
expect(
screen.queryByRole("button", { name: "Kubernetes Guide" }),
).not.toBeInTheDocument();
expect(screen.getByText(/Benefits of Stirling PDF/i)).toBeInTheDocument();
expect(
screen.getByText(/locally hosted web application/i),
).toBeInTheDocument();
});
it("expands a collapsible section when its header is clicked", () => {
@@ -60,7 +62,7 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () =>
fireEvent.click(hits[0]);
await waitFor(() =>
expect(
screen.queryByText(/Benefits of Stirling PDF/i),
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
@@ -71,7 +73,7 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () =>
fireEvent.click(screen.getByRole("link", { name: /Migration Guide/i }));
await waitFor(() =>
expect(
screen.queryByText(/Benefits of Stirling PDF/i),
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
@@ -5,7 +5,9 @@ import { Button, EmptyState } from "@app/ui";
import { DocsNav } from "@portal/components/docs/DocsNav";
import { DocsSearch } from "@portal/components/docs/DocsSearch";
import { DocsSection } from "@portal/components/docs/DocsSection";
import { DocsToc } from "@portal/components/docs/DocsToc";
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
import { extractHeadings } from "@portal/docs/headings";
import {
allDocs,
firstDocId,
@@ -53,6 +55,11 @@ export function DeveloperDocs() {
() => nav.find((s) => s.items.some((i) => i.id === activeId)),
[nav, activeId],
);
// "On this page" headings for the current doc.
const headings = useMemo(
() => (doc ? extractHeadings(doc.markdown) : []),
[doc],
);
// Navigating closes the mobile drawer, clears the search, and resets the pane.
const onSelect = useCallback(
@@ -79,8 +86,10 @@ export function DeveloperDocs() {
);
}
const hasToc = headings.length > 0;
return (
<div className="portal-docs">
<div className={"portal-docs" + (hasToc ? " portal-docs--with-toc" : "")}>
<Button
variant="tertiary"
className="portal-docs__nav-toggle"
@@ -120,6 +129,12 @@ export function DeveloperDocs() {
</DocsSection>
</div>
</main>
{hasToc && (
<aside className="portal-docs__toc-col">
<DocsToc headings={headings} scrollRef={contentRef} />
</aside>
)}
</div>
);
}