/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 Preact .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 frozen 1.0 public API contract is documented in API.md (this directory): subpath exports, zudoDocPreset options (Settings), @theme design tokens, 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, THEN tsup, THEN tsc -p tsconfig.build.json (--emitDeclarationOnly). Both 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. 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 useModalDialog(...): the shared<dialog>modal hook (open/close sync, native-close callback, backdrop click, SPA-navigation close, opt-in focus management). Carries"use client". The S3/S4 enlarge / ai-chat / doc-history islands import it..— 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 (framework, port, tailwind, 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.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 (five static + one compiled)
tsup only compiles .ts/.tsx. The five static CSS artifacts are produced by the tsup onSuccess hook (runs after every build/--watch, so a one-shot build's clean cannot leave dist/ without them); compiled.css is generated by that same chain only for one-shot builds. The CSS-relevant prefix of the chain (the full chain in tsup.config.ts continues with gen-nav-overflow-script.mjs — which must stay BEFORE the eject-sources copy, since eject/header/ gets src/header/ verbatim and would otherwise carry the previous literal for one watch cycle (#3534) — then the eject-sources / routes-src / virtual-modules / theme-packs / catalog copies and gen-search-widget-script.mjs — see that file for the authoritative order):
onSuccess: "node scripts/copy-theme-css.mjs && node scripts/copy-content-css.mjs && node scripts/copy-page-loading-css.mjs && node scripts/copy-features-css.mjs && node scripts/gen-safelist.mjs && …" dist/← copied verbatim fromtheme. css src/bytheme. css scripts/. Exported ascopy- theme- css. mjs @takazudo/. Ships the project's defaultzudo- doc/ theme. css @themetoken block (colors including the--color-*: initialtight-token guardrail, spacing, icon sizes, elevation, typography, radius, breakpoints, and the 13 default--z-index-*tiers) plus a handful of project-agnostic base rules (scroll-margin, selection color, focus ring, search/find-in-page highlight, version-switcher visibility). Introduced by zudolab/zudo-doc#2655 (epic #2651, Wave 3) so a project's ownglobal.cssno longer has to hand-carry ~250 lines of boilerplate token declarations.Consumer contract: must
@importAFTER@layer zd-preflight, zd-flow;+ the two Tailwind imports (which stay project-side — seepackages/), and BEFOREcreate- zudo- doc/ templates/ base/ src/ styles/ global. css safelist.css/content.css/page-loading.css/features.css(all four consume the@themetokens declared here) and before the project's own token-override@theme { … }block (later declarations win). The--color-page-loading-overlayscrim token is deliberately NOT included — it stays feature-injected by a project'sdynamicPageTransitionwiring.Z-index defaults: the 13
--z-index-*tiers baked intotheme.cssmirrordefaultZIndexTiers(@takazudo/, #2654). A project's ownzudo- doc/ z- index- defaults src/+config/ z- index- tokens. ts gen:z-index/check:z-indexcodegen is now opt-in — only needed when a project overrides a tier (its own@themeblock, declared after this import, simply redefines the specific token it wants to change).Editing: change
src/, then rebuild the package sotheme. css dist/updates.theme. css tsup --watchdoes NOT re-copy on a bare.csschange (it only watches.ts/.tsx), so re-runpnpm buildafter editing the stylesheet.
dist/← copied verbatim fromcontent. css src/bycontent. css scripts/. Exported ascopy- content- css. mjs @takazudo/. This is the single source of truth forzudo- doc/ content. css .zd-contentcontent typography (flow-space rhythm, headings'--flow-space, minor elements, admonitions, mermaid layout). Both the showcasesrc/and thestyles/ global. css create-zudo-doctemplate@importit instead of inlining the rules — this is what killed the old showcase↔template copy-drift (zudolab/zudo-doc#2188).Consumer contract (documented in full at the top of
src/): the consumer must declarecontent. css @layer zd-preflight, zd-flow;, define the@themedesign tokens the rules consume (--color-*,--spacing-*,--text-*,--font-*,--leading-*,--radius-DEFAULT), and also importsafelist.cssso the component-emitted utility classes are generated.Major-element visuals (h2–h4, p, a, strong, blockquote, ul, ol, table) do NOT live here — they are emitted by the
defaultComponentsmap insrc/content/(Tailwind classes + inline styles).content.cssowns only what those components don't emit.Editing: change
src/, then rebuild the package socontent. css dist/updates.content. css tsup --watchdoes NOT re-copy on a bare.csschange (it only watches.ts/.tsx), so re-runpnpm buildafter editing the stylesheet.
dist/← generated bysafelist. css scripts/, which scans the compiledgen- safelist. mjs dist/**/*.jsfor Tailwind class candidates and emits a single@source inline(...). Exported as@takazudo/. Consumers import it so the utilities the components emit (which the consumer's own Tailwind scanner can't see insidezudo- doc/ safelist. css node_modules) are generated.dist/← copied verbatim frompage- loading. css src/bypage- loading. css scripts/. Exported ascopy- page- loading- css. mjs @takazudo/. Provides the full visual contract for the page-loading overlay, spinner, and pending-navigation link indicator. Consumerszudo- doc/ page- loading. css @importit alongside the<PageLoadingOverlay>component rather than inlining these rules per-project.Consumer contract: the stylesheet consumes host tokens
--color-page-loading-overlay(falling back tocolor-mix(in oklch, var(--color-overlay, #000) 60%, transparent)),--color-fg(spinner border; falls back to#fff),--color-accent(pending-nav link colour), and--z-index-modal(overlay stack level; falls back to100). All tokens are optional — bare consumers get sensible defaults.Editing: change
src/, then rebuild the package sopage- loading. css dist/updates.page- loading. css tsup --watchdoes NOT re-copy on a bare.csschange (it only watches.ts/.tsx), so re-runpnpm buildafter editing the stylesheet.
dist/← copied verbatim fromfeatures. css src/byfeatures. css scripts/. Exported ascopy- features- css. mjs @takazudo/. Contains all feature CSS every project using the package needs, island-coupled or not: code block buttons, the zfbzudo- doc/ features. css hi-*semantic-token bridge,.zd-html-preview-code, KaTeX, desktop sidebar toggle geometry, view-transition chrome (epic #2331), and — since S4 of epic #2344 — the.ai-chat-md/.zd-enlargeable/.zd-mermaid-enlargeableisland CSS and the docHistory diff-viewer (.diff-row/.diff-line-*) rules. All of it ships unconditionally (dead-weight cost accepted per the Minimal Scaffold plan, zudolab/zudo-doc#2655) so a project'sglobal.cssneeds no per-feature@slotanchor for CSS — only the@takazudo/zdtpstylesheet@importstays conditional (gated ondesignTokenPanel, since it pulls in zdtp's own bytes and can't be made unconditional).Consumer contract: must @import AFTER
@takazudo/,zudo- doc/ theme. css content.css, andpage-loading.css(the@importorder inglobal.cssis:theme.css,safelist.css,content.css,page-loading.css,features.css). Cascade order matters: features.css rules are unlayered and rely on the token definitions from@themewhich must precede this file in the compiled output.Editing: change
src/, then rebuild the package.features. css tsup --watchdoes NOT re-copy on a bare.csschange — re-runpnpm build.
dist/← generated bycompiled. css scripts/with zfb 2.12's standalonegen- compiled- css. mjs zfb csscommand. This is the package's browser-ready stylesheet: unlike the five source artifacts above, it is a Tailwind output and is committed because downstream consumers import it directly.Entry contract:
src/owns the layer declaration, Tailwind preflight/utilities imports, and the five package CSS imports in the order above. Its only source directive is exactlycompiled. entry. css @source not ".. Positive sources are deliberately CLI arguments, not entry directives, so the exclusion is rebased against the package project root while the source order remains explicit:/ dist/ catalog. js"; zfb css \ --input packages/zudo-doc/src/compiled.entry.css \ --output packages/zudo-doc/dist/compiled.css \ --project-root packages/zudo-doc \ --source 'src/**/*.{tsx,ts,jsx,js}' \ --source 'dist/**/*.{tsx,ts,jsx,js}' \ --no-auto-source \ --code-highlight-mode classThe generator runs this command from the repository root. The explicit class-mode flag is required because this library package has no site
zfb.config.ts; a supplied fixture/package root still owns its own entry, source trees, config, imports, and output. A fixture that is outside the workspace may provide only a package-self resolution link atnode_modules/@takazudo/zudo-docso the package CSS imports resolve.Reproducibility:
--no-auto-sourceprevents host pages/components/ layouts/content/src roots from entering the scan. The orderedsrcanddistglobs cover package sources and compiled consumer files, while the authored catalog exclusion prevents generateddist/content from changing the stylesheet. zfb owns deterministic atomic output; the generator does not create a scratch site, runcatalog. js zfb build, discover hashed assets, or perform a second replacement.Build order: the tsup one-shot
onSuccesschain copies the five CSS artifacts and regeneratessafelist.cssbefore invoking this generator. The generator is omitted intsup --watchmode so ordinary package watch rebuilds do not trigger a heavyweight whole-package CSS scan.Editing: change the entry or package sources, then run the package build to regenerate
dist/.compiled. css check-compiled-css.mjsis part ofprepackand byte-compares a fresh native-CLI regeneration against the committed file; the package tests additionally verify clean/warm catalog exclusion and packed-tarball parity.
prepack guards all six (check-theme-css.mjs && check-safelist.mjs && check-content-css.mjs && check-page-loading-css.mjs && check-features-css.mjs && check-compiled-css.mjs) so a build that skipped the onSuccess step fails loudly instead of publishing a package whose . / . / . / . / . / . export 404s or stale bytes for consumers.
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 only"@takazudo/ zudo- doc/ tsconfig. base. json" include(+ a tinypathsblock — see the GOTCHA 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: "preact", …), plus a top-levelfiles: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 remount flag.
GOTCHA — preact/compat paths stay in the PROJECT tsconfig, not the base
The pre-package-first template mapped react / react-dom / react/jsx-runtime to . — a path relative to the consumer project's node_modules. TS resolves a relative baseUrl/ paths value relative to the tsconfig FILE IT ORIGINATED IN, not the file that (transitively) extends it. So if that paths block lived in tsconfig.base.json, . would resolve inside node_ — wrong, and generally absent (preact is hoisted to the consumer's own top-level node_modules). This still matters under jsx: "react-jsx" + jsxImportSource: "preact" (flipped from "preserve" in #3182): the jsx-typing motivation for the mapping is gone — the automatic runtime resolves JSX-namespace types through jsxImportSource, not through the "react" specifier — but the mapping itself is still load-bearing for plain type-only imports. Some files genuinely import type { ReactNode } from "react" (e.g. src/), and without this mapping zfb check fails to resolve "react" on those files (there's no real react package installed; this is a preact-only project).
Resolution (locked, verified empirically against the base tsconfig in a scratch fixture): keep the react*/@/* paths block in the PROJECT's own tsconfig, alongside its OWN baseUrl: ".". A project extending the base must declare both:
{
"extends": "@takazudo/zudo-doc/tsconfig.base.json",
"include": ["src", "pages", "zfb.config.ts"],
"compilerOptions": {
// Re-declaring baseUrl here is REQUIRED, not cosmetic: the inherited
// baseUrl from the base ("." resolved against the base file's own
// directory, i.e. inside node_modules) would otherwise anchor these
// paths in the wrong place. A project-local baseUrl makes "." resolve
// against THIS file's directory (the project root) instead.
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"react": ["./node_modules/preact/compat/"],
"react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
"react-dom": ["./node_modules/preact/compat/"]
}
}
}The #doc-history-meta path alias is intentionally NOT part of this block — per spike Q6, nothing in the minimal floor imports #doc-history-meta (package-owned routes get docHistoryMeta via the optional chromeBindingsModule channel, defaulting to {}); a project that keeps a host pages/ stub importing the alias adds its own paths entry pointing at ., same as before.
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 300 characters 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.