Internationalization
DraftCapsule is internationalized from a single canonical source. Every user-facing
string — across the web, iOS/macOS, Android, desktop, the CLI, and the server’s
errors — is authored once in the repo-root locales/ directory and compiled
into each platform’s native localization format. A translator never touches
application code.
Implemented in:
locales/— the canonical ICU MessageFormat catalogs (the source of truth) and the supported-locale configuration.xtask i18n(xtask/src/i18n.rs) — the build step that compiles the catalogs into each platform’s native files;--checkis the CI drift gate.capsule-i18n— the Rust runtime (locale negotiation + message formatting) used by the server and CLI.- Per-platform generated targets (web JSON, Android
strings.xml, iOS.xcstrings) consumed by each client’s native i18n machinery.
This doc owns the i18n contract: the catalog format, the supported-locale set, locale resolution, and the server error-code scheme. It defers per-platform UI rendering to Clients, and closed-enum locale rejection to Threat Model — Schema Rules.
Canonical source
Section titled “Canonical source”locales/ is the single source of truth (see the
SSoT rule):
locales/config.json— the supported-locale set:sourceLocale, thesupportedLocaleslist, and per-localefallbacks. This is the closed set of locales Capsule recognizes.locales/<locale>.json— one catalog per locale. Each entry maps a key to an ICU MessageFormatmessageplus optional translatorcontext.en.jsonis the source catalog; every key is defined there first, and every translation carries the same key set.locales/schema/catalog.schema.json— the JSON Schema for a catalog (editor autocomplete and validation).
Keys use dotted namespaces (area.subarea.name). A handful of legacy UI keys
inherited from the Android catalog keep their original flat names; codegen
sanitizes any key into each platform’s native identifier rules.
ICU MessageFormat was chosen as the canonical format because it is the common
substrate the client platforms already speak — the web (FormatJS/react-intl),
iOS String Catalogs, and Android all compile it natively — so codegen to those
targets is near-mechanical. The Rust runtime is the one target without a native
ICU formatter and carries a small interpreter instead (see below).
Supported Languages
Section titled “Supported Languages”The committed official language set is English (the source) plus twelve
translations: zh-Hans, zh-Hant, ja, ko, fr, de, es, pt-BR,
it, ru, hi, and ar. Including Arabic puts RTL layout support in scope
for every client (web dir handling, native mirroring). Fallbacks are direct
to the source locale — in particular zh-Hant falls back to en, never to
zh-Hans: showing auto-Simplified text to Traditional-script readers is worse
than showing English.
This section owns the decision; locales/config.json stays the mechanical
truth of what ships — a locale is supported in a build exactly when its catalog
lands and its tag enters supportedLocales. Today that is en only; the twelve
catalogs are the rollout slice S-I2 in the repo-root SLICES.md. Everything
that enumerates languages (per-platform catalogs, README
translations) derives from config.json, so the set is
never declared twice.
Codegen
Section titled “Codegen”mise run i18n (cargo run -p xtask -- i18n) compiles the catalogs into:
| Target | Output | Status |
|---|---|---|
| Rust runtime | capsule-i18n/src/bundles/<locale>.json + generated.rs | Implemented |
| Web (FormatJS) | capsule-web/src/i18n/messages/<locale>.json | Implemented |
| Android | capsule-android/.../res/values[-<qualifier>]/strings.xml | Implemented (literals) |
| iOS/macOS | capsule-swift/Generated/Localizable.xcstrings + InfoPlist.xcstrings | Implemented (ICU compiled to Apple form, plurals included) |
Every renderer is a pure function of the parsed catalogs, so the generated files
are deterministic. mise run i18n-check (xtask i18n --check) re-renders in memory
and fails if any committed file drifted from locales/ — it runs inside
check-rust, so generated files can never silently fall out of sync. Generated
files carry a “do not edit by hand” banner and are committed so a fresh checkout
builds without running the generator.
One limitation, and one outright defect:
- The Android app is not built on this branch, so its generator is validated by
snapshotting string output rather than by compiling the app. iOS no longer shares this
caveat: the
.xcstringstargets are wired into the Xcode project and verified against compiled output — the built app carries a.lprojbundle per locale, with realNSStringPluralRuleTypeentries inLocalizable.stringsdictand the usage descriptions inInfoPlist.strings. - Android shipped raw ICU to users for as long as this document said it did not — fixed
by slice
S-I6, and recorded here because the failure mode is worth remembering. The renderer had a guard meant to emit aTODOcomment rather than a mistranslated<string>, and its regex could not match a plural:[^{}]*cannot span the nested braces every ICU plural contains. It never fired, so 130 strings across thirteen locales carried literal message source intostrings.xml. The wording this replaces described the safe behaviour as fact, which is a large part of why nobody looked — a confident wrong answer is worse than silence. Plurals now compile to Android<plurals>, the native mechanism the tree had never used, and two tests assert over the real catalogs that no rendered value contains ICU syntax and that every emitted quantity is selectable in its locale.
Runtime and locale resolution
Section titled “Runtime and locale resolution”capsule-i18n is the Rust runtime for the server and CLI:
negotiate(accept_language, supported, source)picks the best supported locale for anAccept-Language-style request (exact tag, then primary subtag, then the source locale as the final fallback).Bundle::for_locale(locale)loads a locale’s messages with the source locale as a fallback, so an untranslated key still renders in the source language. A missing key returns the key itself, surfacing the gap rather than an empty string.
There is no production-grade pure-Rust ICU MessageFormat formatter crate, so the
runtime ships a small interpreter over the same FormatJS grammar the web uses. It
currently handles literal text and {name} interpolation — the subset the catalog
exercises today; full plural/select/number/date formatting is follow-up.
Native clients use their platform’s own ICU machinery, which already covers the
full syntax.
CLI help text
Section titled “CLI help text”The CLI’s --help output is localized, resolved at parser-construction time
(slice S-I8). This is the decision that slice existed to make: the “no hardcoded
user-facing strings” contract applies to help pages as much as to any other line
a terminal shows, and the alternative — keeping help English and saying so — would
have left the one surface every new user reads first outside the contract.
The mechanism, in capsule-cli/src/cli/help.rs:
- Help is still authored as
clapdoc comments and attributes, which stay the single place the English lives. The catalog holds acli.help.*entry per string under a key derived from the command tree —cli.help.<path>.about,cli.help.<path>.long_about,cli.help.<path>.arg.<id>and…arg.<id>.long_help, where<path>is the dot-joined subcommand chain (library.init) and the root isroot. Nothing is spelled twice. - Before any argument is parsed, the binary walks the built
clap::Commandtree and replaces each string with the catalog’s message for its key, through the bundle negotiated fromLC_ALL/LC_MESSAGES/LANG. A key the bundle lacks leaves the derive text in place, so a partially translated locale renders a mix and an untranslated one renders English — never a raw key. - A unit test asserts that every
enentry equals the derive text it replaces, and that localizing underenleaves every help page byte-identical. That test is the gate for this surface:i18n-guardcannot see help text (noprintln!carries it), so a doc comment edited without its catalog entry failscargo test -p capsule-cliinstead. The committedcli-surface.jsondescription artifact is resolved through an explicitly pinnedenbundle for the same reason, and is unchanged by localization.
Residual gap: a ValueEnum variant’s help (--filter pick → “A keeper.”) is not
localized. clap 4 can re-word a possible value only by replacing the typed
value_parser with a PossibleValuesParser, which trades typed parsing for a
translated word; the variants stay English until clap offers a seam that does not.
Server error codes
Section titled “Server error codes”APIs are typed, but error messages must be presentable in the user’s language. The contract:
- The server attaches a stable, machine-readable code to high-level errors —
a key from the catalog’s
error.*namespace (e.g.error.auth.invalid_credentials) — alongside an English detail message. The generic response shape isApiError { error: String, code: Option<String> };codeis optional, so older clients ignore it (consistent with forward/backward compatibility). - Clients localize the code, mapping it through their generated catalog to a localized high-level message. The English detail stays English — specific, developer-facing detail is not translated.
- The server references codes via generated
capsule_i18n::error_codesconstants, so a typo is a compile error and the codes stay in sync with the catalog. There is no second source of truth: error codes are catalog keys.
The server does not translate by Accept-Language; localization happens
client-side off the code, which keeps it working offline and avoids coupling the
client’s language to the server.
This contract covers the refuse-by-default surface too: every structured
rejection in Threat Model — Validation
carries an error.* code alongside its HTTP status — the REST carriage is owned by
API Surfaces, and the code, not the
status, is what clients switch on). Flagship codes referenced across the design
docs: error.protocol.version_unsupported (the 426 class),
error.quota.exceeded, error.moderation.account_suspended,
error.auth.invalid_credentials.
README Translation
Section titled “README Translation”The repo-root README.md ships translated as README.<lang>.md for every
non-source locale in locales/config.json — the language list is never
declared outside that file. Generation is xtask translate-readme
(slice S-I3), structure-aware rather than whole-document:
- The markdown is segmented by block (headings, paragraphs, list items, tables); code blocks, link targets, and badge/image URLs pass through untouched.
- Each translatable segment goes through an LLM translation API with a pinned project glossary (product terms — “Capsule”, “sidecar”, “album”, “drop” — and their per-language renderings), so terminology stays consistent across regenerations.
- Output files are committed with the same do-not-edit banner as the other
generated i18n artifacts, and
xtask translate-readme --checkis the CI drift gate: it re-segments the source and fails when a committed translation’s segment structure no longer matchesREADME.md— the same pattern asi18n --check. (The check is structural, not semantic — it needs no API key in CI.)
American English (README.md) is the base version; translations are
regenerated, never hand-edited (hand improvements go into the glossary or the
source).
Contributing translations
Section titled “Contributing translations”Translators edit the JSON catalogs in locales/ and open a pull request — no code
involved. locales/README.md documents the catalog format, key naming, and how to
add a language; CONTRIBUTING.md
covers the commit and review flow. A translation-management hub (Weblate or
Crowdin) backed by these same files is planned so non-technical contributors can
translate through a web UI; until then, the JSON-via-pull-request flow is the
supported path.
Validation
Section titled “Validation”See Validation Tiers.
- Codegen determinism (unit). Each renderer is a pure function;
xtask i18n --checkasserts the committed files match a fresh render. Catalog parsing rejects a malformed entry (missingmessage) and an unsupportedsourceLocale. - Locale negotiation + formatting (unit).
capsule-i18nunit tests cover exact/primary-subtag/weighted matching, fallback to the source locale, message interpolation, and missing-key behavior, against fixed vectors. - Bundle load (smoke). The embedded generated bundle parses and resolves keys
(including an
error.*code round-trip) end-to-end.
i18n adds no new case to the bounded E2E test surface:
the contract is exercised entirely at the unit/smoke tier within capsule-i18n
and xtask, and native-client consumption is verified per platform rather than as
a cross-module integration test.
Future work
Section titled “Future work”Items with a slice are indexed in the repo-root SLICES.md; the rest remain
unowned future work.
- Hardcoded-string migration (web JSX, SwiftUI
Text, Compose → catalog keys): sliceS-I1. - The twelve-locale catalog rollout + RTL support: slice
S-I2. - README translation pipeline: slice
S-I3(see README Translation). - Full ICU
select/number/datefidelity in the Rust runtime and the Android generator (pluralis compiled for Apple today and isS-I6for Android). - Add the desktop target once its framework is chosen. (The iOS
.xcstringstargets are wired and verified.) - Retrofit the remaining server error variants with codes; regenerate the OpenAPI spec / SDK.
- Align the FFI
CatalogErrorsurface with the error-code scheme. - Stand up a translation-management hub (Weblate/Crowdin) and localize the documentation site.