diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index db7385f74b..844417defc 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index b8e2f8f206..505191bbcd 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/portal/components/docs/DocsToc.tsx b/frontend/editor/src/portal/components/docs/DocsToc.tsx new file mode 100644 index 0000000000..1f6ee6ebad --- /dev/null +++ b/frontend/editor/src/portal/components/docs/DocsToc.tsx @@ -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; +}) { + const { t } = useTranslation(); + const [active, setActive] = useState(headings[0]?.slug ?? ""); + + useEffect(() => { + const root = scrollRef.current; + if (!root || headings.length === 0) return; + setActive(headings[0].slug); + + const visible = new Set(); + 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 ( + + ); +} diff --git a/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx b/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx index 80dd0063fd..dff03e318f 100644 --- a/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx +++ b/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx @@ -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 }) =>

{children}

, + h3: ({ children }) =>

{children}

, a: ({ href, children }) => { if (href?.startsWith("doc:")) { const id = href.slice(4); diff --git a/frontend/editor/src/portal/docs/headings.test.ts b/frontend/editor/src/portal/docs/headings.test.ts new file mode 100644 index 0000000000..b3c8164a30 --- /dev/null +++ b/frontend/editor/src/portal/docs/headings.test.ts @@ -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" }, + ]); + }); +}); diff --git a/frontend/editor/src/portal/docs/headings.ts b/frontend/editor/src/portal/docs/headings.ts new file mode 100644 index 0000000000..ada13c0add --- /dev/null +++ b/frontend/editor/src/portal/docs/headings.ts @@ -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; +} diff --git a/frontend/editor/src/portal/views/DeveloperDocs.css b/frontend/editor/src/portal/views/DeveloperDocs.css index 590841bd87..1786940dd5 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.css +++ b/frontend/editor/src/portal/views/DeveloperDocs.css @@ -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) { diff --git a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx index 7f814aac3a..d2071011d8 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx +++ b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx @@ -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(), ); }); diff --git a/frontend/editor/src/portal/views/DeveloperDocs.tsx b/frontend/editor/src/portal/views/DeveloperDocs.tsx index a12ad6ab82..47eaffda5a 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.tsx +++ b/frontend/editor/src/portal/views/DeveloperDocs.tsx @@ -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 ( -
+
+ + {hasToc && ( + + )}
); }