# 6. Internationalization (i18n) & RTL

## 6.1 Library and the two independent configurations

The project uses the **`i18n` npm package** (`"i18n": "^0.8.3"` in `package.json`, resolving to `0.8.6` installed) — not `i18n-nodejs`, not a custom framework. There are **two independently configured instances** of this same package, each a process-wide singleton:

- **`app/i18n.config.js`** — `locales`: `en`, `de`, `fr`, `ar-AE`, `ar-SA`, `pt`, `zh-CN`. Directory: `locales/`. Used by: `lib/pdf_generator.js` / `.min.js` (core report) only.
- **`app/i18n_wellbeing.config.js`** — `locales`: `en`, `de`, `fr`, `ar-AE`, `ar-SA`, `pt`, `zh-CN`, `tr`, `es`. Directory: `locales/wellbeing/`. Used by: `wellbeing_report_generator.js`, `template.js`, `corporate_report_generator.js`, `sanusx_report_generator.js`.

**Every generator except the core lab report pulls its strings from `locales/wellbeing/`, not `locales/`** — including the corporate report and SanusX report, despite neither being "wellbeing" products. This is a non-obvious naming trap when you're hunting for a string to translate: check which config the generator you're editing actually requires before assuming which locale folder to edit.

Both configs also set `cookie: 'currentLang'`, `queryParameter: 'lang'`, and alias the translate API to `translate`/`translateN` instead of the package default `__`/`__n`. Neither sets `objectNotation`, `fallbacks`, or `updateFiles`, so package defaults apply (see 6.5).

Because Node caches `require()`d modules, every file that requires either config gets the **same shared mutable object** — locale state is process-global, not per-request. See 6.6.

## 6.2 `app/services/localeService.js`

A thin wrapper class, instantiated separately by each generator with its own `i18n` singleton:

- `t(key, args)` → `i18nProvider.__(key, args).message` — the actual string getter. In practice, `args` is never passed at any call site in this codebase — the package's built-in `%s`/vsprintf-style interpolation is wired up but unused.
- `setLocale(locale)` → **only** calls through to the underlying `i18n.setLocale(locale)` if `locale` is already in that instance's configured `locales` array; otherwise it silently no-ops (see 6.5 for why this matters).
- `getLocales()`, `getCurrentLocale()`, `translatePlurals()` — thin proxies.

Instead of the package's own `%s` interpolation, this codebase uses ad hoc single-token placeholders baked directly into translation strings and manually substituted at the call site, e.g. `localeService.t('predictionsDesc').replace("{$val}", finalScore)`.

`lib/template.js` additionally has a second, parallel string-getter, `getLocale(key)`, used at a couple of call sites instead of `localeService.t(key)` — worth confirming both resolve against the same underlying data before refactoring either.

## 6.3 Locale JSON shape

Flat (no nesting — `objectNotation` is off), but each value is normally an **object**, not a plain string:

```json
// locales/en.json
"APPROVED BY": { "message": "Approved", "description": "before the doctor signature..." }
```

`LocaleService.t()` must append `.message` itself because of this shape. `locales/en.json` covers header/report labels, section titles, footer/pagination text, biomarker table headers, month names, and a `bigIntegral*` block. `locales/wellbeing/en.json` (424 lines) covers patient-header labels, section labels (`lifeStyle`/`body`/`mind`/`psychological`/`physical`), disclaimer text, corporate-report strings (`Copyright`, `Participant Analytics`), email body strings, and SanusX avatar/prediction content (including the `{$val}`/`{$value}` tokens mentioned above).

## 6.4 RTL handling — three overlapping mechanisms

1. **Whole alternate template files.** Every report family ships a matched pair, `rtl_no_pages.html` / `ltr_no_pages.html` (also under each brand subfolder). The RTL variant hardcodes `<html dir="rtl" lang="ar">` and loads a dedicated `assets/arabic.min.css` alongside the normal stylesheet.
2. **Alternate block partials for Arabic.** The core report swaps in `templates/blocks/ar-first-page-header.html`, `ar-header.html`, `ar-footer.html`, `biomarker-compact-ar.html` when RTL.
3. **Inline CSS direction/attribute switching** for pieces that aren't full templates — dozens of `isRtl ? ... : ...` inline-style branches (float/padding/text-align) scattered through `lib/template.js` and the report generators.

