zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Internationalization (i18n)

Created Mar 11, 2026Updated Sep 1, 2026Takeshi Takatsudo
Tags:#i18n

Add multi-language support to your documentation

zudo-doc supports any number of additional locales through zfb's locale-aware routing. The default locale is the primary content tree; every configured entry in locales is an additional locale.

The Locale Model

The primary locale is selected by defaultLocale and uses the unprefixed /docs/... route space. Additional locales are listed in the locales map and use their code as a URL prefix:

  • Primary English: /docs/...src/content/docs/

  • Additional Japanese: /ja/docs/...src/content/docs-ja/

  • Additional German: /de/docs/...src/content/docs-de/

The list is not limited to Japanese or to a fixed number of locales. Locale codes, labels, and directories are all project configuration.

Settings-Based Locale Configuration

Configure the primary locale and every additional locale in zfb.config.ts:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    defaultLocale: "en",
    locales: {
      ja: { label: "JA", dir: "src/content/docs-ja" },
      de: { label: "DE", dir: "src/content/docs-de" },
    },
  }),
);

Each entry automatically:

  • creates a zfb content collection named docs-{code};

  • registers the code in locale-aware routing;

  • generates the corresponding page routes; and

  • adds the configured label to the language switcher.

Do not hard-code JP, JA, or a fixed list of language links in a component. The switcher renders the configured labels in map order, so a custom locale such as fr-ca: { label: "Français (Canada)", dir: "src/content/docs-fr-ca" } works the same way.

Generator Input Rules

The create-zudo-doc CLI, JSON presets, programmatic API, and Preset Generator share one locale contract:

  • Omitted or blank additional locales: the interactive blank field, an omitted additionalLangs preset field, or no explicit locale list produces a single-locale project. A legacy i18n boolean without a list keeps its compatibility inference: ja is inferred when the primary is en, and en is inferred for any other primary code.

  • Explicit non-empty list: --additional-langs ja,de, additionalLangs: ["ja", "de"], or the equivalent UI input is the complete ordered list. It is normalized to lowercase, implies i18n, and creates docs-ja followed by docs-de.

  • CLI over preset: when both a preset and CLI provide --additional-langs, the CLI list replaces the preset list; entries are not merged or appended. Passing --additional-langs also enables i18n even when a preset or --no-i18n would otherwise disable it. The CLI prints a warning when it overrides an explicit --no-i18n.

  • Validation: each code must match ^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$, must not equal the primary code, and must not repeat. Path separators, .., whitespace, underscores, shell punctuation, and empty explicit entries are rejected before files are written. This keeps locale values safe as directory names and URL segments.

The explicit list wins because it carries enough information to describe the complete locale plan. The legacy inference exists only for callers that still provide the old boolean-style i18n choice.

Directory Structure

Keep each configured locale's tree aligned with the primary tree. Translate prose as you go; filenames, code blocks, and JSX examples should remain the same:

src/content/
├── docs/
│   ├── getting-started/
│   │   ├── introduction.mdx
│   │   └── installation.mdx
│   └── guides/
│       └── configuration.mdx
├── docs-ja/
│   ├── getting-started/
│   │   ├── introduction.mdx
│   │   └── installation.mdx
│   └── guides/
│       └── configuration.mdx
└── docs-de/
    ├── getting-started/
    │   ├── introduction.mdx
    │   └── installation.mdx
    └── guides/
        └── configuration.mdx

The scaffold seeds Japanese starter prose when the locale code is ja. For arbitrary non-Japanese locale codes, it seeds English placeholder starter prose so the route is usable immediately; replace that prose with a translation rather than assuming the page is localized.

Language Switcher

The language switcher appears at the right edge of the header when more than one locale link is available. It lists the configured labels in order, marks the current locale as the active item, and links every other locale to the equivalent page. It recomputes those links after same-locale SPA navigation, including version prefixes and a configured site base.

The switcher also has a CSS hover/focus fallback. With JavaScript disabled, a pointer hover or ordinary keyboard focus/Tab movement still reveals usable locale anchors. On a narrow viewport the same links appear inline in the mobile sidebar footer.

Pages matched by defaultLocaleOnlyPrefixes intentionally collapse to one active link because no alternate-locale route exists. Generated Claude/Codex resources and asset-viewer pages are available in every configured locale by default, so their language switchers retain every locale link.

UI Translation Fallback

Built-in UI strings are resolved for the requested locale first, then the configured default locale, then the package's English table, and finally the raw key:

requested locale → default locale → package English → raw UI-string key

This lets a project configure only the strings it has translated. Locale labels and content-directory paths remain settings-driven and do not depend on the translation table.

The package ships complete built-in UI tables for English and Japanese. Other locale codes in the generator's SUPPORTED_LANGS list do not imply translated chrome; unless a project supplies translations, they follow the same requested locale → configured default → package English → raw key chain.

HtmlPreview controls

The route-bound MDX HtmlPreview binding maps the active page locale to these seven htmlPreview.* keys:

Translation keyUsed for
htmlPreview.viewport.mobileMobile viewport button
htmlPreview.viewport.tabletTablet viewport button
htmlPreview.viewport.fullFull-width viewport button
htmlPreview.viewport.labelViewport preset group aria-label
htmlPreview.source.showCollapsed source toggle label
htmlPreview.source.hideExpanded source toggle label
htmlPreview.iframe.titleiframe title when title is omitted

