/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.
Build: tsup (JS) + tsc (DTS) — two passes, not one
build/prepare run tsup THEN tsc -p tsconfig.build.json (--emitDeclarationOnly). 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.
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-p. 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-p aborts its sibling when one task exits non-zero, and root pnpm dev nests this run-p inside another one (run-p 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.
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 with run-p --continue-on-error. 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)
tsup only compiles .ts/.tsx. CSS is produced by the tsup onSuccess hook (runs after every build/--watch, so a one-shot build's clean cannot leave dist/ without them):
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.
prepack guards all five (check-theme-css.mjs && check-safelist.mjs && check-content-css.mjs && check-page-loading-css.mjs && check-features-css.mjs) so a build that skipped the onSuccess step fails loudly instead of publishing a package whose . / . / . / . / . export 404s 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.
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).