feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
CI / check (push) Successful in 3m20s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 1m37s
CI / perf (push) Successful in 3m11s

## Summary

Continue ADR-0019 implementation. Mark every UI string surfaced by the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript), and populate [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) with French translations. The production build now ships **two genuinely different bundles** under `dist/.../{en,fr}/`.

**43 trans-units** marked, grouped by feature:

| Surface | Strings |
|---|---|
| `app.html` | skip-link |
| `header.html` | wordmark, search label + placeholder, action button aria-labels, user-menu placeholder |
| `sidebar.ts` + `.html` | menu groups + items, aside / nav aria-labels, role badge, toggle button (Expand / Collapse aria + Collapse text) |
| `theme-switcher.ts` + `.html` | mode labels, menu aria, trigger aria (`Theme: <mode> (open menu)` with named placeholder) |
| `footer.html` | aria-labels, copyright (interpolation preserved) |
| `home.html` | welcome heading + intro + status widget labels |
| `app.routes.ts` | browser tab titles |

## Tooling

- Add `"@angular/localize"` to the `types` array in both [`tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`tsconfig.spec.json`](apps/portal-shell/tsconfig.spec.json) so TypeScript resolves the `$localize` global at compile time. Specs need it too — they evaluate the same component code paths.
- Extraction target (`nx run portal-shell:extract-i18n`) reports **44 messages** (43 unique IDs).

## Translation choices worth flagging

- **Wordmark**: "APF Portal" → "Portail APF". Same key used for the `/` browser tab title. The PWA manifest (`site.webmanifest`) stays "APF Portal" — manifest values are not bundled, they sit in the static assets and are language-neutral in v1.
- **System theme mode** → "Système".
- **"Anonymous"** role → "Anonyme"; **"Role:"** → **"Rôle :"** (French uses a non-breaking space before the colon — typographic convention, preserved in the XLIFF target).
- **Accessibility links in the footer stay bilingual.** Each carries its own `lang` attribute (`lang="en"` and `lang="fr"`). The dual-link pattern goes away in the upcoming route-fusion PR; until then it's the most honest stopgap.
- **Both accessibility routes share one title key** (`@@route.accessibility.title`). In the EN bundle, both display "Accessibility statement · APF Portal"; in the FR bundle, both display "Déclaration d'accessibilité · Portail APF". After route fusion only one route remains.

## Verification

- Production build: **129 kB gzip initial per locale** (vs 122 kB before). +7 kB absorbs the i18n marker metadata and the embedded translation data in the FR bundle. Well under the 300 KB budget.
- Spot-checked the FR bundle: no leftover English source text on any swept string, route title, or home page intro. The `Welcome to APF Portal` in the lazy `home` chunk shows "Bienvenue sur Portail APF" in FR.
- **36 / 36 specs unchanged.** They run in the source locale (`en`), so the English assertions still match. No spec edits needed.
- Lint clean.

## Out of scope (each its own follow-up PR)

- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect.
- **Collapse `/accessibility` + `/accessibilite`** into one localised route with locale-translated path segments.
- **CI gate** that fails the build on a missing translation. Once the sweep is reviewed, we add `nx build --localize` to `ci:check` and verify it rejects unsealed strings.
- **Accessibility page content localisation.** Its content is driven by a `copy()` service rather than i18n-marked templates — restructured during the route fusion.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs).
- [x] `pnpm exec nx run portal-shell:extract-i18n` — clean, 43 unique unit IDs.
- [x] Production build emits both locales; FR bundle contains "Tableau de bord", "Aller au contenu principal", etc.
- [ ] Manual: `pnpm exec nx serve portal-shell` shows the EN source. `pnpm exec nx build portal-shell --configuration=production && pnpm exec nx run portal-shell:serve-static` → open `http://localhost:4200/fr/` for FR, `/en/` for EN.
- [ ] Manual: every aria-label announced by a screen reader in FR build matches the French translation.
- [ ] Manual: browser tab title flips between bundles ("APF Portal" vs "Portail APF").

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
2026-05-11 19:54:43 +02:00
parent 04675b1b59
commit 65fae7f963
12 changed files with 312 additions and 56 deletions
@@ -1,9 +1,14 @@
<aside
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
[class.sidebar--collapsed]="collapsed()"
[attr.aria-label]="'Sidebar navigation'"
i18n-aria-label="@@sidebar.aria.label"
aria-label="Sidebar navigation"
>
<nav aria-label="Sections" class="flex-1 overflow-y-auto px-2 py-4">
<nav
i18n-aria-label="@@sidebar.aria.sections"
aria-label="Sections"
class="flex-1 overflow-y-auto px-2 py-4"
>
@for (group of menu; track group.label) {
<div class="mb-4">
@if (!collapsed()) {
@@ -55,10 +60,13 @@
@if (!collapsed()) {
<p
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
i18n-aria-label="@@sidebar.role.aria"
aria-label="Current role: anonymous (signed-out)"
>
Role:
<span class="font-medium text-gray-700 dark:text-gray-200">Anonymous</span>
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
<span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
>Anonymous</span
>
</p>
}
@@ -67,13 +75,11 @@
(click)="toggle()"
class="sidebar__toggle"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
collapsed() ? 'Expand sidebar' : 'Collapse sidebar'
"
[attr.aria-label]="toggleAriaLabel()"
>
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
@if (!collapsed()) {
<span class="sidebar__label">Collapse</span>
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
}
</button>
</div>
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService } from '../../state/layout-state.service';
@@ -21,6 +21,11 @@ import { LayoutStateService } from '../../state/layout-state.service';
* plug in a filter without restructuring the data. The Dashboard
* entry uses `routerLink="/"` so the active-state styling is visible
* on the home page; the rest are anchors until real routes exist.
*
* String localisation: the menu data uses `$localize` tagged template
* literals (per ADR-0019). The literals evaluate at module load — the
* `@angular/localize/init` polyfill is loaded before the bundle so
* the active-locale value is resolved by the time MENU is built.
*/
export interface MenuItem {
@@ -40,24 +45,28 @@ export interface MenuGroup {
const MENU: readonly MenuGroup[] = [
{
label: 'General',
label: $localize`:@@sidebar.group.general:General`,
items: [
{ label: 'Dashboard', icon: 'layout-dashboard', routerLink: '/' },
{ label: 'Calendar', icon: 'calendar', href: '#' },
{
label: $localize`:@@sidebar.item.dashboard:Dashboard`,
icon: 'layout-dashboard',
routerLink: '/',
},
{ label: $localize`:@@sidebar.item.calendar:Calendar`, icon: 'calendar', href: '#' },
],
},
{
label: 'Establishments',
label: $localize`:@@sidebar.group.establishments:Establishments`,
items: [
{ label: 'Directory', icon: 'building-2', href: '#' },
{ label: 'Activities', icon: 'folder', href: '#' },
{ label: $localize`:@@sidebar.item.directory:Directory`, icon: 'building-2', href: '#' },
{ label: $localize`:@@sidebar.item.activities:Activities`, icon: 'folder', href: '#' },
],
},
{
label: 'Communication',
label: $localize`:@@sidebar.group.communication:Communication`,
items: [
{ label: 'Messages', icon: 'mail', href: '#' },
{ label: 'Documents', icon: 'file-text', href: '#' },
{ label: $localize`:@@sidebar.item.messages:Messages`, icon: 'mail', href: '#' },
{ label: $localize`:@@sidebar.item.documents:Documents`, icon: 'file-text', href: '#' },
],
},
];
@@ -75,6 +84,12 @@ export class Sidebar {
protected readonly menu = MENU;
protected readonly collapsed = this.layout.sidebarCollapsed;
protected readonly toggleAriaLabel = computed(() =>
this.collapsed()
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
: $localize`:@@sidebar.toggle.aria.collapse:Collapse sidebar`,
);
protected toggle(): void {
this.layout.toggleSidebar();
}