docs/architecture/internationalization.md
Rama main · réplica de lectura del repositorio original.
# Internationalization
FloCafe supports 32 UI languages. This page describes how a language is declared, loaded, and
validated, and how the UI language stays separate from a store's regional settings.
## Source of truth and the parity rule
`frontend/src/lib/i18n/messages/en.json` is the canonical message catalogue. Every other catalogue
under `frontend/src/lib/i18n/messages/` must match its top-level namespaces and non-empty string
leaves exactly: zero missing keys and zero orphan keys.
Exact parity is a hard gate rather than a fallback, because there is no per-key English fallback in
the runtime. A missing key does not render the English string; it renders nothing, and the
translation test fails. `npm run i18n:check` runs `tests/translations.test.ts`, which enforces
registry and file consistency, key parity, ICU validity, placeholder and tag parity, per-language
completeness, and frontend key safety.
The 32 committed catalogues are: `ar`, `bn`, `de`, `en`, `es`, `fa`, `fil`, `fr`, `ha`, `hi`, `id`,
`it`, `ja`, `jv`, `ko`, `mr`, `ne`, `nl`, `pa`, `pt`, `ru`, `sq`, `sw`, `ta`, `te`, `th`, `tr`, `uk`, `ur`, `vi`, `zh`, `zh-tw`.
## The registry
`frontend/src/lib/i18n/languages.ts` exports `LANGUAGES`, the single source for everything about a
language. Each entry carries:
| Field | Meaning |
| ------------ | ----------------------------------------------------------------------------------------------------------------- |
| key | The object key in `LANGUAGES`, and the language code used in settings, print policies, and message filenames. |
| `locale` | The canonical BCP-47 tag passed to `Intl`. It is the tag, not the key: `de` has locale `de-DE`, `pt` has `pt-BR`. |
| `nativeName` | The endonym shown in the language picker. It is written in the language itself. |
| `direction` | `ltr` or `rtl`. |
| `selectable` | Whether the language may be chosen by an end user. |
| `load` | A lazy dynamic import returning that language's message catalogue. |
Because the registry is the only place a language is declared, adding a language means adding an
entry here and nothing else structural. The registry also exports `isLanguage()`,
`getLanguageFromLocale()`, `getLanguageDirection()`, and `getLanguageLocale()`; the reverse map
from BCP-47 tag to key is derived from `locale`, so the tag and the key cannot drift apart.
The registry key must be a canonical lowercase BCP-47 identifier, which is why a regional variant
gets a key such as `zh-tw` and a `locale` of `zh-TW`. See [adding a language](../guides/adding-a-language.md).
### Right-to-left languages
Exactly three registered languages are right-to-left: `fa` (Persian, `fa-IR`), `ar` (Arabic,
`ar-SA`), and `ur` (Urdu, `ur-PK`). There is no Hebrew locale.
Direction is applied to the document by `HtmlLangSync` in
`frontend/src/components/layout/HtmlLangSync.tsx`, which sets `<html lang>` to the language's
`locale` and `<html dir>` to its `direction`. Toasts flip separately through `DirectionalToaster`.
Direction is a property of the registry entry and of nothing else, so a new RTL language needs no
layout code.
## Loading
`frontend/src/lib/i18n/loader.ts` owns the message cache.
- English is imported statically at module load and pre-seeded into the cache, so a cold boot always
has a complete catalogue and never waits on the network.
- Every other language is a dynamic import, one chunk per catalogue, declared by the registry's
`load` function.
- Concurrent requests for the same language are deduplicated. The second caller receives the same
in-flight promise rather than starting a second import.
- A language resolves from the cache synchronously once loaded, through `getCachedMessages()` and
`isLocaleLoaded()`.
- An unknown language falls back to the English loader rather than throwing.
`I18nProvider` in `frontend/src/components/providers/I18nProvider.tsx` wraps the tree in
`use-intl`'s `IntlProvider` and makes locale switching atomic. It renders from the cached catalogue
and only advances once the new one has loaded in full. A failed load logs a warning, keeps
the last successfully rendered language active, and writes that language back to the store. A
superseded load is discarded, so a rapid sequence of language changes cannot leave the UI showing a
catalogue the store no longer names. A partially loaded catalogue is never rendered.
## Initial language resolution
`resolveInitialLanguage()` in `I18nProvider` resolves in a fixed order:
1. The persisted store value in `localStorage` under the `pos-settings` key, if it names a
registered language.
2. `getBrowserLanguage()`.
3. `en`.
The result is written to the store, so browser detection runs only when there is nothing persisted.
## Browser detection
`frontend/src/lib/i18n/browser-language.ts` matches `navigator.languages` (plus
`navigator.language`) against the registered `selectable` languages. Each registered locale is
expanded with `Intl.Locale` and `maximize()`, which supplies the likely script for a bare language
subtag.
Resolution for a candidate tag, in order:
1. An exact `baseName` match against a registered locale.
2. A maximized `baseName` match, so a bare `de` reaches `de-DE`.
3. A same-script, same-language match, and only when exactly one candidate exists.
Step 3 deliberately never crosses a script boundary. A `zh-TW` tag resolves to `zh-tw` and must not
silently select `zh-CN`; a tag whose script has no unique registered candidate falls through to the
next preference and ultimately to `en`. A malformed tag is skipped rather than thrown on. When
`navigator` is unavailable, the result is `en`.
## Message format and key safety
Messages are ICU MessageFormat, parsed by `use-intl`. `tests/translations.test.ts` checks, across
every locale, that each message parses as valid ICU and that argument names and `plural` and
`select` selector variables match the English message exactly. A locale that renames an argument or
drops a plural branch fails the check.
Key safety is compile-time. `frontend/src/lib/i18n/messages.d.ts` augments `use-intl`'s `AppConfig`
with `Messages = typeof en` and `Locale`, so a `t()` call with a key that does not exist in
`en.json`, or an argument set that does not match, is a TypeScript error. `npm run i18n:check` runs
`tsc --noEmit -p frontend/tsconfig.json` and therefore fails on it. The same step also type-checks
the frontend, which is why the command reports anything it finds beyond translations.
## UI language is independent of regional settings
The interface language and the store's regional identity are separate domains and are configured
separately. The UI language chooses message catalogues, `Intl` locale tags, and the endonym in the
picker. The tenant's regional settings choose the business timezone, number and currency
formatting, digit shape, and calendar. Neither derives from the other: a Persian speaker running an
Iranian store sees Persian text and Iranian number formatting, and a Persian speaker running a
different store sees Persian text with that store's formatting. Changing one does not change the
other. `tests/decoupled-ui-locale.test.ts` pins this, including that an RTL locale formats decimals
with a comma and non-breaking space grouping and renders month names in its own script while
keeping Latin digits.
Print output has its own language settings, separate from both; see
[printing](printing.md).
## Derived print labels
The main process cannot import the frontend's message loaders, but it needs the same label text for
thermal printing and backend plain-text WhatsApp reports (`formatDailySalesWhatsAppMessage` in
`main/services/daily-sales-export.ts` and `formatStoredZReportWhatsAppMessage` in
`main/services/whatsapp.ts`). `main/print/print-labels.generated.ts` is a committed, generated
TypeScript view of the canonical catalogues for those main-process consumers. It is produced by
`scripts/generate-print-labels.cjs`, which extracts the `print.*` namespace plus an explicit manifest
of borrowed keys from other namespaces, for every committed language, in registry order.
- The generated module is a derived view. Never edit it by hand; edit the message catalogues and
regenerate.
- A concept with no translation in a language falls back to English, so a receipt always renders a
real label rather than a raw key.
- `PRINT_LABEL_LANGUAGES` in the generated module is the registry's language list, so adding a
language adds its print labels with no separate registration.
- Drift is gated by `npm run generate:print-labels -- --check`, which regenerates in memory and
byte-compares against the committed file. The check is wired into both `npm run i18n:check` and
`tests/print-labels.test.ts`.
## Warming print locales at bootstrap
Printing can happen before any user interaction, so the locales a store's print policies select are
loaded during authenticated bootstrap. `syncPrintPoliciesAtBootstrap()` in
`frontend/src/lib/print-policy-bootstrap.ts` parses the tenant's `bill_language_policy` and
`kot_language_policy`, resolves the receipt and KOT languages, loads that set with
`Promise.allSettled`, and returns the languages whose load rejected. The caller surfaces those
failures. It does not silently substitute English, because a receipt printed in the wrong language is
a worse outcome than a visible error.
An unparseable or missing print policy is not an error: it resolves to the default policy, which
falls back to the store language.
## Adding or changing a language
`[adding a language](../guides/adding-a-language.md)` is the procedure. The script-level contracts a
translator and implementer must not violate, such as conjunct handling and printer code-page
representability, are in
[translation terminology](../reference/translation-terminology.md).
## Verification
```sh
npm run i18n:check
```
The command runs `npm run test:translations`, the print-label drift check, and the frontend
TypeScript check. It is fully offline and needs no server or database.