# 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. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading. ## 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,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same. - **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 # what each language lacks or no longer uses, and unmarked UI text 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 picker (the globe button in the bottom-right corner). An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`. ## Text the check reports as unmarked `npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. - **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`, `say`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties. Fix it by marking the text. When the text must stay as it is, say so where it lives: | Case | How | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML | | Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element | | A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: ` on the line or the line above | Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page. ## 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 | | `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose | | `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text | | `src/i18n/catalog.test.mjs` | runs that check under `npm test` |