# Translating LobbyForge Adding a language is one command and some JSON. You never touch TypeScript, and a half-finished translation can ship: every string you have not done yet shows in English. - [Add a language](#add-a-language) - [Translate](#translate) - [Plurals](#plurals) - [Check your progress](#check-your-progress) - [Finish a language](#finish-a-language) - [Guidelines](#guidelines) - [For developers: adding strings](#for-developers-adding-strings) - [How it works](#how-it-works) - [Each person's language](#each-persons-language) - [Instance default language](#instance-default-language) ## Add a language ```sh pnpm i18n:add de --name Deutsch --english German ``` - `de` is the language code — a [BCP 47](https://www.w3.org/International/questions/qa-choosing-language-tags) tag such as `de`, `pt-BR` or `zh-Hans`. It becomes the folder name. - `--name` is the language's name **in itself**. It is what people see in the language picker, so a German speaker finds "Deutsch", not "German". - `--english` is its English name, shown alongside for everyone else. - Add `--rtl` for a right-to-left language (Arabic, Hebrew, Persian, Urdu). This creates, with every string blank and ready to fill in: ``` apps/web/messages/de/_locale.json name, direction, status apps/web/messages/de/.json one file per area of the app plugins//locales/de.json one per game that has translations ``` The language appears straight away, marked *In progress*, everywhere a language is chosen — with no code change: - **Settings → Appearance → Language**, for every member; - the first step of the **setup screen** (`/setup`), for a new instance; - the **Default language** list in **Community Settings → Overview**. ## Translate Open a `de` file next to its English twin in `en/`. They have the same keys in the same order: ```jsonc // apps/web/messages/en/lobby.json "lobby.voice.connected": "Voice Connected", "lobby.apps.start": "Start {name}", // apps/web/messages/de/lobby.json "lobby.voice.connected": "", ← fill this in "lobby.apps.start": "", ``` - A **blank** string means "not translated yet" and shows the English text. Fill in as many or as few as you like — every one you do is live. - Keep `{placeholders}` exactly as they are. You may move them within the sentence, since word order differs between languages, but not rename or drop them. `"Start {name}"` can become `"{name} starten"`; it cannot become `"{name} beginnen {game}"`. - Only change the value, never the key on the left. In development, refresh the page to see your edit — no restart needed. ## Plurals Where a number is counted, English writes both forms in one string: ```json "adminSettings.members.count": "{count, plural, one {# member} other {# members}}" ``` `#` is the number. Write the forms **your** language needs — the categories are `zero`, `one`, `two`, `few`, `many` and `other`, plus `=0`, `=1`… for an exact number, and `other` is always required: ```jsonc // Russian — four forms "{count, plural, one {# участник} few {# участника} many {# участников} other {# участника}}" // Turkish — the noun stays singular after a number, so one form is enough "{count} üye" ``` Which numbers fall into which category is built into every browser ([CLDR plural rules](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html)); you only write the words. You may use a plural even where English has a plain `{count}`, and a plain `{count}` where English has a plural — what must match is the argument name, not the shape. `{kind, select, voice {…} other {…}}` picks by a word instead of a number, where the code passes one. An apostrophe is just an apostrophe (`"Hushle'ı başlat"`): unlike some ICU tools, LobbyForge does not treat `'` as an escape character. ## Check your progress ```sh pnpm i18n:status ``` ``` App — apps/web/messages en English ████████████████████ 100% 1887/1887 complete de Deutsch (German) ████████░░░░░░░░░░░░ 41% 774/1887 partial tr Türkçe (Turkish) ████████████████████ 100% 1887/1887 complete Plugins — plugins/*/locales hushle en 100% · de 20% (partial) · tr 100% ``` It also lists anything that would break the build, such as a placeholder typed wrong or a key that does not exist in English. ## Finish a language When everything is translated: ```sh pnpm i18n:complete de ``` This refuses while anything is still blank. Once a language is marked `complete`, the tests hold it to that: a developer who adds an English string has to translate it too before their change can merge. ## The desktop app The desktop app speaks the same languages, with no extra step: - **Its own screens** (the connect screen and the tray menu) use the `desktop.*` strings in `apps/web/messages//desktop.json`. Translate them like any other area. `pnpm i18n:sync` (which `i18n:add` runs) bundles every language into `apps/desktop/dist-shell/locales.js`; the app picks the system's language and falls back to English. - **The Windows installer** offers every language Tauri's installer has a translation for: Arabic, Bulgarian, Chinese (Simplified and Traditional), Dutch, English, French, German, Italian, Japanese, Korean, Norwegian, Persian, Portuguese (and Brazilian), Russian, Spanish, Swedish, Turkish, Ukrainian and Vietnamese. `pnpm i18n:sync` writes the list into `apps/desktop/src-tauri/tauri.conf.json` from the language folders. For any other language, the app itself is translated and its installer is in English. - `pnpm i18n:status` reports either file when it is out of date, and so do the tests, so a forgotten sync can't merge. ## Guidelines - **Write for the person using the app**, not word for word. Say what a native speaker would say. `"Today at 11:34"` is `"Bugün 11:34"` in Turkish — no preposition — and the catalogues carry whole phrases for exactly this reason. - **Use the words your community already uses.** If players of Discord, Steam or TeamSpeak in your language say "sunucu" rather than a literal translation, use that. - **Keep sidebar labels short.** The sidebar is 240px wide. - **Do not translate** brand and product names (LobbyForge, Hushle, LiveKit), the language names in the picker (each language is shown in its own name), or anything that is data rather than interface — channel names, usernames, card-pack names, the words inside a Hushle deck. - **Machine translation** is fine as a starting point, but have a speaker review it, and check placeholders and tone. Hushle's word decks are *content*, not interface: a German deck is a card pack an admin creates under **Settings → Plugins & Word Packs**, separate from translating Hushle's buttons. ### Right-to-left languages `--rtl` sets `dir="rtl"` on the page, which is correct for the text. The layout itself has not yet been audited for right-to-left: some components use physical `left`/`right` positioning rather than logical `start`/`end`, so parts of the chrome may not mirror correctly. The translation still works; the layout pass is tracked separately. ## For developers: adding strings Never put user-facing text in a component. Give it a key: ```tsx // Client component import { useT } from '@/lib/i18n/client'; const t = useT(); // Server component import { getTranslator } from '@/lib/i18n/server'; const t = await getTranslator(); ``` Then add the key to the English catalogue for that area, and to every language marked `complete` (today: Turkish): ```sh # after editing apps/web/messages/en/lobby.json and tr/lobby.json pnpm i18n:sync ``` `sync` gives every *partial* language a blank for your new string, so translators see new work waiting for them, and regenerates each plugin's `src/locales.generated.ts`. `pnpm test` fails if a complete language is missing your string. - **Keys** are `..` — `lobby.voice.connected`. They are global across files, so the prefix matches the file the key lives in. - **A new area** is a new file: add `apps/web/messages/en/.json` and the same file in each `complete` language. Nothing else to register. - **Renaming a key** leaves the old one behind in every translation; `pnpm i18n:status` reports it, and `pnpm i18n:sync --prune` removes it. - **Plugins** keep their own `locales/.json` and translate with the plugin SDK's `tFor`. Keys start with the plugin id. A plugin with no `locales/` folder is English-only and declares `locales: ['en']`. - **Counts:** pass the number, and write the English with a plural — `t('x.members', { count })` over `"{count, plural, one {# member} other {# members}}"`. Never pick between two keys with `count === 1 ? … : …`: that bakes English's two forms into the code, and a language with four cannot fix it from its JSON. - **A link or element inside a sentence:** keep the whole sentence as one string with a marker, and place the element with `rich()` from `lib/i18n/rich.tsx` — each language then puts it where its word order wants it: ```tsx // "discovery.clearFiltersHint": "Try clearing the filters above, or {link}." rich(t('discovery.clearFiltersHint'), { link: {t('discovery.connectByAddress')} }) ``` - **Dates and numbers:** `t.locale` is the language the translator speaks — pass it to `Intl` so a month name matches the sentence around it (see `lib/chat-time.ts`). - **The format is shared with plugins:** `tFor` in the plugin SDK formats the same syntax (`packages/plugin-sdk/src/message-format.ts`). ## How it works - **The folder is the registry.** Languages are discovered from `apps/web/messages//_locale.json`; nothing in the code lists them, which is why adding one needs no code change. - **The server picks the language**, in this order: 1. the signed-in account's saved language (`users.locale`); 2. this browser's choice (the `lf_locale` cookie); 3. the browser's `Accept-Language`, honouring its quality values; 4. the [instance default](#instance-default-language); 5. English. Signed out, the order starts at 2. Each saved choice counts only while its language is installed: one whose folder was removed is skipped. A request does at most one extra lookup for this — a signed-in page reads one column of the account row; the instance default is cached. - **Only the active language reaches the browser**, already merged over English. Adding a language does not make the app bigger for anyone. - **`` is the page's language**, which CSS casing and screen readers depend on. Plugins read theirs from `data-lf-locale`, and their panel is tagged with the language the plugin actually speaks — English, if a game has not been translated into yours yet. - **`complete` and `partial`** live in `_locale.json` (and as `"$status"` in a plugin's file). Only `complete` languages must have every string. ## Each person's language **Settings → Appearance → Language** saves the choice twice: on the account (`users.locale`) and in the browser's `lf_locale` cookie. The account copy is what makes it follow the person — the desktop app and a second browser have cookie jars of their own — and it wins over the cookie. The cookie keeps the choice in that browser after signing out. Guests are accounts too, so they save it the same way. **Match my browser** clears both: the account's language goes back to NULL and the cookie is removed, so the browser's `Accept-Language` decides again. NULL means "never chose"; an explicit English choice is stored as `en` and honoured like any other. (Before migration 0048 the column was `NOT NULL DEFAULT 'en'` and nothing wrote it, so the migration cleared every `en` to NULL.) Emails follow the same idea: the recipient's saved language, else the language of the request that triggered the email, else the instance default — and a password reset is written in the account owner's language, not the language of whoever asked for it (docs/EMAIL.md §2.3). ## Instance default language The default is what a visitor gets when neither their account, nor their browser's cookie, nor their `Accept-Language` names a language the instance offers — and the language of text with no single reader: the built-in bots' default messages, an email with no recipient language, the language hint a sandboxed plugin falls back to. It is decided in this order, each step only when it names an installed language: 1. **the language saved by the owner** — picked on the first step of the setup screen, and changeable in **Community Settings → Overview → Default language**, which lists every installed language (in its own name, with its status) and says which step is in effect; 2. **`LOBBYFORGE_DEFAULT_LOCALE`** in the server environment, e.g. `LOBBYFORGE_DEFAULT_LOCALE=tr`; 3. English. It is a fallback, not an override: someone whose browser asks for a language the instance has still gets it, and anyone can pick their own in Settings. The saved value lives in `instance_settings.default_locale`; each server process caches it for 30 seconds, and the process that saves it uses the new value at once. Changes are written to the audit log. `lib/i18n/instance-default.ts` is the one place that resolves it. **The setup screen.** Its first step, *Language*, lists every language found under `apps/web/messages/`, starting on the one the browser asked for. Picking one switches the wizard immediately — every later step is in that language — and finishing setup saves it as the instance default and as the owner account's language. (`servers.default_locale` is set when a server is created but nothing reads it: the language comes from the account, the browser and the instance default above.)