Back to Zeroclaw

Docs & Translations

docs/book/src/maintainers/docs-and-translations.md

0.8.217.2 KB
Original Source

Docs & Translations

ZeroClaw has two independent translation layers:

LayerFormatWhat it covers
App stringsMozilla Fluent (.ftl)CLI help text, command descriptions, runtime messages
Docsgettext (.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

{{#include ../_snippets/docs-build-commands.md}}

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 dominate gh-pages clone size; restricting search to English keeps clones lean. Adding a search box back to a translated locale means re-enabling output.html.search.enable for that build in build_locales (xtask/src/cmd/mdbook/build.rs).

How translations stay current

When English source changes, cargo mdbook sync runs two stages:

  1. Extract: mdbook-xgettext regenerates po/messages.pot from the current English source.
  2. Merge: msgmerge updates each locale's .po file, new strings get an empty msgstr ""; changed strings get marked #, fuzzy with 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 en and zh-CN are wired into the runtime as built-ins: crates/zeroclaw-runtime/src/i18n.rs embeds en via include_str!, and builtin_cli_ftl_source() returns the embedded zh-CN catalogue for zh-CN and None for every other locale. A disk-override path exists: load_ftl_from_disk resolves zeroclaw_config::schema::ftl_locale_dir(locale), i.e. <config-dir>/data/ftl/<locale>/cli.ftl (the same location zeroclaw locales fetch populates). So a freshly filled ja/cli.ftl is generated and committed, but is not loaded at runtime unless either the locale is added to builtin_cli_ftl_source() or the filled cli.ftl is placed under <config-dir>/data/ftl/ja/. Confirm the current state in i18n.rs and zeroclaw_config::schema::ftl_locale_dir rather than trusting this note.

The apps/zerocode TUI maintains an independent Fluent catalogue (apps/zerocode/locales/), see zerocode strings below. cargo fluent walks both catalogue roots (runtime + zerocode), so every subcommand below covers both by default.

<div class="os-tabs-src">

sh

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
</div>

Scoping to one catalogue: every subcommand takes --catalog <runtime|zerocode> (default: both). To translate only the TUI:

<div class="os-tabs-src">

sh

sh
cargo fluent fill --locale ja --model-provider anthropic.<alias> --catalog zerocode
cargo fluent check --catalog zerocode                                # syntax-check only zerocode
</div>

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/.

WhereWhat
apps/zerocode/locales/en/zerocode.ftlSource of truth, embedded at compile time
apps/zerocode/locales/<locale>/zerocode.ftlOther locales, embedded if present in-tree
$ZEROCODE_LOCALE_DIR/<locale>/zerocode.ftlExplicit override, useful for testing translations
<config-dir>/zerocode/locales/<locale>/zerocode.ftlPer-user catalogue override
~/.zeroclaw/zerocode/locales/<locale>/zerocode.ftlAlternate per-user location
<install-prefix>/share/zerocode/locales/<locale>/zerocode.ftlSystem 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 labels
  • zc-app-<purpose>: strings owned by app.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

  1. 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.
  2. Replace the literal in the source with crate::i18n::t("zc-…"). For enum→label match arms, return the key constant (&'static str) from a fluent_key() method and call t() at the render site, never match on a string.
  3. cargo check -p zerocode and the i18n unit 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.

<div class="os-tabs-src">

sh

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
</div>

--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 check also rejects high-confidence literal corruption in generated .po files. Product names such as ZeroClaw Maturity Framework, command literals such as zeroclaw daemon, and fenced TOML section/key literals must stay byte-for-byte intact inside translations. Translate the surrounding prose, not the machine-facing text.
  • Incremental writes: after each batch, the .po file is rewritten. A Ctrl-C mid-run doesn't lose the progress up to that point.
  • Obsolete stripping: msgmerge + msgattrib --no-obsolete keep 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

  1. Edit locales.toml at the repo root, the only file you need to touch:

  2. Translate the app strings:

    <div class="os-tabs-src">

    sh

    sh
    cargo fluent fill --locale <code> --model-provider ollama
    
    </div>
  3. Bootstrap and fill the docs .po file:

    <div class="os-tabs-src">

    sh

    sh
    cargo mdbook sync --locale <code> --model-provider ollama
    
    </div>
  4. The cargo fluent fill run in step 2 already generates apps/zerocode/locales/<code>/zerocode.ftl in the same pass, since cargo fluent walks both the runtime and zerocode catalogues. No manual zerocode step is needed; verify coverage with cargo 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.

<div class="os-tabs-src">

sh

sh
./scripts/release/refresh-translations.sh    # version from Cargo.toml
</div>

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.

LocaleWell-supported byNotes
ja, zh-CNqwen3 family, any frontier hosted modelQwen is Chinese-first; Japanese also strong
es, frqwen3, mistral, gemma3, hostedRomance languages are broadly well-trained
Low-resource localesHosted frontier models onlyLocal 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.