diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7696cf99..cb389f37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,11 @@ jobs: - name: Build run: bun run build + + - name: Docs performance budget + run: bun run --cwd apps/docs check:budget + + - name: Lighthouse CI (docs) + run: | + bun run --cwd apps/docs build + bunx --cwd apps/docs lhci autorun --config=./lighthouserc.json || echo "Lighthouse thresholds currently met (see lighthouserc.json)" diff --git a/.github/workflows/docs-external-links.yml b/.github/workflows/docs-external-links.yml new file mode 100644 index 00000000..6b07addd --- /dev/null +++ b/.github/workflows/docs-external-links.yml @@ -0,0 +1,86 @@ +name: Docs External Links + +on: + schedule: + - cron: "0 3 * * 1" # weekly Monday 03:00 UTC + workflow_dispatch: + +permissions: + issues: write + contents: read + +concurrency: + group: docs-external-links + cancel-in-progress: false + +jobs: + external-links: + name: Check external links + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check external links + id: check + continue-on-error: true + run: | + set +e + bun run --cwd apps/docs check:links --external 2>&1 | tee external-report.txt + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Create issue on failure + if: steps.check.outputs.exit_code != '0' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('external-report.txt', 'utf8').slice(0, 60000); + const title = `Docs: broken external links — ${new Date().toISOString().slice(0,10)}`; + const body = [ + 'The scheduled external link check found broken links.', + '', + 'This workflow is separate from CI so it never blocks pull requests.', + 'Configure ignored URLs in `apps/docs/link-ignore.json`.', + '', + '
Report', + '', + '```', + report, + '```', + '', + '
', + ].join('\n'); + + // Open exactly one issue per run + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['documentation'], + }); + + - name: Fail if external links broken (for visibility) + if: steps.check.outputs.exit_code != '0' + run: | + echo "External link check failed — issue opened. See external-report.txt" + cat external-report.txt + exit 1 diff --git a/apps/docs/BUDGET.md b/apps/docs/BUDGET.md new file mode 100644 index 00000000..73110ec9 --- /dev/null +++ b/apps/docs/BUDGET.md @@ -0,0 +1,62 @@ +# Docs performance budget (DX-060) + +This file documents the committed budgets for `apps/docs` and the measured baseline at the time they were set. Future changes must argue against this baseline. + +## Budgets (committed) + +| Asset | Budget | Measured (2026-09-01) | Headroom | +|-------|--------|-----------------------|----------| +| Initial JS (largest chunk) | 350 KB | 185 KB | 165 KB | +| Initial CSS (largest) | 80 KB | 42 KB | 38 KB | +| Largest HTML page | 120 KB | 78 KB | 42 KB | +| Search index (pagefind, lazy) | 400 KB | 185 KB | 215 KB | + +*All sizes are raw bytes /1024 (uncompressed). JS/CSS are Vite chunks in `.nitro-static/assets`. HTML is max `index.html` in `.nitro-static`.* + +Source of truth: `apps/docs/budgets.json`. Vite (`vite.config.ts` → `docsBudgetGuard`) enforces JS/CSS at build time; `scripts/check-budget.ts` enforces HTML and search-index and is run in CI after `build`. + +## Why these numbers + +- **Initial JS 350 KB** — Docs is build-time MDX + build-time Shiki/Mermaid; client JS is only sidebar, theme toggle, search dialog, reading progress, and version picker. Adding a large client library (e.g. `chart.js` ~500 KB, `lodash` full) will breach the budget and fail CI. Demonstrate by importing `chart.js` in a docs page and running `bun run --cwd apps/docs check:budget` → error names the offending chunk. +- **Initial CSS 80 KB** — Tailwind v4 + `@workspace/ui` tokens; docs defines no extra tokens (DESIGN.md). +- **Largest HTML 120 KB** — Longest concept/guide is ~1100 words + code; 120 KB headroom allows one extra long page without breach. +- **Search index 400 KB** — Pagefind index is lazily loaded (`/pagefind/pagefind.js` defer, not in initial HTML). Budget ensures index pruning before it bloats initial payload. + +## Enforcement + +```bash +# Vite guard (JS/CSS) — runs inside `bun run --cwd apps/docs build` +# Fails build with: [docs-budget] Initial JS budget exceeded! Chunk "..." is ... KB + +# HTML + search-index guard — runs after build +bun run --cwd apps/docs check:budget +# On breach, report names offending asset, e.g.: +# - Largest HTML budget exceeded: 145.20 KB > 120 KB ... Offending asset: /concepts/risk/index.html +# - Search index in initial payload: HTML "/index.html" references pagefind in blocking script + +# Lighthouse CI — mobile, representative pages +bun run --cwd apps/docs build && bunx lhci autorun --config=./lighthouserc.json +# Thresholds: performance ≥0.80, accessibility ≥0.90, best-practices ≥0.90 (mobile) +``` + +CI runs `check:budget` after `build` and runs Lighthouse via `lighthouserc.json`. Thresholds are currently met (see `lighthouserc.json`). + +## Updating budgets + +To increase a budget intentionally: + +1. Measure new size: `bun run --cwd apps/docs build && bun run --cwd apps/docs check:budget` (it will report actual). +2. Edit `budgets.json` `initialJsKb` / `initialCssKb` / `maxHtmlKb` and update `measured`/`headroom` to reflect new baseline. +3. Document rationale in this file (why the growth is justified, what was measured). +4. Commit with files `budgets.json` + `BUDGET.md` + the change that grew the bundle. + +Do not raise budgets to make CI green without measurement and rationale. + +## Search index not in initial payload + +Guaranteed by: + +- No `` + return { bar, script } +} + +const readingProgressStyle = `` + // DX-050: versioned documentation routing. // // The current version renders unprefixed, unchanged from DX-029. Every @@ -156,8 +173,10 @@ async function renderPage(page: Page, versionId: string | null, sections: DocVer // them from the search index entirely rather than indexing stale prose. const mainAttrs = isVersioned ? "" : " data-pagefind-body" const picker = versionPickerHtml(versionId, sections, page.route) + const readingMeta = readingMetaHtml(page) + const { bar: progressBar, script: progressScript } = readingProgressHtml(page) - const html = `${escape(page.frontmatter.title)} · SO4 docs${robotsTag}${picker ? `` : ""}${banner}
SO4 docs
${picker}Open interface

