Files
apf_portal/docs/decisions/0019-internationalisation-angular-localize.md
julien c5e91f240b
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m22s
CI / check (push) Successful in 2m36s
CI / a11y (push) Successful in 53s
CI / perf (push) Successful in 3m25s
docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin (#89)
## Summary

Pure documentation PR. Two ADRs that answer the two strategic questions raised after the footer chantier:

- **[ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md)** — how the portal handles multiple languages.
- **[ADR-0020](docs/decisions/0020-portal-admin-app.md)** — where portal administration lives.

Implementation will land across follow-up feature PRs, each consumable on its own.

## ADR-0019 — Internationalisation

**Decision:** `@angular/localize` in **build-time** mode, two locales (`fr` default served at `/`, `en` source). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. The locale switcher in the footer writes a `__Host-portal_locale` cookie and hard-refreshes to the matching bundle.

**Considered and rejected:**

- `@angular/localize` runtime mode (single bundle, higher LCP / payload cost).
- `@ngx-translate` / `transloco` (community libraries; tech-bar prefers Angular first-party for foundational primitives).
- Query-param URL strategy (fragile, weaker SEO, `<html lang>` becomes harder).
- Subdomain URL strategy (breaks `__Host-` cookie scoping from ADR-0010).

**Scope boundary:** UI strings owned by developers (templates + `$localize` in code). Editorial content (CMS-managed pages, news, etc.) is BFF-served already localised — that's the admin-app pipeline (ADR-0020), not `@angular/localize`.

**First sweep consequence:** the duplicate `/accessibility` + `/accessibilite` routes collapse to one Angular route with locale-translated paths.

## ADR-0020 — `portal-admin`

**Decision:** new Angular SPA `portal-admin` alongside `portal-shell`, sharing the existing `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role plus `@RequireMfa({ freshness: 600 })` at the entry route. Distinct origin + cookie + session (`__Host-portal_admin_session`).

**v1 modules** (all four selected):

1. Editorial pages CMS (multilingual content, fed back to `portal-shell` via the BFF).
2. Sidebar menu management (activates the `requiredPermissions` field already on `MenuItem`).
3. User list (read-only).
4. Audit log viewer (consumes the `audit.events` table per ADR-0013, via the `audit_reader` role).

**Out of v1:** B2B invitations (stay in Entra Admin Center), feature flags (no substrate yet), CMS workflow / approval flows, theme customisation, live preview.

**Considered and rejected:**

- `/admin/*` lazy-loaded inside `portal-shell` (admin code in the same origin → weaker defense in depth, admin URL not IP-restrictable independently).
- Two SPAs **and** two BFFs (doubles infra at our scale — bricolage).
- Off-the-shelf admin tooling (Retool, etc. — escapes our security baseline).

**Performance budget for admin:** ≤ 500 KB gzip initial (vs 300 KB for `portal-shell`, per ADR-0017). Lighthouse Performance ≥ 85 on critical admin routes (vs ≥ 90 on `portal-shell`). Same a11y baseline (ADR-0016), same dark-mode support.

**Shared-libs graduation:** `Icon`, `LayoutStateService`, brand tokens, dark-mode SCSS helpers move from `portal-shell` to `libs/shared/{ui,state}` when both apps need them. Mechanical refactor; tracked as the first implementation PR.

## Implementation roadmap (out of scope of this PR)

ADR-0019:

1. Install `@angular/localize`, wire build target.
2. Mark every existing UI string in `portal-shell` with `i18n` + `@@id`; produce `messages.fr.xlf`.
3. Locale switcher in footer + `/api/preferences/locale` BFF route + smart redirect at `/`.
4. Collapse the duplicate accessibility routes into a localised single route, with 301s.
5. CI gate: `nx build portal-shell --localize` is added to `ci:check` and fails on missing translation.

ADR-0020:

1. `nx g @nx/angular:app portal-admin` skeleton.
2. Shared-libs extraction (`libs/shared/ui`, `libs/shared/state`).
3. BFF `AdminModule` + `AdminRoleGuard` + smoke `GET /api/admin/me`.
4. Admin shell (header / sidebar / footer with an "Admin" badge).
5. One PR per v1 module — suggested order: CMS pages → menu management → audit viewer → user list.

## Test plan

- [x] Both ADRs follow MADR 4.0.0 (frontmatter, sections, tags from the canonical vocabulary).
- [x] `docs/decisions/README.md` index updated in the same commit.
- [x] `CLAUDE.md` architecture summary picks up entries for both decisions and bumps the ADR coverage line to 0020.
- [ ] Read-through review — invite the project lead to push back on any decision before locking implementation.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #89
2026-05-11 12:29:54 +02:00

17 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-11 R&D Lead
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 (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-Languagefr 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.
  • 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 POSTs { 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