Files
upstream-rocketchat/docs/i18n.md
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

238 lines
10 KiB
Markdown

# 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 `<language>.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 `<language>.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).