zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Customizing zudo-doc

Created Jun 29, 2026Updated Oct 9, 2026Takeshi Takatsudo

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

RungReach for it whenCost
1. buildDocsSchema overrideYou need a custom, validated frontmatter keyReplace one function; own the schema
2. Config fieldsToggling a feature or tuning behaviorNone — it is just a field
3. Theme packsYou want a whole prebuilt look, not a hand-tuneNone — one field or one CLI command
4. Token overridesRe-theming colors, spacing, typographyA :root block in global.css
5. zudo-doc ejectA content-layer component you re-reference must changeYou own the ejected copy and its imports
6. chromeBindingsModuleReplacing primary chrome or injecting host callables, including custom MDX componentsOne host module
7. Your own pages/*.tsxA whole route must be yoursYou own that route
8. Deploy pathGoing live (especially with SSR features)Adapter + wrangler.toml + secrets
9. Restoring pre-push / HTML checksYou want the showcase's validation gatesCopy 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:

zfb.config.ts
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:

zfb.config.ts
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:

zfb.config.ts
zudoDoc({
  siteName: "My Docs",
  themePack: "foundry",
});
pnpm exec zudo-doc theme apply foundry

Every pack defines both its light and dark values, so readers can keep choosing Light, Dark, or System from the appearance menu on any pack. System follows the device independently of the active pack. 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/styles/global.css is a short @import chain that pulls the package's theme.css (all the :root design tokens), content.css, and features.css — followed by an empty :root { … } block reserved for you. Because it comes after the package imports, anything you redefine there wins the cascade:

src/styles/global.css
/* ...package @imports above... */

:root {
  --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.

If your project also merges a second design system’s :root, read Coexisting with another @theme.

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 --help

The 18 ejectable components include two different scopes:

  • Content-layer: tab-item, content-admonition, code-group, and details. 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, and doc-pager. Bind the local copy to the matching chromeBindings.Header, Footer, Breadcrumb, Toc, Sidebar, or DocPager slot.

  • Nested chrome: theme-toggle, page-loading, sidebar-tree-island, sidebar-toggle-island, desktop-sidebar-toggle-island, image-enlarge, doc-history, and site-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 logo

This writes public/img/logo.svg — 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 "/img/logo.svg" in zfb.config.ts.

  • --seed <name> — override the siteName seed used to render the mark. Required when zfb.config.ts is not in the canonical scaffold shape (for example zudoDoc({ ...settings })), since the seed cannot be read statically in that case.

  • --force — overwrite an existing public/img/logo.svg. Without it, re-running the command refuses cleanly rather than silently overwriting a file you may have hand-edited.

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:

zfb.config.ts
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.

zfb.config.ts
export default defineConfig(
  zudoDoc({
    siteName: "My Docs",
    chromeBindingsModule: "./src/chrome-bindings.tsx",
  }),
);
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/index.tsx 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 remains an explicit host-owned seam rather than an optional customization example. zfb 2.13.1 renders injected dynamic routes in both dev and build; the kept stub preserves host ownership and customization. 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:

  1. Add a deploy adapter. For Cloudflare Workers, set the adapter shell field:

    zfb.config.ts
    zudoDoc({
      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.

  2. 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.

  3. Wire the AI assistant, if you enable it. aiAssistant: true mounts a package-owned SSR /api/ai-chat 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, worker-entry.ts, or AiChatDailySpendCap. For a real, Claude-backed assistant you must first implement a host-owned /api/ai-chat handler and source Worker entry/Durable Object equivalent to the showcase, then:

    • set aiChatDemoMode: false after that host handler is installed;

    • create a RATE_LIMIT KV namespace (wrangler kv namespace create RATE_LIMIT) and bind it in wrangler.toml;

    • bind AI_CHAT_DAILY_SPEND_CAP to AiChatDailySpendCap and add migration tag v1-ai-chat-daily-spend-cap with new_sqlite_classes = ["AiChatDailySpendCap"];

    • add the ANTHROPIC_API_KEY secret (wrangler secret put ANTHROPIC_API_KEY);

    • set aiChatAllowedOrigins to 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 ships scripts/run-b4push.sh or a b4push package script. To restore a before-push suite, add your own script (format → typecheck → build → link check) and wire it as a package.json script. The showcase's scripts/run-b4push.sh is a reference implementation.

  • HTML validation. The generated package.json no longer includes the check:html / html-validate step (nor .htmlvalidate.json). To validate built HTML, add html-validate as a dev dependency, restore a .htmlvalidate.json, and add a check:html script that runs it over dist/.

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/zudo-doc/theme.css, so a project only re-adds that codegen if it maintains a custom tier set. Overriding a single tier is a one-line :root change (rung 4), not a codegen concern.

See also

Revision History

Takeshi TakatsudoCreated: 2026-06-30T06:02:03+09:00Updated: 2026-10-09T16:26:27Z

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…