zudo-doc
GitHub repository

Type to search...

to open search from anywhere

l-migrate-to-preset-style

Migrate an existing zudo-doc project to the MINIMAL SCAFFOLD shape (epic #2651): diff against the pinned ~5-file base template, DELETE files that match a known deleted-from-template legacy path (host ...

/l-migrate-to-preset-style

Migrate an existing generated project to the minimal-scaffold shape (epic zudolab/zudo-doc#2651): a single zfb.config.ts (zudoDoc({...})), a handful of unavoidable root files, and markdown content — everything else (chrome, islands, layout, default @theme tokens, even the doc ROUTES) ships from @takazudo/zudo-doc in node_modules. This is the v2 destination shape for what used to be called "preset-style" (the flat-config → zudoDocPreset() migration, Package-First Finale epic #2356) — the skill keeps its original name (l-migrate-to-preset-style) because other tooling references it by name (the #2664 doc-skill child), but the target shape it migrates TOWARD has moved further, to the locked ~12-file manifest.

The migration is human-gated: auto-delete is permitted only for files that are (a) byte-identical to the pinned template baseline, or (b) match a KNOWN deleted-from-template legacy path with no eject-CLI counterpart (see Step 3). Any edited or ambiguous file pauses for explicit human confirmation before touching it.

Shaped by l-lessons-zfb-migration-parity: a dead island silently server-renders and the build still exits 0, so byte-comparison alone is insufficient proof that an island migration succeeded. Every island-touching step is gated behind a hydration smoke test.

Preconditions

Verify ALL of the following before proceeding. Stop and tell the user if any fails.

  1. The target project was generated by create-zudo-doc (a package.json exists at the project root, with a @takazudo/zudo-doc dependency).

  2. The working tree is clean (git status --porcelain returns empty). If not, commit or stash first — the migration writes files and a dirty tree makes audit ambiguous.

  3. Identify the pinned @takazudo/zudo-doc version from package.json. This skill always diffs against today's packages/create-zudo-doc/templates/base/ and templates/features/*/files/ in THIS repo (the current minimal 5-file base template) — not a version-matched historical template. If the target project's pinned version predates the minimal-scaffold cutover (epic #2651) by a lot, expect a large Step 1 diff; that is the point of this migration, not an error condition.

  4. --dry-run — when passed, run Steps 1–3 (diff + classify + report) but apply no mutations, perform no hydration checks, and print the plan without executing it.

Step 0 — Detect the starting shape

Read the target project's zfb.config.ts (or, for a pre-#2657 project, src/config/settings.ts) to determine which generation it's on. This decides which later steps apply:

Starting shapeSignalSteps needed
Already minimal (zudoDoc({...}) in zfb.config.ts, no src/config/settings.ts)zfb.config.ts imports zudoDoc from @takazudo/zudo-doc/configSteps 1–3, 6–8 (config already merged)
Preset-style (zudoDocPreset({...}) spread inline in zfb.config.ts, src/config/settings.ts still the real settings object)zfb.config.ts imports zudoDocPreset from @takazudo/zudo-doc/presetAll steps, including Step 4 (config-merge)
Legacy flat-config (hand-rolled defineConfig({...}) with inline plugin wiring, src/config/settings.ts)No zudoDocPreset/zudoDoc import at allAll steps — expect the largest Step 1 diff

Step 1 — Template-baseline diff

Use scripts/check-template-drift.sh (from the zudo-doc repo) as the canonical diff mechanism. The script compares templates/base/ (today, 5 files: pages/index.tsx, pages/docs/[[...slug]].tsx, src/styles/global.css, tsconfig.json, scripts/setup-doc-skill.sh) and templates/features/*/files/ against the target project, respecting .template-drift-allowlist. It reports three categories:

  • [DIFF] — file differs from the template (may have local edits)

  • [MISSING IN PROD] — template file has no counterpart in the target project

  • [USE-CLIENT DRIFT]"use client" directive mismatch between template and host

Run from the target project root, pointing ROOT_DIR at it:

ROOT_DIR=<target-project-root> bash <path-to-zudo-doc>/scripts/check-template-drift.sh

Capture the full output. This only tells you about files the CURRENT template still has an opinion on — because the template shrank from ~64 files to ~5, most of an older project's files have NO current template counterpart at all, so this script won't mention them. Step 1b below finds those.

Step 1b — Deleted-from-template scan (the bulk of an older project's diff)

Scan the target project for paths matching the epic's known deleted-file families (mirrors packages/create-zudo-doc/src/__tests__/scaffold.test.ts's NEVER_RESURRECTED table — that test file is the authoritative, CI-enforced version of this list):

cd <target-project-root>
find pages/lib src/components src/utils src/types src/config \
  zfb-shim.d.ts .htmlvalidate.json .zfb/doc-history-meta.json \
  scripts/run-b4push.sh src/content.config.ts \
  -type f 2>/dev/null

Every match is a deleted-from-template legacy file — the behavior it implemented is now entirely package-owned (chrome, islands, settings types, z-index codegen, html-validate, etc.). See the epic's Wave 6 completion comment (#2660) for the full rationale per family.

One documented exception: if the target project selected tagGovernance, it legitimately has a narrow src/config/settings.ts + src/config/tag-vocabulary.ts pair (a tiny audit-only mirror the package's tags-audit bin still imports by path — see src/features/tag-governance.ts's header comment). Do NOT flag these two specific files if they match that narrow shape (a small object literal with docsDir/tagGovernance/tagVocabulary/locales only, or a TagVocabularyEntry[] array) — they are current, not legacy. If src/config/settings.ts is instead the OLD full project-wide settings object (dozens of fields), it IS legacy — route it to Step 4 (config-merge), not Step 3 (delete).

Step 2 — Classify each drifted / legacy file

For every file flagged by Step 1 or Step 1b, classify it into one of four categories:

CategoryCriterionAction
A — byte-identical to current templatediff -q against the matching templates/base/ or templates/features/*/files/ counterpart returns 0Safe to auto-delete (no local edits)
D — deleted-from-template legacy fileMatches a Step 1b path family AND has no current template counterpart at allDefault action: DELETE (not eject — see Step 3)
B — editedFile exists in both old and new shape but content differs, OR is a deleted-from-template file with clearly customized contentPause → human confirmation → default depends on eject-mapping (see Step 3)
C — missing in prodCurrent template file has no counterpart in the projectUsually means it should be added; pause → confirm

Byte-identical check (the definitive test for Category A):

diff -q <template-file> <target-file>
# exit 0 → identical (Category A)
# exit 1 → differs (Category B)

Build three lists — AUTO_DELETE (A + D), NEEDS_CONFIRMATION (B + C), and CONFIG_MERGE (a full legacy src/config/settings.ts, routed to Step 4) — before taking any action.

Step 3 — Delete Category A + D files

This is the flipped default from v1 of this skill. The v1 (preset-first, #2356-era) migration defaulted an edited/ambiguous file to eject (zudo-doc eject <component>) because the old template's host stubs (pages/lib/_header-with-defaults.tsx, src/components/*.tsx, …) were genuinely swappable for an ejected package copy at the SAME layer. In the minimal-scaffold shape those host stubs don't exist as a concept anymore — the package owns chrome/routes/islands outright via createChrome() and packageOwnedRoutes, and there is no eject target that corresponds to a deleted host wiring file (the EJECTABLE map below is for PRESENTATIONAL components — header, footer, toc, individual islands — a different and still-valid axis of customization). So a Category D file's default action is DELETE, not eject.

Category A (byte-identical to the current template) and Category D (deleted-from-template legacy file, unmodified) are both safe to remove outright:

for f in "${AUTO_DELETE[@]}"; do
  rm "$f"
  git rm "$f"          # stage the removal
done

Report each deleted path so the user has a clear audit trail, grouped by family (pages/lib/, src/components/, src/utils/, src/types/, src/config/*, standalone files) so the report reads like the Wave 6 completion comment's delete-list rather than a flat file dump.

Do NOT auto-delete "use client" island components even if they are byte-identical to a deleted-from-template stub. Islands that are byte-identical to the old template may still be relied upon for hydration wiring the package's createChrome() doesn't yet auto-default (check routes/_chrome.tsx's header comment for which islands ARE auto-defaulted — currently DesignTokenPanelBootstrap; DocHistory is NOT, and needs the chromeBindingsModule seam). Move every island component to NEEDS_CONFIRMATION regardless of diff result — the hydration smoke gate in Step 6 is mandatory before their removal can be declared safe.

Do NOT delete src/config/settings.ts here even if it looks legacy. Route it to Step 4 (config-merge) instead — deleting it before merging its fields into zfb.config.ts silently reverts every non-default setting to the package default.

Step 4 — Config-merge: settings.tszfb.config.ts's zudoDoc({...})

Skip this step if Step 0 detected the project is already on the minimal shape. Otherwise, the target project's real configuration currently lives in src/config/settings.ts (a plain object) and is either spread into an inline zudoDocPreset({...}) call or hand-wired field-by-field into defineConfig({...}). Migrate it to the single-entry zudoDoc() API (@takazudo/zudo-doc/config).

This is the SAME pattern this repo's own showcase uses (see the root zfb.config.ts and src/config/settings.ts) — the lowest-risk migration, since it changes zero field values, only the wiring:

// zfb.config.ts
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";
import { settings } from "./src/config/settings";

export default defineConfig(
  zudoDoc({
    ...settings,
    // If the project has custom host bindings (search widget, doc-history,
    // frontmatter renderers, footer tag loader, MDX component overrides —
    // see Step 4c below), thread them here:
    // chromeBindingsModule: "./src/chrome-bindings",
  }),
);

Delete any now-redundant escape-hatch imports the old config wired manually (buildDocsSchema, colorSchemes, translations, directives, tagVocabularyEntries) — pass them as zudoDoc() fields instead if the project overrides any of them (see packages/zudo-doc/src/config.ts's ZudoDocConfig for the full field list and each field's @default).

4b. Deeper cleanup (optional) — inline as diff-from-defaults literals

A fresh create-zudo-doc scaffold emits ONLY the fields that differ from ZudoDocConfig's documented defaults, directly as zudoDoc({...}) literals — no settings.ts file at all. A project that wants to fully match that shape can flatten settings.ts's fields into zfb.config.ts and delete the file, but this is optional cosmetic cleanup, not required for the migration to succeed — do it only if the user asks for it, since it's a larger diff with no functional benefit over 4a.

4c. Custom host bindings (only if the project has any)

If the OLD project had real host-bound behavior (a search widget, doc history with real git data, custom frontmatter-preview renderers, a footer tag loader, custom MDX component overrides) wired through its own pages/lib/_chrome.ts-style module, that behavior needs a chromeBindingsModule — a project-root-relative module exporting chromeBindings: ChromeHostBindings. Use this repo's own src/chrome-bindings.tsx as the reference shape (it implements every ChromeHostBindings slot the showcase needs) and packages/zudo-doc/docs/adr/route-injection-seam.md's "Host-callables channel — chromeBindingsModule" section for the contract. If the project has NO custom host bindings, skip this — the package's own stubs (empty search results, no-op DocHistory, etc.) cover a plain scaffold fine.

Step 5 — tsconfig swap to the extends form

Replace the target project's hand-rolled tsconfig.json with the extends form (see packages/zudo-doc/CLAUDE.md's "Shipped ambient type shims + tsconfig base" section for the full contract):

{
  "extends": "@takazudo/zudo-doc/tsconfig.base.json",
  "include": ["src", "pages", "zfb.config.ts"],
  "compilerOptions": {
    // REQUIRED, not cosmetic — see the GOTCHA in packages/zudo-doc/CLAUDE.md:
    // a project-local baseUrl is needed so "." resolves against THIS file's
    // directory, not the base tsconfig's (which lives inside node_modules).
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "react": ["./node_modules/preact/compat/"],
      "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
      "react-dom": ["./node_modules/preact/compat/"]
    }
  }
}

Two traps, both load-bearing:

  • Do NOT declare a top-level files field in the project tsconfig — files is override-only across extends, and the base's files (which pulls in the two ambient shims, zfb-config-shim.d.ts + virtual-modules.d.ts) would be silently dropped, surfacing later as confusing TS2307 errors on zfb/config / virtual:* imports.

  • Delete the project's own zfb-shim.d.ts if it still has one — the ambient zfb/config types now ship from @takazudo/zudo-doc/zfb-config-shim.d.ts, pulled in transitively via the base's files.

After swapping, run pnpm check and fix any newly-surfaced type errors before continuing — this step commonly surfaces stale imports Step 3 missed.

Step 6 — Hydration smoke gate (mandatory for every island-touching change)

Critical invariant (per l-lessons-zfb-migration-parity): a dead island server-renders silently. The zfb build exits 0 and emits valid HTML even when an island component loses its "use client" directive or its registration is broken. Byte-identical deletion is insufficient proof that islands still hydrate. This gate is not optional — skip it only when --skip-hydration-check was explicitly passed.

After completing Steps 3–5, run the hydration smoke check:

pnpm build

Then verify island hydration for each component that was deleted or whose wiring changed. The minimal check is a grep for the island marker in the built HTML:

# Each interactive island emits a data-zfb-island="<Name>" marker.
grep -roh 'data-zfb-island="[^"]*"' dist/ | sort -u    # distinct island set
grep -rl  'data-zfb-island' dist/ | wc -l              # total island-bearing pages

Compare the distinct-island set before vs after (capture a baseline with git stash + build BEFORE Step 3 if you didn't already) — a missing island name means a silently dead island. DocHistory is the highest-risk case: its package-default is a deliberate no-op stub (unlike DesignTokenPanelBootstrap, which auto-defaults) — if the project had docHistory enabled and Step 4 didn't wire a chromeBindingsModule threading the real DocHistory component, the History button silently stops hydrating with real data even though the build stays green.

For any island component touched in Steps 3–5, also verify the component is reachable in the dev server and interactive — a static HTML render is not the same as a hydrated island. Use pnpm dev + a quick manual check, or invoke /verify-ui for a computed-style smoke:

pnpm dev &
# Then check the component interacts as expected.

If any island fails the hydration check:

  1. Stop immediately — do not proceed to Step 7.

  2. Report which component is dead (compare the distinct-island set: grep -roh 'data-zfb-island="[^"]*"' dist/ | sort -u).

  3. Offer to restore the file from git (git checkout -- <path>) or wire a chromeBindingsModule (Step 4c).

Step 7 — Verify build and types

After all mutations, run a full build + typecheck to confirm nothing is broken:

pnpm build
pnpm check

Fix any TypeScript errors before committing. The most common post-migration errors are:

  • Import path still references a deleted host file (pages/lib/_chrome.ts, src/config/settings-types.ts, …) — the minimal shape resolves the equivalent type/behavior through @takazudo/zudo-doc subpaths instead.

  • A "use client" directive is missing on a component still wired through a chromeBindingsModule slot.

  • zfb.config.ts field errors — cross-check against ZudoDocConfig in packages/zudo-doc/src/config.ts (every field has a @default JSDoc).

Step 8 — Emit summary report

After Steps 3–7 complete (or at any early stop), print a structured summary:

## Migration Summary

### Starting shape (Step 0)
Legacy flat-config / Preset-style / Already minimal

### Deleted — Category A (byte-identical to current template)
- <path>
- ...

### Deleted — Category D (deleted-from-template legacy file, no eject target)
- pages/lib/_header-with-defaults.tsx
- src/components/theme-toggle.tsx
- ...

### Kept as-is / ejected (user chose to retain ownership of an edited file)
- src/components/custom-header.tsx (kept)
- theme-toggle → src/components/zudo-doc/theme-toggle/ (ejected)
- ...

### Config merge (Step 4)
- src/config/settings.ts → spread into zfb.config.ts's zudoDoc({...}) (4a)
- chromeBindingsModule wired: src/chrome-bindings.tsx (4c) / not needed

### tsconfig swap (Step 5): DONE / SKIPPED (already extends form)

### Skipped (deferred for later)
- src/components/bar.tsx
- ...

### Hydration smoke: PASS / FAIL
<list any failed islands>

### Build: PASS / FAIL
### Typecheck: PASS / FAIL

### Next steps
<list any remaining manual steps>

Human-gated loop for edited / ambiguous files (Category B + C)

For each file in NEEDS_CONFIRMATION, pause and present the following prompt to the user:

File: <relative-path>
Category: B (edited) / C (missing in prod)
Diff summary:
<show diff --stat or a short unified diff — keep it readable>

Action?
  [D] delete — this file's family has no eject target (Category D-adjacent); safe once confirmed
  [E] eject  — copy local source into project via `zudo-doc eject <component>` (only if it maps to an EJECTABLE entry, see below)
  [K] keep   — keep the file as-is (project retains ownership, no package import)
  [S] skip   — leave file unchanged for now (handle later)

Default: [E] eject if the file maps to an EJECTABLE component, else [K] keep

Wait for the user's explicit response before proceeding to the next file. Never silently delete or overwrite an edited file.

Eject handoff

When the user chooses eject (or accepts the default for an ejectable component), call the C1 eject CLI:

zudo-doc eject <component>

The CLI (ships from @takazudo/zudo-doc, reachable in generated projects as node_modules/.bin/zudo-doc) performs:

  1. Copies the component's TS source from the published eject/ bundle into src/components/zudo-doc/<component>/ inside the target project.

  2. Rewrites parent-relative cross-component imports to @takazudo/zudo-doc/<dir> subpath specifiers so they keep resolving against the installed package.

  3. Records the ejected component in .zudo-doc.json ({ "ejected": { "<component>": "src/components/zudo-doc/<component>" } }) — lazily creating the file on first successful eject (it is never seeded by the scaffold itself).

  4. Is idempotent — re-running on an already-ejected component prints "already ejected at <path>" and exits 0 without clobbering local edits.

Ejectable components (18 presentational/layout/island components — the full allowlist, packages/zudo-doc/src/eject/index.ts's EJECTABLE map):

CLI nameImport subpath
header@takazudo/zudo-doc/header
footer@takazudo/zudo-doc/footer
breadcrumb@takazudo/zudo-doc/breadcrumb
toc@takazudo/zudo-doc/toc
sidebar@takazudo/zudo-doc/sidebar
theme-toggle@takazudo/zudo-doc/theme-toggle
page-loading@takazudo/zudo-doc/page-loading
tab-item@takazudo/zudo-doc/tab-item
doc-pager@takazudo/zudo-doc/doc-pager
content-admonition@takazudo/zudo-doc/content-admonition
code-group@takazudo/zudo-doc/code-group
details@takazudo/zudo-doc/details
sidebar-tree-island@takazudo/zudo-doc/sidebar-tree-island
sidebar-toggle-island@takazudo/zudo-doc/sidebar-toggle-island
desktop-sidebar-toggle-island@takazudo/zudo-doc/desktop-sidebar-toggle-island
image-enlarge@takazudo/zudo-doc/image-enlarge
doc-history@takazudo/zudo-doc/doc-history
site-tree-nav-island@takazudo/zudo-doc/site-tree-nav-island

If the file to be ejected does not map to one of these 18 components, the eject CLI will reject it. In that case, and the file is a deleted-from-template legacy wiring stub (Category D), route to delete; otherwise route to keep and advise the user to handle it manually.

Flags

FlagEffect
--dry-runRun Steps 1–3 classification only; print the plan without mutating any files.
--skip-hydration-checkSkip Step 6 (only for non-island migrations or known-safe deletions). Must be explicit — not implied by any other flag.

Key files and references

PathRole
scripts/check-template-drift.shTemplate-baseline diff mechanism for files the CURRENT template still has an opinion on
.template-drift-allowlistFiles excluded from the automated drift check (still need manual review)
packages/create-zudo-doc/src/__tests__/scaffold.test.tsNEVER_RESURRECTED — the authoritative, CI-enforced list of deleted-from-template legacy paths (Step 1b)
packages/create-zudo-doc/templates/base/Pristine base template (5 files)
packages/create-zudo-doc/templates/features/*/files/Pristine feature-specific templates (only i18n, tagGovernance, tauri, tauriDev have any)
packages/zudo-doc/src/config.tsZudoDocConfig / zudoDoc() — the single-entry config API and field census (Step 4)
packages/zudo-doc/CLAUDE.md"Shipped ambient type shims + tsconfig base" section — the tsconfig extends-form contract and its two traps (Step 5)
packages/zudo-doc/docs/adr/route-injection-seam.md"Host-callables channel — chromeBindingsModule" ADR (Step 4c)
src/chrome-bindings.tsxThis repo's own reference ChromeHostBindings implementation (Step 4c)
packages/create-zudo-doc/docs/eject-contract.mdFull eject CLI contract (C0 #2359; Decision 5 revised S4 #2373)
packages/zudo-doc/src/eject/index.tsEJECTABLE map and eject()zudo-doc bin ships from @takazudo/zudo-doc, not create-zudo-doc
.zudo-doc.jsonProvenance marker in the target project (records ejected components + version), lazily created
.claude/skills/l-lessons-zfb-migration-parity/SKILL.mdLessons on why hydration smoke is mandatory

Revision History

CreatedUpdated

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…