# Translations (i18n) How translation keys are stored, named, and written. This applies to every runtime that consumes `@rocket.chat/i18n`. Client-specific guidance — hooks, the `Trans` component, escaping — lives in [docs/frontend/i18n.md](frontend/i18n.md). ## Where translations live All translations are flat JSON files in [`packages/i18n/src/locales/`](../packages/i18n/src/locales/), one per language, named `.i18n.json` (68 locales at the time of writing). `en.i18n.json` is the **base language**. It is the only file you should edit when adding a feature: - keys absent from a locale fall back to `en` (`fallbackLng: 'en'` in every runtime); - keys present in a locale but *not* in `en` are treated as stale and removed by the linter (`wipe-extra-keys`); - key order in every other locale is derived from `en`, so reordering `en` reorders all of them. Translations for other languages arrive separately. Don't hand-write them for your own feature. ### Types are generated from `en` `packages/i18n/src/resources.ts` is a checked-in dummy. The real key union is generated into `dist/resources.d.ts` from `en.i18n.json` at build time. At the time of writing it doesn't restrict `t`'s signature — the declaration adds overloads to i18next's `TFunction` rather than narrowing it, and narrowing was avoided for typecheck performance. **A misspelled key is not a compile error**, so verify keys by grepping the base locale. A key you just added won't appear in the generated types until the package is rebuilt: ```sh yarn workspace @rocket.chat/i18n build ``` ## Naming keys The dominant convention is capitalized snake case — `Sentence_case_with_underscores`: ```json { "Cam_on": "Camera on", "Delete_room": "Delete room", "You_are_offline_please_reconnect": "You are offline, please reconnect" } ``` Write the key to describe the *meaning*, not the value, and not where it is rendered. `Delete_room` survives a copy change; `Delete_room_red_button` does not. Reuse an existing key when the meaning is identical. Do not reuse one because the English happens to match — languages that inflect by context will need them separated, and splitting a shared key later is a breaking change for every locale. ### Namespaces A key may be prefixed with one of exactly five namespaces, separated by a dot (`nsSeparator: '.'`): `core` (the default) · `onboarding` · `registration` · `cloud` · `subscription` ```json { "onboarding.component.form.action.next": "Next", "subscription.callout.title.limitsReached": "Limits reached" } ``` An unprefixed key lands in `core`. Namespaces exist so a client can load a subset of the bundle, not as general grouping. The set is defined in [`packages/i18n/src/index.ts`](../packages/i18n/src/index.ts). Within a namespace, keys use lowercase dotted paths rather than snake case, following the existing entries. ## Interpolation Use i18next named placeholders, `{{likeThis}}`, with camelCase names: ```json { "Room_removed": "Room {{roomName}} removed from ABAC management" } ``` Two obsolete forms still exist in the base locale and must not be copied: | Form | Status | | ---- | ------ | | `{{name}}` | ✅ correct | | `__name__` | ❌ auto-rewritten by the linter (`replace-2-underscores`) | | `%s` | ❌ legacy sprintf, positional; ~75 remaining, tracked by `find-sprintf-params` | The sprintf form still works at runtime — both the client and the Meteor server install `i18next-sprintf-postprocessor` and wrap `t` with `addSprinfToI18n` — but it is positional, so a translator reordering a sentence silently swaps the values. Don't add new ones. Some *key names* also embed the old marker — `Added__username__to_team`, `__count__result_found`. That's cosmetic legacy in the name only; the values use `{{...}}`. Don't imitate it in new keys. ### Never assemble a sentence from fragments Word order is not universal, and a translator only sees the pieces: ❌ Incorrect: ```ts `${t('Deleted')} ${count} ${t('messages')}`; ``` ✅ Correct — one key holds the whole sentence: ```ts t('Messages_deleted', { count }); ``` A key carrying a count needs plural forms too, so `Messages_deleted` would be defined as an object — see [Pluralization](#pluralization). Composing a `Label: value` pair from a label key and a runtime value is fine; splitting prose across keys is not. ### Formatters Placeholders accept an i18next formatter after a comma. The Intl-backed built-ins work in every runtime: ```json { "Exceeded_limits": "Your workspace exceeded the {{val, list}} license limits.", "Seats_used": "{{count, number}} seats used" } ``` One custom formatter, `capitalize`, is registered **only in the client** ([`TranslationProvider`](../apps/meteor/client/providers/TranslationProvider.tsx)). It exists so that a language needing a different word order can capitalize whichever word ends up first, inside the translation file rather than in code. No base key uses it yet. Don't reach for it in a key the server also renders — there it would pass through unformatted. ## Pluralization Give the key an object of plural forms and pass `count`. i18next picks the form using the locale's CLDR rules: ```json { "message_counter": { "one": "{{count}} message", "other": "{{count}} messages" } } ``` For `en` that means `one` and `other`. Other locales have different sets — Arabic has six — which is exactly why this must not be hand-rolled: ❌ Incorrect: ```ts count === 1 ? t('message_counter_one') : t('message_counter_other'); ``` ✅ Correct: ```ts t('message_counter', { count }); ``` i18next also honours a special `zero` form, used when a dedicated empty-state phrasing reads better than "0 items": ```json { "Calls_in_queue": { "zero": "Queue is empty", "one": "{{count}} call in queue", "other": "{{count}} calls in queue" } } ``` Only add `zero` when the wording genuinely differs; `other` already covers 0 in English. Plural forms are validated per language: forms outside that language's CLDR set are stripped (`wipe-invalid-plurals`), and a locale missing a form that `en` defines is reported (`find-missing-plurals`). ## Server-side usage Three runtimes initialise the shared resources independently: | Runtime | Init | | ------- | ---- | | Client | [`client/providers/TranslationProvider.tsx`](../apps/meteor/client/providers/TranslationProvider.tsx) — `en` bundled, a non-English active locale fetched over HTTP | | Meteor server | [`server/lib/i18n.ts`](../apps/meteor/server/lib/i18n.ts) — eager, all 68 locales in memory | | `omnichannel-transcript` service | [`ee/apps/omnichannel-transcript/src/i18n.ts`](../ee/apps/omnichannel-transcript/src/i18n.ts) — same eager shape | On the server, import the instance rather than creating one: ```ts import { i18n } from '../../app/utils/lib/i18n'; ``` ### Always pass `lng` > This actually exposes a gap in the server-side i18n design. The server instance is initialised with `lng: 'en'` and there is no per-request language context. Omitting `lng` is not an error — it silently returns English. Only about a third of the ~200 server call sites pass it, so the surrounding code is not a reliable guide here. ❌ Incorrect — English regardless of who receives it: ```ts i18n.t('Username_and_message_must_not_be_empty'); ``` ✅ Correct: ```ts i18n.t('Username_and_message_must_not_be_empty', { lng: user.language || settings.get('Language') || 'en' }); ``` That fallback chain — recipient's language, then the workspace `Language` setting, then `en` — is the established pattern. There is no shared helper for it yet, so it is written out at each call site. Pick the language of whoever **reads** the string, which is not always the acting user: a notification, an email, or an export is rendered for its recipient. ### Don't translate at the API boundary Prefer returning a key from an endpoint and translating in the client, which is what most endpoints already do. The client knows the reader's language; the server has to be told. ## Separate systems [`packages/livechat`](../packages/livechat/) has its **own** translations in `src/i18n/`, unrelated to `@rocket.chat/i18n`: 59 locales, plain `.json`, nested under a single `translation` root key, `lower_snake_case` keys, and plurals as `_one`/`_other` key suffixes instead of nested objects. None of the rules on this page — the linter included — apply there. Don't copy conventions in either direction. ## What the linter enforces `yarn workspace @rocket.chat/i18n lint` runs ESLint plus [`src/scripts/check.mts`](../packages/i18n/src/scripts/check.mts). Most findings are auto-fixable with `lint:fix`. | Task | Rule | | ---- | ---- | | `sort-base-keys` | `en` keys sorted alphabetically, case-insensitive | | `sort-keys` | every locale follows `en`'s key order | | `wipe-extra-keys` | locales may not hold keys absent from `en` | | `wipe-invalid-plurals` | plural forms must be valid for that language (plus `zero`) | | `find-missing-plurals` | a locale must define every plural form `en` defines | | `replace-2-underscores` | `__name__` → `{{name}}` | | `missing-placeholders` / `extra-placeholders` | placeholders must match `en` exactly | | `find-duplicate-keys` | no duplicate JSON keys | | `trim-eof` | no trailing whitespace at end of file | `sort-base-keys` runs before `sort-keys`, since every other locale's order is derived from `en`. A new key can go anywhere in `en`; `lint:fix` moves it into place and reorders the other 67 files to match. Two details of the order are not alphabetical, and both are forced by JavaScript rather than chosen: - integer-like keys come first (`"500"`), because `JSON.parse` hoists them to the front of the object no matter where the file puts them; - keys differing only in case (`Private` / `private`, 69 such pairs) tie under the case-insensitive comparison and are broken by a plain code-point comparison, so the order stays canonical. One task is defined but excluded from the default run, so it won't fail your build: `find-sprintf-params`, which flags the leftover `%s` values in `en` (6 keys today). It is excluded because of an existing backlog, and shouldn't be "fixed" as a side effect of a feature PR. You can inspect it without changing anything: ```sh cd packages/i18n && node --experimental-transform-types ./src/scripts/check.mts -t find-sprintf-params ``` ## Committing Translation-only changes use the `i18n:` commit type, per the [pull request template](../.github/PULL_REQUEST_TEMPLATE.md).