docs(i18n): add a developer guide for t, tn, msg and data-i18n

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 14:03:30 +04:00
1 parent 13efd411b6
commit 4dcbf7fbfa
5 files changed
+74 -17

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ The highest-leverage places to jump in:
- ES modules, **2-space indent, single quotes, semicolons.**
- JSDoc on exported/public functions.
- Match the surrounding code — comment density, naming, and idiom.
- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
## Formatting and reusable components
+2 -1
View File
@@ -3012,7 +3012,8 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback) and **ru**. The code lives in
`src/i18n/`; the contributor workflow is in [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
+69
View File
@@ -0,0 +1,69 @@
# UI text and translation
Every piece of text a user can see goes through `t()`, `tn()` or `msg()` in code, or a `data-i18n*` attribute in markup. The English text itself is the key. English needs no catalog; other languages live in `src/i18n/locales/<code>.json`. Translators work from [TRANSLATORS.md](./TRANSLATORS.md).
## Which one to use
| | Translates | Use for |
| ------------------------------- | --------------------------------- | --------------------------------------------- |
| `t(text, values)` | now | any text shown right where it is written |
| `tn(count, one, other, values)` | now, picking the form for `count` | text that changes with a number |
| `msg(text)` | never: returns `text` unchanged | text stored in data and passed to `t()` later |
```js
import { t, tn, msg } from '../i18n/index.js';
status.textContent = t('Save keys');
status.textContent = t('Saved to {store}', { store: storeLabel() });
chip.textContent = tn(missing, '{count} KEY WAITING', '{count} KEYS WAITING');
// en: 1 KEY / 3 KEYS ru: 1 КЛЮЧ / 3 КЛЮЧА / 5 КЛЮЧЕЙ
const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') };
label.textContent = t(FEED_STATE_LABELS[state]);
```
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
## Markup
```html
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
<input placeholder="Search any location..." data-i18n-placeholder />
```
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page.
## Rules
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`.
- **Use placeholders, not concatenation.** `t('Flying to {place}…', { place })`, not `t('Flying to') + ' ' + place`. Word order differs between languages.
- **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms.
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
- **Numbers and dates** go through `formatNumber`, `formatDate` and `formatRelativeTime` from `src/i18n/index.js`, which follow the active language.
- **Identical English means identical translation.** If two places need different words, make the English differ.
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
## After you add or change text
```sh
npm run i18n # shows what each language now lacks or no longer uses
npm run i18n -- --missing ru # the missing ru entries as JSON
npm run i18n -- --prune # drop entries whose English no longer exists
```
Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete.
To see a language, open the app with `?lang=ru`, or use the EN/RU switch in the Visual Presets dock.
## Where things live
| File | What it holds |
| ------------------------------ | ----------------------------------------------------------------------------- |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages and checks the catalogs |
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
+1 -15
View File
@@ -4,21 +4,7 @@ Shipped languages: **English** (source and fallback) and **Russian** (`ru`).
## How it works
The English text is the key. There is no English catalog: the code is the source.
```js
t('Save keys'); // plain text
t('Saved to {store}', { store }); // named placeholders
tn(count, '{count} camera', '{count} cameras'); // count-dependent text
msg('Live Flights'); // text kept in data, translated later by t()
```
```html
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
```
`data-i18n` translates the element's own text (put it on the element that holds the text, not on a parent with children). `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute's English value.
The English text is the key: code calls `t('Save keys')`, and markup marks elements with `data-i18n`. How developers mark text is in [I18N.md](./I18N.md).
Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keyed by the English text:
+1 -1
View File
@@ -9,7 +9,7 @@
// English needs no catalog. Every other locale is one flat JSON file,
// locales/<code>.json, keyed by the English text (for tn, by the English
// plural form). A missing entry falls back to English.
// `npm run i18n:check` keeps the catalogs in step with the code.
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
import {
DEFAULT_LOCALE,
normalizeLocale,