**RTL detection is a substring test:** `language.indexOf('ar') !== -1`, applied independently in the core report, wellbeing generator, and corporate report generator. In **all three** of those, once `isRtl` is true, **the language is forcibly overwritten to `'ar-SA'`**, regardless of whether the caller actually asked for `ar`, `ar-AE`, or `ar-SA`. Practical effect: **`locales/ar-AE.json` / `locales/wellbeing/ar-AE.json` are functionally unreachable** through those three pipelines — only `sanusx_report_generator.js` skips this coercion (though SanusX has its own, separate RTL problem — see [doc 4.D](04-report-pipelines.md#4d-sanusx-report): it never computes an `isRtl` flag at all and always loads the LTR template).

## 6.5 Fallback logic — two layers that disagree

- **Package layer:** if a requested locale isn't in the configured `locales` list and there's no `fallbacks` entry (there never is, here), `i18n`'s own `setLocale`/`translate` internals force the locale to `defaultLocale` (`'en'`) — the behavior you'd naively expect.
- **`LocaleService` layer:** `setLocale()` only forwards to the package **if** the requested locale is already in `getLocales()`. If it isn't, the package's `setLocale` is **never called at all**, so its own fallback-to-`en` logic never runs — **the previously active locale on the shared singleton simply stays in effect.** Net effect: requesting an unsupported locale does not deterministically fall back to English; it silently reuses whatever locale a prior call last set on that same process-global singleton.
- Separately, the package's `updateFiles: true` default means a **missing translation key** (not a missing locale) causes the literal key string to be auto-persisted back into that locale's JSON file on disk the first time it's looked up, rather than falling back to another locale's text. This is visible today in `locales/wellbeing/en.json`, where a few keys near the bottom (e.g. `bigIntegralSystem`) are stored as bare strings rather than the usual `{message, description}` object — almost certainly auto-written by a prior lookup from the (currently unused) `big_integral_questionnaire.js` locale hook. **If that hook is ever wired up for real, `.message` access on these bare-string entries will return `undefined`, producing blank text in the PDF** — worth fixing (wrap them as proper `{message, description}` objects) before relying on them.

## 6.6 Gotchas for a new engineer

- **Global, non-request-scoped locale state.** `i18n.setLocale(...)` mutates a shared, cached singleton. If two report-generation calls run concurrently in the same Node process (this package does not enforce single-threaded/sequential use), one request's language can leak into another's mid-render. There is no per-request locale scoping in use, though the `i18n` package does support it (passing a `req`/`res`-like context) — it's simply not adopted here. This is the same class of bug the codebase already had to specifically work around for temp file paths in `generateFullPdf` (see [doc 1.7](01-architecture-overview.md#17-filetemp-file-handling--an-evolving-pattern-worth-knowing)) — locale state was not given the same treatment.
- **Orphaned locale files.** `locales/ar.json` exists on disk but is **not** in `app/i18n.config.js`'s `locales` array, and — telling sign — its key set matches the *wellbeing* schema, not the main lab-report schema, suggesting it was dropped in the wrong directory. `locales/wellbeing/it-IT.json` similarly exists but `'it-IT'` is absent from `app/i18n_wellbeing.config.js`'s `locales` array. Neither file is ever loaded.
- **`ar-AE` is effectively dead** in three of the four generators (see 6.4).
- **Dead RTL template:** `templates/sanusx/rtl_no_pages.html` is never read by any code path.
- If you add a new locale, you must add it to **both** the correct `locales` array *and* the `LocaleService`'s implicit gate (it derives from the same array, so this is really one step) — but remember to check whether the RTL-coercion-to-`ar-SA` logic needs updating too if it's a new Arabic variant.
