Step 2 (i18n): $localize sweep + JA_NEE dedup (M3, M4)

Wrap every user-facing Dutch string in Angular's first-party i18n — `i18n`/
`i18n-<attr>` in templates, `$localize` in TS (value-objects, machines, commands,
label constants, shared-component defaults). Source locale stays nl; a second
locale is now a translation file, not a code change.

- M3: ~145 strings localized with stable @@ ids across registratie,
  herregistratie, auth, shared/ui, shared/layout. Skipped: showcase, debug-state,
  scenario interceptor, generated client, specs/stories, raw status enum tags,
  internal parse* diagnostics.
- M4: single shared JA_NEE (localized labels) in radio-group; both wizard copies
  removed.

Gate green: lint, check:tokens, build, test 77/77.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 14:00:10 +02:00
parent 1c65025fef
commit 474c040410
36 changed files with 193 additions and 181 deletions

View File

@@ -15,6 +15,6 @@ export async function runSubmit<T>(fn: () => Promise<T>, fallback: string): Prom
}
}
// ponytail: i18n in Step 3/M3 wraps this in $localize with a stable @@id, which
// dedupes it at the translation layer; for now it's the single shared default.
export const SUBMIT_FAILED = 'Het indienen is niet gelukt. Probeer het later opnieuw.';
// Single shared default for a failed submit; the @@id dedupes it at the
// translation layer.
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;

View File

