Skip to content

Docs Wiki β€” Design ​

  • Date: 2026-07-16
  • Status: Approved (brainstorming) β€” ready for implementation plan
  • Branch: feature/docs-wiki

Goal ​

A small, self-hosted wiki over the repo's docs/ markdown, added to the Nx workspace. It must:

  1. Render every .md under docs/ with a browsable sidebar + full-text search.
  2. Hot reload on add β€” editing a doc reloads instantly, and dropping in a new .md makes it appear in the sidebar live (no manual restart).
  3. Build to a static site that deploys to Cloudflare Pages, gated behind Cloudflare Access (internal-only; the docs contain internal architecture and authz notes that must not be world-readable).

Confirmed decisions ​

DecisionChoice
ToolVitePress (Vite-based SSG; instant HMR + built-in local search)
Where it livesNx project docs, rooted at the repo-root docs/ (not apps/docs) β€” content stays in place, .vitepress/ co-located per VitePress convention, no srcDir-outside-root gymnastics
Content scopedocs/**/*.md only
New-file behaviourAppears live on add (custom watch-restart plugin)
Deploynx build docs + Cloudflare Pages project dispatch-docs, gated by Cloudflare Access

Architecture ​

text
docs/                         ← Nx project root (project.json lives here); existing .md untouched
  .vitepress/
    config.mts                ← nav, search, sidebar wiring, srcExclude, ignoreDeadLinks, vite plugin
    sidebar.ts                ← hand-written tree scanner (buildSidebar) + pure helpers
    sidebar.test.ts           ← bun test coverage for the scanner
    live-sidebar.ts           ← Vite plugin: watch *.md add/unlink β†’ debounced server.restart()
    theme/
      index.ts                ← extends default theme, imports custom.css
      custom.css              ← thin Dispatch brand accent (derived from #C1D841 / #10130B)
  index.md                    ← minimal hand-authored landing page (VitePress "home" layout)
  project.json                ← nx targets: serve / build / preview (nx:run-commands, cwd: docs)
  …all existing docs…          ← rendered as pages, unchanged
  • VitePress project root = docs/. The dev server and build run with cwd: docs.
  • The Nx project is registered by docs/project.json; its name is docs.

Components (each independently understandable) ​

  • config.mts β€” the single source of VitePress configuration. Calls the sidebar scanner, registers the live-sidebar Vite plugin, sets exclusions and search. Depends on sidebar.ts + the plugin; consumed by the VitePress CLI.
  • sidebar.ts β€” a small hand-written scanner (~60 lines, unit-tested) that walks the docs tree at config-load and returns a nested, collapsible sidebar grouped by folder. Each file's label is its first # H1 (fallback: prettified filename); date-prefixed files (YYYY-MM-DD-…) sort newest-first, others alphabetically; empty folders are omitted. Paths are anchored to this file's own directory via import.meta.url, NOT process.cwd() β€” so it is correct regardless of the directory Nx runs the command from. This replaces the vitepress-sidebar package (whose documentRootPath resolves against process.cwd(), which is fragile under Nx cwd: docs, and whose exact API could not be verified against current docs). Pure helpers (titleFromContent, prettifyName) and the tree builder (buildSidebar) are covered by sidebar.test.ts run with bun test (no new test dependency).
  • live-sidebar.ts β€” a ~20-line Vite plugin (configureServer(server)). Watches via Vite's built-in chokidar watcher; on add/unlink of a .md (change events ignored to avoid loops, debounced ~150ms) it bumps the mtime of config.mts (via utimes, no content change β†’ git stays clean), which makes VitePress run its own config-change restart. That path re-runs config.mts β†’ buildSidebar() re-scans β†’ the new file is in the sidebar. Why not call server.restart() directly: a plugin-initiated server.restart() does not reset the local-search MiniSearch index, so with search.provider: 'local' every restart throws MiniSearch: duplicate ID … β†’ server restart failed and the sidebar never refreshes (vuejs/vitepress#4251). VitePress's own config-restart clears that index; touching the config delegates to it. This is the only bespoke runtime code beyond the scanner.
  • theme/custom.css β€” overrides VitePress --vp-c-brand-* to the Dispatch primary and sets the brand font family. Cosmetic; no logic.

Hot-reload-on-add: how it works ​

Two layers, deliberately separated:

  • Content edits β†’ VitePress/Vite HMR handles this natively. Free.
  • New file appears live β†’ Vite auto-registers a route for a new .md, but the sidebar is computed once at config-load, so it will not include the new file until config re-runs. live-sidebar.ts forces that re-run by touching config.mts (mtime bump) when a .md is added or removed, which triggers VitePress's own config-change restart (search-index-safe β€” see the live-sidebar.ts component note). Net effect: touch docs/foo.md β†’ it shows in the sidebar within ~1s, with local search intact.

Content scope & exclusions ​

Scope is docs/**/*.md only. Safety is mostly automatic:

  • VitePress serves only markdown β†’ the sensitive docs/google_client_secret*.json and docs/samples/*.pdf (and all .go / .ts / .py source under docs/implementation/contract-qa/) are never served. No code needed.
  • srcExclude: ['**/contract-qa/**'] β€” drops the vendored contract-qa prototype's README.md files from the VitePress page set. The sidebar.ts scanner applies the same exclusion (a contract-qa path check + skipping .vitepress, node_modules, samples) so the sidebar and the route set agree.
  • ignoreDeadLinks: true β€” the existing docs were not authored for VitePress routing and cross-reference each other with plain relative paths; without this the build fails on dead links. Set it now to keep nx build docs green; can be tightened to 'localhostLinks' or a targeted list later.

Theming ​

Thin brand layer only: theme/custom.css overrides the VitePress brand variables (--vp-c-brand-*, --vp-button-brand-*) with shades derived from the Dispatch primary #C1D841 / primary-content #10130B, so the site doesn't read as stock VitePress. For accessibility, links use a darkened lime on the light theme (the raw #C1D841 is a surface colour, not a text colour) and the raw lime on the dark theme; solid accents/buttons use the exact #C1D841 background with #10130B text. No brand fonts are loaded (avoids an external font fetch in the docs theme) β€” colour accent only. This is intentionally not a full port of the design system; that would be scope creep for a docs tool.

Nx targets & dependencies ​

docs/project.json (executor nx:run-commands, cwd: docs):

TargetCommandNotes
servebunx vitepress devHMR + live-sidebar watcher
buildbunx vitepress buildstatic output β†’ docs/.vitepress/dist; wire outputs for Nx cache
previewbunx vitepress previewserve the built site locally
  • Dev dependencies (via bun): vitepress only. No vitepress-sidebar (we hand-write the scanner), no extra watcher dependency (reuse Vite's bundled chokidar), no extra test runner (use built-in bun test).
  • .gitignore: add docs/.vitepress/dist and docs/.vitepress/cache.
  • The docs project participates in the workspace nx run-many -t lint test build gate (CI + husky pre-push): Nx infers a lint target, and project.json declares build + a test target that runs the sidebar scanner spec via bun test. This is intentional β€” a broken docs build/lint is caught before merge, consistent with the repo's "never let CI find it" rule. (Trade-off: a future doc with unescaped Vue {{ }} would fail nx build docs; wrap such spans in <div v-pre> β€” see the caveats. CI's ci.yml skips docs-only PRs via paths-ignore: docs/**, so pure-docs changes don't spend Actions minutes, but a mixed code+docs PR runs the docs targets too.)

Deploy β€” Cloudflare Pages behind Cloudflare Access ​

Mirrors the existing web deploy (cloudflare/wrangler-action@v3, CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID, manual workflow_dispatch).

  1. Pages project: dispatch-docs. Add infra/cloudflare/docs.wrangler.toml analogous to the existing wrangler.toml (pages_build_output_dir = "../../docs/.vitepress/dist").

  2. Deploy step β€” a dedicated .github/workflows/deploy-docs.yml, manual workflow_dispatch (kept separate from the product deploy.yml so docs and app releases stay decoupled):

    bash
    bun install --frozen-lockfile
    bunx nx build docs
    wrangler pages deploy docs/.vitepress/dist --project-name=dispatch-docs
  3. Cloudflare Access gate (mandatory β€” do not deploy ungated): create a Zero Trust self-hosted Access application covering the docs hostname:

    • Application domains: the production Pages hostname and the preview wildcard (*.dispatch-docs.pages.dev) so preview deployments are gated too.

    • Policy: Allow, include = an explicit allow-list of email addresses (not a whole-domain rule). Baseline entries:

      • damien@seeg.dev
      • damien@seeg.io
      • damien@seeg.co
      • damiens@gmail.com
      • damien@damien.in

      Additional addresses added as needed. Everything else is blocked at Cloudflare's edge before the site loads.

    • Documented as a Zero Trust dashboard/API setup step in this work; may be codified (Terraform/API) as a follow-up, matching the repo's current minimal-infra style.

The Access gate is what makes it safe to publish internal architecture/authz docs; the local nx serve docs tool needs no gating.

Caveats / risks ​

  • Never deploy without the Access gate. authz-matrix.md, auth-setup.md, and dispatch-production-design.md are internal; ungated publication would leak them. The deploy job must not be promoted to push: master until the Access application is confirmed in place.
  • Dead links: ignoreDeadLinks: true hides pre-existing broken cross-links rather than fixing them β€” acceptable for a first cut; revisit later.
  • Vue-interpolation in existing docs: VitePress compiles each page as a Vue template, so any literal {{ … }} outside a code fence is evaluated and can break the build. Existing docs weren't written with this in mind. Remediation if nx build docs fails on a page: wrap the offending block in <div v-pre> … </div> (or add that page to srcExclude). Handle case-by-case during the build task; do not pre-emptively edit docs.
  • .vitepress/ inside docs/: tooling now lives alongside content. This is standard VitePress and the tradeoff we accepted to root the project at docs/.

Out of scope (YAGNI) ​

Mermaid/graphviz diagram rendering, in-browser editing, auth beyond the Access gate, doc versioning, and a full design-system theme port. Revisit only if explicitly wanted.

Implementation outline (not the plan) ​

  1. bun add -d vitepress; scaffold docs/.vitepress/ with a minimal config.mts + index.md + project.json + .gitignore entries; verify nx serve docs and nx build docs work.
  2. Write sidebar.ts (+ sidebar.test.ts, TDD with bun test) and wire buildSidebar() into config.mts with srcExclude + ignoreDeadLinks + local search; verify the sidebar lists all docs and excludes contract-qa.
  3. Add live-sidebar.ts and register it; verify nx serve docs HMRs edits and shows a newly-dropped .md live.
  4. Add theme/index.ts + theme/custom.css (brand accent); verify the accent renders.
  5. nx build docs β†’ static output; add infra/cloudflare/docs.wrangler.toml and .github/workflows/deploy-docs.yml.
  6. Stand up the dispatch-docs Pages project + the Cloudflare Access application (explicit email allow-list); verify the gate blocks unauthenticated access on both production and preview URLs.