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
10 changes: 4 additions & 6 deletions .changelog/unreleased/540-dx-028-mdx-pipeline.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
---
category: added
type: added
area: docs
description: "MDX build pipeline, content loader, sidebar navigation, and Callout component for the docs site"
pr: 540
breaking: false
---

- Added typed frontmatter schema with build-time validation (DX-028)
- Added content loader with kebab-case validation and duplicate route detection (DX-029)
- Added sidebar navigation driven by meta.json manifests with keyboard support and localStorage collapse state (DX-031)
- Added Callout component with four admonition variants: note, tip, warning, caution (DX-036)
Added MDX build pipeline, content loader, sidebar navigation, and Callout component for the docs site.
8 changes: 8 additions & 0 deletions .changelog/unreleased/552-docs-site.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: docs
pr: 552
breaking: false
---

A complete developer documentation site is now available, featuring a statically generated search index and robust syntax highlighting.
3 changes: 3 additions & 0 deletions apps/docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.nitro-static/
.nitro/
.output/
10 changes: 10 additions & 0 deletions apps/docs/middleware/redirects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default defineEventHandler((event) => {
const redirects: Record<string, string> = {
'/old-path': '/new-path'
};

const path = event.path;
if (redirects[path]) {
return sendRedirect(event, redirects[path], 301);
}
});
31 changes: 31 additions & 0 deletions apps/docs/nitro.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineNitroConfig } from "nitro/config"

export default defineNitroConfig({
publicAssets: [
{
dir: "public",
},
{
dir: ".nitro-static",
},
],
routeRules: {
"/**": {
headers: {
"Cache-Control": "s-maxage=300, stale-while-revalidate=86400",
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
}
},
"/assets/**": {
headers: {
"Cache-Control": "public, max-age=31536000, immutable"
}
}
},
handlers: [
{
route: "/old-path",
handler: "./redirect.ts"
}
]
})
26 changes: 24 additions & 2 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"build": "bun run scripts/build.ts",
"dev": "bun run scripts/build.ts",
"build": "bun run scripts/build.ts && bunx --bun pagefind --site .nitro-static && bunx nitro build",
"dev": "bun run scripts/build.ts && bunx nitro dev",
"lint": "bun run check:content",
"format": "prettier --write \"content/**/*.mdx\" \"scripts/**/*.ts\" \"src/**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit",
Expand All @@ -21,14 +21,36 @@
"check:errors:generated": "bun run ../../scripts/generate-errors-reference.ts --check"
},
"dependencies": {
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
"@tanstack/react-router": "^1.170.32",
"@workspace/ui": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zod": "^3.25.76"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.11.6",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/rollup": "^3.1.0",
"@repo/vitest-config": "workspace:*",
"@shikijs/rehype": "^4.4.3",
"@shikijs/transformers": "^4.4.3",
"@testing-library/react": "^16.3.2",
"@types/bun": "^1.3.0",
"@types/mdx": "^2.0.14",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"h3": "^2.0.1-rc.29",
"happy-dom": "^20.11.6",
"jsdom": "^30.0.1",
"nitro": "^3.0.260610-beta",
"pagefind": "^1.5.2",
"prettier": "^3.8.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.2.0",
"shiki": "^4.4.3",
"typescript": "^5.9.3",
"vite": "^7.3.2"
}
Expand Down
Empty file.
2 changes: 2 additions & 0 deletions apps/docs/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { defineEventHandler, sendRedirect } from "h3"
export default defineEventHandler((event) => sendRedirect(event, "/new-path", 301))
1 change: 1 addition & 0 deletions apps/docs/routes/old-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default defineEventHandler((event) => sendRedirect(event, '/new-path', 301));
11 changes: 7 additions & 4 deletions apps/docs/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir } from "node:fs/promises"
import { mkdir, rm } from "node:fs/promises"
import { join } from "node:path"

import { $ } from "bun"
Expand All @@ -10,8 +10,10 @@ await $`bun run ${join(appRoot, "scripts/generate-faq.ts")} --check`
await $`bun run ${join(appRoot, "../../scripts/generate-design-tokens.ts")} --check`
await $`bun run ${join(appRoot, "../../scripts/generate-errors-reference.ts")} --check`

const pages = await loadPages()
const outputRoot = join(appRoot, ".output/public")
const pages = (await loadPages()).filter(
(page) => page.frontmatter.status !== "draft",
)
const outputRoot = join(appRoot, ".nitro-static")

function escape(value: string) {
return value
Expand Down Expand Up @@ -57,10 +59,11 @@ function render(body: string) {
.join("\n")
}

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><a href="/">SO4 docs</a><a href="https://so4.market">Open interface</a></header><main><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)}"><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>`
await Bun.write(join(directory, "index.html"), html)
}

Expand Down
117 changes: 117 additions & 0 deletions apps/docs/scripts/components.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 { components } from "../src/mdx/components"
import * as jsxRuntime from "react/jsx-runtime"
import { compile, run } from "@mdx-js/mdx"
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router"
import { shikiPlugin } from "../src/lib/rehype-shiki"
import remarkGfm from "remark-gfm"

