import { Meta } from '@storybook/addon-docs/blocks'; # Internationalization (the locale seam) Every user-visible string goes through Angular's first-party **`$localize`** — no third-party i18n library. The source locale is **`nl`**; a second locale is a **translation file, not a code change**. That's the seam: adding English touched `src/locale/messages.en.xlf`, not the components. ## How it's wired | Piece | Where | What | | -------------------------- | ----------------------------------------- | ------------------------------------------------------------ | | Source locale | `angular.json` → `i18n.sourceLocale` | `nl` — the language the code is written in | | Locales | `angular.json` → `i18n.locales.en` | points at `src/locale/messages.en.xlf` | | Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `` fails the build | | Runtime global | `angular.json` → `polyfills` | `@angular/localize/init` provides `$localize` | | English build/serve | `angular.json` → `configurations.en` | `ng build --configuration=en`, `ng serve --configuration=en` | Locale switching is **build-time**, not runtime: each locale is its own bundle. There is no in-app language picker (out of scope for the POC). ## Authoring copy Two forms, same custom-id rule. The id is **stable** and shaped `@@.`, so translations survive copy edits. **In TS logic / value objects — tagged template:** ```ts // src/app/registratie/domain/value-objects/postcode.ts return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`); ``` With placeholders (named, so translators can reorder): ```ts $localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`; ``` **In inline component templates — the `i18n` attribute:** ```html Inloggen met DigiD ``` **Shared/English components never hardcode Dutch.** They expose copy as `input()`s with localizable defaults; the domain caller may override. See `shared/ui/async/async.component.ts`: ```ts errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`); ``` ## Extract & translate loop ```bash npm run extract-i18n # ng extract-i18n → src/locale/messages.xlf (source, nl) ``` Then a translator fills ``s in `src/locale/messages.en.xlf`. Both files carry the same trans-units (currently 690 = 690, no drift); the `.en.xlf` header is `source-language="nl" target-language="en"`. Because `i18nMissingTranslation: error`, a forgotten target breaks the `en` build rather than silently shipping Dutch. ## Testing languages without coupling to the strings **Rule: never assert on rendered copy.** Copy is the thing that changes per locale and per edit — a test that reads `"Voer een geldige postcode in"` breaks the moment a translator or a product owner touches the wording, in every locale. Assert on what's _invariant_ instead: - **Parsers / value objects** — assert on the `Result` discriminant and the parsed value, not the error message. This is the existing house pattern (`registratie/domain/value-objects/postcode.spec.ts`): ```ts expect(parsePostcode('0234AB').ok).toBe(false); // rejects — never inspects the $localize string ``` - **The seam itself** — if you must verify that translation works, check that a known **id flips**, not that a specific phrase appears. Build/serve the `en` configuration and confirm the target for a stable id renders, e.g. `login.submit`: `nl` "Inloggen met DigiD" → `en` "Log in with DigiD". You're testing the wiring, not the wording. ```bash ng serve --configuration=en # then eyeball, or point an e2e at the en bundle ``` See [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for how this fits the rest of the test pyramid.