/packages/zudo-doc/CLAUDE.md
CLAUDE.md at /packages/zudo-doc/CLAUDE.md
Path: packages/
@takazudo/zudo-doc
Shared layout + content-rendering package consumed by both this repo's showcase (workspace:*) and every project scaffolded by create-zudo-doc (published npm). Components are zudo-react .tsx compiled by tsup (bundle:false, 1:1 source→dist/ so "use client" directives survive — see tsup.config.ts). The exports map in package.json is the API surface; consumers import from dist/.
The current public API contract is documented in API.md (this directory): subpath exports, zudoDocPreset options (Settings), Wind tokens and CSS variables, doclayout slot anchors, and the ejectable component list.
Note-tray schema and navigation
src/ owns the note-tray frontmatter fields. Cross-field and calendar validation lives in src/; shared ordering, rank, grouping, and date behavior lives in src/note-tray-model/. The sidebar, NoteTrayIndex, pager, and home page must consume that shared model rather than re-deriving tray behavior.
Build: tsup (JS) + tsc (DTS) — two passes, not one
build/prepare run gen-search-widget-script.mjs, gen-nav-overflow-script.mjs, gen-switcher-scripts.mjs, THEN tsup, THEN tsc -p tsconfig.build.json (--emitDeclarationOnly). All three generators must run first: gen-search-widget-script.mjs (#3412) writes src/ that search- imports, and gen-nav-overflow-script.mjs (#3534) writes src/ that header/ imports — without either, the first tsup/tsc pass fails resolving the missing . / . (both generators are also wired into predev and the tsup onSuccess chain for the same reason). tsup emits only the JS (dts:false); tsc emits the .d.ts. Switcher scripts are frozen from scripts/; the generator emits src/, with drift validation in the prepack contract. The split exists because tsup's dts:true rollup-based declaration bundler is combinatorial in memory across entries — with bundle:false + ~200 source entries it OOMs even at an 8GB Node heap (the JS pass alone finishes in ~150ms). tsc
--emitDeclarationOnly emits per-file 1:1 (same flat layout as the bundle:false JS), is linear in file count, and completes under the default ~2GB heap, so CI no longer needs (and the scripts no longer set) a raised NODE_OPTIONS heap. tsconfig.build.json extends tsconfig.json with emitDeclarationOnly/outDir:dist/ rootDir:src and excludes test globs so test files don't emit. See zudolab/zudo-doc epic #2344.
Since zudolab/zudo-doc#3431, generated-script.ts is committed to git (a deliberate departure from the gitignored-generated-file convention below — see the generator's own header comment) with a pnpm check:search-widget-drift guard (b4push + CI) proving the committed bytes match a fresh regeneration; pretest/pretypecheck no longer regenerate it ahead of those runs, so test/typecheck exercise whatever is actually checked in. Since zudolab/zudo-doc#3534, src/ is committed the same way, guarded by the mirrored pnpm check:nav-overflow-drift (b4push + CI, #3535).
dev mirrors that same two-pass split as two parallel watchers (#3113): dev:js (tsup --watch) and dev:dts (tsc -p tsconfig.build.json --watch --preserveWatchOutput), joined by run-parallel. Declarations therefore stay current during package dev — they no longer lag behind the JS. The two do not fight over dist/: they write disjoint extensions, and tsup's watcher already ignores dist/, so the .d.ts writes cannot retrigger a JS rebuild. Three consequences of pairing them:
tsup runs
clean: !options.watch, so a watch session does not wipedist/(it could not regenerate the.d.tsit destroyed).build/preparestill clean.Because nothing cleans under watch, a deleted or renamed source file leaves a stale
.js/.d.tsbehind — runpnpm build:workspaceafter one.predevrunsscripts/becauseensure- workspace- build. mjs dev:dtstypecheckspre-build.ts, which imports the sibling@takazudo/zudo-doc-history-server'sgit-historydeclarations — on a cold tree that watcher dies immediately with TS2307. No-op when warm.
A watcher exit tears down the whole dev session (accepted, #3129)
run-parallel aborts its sibling when one task exits non-zero, and root pnpm dev nests this run-parallel inside another one (run-parallel dev:zfb dev:history dev:claude-watch dev:zudo-doc). So a fatal dev:dts exit kills dev:js, which fails dev:zudo-doc, which takes down zfb dev and the doc-history server with it. Two hops, verified against npm-run-all2@7.0.2 — not assumed — and preserved deliberately when that dependency was replaced by the package-owned bin/ (its header documents the parity).
This is accepted behaviour, not an open bug. It is loud and self-announcing:
ERROR: "dev:dts" exited with 1.
ERROR: "dev:zudo-doc" exited with 1.and pnpm dev returns to the shell prompt. Re-running pnpm dev is usually all it takes — the two exceptions (a startup build error, and inotify exhaustion) are called out below.
Mid-session, exposure is narrow. Once a session is up both watchers survive ordinary work: tsc --watch reports type errors and keeps watching, and tsup --watch logs a failed rebuild and keeps watching. So only fatal exits cascade — a missing or unreadable tsconfig.build.json, an OOM, or inotify exhaustion.
inotify is a real hazard on WSL2 here, and its two limits surface as different errno values. Match the errno to the limit before tuning, or you will raise a ceiling that was never the problem:
EMFILE→fs.inotify.max_user_instances(128 here). This is the one that actually bites: orphaned watchers and codex brokers accumulate untilinotify_initfails.ENOSPC→fs.inotify.max_user_watches(524288 here). Much rarer.
Re-running pnpm dev fixes neither — free instances or raise the ceiling the errno actually points at (see the / and / skills).
Startup is stricter, and the two watchers differ there. tsup --watch exits non-zero when its first build fails, so launching pnpm dev with a syntax error already sitting in packages/ tears the whole session down immediately. tsc --watch does not — it prints the errors and watches anyway. Both verified against the installed tsup 8.5.1 / TypeScript. This case is instant and self-evident rather than insidious: fix the syntax error and relaunch. Before #3126 there was a single watcher, so neither shape of this cascade existed.
Do NOT "fix" this by adding a --continue-on-error equivalent to run-parallel. (npm-run-all2 spelled it run-p --continue-on-error; the replacement deliberately ships no such flag.) It keeps the surviving watcher alive, but the dead one then fails silently: a dead dev:dts leaves dist/ frozen at its last-emitted state while the JS keeps updating around it. That is the same class of dist-out-of-sync-with-source problem the paired-watcher split (#3126) was added to end — and a nastier variant of it. #3113's declarations were absent (tsup's clean: true wiped all 285 of them and nothing regenerated them), which fails loudly with TS2306/TS2724; frozen declarations instead typecheck cleanly against stale types. A loud crash you re-run beats a quiet lie.
If the inotify case starts happening for real, the remedy is to supervise the watchers (restart with capped backoff, indefinitely) rather than to continue-on-error. Note that a supervisor which gives up after N retries but stays alive is the same trap: it leaves a dev session that looks healthy while its output is knowingly stale.
Shared-surface (exports / tsup) append convention — package-first migration
The package.json#exports map and tsup.config.ts are a shared surface that the package-first migration (epic #2321) touches from several parallel tasks (S3/S4/S7/S8/S9). To keep those edits conflict-free, the convention established by S2 (#2325) is:
New
.ts/.tsxsource undersrc/**(e.g.src/) is compiled automatically by the tsuppreset. ts entryglobs — notsup.config.tsedit needed. Just append oneexportsentry (a{ "types", "default" }pair pointing at the matchingdist/*path). Append it to the JS subpath group, right before the.cssstatic-asset entries at the bottom of the map (the.entry is the current tail of that group — append after it). Order within the group is cosmetic; keep one entry per line so parallel diffs touch disjoint lines./ preset exportscannot carry inline comments — Node (and esbuild) reject a"//"key sitting alongside.-prefixed subpath keys. So the append point is documented here and intsup.config.ts, not as a JSON comment.Source files the tsup globs do NOT match (e.g. S3's relocated
.mjsplugin wrappers) append at the markedENTRY APPEND POINTintsup.config.ts#entry, or copy via theonSuccesschain.
"./routes-src/*" must stay in the map (zfb ≥ 0.1.0-next.97)
exports carries "./routes-src/*": "./routes-src/*" even though nothing imports that subpath by name. zfb next.97 added a bundler stage-escape audit that rejects any metafile input reached inside a workspace package at a location the package does not declare — and the routes plugin injects the raw routes-src/*.tsx entrypoints by absolute path (it must: zfb extracts paths() by AST from the .tsx source, never from compiled .js). Without the wildcard entry, zfb build fails with SSR work-mirror stage-escape audit failed naming every routes-src/*.tsx and _context.ts. Shipping them via files[] alone is not enough — the audit reads exports.
Factory context type + foundation primitives (epic #2344, S1a)
The package-first Wave 3 migration relocates the pages/lib/* rendering/data modules into this package behind injected-context factories. The shared contract those factories receive is the factory-context TYPE, and the load-bearing pure primitives they build on ship from S1a. None of these import node builtins or the host @/ alias (enforced by check:no-host-alias-in-package and the foundation-eval-graph node-free guard).
./factory-context — FactoryContext (types only)
Signature { settings, i18n, components, navSource } — deliberately NO generic utils bag (a utils key would re-couple the factory API to this project's util surface and defeat the migration). A factory receives exactly these four typed slots and builds everything else from them.
settings— the host's resolvedSettingsobject (single config source).i18n(FactoryI18n) —{ defaultLocale, locales, getLocaleLabel, t? }.components(FactoryComponents) — the allowlist below.navSource— opaque per-locale nav-source handle (host owns the loader; factories pass it to the pure nav builders without inspecting it).
ALLOWED { components } slots (explicit allowlist — NOT a dumping ground)
Every key is a component the package CANNOT own because it depends on the host's content collections / settings wiring / showcase markup. All slots are optional. Adding a slot requires a real cross-package coupling reason AND an entry here — do not widen this into a generic component bag.
| Slot | Why it can't live in the package |
|---|---|
CategoryNav | locale-aware; reads the project's content collection |
CategoryTreeNav | locale-aware category-tree wrapper |
SiteTreeNav | locale-aware site-tree wrapper (also serves the demo variant) |
HtmlPreview | bound to the host's preview config |
Details | <details> content override |
Island | zfb <Island> pass-through (host owns the import so the scanner walks it) |
PresetGenerator | showcase-only SSR shell; downstream projects stub it |
Foundation primitive exports (S1a)
.—/ render- markdown renderMarkdown(src): the chat-message markdown→HTML renderer (escape-first, safe by construction)..—/ slug toRouteSlug/toHistorySlug/toSlugParams/toTitleCase: the canonical root-slug rule (#1891 / #1873). The packagemd-utilsimportstoRouteSlugfrom here instead of re-inlining the rule..—/ smart- break isPathLike/smartBreak/SmartBreak/escapeAndInjectWbr/smartBreakToHtml. The former toc-local copy (toc/) was consolidated into this single module; toc and content overrides import it from here.smart- break. tsx .—/ use- modal- dialog modalDialog(scope, options)with exportedModalDialogOptions/ModalDialogResult: setup-only dialog synchronization, native-close/backdrop callbacks, navigation close and focus return. It is an ordinary module;useModalDialogis removed. A conditional dialog needs a child component/scope insideShowso the helper owns its actual element..— shared island prop/type contracts:/ island- types ChatMessage,DocHistoryData(+DocHistoryEntry), and the enlarge-dialog shared constants (ENLARGE_DIALOG_STYLE,IMAGE_ENLARGE_DIALOG_CLASS,MERMAID_ENLARGE_DIALOG_CLASS,EnlargeDialogProps)..—/ url- helpers makeUrlHelpers(settings, i18n): the base.ts URL logic parameterized into a constructor (withBase / docsUrl / navHref / getPathForLocale / buildLocaleLinks / versionedDocsUrl / …). The host'ssrc/keeps the singleton import; the logic lives here.utils/ base. ts
Host code imports these canonical package subpaths directly. The host buildNavTree(entries, lang, categoryMeta, { buildHref }) adapter retains its explicit buildHref injection point for current route construction.
./preset — zudoDocPreset()
src/ (exported as @takazudo/) returns the zfb config fragment every project used to hand-write in zfb.config.ts — collections loop, markdown.features, dual-theme codeHighlight, resolveMarkdownLinks, stripMdExt, trailingSlash, minifyHtml, and the integration plugins array. The host spreads it into defineConfig and keeps only the shell fields it still owns (port, wind, bundle, base, adapter).
Signature:
zudoDocPreset({ settings, buildDocsSchema, directiveVocabulary }).buildDocsSchemaanddirectiveVocabularyare passed in, not imported, so the preset never re-imports the project'ssettings/tag-vocabulary/docs-schemasingletons (already in the config eval) and its own import graph stays node-builtin-free.Plugins are bare-specifier descriptors (
{ name:), never imported plugin functions — importing the plugin modules would drag their"@takazudo/ zudo- doc/ plugins/ <x>", options } node:fs/node:pathgraph into the config eval. All integration plugins now resolve via@takazudo/; the old project-relativezudo- doc/ plugins/ * copy-public-plugin.mjswas removed in #2358 (zfb nativepublicDirreplaces it).Node-free eval-graph guard (
src/): esbuild-bundles_ _ tests_ _ / preset. test. ts src/withpreset. ts --platform=neutral(mirrors zfb'sloader.rs:277), noexternal, and FAILS on any reachablenode:*builtin. Underplatform: neutralesbuild does NOT shim builtins — an unresolvablenode:*makesbuild()reject with aCould not resolve "node:…"diagnostic, so the guard scans BOTH the rejection's.errorsAND (defensively) the emitted bundle for a literal passthrough. A companion self-test bundles anode:fsprobe to prove the detector stays live (not dead code). Non-negotiable — keep it green when adding imports to the preset.zodis a required peerDependency.preset.tsimportszodforz.toJSONSchema; withbundle:falsethat bare import ships verbatim indist/and resolves against the consumer'spreset. js node_modules. The host already supplies zod (it ownsbuildDocsSchema), so a required peer shares that single instance — avoiding a dual-zod hazard fortoJSONSchemaand aCannot find package 'zod'at config-eval time in generated projects.katexanddiffare optional peers, and must stay non-build-fatal.katexis needed only whenmath: true, anddiffonly for docHistory's Compare view. Both are reachable from the always-bundled route graph (mdx-components → math-block,_chrome → doc-history), so each is loaded ONLY through a rejection-handledimport("pkg").then(onFulfilled, onRejected). esbuild leaves that shape unresolved when the package is absent instead of failing the build. A static import or a bareawait import()reintroducesCould not resolvefor consumers without the peer (#4206 / #4209). The packed-tarball OPT-KATEX-DIFF case inroute-injection-build.slow.test.tsproves it. Do not use theaddVirtualModuleshadow here: that channel carries a HOST-supplied callable/path (chromeBindingsModule/designTokenPanelConfigModule— seesrc/), not a way to make an optional npm peer conditional — a virtual module's loader is plain ESM source, so aplugins/ routes. ts katex/diffimport inside it would still resolve (or fail) at build time exactly like a static import does today. The rejection-handledimport("pkg").then(...)pattern above is the actual mechanism that keeps the peer optional.Package-owned route injection (
settings.packageOwnedRoutes, defaulttruesince #2404) is pinned indocs/— the authoritative seam spec for theadr/ route- injection- seam. md @takazudo/plugin +zudo- doc/ plugins/ routes routes/*entrypoints (virtual module carries serializablesettings/translations/tagVocabulary; everything callable is an importable package subpath; package routes use@takazudo/, not the hostzfb/ content zfb/contenttsconfig alias).
Shipped CSS artifacts and Wind manifest
The current engine tuple is published zfb family 4.3.3, peers ^4.3.3. The coordinated zudo-doc/create-zudo-doc 6.0.0 is published (all three packages at 6.0.0 on npm latest; history-server peer floor ^6.0.0) — see the 6.0.0 publication record. Upstream zfb #4097 is resolved; its strict published 4.3.1 consumer control passed 3/3 with no skips or retries in the source-bound verification. Linux Chromium evidence does not prove native macOS WebKit or Apple IME behavior; retain the owner disposition and platform limits. Release preparation and publication remain separate from these architecture instructions.
theme.css, content.css, page-loading.css, and features.css are copied from src/ to dist/ by the tsup onSuccess chain. gen-wind-manifest.mjs then produces dist/ from compiled JS, and one-shot builds run gen-compiled-css.mjs. Watch builds skip compiled CSS generation. All five CSS files and wind.json have public export subpaths. The former safelist.css and theme-no-reset.css exports, generators and imports are removed.
Consumer entry order:
@layer zw-reset, zd-flow;
@import "@takazudo/zudo-doc/theme.css";
@import "@takazudo/zudo-doc/content.css";
@import "@takazudo/zudo-doc/page-loading.css";
@import "@takazudo/zudo-doc/features.css";Add @takazudo/ only for a panel-enabled consumer. Use public CSS exports, never physical package dist/*.css paths. theme.css owns the default :root custom properties and reset-parity/base rules; content.css owns the single .zd-content typography/flow contract; page-loading.css owns navigation overlay/pending styles; features.css owns highlighting, preview, math, sidebar, transition, enlarge and history styles. Keep the existing theme-independent page-loading spinner fallback rather than replacing it with scheme foreground.
zudoDoc() supplies the definePreset-owned Wind fragment, including @takazudo/, var-backed token mappings, reset: "owned-v1", dark: false, and sm/lg/xl breakpoints (640/1024/1280px). Consumers ordinarily do not register the package manifest themselves. No implicit numeric spacing scale is enabled. wind is a top-level engine option, not a Settings field. zfb deep-merges a supplied override over defaults; explicit wind: false disables generation. Override variable values in ordinary :root rules after the imports, or mapping values through zudoDoc({ wind: { tokens: … } }).
Remove Tailwind imports and directives (@theme, @source, @apply, etc.). Keep token names, including bare --color-* and namespaced --color-zd-*; do not reintroduce the former --color-*: initial palette reset. Unsupported Wind utility forms need authored zd- CSS and a coverage disposition. Generated utilities are unlayered and follow authored CSS: check actual specificity, hover/focus winners and computed values rather than assuming layer parity.
compiled.css is the browser-ready embedding export. Its entry is src/. gen-compiled-css.mjs invokes native zfb css with --no-auto-source --code-highlight-mode class and a temporary config using the package Wind defaults and strict manifest. It does not scan host content or package outDir as a substitute for the manifest. Never hand-edit generated dist/ files. CSS-only edits do not trigger tsup's source watcher; rebuild the package when source CSS changes.
check:prepack-contract covers static CSS, Wind manifest, compiled CSS, generated scripts, routes, declarations, browser-safe API graphs and other public artifacts. Run pnpm check:package-wind-manifest and pnpm exec zfb wind audit --project-root . --fail-on error for their distinct coverage. Native audit needs the error exit flag and does not establish emitted CSS completeness. Retain authored/emitted-rule tests and browser computed-style evidence. Historical 3.x source-resolution probes use ZFB3_SOURCE_RESOLVE=1; normal released consumers use the built/packed public exports and declaration graphs, never stale v2 dist or private source imports.
Theme-pack nav :hover guard — pack-author contract (epic #4032)
Rule. A theme pack (src/) that authors a nav :hover rule setting color or background MUST exclude [aria-current="page"], on the anchor, in both header DOM shapes. Until epic #4032 this rule existed only as a code comment inside one pack (blueprint/, calling it "the scandi/washi guard pattern") — tribal knowledge. The survey that drove the epic found 14 packs violating it and four packs that did guard using the wrong attribute (see the trap below).
Why. The active nav item carries the base inverted fill — NAV_TOP_ACTIVE = ["bg-fg", "text-bg"] (src/). A pack selector like html[data-theme-pack="x"] [data-nav-item]:hover is unlayered at specificity (0,3,1), which beats the base Wind text-bg rule at (0,1,0). An unguarded hover therefore repaints the active pill's ink or its background and collapses the contrast — measured as low as 1.00:1 (identical fg and bg, text literally invisible) across the packs that shipped this bug.
Reference pattern — src/ guards both header DOM shapes:
html[data-theme-pack="phosphor"] a[data-nav-item]:not([aria-current="page"]):hover,
html[data-theme-pack="phosphor"] [data-nav-item-dropdown] > a:not([aria-current="page"]):hover {
text-decoration: none;
color: var(--zd-accent);
...
}Guarding only the wrapper [data-nav-item-dropdown] div is ineffective — the colors live on the child <a>, and that same child carries aria-current (dropdown-item anchor: src/; plain top-level item's own anchor: src/).
The data-nav-active trap. :not([data-nav-active]) looks like the same guard and is not:
Never emitted on header nav items —
header.tsx:487and:550set onlyaria-current, neverdata-nav-active.:not([data-nav-active])on a header selector is a structural no-op; the hover rule runs unguarded regardless of the active state.Incomplete on the sidebar — an active root/category node gets
aria-current="page"atsidebar-WITHOUTtree- island/ index. tsx: 766 data-nav-active(:767sets it only when!isRoot && isActive, i.e. never on a root). observatory, washi, riso, and sakura shipped exactly this guard and still failed on an active category row — this exact mistake is why it propagated to four packs.
Guard with :not([aria-current="page"]), and only that, on both the header and the sidebar.
Audit-gated exception. A rule that sets color and background together replaces the pill wholesale rather than tinting the existing fill. Several packs (sakura, scandi, bauhaus, drift's header rule) do this deliberately, unguarded, and measure AA-clean. Do not "fix" these by adding a guard on sight — omitting the guard here is correct only while pnpm theme-a11y:audit proves the state green. The audit is the arbiter, not the shape of the rule.
A second trap: descendant color. A pack's anchor-level color reaches only what inherits from it. Card-style links can put their own ink on a group-hover:text-fg / group-hover:text-accent utility class on a descendant <span>, which wins over the anchor's inherited color — a fix that only recolors the anchor reads correct in a static look at the rule and is still wrong on screen. brutalist/ is the concrete case: the prose-link hover rule (:400-407) needed a companion … a:hover * rule (:408-414) to actually reach the note-tray card spans it shares a selector with. This is why pnpm theme-a11y:audit renders real pages and reads computed styles instead of computing contrast from CSS declarations alone.
Which ink those descendants should carry is fixed by the Link Color Rule in src/ (rule 3: card links sit at text-fg, descendant icons/spans follow via group-hover:text-accent / group-focus-visible:text-accent) — a pack override must reach them the same way.
See also: the color-scheme-a11y skill's "Theme-pack nav :hover guard" section and TESTING.md's "Theme A11y Audit" section for the verification tooling.
Shipped ambient type shims + tsconfig base (#2656, minimal-scaffold epic #2651)
Three files ship from the package root (not dist/) so a downstream project's tsconfig can pull them in with almost no boilerplate of its own. Two are hand-authored and checked into git (tsconfig.base.json, zfb-config-shim.d.ts); the third (virtual-modules.d.ts) is generated at build time. Consumer-level regression proof (running tsc/zfb check against a real fixture project that extends the base) is deliberately NOT duplicated here — it is the Wave-5 central confirm case (#2659), which must exercise the self-referencing import("@takazudo/ specifier end-to-end.
tsconfig.base.json— exported as@takazudo/. A project extends it (zudo- doc/ tsconfig. base. json "extends":) and keeps"@takazudo/ zudo- doc/ tsconfig. base. json" includeplus any required project-local aliases (see below). Carries everycompilerOptionsflag the pre-package-first project template (packages/) hand-rolled (strict +create- zudo- doc/ templates/ base/ tsconfig. json noImplicit*set,target/module/moduleResolution,jsx: "react-jsx"+jsxImportSource:, …), plus a top-level"@takazudo/ zfb/ zudo- react" files:to pull in the two ambient shims below.[". / zfb- config- shim. d. ts", ". / virtual- modules. d. ts"] MUST ship the shims via
files, neverinclude.files/include/excludeare all override-only acrossextends(the inheriting config's value replaces the base's; a base value applies only when the project declares none of its own). That makes a base-levelincludewrong in BOTH directions — spike #2652 Q5: an extends-only project tsconfig inherits ONLY the base's shim-include, so the project's own files are silently never typechecked (a planted error passedzfb check); conversely a project that declares its ownincludesilently discards the base's, dropping the shims. Shipping via basefilesworks because the project tsconfig declaresinclude(its own file set) but no top-levelfiles, so the base'sfilesis inherited intact alongside it.Consumer caveat (same override rule): a project extending the base must NOT declare its own top-level
files— doing so replaces the base's and silently drops both shims from the program (surfacing later as confusing TS2307s onzfb/config/virtual:*imports). The documented project-tsconfig shape (below) uses onlyextends/include/compilerOptions.paths.Deliberately carries NO
paths. See the GOTCHA below.scripts/(prepack) asserts this shape (nocheck- shim- artifacts. mjs include/exclude,filesat the top level — not nested in compilerOptions, TS5023 — and exactly these two entries) so the traps above can't silently regress.
zfb-config-shim.d.ts— exported as@takazudo/. The ambientzudo- doc/ zfb- config- shim. d. ts declare module "zfb/config"a project previously had to copy-paste as a localzfb-shim.d.ts(183 lines). No hand-sync duty (since #3237): the shim re-exports@takazudo/(zfb/ config export *) rather than restating its shape, so it carries no fields of its own and tracks whatever@takazudo/zfbversion the consumer has installed automatically. It previously WAS a hand-copied subset and drifted twice —bundle(Takazudo/zudo-front-builder#678 / zudolab/zudo-doc#1834) and then 12 top-level fields includingcopyPublicWithBase(#3237) — each drift failing a valid config field with TS2353. That class of bug is now structurally impossible: there is nothing left to lag. Do NOT add a top-levelimport/exportto this file (outside thedeclare moduleblock) — that would turn it into a module and the block would stop being ambient. This is the ONLY copy — the pre-#2656 per-projectzfb-shim.d.tsfiles (root andtemplates/base/) were deleted when the cutover completed (epic #2651 Wave 7 #2663; seee2e/), so there is no dual-copy sync duty left. Consumers reach the shim transitively viaCLAUDE. md tsconfig.base.json'sfiles.virtual-modules.d.ts— exported as@takazudo/. Ambient declarations forzudo- doc/ virtual- modules. d. ts virtual:zudo-doc-route-contextandvirtual:zudo-doc-chrome-bindings— the two zfb virtual modules the routes plugin injects at build time (no on-disk source, so an importing HOST file needs an ambientdeclare moduleorzfb checkfails TS2307). Needed once a project's tsconfigincludecoverspages/(the minimal-scaffold floor does this on purpose, unlike the pre-#2656 template which excludespages/and so never typechecked it) andpages/contains a file that imports one of these virtuals directly (e.g. apages/re-export stub callingindex. tsx createRouteContext(routeContext)).GENERATED, not hand-authored — no sync duty. Built from the single source of truth
src/byroutes/ _ virtual. d. ts scripts/(tsupcopy- virtual- modules. mjs onSuccess), which prepends a do-not-edit banner and rewrites the parent-relativeimport(...)type specifier to the bare@takazudo/subpath — the same rewritezudo- doc/ factory- context copy-routes-src.mjsapplies, for the same reason (the shipped copy resolves types from a consumer's node_modules). Gitignored likeroutes-src/, published viafiles[]/exports. To change the virtual-module contract, editsrc/and rebuild — never edit the generated file.routes/ _ virtual. d. ts scripts/(prepack) guards presence + the rewritten specifier.check- virtual- modules. mjs
Chrome bindings are the public customization boundary.
defineChromeBindingstype-checks exact call-side props for all six primary components (Header,Footer,Sidebar,Toc,Breadcrumb,DocPager) and carries namedheaderRightComponentsseparately from serializablesettings.headerRightItems. Omitted keys retain package defaults. Fresh base/i18n stubs consume the virtual object; the generator's doc-history patch must spread it before replacing onlyDocHistory. Components declared only inside the virtual module are SSR-presentational unless a separate static island registration path exists. A host island with session-scoped props (for example, a badge nested under the persisted header) can opt out of the nested-island props refresh withdata-zd-props-preserveon the island or an ancestor inside the persisted root. The live side governs; the attribute on the persisted root is a blanket opt-out for every nested island, and an opted-out island receives neither a props write nor a forced remount flag.
Owned JSX and project-local aliases
The base owns jsxImportSource:. Remove React→Preact compatibility paths and React/Preact type imports. Use Child, Description, Component<P> and JSX.IntrinsicElements from the owned runtime. Preact remains only as zdtp 0.8.6's own dependency (^10.29.1; a peer through 0.8.5) for its opaque subtree; zdtp's internal Tailwind browser dependency does not become a host engine dependency.
The base sets no baseUrl: TypeScript 7 removed the option (TS5102 in every project extending a config that sets it — #4558). Declare host aliases in the project tsconfig with explicitly relative targets; each paths entry resolves against the file that declares it, so no baseUrl is needed under TS 5.9 or 7.0, and zfb's native resolver maps the same targets for server code and island dependencies:
{
"extends": "@takazudo/zudo-doc/tsconfig.base.json",
"include": ["src", "pages", "zfb.config.ts"],
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}An existing TS5 host may keep its own baseUrl (this showcase does); only the shared base and the generated template must stay free of it. Regression proof lives in create-zudo-doc's tsconfig-compat.slow.test.ts.
Do not override the base's top-level files: it owns the config and virtual module declarations. A showcase importing #doc-history-meta adds its own path to .; the minimal floor has no such alias requirement.
Fixed-target host boundaries and browser imports
DocHistory and DesignTokenPanelBootstrap overrides are server boundaries that statically import one named client target, own the Island mount and return a Fragment. Do not hand a dynamic raw client target to package code for wrapping. BodyEndIslandsDeps.ThemePackSwitcher follows the same rule. Package settings gates and override precedence remain; panel configuration belongs in designTokenPanelConfigModule unless replacing the actual island implementation.
Use DocHistoryBoundary from . for the standard history slot. Its ssrFallback is extracted on the server and never becomes client JSON props. Existing fallback exports at . and . remain public through ordinary facades. Island transport accepts finite JSON scalars, dense arrays and plain records; explicitly omit absent object members and use the same normalized props for SSR/client. Reject functions, signals, descriptions, dates/classes, cycles, getters, symbols and undefined array elements. Never use a JSON round trip or serializer-only omission to hide invalid props.
Browser-safe defaults live internally in settings-defaults.ts and are re-exported by .; the config/preset evaluation graph itself is not a browser-import recipe. . is types-only. Use . for browser-safe payload building and . for engine-free site semantics. The root barrel is type-only. Client entries must not import host virtual modules.
Persisted island props are refreshed before native teardown at zfb:before-swap, writing only the detached incoming document. Unchanged identity/props preserve native scope state; changed identity/props recreate it. Live data-zd-props-preserve keeps exact old transport only when identities match. Unsafe structure/scheduling changes remove incoming persistence; do not reintroduce blanket remount flags.
Doc-history self-seed (.zfb/doc-history-meta.json )
plugins/'s preBuild hook (runDocHistoryMetaStep, in src/) already unconditionally writes . — creating the .zfb/ directory if absent — before every build, whether populated from git history or short-circuited to {} under SKIP_DOC_HISTORY=1. No code change was needed for #2656: this was already a "self-seed when absent" behavior (confirmed by spike #2652 Q6 and pinned by the existing pre-build-manifest.test.ts suite, whose beforeEach always starts from a fresh temp dir with no .zfb/). The scaffold-floor implication is that a project can stop committing . and its .gitignore un-ignore lines outright — the plugin recreates it on every build. SKIP_DOC_HISTORY=1 and CI full-manifest behavior are unaffected (see the repo root CLAUDE.md "Doc History Architecture" decision table — this wave changes none of it).
Localized generated-resource and asset routes
Claude/Codex resource generators keep detail MDX in the default docsDir, but emit their overview and category-index MDX into every configured locale directory. Route enumeration exposes the default-locale detail entries at every configured locale path unless defaultLocaleOnlyPrefixes opts a resource prefix out. Those detail routes are intentional body fallbacks: they use localized sidebar, breadcrumb, header, and language-switcher chrome, but suppress the ordinary untranslated-page banner because the configured default locale owns the canonical source dump.
The generated overview and category-index targets are generator-owned. An existing index with generated: true may be refreshed, but the generator refuses to overwrite an authored target file. Remove or rename the authored index and express its title, description, and labels through ZudoDocConfig.translations.
Do not copy resource detail MDX into locale directories. The body is the canonical source dump owned by the configured default locale; its language follows the source corpus and is not necessarily English. For this repository's measured resource corpus, copies would duplicate roughly 530 KB per additional locale on disk and again as untruncated text in every dist/, because plugins/ scans physical locale directories. Search and locale llms.txt therefore include localized overview pages but omit fallback-only details. This matches other fallback content. Search bodies are capped at MAX_BODY_LENGTH characters (default 3000, configurable via searchMaxBodyLength) by search-, so search-index size is not the reason for the body-ownership decision.
Asset index and leaf routes are likewise enumerated for every configured locale at /. Every generated asset href must carry its active locale; a valid fallback to the unprefixed route is still a regression. Both route families use the regular language switcher and remain unversioned.
Resource shell labels resolve from resource.*; asset viewer and index labels resolve from asset.*. Both namespaces flow through merged ZudoDocConfig.translations. Only English and Japanese are complete built-ins; all other supported locale codes resolve requested locale → configured default → package English → literal key. To restore default-locale-only behavior, consumers add / and/or the desired resource prefixes to defaultLocaleOnlyPrefixes; these prefixes are not package defaults.