From baeb10f0214c0bda116b687f81a9bd0cc44b94d1 Mon Sep 17 00:00:00 2001 From: Ay-obami Date: Sun, 30 Aug 2026 08:30:35 +0100 Subject: [PATCH] feat(docs): add a print stylesheet for documentation pages (DX-063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference pages — particularly /resources/terms and /concepts/risk — get printed and saved as PDFs. Without print rules the output is a navigation header followed by whatever the active theme happens to be. - apps/docs/src/styles/print.css (new): @media print rules that hide the page chrome (static header, DocsLayout sidebar/TOC/header/footer slots, search dialog, mobile TOC, heading anchors), force the light palette by re-declaring the dark-theme token overrides, expand the content column, open every collapsible and tab panel, print destination URLs after internal links, keep code blocks/callouts/table rows unsplit, and show a print-only footer with the page URL and last-updated date. - the rules live in a docs-print cascade layer declared before Tailwind's layers, so their !important rules beat the base preflight [hidden]{display:none!important} that guards inactive tab panels. - the static generator emits the print footer (canonical URL from seo.ts DEFAULT_SITE_URL + frontmatter updated date) — no client JS needed. - the content-map Tabs now keep every panel mounted (inactive ones hidden) so print/search can reach them, matching the primitives Tabs convention. - DocsLayout marks its chrome regions with data-slot hooks the print sheet targets. Also repairs pre-existing docs-gate defects on main that blocked building and verifying this change: - scripts/build.ts: drop the orphaned inline-styled page template left by the #636 merge and restore the vite stylesheet pipeline it clobbered. - scripts/check-content.ts: the glossary check read entry.answer, a field headingEntries() no longer returns (crash); read each entry's own section. The home page is reachable by definition, so it is exempt from the orphan-page check. - scripts/generate-faq.ts: same stale entry.answer usage (crash); derive each entry's answer from its FAQ section body. - mdx/components.tsx: remove the imported Tabs that conflicted with the local Tabs declaration (TS2440). Closes #575 --- apps/docs/scripts/build.ts | 18 +- apps/docs/scripts/check-content.ts | 13 +- apps/docs/scripts/components.test.tsx | 24 ++ apps/docs/scripts/generate-faq.ts | 16 +- apps/docs/src/components/DocsLayout.tsx | 25 +- apps/docs/src/lib/seo.ts | 6 +- apps/docs/src/mdx/Tabs.tsx | 1 + apps/docs/src/mdx/components.tsx | 22 +- apps/docs/src/styles/globals.css | 12 + apps/docs/src/styles/print.css | 315 ++++++++++++++++++++++++ 10 files changed, 433 insertions(+), 19 deletions(-) create mode 100644 apps/docs/src/styles/print.css diff --git a/apps/docs/scripts/build.ts b/apps/docs/scripts/build.ts index a1f51bc..3d28cc1 100644 --- a/apps/docs/scripts/build.ts +++ b/apps/docs/scripts/build.ts @@ -2,8 +2,8 @@ import { mkdir, readdir, rm } from "node:fs/promises" import { join } from "node:path" import { $ } from "bun" -import { appRoot, loadPages } from "./content" import { appRoot, loadPages, slugifyHeading } from "./content" +import { DEFAULT_SITE_URL } from "../src/lib/seo" await $`bun run ${join(appRoot, "scripts/check-content.ts")}` await $`bun run ${join(appRoot, "scripts/check-links.ts")}` @@ -68,14 +68,20 @@ function render(body: string) { } await rm(outputRoot, { recursive: true, force: true }) -const themeBootstrap = `` -const pageScript = `` -await rm(outputRoot, { recursive: true, force: true }) - const html = `${themeBootstrap}${escape(page.frontmatter.title)} · SO4 docs
SO4 docs

${escape(page.frontmatter.title)}

${render(page.body)}
${pageScript}` +// The docs stylesheet is the Vite bundle of `src/app/main.tsx` → +// `src/styles/globals.css` (Tailwind v4 + the shared `@workspace/ui` theme). +// Content pages link the hashed asset so they share one compiled stylesheet +// with the SPA home page. +await $`bunx vite build`.cwd(appRoot) +const stylesheet = (await readdir(join(outputRoot, "assets"))).find( + (file) => file.startsWith("index-") && file.endsWith(".css") +) +if (!stylesheet) throw new Error("Vite did not emit the docs stylesheet") + for (const page of pages) { const directory = join(outputRoot, page.route.slice(1)) await mkdir(directory, { recursive: true }) - const html = `${escape(page.frontmatter.title)} · SO4 docs
SO4 docsOpen interface

${escape(page.frontmatter.title)}

${render(page.body)}
` + const html = `${escape(page.frontmatter.title)} · SO4 docs
SO4 docsOpen interface

${escape(page.frontmatter.title)}

${render(page.body)}
Last updated
` await Bun.write(join(directory, "index.html"), html) } diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts index 6353834..4dd6f37 100644 --- a/apps/docs/scripts/check-content.ts +++ b/apps/docs/scripts/check-content.ts @@ -43,7 +43,10 @@ const navRoutes = meta.sections.flatMap((section) => for (const route of navRoutes) if (!routes.has(route)) errors.push(`sidebar references missing ${route}`) for (const route of routes) - if (!navRoutes.includes(route)) errors.push(`orphan page ${route}`) + // The home page is the site root — reachable by definition, not a sidebar + // entry — so it is exempt from the orphan check. + if (!navRoutes.includes(route) && route !== "/index") + errors.push(`orphan page ${route}`) const glossary = pages.find((page) => page.route === "/reference/glossary") if (!glossary) { @@ -56,8 +59,14 @@ if (!glossary) { ) if (titles.some((title, index) => title !== sorted[index])) errors.push("glossary is not alphabetical") + // `headingEntries` returns `{ title, id }` pairs, so each entry's onward + // link is found in its own section of the glossary body. + const sections = glossary.body.split(/\n(?=## )/) for (const entry of entries) { - const link = entry.answer.match(/\]\((\/[a-z0-9/#-]+)\)/)?.[1] + const section = sections.find((text) => + text.split("\n")[0].startsWith(`## ${entry.title}`), + ) + const link = section?.match(/\]\((\/[a-z0-9/#-]+)\)/)?.[1] if (!link) errors.push(`glossary#${entry.id}: missing onward link`) else if (!routes.has(link.split("#")[0])) errors.push(`glossary#${entry.id}: missing page ${link}`) diff --git a/apps/docs/scripts/components.test.tsx b/apps/docs/scripts/components.test.tsx index 4282cd5..a571e6f 100644 --- a/apps/docs/scripts/components.test.tsx +++ b/apps/docs/scripts/components.test.tsx @@ -223,6 +223,30 @@ test("MDX tabs sync groups, persist selection, and keep every panel", async () = expect(result.getByText("npm second")).toBeDefined() }) +test("MDX content tabs keep every panel mounted for print and search", () => { + const result = render( + + Bun instructions + npm instructions + + ) + + const panels = [...result.container.querySelectorAll("[data-tab-panel]")] + expect(panels.length).toBe(2) + expect(panels.map((panel) => panel.getAttribute("data-tab-label"))).toEqual([ + "bun", + "npm", + ]) + expect(panels[0].hidden).toBe(false) + expect(panels[1].hidden).toBe(true) + + // Only the `hidden` attribute separates the panels, so the print stylesheet + // can expand all of them without a beforeprint JS hook. + fireEvent.click(result.getByRole("button", { name: "npm" })) + expect(panels[0].hidden).toBe(true) + expect(panels[1].hidden).toBe(false) +}) + test("MDX tabs restore a persisted selection", async () => { window.localStorage.setItem("so4-docs-tabs:manager", "npm") const result = render() diff --git a/apps/docs/scripts/generate-faq.ts b/apps/docs/scripts/generate-faq.ts index e4fe300..dd66e13 100644 --- a/apps/docs/scripts/generate-faq.ts +++ b/apps/docs/scripts/generate-faq.ts @@ -5,16 +5,28 @@ import { headingEntries, loadPages } from "./content" const faq = (await loadPages()).find((page) => page.route === "/resources/faq") if (!faq) throw new Error("FAQ source is missing") const landing = new Set(faq.frontmatter.landing ?? []) +// `headingEntries` returns `{ title, id }` pairs, so each entry's answer text +// is taken from its own section of the FAQ body. +const sectionBodies = new Map( + faq.body + .split(/\n(?=## )/) + .filter((text) => text.startsWith("## ")) + .map((text) => [ + text.match(/\{#([a-z0-9-]+)\}/)?.[1], + text.slice(text.indexOf("\n") + 1).trim(), + ]), +) const entries = headingEntries(faq.body) .filter((entry) => landing.has(entry.id)) .map((entry) => { - const link = entry.answer.match(/\[([^\]]+)\]\(([^)]+)\)\.$/) + const answer = sectionBodies.get(entry.id) ?? "" + const link = answer.match(/\[([^\]]+)\]\(([^)]+)\)\.$/) if (!link) throw new Error(`FAQ ${entry.id} must end with one documentation link`) return { id: entry.id, question: entry.title, - answer: entry.answer.slice(0, link.index).trim(), + answer: answer.slice(0, link.index).trim(), linkLabel: link[1], href: link[2], } diff --git a/apps/docs/src/components/DocsLayout.tsx b/apps/docs/src/components/DocsLayout.tsx index 7d475fd..8c764e8 100644 --- a/apps/docs/src/components/DocsLayout.tsx +++ b/apps/docs/src/components/DocsLayout.tsx @@ -34,9 +34,15 @@ export function DocsLayout({ children, }: DocsLayoutProps) { return ( -
+
-
+
@@ -67,7 +73,10 @@ export function DocsLayout({
-
{footer ? ( -