Files
apf_portal/docs/decisions/0019-internationalisation-angular-localize.md
T
Julien Gautier 161e2ecb1c
CI / scan (pull_request) Successful in 2m26s
CI / check (pull_request) Successful in 2m38s
CI / commits (pull_request) Successful in 2m44s
CI / a11y (pull_request) Successful in 1m17s
CI / perf (pull_request) Successful in 2m17s
docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin
ADR-0019 picks `@angular/localize` in build-time mode with two locales
(`fr` default, `en` source). URLs are always prefixed (`/fr/...`,
`/en/...`); `/` smart-redirects via cookie → Accept-Language → fr.
UI strings live in XLIFF; editorial content (CMS-served) is locked to
the admin-app pipeline. The locale switcher in the footer writes a
`__Host-portal_locale` cookie and hard-refreshes to the matching
bundle. The `@angular/localize` runtime mode, ngx-translate, and
transloco alternatives are recorded as considered-and-rejected.

ADR-0020 splits portal administration into a dedicated Angular SPA
(`portal-admin`) sharing the existing `portal-bff` via `/api/admin/*`
routes guarded by an Entra `admin` role plus fresh-MFA at entry. Same
identity / sessions / audit / observability primitives reused, no
infrastructure duplication. v1 ships four modules: CMS for static
multilingual pages, sidebar menu management (activating the
`requiredPermissions` field already on `MenuItem`), read-only user
list, and an audit log viewer. Bundle budget relaxed to 500 KB gzip
(vs 300 KB on `portal-shell`); same a11y + dark-mode baseline.

Together they answer the two questions raised after the footer
chantier: how the multilingual story works, and where the admin
surface lives. Implementation lands across follow-up feature PRs;
this commit is documentation only.

CLAUDE.md picks up summary entries for both decisions and bumps the
ADR coverage line to 0020.
2026-05-11 11:19:30 +02:00

198 lines
17 KiB
Markdown

