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`:

```ts title="zfb.config.ts"
export default defineConfig(
  zudoDoc({
    siteDescription: "Documentation for using the project.",
    defaultLocale: "en",
    locales: {
      ja: {
        label: "JA",
        dir: "src/content/docs-ja",
        description: "プロジェクトの使い方をまとめたドキュメント。",
      },
      de: { label: "DE", dir: "src/content/docs-de" },
    },
  }),
);
```

Each `locales` entry requires `label` and `dir`. In this example, the Japanese hero uses its own description; German uses the global `siteDescription`.

`description` is an optional string for that locale’s home page hero. When omitted, it falls back to `settings.siteDescription`; an empty string (`""`) intentionally leaves the hero description blank. `settings.locales` contains only non-default locales, so the default locale always uses `settings.siteDescription` and has no locale-specific override.

Current limitations: this field affects only the home page hero description. `llms.txt` still uses the global `siteDescription` for every locale, and home pages still emit no `<meta name="description">`.

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:

```text
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 key | Used for |
|-----------------|----------|
| `htmlPreview.viewport.mobile` | Mobile viewport button |
| `htmlPreview.viewport.tablet` | Tablet viewport button |
| `htmlPreview.viewport.full` | Full-width viewport button |
| `htmlPreview.viewport.label` | Viewport preset group `aria-label` |
| `htmlPreview.source.show` | Collapsed source toggle label |
| `htmlPreview.source.hide` | Expanded source toggle label |
| `htmlPreview.iframe.title` | iframe 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`:

```ts title="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`:

```ts title="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`. Optionally set `description` to translate its home page hero text:

   ```ts
   locales: {
     ja: { label: "JA", dir: "src/content/docs-ja" },
     de: {
       label: "DE",
       dir: "src/content/docs-de",
       description: "Dokumentation zur Verwendung des Projekts.",
     },
   },
   ```

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.

[Dynamic content unavailable in text export]

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`:

```ts title="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.

## Date Formats Per Locale

`dateFormat` accepts per-locale overrides, and on a multilingual site that is normally the form you want: `Aug 22, 2026` and `2026年8月22日` are both correct for their locale, and no single pattern serves both.

On every dated surface, the render locale is the page locale, including on single-locale sites; it is no longer derived from whether a locale switcher is present.

```ts title="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" },
    },
    dateFormat: {
      full: "MMM D, YYYY",
      locales: {
        ja: { full: "YYYY年M月D日", yearMonth: "YYYY年M月" },
      },
    },
  }),
);
```

Each role resolves as `locales[locale][role]` → the top-level `[role]` → `"locale"`. A locale with no entry in `dateFormat.locales` simply uses the top-level roles. See [`dateFormat`](https://zudo-doc.takazudomodular.com/docs/guides/configuration#content-and-i18n-dateformat) for the five roles, the token vocabulary, and the rest of the rules.

[Dynamic content unavailable in text export]

Month **names** — the `MMM` and `MMMM` tokens, and the built-in `"locale"` rendering — resolve through a fixed map: `en` → `en-US`, `ja` → `ja-JP`, `de` → `de-DE`. Every other locale code falls back to `en-US`.

So for a locale outside that map, say `fr`:

- A numeric pattern such as `"DD/MM/YYYY"` renders correctly — digits do not depend on the locale.
- A pattern containing `MMM` or `MMMM` still renders **English** month names.
- Leaving a role at `"locale"` renders that role in the `en-US` shape.

Give such a locale a numeric pattern. A month-name pattern will not produce its language.



## 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: each page's body excerpt is capped by the configurable [`searchMaxBodyLength`](https://zudo-doc.takazudomodular.com/docs/guides/configuration#search-searchmaxbodylength) setting (3,000 characters by default), not stored in full.

## Default-Locale-Only Prefixes

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

```ts title="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.

### A resource section includes its overview prefix

The four sub-section prefixes above are not the whole Claude section. The overview page lives under its own prefix, `/docs/claude/` — `/docs/codex/` for Codex — and a prefix list that omits it leaves the overview localized while the sub-sections are restricted.

That matters at build time, not just in navigation. The overview index is locale-owned: with its prefix unlisted, the generator writes it into every configured locale directory. If an authored `src/content/docs-<locale>/claude/index.mdx` is already sitting at that target and its frontmatter lacks `generated: true`, the build stops with:

```text
resource-docs: refusing to overwrite authored locale index
```

Pick one of the two shapes and apply it to the whole section:

- **Default-locale-only** — list the overview prefix alongside the sub-section prefixes, so nothing under `/docs/claude/` is generated for other locales.
- **Localized** — leave the prefixes unlisted, and remove or rename the authored locale index so the generator can own it. Move its title, description, and labels to `ZudoDocConfig.translations`.