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
8 changes: 8 additions & 0 deletions .changelog/unreleased/636-docs-foundations.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion apps/docs/content/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
16 changes: 16 additions & 0 deletions apps/docs/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Documentation for SO4 markets, contracts, and developer tooling."
/>
<title>SO4 documentation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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"
}
Expand Down
14 changes: 7 additions & 7 deletions apps/docs/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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")

Expand All @@ -27,7 +27,7 @@ function renderInline(value: string) {
return value
.replace(
/<Term id="([a-z0-9-]+)">([^<]+)<\/Term>/g,
'<a href="/reference/glossary#$1">$2</a>',
'<a href="/reference/glossary#$1">$2</a>'
)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
Expand Down Expand Up @@ -56,13 +56,13 @@ function render(body: string) {
return `<ol class="steps">${items}</ol>`
}
if (block.startsWith("> "))
return `<aside>${renderInline(block.replace(/^> ?/gm, ""))}</aside>`
return `<aside class="my-6 rounded-lg bg-warning-subtle p-4 text-sm text-text-primary">${renderInline(block.replace(/^> ?/gm, ""))}</aside>`
if (block.startsWith("- "))
return `<ul>${block
return `<ul class="mb-4 list-disc space-y-2 ps-6 text-sm text-text-primary">${block
.split("\n")
.map((line) => `<li>${renderInline(line.slice(2))}</li>`)
.join("")}</ul>`
return `<p>${renderInline(block)}</p>`
return `<p class="mb-4 text-sm leading-7 text-text-primary">${renderInline(block)}</p>`
})
.join("\n")
}
Expand All @@ -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 = `<!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)}"><style>:root{font:16px/1.65 system-ui;color:#17191d;background:#fff}body{margin:0}header,main{max-width:760px;margin:auto;padding:24px}header{display:flex;justify-content:space-between;border-bottom:1px solid #ddd}a{color:#3156c8}h1{font-size:2.4rem;line-height:1.1}h2{margin-top:2.5rem}aside{border-left:4px solid #d99b16;background:#fff8df;padding:16px}code{background:#eee;padding:2px 5px}@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{break-after:avoid}}</style></head><body><header data-pagefind-ignore><a href="/">SO4 docs</a><a href="https://so4.market">Open interface</a></header><main data-pagefind-body><h1>${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)}</main></body></html>`
await Bun.write(join(directory, "index.html"), html)
}

Expand Down
106 changes: 104 additions & 2 deletions apps/docs/scripts/components.test.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -16,6 +22,8 @@
} 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()
Expand All @@ -27,6 +35,7 @@

afterEach(() => {
cleanup()
window.localStorage.clear()
})

const mdxFixture = `
Expand Down Expand Up @@ -85,7 +94,7 @@
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']")
Expand Down Expand Up @@ -118,7 +127,7 @@
render(<RouterProvider router={router} />)

const activeLink = document.querySelector("a[aria-current='page']")
expect(activeLink).not.toBeNull()

Check failure on line 130 in apps/docs/scripts/components.test.tsx

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Build

error: expect(received).not.toBeNull()

Received: null at <anonymous> (/home/runner/work/interface/interface/apps/docs/scripts/components.test.tsx:130:26)
expect(activeLink?.textContent).toContain("Introduction")
})

Expand Down Expand Up @@ -151,8 +160,101 @@
const prevLink = document.querySelector("a[rel='prev']")
const nextLink = document.querySelector("a[rel='next']")

expect(prevLink).not.toBeNull()

Check failure on line 163 in apps/docs/scripts/components.test.tsx

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Build

error: expect(received).not.toBeNull()

Received: null at <anonymous> (/home/runner/work/interface/interface/apps/docs/scripts/components.test.tsx:163:24)
expect(nextLink).not.toBeNull()
expect(prevLink?.textContent).toContain("Introduction")
expect(nextLink?.textContent).toContain("Wallets")
})

function SyncedTabs() {
return (
<>
<DocsTabs groupId="manager">
<Tab label="bun">Bun first</Tab>
<Tab label="npm">npm first</Tab>
</DocsTabs>
<DocsTabs groupId="manager">
<Tab label="bun">Bun second</Tab>
<Tab label="npm">npm second</Tab>
</DocsTabs>
</>
)
}

test("DocsLayout exposes landmarks and a working skip link", () => {
const result = render(
<DocsLayout
header={<a href="/">SO4 docs</a>}
sidebar={<nav aria-label="Documentation">Sidebar</nav>}
toc={
<ol>
<li>
<a href="#intro">Introduction</a>
</li>
</ol>
}
footer="Footer"
>
<h1 id="intro">Introduction</h1>
</DocsLayout>
)

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(<SyncedTabs />)
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(<SyncedTabs />)

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(<SyncedTabs />)
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,
})
}
})
6 changes: 6 additions & 0 deletions apps/docs/scripts/content-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions apps/docs/scripts/navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -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/")
})
})
38 changes: 26 additions & 12 deletions apps/docs/scripts/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,44 @@
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('<header data-pagefind-ignore>')
expect(sampleHtml).toContain('<main data-pagefind-body>')

const sampleHtml = await readFile(

Check failure on line 41 in apps/docs/scripts/search.test.ts

View workflow job for this annotation

GitHub Actions / Lint · Typecheck · Build

error: ENOENT: no such file or directory, open '/home/runner/work/interface/interface/apps/docs/.nitro-static/resources/faq/index.html'

at async <anonymous> (/home/runner/work/interface/interface/apps/docs/scripts/search.test.ts:41:30)
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.
})
})
11 changes: 11 additions & 0 deletions apps/docs/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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}</>
}
Loading
Loading