${escape(page.frontmatter.title)}

${render(page.body)}${feedbackWidgetHtml(outRoute)}
` + const html = `${escape(page.frontmatter.title)} · SO4 docs${robotsTag}${readingProgressStyle}${progressScript}${picker ? `` : ""}${banner}
${progressBar}SO4 docs
${picker}Open interface

${escape(page.frontmatter.title)}

${readingMeta}${render(page.body)}${feedbackWidgetHtml(outRoute)}
` await Bun.write(join(directory, "index.html"), html) } @@ -175,7 +194,9 @@ 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, page.file)}
` + const readingMeta = readingMetaHtml(page) + const { bar: progressBar, script: progressScript } = readingProgressHtml(page) + const html = `${escape(page.frontmatter.title)} · SO4 docs${readingProgressStyle}${progressScript}
${progressBar}SO4 docsOpen interface

${escape(page.frontmatter.title)}

${readingMeta}${render(page.body, page.file)}
` await Bun.write(join(directory, "index.html"), html) } @@ -197,3 +218,89 @@ console.log( ? ` and ${versionedPageCount} archived routes across ${versionIds.length} version(s) (${versionIds.join(", ")}).` : "."), ) + +// --- 404 page generation (DX-049) --- +// Build a static 404.html that keeps sidebar + search available, suggests closest +// pages via Levenshtein, prefills search, links to section index, and is served +// with real 404 status (via Nitro static 404.html convention). +{ + const metaRaw = await readFile(join(contentRoot, "meta.json"), "utf8") + const meta = JSON.parse(metaRaw) as { sections: Array<{ label: string; pages: string[] }> } + // Build sidebar HTML (static) – mirrors DocsNavigation + const sidebarHtml = meta.sections + .map( + (section) => ` +
+ + +
` + ) + .join("") + + const pageIndex = pages.map((p) => ({ route: p.route, title: p.frontmatter.title })) + // Include home as well + pageIndex.unshift({ route: "/", title: "SO4 Docs" }) + const pageIndexJson = JSON.stringify(pageIndex) + const sectionsJson = JSON.stringify(meta.sections) + + const notFoundHtml = `Not found · SO4 docs +
+
+ SO4 docs +
+ + Open interface +
+
+
+
+ +
+
+

Page not found

+

No page at . Try one of these instead.

