Portal docs: add On this page TOC, edge-to-edge nav, centred content
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user