Each key follows the same lookup order: requested locale → configured defaultLocale → package English table → raw key. A project can provide only the labels it translates. Configure per-locale overrides in translations:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    translations: {
      fr: {
        "htmlPreview.viewport.mobile": "Téléphone",
        "htmlPreview.viewport.tablet": "Tablette",
        "htmlPreview.viewport.full": "Plein écran",
        "htmlPreview.viewport.label": "Taille de la fenêtre",
        "htmlPreview.source.show": "Afficher le code",
        "htmlPreview.source.hide": "Masquer le code",
        "htmlPreview.iframe.title": "Aperçu",
      },
    },
  }),
);

The per-call labels prop is merged after the locale defaults, by key. Supplied keys override their locale values; omitted keys and keys set to undefined retain the locale values. A direct, non-route-bound component import has no requested locale and therefore stays on the built-in English labels unless labels is supplied. The technical HTML, CSS, Head, and JS code-panel headings are fixed and are intentionally not part of this translation API.

The presentation-only flags are independent of translation: showSource and showViewportControls both default to true. Setting showSource={false} structurally removes the source toggle, code panel, and highlighting markup while keeping the iframe; defaultOpen then has no effect. Setting showViewportControls={false} removes the preset group but keeps the Full-width container and drag-resize affordance. Both flags may be false together; the iframe remains, and a title bar is shown when viewport controls are visible or a non-empty title is supplied. With both controls hidden and no title, the otherwise-empty bar is omitted.

Generated resource overviews and category labels use the resource.* namespace. Asset index and viewer chrome use asset.*. Override either namespace through ZudoDocConfig.translations:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    translations: {
      ja: {
        "resource.claude.description": "チーム用 Claude Code リファレンス。",
        "asset.details": "ファイル情報",
      },
    },
  }),
);

Adding a Locale

To add a locale such as German:

  1. Add its exact code, display label, and content directory to locales:

    locales: {
      ja: { label: "JA", dir: "src/content/docs-ja" },
      de: { label: "DE", dir: "src/content/docs-de" },
    },
  2. Create src/content/docs-de/ and mirror the primary directory tree.

  3. Translate the mirrored MDX pages while preserving filenames, code blocks, and JSX blocks.

  4. Add any project-specific UI translations for de; missing keys follow the fallback chain above.

Tip

The locales map is the single source of truth. Adding an entry creates its collection, routes, and switcher link; no per-locale route file or hand-written collection is needed.

defaultLocale

defaultLocale identifies the locale served without a URL prefix. It defaults to "en" and must be the primary locale code, not one of the keys in locales:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    defaultLocale: "en",
    locales: {
      ja: { label: "JA", dir: "src/content/docs-ja" },
      de: { label: "DE", dir: "src/content/docs-de" },
    },
  }),
);

The primary locale is served at /docs/...; each additional locale uses its code, such as /ja/docs/... or /de/docs/.... This comes from defaultLocale plus the route split in pages/ (pages/docs/[[...slug]].tsx and pages/[locale]/docs/[[...slug]].tsx); there is no prefixDefaultLocale option.

Generated Pages and Default-Locale Bodies

Claude/Codex resource generators emit their overview and category-index pages into every configured locale directory. Asset viewer routes likewise exist at /{locale}/${assetViewerRoutePrefix}/.... Their title, description, navigation, breadcrumb, header, actions, and other shell labels resolve for the requested locale. The language switcher therefore links equivalent generated routes instead of collapsing to the default locale.

Resource detail bodies remain owned by the configured default locale by design. They are canonical dumps of project source, not translated documentation; their language follows the source corpus and is not necessarily English. For this repository's measured resource corpus, copying them would duplicate roughly 530 KB per additional locale on disk and duplicate the same untruncated bodies again in each dist/{locale}/llms-full.txt, because plugins/internal/llms-txt/load.ts scans locale directories directly. It would also break the fallback convention used by every other untranslated page. Locale routes render the default-locale body inside localized chrome and intentionally omit the ordinary untranslated-page banner.

This creates a deliberate indexing asymmetry. A locale's llms.txt and search entries include its localized resource overview pages, but not resource detail pages supplied through route fallback. This is consistent with other fallback pages because both generators index files physically present in that locale's content directory. Search-index storage is not the duplication concern: its body excerpt is capped at 300 characters by MAX_BODY_LENGTH in search-index/types.ts.

Default-Locale-Only Prefixes

Some content has no translation and should exist only in the primary locale. Configure those URL prefixes in defaultLocaleOnlyPrefixes:

zfb.config.ts
export default defineConfig(
  zudoDoc({
    defaultLocaleOnlyPrefixes: [
      "/files/",
      "/docs/claude-md/",
      "/docs/claude-skills/",
      "/docs/claude-agents/",
      "/docs/claude-commands/",
      "/docs/codex-agents-md/",
      "/docs/codex-config/",
      "/docs/codex-agents/",
      "/docs/codex-hooks/",
      "/docs/codex-rules/",
      "/docs/codex-skills/",
    ],
  }),
);

Any page under one of these prefixes is emitted only in the primary locale. The non-primary route list omits it and the language switcher emits only the current active link. Keep prefixes trailing-slash terminated and narrow enough that they do not hide pages you intend to translate. Documentation routes are rooted at /docs/; the asset viewer uses /${assetViewerRoutePrefix}/ (shown as /files/ above).

These entries are an opt-out recipe, not package defaults. Add /${assetViewerRoutePrefix}/ to restore default-locale-only asset pages, and add whichever resource prefixes you want to restrict. Leave the top-level /docs/claude/ and /docs/codex/ overviews unrestricted if you still want their localized landing pages.

Revision History

Takeshi TakatsudoCreated: 2026-03-11T22:32:04+09:00Updated: 2026-09-01T12:32:23+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…