diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63f83e4c2..faff08a1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index aa6075682..3d5842f67 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 000000000..b878ae66e --- /dev/null +++ b/docs/I18N.md @@ -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/.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 +Normal + + +``` + +`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/.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` | diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index bef41b634..567309aa1 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -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 -Normal - -``` - -`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/.json`, keyed by the English text: diff --git a/src/i18n/index.js b/src/i18n/index.js index 3f8f4cf4d..4578cfcc9 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -9,7 +9,7 @@ // English needs no catalog. Every other locale is one flat JSON file, // locales/.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,