zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Host Chrome Bindings

Created Jul 2, 2026Updated Aug 19, 2026Claude

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 named header-right components, the docContentHeaderExtras and homeExtras content seams, and the delivery channel that carries every binding into injected and self-contained 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/zudo-doc/factory-context:

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/zudo-doc/chrome-bindings: 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:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    // packageOwnedRoutes defaults to true
    chromeBindingsModule: "./src/chrome-bindings.tsx",
  }),
);
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.

Scaffold and custom-route threading

Fresh scaffold document-route stubs import virtual:zudo-doc-chrome-bindings and pass the result to createChrome, so the same module reaches both self-contained and injected routes. If you author a route from scratch, pass chromeBindings as the second createChrome(routeCtx, chromeBindings) argument too. The global mdxExtras registration pattern shows that route seam in context.

Named header-right components

headerRightItems stays serializable: a custom component item carries only its string name. The callable renderer lives in chromeBindings.headerRightComponents and is resolved during build/SSR. Here is the complete configuration:

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

export default defineConfig(
  zudoDoc({
    siteName: "Acme Docs",
    chromeBindingsModule: "./src/chrome-bindings.tsx",
    headerRightItems: [
      { type: "link", href: "/status", label: "Status" },
      { type: "component", component: "release-badge" },
      { type: "component", component: "search" },
    ],
  }),
);
src/chrome-bindings.tsx
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";

export const chromeBindings = defineChromeBindings({
  headerRightComponents: {
    "release-badge": ({ item, index, lang }) => (
      <a
        href={`/${lang ?? "en"}/releases`}
        data-component={item.component}
        data-position={index}
      >
        v4
      </a>
    ),
  },
});

The renderer receives the exact header-right render context: the serialized item, its zero-based index, locale, GitHub values, built-in child slots, and the color-mode/locale gates. Registry values must be functions. The built-in names (theme-toggle, language-switcher, version-switcher, github-link, and search) are reserved and cannot be shadowed. A reserved duplicate or unknown configured name fails with the offending registry/settings location and a remediation message instead of silently omitting markup.

Only the item name travels in the route-context data. headerRightComponents remains in the host-callables module and is never serialized. As with other bindings-module components, treat these renderers as SSR-presentational unless they also have a supported static island-registration path.

Preserving session-scoped island props

By default, same-locale navigation refreshes the serialized props of islands nested under the persisted header from the incoming page. Keep that behavior for page-derived props. If a statically registered host island instead owns session-scoped props — for example, a badge whose initial count must not be replaced by the next page's SSR snapshot — place data-zd-props-preserve on the island's root element or an ancestor inside the persisted header:

<div data-zd-props-preserve>
  <SessionBadge initialCount={unreadCount} />
</div>

The live DOM governs the opt-out; adding the attribute only to the incoming page does not preserve the current island. A preserved island receives neither an updated data-props value nor a remount flag. Placing data-zd-props-preserve on the persisted header itself is a blanket opt-out for every nested island, so prefer the narrowest boundary that owns the session-scoped props.

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:

SlotDefault when omitted
HeaderPackage HeaderWithDefaults; receives HeaderSlotProps
FooterPackage FooterWithDefaults; receives FooterSlotProps
SidebarPackage SidebarWithDefaults; receives SidebarSlotProps
TocPackage desktop Toc; receives TocSlotProps
BreadcrumbPackage Breadcrumb; receives BreadcrumbSlotProps
DocPagerPackage DocPager; receives DocPagerSlotProps
SearchWidgetPackage search widget, with the site base bound internally
headerRightComponents{} — only package built-in header-right names resolve
docHistoryMeta{} — no Created/Updated block
sidebarsConfig{} — auto-generated tree only
frontmatterRenderers{} — no custom frontmatter preview renderers
buildFrontmatterPreviewEntries() => [] — the preview table never renders
loadTagsForLocale() => [] — no footer tag entries
tagVocabulary[] — no footer tag vocabulary
BodyEndIslandsPackage island subset derived from settings
DocHistoryNo-op stub — renders nothing
DesignTokenPanelBootstrapReal package bootstrap; it mounts when designTokenPanel: true, with no host wiring
mdxExtrasPackage SSR components (Details, HtmlPreview, Island) plus a no-op PresetGenerator
docContentHeaderExtrasAbsent — nothing renders
homeExtrasAbsent — nothing renders

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.

Generated and package-owned document routes are one deliberate exception to normal object precedence: when docHistory is enabled, their statically imported package DocHistory is overlaid after the configured object. That keeps its client-island registration scanner-reachable while preserving every other binding. A DocHistory value declared only in the virtual host module cannot replace it on those 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:

src/chrome-bindings.tsx
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 INLINE at the end of the home hero's links row, /-separated from whatever precedes it (the primary link and/or the GitHub link) — for example, a brand or social link alongside the overview/GitHub links:

homeExtras?: (args: { locale: string }) => unknown;
src/chrome-bindings.tsx
export const chromeBindings = defineChromeBindings({
  homeExtras: ({ locale }) => (
    <a
      href="https://example.com/blog"
      class="text-fg underline hover:text-accent"
    >
      Read the blog
    </a>
  ),
});

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). The scaffold's pages/index.tsx is a one-line re-export of @takazudo/zudo-doc/routes/index; it supplies no extras prop. The host file always wins at / because there is no injected / route to use instead.

  • The unmodified / home uses the package route re-export and does not receive homeExtras through an extras prop.

  • Injected /[locale] homes read hostBindings.homeExtras through chromeBindingsModule.

To customize the root home, replace the re-export with your own page that calls HomePageView and passes extras. For an i18n site, pass the same content there that homeExtras returns for locale homes.

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/zudo-doc/design-token-panel-config) 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):

src/design-token-panel-config.ts
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 DesignTokenPanelBootstrap island 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.

  • Self-contained pages/ stubs mount no panel while this setting is set. The setting only reaches pages rendered through the package-injected routes, and the panel is configured once per browser session — so a stub page mounting the package-default bootstrap used to decide the whole session's config whenever it was the hard-loaded entry page. With the setting present (and no explicit chromeBindings.DesignTokenPanelBootstrap), stub-rendered pages therefore mount no panel island (and no header panel button) at all; the injected routes still get your configured panel from any entry page. To get a panel on stub-rendered pages too, thread your builder through chromeBindings.DesignTokenPanelBootstrap — that binding wins everywhere.

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

Revision History

ClaudeCreated: 2026-07-02T06:07:27ZUpdated: 2026-08-20T07:16:12+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…