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