Docs & Translations
ZeroClaw has two independent translation layers:
| Layer | Format | What it covers |
|---|---|---|
| App strings | Mozilla Fluent (.ftl) | CLI help text, command descriptions, runtime messages |
| Docs | gettext (.po) | Everything in this mdBook |
They are filled separately and stored separately. Both use a provider-agnostic fill pipeline: configure any OpenAI-compatible endpoint under providers.models.<kind>.<alias> and pass --model-provider <alias> to the fill commands. Any configured alias is choosable: a bare alias (--model-provider <alias>), or a kind.alias qualifier (--model-provider anthropic.<alias>) when the same alias exists under more than one kind. The resolver reads uri, model, and api_key straight from the matched entry; a missing uri or model is a hard error, not a guessed default.
Local models via Ollama are a first-class option: no API keys required, no per-call cost. A hosted provider is also fine for release-grade quality. Translation is a local operation. Run cargo mdbook sync for dedicated translation-cache PRs, release translation passes, and new locales; routine English docs PRs may defer broad generated .po churn to a focused follow-up.
Provider configuration
Ollama is the current canonical source for docs. Ensure you have Ollama installed and have qwen3:30b-a3b pulled, then configure an Ollama provider entry. uri is the full endpoint URL and is optional: leave it unset to use the provider family’s default endpoint (resolved by the runtime provider stack). Set it only to point at a self-hosted gateway or proxy. Any configured family works (Anthropic, OpenAI, OpenRouter, Ollama, …); the translation tools build the real runtime provider, so each family’s endpoint, auth header, and wire protocol are handled for you: no OpenAI-compatibility requirement.
Building the docs locally
Translation catalogues (git submodule)
The translated .po catalogues live in the zeroclaw-labs/zeroclaw-docs-translations submodule mounted at docs/book/po. The Rust dev loop (cargo build, cargo test, cargo clippy) does not need it, but building or syncing the docs does. Initialise it once:
sh
git clone --recurse-submodules https://github.com/zeroclaw-labs/zeroclaw # fresh clone
git submodule update --init docs/book/po # existing clone
Without the submodule checked out, English still builds (the English source lives in docs/book/src/), but translated locales render as empty.
One-command quickstart
sh
cargo mdbook serve # serve all locales at http://localhost:3000/en/
cargo mdbook serve --locale ja # live-reload against Japanese source
cargo mdbook build # static build of every locale into docs/book/book/
cargo mdbook refs # regenerate the auto-generated reference pages
cargo mdbook sync # translation-cache pass: re-extract + merge .po files
cargo mdbook sync --locale ja # sync one locale only
cargo mdbook sync --force # force-retranslate everything (quality pass)
cargo mdbook sync --locale ja --force # force-retranslate one locale
cargo mdbook stats # show translated/fuzzy/untranslated per locale
cargo mdbook check # validate .po format (run before a translation PR)
Always go through the
cargo mdbook …wrapper. Runningmdbook builddirectly fromdocs/book/skips the xtask step that renderstheme/lang-switcher.jsfromlocales.toml, which fails the build withfailed to open theme/lang-switcher.js for hashing.
Required tools
cargo mdbook will fail fast and tell you what’s missing, but for reference:
| Tool | Install |
|---|---|
mdbook | cargo install mdbook --locked |
mdbook-i18n-helpers | cargo install mdbook-i18n-helpers --locked |
cargo | https://rustup.rs |
gettext (msgfmt, msgmerge) | apt install gettext / brew install gettext |
What gets built where
| Source | Output | Generated by |
|---|---|---|
docs/book/src/**/*.md (hand-written) | docs/book/book/<locale>/ | mdbook build |
docs/book/src/reference/cli.md | (same path; gitignored) | cargo mdbook refs |
docs/book/src/reference/config.md | (same path; gitignored) | cargo mdbook refs |
target/doc/ (rustdoc) | docs/book/book/api/ | cargo doc --no-deps --workspace |
The two reference/*.md files are generated from the actual clap derives and JSON schema in the code, never edit them by hand. Edit the /// doc comments on the relevant Rust types instead.
cargo mdbook is an alias for cargo run -p xtask --bin mdbook -- (defined in the cargo config). For a lean contributor-facing version of this section, see Building the docs locally.
Note
Full-text search is built only for the primary locale (English, first in
locales.toml). Translated locales build without a search index or search box. Per-locale search indexes are large (~6-7 MB each) and dominategh-pagesclone size; restricting search to English keeps clones lean. Adding a search box back to a translated locale means re-enablingoutput.html.search.enablefor that build inbuild_locales(xtask/src/cmd/mdbook/build.rs).
How translations stay current
When English source changes, cargo mdbook sync runs two stages:
- Extract:
mdbook-xgettextregeneratespo/messages.potfrom the current English source. - Merge:
msgmergeupdates each locale’s.pofile, new strings get an emptymsgstr ""; changed strings get marked#, fuzzywith the old translation preserved as a starting point.
Then the command counts fuzzy + untranslated entries and, when --model-provider is given, fills only those. Unchanged strings cost nothing: the .po cache means re-running against unchanged source is a no-op. Without --model-provider, sync still runs extract + merge and reports the delta; strings without a msgstr fall back to English at render time.
Sync normalizes catalogs with stable output rules (msgcat --sort-output --no-wrap --add-location=file), so diffs stay focused on real source changes. Unavoidable churn: header metadata (POT-Creation-Date etc.), reference-location updates when a string moves files, and actual source-string edits.
Routine English docs PRs may defer broad .po churn to a focused follow-up. Include .po updates only when the PR is a translation-cache pass, a release-translation pass, adds a locale, or produces a small reviewable diff.
Filling app strings (Fluent)
App strings live in crates/zeroclaw-runtime/locales/. English is the source of truth and is embedded at compile time.
Runtime loading caveat (verify before relying on this). Only
enandzh-CNare wired into the runtime as built-ins:crates/zeroclaw-runtime/src/i18n.rsembedsenviainclude_str!, andbuiltin_cli_ftl_source()returns the embeddedzh-CNcatalogue forzh-CNandNonefor every other locale. A disk-override path exists:load_ftl_from_diskresolveszeroclaw_config::schema::ftl_locale_dir(locale), i.e.<config-dir>/data/ftl/<locale>/cli.ftl(the same locationzeroclaw locales fetchpopulates). So a freshly filledja/cli.ftlis generated and committed, but is not loaded at runtime unless either the locale is added tobuiltin_cli_ftl_source()or the filledcli.ftlis placed under<config-dir>/data/ftl/ja/. Confirm the current state ini18n.rsandzeroclaw_config::schema::ftl_locale_dirrather than trusting this note.The
apps/zerocodeTUI maintains an independent Fluent catalogue (apps/zerocode/locales/), see zerocode strings below.cargo fluentwalks both catalogue roots (runtime + zerocode), so every subcommand below covers both by default.
sh
cargo fluent stats # coverage per locale, per catalogue
cargo fluent check # validate .ftl syntax across both catalogues
cargo fluent fill --locale ja --model-provider anthropic.<alias> # fill missing keys (default batch 50)
cargo fluent fill --locale ja --model-provider anthropic.<alias> --batch 10 # smaller batches: fewer entries per request (eases rate limits / truncation)
cargo fluent fill --locale ja --model-provider anthropic.<alias> --force # retranslate everything
cargo fluent scan # find stale or missing keys vs Rust source
Scoping to one catalogue: every subcommand takes --catalog <runtime|zerocode> (default: both). To translate only the TUI:
sh
cargo fluent fill --locale ja --model-provider anthropic.<alias> --catalog zerocode
cargo fluent check --catalog zerocode # syntax-check only zerocode
An unknown --catalog value errors with the valid choices.
fill generates <locale>/<domain>.ftl for every selected catalogue root that has an en/ directory: the runtime’s cli.ftl/tools.ftl and zerocode’s zerocode.ftl.
Provider resolution is shared with the runtime. --model-provider accepts any alias configured under [providers.models.<kind>.<alias>]: a bare alias (<alias>) or a kind.alias qualifier (anthropic.<alias>) when ambiguous. The tool builds the actual runtime provider, so the endpoint, auth header, and wire protocol are resolved per family (Anthropic /v1/messages + x-api-key, OpenAI-compatible /v1/chat/completions + Bearer, etc.): nothing is assumed. Encrypted api_key values are decrypted through the canonical SecretStore. Use --config-dir <dir> (mirrors zeroclaw --config-dir) to read config + .secret-key from a non-default location; defaults to ~/.zeroclaw then ~/.config/zeroclaw.
Batching: fill sends one request per batch (all N entries as a single JSON object); --batch lowers N to ease provider rate limits or response truncation on long entries. Each batch is written to disk before the next request, so a mid-run failure only loses the in-flight batch. Re-running skips keys that already exist in the target .ftl, so resume is automatic: no --force needed.
zerocode strings (Fluent, independent)
apps/zerocode carries its own self-contained Fluent setup, separate from the runtime catalogues above. The TUI is intentionally decoupled from the rest of the workspace: it has no zeroclaw-* crate dependency, and its strings live next to its source rather than under zeroclaw-runtime/locales/.
| Where | What |
|---|---|
apps/zerocode/locales/en/zerocode.ftl | Source of truth, embedded at compile time |
apps/zerocode/locales/<locale>/zerocode.ftl | Other locales, embedded if present in-tree |
$ZEROCODE_LOCALE_DIR/<locale>/zerocode.ftl | Explicit override, useful for testing translations |
<config-dir>/zerocode/locales/<locale>/zerocode.ftl | Per-user catalogue override |
~/.zeroclaw/zerocode/locales/<locale>/zerocode.ftl | Alternate per-user location |
<install-prefix>/share/zerocode/locales/<locale>/zerocode.ftl | System install path |
Key namespace
All zerocode keys are prefixed zc- and never collide with the runtime’s cli-, channel-, or tool- namespaces. The convention inside zc- is zc-<pane>-<purpose>:
zc-pane-<name>: top-level mode bar labelszc-app-<purpose>: strings owned byapp.rs(dialogs, help, status)zc-<pane>-<purpose>: strings local to a specific pane (zc-dashboard-*,zc-chat-*, …)
Chord literals are not translated
Chord glyphs like Ctrl+C, Esc, Shift+Up are protocol, not language. The HelpEntry and HelpNode constructors take the chord vector as &'static str and the description as String, so chord literals stay hard-coded while descriptions flow through t(). When prose embeds a chord inline, use a { $keys } Fluent slot and pass the chord at render time rather than concatenating translated text around a literal.
Locale resolution
Locale comes from a top-level locale field in zerocode’s config. When unset, i18n::detect_locale() reads the config dir resolved as --config-dir, then ZEROCLAW_CONFIG_DIR, then ~/.zeroclaw, and otherwise falls back to en. zerocode resolves its locale independently from its own config; it does not share the daemon’s lookup.
Adding strings
- Add the key + English value to
apps/zerocode/locales/en/zerocode.ftl. Group keys by source file with a section comment so the catalogue stays scannable. - Replace the literal in the source with
crate::i18n::t("zc-…"). For enum→labelmatcharms, return the key constant (&'static str) from afluent_key()method and callt()at the render site, nevermatchon a string. cargo check -p zerocodeand thei18nunit tests (cargo test -p zerocode i18n) catch missing keys at compile/test time. Missing keys at runtime render as{zc-key-name}and emit a one-shot stderr warning.
Filling translations
cargo fluent walks the zerocode catalogue alongside the runtime one, so no manual step is needed. Running cargo fluent fill --locale <code> --model-provider <alias> generates apps/zerocode/locales/<code>/zerocode.ftl in the same pass that fills the runtime catalogue. cargo fluent check and cargo fluent stats likewise report zerocode; scan indexes apps/ so zc- key references resolve against zerocode’s source. The generated <code>/zerocode.ftl is embedded in-tree at compile time, or can be dropped into any of the disk-search paths above for testing with --config-dir.
Filling doc translations (gettext)
Doc translations live in docs/book/po/. cargo mdbook sync runs extract → merge → strip obsolete → AI-fill in one step. Without --model-provider, sync still runs extract + merge and reports how many strings need translation: partial translations fall back to English at render time.
sh
cargo mdbook sync --model-provider anthropic.<alias> # delta fill
cargo mdbook sync --model-provider anthropic.<alias> --force # quality pass: retranslate all entries
cargo mdbook sync --model-provider anthropic.<alias> --batch 1 # write after every entry (safest resume)
cargo mdbook sync --locale ja --model-provider anthropic.<alias> # single locale
cargo mdbook sync --model-provider anthropic.<alias> --config-dir ~/.zeroclaw # qualified alias + explicit config dir
--model-provider resolves through the same shared runtime provider path as cargo fluent (any configured family/alias, per-family endpoint + auth + wire protocol, SecretStore decryption, --config-dir support). Unlike cargo fluent, which sends a whole batch as one JSON object, the gettext filler issues one request per source string to keep the msgid → msgstr mapping unambiguous, so --batch controls how often the .po is flushed to disk (the checkpoint interval), not the request size. A full-catalogue locale is thousands of sequential requests; for routine delta fills a cheap local Ollama alias is the economical choice.
The pipeline has built-in resilience:
- Leak detection: if a model returns its own instructions instead of a translation, the tool detects the pattern (via response-length ratio and bullet-list structure), attempts to recover the real translation from the response tail, and blanks the entry for re-translation if recovery fails.
- Protected literal checks:
cargo mdbook checkalso rejects high-confidence literal corruption in generated.pofiles. Product names such asZeroClaw Maturity Framework, command literals such aszeroclaw daemon, and fenced TOML section/key literals must stay byte-for-byte intact inside translations. Translate the surrounding prose, not the machine-facing text. - Path leak checks: generated translations must not introduce machine-local absolute paths that were not present in the English source; those entries are blanked for re-translation and rejected by
cargo mdbook check. - Incremental writes: after each batch, the
.pofile is rewritten. A Ctrl-C mid-run doesn’t lose the progress up to that point. - Obsolete stripping:
msgmerge+msgattrib --no-obsoletekeep removed source strings from accumulating as#~entries.
Maintainers should accept the routine English docs exception documented in Building the docs locally. Ask for .po updates only when the PR is itself a translation-cache pass, a release translation pass, a new-locale change, or the generated diff is small enough to review.
Adding a new locale
-
Edit
locales.tomlat the repo root, the only file you need to touch: -
Translate the app strings:
sh
cargo fluent fill --locale <code> --model-provider ollama -
Bootstrap and fill the docs
.pofile:sh
cargo mdbook sync --locale <code> --model-provider ollama -
The
cargo fluent fillrun in step 2 already generatesapps/zerocode/locales/<code>/zerocode.ftlin the same pass, sincecargo fluentwalks both the runtime and zerocode catalogues. No manual zerocode step is needed; verify coverage withcargo fluent stats.
Everything else, lang-switcher.js, CI deploy target list, cargo mdbook locales output, reads from locales.toml automatically.
Translation catalogue submodule
The translated .po catalogues are not in this repo’s main tree. They live in the dedicated zeroclaw-labs/zeroclaw-docs-translations repo, mounted as a git submodule at docs/book/po (default branch main). The mount point is path-transparent: book.toml’s gettext preprocessor, cargo mdbook sync, and cargo mdbook build all read po/ exactly as before.
The Rust crate dev loop never needs the submodule. Only docs builds and the docs-deploy / release jobs require it; those checkouts pass submodules: recursive. Everything else stays submodule-free.
Per release, the submodule is tagged v{version} to mirror the main repo, and scripts/release/bump-version.sh pins the gitlink to that tag (falling back to main with a warning if the tag is not yet cut). messages.pot and *.failures.log are regenerated artifacts and are gitignored in both repos, not tracked.
Release translation workflow
During a release, after ./scripts/release/bump-version.sh has set the version in Cargo.toml, refresh the catalogues and cut the matching submodule tag. This is one command: it reads the version from Cargo.toml, runs the translation pass, commits and pushes the catalogues to the submodule repo, tags it v{version}, and stages the main-repo gitlink pinned to that tag. No git -C by hand, no version typed into git commands. It initialises the submodule if needed.
sh
./scripts/release/refresh-translations.sh # version from Cargo.toml
Run it after bump-version.sh so the version it reads is the release version. To review coverage or validate format without cutting a tag, run cargo mdbook stats / cargo mdbook check in the working tree first. Pass --no-translate to skip the sync pass when the catalogues are already current, or an explicit version (./scripts/release/refresh-translations.sh 0.8.2) to override the Cargo.toml default. The Validate Translations Pin CI gate validates the pinned catalogues before merge.
The model used is whatever is configured under providers.models.<name>.
Model quality notes
Translation quality varies significantly by language and model.
| Locale | Well-supported by | Notes |
|---|---|---|
ja, zh-CN | qwen3 family, any frontier hosted model | Qwen is Chinese-first; Japanese also strong |
es, fr | qwen3, mistral, gemma3, hosted | Romance languages are broadly well-trained |
| Low-resource locales | Hosted frontier models only | Local models often hallucinate words |
For release-grade passes, prefer a hosted frontier model via --force. For ongoing delta fills during development, a local Ollama model is fine and free.