161e2ecb1c
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.
198 lines
17 KiB
Markdown
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).
|