Internationalization (i18n)
Localize OpenPets desktop UI, pet speech, and plugins with the shared fallback model and locale contribution workflow.
Internationalization (i18n)
OpenPets localizes three distinct surfaces, each with its own mechanism but a shared principle: resolve through fallback-aware catalogs, default to English, never crash on a missing key. This doc explains all three and how to add a locale.
Source maps: apps/desktop/src/i18n/codemap.md (+ locales/, reactions/),
apps/desktop/src/codemap.md (plugin-i18n.ts).
Supported locales
The desktop host ships 7 locales (English plus six):
| Locale | Language |
|---|---|
en |
English (base / fallback) |
ja |
Japanese |
ko |
Korean |
zh-Hans |
Chinese (Simplified) |
zh-Hant |
Chinese (Traditional) |
es-419 |
Spanish (Latin America) |
pt-BR |
Portuguese (Brazil) |
The target set is deliberately Asian + Latin American markets. The repo
README is also translated into the same six (README.<locale>.md). The public
web site currently localizes a narrower set (English unprefixed, Chinese under
/zh; site docs are English-only) - see web/AGENTS.md; that is out of scope
for the app docs here.
Surface 1 - desktop host UI
Tray entries, Control Center labels, pet status text, and other main-process strings.
i18n/catalog.tsis the pure, dependency-free core: locale dictionaries, BCP-47 → supported-locale mapping,translate(), and brace interpolation ({name}). It’s pure so it can be unit-tested without Electron.i18n/index.tsis the Electron-aware layer:setLocaleFromPreference()maps"system"toapp.getLocale()(or honors the user’s explicit choice), andgetActiveMessages()returns the resolved dictionary.locales/<locale>.tsholds each dictionary;en.tsis the source of truth and the fallback for any missing key.- The renderer hydrates via the
openpets:get-i18nIPC hook inwindows.ts, so the React UI renders in the active locale.
Locale preference is stored in app state and coordinated through app-state.ts.
Surface 2 - pet reaction speech
What the pet says per reaction (see Pets).
i18n/reactions/<locale>.tsprovides localized message pools per reaction type;reactions/index.tsaggregates them.reaction-messages.tspicks a message from the pool for the active locale.
This keeps speech in the user’s language without coupling it to the host UI dictionary.
Surface 3 - plugins
Plugins localize their own manifest strings and runtime text.
- Manifests reference
$t:keys forname,description, and config labels/descriptions; runtime code callsctx.t(key, vars). plugin-i18n.tsloads each plugin’slocales/<locale>.json(flat, dotted keys) and resolves$t:andctx.t()with English fallback.- Every plugin must ship
locales/en.json; the release validator fails on a missing one or an unresolved$t:in a catalog card (see Testing and validation and Plugin platform).
The locale-key audit for plugins is pnpm plugins:locales
(scripts/check-plugin-locales.mjs), run as part of pnpm plugins:test.
Fallback model (all surfaces)
- Map the requested BCP-47 locale to a supported locale.
- Look up the key in that locale.
- On any miss (unknown locale, missing key), fall back to English.
- Interpolate
{var}tokens.
Nothing throws on a missing translation - it degrades to English. That is the contract; preserve it.
Adding a locale (desktop)
- Add
locales/<locale>.tsmirroring all keys inen.ts. - Add
reactions/<locale>.tswith the reaction message pools and register it inreactions/index.ts. - Ensure the BCP-47 mapping in
i18n/catalog.tsresolves the new locale. - Translate
README.<locale>.mdif you want parity with the others. - For official plugins, add
locales/<locale>.jsonto each plugin you want covered (English remains the fallback).
Notes & known state
- Earlier project notes treated plugin i18n as “deferred”; it now exists and is
enforced (
plugin-i18n.ts,$t:,ctx.t(),plugins:locales). Treat plugin localization as a first-class requirement, not a future. - Keep
en.ts/en.jsonauthoritative: add keys there first, then translate.
