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
238 lines
10 KiB
Markdown
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).
|