zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Customizing zudo-doc

Created Jun 29, 2026Updated Aug 3, 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 @theme 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 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/styles/global.css 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:

src/styles/global.css
/* ...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 --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 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 sitepnpm 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 @theme change (rung 4), not a codegen concern.

See also

Revision History

Takeshi TakatsudoCreated: 2026-06-30T06:02:03+09:00Updated: 2026-08-04T01:32:17+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…