# Internationalization (i18n) in the renderer

Status: **first slice, adapted to maintainer review** — see issue [#1996](https://github.com/aehrc/smart-forms/issues/1996) (motivation and review decision) and RFC PR [#1995](https://github.com/aehrc/smart-forms/pull/1995) (discussion). Per the maintainers' decision in #1996, the renderer ships **no translation catalogs**: consuming apps own their translations and inject them via the renderer config. English defaults remain built in.

## Goal

Let a consuming app localize the renderer's **own** output (labels, messages, date formatting) per language/region, opt-in and backward-compatible. English / `DD/MM/YYYY` remain the default when nothing is configured.

## Two layers of i18n

There are two distinct concerns. They have different sources of truth and are solved differently.

| Layer                        | What                                                                                            | Source of truth              | Mechanism                                                              |
| ---------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------- |
| **1. Renderer chrome**       | Text the renderer owns: Yes/No, validation messages, buttons, aria-labels; plus date formatting | The renderer's code          | A consumer-injected string catalog + `Intl` for dates                  |
| **2. Questionnaire content** | `item.text`, `answerOption.display`, group/questionnaire titles                                 | The `Questionnaire` resource | FHIR `Questionnaire.language` + the `translation` extension on `_text` |

**This slice implements Layer 1 only** (and only a first set of strings). Layer 2 is a separate, larger follow-up.

## Architecture (Layer 1)

Everything is configured through the existing `rendererConfigStore`, via two new fields on `RendererConfig`:

- `locale?: string` — a BCP-47 tag (e.g. `'de-CH'`). Drives date formatting and calendar localisation **only**; it does not select strings.
- `rendererStrings?: Partial<RendererStrings>` — the consumer-supplied translation catalog, merged on top of the English defaults.

Set via `buildForm({ rendererConfigOptions: { locale, rendererStrings } })`.

### String catalog

- `RendererStrings` (`src/i18n/rendererStrings.ts`) — the typed contract: every renderer-owned string is a key here.
- `defaultRendererStrings` — English defaults; the base every override merges onto. Must contain every key.
- `resolveRendererStrings(overrides)` — merge order: English defaults ← consumer-supplied catalog. Missing keys fall back to English; never throws.
- `interpolate(template, params)` — replaces `{token}` placeholders at runtime (for messages with dynamic values).

The renderer bundles **no translations** (decision in [#1996](https://github.com/aehrc/smart-forms/issues/1996): the repo should not host/maintain catalogs it cannot natively review). A consuming app authors its own translation files (e.g. per-locale JSON in its deployment), loads them however it likes, and passes a plain `Partial<RendererStrings>` — see the `BooleanLocaleDeCH` story for the reference pattern.

Consumed in components via `useRendererConfigStore.use.rendererStrings()`.

### Dates

The date format is **derived from the locale**, not stored per locale.

- `resolveDateFormat(locale, override)` (`.../DateTimeItems/utils/parseDate.ts`):
  1. explicit `dateFormat` override, else
  2. derived from the locale via `Intl.DateTimeFormat().formatToParts()` (all locales, no bundled data: `de-CH`→`DD.MM.YYYY`, `en-US`→`MM/DD/YYYY`, `ja-JP`→`YYYY/MM/DD`), else
  3. `DD/MM/YYYY` fallback.
- Step 2 only accepts a locale format whose tokens are joined by a **single repeated punctuation character**, since the input handling assumes one separator. Locales whose short date has no separator, mixed separators or extra literals (`hu-HU`/`ko-KR` → `YYYY. MM. DD.`) fall through to the `DD/MM/YYYY` fallback rather than yielding a format no typed date could match.
- `useDateFormat()` — reactive hook wrapping the resolver for components.
- Validation (`validateThreeMatches`, `validateTwoMatches`, `useDateValidation`) is **order-aware**: positional input parts are mapped to day/month/year using the format's token order, so day-first, month-first (US) and year-first all validate correctly.
- Partial FHIR dates follow the same order: `getMonthYearFormat` keeps the full date's token order, so `ja-JP` displays and accepts `2024/03` (not `03/2024`) for `2024-03`.
- Validation error messages are catalog templates with `{format}` / `{separator}` / `{monthYearFormat}` placeholders, interpolated at runtime.
- The renderer keeps its **custom parser** (rather than `Intl`/MUI-native fields) because it supports FHIR **partial dates** (`YYYY`, `YYYY-MM`).

### Calendar popup

The date-picker calendar's month/weekday names come from `dayjs` locale data. The renderer bundles none of it: a consuming app imports the matching `dayjs/locale/<tag>` itself (e.g. `import 'dayjs/locale/de-ch'`), and the renderer passes `adapterLocale` (derived from `locale`) to `LocalizationProvider`. If the locale data is not imported, dayjs silently falls back to English names. The **field format needs no dayjs locale import** (that's Intl-derived). Note the app's `dayjs` must resolve to the same module instance as the renderer's for the locale registration to be visible — standard npm deduplication ensures this.

## Usage (consumers)

```ts
// The app owns its translation files and injects them; locale drives date handling.
import deCHStrings from './locales/renderer/de-CH.json'; // app-owned translation file
import 'dayjs/locale/de-ch'; // app-owned calendar localisation

buildForm({
  questionnaire,
  rendererConfigOptions: {
    locale: 'de-CH', // → dates DD.MM.YYYY (Intl-derived), German calendar popup
    rendererStrings: deCHStrings // → Ja / Nein, German validation errors, ...
  }
});

// Override individual strings / the date format, independent of locale
buildForm({
  questionnaire,
  rendererConfigOptions: {
    rendererStrings: { booleanYesLabel: 'Oui', dateFormat: 'MM/DD/YYYY' }
  }
});
```

The library imposes no i18n framework: an app already using i18next/FormatJS/etc. loads its own strings however it likes and passes a plain `Partial<RendererStrings>` object. The Swiss Storybook stories (`BooleanLocaleDeCH`, `DateLocaleDeCH`) demonstrate the pattern end-to-end.

## Contributing

**Localise the renderer in your app**

1. Author a translation file in your deployment (e.g. `de-CH.json`) with the `RendererStrings` keys you want to translate (only those differing from English).
2. Pass it via `buildForm({ rendererConfigOptions: { locale, rendererStrings } })`.
3. (Optional) import `dayjs/locale/<tag>` in your app for calendar localisation.

**Add a new renderer string**

1. Add the key to the `RendererStrings` interface with a doc comment.
2. Add the English value to `defaultRendererStrings`.
3. Replace the hardcoded literal in the component/hook with a catalog lookup (`interpolate(...)` if it has placeholders).

New keys are automatically backward-compatible for consumers: any key missing from an injected catalog falls back to its English default.

## Key design decisions (reasoning)

- **Config-injected catalog, not an i18n framework** — the renderer is a published library; a framework would force peer deps/a provider/a workflow on every consumer. English defaults + object injection keeps the common case zero-setup.
- **No bundled translations** ([#1996](https://github.com/aehrc/smart-forms/issues/1996) review decision) — hosting catalogs in the repo would imply maintaining and vouching for translations the team cannot natively review. Consumers own their translation files; the injection point makes this a one-liner.
- **`locale` for dates only** — string selection is fully in consumer hands; `locale` remains so date format derivation (`Intl`) and the calendar popup work without the consumer restating them.
- **Dates via `Intl`, not hardcoded/per-locale imports** — all locales for free, no bundled data, no import list; explicit override remains.
- **Order-aware validation** — prerequisite for locale-driven formats (US month-first, etc.).
- **Interpolated messages** — translations stay natural while dynamic values are injected.

## Next steps

- [ ] Layer 2: resolve `Questionnaire`-sourced text (`item.text`, `answerOption.display`) against `Questionnaire.language` + `_text` translation extensions.

## File map

- `src/i18n/rendererStrings.ts` — interface, English defaults, resolver, `interpolate`
- `src/i18n/index.ts` — public exports
- `src/hooks/useDateFormat.ts` — reactive date-format hook
- `src/hooks/useDateValidation.tsx` — locale-aware, localized validation
- `src/components/FormComponents/DateTimeItems/utils/parseDate.ts` — format resolution/derivation, order-aware parsing/validation
- `src/components/FormComponents/BooleanItem/BooleanField.tsx` — catalog-driven Yes/No
- `src/stores/rendererConfigStore.ts` — `locale` + `rendererStrings` config
- `src/stories/itemTypes/{Boolean,Date}.stories.tsx` — consumer localisation pattern (inline catalog injection, consumer dayjs locale import)
- `src/test/{rendererStrings,parseDateFormat}.test.ts` — catalog + date tests