beforeAll(() => {
GlobalRegistrator.register()
})

afterAll(() => {
GlobalRegistrator.unregister()
})

afterEach(() => {
cleanup()
})

const mdxFixture = `
# Heading 1

## Heading 2

This is a paragraph with some \`inline code\`.

> This is a blockquote.

| Column 1 | Column 2 |
| -------- | -------- |
| Value 1 | Value 2 |

Here is an [internal link](/foo) and an [external link](https://example.com).

- Item 1
- Item 2

---

![Alt text](/image.png)
`

test("MDX components map renders kitchen-sink fixture correctly", async () => {
const compiled = await compile(mdxFixture, {
outputFormat: "function-body",
remarkPlugins: [remarkGfm],
rehypePlugins: [shikiPlugin],
})

const { default: MDXContent } = await run(String(compiled), {
...jsxRuntime,
})

const rootRoute = createRootRoute({
component: () => <MDXContent components={components} />,
})

const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory(),
})

let container: HTMLElement
await act(async () => {
const result = render(<RouterProvider router={router} />)
container = result.container
})

// Verify Heading 1 (Typography via Heading)
const h1 = container!.querySelector("h1")
expect(h1).not.toBeNull()
expect(h1?.className).toContain("text-22")
expect(h1?.className).toContain("font-semibold")

// Verify blockquote (Callout)
const callout = container.querySelector("[role='status']")
expect(callout).not.toBeNull()
expect(callout?.textContent).toContain("This is a blockquote.")

// Verify inline code
const codes = Array.from(container.querySelectorAll("code"))
const inlineCode = codes.find(c => c.textContent === "inline code")
expect(inlineCode).not.toBeUndefined()
expect(inlineCode?.className).toContain("bg-surface-sunken")

// Verify internal link
const internalLink = container.querySelector("a[href='/foo']")
expect(internalLink).not.toBeNull()
expect(internalLink?.className).toContain("hover:underline")

// Verify external link
const externalLink = container.querySelector("a[href='https://example.com']")
expect(externalLink).not.toBeNull()
expect(externalLink?.getAttribute("target")).toBe("_blank")
expect(externalLink?.getAttribute("rel")).toBe("noopener noreferrer")
expect(externalLink?.querySelector("svg")).not.toBeNull() // Arrow icon

// Verify table structure
const tableContainer = container.querySelector("[data-slot='table-container']")
if (!tableContainer) {
console.log("HTML Output:", container.innerHTML)
}
expect(tableContainer).not.toBeNull()
expect(tableContainer?.className).toContain("overflow-x-auto")
expect(tableContainer?.querySelector("table")).not.toBeNull()
})
55 changes: 55 additions & 0 deletions apps/docs/scripts/nitro.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect, beforeAll, afterAll } from "bun:test";
import { spawn, Subprocess } from "bun";
import { join } from "node:path";

let server: Subprocess;
const PORT = 3005;

beforeAll(async () => {
const appRoot = import.meta.dirname.replace(/\/scripts$/, "");
server = spawn(["node", join(appRoot, ".output/server/index.mjs")], {
env: { ...process.env, PORT: String(PORT) },
stdout: "inherit",
stderr: "inherit",
});

// wait for server to start
for (let i = 0; i < 50; i++) {
try {
const res = await fetch(`http://localhost:${PORT}/resources/faq`);
if (res.ok) break;
} catch (e) {
await Bun.sleep(100);
}
}
});

afterAll(() => {
server?.kill();
});

test("serves HTML with correct headers", async () => {
const res = await fetch(`http://localhost:${PORT}/resources/faq`);
expect(res.status).toBe(200);

const cacheControl = res.headers.get("cache-control");
expect(cacheControl).toBe("s-maxage=300, stale-while-revalidate=86400");

const csp = res.headers.get("content-security-policy") || "";
expect(csp).toContain("default-src 'none'");
expect(csp).not.toContain("'unsafe-eval'");
expect(csp).not.toContain("'unsafe-inline' script");
});

test("serves hashed assets with immutable cache", async () => {
// Test routeRules for /assets/**
const res = await fetch(`http://localhost:${PORT}/assets/fake.css`);
const cacheControl = res.headers.get("cache-control");
expect(cacheControl).toBe("public, max-age=31536000, immutable");
});

test("redirects resolve with a 301", async () => {
const res = await fetch(`http://localhost:${PORT}/old-path`, { redirect: "manual" });
expect(res.status).toBe(301);
expect(res.headers.get("location")).toBe("/new-path");
});
38 changes: 38 additions & 0 deletions apps/docs/scripts/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun"
import { join } from "node:path"
import { existsSync } from "node:fs"
import { readFile } from "node:fs/promises"

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

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

// 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")
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>')

// 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
// relying on pagefind's own directives is the supported way to assert this.
})
})
Loading
Loading