Skip to content

Internationalization

Draft

Capsule 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; --check is 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.

locales/ is the single source of truth (see the SSoT rule):

  • locales/config.json — the supported-locale set: sourceLocale, the supportedLocales list, and per-locale fallbacks. This is the closed set of locales Capsule recognizes.
  • locales/<locale>.json — one catalog per locale. Each entry maps a key to an ICU MessageFormat message plus optional translator context. en.json is 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).

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.

mise run i18n (cargo run -p xtask -- i18n) compiles the catalogs into:

TargetOutputStatus
Rust runtimecapsule-i18n/src/bundles/<locale>.json + generated.rsImplemented (ICU evaluated at runtime, plurals included)
Web (FormatJS)capsule-web/src/i18n/messages/<locale>.jsonImplemented
Androidcapsule-android/.../res/values[-<qualifier>]/strings.xmlImplemented (literals)
iOS/macOScapsule-swift/Generated/Localizable.xcstrings + InfoPlist.xcstringsImplemented (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 .xcstrings targets are wired into the Xcode project and verified against compiled output — the built app carries a .lproj bundle per locale, with real NSStringPluralRuleType entries in Localizable.stringsdict and the usage descriptions in InfoPlist.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 a TODO comment 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 into strings.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.

capsule-i18n is the Rust runtime for the server and CLI:

  • negotiate(accept_language, supported, source) picks the best supported locale for an Accept-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. Native clients use their platform’s own ICU machinery, which already covers the full syntax; the Rust runtime is the one target with no platform underneath it, so it is the only one that carries CLDR rules itself.

  • Bundle::format(key, args) and format_message_in(locale, template, args) render literal text, {name} interpolation, and {name, plural, …} with =N exact arms, CLDR category arms, # for the count, and nesting (an arm may hold placeholders and further plurals). format_message(template, args) is the locale-free entry point and means English.
  • Arm selection comes from capsule_i18n::plural, an in-house CLDR integer cardinal table covering the twelve language subtags in locales/config.json. It is the same table xtask i18n filters Android <item quantity=…> arms with — one table, two consumers, reconciled by test in both directions.
  • A category the message does not carry falls back to other. That fallback is load-bearing today: every translated plural is still an English one/other copy, so a Russian few has nowhere else to go.
  • Not implemented, and refused rather than mis-rendered: select, selectordinal, plural with offset:, a plural whose arms do not parse, number/date skeletons, and ICU apostrophe quoting ('#' for a literal #, which the ahead-of-time generators do not implement either). A construct outside the subset trips a debug_assert! where a developer sees it and is copied through verbatim in release, so a release build gains no crash on a catalog it could previously render badly.
  • One exception to the pass-through: a plural whose arms parse but carry no other asserts and then renders its first arm in CLDR order. xtask i18n refuses to emit such a message, so it is unreachable from the catalogs; for a hand-written template that slipped past the generator, rendering some text beats showing the reader ICU source. An unterminated { is likewise not an assertion case — a lone brace in literal copy (“50% off {sale”) cannot be told apart from a mistyped placeholder — so it is emitted as an ordinary character and scanning continues past it.
  • Numbers render as plain digits — no grouping separators, no locale digit shaping. Doing that properly is number skeleton support, which needs CLDR number data this runtime does not carry.

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 is ApiError { error: String, code: Option<String> }; code is 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_codes constants, 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.

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 --check is the CI drift gate: it re-segments the source and fails when a committed translation’s segment structure no longer matches README.md — the same pattern as i18n --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).

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.

See Validation Tiers.

  • Codegen determinism (unit). Each renderer is a pure function; xtask i18n --check asserts the committed files match a fresh render. Catalog parsing rejects a malformed entry (missing message) and an unsupported sourceLocale.
  • Locale negotiation + formatting (unit). capsule-i18n unit tests cover exact/primary-subtag/weighted matching, fallback to the source locale, message interpolation, and missing-key behavior, against fixed vectors. The CLDR plural table is asserted cell by cell at the boundary counts, and its two halves — which categories a language lists and which its rules produce — are pinned equal.
  • Plural rendering (smoke). Every plural key in every one of the thirteen catalogs is rendered at eight boundary counts and asserted to contain no ICU syntax; and, in xtask, to equal the sentence the generated Android strings.xml produces from the same arm. That second one is the runtime/ ahead-of-time agreement, checked against the real catalogs.
  • 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.

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): slice S-I1.
  • The twelve-locale catalog rollout + RTL support: slice S-I2.
  • README translation pipeline: slice S-I3 (see README Translation).
  • Full ICU select/selectordinal/number/date fidelity, and apostrophe quoting, in the Rust runtime and the Android generator (plural is compiled ahead of time for Apple and Android, and evaluated at runtime in Rust — slice S-I7).
  • Add the desktop target once its framework is chosen. (The iOS .xcstrings targets are wired and verified.)
  • Retrofit the remaining server error variants with codes; regenerate the OpenAPI spec / SDK.
  • Align the FFI CatalogError surface with the error-code scheme.
  • Stand up a translation-management hub (Weblate/Crowdin) and localize the documentation site.