/CLAUDE.md
CLAUDE.md at /CLAUDE.md
Path: CLAUDE.md
zudo-doc
Minimal documentation framework built with zfb, MDX, Tailwind CSS v4, and Preact islands.
The current repository contract targets zfb exclusively.
Setup on a new machine and the local-zfb/zdtp escape hatch live in CONTRIBUTING.md.
Tech Stack
zfb (
@takazudo/zfb) — static site generator with MDX content collections, file-routedpages/, and a built-in dev/build/preview/check CLIMDX — authored under
src/content/, content directory configurable via thedocsDirsetting; pipeline configured inzfb.config.tsTailwind CSS v4 — compiled by zfb's embedded Tailwind engine;
@import "tailwindcss/preflight"/"tailwindcss/utilities"insrc/are intercepted by zfb's internal resolver and never reachstyles/ global. css node_modules, so neithertailwindcssnor@tailwindcss/viteis a dependency of this projectPreact — for interactive islands (TOC scroll spy, sidebar toggle, collapsible categories) and server-rendered content typography components; runs in compat mode for React API compatibility
zfb semantic highlighting — document fences use zfb's native build-time renderer; HtmlPreview lazily imports the public
@takazudo/zfb-md-wasmroot for browser-time HTML/CSS/JavaScript. Both emitpre.hi-root/hi-*classes and resolve through--zd-syntax-*design tokens. Do not add Shiki, theme-name config, inline token colors, or package-internal WASM paths.@takazudo/zdtp (zdtp) — external npm package that owns the Design Token Panel UI; the package-owned
DesignTokenPanelBootstrapisland configures it from a mode-scoped builder and self-mounts it as a side effectTypeScript — strict mode via
@takazudo/, which the projectzudo- doc/ tsconfig. base. json tsconfig.jsonextends. The project file adds exactly one extra compiler flag of its own,noUncheckedIndexedAccess, plusinclude/exclude/baseUrl/paths(excludedropssrc/**/__tests__; thepathsblock is required, not cosmetic — see the GOTCHA inpackages/).zudo- doc/ CLAUDE. md
Commands
pnpm dev— runs zfb dev (port 4321), doc-history-server (port 4322), a.claude/watcher, and@takazudo/zudo-doc's own paired JS + declarations watchers concurrently viarun-p; edits to.claude/files regenerate the corresponding MDX live, and edits topackages/auto-rebuildzudo- doc/ src/ ** dist/so zfb HMR picks them up. If a previous dev process is still bound to 4321 / 4322, the new launch fails fast withEADDRINUSE— kill it manually before retrying (e.g.lsof -ti :4321 -ti :4322 | xargs -r kill, after confirming the matched PIDs are actually yours). A hook used to do this automatically; it was removed because matching by port alone meantpnpm devwould silently kill unrelated apps on the same port (4321 is the Vite default) — do not re-add one. A fatal exit in any one of those processes tears down all of them — for why, the inotifyEMFILE-vs-ENOSPCdistinction, and why--continue-on-erroris the wrong remedy, see the#3129section inpackages/.zudo- doc/ CLAUDE. md pnpm dev:zfb— zfb dev server only (port 4321)pnpm dev:history— doc history API server only (port 4322)pnpm dev:zudo-doc—@takazudo/zudo-doc's watchers only: tsup--watchfor the JS plustsc -p tsconfig.build.json --watchfor the.d.ts, run in parallel (#3113); host imports resolve throughdist/because the package now ships compiled JS (W8 Blocker-2 fix — Node 24 rejects raw.tsinnode_modules, so the package's source is private and dist is the API surface)pnpm dev:stable— alternative build-then-serve dev mode (avoids HMR crashes on content file add/remove)pnpm dev:network— zfb dev with--host 0.0.0.0for LAN accesspnpm build— static HTML export todist/(runszfb build)pnpm preview— serve the builtdist/(runszfb preview)pnpm check— type checking (runszfb check, which delegates totsc --noEmit)pnpm b4push— pre-push validation: 24-step suite (format check → template drift → no-host-alias guard → pin parity → fixture drift → tags audit → current-only compatibility contract → token lint → component-tokens drift → e2e spec naming guard → @flaky tracking-issue guard → wait-debt guard → b4push/CI parity → typecheck → Worker contract proof → root unit tests → package tests → safelist check → build → content-fallback allowlist scan → link check → html validation → preview smoke → manual smoke); each step's elapsed time is recorded and printed as a breakdown in the final summary. The content-fallback step (scripts/) is the allowlist-gated half of the content-bridge guard — the non-allowlisted half,check- content- fallback. mjs strictContentBridge: trueinzfb.config.ts, fails plainpnpm build/CI directly and never runs as a b4push step; see the script's header for why both exist. Playwright E2E runs in CI (pr-checks e2e job) and is intentionally excluded from b4push for time-budget reasons — seeTESTING.mdfor the full tier rationalepnpm test— unified test entry point: runsbuild:workspace(a full rebuild of both workspace packages) then root unit tests (test:unit) and workspace package tests (test:packages); does not include e2epnpm build:workspace— force-rebuild the workspace packages consumers compile against, in dependency order:@takazudo/zudo-doc-history-serverthen@takazudo/zudo-docpnpm ensure:workspace-build— the same list, but builds only what is missing; a no-op on a warm tree
Workspace build prerequisite (#3053)
dev, build, check, check:pages, test:unit, and test:packages each run ensure:workspace-build first (b4push runs it as a preflight), so an --ignore-scripts tree self-heals. Two facts that cost real debugging time if forgotten:
Order is mandatory.
@takazudo/zudo-doc'stscpass needs@takazudo/zudo-doc-history-server's declarations (pre-build.tsimports itsgit-historysubpath), so buildingzudo-docalone on a cold tree fails with TS2307.scripts/owns that order.ensure- workspace- build. mjs The guard checks existence, not freshness — it never rebuilds a stale
dist/, so a deleted or renamed source file leaves a stale artifact it happily accepts. Runpnpm build:workspaceafter a delete, rename, orexports-map removal.
Don't run pnpm test, pnpm build:workspace, or pnpm b4push while pnpm dev is live — those rebuild unconditionally and would write dist/ under the watchers. pnpm check is safe alongside pnpm dev. The #3113 clean: !options.watch history is in packages/.
Automation
These run automatically — be aware when working in this repo:
lefthook pre-commit (
lefthook.yml): on commit, staged*.mdand*.mdxfiles are formatted with@takazudo/mdx-formatterand re-added. You do not need to manuallypnpm formatmarkdown before committing.prepare:
pnpm installrunslefthook installandscripts/(the worktree push-guard hook). zfb and zdtp come straight from npm — there is no link/build postinstall step anymore.install- git- hooks. sh
Worktree push policy (enforced)
This repo uses / for multi-topic development. Child agents work in git worktrees under worktrees/. Pushing from a worktree is forbidden. Only the manager session — running from the main repo at the repo root — pushes, after merging topic branches into the base branch locally.
Why
CI runs on every push. Children pushing pre-empt the manager's merge + review step, multiplying CI cost across intermediate state.
Topic branches in
worktrees/*/are intermediate by design — they shouldn't appear as standalone PRs unless the manager creates them.
How it's enforced
. is a direct script (not managed via lefthook.yml) that blocks any push from a git worktree. It is auto-installed by pnpm install (via the prepare lifecycle script) and can be re-installed manually with:
pnpm init-worktreeThe installer source lives at scripts/; the hook itself at scripts/.
Emergency bypass (human use)
ALLOW_WORKTREE_PUSH=1 git push ...Use only when you genuinely need to push from a worktree (rare). Never set this in agent prompts.
Guidance for agents
Child agents working in
worktrees/*/: commit locally only. Pushing will fail with the message above — do not retry, do not invoke the bypass. Report back to the manager with the branch name and commit SHAs; the manager merges and pushes from the main repo./manager session: the hook does not affect you. Yourx- wt- teams git pushruns from the main repo (the cwd is the repo root, notworktrees/...). After every wave's local merges, push as usual. Do not passALLOW_WORKTREE_PUSHto children.
Key Directories
Package-first migration (epic #2321) + the minimal-scaffold cutover (epic #2651) moved engine plugins, chrome/routes/islands, and most host wiring into @takazudo/zudo-doc. There is no root-level plugins/ directory — plugins resolve as @takazudo/ bare specifiers. Most of what a fresh create-zudo-doc scaffold ships lives in node_modules, not the project tree; the entries marked SHOWCASE below exist here because this repo demonstrates every feature with real data, and a fresh scaffold has none of them.
zfb.config.ts # The ONE config entry — zudoDoc({ ...settings, chromeBindingsModule })
# (@takazudo/zudo-doc/config). Collections/plugins/markdown logic is
# package-owned via zudoDocPreset(), which zudoDoc() calls.
pages/ # File-routed pages (.tsx): docs/, [locale]/docs/, v/ (versioned), api/
└── lib/ # SHOWCASE host bindings (SearchWidget, DocHistory data, frontmatter
# renderers, …), threaded in via src/chrome-bindings.tsx
packages/
├── search-worker/ # CF Worker search API (opt-in; the showcase does not deploy it)
├── doc-history-server/ # Doc history REST API + CLI generator
├── zudo-doc/ # Shared layout + integration package — owns chrome/routes/islands/plugins/preset
└── create-zudo-doc/ # CLI scaffold tool — emits the locked ~17-file minimal manifest
# (the zudo-doc-online app that used to live here was extracted to
# the private repo zudolab/zudo-doc-cloud — #3371)
src/
├── chrome-bindings.tsx # SHOWCASE ChromeHostBindings impl, wired via chromeBindingsModule
├── components/ # SHOWCASE client islands: client-router-bootstrap, preset-generator
├── config/ # SHOWCASE settings, tag vocabulary, sidebars, i18n, contrast utils,
│ # frontmatter preview renderers (color schemes moved to the package)
├── content/{docs,docs-ja}/ # English + Japanese MDX content (JA mirrors EN)
├── lib/ # SHOWCASE island logic (preset-generator-logic.ts)
├── types/ # SHOWCASE types + ambient decls (locale.ts, doc-history-meta.d.ts)
├── utils/ # SHOWCASE helpers (base.ts, docs.ts, tags.ts)
└── styles/global.css # @theme tokens, feature styles, slots; @imports the shared
# @takazudo/zudo-doc/theme.css + content.css chromeBindingsModule is the supported callable/markup seam for package-owned chrome. defineChromeBindings accepts partial objects (omitted slots keep package defaults); its six primary replacement keys are Header, Footer, Sidebar, Toc, Breadcrumb, DocPager, and serializable custom headerRightItems names resolve through headerRightComponents. Do not add a legacy host DesignTokenPanel override or resurrect removed public aliases. Full contract: packages/.
Content Collections
Schema ships from the package (
@takazudo/, Zod validation) and is wired byzudo- doc/ docs- schema zudoDocPreset().zfb.config.tsdeliberately OMITSbuildDocsSchema— override it there only when the showcase genuinely needs to diverge.Loaded via zfb's MDX content pipeline with a configurable
basedirectory from settingsContent directories:
docsDir(default:src/) for the default locale; every other locale declares its owncontent/ docs dirunderlocales(this showcase:locales.)ja. dir = "src/ content/ docs- ja"
Terminology: "Update docs"
When we say "update docs" or "update our doc," it means updating the showcase documentation content in src/ (English) and src/ (Japanese). Since zudo-doc is a documentation framework, its own content directories serve as the default showcase. These are the pages visible when running pnpm dev.
i18n
English (default):
/— content indocs/ . . . docsDir(src/)content/ docs Japanese:
/— content inja/ docs/ . . . locales.ja.dir(src/)content/ docs- ja The default locale is served unprefixed — that falls out of
defaultLocale: "en"plus the route split inpages/(pages/docs/[[...slug]].tsxvspages/[locale]/docs/[[...slug]].tsx). There is noprefixDefaultLocaleoption; don't look for one.Bilingual rule: when creating or updating any doc page, update both EN and JA versions. Detailed exceptions and the content-writing workflow live in
src/(auto-loaded when working on content).content/ CLAUDE. md
Doc Skill (setup-doc-skill)
The doc-skill (scripts/) generates . and symlinks docs into it. It is gitignored — do NOT track the generated SKILL.md in git. Run pnpm setup:doc-skill to regenerate. To update the skill template, edit scripts/.
This script is also the source template copied to downstream projects by create-zudo-doc when the skillSymlinker feature is enabled.
Doc History Architecture
Document git history is handled by a standalone package @takazudo/zudo-doc-history-server (at packages/doc-history-server/), intentionally decoupled from the main build pipeline so expensive git log --follow calls do not block it. Two modes:
Server mode (local dev) — HTTP server on port 4322, started by
pnpm dev:history. The zfb plugin atpackages/proxieszudo- doc/ src/ plugins/ internal/ doc- history/ index. ts /requests to it.doc- history/ * CLI mode (CI) — batch-generates JSON files into
dist/doc-history/. Used by thebuild-historyCI job in parallel with the main site build.
postBuild JSON: the env-var decision table
Two artifacts, gated differently. The preBuild Created/Updated/Author manifest (the visible per-page metadata block) is gated by SKIP_DOC_HISTORY alone — set SKIP_DOC_HISTORY=1 and the plugin writes an empty manifest ({}), making that block absent from every SSG page; use it only when intentionally bypassing git-based meta generation (a truly shallow clone, a custom CI variant).
The postBuild step (per-page history-dropdown JSON in dist/doc-history/) is skipped by default on local builds, opt-in via GEN_DOC_HISTORY=1 (#1986). It defaults off because it runs one git log --follow chain per content file, which on a large corpus exceeds zfb's 120s postBuild lifecycle-hook budget and fails a plain pnpm build — and the JSON is redundant locally anyway (dev reads it live from :4322; CI generates it in the parallel build-history job). The table (in runDocHistoryPostBuild / shouldGeneratePostBuild):
SKIP_DOC_HISTORY=1→ never generate (wins over everything).DOC_HISTORY_SKIP_POSTBUILD=1→ never generate (wins overGEN_DOC_HISTORYand CI, loses only toSKIP_DOC_HISTORY).GEN_DOC_HISTORY=1→ generate (local opt-in — e.g. beforepnpm previewof a locally-builtdist/).CI (
CI/GITHUB_ACTIONS) → generate (keeps the CI build-site artifact identical; the async generator stays within budget).otherwise (plain local build) → skip.
DOC_HISTORY_SKIP_POSTBUILD=1 therefore skips only the dropdown JSON, leaving preBuild metadata intact — for a shallow-clone CI variant that keeps enough git history for the cheap preBuild walk but opts out of the heavier chain (#2927). Its name deliberately avoids the substring SKIP_DOC_HISTORY so scripts/'s literal survivor scan for that marker needs no allowlist update.
CI Pipeline
All three workflows (main-deploy.yml, pr-checks.yml, preview-deploy.yml) use parallel build jobs:
build-site — full clone (
fetch-depth: 0),pnpm build— preBuild populates.with real git dates so the SSG HTML contains the visible Created/Updated/Author blockzfb/ doc- history- meta. json build-history — full clone (
fetch-depth: 0),@takazudo/zudo-doc-history-server generate— generates per-page dropdown JSON files for the DocHistory islanddeploy/preview — merges both artifacts, deploys via
wrangler deployto Cloudflare Workers static assets atzudo-doc.takazudomodular.com
E2E tests also run with full clone (no SKIP_DOC_HISTORY).
Workers Cutover Runbook
One-time Cloudflare operator setup (KV namespace, secrets, the AiChatDailySpendCap Durable Object migration, DOCS_SITE_URL, custom domain) is already done for this project. The runbook, its IP_HASH_SECRET rotation caveat, and the preview-vs-Durable-Objects note live in docs/ — read it before a fresh cutover or a Cloudflare account change.
Feature Change Checklist
This is the ONE canonical ordering. packages/ points here rather than keeping a second copy — do not re-fork it.
Since the minimal-scaffold cutover (epic zudolab/zudo-doc#2651), a feature's field census lives in ONE place (packages/) and a generated project's config is a SINGLE file (zfb.config.ts, diff-from-defaults zudoDoc({...})). The old settings-gen.ts + zfb-config-gen.ts two-file split, and the per-project src/ a fresh scaffold used to ship, are both gone from the generator's output — this repo's own showcase still has a real src/ (spread into zudoDoc({...settings})) because it demonstrates every feature with real data, but that's a showcase choice, not something a fresh scaffold gets. When adding or removing a feature from zudo-doc, update these in order:
packages/— Add/remove the field onzudo- doc/ src/ config. ts ZudoDocConfig(with a@defaultJSDoc — enforced byconfig-jsdoc.test.ts) andDEFAULT_SETTINGS. This is the ONE census every other step reads against.packages/— If the feature introduces a new plugin or collection, updatezudo- doc/ src/ preset. ts zudoDocPreset()to wire it from the settings field you just added.zudoDoc()(step 1'sconfig.ts) calls this internally — a generated project'szfb.config.tsnever wires plugins directly.packages/— Add/remove the field increate- zudo- doc/ src/ zfb- config- gen. ts DEFAULT_MIRROR(a hand-kept local copy of step 1'sDEFAULT_SETTINGS— the generator can'timportthe package, see the file's header comment) +buildDesiredConfig()(user-choice → field mapping) +FIELD_ORDER(cosmetic emission order).packages/— Create/update the feature module. Register a new module increate- zudo- doc/ src/ features/ <name>. ts packages/(or, for the scaffold.ts-handled featurescreate- zudo- doc/ src/ features/ index. ts skillSymlinker/claudeSkills/claudeSkillsWriting/changelog, inscaffold.ts). Most features need ONLY step 3's field mapping (leaveinjections: [], nopostProcess) — only add apostProcesspatch ortemplates/features/<name>/files/copy if the feature genuinely has no package-owned equivalent (rare; seedocHistory/designTokenPanel/tagGovernance/tauri/tauriDevfor the current examples and why each one needs it). Also add the feature toFEATURESinpackages/if it needs a CLI flag.create- zudo- doc/ src/ constants. ts packages/— Add/remove feature-specific files, only when there's a genuine gap. Most features ship zero files; the only five with a template directory arecreate- zudo- doc/ templates/ features/ <name>/ files/ claudeSkills,claudeSkillsWriting,i18n,tauri, andtauriDev. (tagGovernancehas no template dir — it writessrc/inline from itsconfig/ tag- vocabulary. ts postProcess.)packages/— Add/remove dependencies increate- zudo- doc/ src/ scaffold. ts generatePackageJson().packages/— Update the manifest-shape assertions (create- zudo- doc/ src/ _ _ tests_ _ / scaffold. test. ts BAREBONE_MANIFEST/ALL_FEATURES/NEVER_RESURRECTED) and the settings-drift guard.This repo's own
src/— Add/remove the field so the showcase demonstrates it (optional relative to the generator, but keep it in sync so the showcase stays a faithful reference).config/ settings. ts Run
/to verify no drift remains.l- update- generator
Template counterpart rule: this checklist also applies to incremental improvements (CSS token migrations, icon sizing, spacing changes, etc.) — not just new features. The rule now covers far fewer files than before the minimal-scaffold cutover: only the ~5 files in packages/ (pages/, pages/docs/[[...slug]].tsx, src/, tsconfig.json, scripts/) have a showcase counterpart to keep in sync — if you change one of the showcase's matching files, update the template too. Run pnpm check:template-drift to verify (allowlisted files in .template-drift-allowlist — global.css, tsconfig.json, pages/, the two doc-route stubs, and the tauri feature's orphaned find-in-page files — are excluded from the automated whole-file check and need manual review instead).
e2e fixture sync: adding a field to src/ also requires mirroring it into all five e2e/ files — or adding an allowlist entry in .fixture-settings-drift-allowlist with a # reason: comment. This is enforced in CI by the Fixture Settings Drift Check job (scripts/). The check's CANONICAL_PATH deliberately stays src/, not zfb.config.ts — every fixture's (and the host's) zfb.config.ts is a byte-identical zudoDoc({ ...settings, chromeBindingsModule, ... }) shell carrying no field names, so settings.ts remains the only place individual field names appear as literal keys to diff.
Content typography (.zd-content) is NOT per-project — it ships once from packages/. Never re-inline .zd-content rules into any global.css. Canonical rules (consumer contract, import order, rebuild duty) are in packages/. Note the propagation lag: generated projects only pick up a content.css change after a new @takazudo/zudo-doc is published and create-zudo-doc's pinned dependency is bumped (the lockstep release handles this).
Testing
See TESTING.md (repo root) for the full testing strategy — levels (L1 vitest through L6 test-flow skills), tiers (T0 local fast pass / T1 CI gates / T3 nightly exam), tag taxonomy (@flaky quarantine rules), retry budgets, anti-gaming rules, and wait-pattern rules.
Directory-scoped CLAUDE.md files
These auto-load when working in the corresponding directory — read them when relevant work is in scope:
src/— components, design tokens, three-tier color/font-size strategy, CSS rulesCLAUDE. md src/— tag vocabulary and tag governanceconfig/ CLAUDE. md src/— doc-writing rules (frontmatter, admonitions, linking, bilingual workflow)content/ CLAUDE. md e2e/— Playwright fixture architecture and how-to (policy in TESTING.md)CLAUDE. md src-— the two Tauri apps (Mode 1 offline reader, Mode 2 dev wrapper) and their build prerequisitestauri/ CLAUDE. md packages/— per-package architecture notes (workers, generator, doc-history-server)*/ CLAUDE. md