feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
## 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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user