Customizing zudo-doc
The minimal→extend escalation ladder — from a one-line config field to full source control, in the order you reach for them.
A scaffolded zudo-doc project is intentionally minimal: one config file, your content, a stylesheet, and two thin route stubs (see Installation). Everything else — layout, chrome, schema, tokens — ships from @takazudo/zudo-doc and is consumed from node_modules.
That "one config file" ideal covers the common cases. This page is the escalation ladder for when it does not: each rung is more invasive than the last, and you should climb only as far as the change actually requires. The rungs are ordered by how soon a real project tends to hit them.
The ladder
| Rung | Reach for it when | Cost |
|---|---|---|
1. buildDocsSchema override | You need a custom, validated frontmatter key | Replace one function; own the schema |
| 2. Config fields | Toggling a feature or tuning behavior | None — it is just a field |
| 3. Theme packs | You want a whole prebuilt look, not a hand-tune | None — one field or one CLI command |
| 4. Token overrides | Re-theming colors, spacing, typography | A @theme block in global.css |
5. zudo-doc eject | A content-layer component you re-reference must change | You own the ejected copy and its imports |
6. chromeBindingsModule | Replacing primary chrome or injecting host callables, including custom MDX components | One host module |
7. Your own pages/*.tsx | A whole route must be yours | You own that route |
| 8. Deploy path | Going live (especially with SSR features) | Adapter + wrangler.toml + secrets |
| 9. Restoring pre-push / HTML checks | You want the showcase's validation gates | Copy a script + add deps |
Rung 1 — Custom frontmatter keys (buildDocsSchema)
This is the first place the one-config ideal breaks, so it leads the ladder.
Every other feature is a field on zudoDoc(). Frontmatter validation is not: the docs collection is validated by a Zod schema, and there is no config field that says "also allow a tier key." The schema is a function you replace — the buildDocsSchema escape hatch.
By default zudoDoc() builds the package schema for you (governance-aware, derived from tagGovernance + tagVocabularyEntries). To add your own key, import the default builder, extend its result, and hand the new builder back:
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";
import { buildDocsSchema } from "@takazudo/zudo-doc/docs-schema";
import { z } from "zod";
export default defineConfig(
zudoDoc({
siteName: "My Docs",
// Replace the schema builder entirely. Start from the package default,
// then extend it with your own validated frontmatter keys.
buildDocsSchema: () =>
buildDocsSchema({ tagGovernance: "off" }).extend({
tier: z.enum(["core", "opt-in"]).optional(),
reviewed_by: z.string().optional(),
}),
}),
);Now pages can declare tier: core in frontmatter and it validates at build time. Combine this with docContentHeaderExtras (rung 6) to actually render a badge from that key.
Note
buildDocsSchema is one of the non-serializable escape-hatch fields on ZudoDocConfig — alongside colorSchemes, translations, directives, and tagVocabularyEntries. They travel the import graph (never JSON-serialized), which is exactly why they can carry functions and Zod types. See Configuration.
Rung 2 — Config fields
Almost everything else is a field. zudoDoc() defaults every setting, so your zfb.config.ts lists only what differs:
export default defineConfig(
zudoDoc({
siteName: "My Docs",
docHistory: true,
sidebarToggle: true,
tocToggle: true,
tocMaxDepth: 3,
headerNav: [
{ label: "Guides", path: "/docs/guides", categoryMatch: "guides" },
],
footer: { copyright: "© 2026 Me" },
}),
);Reach for it when: you are turning a feature on/off or tuning behavior (TOC depth, navigation, color mode, locales).
Limit: config fields control what exists and how it behaves, not the pixel-level rendering of a component. For that, climb to rungs 3–5.
The full field list — with the default for every one — is the Configuration reference, which mirrors the ZudoDocConfig type's own JSDoc.
Rung 3 — Theme packs
Before hand-tuning tokens, check whether a bundled theme pack already gets you the look. A pack is a prebuilt design bundle — tokens, fonts, component details — applied with one config field or one CLI command:
zudoDoc({
siteName: "My Docs",
themePack: "foundry",
});pnpm exec zudo-doc theme apply foundryEvery pack defines both its light and dark values, so the mode toggle keeps working unchanged. Add themePackSwitcher: true to let readers switch packs live from a bottom-right flyout.
Reach for it when: you want a different overall look and a bundled pack matches it.
Limit: you pick from installed packs as-is; adjusting individual tokens on top is the next rung. See Theme Packs.
Rung 4 — Token overrides
The scaffold's src/ is a short @import chain that pulls the package's theme.css (all the @theme design tokens), content.css, and features.css — followed by an empty @theme { … } block reserved for you. Because it comes after the package imports, anything you redefine there wins the cascade:
/* ...package @imports above... */
@theme {
--color-accent: oklch(0.6 0.2 250);
--font-sans: "Inter", system-ui, sans-serif;
--z-index-modal: 200; /* override a single default z-index tier */
}For per-component rebranding without touching every element that reads a global token, the package also exposes --zdc-* component tokens (heading font, prose font, card radius, TOC width, …). Set them once in :root.
Reach for it when: the change is a re-theme — colors, spacing, typography, radii.
Limit: tokens restyle; they do not restructure markup. See Design System, Color, and Component Tokens.
Rung 5 — Ejecting a component
When no token can reach the change — the markup itself is wrong for you — eject a content-layer component that your project re-references (for example through mdxExtras). Ejection copies its source out of the package into your project and rewrites matching host imports to the local copy:
# eject a component (run via your package manager's bin runner)
pnpm exec zudo-doc eject details
# → copies source into your project and rewrites imports to resolve locally
# see the full list
pnpm exec zudo-doc --helpThe 18 ejectable components include two different scopes:
Content-layer:
tab-item,content-admonition,code-group, anddetails. Re-reference these from project-owned bindings or MDX component registration when you need the local copy to render.Primary chrome:
header,footer,breadcrumb,toc,sidebar, anddoc-pager. Bind the local copy to the matchingchromeBindings.Header,Footer,Breadcrumb,Toc,Sidebar, orDocPagerslot.Nested chrome:
theme-toggle,page-loading,sidebar-tree-island,sidebar-toggle-island,desktop-sidebar-toggle-island,image-enlarge,doc-history, andsite-tree-nav-island. These need the exact owner-level binding named by the CLI warning; copying a nested file alone does not replace its package-owned parent.
For content-layer components, wire the local import into the host code that renders it, then edit the local copy. The custom components guide shows the bindings and route-stub setup required for mdxExtras.
Note
Eject writes provenance into a .zudo-doc.json file recording which components you own. That file is lazy-created on the first eject — a fresh scaffold does not ship it, so an un-ejected project never carries an unexplained config file. To revert an eject, first restore every rewritten or manually added local import to its original @takazudo/zudo-doc/... package import; then remove the component directory and its .zudo-doc.json entry. Removing the directory first breaks those imports.
Limit: an ejected copy no longer receives package updates for that component — you take on its maintenance. The CLI classifies each eject statically: primary and nested chrome copies warn loudly until a supported binding is present, while content-layer copies keep quiet manual mdxExtras guidance. Ejection copies source; chromeBindingsModule is the supported seam that makes a primary or owner-level replacement render.
A different kind of eject — zudo-doc eject logo. This subcommand shares the eject name but is not a component-source copy like the targets above: it never touches .zudo-doc.json provenance or a component directory. It is an asset materialization — it renders the generated auto-logo to a real SVG file and rewires the logo config field to point at it, so you end up owning an editable file instead of the request-time generated default.
pnpm exec zudo-doc eject logoThis writes public/ — the mark is expressed as an internal SVG luminance mask, so it stays theme-adaptive through the same CSS-mask hero path as any custom logo asset — and rewrites logo to "/ in zfb.config.ts.
--seed <name>— override thesiteNameseed used to render the mark. Required whenzfb.config.tsis not in the canonical scaffold shape (for examplezudoDoc({ ...settings })), since the seed cannot be read statically in that case.--force— overwrite an existingpublic/. Without it, re-running the command refuses cleanly rather than silently overwriting a file you may have hand-edited.img/ logo. svg
Note
--seed only supplies the seed — it does not make the config rewrite possible. On a non-canonical config the command still writes the SVG, then reports that it could not update zfb.config.ts, prints the line to add, and exits nonzero. Finish it by hand:
export default zudoDoc({
...settings,
logo: "/img/logo.svg",
});The CLI only rewrites a literal, hand-editable field list, so it refuses rather than guess where the field belongs in a spread-controlled object.
Rung 6 — chromeBindingsModule (host-callables)
Under packageOwnedRoutes: true (the default) the doc chrome is wired inside the package, so there is no host file to render project-specific content into. chromeBindingsModule is the seam that gives it back: point it at a host module that exports a chromeBindings object, and the injected routes pick up your slots — a content-header renderer, home-hero extras, custom frontmatter-preview renderers, a footer tag loader, and more.
export default defineConfig(
zudoDoc({
siteName: "My Docs",
chromeBindingsModule: "./src/chrome-bindings.tsx",
}),
);import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
export const chromeBindings = defineChromeBindings({
docContentHeaderExtras: ({ entry }) =>
entry.data.tier === "core" ? <span class="...">Core</span> : null,
});Fresh scaffold document-route stubs already import virtual:zudo-doc-chrome-bindings and pass it to createChrome, including the locale and doc-history shapes. Add the config field and module; do not fork the route stub. Hand-authored routes must pass the same object as createChrome(routeCtx, chromeBindings)'s second argument. The custom components guide shows the complete pattern.
The Design Token Panel has a parallel channel, designTokenPanelConfigModule: the package-default panel works with zero config (just designTokenPanel: true), and to fully customize it you point that setting at a host module exporting buildDesignTokenPanelConfig(mode). Both channels are documented in full — including their loud missing-file behavior — on Host Chrome Bindings.
Rung 7 — Your own pages/*.tsx
If a whole route needs to be yours, add the .tsx file to pages/ at the matching path. A project-owned page always wins over the package-injected route for the same path — the host file shadows the injection. This is how you add an entirely custom landing route or take over a specific injected doc path.
The scaffold stubs are special. pages/ shadows nothing: / is never injected, so it is a one-line re-export that provides the package home page. pages/docs/[[...slug]].tsx does shadow the injected doc route, but it is load-bearing rather than an optional customization example: injected dynamic routes currently return 404 in zfb dev, so the stub keeps doc pages working during development. See Routing Conventions for the complete injection and shadowing model.
Rung 8 — The deploy path (an explicit extend step)
The minimal scaffold builds a pure static site — pnpm build emits dist/, which you can drop on any static host. It ships no adapter and no wrangler.toml by design. Going beyond a static export is a deliberate extend step:
Add a deploy adapter. For Cloudflare Workers, set the
adaptershell field:zfb.config.tszudoDoc({ siteName: "My Docs", adapter: "@takazudo/zfb-adapter-cloudflare", });An adapter is required for any route that opts out of prerendering (
prerender = false) — which is exactly what the AI assistant endpoint does.Add
wrangler.toml. Declare the Worker name, the custom domain route, and any bindings. This file does not exist in a fresh scaffold — you author it when you decide to deploy.Wire the AI assistant, if you enable it.
aiAssistant: truemounts a package-owned SSR/seam. It returns the fixed demo response while demo mode is enabled and HTTP 501 if live mode is selected without a host implementation. The package/scaffold does not ship the showcase's full handler,api/ ai- chat worker-entry.ts, orAiChatDailySpendCap. For a real, Claude-backed assistant you must first implement a host-owned/handler and source Worker entry/Durable Object equivalent to the showcase, then:api/ ai- chat set
aiChatDemoMode: falseafter that host handler is installed;create a
RATE_LIMITKV namespace (wrangler kv namespace create RATE_LIMIT) and bind it inwrangler.toml;bind
AI_CHAT_DAILY_SPEND_CAPtoAiChatDailySpendCapand add migration tagv1-ai-chat-daily-spend-capwithnew_sqlite_classes = ["AiChatDailySpendCap"];add the
ANTHROPIC_API_KEYsecret (wrangler secret put ANTHROPIC_API_KEY);set
aiChatAllowedOriginsto your site origin.
See the showcase Deployment guide and AI Assistant API reference as a host-implementation reference (secrets, soft per-IP KV, exact UTC-day admission, CORS, and preview limitations). Binding the named class without supplying its source is not sufficient.
Rung 9 — Restoring pre-push and HTML validation
The minimal scaffold drops the validation gates the showcase runs — they were dead weight for a small doc set. If your project grows to want them back, add them explicitly:
Pre-push validation (
b4push). The scaffold no longer shipsscripts/or arun- b4push. sh b4pushpackage script. To restore a before-push suite, add your own script (format → typecheck → build → link check) and wire it as apackage.jsonscript. The showcase'sscripts/is a reference implementation.run- b4push. sh HTML validation. The generated
package.jsonno longer includes thecheck:html/html-validatestep (nor.htmlvalidate.json). To validate built HTML, addhtml-validateas a dev dependency, restore a.htmlvalidate.json, and add acheck:htmlscript that runs it overdist/.
Note
The gen:z-index / check:z-index codegen was likewise removed from generated projects — the 13 default z-index tiers now ship unconditionally from @takazudo/, so a project only re-adds that codegen if it maintains a custom tier set. Overriding a single tier is a one-line @theme change (rung 4), not a codegen concern.
See also
Configuration — every
zudoDoc()field and its defaultTheme Packs — the bundled packs, the switcher UI, and the theme CLI
Custom Components — register project-owned MDX components through the shared bindings module
Host Chrome Bindings —
chromeBindingsModuleanddesignTokenPanelConfigModulein depthcreate-zudo-doc CLI — what the scaffold emits