---
status: accepted
date: 2026-05-11
decision-makers: R&D Lead
tags: [frontend, accessibility, performance, process]
---
# Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing
## Context and Problem Statement
The portal addresses a primarily French audience (APF France handicap, fédération française) but must also serve English content for international stakeholders, internal staff who prefer English, and the broader accessibility audit ecosystem (WCAG / EN 301 549). The current state ships UI strings hard-coded in English (project rule: "All code, identifiers, comments, ... written in English"), duplicates the accessibility page across two routes (`/accessibility` and `/accessibilite`), and exposes both language labels in the footer. That works as a placeholder; it does not scale.
Two questions need a recorded answer before any new feature wires a string into a template:
1. **Which i18n library / strategy?**`@angular/localize` (Angular-canonical, build-time), the runtime variant of the same, or a community alternative (`@ngx-translate`, `transloco`).
2. **How is the locale carried in URLs and across navigations?** — path prefix (`/fr/dashboard`), query parameter (`?lang=fr`), subdomain (`fr.portal.apf.fr`), or no URL signal (locale held in a cookie only).
This ADR settles both, plus the related questions of source locale, default locale, locale resolution order, and how the locale switcher in the footer interacts with the build-time bundles.
A related concern — **editorial content** localisation (CMS-managed pages, news, etc.) — is **out of scope** for this ADR. Editorial copy is fetched from the BFF already localised per the active locale; that pipeline belongs to [ADR-0020](0020-portal-admin-app.md) (the admin application). This ADR is about **UI strings owned by developers**: button labels, menu titles, error messages, ARIA labels, format strings.
## Decision Drivers
- **First-party, recognised, stable** — per the project tech bar, default to Angular's own i18n module unless an exception is justified. `@angular/localize` is shipped by the Angular team, tracks Angular versions, will not be orphaned.
- **Performance** — ADR-0017 sets Core Web Vitals + Lighthouse ≥ 90 + initial bundle ≤ 300 KB gzip. A build-time strategy (one bundle per locale) avoids shipping translations to the wrong audience and avoids the runtime cost of resolving every `$localize` token on first paint.
- **Accessibility** — `<html lang>` must match the served content (WCAG 3.1.1 "Language of Page"); screen readers and translation tooling rely on it. A URL prefix per locale makes this trivial; a query-param strategy makes the server forget which locale to declare.
- **SEO & shareability** — path-based locale URLs are the documented best practice (Google Search Central, W3C i18n WG). `/fr/...` and `/en/...` are crawled, indexed, and shared without ambiguity.
- **No bricolage** — a runtime locale switcher that hot-swaps strings without a hard reload is appealing but introduces complexity (every text node observes the locale signal); we accept a hard refresh on switch for v1 because it costs no implementation surface and produces the smaller artefact.
## Considered Options
### i18n library
- **`@angular/localize` — build-time mode** _(chosen)_. Strings marked in templates (`i18n` attribute, `<ng-container i18n>...`) and in code (`$localize`). Translation files in XLIFF 1.2 (`.xlf`). One application bundle per locale at build time. Locale-aware tooling (`extract-i18n`, `nx build --localize`) shipped with the framework.
- **`@angular/localize` — runtime mode (`$localize` only, no build-time embed)**. Single bundle ships all locales; the locale is selected at runtime via `loadTranslations()`. Smaller deploy artefact count, higher runtime cost and bigger initial JS payload.
- **`@ngx-translate/core`**. Community library (organisation-maintained since 2024). Runtime translations from JSON files. Mature but smaller team than Angular Core, occasional Angular-version lag.
- **`transloco` (`@jsverse/transloco`)**. Active community library with a modern, Signals-friendly API and lazy translation file loading. Less mainstream than `@angular/localize`.
- **Roll our own** (Signals + a `tr()` function over a JSON map). _Rejected on the tech bar — bricolage._
### URL strategy
- **Path-based with default locale at root: `/dashboard` _(serves `fr`)_, `/en/dashboard`**. Idiomatic for sites with one strongly dominant locale. Asymmetric: removing the prefix means "default locale".
- **Path-based, always-prefixed: `/fr/dashboard`, `/en/dashboard`; `/` redirects to `/fr/`** _(chosen)_. Symmetric. Every URL carries an explicit locale signal. The redirect at `/` uses a small smart-resolver (cookie → `Accept-Language``fr` fallback).
- **Query parameter: `/dashboard?lang=fr`**. Single canonical path. Fragile (a user trimming the query lands on whatever the default is), worse for SEO, and the locale signal is invisible in the address bar at a glance.
- **Subdomain: `fr.portal.apf.fr`, `en.portal.apf.fr`**. Highest isolation. Overkill for two locales, complicates `__Host-` cookie scoping (ADR-0009 / ADR-0010), requires more TLS certificates.
### Source locale
- **English** _(chosen)_. The source code holds English text in `i18n` attributes; the FR translation is a target. Matches the project English-only rule (CLAUDE.md), matches what translators expect.
- **French**. Source is French; English is a target. Would conflict with the project English-only rule for code artefacts.
### Default locale (served at `/`)
- **French** _(chosen)_. APF audience is overwhelmingly French; the redirect at `/` lands users in the locale most of them want first.
- **English**. Lingua franca but mismatched with the audience.
### Locale switcher mechanism
- **Footer link / button that posts the chosen locale to the BFF; the BFF sets `__Host-portal_locale` cookie; client hard-refreshes to the same path under the new prefix** _(chosen)_. Honest about the cost (full reload to swap bundles), the cookie persists the choice across visits, and the resolver at `/` uses the cookie next time.
- **Pure client-side path swap (`router.navigateByUrl('/en' + currentPath)`)**. Equivalent for the user but loses the cookie persistence — next visit the resolver does not know the preference.
- **Hot-swap translations at runtime via `loadTranslations()`**. Only feasible with the runtime mode of `@angular/localize` (rejected above) or `transloco` / `@ngx-translate`. Smoother UX, much higher complexity.
## Decision Outcome
### Library — `@angular/localize`, build-time per-locale bundles
- Strings are marked with the Angular `i18n` template attribute (`<button i18n="@@dashboard.title">Dashboard</button>`) for templates, and `$localize` tagged template strings for code (`throw new Error($localize\`:@@auth.expired:Session has expired\`)`). Every key gets an explicit `@@id` — auto-generated IDs are brittle (they change when the surrounding text changes).
- Translation files live at `apps/portal-shell/src/locale/messages.fr.xlf`. Source language is `en`, target language is `fr`. The English bundle is the source — no translation file, the source strings ship as-is. The extraction target (`nx extract-i18n portal-shell`) produces `messages.xlf` (source-only); we maintain `messages.fr.xlf` by hand-merging extractor output into the existing translations.
- Build target gains a `localize` configuration: `nx build portal-shell --localize` produces `dist/apps/portal-shell/{fr,en}/...` in one pass. The dev server (`nx serve portal-shell`) defaults to French; `--configuration=en` flips to English.
- Production deploy serves both locale folders behind one origin; the reverse proxy (or the BFF for SPA pass-through) routes `/fr/*` and `/en/*` to the matching folder.
### URL strategy — path-based, always prefixed, `/` smart-redirects
- Every route in the app sits under `/fr/...` or `/en/...`. The Angular routes themselves are locale-agnostic (`/dashboard`, `/accessibility-statement`, ...); the locale prefix is injected by the build-time `baseHref` plus the SPA's `provideRouter({ baseHref: '/fr/' })` (set per the active build locale).
- The bare path `/` redirects via a small smart resolver, executed at the reverse-proxy / BFF level:
1. If the `__Host-portal_locale` cookie is set and matches a supported locale, redirect to that prefix.
2. Else, parse `Accept-Language` and pick the highest-q match among `{fr, en}`.
3. Else, redirect to `/fr/`.
- Direct paths missing a locale prefix (e.g. someone shares `/dashboard`) hit the same resolver and get redirected to the prefixed equivalent under the resolved locale.
- `<html lang="fr">` (or `en`) is set at build time from the active locale; no JavaScript fiddling at runtime.
### Source = `en`, default served = `fr`
- The source locale is English. All `i18n` attribute texts in templates, all `$localize` template literal payloads, are English. This matches the project English-only rule.
- The locale served at the root URL is French. The redirect lands the user there unless their cookie or `Accept-Language` says otherwise.
### Locale switcher — footer dropdown, BFF cookie, hard refresh
- The footer (per the previous chantier) gets a small locale switcher next to the accessibility links. UI: a `[cdkMenuTriggerFor]` button labelled with the active locale's name, opening a menu with the two options ("Français", "English"). The same accessible CDK menu pattern as the theme switcher (ADR-0016 derivative).
- Clicking an option `POST`s `{ locale: 'fr' | 'en' }` to `/api/preferences/locale`. The BFF sets `__Host-portal_locale` (`Secure`, `HttpOnly`, `SameSite=Lax`, scoped to `/`) and returns 204. The client then `window.location.assign('/en' + currentPathWithoutLocalePrefix)` (or the FR equivalent) — a hard refresh that boots the right bundle.
- We accept the hard refresh as the v1 cost. Runtime hot-swap is a v2 ADR if user research surfaces friction; for now the gain (no runtime locale state, no per-text observer) is worth the per-switch reload.
### Routing migration
- The current `/accessibility` and `/accessibilite` duplicate routes collapse to a **single localised route**. The Angular route is `'/accessibility-statement'`; the displayed URL is `/fr/declaration-d-accessibilite` (translated via Angular's i18n route paths feature) or `/en/accessibility-statement`. Both old routes 301-redirect to the localised version.
- All future routes use a single Angular path; the build pipeline emits the locale-specific URL per the i18n route-path translation file.
### Confirmation
**Wired across a sequence of PRs:**
1. Install `@angular/localize`, add the `localize` polyfill to `polyfills.ts`, configure the build target (this ADR's accompanying PR or the next one).
2. First sweep: mark every existing UI string in `portal-shell` with an `i18n` attribute + explicit `@@id`; run extraction; produce `messages.fr.xlf` with translations of the current copy (≤ 30 strings today).
3. Locale switcher in the footer + BFF route `/api/preferences/locale` + cookie + smart redirect at `/`.
4. Collapse `/accessibility` + `/accessibilite` into the single localised route, with 301s.
5. Lint rule (`@angular-eslint/template/no-positive-tabindex` analogue, custom) to flag template strings without an `i18n` attribute — wired only after sweep #2 to avoid mass lint debt.
**CI gate:** the build script `pnpm exec nx build portal-shell --localize` is added to `ci:check`. If a string is missing a translation in `messages.fr.xlf`, the build fails with the missing-translation list. This catches "I added a label and forgot to translate it" at the PR stage rather than at deploy.
**Lighthouse bench:** the localised production builds are exercised by the existing `ci:perf` Lighthouse CI (ADR-0017) on `/fr/` (default) and `/en/`. Both must stay ≥ 90 Performance.
### Consequences
- Good, because the i18n primitive is first-party, recognised, and aligned with the project tech bar. Future Angular upgrades carry it.
- Good, because build-time bundles ship only the strings the user actually sees — smallest possible payload per visit, best Core Web Vitals.
- Good, because the URL strategy makes the locale signal explicit (SEO, sharing, accessibility — `<html lang>` is correct by construction).
- Good, because the source locale is English — matches the project rule and the global i18n convention (translators translate _from_ English).
- Good, because the accessibility statement collapses to one route — a single source of truth instead of two manually-kept-in-sync templates.
- Bad, because every build now produces N bundles (N = locale count). Bounded — we ship two for the foreseeable future. The CI build wall-time grows linearly with locale count; acceptable while N ≤ 4.
- Bad, because adding a new locale requires a code-level change (new translation file, build target, deploy route) rather than a configuration toggle. Acceptable trade-off for the runtime perf gain. Editorial content (CMS-driven, ADR-0020) does not have this constraint — adding a CMS locale is a backend operation.
- Bad, because the locale switch costs a hard refresh. Mitigated by the rarity of the action (users typically pick a locale on first visit and stay there) and by the SPA's fast cold-start budget (initial bundle ≤ 300 KB gzip, LCP ≤ 2.5 s).
- Neutral, because translators see XLIFF 1.2 — the industry standard for CAT tools (memoQ, Trados, Crowdin, Lokalise). Good for handoff to a professional translator if needed; less ergonomic than JSON for a developer-managed file. Acceptable: the file is small.
### Confirmation (continued)
A future ADR may revisit this decision if:
- Runtime locale hot-swap becomes a usability requirement that user research confirms is worth the implementation cost.
- A third locale (Spanish, German, Arabic) is requested — at that point the build-time-per-locale cost grows enough to reconsider the runtime alternatives, AND RTL support (Arabic) needs first-class treatment.
- The number of UI strings grows to the point where hand-maintained XLF becomes a maintenance burden — at which point we plug in a translation management platform (Crowdin / Lokalise) over the same XLF artefacts; no ADR change.
## Pros and Cons of the Options
### Library
#### `@angular/localize` build-time (chosen)
- Good, because first-party, supported as long as Angular itself is.
- Good, because zero runtime cost — strings are literals in the produced bundle.
- Good, because tooling (`extract-i18n`) is integrated into the Angular CLI.
- Bad, because one bundle per locale (N artefacts). Bounded.
- Bad, because adding a locale requires a rebuild rather than a config change. Bounded for our locale count.
#### `@angular/localize` runtime
- Good, because single artefact across locales — single deploy.
- Good, because runtime locale switch is possible without a page reload.
- Bad, because all translations ship to every user — bigger initial payload, worse mobile-3G LCP.
- Bad, because the `$localize` runtime resolver runs on every translated node — measurable on first render of a complex view.
#### `@ngx-translate/core`
- Good, because lazy-loaded JSON files (load on demand per feature module).
- Good, because runtime switching with no reload.
- Bad, because community-maintained — Angular version compatibility has occasionally lagged by weeks.
- Bad, because non-canonical for Angular — every contributor must learn its API on top of Angular's.
#### `transloco`
- Good, because modern, Signals-friendly, actively maintained.
- Good, because lazy-loadable, scope-based.
- Bad, because community library — tech-bar threshold is "recognised + battle-tested"; transloco is recognised but not at the Angular-team level.
- Bad, because we'd carry a non-canonical i18n abstraction across the codebase forever.
### URL strategy
#### Path-based, always prefixed (chosen)
- Good, because explicit, SEO-canonical, easy to reason about.
- Good, because `<html lang>` is correct by construction.
- Good, because cacheable per locale at the proxy / CDN level.
- Bad, because every URL is slightly longer.
#### Path-based with default at root
- Good, because shorter URLs for the dominant locale.
- Bad, because asymmetric — the absence of a prefix is itself a signal, which is harder to teach contributors than "every URL has a locale prefix".
- Bad, because URL surgery on locale switch is more complex (sometimes strip, sometimes add a prefix).
#### Query parameter
- Bad, because trivial to lose; fragile.
- Bad, because SEO crawlers index multiple URLs as one with parameter variants.
#### Subdomain
- Good, because hard isolation per locale.
- Bad, because `__Host-` cookie scoping breaks (the cookie is host-bound). Sessions ADR-0010 explicitly uses `__Host-` for the cookie security guarantees.
- Bad, because every locale needs a TLS certificate.
## More Information
- Angular i18n docs: https://angular.dev/guide/i18n
- Google Search Central — Multi-regional and multilingual sites: https://developers.google.com/search/docs/specialty/international
- W3C i18n — Choosing a language strategy: https://www.w3.org/International/questions/qa-when-xmllang.en
- Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (frontend stack), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (WCAG 3.1.1 Language of Page), [ADR-0017](0017-performance-budgets-lighthouse-ci.md) (Lighthouse gate), [ADR-0020](0020-portal-admin-app.md) (admin app — editorial content localisation).