@@ -6,12 +6,12 @@ import { BreadcrumbItem } from './breadcrumb.component';
interface Crumb { label: string; parent?: string }
const ROUTES: Record<string, Crumb> = {
'/dashboard': { label: 'Mijn overzicht' },
'/registratie': { label: 'Mijn gegevens', parent: '/dashboard' },
'/registreren': { label: 'Inschrijven', parent: '/dashboard' },
'/herregistratie': { label: 'Herregistratie', parent: '/dashboard' },
'/intake': { label: 'Herregistratie-intake', parent: '/dashboard' },
'/concepts': { label: 'Functionele patronen', parent: '/dashboard' },
'/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` },
'/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },
'/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },
'/herregistratie': { label: $localize`:@@crumb.herregistratie:Herregistratie`, parent: '/dashboard' },
'/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },
'/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' },
};
/** Build the breadcrumb trail for a router url (query/fragment stripped).

View File

@@ -26,7 +26,7 @@ export interface BreadcrumbItem {
:host(.inverse) .sep { color: var(--rhc-color-foreground-on-primary); }
`],
template: `
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
<ol class="utrecht-breadcrumb-nav__list list">
@for (item of items(); track item.label; let last = $last; let first = $first) {
<li class="utrecht-breadcrumb-nav__item crumb">

View File

@@ -32,6 +32,6 @@ export class PageShellComponent {
heading = input.required<string>();
intro = input<string>();
backLink = input<string>();
backLabel = input('Terug naar overzicht');
backLabel = input($localize`:@@pageShell.backLabel:Terug naar overzicht`);
width = input<'default' | 'narrow'>('default');
}

View File

@@ -17,7 +17,7 @@ import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.componen
.content{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box}
`],
template: `
<a href="#main" class="rhc-skip-link skip">Naar de inhoud</a>
<a href="#main" class="rhc-skip-link skip" i18n="@@shell.skipLink">Naar de inhoud</a>
<div class="utrecht-page-layout utrecht-page-layout--stretch layout">
<app-site-header />
<main id="main" class="utrecht-page-content main">

View File

@@ -46,5 +46,5 @@ export interface NavItem {
})
export class SideNavComponent {
items = input.required<NavItem[]>();
ariaLabel = input('Mijn omgeving');
ariaLabel = input($localize`:@@sideNav.aria:Mijn omgeving`);
}

View File

@@ -22,18 +22,18 @@ import { Component } from '@angular/core';
<footer class="utrecht-page-footer bar">
<div class="inner">
<div>
<div class="tagline">De Rijksoverheid. Voor Nederland.</div>
<div class="ministry">CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</div>
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>
<div class="ministry" i18n="@@footer.ministry">CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</div>
</div>
<nav class="col" aria-label="Over deze site">
<h2>Over deze site</h2>
<nav class="col" i18n-aria-label="@@footer.overSiteAria" aria-label="Over deze site">
<h2 i18n="@@footer.overSite">Over deze site</h2>
<ul class="links">
<li><a href="https://www.rijksoverheid.nl/privacy" rel="noopener" target="_blank">Privacy</a></li>
<li><a href="https://www.rijksoverheid.nl/cookies" rel="noopener" target="_blank">Cookies</a></li>
<li><a href="https://www.rijksoverheid.nl/toegankelijkheid" rel="noopener" target="_blank">Toegankelijkheid</a></li>
<li><a href="https://www.rijksoverheid.nl/privacy" rel="noopener" target="_blank" i18n="@@footer.privacy">Privacy</a></li>
<li><a href="https://www.rijksoverheid.nl/cookies" rel="noopener" target="_blank" i18n="@@footer.cookies">Cookies</a></li>
<li><a href="https://www.rijksoverheid.nl/toegankelijkheid" rel="noopener" target="_blank" i18n="@@footer.toegankelijkheid">Toegankelijkheid</a></li>
</ul>
</nav>
<div class="meta">Demo / POC — geen echte gegevens.</div>
<div class="meta" i18n="@@footer.demo">Demo / POC — geen echte gegevens.</div>
</div>
</footer>
`,

View File

@@ -35,13 +35,13 @@ import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
<div class="inner">
<a routerLink="/dashboard" class="brand">
<span aria-hidden="true" class="mark"></span>
<span class="name">Rijksoverheid<br><span class="sub">BIG-register</span></span>
<span class="name" i18n="@@header.brand">Rijksoverheid<br><span class="sub">BIG-register</span></span>
</a>
<span class="portal sub">{{ subtitle() }}</span>
@if (session(); as s) {
<span class="session">
<span class="user">Ingelogd als {{ s.naam }}</span>
<button type="button" class="logout" (click)="logout()">Uitloggen</button>
<span class="user"><ng-container i18n="@@header.ingelogdAls">Ingelogd als</ng-container> {{ s.naam }}</span>
<button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">Uitloggen</button>
</span>
}
</div>
@@ -56,7 +56,7 @@ import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
`,
})
export class SiteHeaderComponent {
subtitle = input('Mijn omgeving');
subtitle = input($localize`:@@header.subtitle:Mijn omgeving`);
private router = inject(Router);
private sessionPort = inject(SESSION_PORT, { optional: true });

View File

@@ -39,7 +39,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
@if (errors().length) {
<div #errorSummary tabindex="-1" role="alert" aria-labelledby="wizard-error-title" class="app-section">
<app-alert type="error">
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title">Er ging iets mis met uw invoer</h3>
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title" i18n="@@wizard.errorTitle">Er ging iets mis met uw invoer</h3>
<ul class="es-list">
@for (e of errors(); track e.id) {
<li><a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a></li>
@@ -52,10 +52,10 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
<ng-content />
<div class="app-button-row app-button-row--spaced">
@if (canGoBack()) {
<app-button type="button" variant="secondary" (click)="back.emit()">Vorige</app-button>
<app-button type="button" variant="secondary" (click)="back.emit()" i18n="@@wizard.vorige">Vorige</app-button>
}
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
<app-button type="button" variant="subtle" (click)="cancel.emit()">Annuleren</app-button>
<app-button type="button" variant="subtle" (click)="cancel.emit()" i18n="@@wizard.annuleren">Annuleren</app-button>
</div>
</form>
}
@@ -68,7 +68,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
@case ('failed') {
<app-alert type="error">{{ errorMessage() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="retry.emit()">Opnieuw proberen</app-button>
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen">Opnieuw proberen</app-button>
</div>
}
}
@@ -83,7 +83,7 @@ export class WizardShellComponent {
canGoBack = input(false);
errors = input<readonly WizardError[]>([]);
errorMessage = input('');
submittingLabel = input('Aanvraag wordt verwerkt…');
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
primary = output<void>();
back = output<void>();

View File

@@ -14,7 +14,7 @@ import { Component, booleanAttribute, input } from '@angular/core';
template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
<label class="utrecht-form-label label" [id]="fieldId() + '-label'" [for]="fieldId()">
{{ label() }}@if (required()) { <span class="req">(verplicht)</span> }
{{ label() }}@if (required()) { <span class="req" i18n="@@formField.verplicht">(verplicht)</span> }
</label>
@if (description()) {
<div class="utrecht-form-field-description desc" [id]="fieldId() + '-desc'">{{ description() }}</div>

View File

@@ -6,6 +6,13 @@ export interface RadioOption {
label: string;
}
/** The ubiquitous yes/no option pair. Language-agnostic now the labels are
localized, so it lives next to RadioOption and both wizards import it. */
export const JA_NEE: RadioOption[] = [
{ value: 'ja', label: $localize`:@@common.ja:Ja` },
{ value: 'nee', label: $localize`:@@common.nee:Nee` },
];
/** Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a
form control so it works with ngModel just like the text-input atom. */
@Component({

View File

@@ -14,7 +14,7 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
`],
template: `
@if (visible()) {
<div class="sp" role="status" aria-label="Bezig met laden"></div>
<div class="sp" role="status" i18n-aria-label="@@spinner.aria" aria-label="Bezig met laden"></div>
}
`,
})

View File

@@ -38,8 +38,8 @@ import { Component, input } from '@angular/core';
.step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
`],
template: `
<nav aria-label="Voortgang">
<p class="sr-only">Stap {{ current() + 1 }} van {{ steps().length }}: {{ steps()[current()] }}</p>
<nav i18n-aria-label="@@stepper.aria" aria-label="Voortgang">
<p class="sr-only" i18n="@@stepper.stapVan">Stap {{ current() + 1 }} van {{ steps().length }}: {{ steps()[current()] }}</p>
<ol class="steps">
@for (label of steps(); track label; let i = $index) {
<li