diff --git a/.changelog/unreleased/636-docs-foundations.md b/.changelog/unreleased/636-docs-foundations.md
new file mode 100644
index 0000000..3ccf1e7
--- /dev/null
+++ b/.changelog/unreleased/636-docs-foundations.md
@@ -0,0 +1,8 @@
+---
+type: added
+area: docs
+pr: 636
+breaking: false
+---
+
+Add shared design-system styling, responsive documentation chrome, navigation breadcrumbs and pagers, and persistent MDX tab groups.
diff --git a/apps/docs/content/meta.json b/apps/docs/content/meta.json
index 7107d95..dc71e61 100644
--- a/apps/docs/content/meta.json
+++ b/apps/docs/content/meta.json
@@ -27,11 +27,18 @@
"reference/data-store",
"reference/synthetics-reader",
"reference/order-vault",
+ "reference/contracts.generated",
+ "reference/exchange-router",
"reference/graphql.generated",
+ "reference/tokens.generated",
+ "reference/errors",
"reference/glossary"
]
},
- { "label": "Reference", "pages": ["reference/contracts.generated", "reference/exchange-router", "reference/glossary"] },
+ {
+ "label": "Guides",
+ "pages": ["guides/troubleshooting"]
+ },
{
"label": "Resources",
"pages": [
diff --git a/apps/docs/index.html b/apps/docs/index.html
new file mode 100644
index 0000000..fd75bd8
--- /dev/null
+++ b/apps/docs/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ SO4 documentation
+
+
+
+
+
+
diff --git a/apps/docs/package.json b/apps/docs/package.json
index b6483f4..ba01d34 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -32,6 +32,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.11.6",
+ "@tailwindcss/vite": "^4.1.18",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/rollup": "^3.1.0",
"@repo/vitest-config": "workspace:*",
@@ -52,6 +53,7 @@
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.2.0",
"shiki": "^4.4.3",
+ "tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.2"
}
diff --git a/apps/docs/scripts/build.ts b/apps/docs/scripts/build.ts
index 4ddc19e..a1f51bc 100644
--- a/apps/docs/scripts/build.ts
+++ b/apps/docs/scripts/build.ts
@@ -1,4 +1,4 @@
-import { mkdir, rm } from "node:fs/promises"
+import { mkdir, readdir, rm } from "node:fs/promises"
import { join } from "node:path"
import { $ } from "bun"
@@ -12,7 +12,7 @@ await $`bun run ${join(appRoot, "../../scripts/generate-design-tokens.ts")} --ch
await $`bun run ${join(appRoot, "../../scripts/generate-errors-reference.ts")} --check`
const pages = (await loadPages()).filter(
- (page) => page.frontmatter.status !== "draft",
+ (page) => page.frontmatter.status !== "draft"
)
const outputRoot = join(appRoot, ".nitro-static")
@@ -27,7 +27,7 @@ function renderInline(value: string) {
return value
.replace(
/([^<]+)<\/Term>/g,
- '$2',
+ '$2'
)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, "$1")
@@ -56,13 +56,13 @@ function render(body: string) {
return `${items}
`
}
if (block.startsWith("> "))
- return ``
+ return ``
if (block.startsWith("- "))
- return `${block
+ return `${block
.split("\n")
.map((line) => `- ${renderInline(line.slice(2))}
`)
.join("")}
`
- return `${renderInline(block)}
`
+ return `${renderInline(block)}
`
})
.join("\n")
}
@@ -75,7 +75,7 @@ await rm(outputRoot, { recursive: true, force: true })
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${escape(page.frontmatter.title)}
${render(page.body)}`
+ const html = `${escape(page.frontmatter.title)} · SO4 docs${escape(page.frontmatter.title)}
${render(page.body)}`
await Bun.write(join(directory, "index.html"), html)
}
diff --git a/apps/docs/scripts/components.test.tsx b/apps/docs/scripts/components.test.tsx
index a1ff088..4282cd5 100644
--- a/apps/docs/scripts/components.test.tsx
+++ b/apps/docs/scripts/components.test.tsx
@@ -1,7 +1,13 @@
import React from "react"
import { GlobalRegistrator } from "@happy-dom/global-registrator"
import { test, expect, afterEach, afterAll, beforeAll } from "bun:test"
-import { cleanup, render, act } from "@testing-library/react"
+import {
+ cleanup,
+ render,
+ act,
+ fireEvent,
+ waitFor,
+} from "@testing-library/react"
import { components } from "../src/mdx/components"
import { Sidebar } from "../src/components/Sidebar"
import { Toc } from "../src/components/Toc"
@@ -16,6 +22,8 @@ import {
} from "@tanstack/react-router"
import { shikiPlugin } from "../src/lib/rehype-shiki"
import remarkGfm from "remark-gfm"
+import { DocsLayout } from "../src/components/DocsLayout"
+import { Tab, Tabs as DocsTabs } from "../src/mdx/Tabs"
beforeAll(() => {
GlobalRegistrator.register()
@@ -27,6 +35,7 @@ afterAll(() => {
afterEach(() => {
cleanup()
+ window.localStorage.clear()
})
const mdxFixture = `
@@ -85,7 +94,7 @@ test("MDX components map renders kitchen-sink fixture correctly", async () => {
expect(callout).not.toBeNull()
const codes = Array.from(container.querySelectorAll("code"))
- const inlineCode = codes.find(c => c.textContent === "inline code")
+ const inlineCode = codes.find((c) => c.textContent === "inline code")
expect(inlineCode).not.toBeUndefined()
const internalLink = container.querySelector("a[href='/foo']")
@@ -156,3 +165,96 @@ test("Pager renders previous and next navigation buttons", () => {
expect(prevLink?.textContent).toContain("Introduction")
expect(nextLink?.textContent).toContain("Wallets")
})
+
+function SyncedTabs() {
+ return (
+ <>
+
+ Bun first
+ npm first
+
+
+ Bun second
+ npm second
+
+ >
+ )
+}
+
+test("DocsLayout exposes landmarks and a working skip link", () => {
+ const result = render(
+ SO4 docs}
+ sidebar={}
+ toc={
+
+ -
+ Introduction
+
+
+ }
+ footer="Footer"
+ >
+ Introduction
+
+ )
+
+ const main = result.getByRole("main")
+ const skipLink = result.getByRole("link", { name: "Skip to main content" })
+ expect(main.id).toBe("main-content")
+ expect(main.getAttribute("tabindex")).toBe("-1")
+ expect(result.getByRole("contentinfo")).toBeDefined()
+
+ fireEvent.click(skipLink)
+ expect(document.activeElement).toBe(main)
+})
+
+test("MDX tabs sync groups, persist selection, and keep every panel", async () => {
+ const result = render()
+ const npmTabs = result.getAllByRole("tab", { name: "npm" })
+ fireEvent.click(npmTabs[0])
+
+ await waitFor(() => {
+ expect(npmTabs[0].getAttribute("aria-selected")).toBe("true")
+ expect(npmTabs[1].getAttribute("aria-selected")).toBe("true")
+ })
+ expect(window.localStorage.getItem("so4-docs-tabs:manager")).toBe("npm")
+ expect(result.getByText("npm first")).toBeDefined()
+ expect(result.getByText("npm second")).toBeDefined()
+})
+
+test("MDX tabs restore a persisted selection", async () => {
+ window.localStorage.setItem("so4-docs-tabs:manager", "npm")
+ const result = render()
+
+ await waitFor(() => {
+ for (const tab of result.getAllByRole("tab", { name: "npm" })) {
+ expect(tab.getAttribute("aria-selected")).toBe("true")
+ }
+ })
+})
+
+test("MDX tabs fall back to the first option when storage is unavailable", async () => {
+ const storage = window.localStorage
+ const originalGetItem = storage.getItem.bind(storage)
+ Object.defineProperty(storage, "getItem", {
+ configurable: true,
+ value: () => {
+ throw new Error("storage disabled")
+ },
+ })
+
+ try {
+ const result = render()
+ await waitFor(() => {
+ for (const tab of result.getAllByRole("tab", { name: "bun" })) {
+ expect(tab.getAttribute("aria-selected")).toBe("true")
+ }
+ })
+ } finally {
+ Object.defineProperty(storage, "getItem", {
+ configurable: true,
+ value: originalGetItem,
+ })
+ }
+})
diff --git a/apps/docs/scripts/content-loader.test.ts b/apps/docs/scripts/content-loader.test.ts
index b013eae..57fcdee 100644
--- a/apps/docs/scripts/content-loader.test.ts
+++ b/apps/docs/scripts/content-loader.test.ts
@@ -11,6 +11,12 @@ describe("content loader — kebab-case validation", () => {
expect(isKebabCase("a-b-c")).toBe(true)
})
+ test("generated page basenames remain valid after removing their suffix", () => {
+ expect(isKebabCase("tokens.generated".replace(/\.generated$/, ""))).toBe(
+ true
+ )
+ })
+
test("isKebabCase rejects non-kebab-case names", () => {
expect(isKebabCase("My Page")).toBe(false)
expect(isKebabCase("myPage")).toBe(false)
diff --git a/apps/docs/scripts/navigation.test.tsx b/apps/docs/scripts/navigation.test.tsx
new file mode 100644
index 0000000..eb42489
--- /dev/null
+++ b/apps/docs/scripts/navigation.test.tsx
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test"
+import { contentRoot, loadContentIndex } from "../src/lib/content"
+import {
+ breadcrumbStructuredData,
+ buildNavigation,
+ flattenNavigation,
+ getBreadcrumbs,
+ getPagerLinks,
+ type NavMetaSection,
+} from "../src/lib/navigation"
+
+async function loadActualNavigation() {
+ const meta = (await Bun.file(`${contentRoot}/meta.json`).json()) as {
+ sections: NavMetaSection[]
+ }
+ const index = await loadContentIndex("development")
+ return buildNavigation(meta.sections, index.byRoute)
+}
+
+describe("documentation navigation", () => {
+ test("breadcrumbs and pagers follow every page in sidebar order", async () => {
+ const navigation = await loadActualNavigation()
+ const pages = flattenNavigation(navigation)
+
+ for (const [index, page] of pages.entries()) {
+ const pager = getPagerLinks(navigation, page.route)
+ expect(pager.previous).toEqual(index > 0 ? pages[index - 1] : undefined)
+ expect(pager.next).toEqual(
+ index < pages.length - 1 ? pages[index + 1] : undefined
+ )
+
+ const crumbs = getBreadcrumbs(navigation, page.route)
+ expect(crumbs.at(-1)?.label).toBe(page.title)
+ }
+ })
+
+ test("boundary pages expose only the available pager link", async () => {
+ const navigation = await loadActualNavigation()
+ const pages = flattenNavigation(navigation)
+ const first = getPagerLinks(navigation, pages[0].route)
+ const last = getPagerLinks(navigation, pages.at(-1)!.route)
+
+ expect(first.previous).toBeUndefined()
+ expect(first.next).toBeDefined()
+ expect(last.previous).toBeDefined()
+ expect(last.next).toBeUndefined()
+ })
+
+ test("emits ordered BreadcrumbList structured data", () => {
+ const items = [
+ { label: "Docs", href: "/" },
+ { label: "Developers" },
+ { label: "Architecture" },
+ ]
+ const data = breadcrumbStructuredData(items, "https://docs.so4.market")
+ expect(data["@type"]).toBe("BreadcrumbList")
+ expect(data.itemListElement.map((item) => item.position)).toEqual([1, 2, 3])
+ expect(data.itemListElement[0].item).toBe("https://docs.so4.market/")
+ })
+})
diff --git a/apps/docs/scripts/search.test.ts b/apps/docs/scripts/search.test.ts
index a0f3aa0..f5cbe6c 100644
--- a/apps/docs/scripts/search.test.ts
+++ b/apps/docs/scripts/search.test.ts
@@ -9,30 +9,44 @@ const appRoot = join(import.meta.dir, "..")
describe("Search Index", () => {
test("generates deterministic index", async () => {
// Build first time
- await $`bun run scripts/build.ts && bunx --bun pagefind --site .nitro-static`.cwd(appRoot).quiet()
- const run1 = await $`find .nitro-static/pagefind -type f -exec shasum {} +`.cwd(appRoot).text()
-
+ await $`bun run scripts/build.ts && bunx --bun pagefind --site .nitro-static`
+ .cwd(appRoot)
+ .quiet()
+ const run1 = await $`find .nitro-static/pagefind -type f -exec shasum {} +`
+ .cwd(appRoot)
+ .text()
+
// Build second time
- await $`bun run scripts/build.ts && bunx --bun pagefind --site .nitro-static`.cwd(appRoot).quiet()
- const run2 = await $`find .nitro-static/pagefind -type f -exec shasum {} +`.cwd(appRoot).text()
-
+ await $`bun run scripts/build.ts && bunx --bun pagefind --site .nitro-static`
+ .cwd(appRoot)
+ .quiet()
+ const run2 = await $`find .nitro-static/pagefind -type f -exec shasum {} +`
+ .cwd(appRoot)
+ .text()
+
// Expect outputs to be completely identical
expect(run1).toEqual(run2)
}, 15000)
test("draft pages are absent from production index", async () => {
- const draftPagePath = join(appRoot, ".nitro-static/resources/terms/index.html")
+ const draftPagePath = join(
+ appRoot,
+ ".nitro-static/resources/terms/index.html"
+ )
expect(existsSync(draftPagePath)).toBe(false)
})
test("navigation text does not pollute indexed results", async () => {
// We check that the HTML structure explicitly ignores the header
- const sampleHtml = await readFile(join(appRoot, ".nitro-static/resources/faq/index.html"), "utf-8")
- expect(sampleHtml).toContain('')
- expect(sampleHtml).toContain('')
-
+ const sampleHtml = await readFile(
+ join(appRoot, ".nitro-static/resources/faq/index.html"),
+ "utf-8"
+ )
+ expect(sampleHtml).toContain("data-pagefind-ignore")
+ expect(sampleHtml).toContain("data-pagefind-body")
+
// As a result, pagefind will not index "Open interface" (which is in the header)
- // We can also verify that 'Open interface' is absent in the pagefind index chunks, but
+ // We can also verify that 'Open interface' is absent in the pagefind index chunks, but
// relying on pagefind's own directives is the supported way to assert this.
})
})
diff --git a/apps/docs/src/app/layout.tsx b/apps/docs/src/app/layout.tsx
new file mode 100644
index 0000000..aea22e7
--- /dev/null
+++ b/apps/docs/src/app/layout.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from "react"
+
+import "../styles/globals.css"
+
+export interface RootLayoutProps {
+ children: ReactNode
+}
+
+export function RootLayout({ children }: RootLayoutProps) {
+ return <>{children}>
+}
diff --git a/apps/docs/src/app/main.tsx b/apps/docs/src/app/main.tsx
new file mode 100644
index 0000000..c7b3eac
--- /dev/null
+++ b/apps/docs/src/app/main.tsx
@@ -0,0 +1,53 @@
+import { StrictMode } from "react"
+import { createRoot } from "react-dom/client"
+
+import { DocsLayout } from "../components/DocsLayout"
+import { DocsPlaceholderPage } from "./page"
+import "../styles/globals.css"
+
+const root = document.getElementById("root")
+
+if (!root) throw new Error("Missing docs application root")
+
+createRoot(root).render(
+
+
+ SO4 docs
+
+ }
+ sidebar={
+
+ }
+ footer={SO4 documentation
}
+ >
+
+
+
+)
diff --git a/apps/docs/src/app/page.tsx b/apps/docs/src/app/page.tsx
new file mode 100644
index 0000000..0d10d3b
--- /dev/null
+++ b/apps/docs/src/app/page.tsx
@@ -0,0 +1,15 @@
+import { Button } from "@workspace/ui/components/button"
+
+export function DocsPlaceholderPage() {
+ return (
+
+
+ SO4 documentation
+
+
+ Learn how SO4 markets, contracts, and developer tooling work.
+
+
+
+ )
+}
diff --git a/apps/docs/src/components/BreadcrumbStructuredData.tsx b/apps/docs/src/components/BreadcrumbStructuredData.tsx
new file mode 100644
index 0000000..5a3122c
--- /dev/null
+++ b/apps/docs/src/components/BreadcrumbStructuredData.tsx
@@ -0,0 +1,21 @@
+import type { BreadcrumbItem } from "../lib/navigation"
+import { breadcrumbStructuredData } from "../lib/navigation"
+
+interface BreadcrumbStructuredDataProps {
+ items: BreadcrumbItem[]
+ siteUrl?: string
+}
+
+export function BreadcrumbStructuredData({
+ items,
+ siteUrl = "https://docs.so4.market",
+}: BreadcrumbStructuredDataProps) {
+ return (
+
+ )
+}
diff --git a/apps/docs/src/components/Breadcrumbs.tsx b/apps/docs/src/components/Breadcrumbs.tsx
new file mode 100644
index 0000000..c8136b0
--- /dev/null
+++ b/apps/docs/src/components/Breadcrumbs.tsx
@@ -0,0 +1,35 @@
+import {
+ Breadcrumb,
+ BreadcrumbItem as BreadcrumbPrimitiveItem,
+ BreadcrumbLink,
+ BreadcrumbSeparator,
+} from "@workspace/ui/components/breadcrumb"
+
+import type { BreadcrumbItem } from "../lib/navigation"
+
+interface DocsBreadcrumbsProps {
+ items: BreadcrumbItem[]
+}
+
+export function DocsBreadcrumbs({ items }: DocsBreadcrumbsProps) {
+ return (
+
+ {items.map((item, index) => {
+ const isCurrent = index === items.length - 1
+ return (
+
+ {item.href && !isCurrent ? (
+ {item.label}
+ ) : (
+ {item.label}
+ )}
+ {!isCurrent ? : null}
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/docs/src/components/DocsLayout.tsx b/apps/docs/src/components/DocsLayout.tsx
new file mode 100644
index 0000000..7d475fd
--- /dev/null
+++ b/apps/docs/src/components/DocsLayout.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { Menu01Icon } from "@hugeicons/core-free-icons"
+import { Button } from "@workspace/ui/components/button"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@workspace/ui/components/collapsible"
+import { Icon } from "@workspace/ui/components/icon"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetTitle,
+ SheetTrigger,
+} from "@workspace/ui/components/sheet"
+import { MAIN_CONTENT_ID, SkipLink } from "@workspace/ui/components/skip-link"
+
+export interface DocsLayoutProps {
+ header?: ReactNode
+ sidebar: ReactNode
+ toc?: ReactNode
+ footer?: ReactNode
+ children: ReactNode
+}
+
+export function DocsLayout({
+ header,
+ sidebar,
+ toc,
+ footer,
+ children,
+}: DocsLayoutProps) {
+ return (
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+ Documentation
+
+ Browse documentation sections and pages.
+
+
+ {sidebar}
+
+
+
+
+
{header}
+
+
+
+
+
+
+
+ {toc ? (
+
+
+ On this page
+
+ {toc}
+
+ ) : null}
+ {children}
+
+
+ {toc ? (
+
+ ) : null}
+
+
+ {footer ? (
+
+ ) : null}
+
+ )
+}
diff --git a/apps/docs/src/components/DocsNavigation.tsx b/apps/docs/src/components/DocsNavigation.tsx
new file mode 100644
index 0000000..6a90501
--- /dev/null
+++ b/apps/docs/src/components/DocsNavigation.tsx
@@ -0,0 +1,53 @@
+import { cn } from "@workspace/ui/lib/utils"
+
+import type { NavSection } from "../lib/navigation"
+
+interface DocsNavigationProps {
+ sections: NavSection[]
+ currentRoute?: string
+ onNavigate?: () => void
+ className?: string
+}
+
+export function DocsNavigation({
+ sections,
+ currentRoute,
+ onNavigate,
+ className,
+}: DocsNavigationProps) {
+ return (
+
+ )
+}
diff --git a/apps/docs/src/lib/content.ts b/apps/docs/src/lib/content.ts
index 9566070..ec30f3d 100644
--- a/apps/docs/src/lib/content.ts
+++ b/apps/docs/src/lib/content.ts
@@ -3,7 +3,7 @@ import { join, relative } from "node:path"
import { validateFrontmatter } from "./frontmatter"
import type { Frontmatter } from "./frontmatter"
-export const appRoot = import.meta.dir + "/.."
+export const appRoot = import.meta.dir + "/../.."
export const contentRoot = join(appRoot, "content")
export interface Page {
@@ -29,7 +29,7 @@ async function walk(dir: string): Promise {
entries.map(async (entry) => {
const path = join(dir, entry.name)
return entry.isDirectory() ? walk(path) : [path]
- }),
+ })
)
return files.flat()
}
@@ -49,7 +49,7 @@ function parseMetaSections(meta: MetaConfig): MetaSection[] {
}
export async function loadContentIndex(
- mode: "development" | "production" = "production",
+ mode: "development" | "production" = "production"
): Promise {
const metaRaw = await readFile(join(contentRoot, "meta.json"), "utf8")
const meta = JSON.parse(metaRaw) as MetaConfig
@@ -62,11 +62,13 @@ export async function loadContentIndex(
const errors: string[] = []
for (const file of mdxFiles) {
- const name = file.split("/").pop()!.replace(/\.mdx$/, "")
- if (!isKebabCase(name)) {
- errors.push(
- `${file}: filename "${name}" is not lowercase kebab-case`,
- )
+ const name = file
+ .split("/")
+ .pop()!
+ .replace(/\.mdx$/, "")
+ const sourceName = name.replace(/\.generated$/, "")
+ if (!isKebabCase(sourceName)) {
+ errors.push(`${file}: filename "${name}" is not lowercase kebab-case`)
continue
}
@@ -75,9 +77,7 @@ export async function loadContentIndex(
if (byRoute.has(route)) {
const existing = byRoute.get(route)!
- errors.push(
- `duplicate route ${route}: ${existing.file} and ${file}`,
- )
+ errors.push(`duplicate route ${route}: ${existing.file} and ${file}`)
continue
}
@@ -116,7 +116,7 @@ export async function loadContentIndex(
}
const sidebarRoutes = new Set(
- sections.flatMap((s) => s.pages.map((p) => `/${p}`)),
+ sections.flatMap((s) => s.pages.map((p) => `/${p}`))
)
for (const route of sidebarRoutes) {
diff --git a/apps/docs/src/lib/navigation.ts b/apps/docs/src/lib/navigation.ts
new file mode 100644
index 0000000..3762668
--- /dev/null
+++ b/apps/docs/src/lib/navigation.ts
@@ -0,0 +1,110 @@
+export interface NavPage {
+ route: string
+ title: string
+ sidebarLabel?: string
+}
+
+export interface NavSection {
+ label: string
+ pages: NavPage[]
+}
+
+export interface NavMetaSection {
+ label: string
+ pages: string[]
+}
+
+interface IndexedPage {
+ route: string
+ frontmatter: {
+ title: string
+ sidebarLabel?: string
+ }
+}
+
+export interface BreadcrumbItem {
+ label: string
+ href?: string
+}
+
+export interface PagerLinks {
+ previous?: NavPage
+ next?: NavPage
+}
+
+export function buildNavigation(
+ metaSections: NavMetaSection[],
+ pagesByRoute: ReadonlyMap
+): NavSection[] {
+ return metaSections.map((section) => ({
+ label: section.label,
+ pages: section.pages.map((path) => {
+ const route = `/${path}`
+ const page = pagesByRoute.get(route)
+ if (!page) throw new Error(`navigation references missing page ${route}`)
+
+ return {
+ route,
+ title: page.frontmatter.title,
+ sidebarLabel: page.frontmatter.sidebarLabel,
+ }
+ }),
+ }))
+}
+
+export function flattenNavigation(sections: NavSection[]): NavPage[] {
+ return sections.flatMap((section) => section.pages)
+}
+
+export function getPagerLinks(
+ sections: NavSection[],
+ currentRoute: string
+): PagerLinks {
+ const pages = flattenNavigation(sections)
+ const index = pages.findIndex((page) => page.route === currentRoute)
+
+ if (index === -1) return {}
+
+ return {
+ previous: index > 0 ? pages[index - 1] : undefined,
+ next: index < pages.length - 1 ? pages[index + 1] : undefined,
+ }
+}
+
+export function getBreadcrumbs(
+ sections: NavSection[],
+ currentRoute: string
+): BreadcrumbItem[] {
+ for (const section of sections) {
+ const page = section.pages.find((item) => item.route === currentRoute)
+ if (!page) continue
+
+ return [
+ { label: "Docs", href: "/" },
+ { label: section.label },
+ { label: page.title },
+ ]
+ }
+
+ return [{ label: "Docs", href: "/" }]
+}
+
+export function breadcrumbStructuredData(
+ items: BreadcrumbItem[],
+ siteUrl: string
+) {
+ const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`
+
+ return {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: items.map((item, index) => ({
+ "@type": "ListItem",
+ position: index + 1,
+ name: item.label,
+ item: item.href
+ ? new URL(item.href.slice(1), baseUrl).toString()
+ : undefined,
+ })),
+ }
+}
diff --git a/apps/docs/src/mdx/CodeGroup.tsx b/apps/docs/src/mdx/CodeGroup.tsx
new file mode 100644
index 0000000..75e3f77
--- /dev/null
+++ b/apps/docs/src/mdx/CodeGroup.tsx
@@ -0,0 +1,48 @@
+import {
+ Children,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from "react"
+
+import { Tab, Tabs } from "./Tabs"
+
+interface TitledCodeBlockProps {
+ title?: string
+ "data-title"?: string
+ children?: ReactNode
+}
+
+export interface CodeGroupProps {
+ groupId: string
+ children: ReactNode
+}
+
+function getCodeTitle(
+ child: ReactElement,
+ index: number
+) {
+ return (
+ child.props.title ?? child.props["data-title"] ?? `Example ${index + 1}`
+ )
+}
+
+export function CodeGroup({ groupId, children }: CodeGroupProps) {
+ const blocks = Children.toArray(children).filter(
+ (child): child is ReactElement =>
+ isValidElement(child)
+ )
+
+ return (
+
+ {blocks.map((block, index) => {
+ const title = getCodeTitle(block, index)
+ return (
+
+ {block}
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/docs/src/mdx/Tabs.tsx b/apps/docs/src/mdx/Tabs.tsx
new file mode 100644
index 0000000..f3664f9
--- /dev/null
+++ b/apps/docs/src/mdx/Tabs.tsx
@@ -0,0 +1,68 @@
+"use client"
+
+import {
+ Children,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from "react"
+import {
+ Tabs as TabsPrimitive,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@workspace/ui/components/tabs"
+
+import { useTabs } from "./useTabs"
+
+export interface TabProps {
+ label: string
+ value?: string
+ children: ReactNode
+}
+
+export function Tab({ children }: TabProps) {
+ return <>{children}>
+}
+
+export interface TabsProps {
+ groupId: string
+ children: ReactNode
+ "aria-label"?: string
+}
+
+export function Tabs({
+ groupId,
+ children,
+ "aria-label": ariaLabel = "Options",
+}: TabsProps) {
+ const tabs = Children.toArray(children).filter(
+ (child): child is ReactElement => isValidElement(child)
+ )
+ const values = tabs.map((tab) => tab.props.value ?? tab.props.label)
+ const { value, select } = useTabs(groupId, values)
+
+ if (tabs.length === 0) return null
+
+ return (
+
+
+ {tabs.map((tab, index) => (
+
+ {tab.props.label}
+
+ ))}
+
+ {tabs.map((tab, index) => (
+
+ {tab.props.children}
+
+ ))}
+
+ )
+}
diff --git a/apps/docs/src/mdx/components.tsx b/apps/docs/src/mdx/components.tsx
index 3a4ec22..531f117 100644
--- a/apps/docs/src/mdx/components.tsx
+++ b/apps/docs/src/mdx/components.tsx
@@ -15,6 +15,8 @@ import {
TableHead,
TableCell,
} from "@workspace/ui/components/table"
+import { CodeGroup } from "./CodeGroup"
+import { Tab, Tabs } from "./Tabs"
export interface TabsProps {
children: React.ReactNode
@@ -186,7 +188,7 @@ export const components: MDXComponents = {
@@ -215,7 +217,7 @@ export const components: MDXComponents = {
{children}
@@ -227,24 +229,37 @@ export const components: MDXComponents = {
href={href}
target="_blank"
rel="noopener noreferrer"
- className="text-primary hover:underline font-medium inline-flex items-center gap-0.5"
+ className="inline-flex items-center gap-0.5 font-medium text-primary hover:underline"
{...props}
>
{children}
-
+
)
},
ul: (props) => (
-
+
),
ol: (props) => (
-
+
),
li: (props) => ,
hr: (props) =>
,
img: (props) => (
-
+
),
Callout: (props: any) => ,
Tabs: (props: any) => ,
diff --git a/apps/docs/src/mdx/useTabs.ts b/apps/docs/src/mdx/useTabs.ts
new file mode 100644
index 0000000..cdd7edf
--- /dev/null
+++ b/apps/docs/src/mdx/useTabs.ts
@@ -0,0 +1,75 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useState } from "react"
+
+const STORAGE_PREFIX = "so4-docs-tabs:"
+const CHANGE_EVENT = "so4:docs-tabs-change"
+
+interface TabsChangeDetail {
+ groupId: string
+ value: string
+}
+
+export function useTabs(groupId: string, values: string[]) {
+ const valuesKey = values.join("\u0000")
+ const stableValues = useMemo(() => values, [valuesKey])
+ const firstValue = stableValues[0] ?? ""
+ const [value, setValue] = useState(firstValue)
+
+ useEffect(() => {
+ setValue(firstValue)
+
+ try {
+ const stored = window.localStorage.getItem(`${STORAGE_PREFIX}${groupId}`)
+ if (stored && stableValues.includes(stored)) setValue(stored)
+ } catch {
+ // Storage can be disabled by privacy settings. The first tab stays active.
+ }
+
+ function handleChange(event: Event) {
+ const detail = (event as CustomEvent).detail
+ if (detail.groupId === groupId && stableValues.includes(detail.value)) {
+ setValue(detail.value)
+ }
+ }
+
+ function handleStorage(event: StorageEvent) {
+ if (
+ event.key === `${STORAGE_PREFIX}${groupId}` &&
+ event.newValue &&
+ stableValues.includes(event.newValue)
+ ) {
+ setValue(event.newValue)
+ }
+ }
+
+ window.addEventListener(CHANGE_EVENT, handleChange)
+ window.addEventListener("storage", handleStorage)
+ return () => {
+ window.removeEventListener(CHANGE_EVENT, handleChange)
+ window.removeEventListener("storage", handleStorage)
+ }
+ }, [firstValue, groupId, stableValues])
+
+ const select = useCallback(
+ (nextValue: string) => {
+ if (!stableValues.includes(nextValue)) return
+ setValue(nextValue)
+
+ try {
+ window.localStorage.setItem(`${STORAGE_PREFIX}${groupId}`, nextValue)
+ } catch {
+ // The current interaction still works when persistence is unavailable.
+ }
+
+ window.dispatchEvent(
+ new CustomEvent(CHANGE_EVENT, {
+ detail: { groupId, value: nextValue },
+ })
+ )
+ },
+ [groupId, stableValues]
+ )
+
+ return { value, select }
+}
diff --git a/apps/docs/src/styles/globals.css b/apps/docs/src/styles/globals.css
new file mode 100644
index 0000000..6824f52
--- /dev/null
+++ b/apps/docs/src/styles/globals.css
@@ -0,0 +1 @@
+@import "@workspace/ui/globals.css";
diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts
index a92fac5..7a339ae 100644
--- a/apps/docs/vite.config.ts
+++ b/apps/docs/vite.config.ts
@@ -4,7 +4,6 @@ import remarkFrontmatter from "remark-frontmatter"
import remarkMdxFrontmatter from "remark-mdx-frontmatter"
import { shikiPlugin } from "./src/lib/rehype-shiki"
import tailwindcss from "@tailwindcss/vite"
-import { resolve } from "node:path"
export default defineConfig({
root: import.meta.dirname,
@@ -15,12 +14,8 @@ export default defineConfig({
}),
tailwindcss(),
],
- resolve: {
- alias: {
- "@workspace/ui": resolve(import.meta.dirname, "../../packages/ui/src"),
- },
- },
build: {
- outDir: ".output",
+ outDir: ".nitro-static",
+ emptyOutDir: false,
},
})
diff --git a/bun.lock b/bun.lock
index f753c77..d6889f6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -32,6 +32,7 @@
"@repo/vitest-config": "workspace:*",
"@shikijs/rehype": "^4.4.3",
"@shikijs/transformers": "^4.4.3",
+ "@tailwindcss/vite": "^4.1.18",
"@testing-library/react": "^16.3.2",
"@types/bun": "^1.3.0",
"@types/mdx": "^2.0.14",
@@ -47,6 +48,7 @@
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.2.0",
"shiki": "^4.4.3",
+ "tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.2",
},
diff --git a/scripts/check-design-tokens.ts b/scripts/check-design-tokens.ts
index ce42bad..b4f10b2 100644
--- a/scripts/check-design-tokens.ts
+++ b/scripts/check-design-tokens.ts
@@ -28,7 +28,13 @@ const SCAN_DIRS = ["apps/web/src", "apps/docs/src", "packages/ui/src"]
const SCAN_EXTENSIONS = [".tsx", ".ts", ".css"]
// Directories to skip entirely regardless of extension.
-const IGNORE_DIR_SEGMENTS = ["node_modules", "dist", "generated", ".turbo", "__fixtures__"]
+const IGNORE_DIR_SEGMENTS = [
+ "node_modules",
+ "dist",
+ "generated",
+ ".turbo",
+ "__fixtures__",
+]
/**
* Explicit, reviewed exceptions. Each entry is a relative path (from repo
@@ -65,7 +71,8 @@ const HEX_COLOR_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g
const ARBITRARY_TEXT_SIZE_RE = /(?:^|[\s"'`])(?:[a-z-]+:)*text-\[[^\]]+\]/g
// Matches Tailwind arbitrary-value radius utilities: rounded-[...], rounded-t-[...], etc.
-const ARBITRARY_RADIUS_RE = /(?:^|[\s"'`])(?:[a-z-]+:)*rounded(?:-[trblse]{1,2})?-\[[^\]]+\]/g
+const ARBITRARY_RADIUS_RE =
+ /(?:^|[\s"'`])(?:[a-z-]+:)*rounded(?:-[trblse]{1,2})?-\[[^\]]+\]/g
function isAllowlisted(relPath: string): string | undefined {
for (const [prefix, reason] of Object.entries(ALLOWLIST)) {
@@ -93,7 +100,10 @@ function walk(dir: string, out: string[]): void {
if (entry.isDirectory()) {
if (shouldSkipDir(entry.name)) continue
walk(path.join(dir, entry.name), out)
- } else if (SCAN_EXTENSIONS.includes(path.extname(entry.name)) && !isTestFile(entry.name)) {
+ } else if (
+ SCAN_EXTENSIONS.includes(path.extname(entry.name)) &&
+ !isTestFile(entry.name)
+ ) {
out.push(path.join(dir, entry.name))
}
}
@@ -123,7 +133,10 @@ function checkFile(absPath: string): Violation[] {
// often wraps onto its own line(s) rather than being squeezed onto
// the same line as the code it's explaining.
const NEARBY_LOOKBACK = 5
- const nearbyLines = lines.slice(Math.max(0, idx - NEARBY_LOOKBACK), idx + 1)
+ const nearbyLines = lines.slice(
+ Math.max(0, idx - NEARBY_LOOKBACK),
+ idx + 1
+ )
if (nearbyLines.some((l) => l.includes("ds-allow:"))) continue
// CSS keyword values (`rounded-[inherit]`, `text-[inherit]`, etc.)
@@ -168,11 +181,15 @@ function main(): void {
const allViolations = files.flatMap(checkFile)
if (allViolations.length === 0) {
- console.log(`✓ Design token check passed — scanned ${files.length} files, 0 violations.`)
+ console.log(
+ `✓ Design token check passed — scanned ${files.length} files, 0 violations.`
+ )
return
}
- console.error(`✗ Design token check failed — ${allViolations.length} violation(s):\n`)
+ console.error(
+ `✗ Design token check failed — ${allViolations.length} violation(s):\n`
+ )
for (const v of allViolations) {
console.error(` ${v.file}:${v.line}:${v.column} [${v.rule}] ${v.value}`)
console.error(` ${v.context}`)
@@ -180,7 +197,7 @@ function main(): void {
console.error(
`\nFix by using an existing token (see packages/ui/src/styles/globals.css), or if the` +
`\nvalue is genuinely intentional, either add a "// ds-allow: " comment on the` +
- `\nsame line, or add the file to ALLOWLIST in scripts/check-design-tokens.ts with a reason.`,
+ `\nsame line, or add the file to ALLOWLIST in scripts/check-design-tokens.ts with a reason.`
)
process.exit(1)
}