Appearance
Translations
Status: working. The webui, the document schemas and the notifications are translated; Finnish covers most of the interface, with features added since October still in English. Not yet: the Android notification channel name (set before the language is known).
Problem
A wish-app writes text in two places. The webui draws the screens, and the backend writes text the user sees outside them, such as OS notifications that arrive while the app is in the background. With two runtimes, two languages and one product, translations need to:
- be added and updated without touching code. A new language is a file, not a change to the code.
- stay correct as the English changes. A translation made from old English has to be noticed.
- suit an AI translator. In practice an AI does most of the translating, so the system should hand it exactly what it needs and check what comes back.
- avoid the classic traps: concatenated sentences, English plural rules forced on other languages, and one English word that means two different things.
A library such as i18next covers only the webui half and brings tooling built for human translation agencies. Reason uses neither.
How Reason does it
apps/reason/
├── locales/ en.json (source) · fi.json · <tag>.json … ← the only thing translators touch
├── i18n/ Rust crate: backend runtime + the `i18n` tool + fixtures.json
├── webui/src/i18n/ TS runtime: useT(), t(), the language store
└── backend-rs/src/ locale.rs (locale.set endpoint) · notify.rs (a user)One catalog per language, shared
locales/en.json is the source. Every message has a note, the context a translator needs, and the file has a glossary of the app's own terms:
json
{
"name": "English",
"glossary": {
"discussion": "A conversation among chosen members, with messages and replies. Not a public chat room or a forum thread."
},
"messages": {
"notify.newMessage.title": {
"text": "New message in {title}",
"note": "OS notification title; {title} is the discussion's name."
}
}
}A translation has the same keys. Each entry also records src, a short hash of the English text it was translated from:
json
{
"name": "Suomi",
"glossary": { "discussion": "keskustelu" },
"messages": {
"notify.newMessage.title": { "text": "Uusi viesti: {title}", "src": "df7d9c52" }
}
}Both runtimes find their files themselves. The webui globs locales/*.json into the bundle (import.meta.glob), and the backend's i18n/build.rs embeds every file it finds there. So dropping in sv.json gives you Swedish in both on the next build. Lookups fall back one key at a time, fi-FI → fi → en, so a half-finished language works, with English filling the gaps.
Calling it
tsx
// webui — re-renders when the language changes
const t = useT();
<h2>{t('settings.title')}</h2>
<p>{t('settings.general.stayConnectedError', { error })}</p>rust
// backend
use reason_i18n as i18n;
let title = i18n::t("notify.newMessage.title", &[("title", (&name).into())]);In TS the key type is keyof typeof en.messages, so a typo fails tsc. A table built once at module load, such as the action registry, can't translate yet: the language can change later. It stores the key with tKey('action.discussion.create.label') and calls t(meta.label) when it renders.
The language is a per-device preference that the webui keeps. The webui sends it to the backend when it connects and whenever it changes (locale.set([tag])), so notifications use the same language as the screen.
Schema text: the same catalogs, a convention
Document-type schemas carry their own UI text: type names, field labels and action labels, as the Document Store's LocalizedString ({"en": …, "fi": …}). Generic tools such as schema-driven forms read it from there. Reason doesn't write those maps by hand. The schemas build them from the catalogs:
rust
// backend-rs/src/schemas/discussion.rs
f.label = Some(i18n::loc("schema.discussion.field.name.label"));i18n::loc(key) returns the key in every language there is. The schema still carries all of its translations, but the catalogs are the one source, so check and the AI loop cover schema text like any other. The webui picks the language with useLocalized(): l(schema.label) resolves fi-FI → fi → en, just like t.
One message syntax, two identical runtimes
Messages use a fixed subset of ICU MessageFormat:
| Syntax | Example |
|---|---|
| argument | New message in {title} |
plural (# = the number, =N beats a category) | {n, plural, =0 {No files} one {# file} other {# files}} |
| select | {kind, select, video {Video} other {File}} |
There are no escapes: braces are always syntax and apostrophes are plain text. Full ICU treats ' as a quote character, which famously breaks Don't. The subset is small enough that i18n/src/format.rs and webui/src/i18n/format.ts are line-for-line twins. Both run the same i18n/fixtures.json (just test i18n), which also checks that the Rust plural rules agree with the browser's Intl.PluralRules for every language in the table. Polish, for example, needs one, few and many.
The tool keeps it honest
just i18n (the i18n binary in the crate) has three commands:
check, run byjust lint. It reads the catalogs and the source: it findst('…')andtKey('…')in TS, andi18n::t("…")andi18n::loc("…")in Rust. Errors: bad syntax, placeholders that differ from the English, a key the code uses thaten.jsonlacks, and a Rust key that isn't a string literal. Warnings: unused keys, missing or stale translations, a plural missing one of the language's categories, and an English entry without a note.check --strictalso turns missing and stale translations into errors: run it before a release (see Translating new features below).todo <lang>prints the work packet for an AI: only the missing and stale items, each with its English text, note and previous translation. It also flags items whose English is shared with other keys, and includes the glossary, the language's plural categories and the translation rules.apply <lang> answer.jsonmerges the answer back. It rejects anything with broken syntax or changed placeholders, and records thesrchashes.
bash
just i18n todo fi > packet.json # → give to the AI translator
just i18n apply fi answer.json # validated merge into locales/fi.json
just i18n check -v # 0 errors before commitFor a new language, run todo <tag> and then apply <tag>. The answer must include "name", the language's name for itself, which appears in the language picker.
Translating new features
While a feature is in development its wording keeps changing, so translating it early is wasted work. The split:
- Keys from the start. New UI text goes through
t('…')with an English entry and a note inen.jsonfrom the first commit. Converting hardcoded English later is the expensive part, so it isn't left for later. - Translations when it's ready. Nothing forces them sooner. A missing translation shows the English text, and
checkreports it as a single count line per language ("fi: 40 missing"), not as an error. - Before a release, translate what's missing:
todo→ AI →applyper language, thencheck --strictmust report 0 errors.
There is no list of beta features to keep in step: whatever is missing when you release is what gets translated.
Seeing what's left
Settings → General → Language has a developer-mode option, Pseudo (qps). It renders the English accented, bracketed and about 35% longer: [Šéţţîñĝš~~~]. Any plain text on screen is a string nobody converted yet, and a clipped button shows a layout that will break in German.
Copy it into your app
This is the part worth taking if your app has a Rust backend and a web UI:
- The catalog format and the
locales/directory. Plain JSON that any tool or model can read and write. The format belongs to no runtime. - The message subset and its twin formatters.
format.rsandformat.tsare a few hundred lines together and have no dependencies. Keepfixtures.jsonwith them, since it's what keeps the twins identical. checkin your lint gate. Most of the value is here. Translation bugs are silent, and the tool turns them into build errors.- The
todo→ AI →applyloop. The instructions insidetodo(i18n/src/tool.rs,INSTRUCTIONS) are what make the AI's answers consistent. Adjust them to describe your app.
To adapt it, change Project::reason() in i18n/src/tool.rs (which folders to scan), the crate name, and the locale.set endpoint. A TS-only app can drop the Rust runtime but should keep the tool.
Gotchas
- Keys name a role, not the English text. Use
post.actions.share, notshare. Two keys with the same English are fine and often correct: "Share" the verb is not "Share" the noun in most languages. Keep shared keys for true UI primitives (common.cancel). Consistency comes from the glossary, not from reusing keys. - Name every key as a literal somewhere. The checker reads the source. A TS
t(expr)is fine, sincetscchecks that it's a key, but the key has to appear ast('…')ortKey('…')somewhere, orcheckreports it as unused.t(`settings.section.${id}`)hides keys, so map ids to keys explicitly. In Rust nothing type-checks keys, so a non-literal key is an error. - Developer-mode views stay English. The schema explorer, document inspector, app map and transfer log are diagnostic tools, and their vocabulary is the code's.
- Placeholders stand alone. Finnish would want keskustelussa ("in the discussion"), with the case ending attached to the name. A placeholder can't take one, so the translation rephrases: "New message in {title}" becomes "Uusi viesti: {title}". The
todoinstructions say this explicitly. - Whole sentences only.
"New message in " + titlecan't be translated, because word order changes between languages. Put the whole sentence in the catalog with a placeholder. - Dates, times and numbers come from
Intl.DateTimeFormat,RelativeTimeFormatandNumberFormat, never from the catalog. - Rust
t(is ambiguous. Plenty of code has a helper calledt. On the Rust side the checker only matchesi18n::t(andi18n::loc(, so import the crate under that name (use reason_i18n as i18n;). - Dev server file access. The webui's dev server reads
../localesoutside its own folder, which needs an entry inserver.fs.allowinwebui/vite.config.ts.
Reference: i18n/README.md, kept next to the code, lists the commands.