Localization (i18n)¶
SkillKeeper ships in 18 languages. Every user-facing string flows from a
single source of truth -- the gettext .po catalogs under locales/ -- into
both the desktop renderer (TypeScript) and the native macOS menu (Rust). This
page describes that pipeline and how to add or change translations.
Source of truth¶
locales/<lang>.po are the only files you edit by hand. They are keyed gettext
catalogs: the msgid is a dotted message key (e.g. nav.repositories,
menu.edit) and the msgstr is the text for that language.
locales/en.pois canonical: it defines the full key set. The TypeScriptMessageKeytype is derived from it, so a key must exist inen.poto be usable.- A key omitted from a non-English
.pofalls back to the English value at runtime (per key), in every consumer.
Supported codes (canonical order): en, de, ru, uk, be, fr, ja, zh-cn, pl,
sr-cyrl, sr-latn, zh-tw, es, pt, ko, it, hi, th.
The generation pipeline¶
scripts/gen-i18n.mjs (run with pnpm run i18n) compiles the .po sources
into the artifacts each consumer needs. It regenerates every language by
default; pass codes to limit it while iterating on one translation:
pnpm run i18n # all languages (the default)
pnpm run i18n -- ru de # only these
pnpm run i18n -- --langs=ru,de # same, comma-separated
An unknown code fails with the supported list. English is always regenerated,
even when a subset does not name it: catalogs/en.ts defines the
MessageKey/Catalog types every other catalog imports, so regenerating one
language against a stale English catalog would emit keys the type does not have.
locales/<lang>.po (SOURCE -- hand-edited)
|
| pnpm run i18n (scripts/gen-i18n.mjs, uses gettext-parser)
v
packages/i18n/src/catalogs/<lang>.ts renderer catalogs (TypeScript)
apps/desktop/src-tauri/locales/<lang>.mo compiled catalogs (Rust menu)
catalogs/en.tsexports the canonicalenobject plus theMessageKeyandCatalogtypes; the othercatalogs/<lang>.tsexport aPartial<Catalog>.<lang>.moare compiled gettext binaries the Rust side reads.
Both sets of generated files are git-ignored, not committed. The renderer
imports the .ts at build time and the Rust menu embeds the .mo via
include_bytes! at cargo build time, so they must exist -- but they are
produced automatically: pnpm install runs a postinstall hook, and
src-tauri/build.rs regenerates the .mo when missing. After editing a .po,
run pnpm run i18n (or just pnpm install); commit only the .po -- never the
regenerated catalogs/*.ts or locales/*.mo.
Do not edit
packages/i18n/src/catalogs/*.tsorapps/desktop/src-tauri/locales/*.moby hand -- they are overwritten bypnpm run i18n. Edit the.poand regenerate.
Consumers¶
Desktop renderer (TypeScript)¶
The @skillkeeper/i18n package exposes the catalogs and a translator. The
renderer uses the lazy entry point @skillkeeper/i18n/lazy, which imports
only English statically and code-splits every other catalog into its own chunk
(import()), loaded on demand.
systems/i18n/runtime.tsholds the registry of loaded catalogs, loads others viaensureCatalog(lang), and notifies subscribers when one arrives. Startup and the settings language switch gate the UI onensureCatalogso the English fallback is never flashed.useTranslator()(systems/i18n/useTranslator.ts) returns a translator bound toconfig.general.language, re-rendering (viauseSyncExternalStore) once the real catalog finishes loading.
The translator:
const t = useTranslator();
t('nav.repositories'); // -> "Repositories"
t('errors.cloneFailed', { name: 'acme' }); // {name} interpolation
t.plural('skills.count', n); // Intl.PluralRules; picks
// skills.count.<one|other|...>, {count} auto-filled
Lookup order is primary language -> English fallback -> the raw key string.
Native macOS menu (Rust)¶
app::i18n::Translator::for_lang(<code>) parses the embedded .mo (via the
pure-Rust gettext crate) and translates keys with the same English fallback:
let tr = app::i18n::Translator::for_lang("de");
tr.t("menu.edit"); // German, or English if the key is missing
The menu (app::menu) is built once at startup, reading the configured
language from config.yaml. Two important consequences on macOS:
- A language change takes effect for the menu on the next launch, not immediately. The renderer relocalizes live, but the native menu is not rebuilt when the config changes.
- The menu items AppKit injects itself (the Window tiling group: Fill, Center,
Move & Resize, ...) are localized by AppKit, not by us.
app::menu'sset_process_languagesetsAppleLanguagesat startup, andInfo.plist'sCFBundleLocalizationsdeclares the supported locales, so AppKit renders those items in the app's language. This too resolves at launch.
CLI (Rust)¶
The skillkeeper CLI is English-only by design (crates/skillkeeper-cli/
src/messages.rs). It is a scripting/automation surface whose output is parsed
by tooling, so a single stable language is preferred over locale-dependent
strings. The few strings it needs are inlined as ASCII English mirroring the
en catalog values.
ASCII rule¶
All source and documentation are ASCII-only. The only place non-ASCII text
is allowed is the i18n data: locales/*.po and packages/i18n/ (the non-English
catalogs/*.ts and the native language-name table src/nativeNames.ts). Never
inline non-ASCII UI text in app/renderer/Rust source -- add a key instead.
How to¶
Add or change a user-facing string¶
- Add/edit the key in
locales/en.po(the canonical value). - Run
pnpm run i18n. - Use the key via
t('your.key')(renderer) ortr.t("your.key")(Rust menu). - Commit the
.pochange only; the regeneratedcatalogs/*.tsandlocales/*.moare git-ignored (produced on install/build).
New strings are added to en.po only. Do not translate them into other
locales as part of feature work -- untranslated keys show English until a
dedicated translation pass (before a release, or when explicitly asked).
Translate a language¶
- Fill in the
msgstrs inlocales/<lang>.po, preserving every{token}placeholder and providing the CLDR plural categories that locale uses (<baseKey>.one,<baseKey>.other, etc.). - Run
pnpm run i18nand commit the.po(the regenerated artifacts are git-ignored).
Add a new selectable language¶
Adding code xx (BCP-47 form Xx where it differs, e.g. zh-cn -> zh-Hans):
locales/xx.po-- create (copyen.po, translate).scripts/gen-i18n.mjs-- addxxtoALL_LANGS.packages/i18n/src/langs.ts-- add to theLangunion andSUPPORTED_LANGS.packages/i18n/src/index.ts-- import the catalog and add it tocatalogs.packages/i18n/src/lazy.ts-- add a dynamic-import()loader.packages/i18n/src/nativeNames.ts-- add the language's own name (and a Chinese qualifier if it is a Chinese script).crates/skillkeeper-config/src/schema.rs-- add it to theLanguageenum.apps/desktop/src-tauri/src/app/i18n.rs-- add it tocatalog_bytes.apps/desktop/src/renderer/domain/languages.ts-- add it toLANGS.apps/desktop/src-tauri/Info.plist-- add the BCP-47 code toCFBundleLocalizations(and map it inapp::menu'smacos_language_codeif the code differs from the app code, likezh-cn->zh-Hans).- Run
pnpm run i18n; commit the.poand source changes (generated.ts/.moare git-ignored).
Native language names¶
The language picker uses LANGUAGE_NATIVE_NAMES from
packages/i18n/src/nativeNames.ts (each locale's own name for itself), not
Intl.DisplayNames -- the system WebView's ICU data is unreliable for some
locales.
Dependencies / licenses¶
gettext(Rust crate, MIT) -- reads the compiled.mofor the menu.gettext-parser(npm, MIT) -- parses.poand compiles.moinscripts/gen-i18n.mjs.