Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions apps/docs/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`
Expand Down Expand Up @@ -68,14 +68,20 @@ function render(body: string) {
}

await rm(outputRoot, { recursive: true, force: true })
const themeBootstrap = `<script>(function(){var key="so4-docs-theme";var saved=localStorage.getItem(key);var theme=saved||"system";var dark=theme==="dark"||(theme==="system"&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",dark);document.documentElement.dataset.theme=dark?"dark":"light"})()</script>`
const pageScript = `<script>document.addEventListener("click",function(event){var anchor=event.target.closest(".heading-anchor");if(anchor){event.preventDefault();var url=location.href.split("#")[0]+anchor.getAttribute("href");navigator.clipboard?.writeText(url);history.replaceState(null,"",anchor.getAttribute("href"))}var toggle=event.target.closest("[data-theme-toggle]");if(toggle){var dark=!document.documentElement.classList.contains("dark");document.documentElement.classList.toggle("dark",dark);document.documentElement.dataset.theme=dark?"dark":"light";localStorage.setItem("so4-docs-theme",dark?"dark":"light")}var search=event.target.closest("[data-search-open]");if(search){document.querySelector("[data-search-dialog]").showModal();document.querySelector("[data-search-input]").focus()}});document.querySelector("[data-search-input]")?.addEventListener("input",async function(event){var q=event.target.value.trim();var out=document.querySelector("[data-search-results]");if(!q){out.innerHTML="";return}var pagefind=await import("/pagefind/pagefind.js");var results=await pagefind.search(q);out.innerHTML="";for(var item of results.results.slice(0,8)){var data=await item.data();var link=document.createElement("a");link.href=data.url;link.textContent=data.meta?.title||data.url;out.append(link)}})</script>`
await rm(outputRoot, { recursive: true, force: true })
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">${themeBootstrap}<title>${escape(page.frontmatter.title)} · SO4 docs</title><meta name="description" content="${escape(page.frontmatter.description)}"><style>:root{font:16px/1.65 system-ui;color:#17191d;background:#fff}:root.dark{color:#f5f5f5;background:#17191d}body{margin:0}header,main{max-width:760px;margin:auto;padding:24px}header{display:flex;justify-content:space-between;gap:16px;border-bottom:1px solid currentColor}header nav{display:flex;gap:12px}button{font:inherit;color:inherit;background:transparent;border:1px solid currentColor;padding:4px 8px;cursor:pointer}a{color:#3156c8}.dark a{color:#9ab4ff}h1{font-size:2.4rem;line-height:1.1}h2,h3,h4,h5,h6{margin-top:2.5rem}.heading-anchor{opacity:0;margin-right:8px;text-decoration:none}.heading-anchor:focus,.heading-anchor:hover{opacity:1}.steps{counter-reset:steps;list-style:none;padding:0}.steps li{counter-increment:steps;border-left:2px solid #3156c8;padding:8px 0 8px 36px;position:relative}.steps li:before{content:counter(steps);position:absolute;left:-14px;background:#3156c8;color:white;border-radius:50%;width:26px;height:26px;text-align:center;line-height:26px}aside{border-left:4px solid #d99b16;background:#fff8df;padding:16px}code{background:#eee;padding:2px 5px}dialog{color:inherit;background:Canvas;max-width:600px;width:calc(100% - 48px)}[data-search-results]{display:grid;gap:8px;padding-top:12px}@media print{header{display:none}main{max-width:none;padding:0}a{color:inherit;text-decoration:none}aside{break-inside:avoid;background:none;border:1px solid #777}h2,h3,h4,h5,h6{break-after:avoid}}</style></head><body><header data-pagefind-ignore><a href="/">SO4 docs</a><nav><button type="button" data-search-open>Search</button><button type="button" data-theme-toggle aria-label="Toggle theme">Theme</button></nav></header><dialog data-search-dialog><form method="dialog"><input data-search-input aria-label="Search documentation" placeholder="Search documentation"><button>Close</button></form><div data-search-results></div></dialog><main data-pagefind-body><h1>${escape(page.frontmatter.title)}</h1>${render(page.body)}</main>${pageScript}</body></html>`
// 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 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(page.frontmatter.title)} · SO4 docs</title><meta name="description" content="${escape(page.frontmatter.description)}"><link rel="stylesheet" href="/assets/${stylesheet}"></head><body class="bg-surface-canvas text-text-primary"><header class="mx-auto flex h-16 max-w-3xl items-center justify-between border-b border-border px-4" data-pagefind-ignore><a class="text-sm font-semibold text-text-primary" href="/">SO4 docs</a><a class="text-sm font-medium text-text-link" href="https://so4.market">Open interface</a></header><main class="mx-auto max-w-3xl px-4 py-10" data-pagefind-body><h1 class="mb-6 text-2xl font-semibold text-text-primary">${escape(page.frontmatter.title)}</h1>${render(page.body)}</main></body></html>`
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(page.frontmatter.title)} · SO4 docs</title><meta name="description" content="${escape(page.frontmatter.description)}"><link rel="stylesheet" href="/assets/${stylesheet}"></head><body class="bg-surface-canvas text-text-primary"><header class="mx-auto flex h-16 max-w-3xl items-center justify-between border-b border-border px-4" data-pagefind-ignore><a class="text-sm font-semibold text-text-primary" href="/">SO4 docs</a><a class="text-sm font-medium text-text-link" href="https://so4.market">Open interface</a></header><main class="mx-auto max-w-3xl px-4 py-10" data-pagefind-body><h1 class="mb-6 text-2xl font-semibold text-text-primary">${escape(page.frontmatter.title)}</h1>${render(page.body)}<footer class="docs-print-footer" data-pagefind-ignore data-print-url="${escape(`${DEFAULT_SITE_URL}${page.route}`)}">Last updated <time datetime="${escape(page.frontmatter.updated)}">${escape(page.frontmatter.updated)}</time></footer></main></body></html>`
await Bun.write(join(directory, "index.html"), html)
}

Expand Down
13 changes: 11 additions & 2 deletions apps/docs/scripts/check-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}`)
Expand Down
24 changes: 24 additions & 0 deletions apps/docs/scripts/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<components.Tabs>
<components.TabItem label="bun">Bun instructions</components.TabItem>
<components.TabItem label="npm">npm instructions</components.TabItem>
</components.Tabs>
)

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(<SyncedTabs />)
Expand Down
16 changes: 14 additions & 2 deletions apps/docs/scripts/generate-faq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
Expand Down
25 changes: 20 additions & 5 deletions apps/docs/src/components/DocsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,15 @@ export function DocsLayout({
children,
}: DocsLayoutProps) {
return (
<div className="min-h-dvh overflow-x-clip bg-surface-canvas text-text-primary">
<div
data-slot="docs-shell"
className="min-h-dvh overflow-x-clip bg-surface-canvas text-text-primary"
>
<SkipLink />
<header className="sticky top-0 z-40 border-b border-border bg-surface-canvas">
<header
data-slot="docs-header"
className="sticky top-0 z-40 border-b border-border bg-surface-canvas"
>
<div className="mx-auto flex h-16 max-w-screen-2xl items-center gap-3 px-4 md:px-6 lg:px-8">
<div className="lg:hidden">
<Sheet>
Expand Down Expand Up @@ -67,7 +73,10 @@ export function DocsLayout({
</header>

<div className="mx-auto flex max-w-screen-2xl items-start px-4 md:px-6 lg:px-8">
<aside className="sticky top-16 hidden max-h-[calc(100dvh-4rem)] w-56 shrink-0 overflow-y-auto py-8 pe-6 lg:block xl:w-64">
<aside
data-slot="docs-sidebar"
className="sticky top-16 hidden max-h-[calc(100dvh-4rem)] w-56 shrink-0 overflow-y-auto py-8 pe-6 lg:block xl:w-64"
>
{sidebar}
</aside>

Expand All @@ -89,14 +98,20 @@ export function DocsLayout({
</main>

{toc ? (
<aside className="sticky top-16 hidden max-h-[calc(100dvh-4rem)] w-56 shrink-0 overflow-y-auto py-8 ps-6 xl:block">
<aside
data-slot="docs-toc"
className="sticky top-16 hidden max-h-[calc(100dvh-4rem)] w-56 shrink-0 overflow-y-auto py-8 ps-6 xl:block"
>
<nav aria-label="On this page">{toc}</nav>
</aside>
) : null}
</div>

{footer ? (
<footer className="border-t border-border px-4 py-8 md:px-6 lg:px-8">
<footer
data-slot="docs-footer"
className="border-t border-border px-4 py-8 md:px-6 lg:px-8"
>
<div className="mx-auto max-w-screen-2xl">{footer}</div>
</footer>
) : null}
Expand Down
6 changes: 5 additions & 1 deletion apps/docs/src/lib/seo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/** Canonical origin of the documentation site. Used for canonical/OG URLs and
* by the static generator for the print-only page footer (DX-063). */
export const DEFAULT_SITE_URL = "https://docs.so4.market"

export interface SeoMetadataOptions {
title: string
description: string
Expand All @@ -13,7 +17,7 @@ export function generateSeoTags({
route,
updated,
section = "Documentation",
siteUrl = "https://docs.so4.market",
siteUrl = DEFAULT_SITE_URL,
}: SeoMetadataOptions): { headTags: string; structuredDataHtml: string } {
const fullTitle = `${title} · SO4 docs`
const canonicalUrl = `${siteUrl}${route}`
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/mdx/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function Tabs({
key={values[index]}
value={values[index]}
keepMounted
data-tab-label={tab.props.label ?? values[index]}
className="mt-4 text-sm print:block"
>
{tab.props.children}
Expand Down
22 changes: 19 additions & 3 deletions apps/docs/src/mdx/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
TableCell,
} from "@workspace/ui/components/table"
import { CodeGroup } from "./CodeGroup"
import { Tab, Tabs } from "./Tabs"

export interface TabsProps {
children: React.ReactNode
Expand All @@ -30,7 +29,7 @@ export function Tabs({ children, defaultValue, className }: TabsProps) {

return (
<div className={cn("my-6 border border-border rounded-xl overflow-hidden", className)}>
<div className="flex border-b border-border bg-surface-sunken">
<div className="flex border-b border-border bg-surface-sunken" data-tab-list>
{childrenArray.map((child: any, idx: number) => {
const label = child?.props?.label || `Tab ${idx + 1}`
return (
Expand All @@ -50,7 +49,24 @@ export function Tabs({ children, defaultValue, className }: TabsProps) {
)
})}
</div>
<div className="p-4">{childrenArray[activeTab]}</div>
{/*
Every panel stays mounted (inactive ones carry the `hidden` attribute)
so hidden panels remain searchable and can be expanded by the print
stylesheet — same convention as the primitives-based `Tabs`, whose
panels use `keepMounted`.
*/}
<div className="p-4">
{childrenArray.map((child: any, idx: number) => (
<div
key={idx}
data-tab-panel
data-tab-label={child?.props?.label || `Tab ${idx + 1}`}
hidden={idx !== activeTab}
>
{child}
</div>
))}
</div>
</div>
)
}
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/src/styles/globals.css
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
/* Print stylesheet for documentation pages (DX-063 / issue #575).
Its rules live in the `docs-print` layer, declared FIRST in the layer order
(before Tailwind's `theme, base, components, utilities`). For `!important`
declarations the cascade gives priority to the earliest layer, so
`docs-print` important rules beat the base-layer preflight rule
`[hidden] { display: none !important }` — which is what lets print expand
`hidden` tab panels (the MDX content tabs and Base UI's `keepMounted`
panels both hide inactive panels with the hidden attribute). The layer
statement must precede the imports that pull Tailwind in. */
@layer docs-print, theme, base, components, utilities;

@import "@workspace/ui/globals.css";
@import "./print.css" layer(docs-print);
Loading