Translating Lumen
Nội dung này không tồn tại trong ngôn ngữ của bạn
Lumen’s web app ships with runtime i18n — strings are loaded as a typed object at build time and switched on-the-fly via the LanguageToggle in the top bar, no page reload. As of v0.6.x the app ships English (EN) and Vietnamese (VI). Adding a third locale (German, Japanese, …) is a small, well-bounded PR.
This page covers:
- Where translations live
- Adding a missing key to an existing locale
- Adding a brand-new locale
- Interpolation, pluralization, edge cases
- How the type system catches incomplete translations
- Docs site translation (Starlight) — separate from the web app
Where translations live
web/src/i18n/├── messages.ts ← all strings live here, one big typed object├── types.ts ← Locale + TranslationKey types derived from messages.ts├── I18nProvider.tsx ← context provider + locale persistence└── useI18n.ts ← the `t()` + `locale` hook every component callsThe whole locale catalog is a single TypeScript file rather than separate JSON. Reason: TranslationKey is auto-derived from the EN object (type TranslationKey = LeafKeys<typeof en>), so any call to t("foo.bar") is type-checked at compile time. Splitting locales across JSON would lose that guarantee.
messages.ts is organised by namespace (app.*, dashboard.*, host.*, settings.*, alerts.*, common.*, …). When adding a new key, put it under the namespace that owns the feature.
Fix or add a key in an existing locale
Most translation PRs look like this. To rename “Hosts” → “Servers” in the dashboard tab, say:
- Open
web/src/i18n/messages.ts. - Find the
enobject → the namespace (dashboard.*here). - Edit the English string. Save.
- Scroll down to the
viobject → fix the same key. - Run
pnpm --filter web exec tsc --noEmitfrom the repo root. If TS errors, you missed the matchingvikey. - Open the page in the dev server (
make dev-hub+make dev-web), flip the language toggle, eyeball both versions.
That’s it. No build step, no key generation, no extraction tool.
Add a new locale
Walk through with German (de) as a worked example. Same shape for any other locale.
1. Extend the Locale type
web/src/i18n/types.ts:
export type Locale = "en" | "vi";export type Locale = "en" | "vi" | "de";2. Mirror the en object as de
In web/src/i18n/messages.ts, scroll past the closing } as const; of en (and past the existing vi block). Add a new mirror:
export const de: WidenStrings<typeof en> = { app: { loading: "Wird geladen…", // … every leaf string from `en` must have a German version here. }, // … fill in every namespace the same way.};WidenStrings<typeof en> is a TypeScript trick that lets the compiler enforce that every key in en exists in your locale, while still accepting strings of any value (rather than insisting on the exact English literal). If you forget a key, you’ll get a Property 'foo' is missing in type error — see The type system is your co-translator.
3. Register the locale
At the bottom of messages.ts, add the new export to the messages map:
export const messages = { en, vi } as const;export const messages = { en, vi, de } as const;4. Add the language toggle entry
web/src/components/LanguageToggle.tsx (or wherever setLocale is offered) lists the available locales. Add an entry so users can actually pick German.
Same for web/src/components/Settings.tsx → DisplaySettings → languages segmented control (around line 1320), if you want the language available in the Settings → Display panel.
options={[ { value: "en", label: "EN" }, { value: "vi", label: "VI" }, { value: "de", label: "DE" },]}5. Run typecheck + visual check
pnpm --filter web exec tsc --noEmit # 0 errors requiredpnpm --filter web run build # production build sanitymake dev-hub # open browser, flip toggle to DE6. (Optional) Add a Vietnamese / English-only string note
Some strings are deliberately locale-neutral (e.g. "EN", "VI", "%"). Don’t translate those — leave them as-is.
Interpolation & pluralization
Interpolation
Lumen uses {name} placeholders, evaluated by the t() helper:
t("host.cores", { count: 8, coreLabel: "cores" })// → "8 cores"When translating, preserve {name} placeholders exactly — don’t translate them and don’t reorder them out of grammar. Position is fine, the helper substitutes by key.
VI example from the existing bundle:
host: { cores: "{count} {coreLabel}", // EN: same shape // …},Pluralization
Lumen uses a passed-in plural form rather than an ICU plural engine. Components that need plurals pre-pick singular vs plural based on count and pass the matching label as a param:
const coreLabel = n === 1 ? t("host.coreSingular") : t("host.corePlural");t("host.cores", { count: n, coreLabel });When adding a new locale that has more than one plural form (Russian, Polish, Arabic, …), you have two options:
- Add extra plural keys (
coreFew,coreMany) and update the few component call-sites that pick — small, explicit, fits the existing pattern. - Open an RFC to introduce
Intl.PluralRules— heavier infrastructure, only worth it if multiple new locales need it.
Default to (1) for the first non-EN/VI locale; (2) is a bridge when the plural complexity actually surfaces.
Date and number formatting
The current code uses small ad-hoc formatters in web/src/lib/format.ts and web/src/lib/time.ts (relative-time strings, byte formatters, etc.). Some of those need locale-specific output and accept a locale arg today — others don’t. If a locale needs different formatting, add a small per-locale fork inside the formatter rather than scattering if (locale === "de") throughout the component code.
The type system is your co-translator
The vi: WidenStrings<typeof en> declaration ensures that the VI object has exactly the same key shape as en. If you add a new EN key and forget VI, TypeScript will error:
Type '{ … }' is missing the following properties from type 'WidenStrings<{ readonly app: { … } }>': newKeypnpm --filter web exec tsc --noEmit (or pnpm lint from the repo root, which runs typecheck across the workspace) is the single check that enforces this. CI runs it on every PR, so a PR that misses a VI mirror will be flagged automatically — but running locally is faster.
If you intentionally want a string only in EN (very rare — usually for one-off debug builds), the type system won’t let you skip the VI side. Either add a VI version, or move that string out of the i18n bundle entirely.
Docs site translation (Starlight)
The Starlight docs site under docs/src/content/docs/ is separate from the web app i18n — Starlight has its own routing-based i18n.
To translate a docs page to Vietnamese:
- Copy
docs/src/content/docs/<section>/<page>.md→docs/src/content/docs/vi/<section>/<page>.md. - Translate the content. Keep the frontmatter
title:/description:translated too. - Cross-reference internal links:
/install/agent-docker/becomes/vi/install/agent-docker/on the VI side. pnpm --filter lumen-docs run buildvalidates that all links resolve.
The web app and docs site can ship at different translation completeness levels — they’re independent.
What translators should know
- Tone: friendly + concise. “Sign in”, not “Please sign into your account at your earliest convenience”.
- Sentence case for buttons + labels (“Save changes”, not “Save Changes”). Keep this even in languages where headline case is the convention — it matches the existing UI rhythm.
- No emoji-flag-as-locale-name. The toggle uses
EN/VI/ etc., not 🇬🇧 / 🇻🇳. (Flag-for-language is widely considered an anti-pattern — language ≠ country, and ISO codes are unambiguous.) - Quotation marks: use the locale’s native marks (
„…"in German,« … »in French,「…」in Japanese). Don’t force the EN-style"…"shape.
PR checklist
When you open a translation PR:
-
pnpm --filter web exec tsc --noEmitpasses (TS catches missing keys). -
pnpm --filter web run buildbuilds cleanly. - Visual eyeball of every translated string in the dev server, in the target locale.
- Add a CHANGELOG entry under
## Unreleased(e.g.Added German UI translation). - If you added a new locale, mention it in the README “Feature highlights” table under the
UIrow.
A maintainer will follow up with native-speaker review when one is available — translation PRs land even without a native speaker since the i18n shape is mechanically enforced; a follow-up polish PR can refine wording later.