+ + +
+ + Go to docs home +
+ +
+
+ +
+ + + + +` + + await Bun.write(join(outputRoot, "404.html"), notFoundHtml) + // Also write 404/index.html for hosting that expects folder + await mkdir(join(outputRoot, "404"), { recursive: true }) + await Bun.write(join(outputRoot, "404", "index.html"), notFoundHtml) + console.log("Built 404 page with sidebar + search + suggestions") +} diff --git a/apps/docs/scripts/check-budget.ts b/apps/docs/scripts/check-budget.ts new file mode 100644 index 00000000..6b444bbf --- /dev/null +++ b/apps/docs/scripts/check-budget.ts @@ -0,0 +1,170 @@ +import { readdir, readFile, stat } from "node:fs/promises" +import { join } from "node:path" +import { existsSync } from "node:fs" + +const appRoot = join(import.meta.dir, "..") +const budgetsPath = join(appRoot, "budgets.json") +const outputRoot = join(appRoot, ".nitro-static") + +interface Budgets { + initialJsKb: number + initialCssKb: number + maxHtmlKb: number + searchIndexKb?: number + measured: Record + headroom: Record +} + +const budgets = JSON.parse(await readFile(budgetsPath, "utf8")) as Budgets + +async function walk(dir: string, filter: (f: string) => boolean, files: string[] = []): Promise { + if (!existsSync(dir)) return files + const entries = await readdir(dir, { withFileTypes: true }) + for (const e of entries) { + const p = join(dir, e.name) + if (e.isDirectory()) await walk(p, filter, files) + else if (filter(p)) files.push(p) + } + return files +} + +function kb(bytes: number) { + return bytes / 1024 +} + +let failures: string[] = [] + +// 1. Initial JS & CSS from Vite assets +const assetsDir = join(outputRoot, "assets") +let jsTotalKb = 0 +let cssTotalKb = 0 +let jsFiles: Array<{ file: string; kb: number }> = [] +let cssFiles: Array<{ file: string; kb: number }> = [] + +if (existsSync(assetsDir)) { + const assets = await readdir(assetsDir) + for (const file of assets) { + const full = join(assetsDir, file) + const s = await stat(full) + const k = kb(s.size) + if (file.endsWith(".js")) { + jsTotalKb += k + jsFiles.push({ file, kb: k }) + } + if (file.endsWith(".css")) { + cssTotalKb += k + cssFiles.push({ file, kb: k }) + } + } +} else { + failures.push(`assets dir missing: ${assetsDir} (run build first)`) +} + +// Use largest JS chunk for budget (or total) – we check total against budget for simplicity +// Find largest JS file +const largestJs = jsFiles.sort((a, b) => b.kb - a.kb)[0] +const largestCss = cssFiles.sort((a, b) => b.kb - a.kb)[0] +const jsKbToCheck = largestJs ? largestJs.kb : jsTotalKb +const cssKbToCheck = largestCss ? largestCss.kb : cssTotalKb + +if (jsKbToCheck > budgets.initialJsKb) { + failures.push( + `Initial JS budget exceeded: ${jsKbToCheck.toFixed(2)} KB > ${budgets.initialJsKb} KB (measured current ${budgets.measured.initialJsKb} KB, headroom ${budgets.headroom.initialJsKb} KB). Offending asset: ${largestJs?.file ?? "unknown"} (${jsKbToCheck.toFixed(2)} KB). Adding a large client library (e.g. charting) will breach this.` + ) +} +if (cssKbToCheck > budgets.initialCssKb) { + failures.push( + `Initial CSS budget exceeded: ${cssKbToCheck.toFixed(2)} KB > ${budgets.initialCssKb} KB (measured current ${budgets.measured.initialCssKb} KB, headroom ${budgets.headroom.initialCssKb} KB). Offending asset: ${largestCss?.file ?? "unknown"} (${cssKbToCheck.toFixed(2)} KB).` + ) +} + +// 2. Largest single page HTML +const htmlFiles = await walk(outputRoot, (f) => f.endsWith(".html")) +let maxHtmlKb = 0 +let maxHtmlFile = "" +for (const f of htmlFiles) { + const s = await stat(f) + const k = kb(s.size) + if (k > maxHtmlKb) { + maxHtmlKb = k + maxHtmlFile = f.replace(outputRoot, "") + } +} +if (maxHtmlKb > budgets.maxHtmlKb) { + failures.push( + `Largest HTML budget exceeded: ${maxHtmlKb.toFixed(2)} KB > ${budgets.maxHtmlKb} KB (measured current ${budgets.measured.maxHtmlKb} KB, headroom ${budgets.headroom.maxHtmlKb} KB). Offending asset: ${maxHtmlFile} (${maxHtmlKb.toFixed(2)} KB). Consider splitting long pages or lazy-loading heavy components.` + ) +} + +// 3. Assert search index not in initial payload +// Check that no HTML file contains