Host Chrome Bindings
Inject host-owned content into the doc-content header and home hero of package-owned routes, delivered through a single chromeBindings module.
When a project sets packageOwnedRoutes: true, @takazudo/zudo-doc injects the doc routes itself and the project ships an almost-empty pages/ directory. That is convenient, but it removes the host files where you would normally hang project-specific rendering. Host chrome bindings are the seam that gives it back: a small, typed set of slots (ChromeHostBindings) that let the host inject its own content and callables into the package-owned chrome without ejecting anything.
This page covers the two content-injection seams — docContentHeaderExtras and homeExtras — and the delivery channel that carries every binding into the injected routes, settings.chromeBindingsModule.
Note
These seams matter mainly under packageOwnedRoutes: true. A project that still ships its own pages/*.tsx stubs already has host files to render into and can call createChrome(context, hostBindings) directly. See Routing Conventions for how package-owned routes are enumerated.
The ChromeHostBindings type
Every seam described here is a field on the ChromeHostBindings interface, exported from @takazudo/:
import type { ChromeHostBindings } from "@takazudo/zudo-doc/factory-context";All fields are optional. Any slot you omit falls back to the package default that reproduces the pre-seam behavior byte-for-byte, so a partial bindings object is always safe.
ChromeHostBindings types every slot with the wide structural shape the chrome's own call sites need internally. Assigning a real, narrowly-typed component or callable to it directly — or reaching for an as/as unknown as cast to make the assignment compile — erases the one check that matters: whether the value you provide actually accepts the props the chrome passes it. Build bindings with defineChromeBindings instead, exported from @takazudo/: its input type, ChromeBindingsInput, declares the exact props or args each slot's real call site passes, so a component that requires a prop the chrome never provides is a compile error (the drift-detection check from #2674), and the wide ChromeHostBindings shape is produced by one internal widening step you never have to reason about.
The delivery channel: chromeBindingsModule
Under packageOwnedRoutes: true the chrome is wired inside the package, so there is no host call site to pass bindings into. The route context that the plugin hands to the routes carries serializable data only (settings, translations, tag vocabulary) — it cannot carry functions or components. The channel works around this: instead of serializing the callables, the host serializes a path to a module that exports them.
Set chromeBindingsModule in your zudoDoc() config to a project-root-relative path pointing at a module with a named chromeBindings export built with defineChromeBindings:
export default defineConfig(
zudoDoc({
// packageOwnedRoutes defaults to true
chromeBindingsModule: "./src/chrome-bindings.tsx",
}),
);import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
export const chromeBindings = defineChromeBindings({
// ...seams go here (see below)
});At build time the routes plugin registers a virtual module that re-exports your chromeBindings, and the injected chrome shim spreads it into the chrome factory. A string path is serializable, so the "route context is data only" rule still holds — only the loader source differs.
It un-strands the pre-existing slots too
docContentHeaderExtras and homeExtras are new, but ChromeHostBindings already had several slots that had no way to reach injected routes before this channel existed — they silently stayed at their stub defaults. chromeBindingsModule un-strands all of them at once:
| Slot | Default without the channel |
|---|---|
frontmatterRenderers | {} — no custom frontmatter preview renderers |
buildFrontmatterPreviewEntries | () => [] — the preview table never rendered |
SearchWidget | package default only |
loadTagsForLocale / tagVocabulary | empty — no footer tag columns |
sidebarsConfig | {} — auto-generated tree only |
docHistoryMeta | {} — no Created/Updated block |
So the same module you create for docContentHeaderExtras is also where you would, for example, register the custom frontmatter renderers that previously only worked on host-owned routes.
Warning
SSR-presentational contract only. A client island defined inside the bindings module is not guaranteed to hydrate on injected routes — the virtual re-export sits outside zfb's static-import scanner reachability graph. Use the bindings module for server-rendered content and callables. If you need a hydrating island on an injected route, it still needs a statically-imported registration path.
Info
Missing-file and empty-string behavior are both loud. If chromeBindingsModule is set but the resolved file does not exist, the build fails at plugin setup with an error naming the resolved absolute path — never a silent empty fallback. If chromeBindingsModule is set to an empty or blank string, the build also fails at plugin setup, naming the setting. When the setting is absent (not set at all), the channel emits export const chromeBindings = {} and behavior is byte-identical to omitting it.
docContentHeaderExtras — content-header injection
docContentHeaderExtras renders extra content in the doc-content header, between the page <h1> and the metainfo block. It is a renderer — a function that receives the page entry and returns renderable output — so it is naturally keyed on the current page's frontmatter rather than re-deriving it from props.
docContentHeaderExtras?: (args: {
entry: DocPageEntry;
slug: string;
locale: string;
isFallback?: boolean;
version?: string;
}) => unknown;A common use is a tier badge derived from a custom frontmatter field. Given pages that declare tier: core or tier: opt-in in their frontmatter:
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
export const chromeBindings = defineChromeBindings({
docContentHeaderExtras: ({ entry }) => {
const tier = entry.data.tier;
if (tier !== "core" && tier !== "opt-in") return null;
const label = tier === "core" ? "Core" : "Opt-in";
const tone = tier === "core" ? "bg-accent text-bg" : "bg-surface text-fg";
return (
<span
class={`inline-block px-hsp-sm py-vsp-2xs text-caption rounded-full ${tone}`}
>
{label}
</span>
);
},
});Default: absent → nothing renders, and the header output is byte-identical to the pre-seam header.
Versioned pages
The renderer is called for entry doc pages on all four doc routes, including versioned pages, where it receives the version argument. On versioned pages the metainfo block and tags are hidden — the renderer decides for itself whether and how to render there. See Versioning for the versioned-route model.
homeExtras and HomePageView — home hero injection
homeExtras renders extra content in the home hero, after the overview/GitHub links row and inside the hero text column — for example, a brand or social link under the site description:
homeExtras?: (args: { locale: string }) => unknown;export const chromeBindings = defineChromeBindings({
homeExtras: ({ locale }) => (
<p class="mt-vsp-sm text-small">
<a
href="https://example.com/blog"
class="text-fg underline hover:text-accent"
>
Read the blog
</a>
</p>
),
});Precedence: extras prop wins
The shared home body is built by HomePageView, which the package routes and any host page both call. It accepts an extras prop — an already-rendered value the host page passes directly. When both are present, the prop wins:
// resolved inside HomePageView as:
extras ?? hostBindings.homeExtras?.({ locale })The asymmetry is intentional: a host page has its JSX in hand (a value), while the injected/bindings path only has a locale string at render time and must derive its own content from it (a renderer).
The / route topology
The default home at / is never injected by the routes plugin — zfb rejects a / pattern (upstream Takazudo/zudo-front-builder#1227). This creates a split you should know about:
The
/home always renders through the host's thinpages/adapter, so extras arrive via itsindex. tsx HomePageViewextrasprop (or the host's owncreateChromecall).Injected
/homes read[locale] hostBindings.homeExtrasthroughchromeBindingsModule.
For an i18n site, setting homeExtras in your bindings module — and passing the same content as the extras prop in pages/ — is what keeps the default home and the locale homes consistent.
The Design Token Panel channel (designTokenPanelConfigModule)
The Design Token Panel has its own host-module channel that works exactly like chromeBindingsModule — same mechanics, same loud missing-file behavior — but carries the panel's config builder instead of chrome bindings.
The panel needs no host config file to work. With designTokenPanel: true, the injected DesignTokenPanelBootstrap island uses a package-default builder (@takazudo/) derived from the shipped token manifest and the bundled Default Light / Default Dark schemes. That is the zero-config path.
To fully customize the panel, set designTokenPanelConfigModule to a project-root-relative path pointing at a host module that exports a named buildDesignTokenPanelConfig(mode):
import type { PanelConfig } from "@takazudo/zdtp";
type PanelMode = "light" | "dark";
export function buildDesignTokenPanelConfig(mode: PanelMode): PanelConfig {
// return a mode-scoped panel config (spacing/font/size/color tiers)
// ...
}The routes plugin registers a third virtual module (virtual:zudo-doc-design-token-panel-config) that re-exports your builder. As with chromeBindingsModule, only the path travels through config; the builder is imported via the module graph.
Note
designTokenPanelConfigModule is a first-class ZudoDocConfig field, just like chromeBindingsModule — pass it directly as an inline zudoDoc({ … }) key. Enabling the panel (designTokenPanel: true) works inline as usual.
Two differences from chromeBindingsModule are worth knowing:
The default is the package builder, not an empty object. When the setting is absent the loader re-exports the package default (a fully working panel) — there is no meaningful "empty" builder to fall back to.
Scanner reachability is part of this contract. The real
DesignTokenPanelBootstrapisland is statically imported by the package chrome (not carried through the config virtual module), so it is always reachable by zfb's island scanner. Only the panel-config data (the mode-scoped builder) travels through the channel — which is why a customized panel hydrates on injected routes where a chrome-bindings island would not.
The same guard rules apply: an explicitly empty string, a missing file, or a directory path fails loudly at plugin setup, naming the resolved path. storagePrefix: "zudo-doc-tweak" is preserved by the package default so existing user saves carry over.
See also
Customizing zudo-doc — the minimal→extend ladder, of which
chromeBindingsModuleis rung 5Frontmatter Preview — the
frontmatterRenderersslot this channel also un-strandsConfiguration — the full
zudoDoc()field referenceDesign Token Panel — the panel UI this channel configures