Step 1: i18n foundation (@angular/localize) + data inventory

Domain reference data already lives in the backend (per ADR-0001); the only
residual "move" was ~90 inlined Dutch UI strings with no i18n layer.

- Wire @angular/localize ($localize) — Angular first-party, no third-party lib
- Pin the pattern on shared/ui/async: Dutch fallbacks → language-agnostic
  input()s with localizable $localize defaults (English-shared-UI rule)
- CLAUDE.md: drop i18n (now in scope) + stale "real backend"/"OpenAPI codegen"
  (both already shipped); document the $localize convention

Bulk string sweep deferred to Step 2 to avoid touching ~36 files twice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 13:42:12 +02:00
parent 7a582ae2fa
commit 94ffcf3d41
8 changed files with 154 additions and 39 deletions

View File

@@ -122,6 +122,13 @@ not heavy component tests.
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`, - **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on. `*.machine.ts`). Pick the language by which side of the seam the code is on.
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
first-party `$localize` (no third-party i18n lib), with a stable custom id
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
translation file, not a code change (the seam). Shared/English components must **not**
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
supplies the text (see `shared/ui/async`). Format-validation messages in
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts` - **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the (Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
@@ -148,5 +155,5 @@ dispatch messages). Worked example: the intake wizard (`herregistratie/`).
## Out of scope (POC, don't build unprompted) ## Out of scope (POC, don't build unprompted)
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo, Real auth/DigiD, NgRx, licensed Rijkshuisstijl font/logo,
runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen. runtime DTO validation on every endpoint, multi-tab session sync.

View File

@@ -30,9 +30,8 @@
"input": "public" "input": "public"
} }
], ],
"styles": [ "styles": ["src/styles.scss"],
"src/styles.scss" "polyfills": ["@angular/localize/init"]
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -85,12 +84,7 @@
"configDir": ".storybook", "configDir": ".storybook",
"browserTarget": "atomic-design-poc:build", "browserTarget": "atomic-design-poc:build",
"compodoc": true, "compodoc": true,
"compodocArgs": [ "compodocArgs": ["-e", "json", "-d", "."],
"-e",
"json",
"-d",
"."
],
"port": 6006 "port": 6006
} }
}, },
@@ -100,16 +94,11 @@
"configDir": ".storybook", "configDir": ".storybook",
"browserTarget": "atomic-design-poc:build", "browserTarget": "atomic-design-poc:build",
"compodoc": true, "compodoc": true,
"compodocArgs": [ "compodocArgs": ["-e", "json", "-d", "."],
"-e",
"json",
"-d",
"."
],
"outputDir": "storybook-static" "outputDir": "storybook-static"
} }
} }
} }
} }
} }
} }

119
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"@angular/build": "^22.0.4", "@angular/build": "^22.0.4",
"@angular/cli": "^22.0.4", "@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@angular/localize": "^22.0.4",
"@angular/platform-browser-dynamic": "^22.0.0", "@angular/platform-browser-dynamic": "^22.0.0",
"@compodoc/compodoc": "^1.2.1", "@compodoc/compodoc": "^1.2.1",
"@storybook/addon-a11y": "^10.4.6", "@storybook/addon-a11y": "^10.4.6",
@@ -781,6 +782,79 @@
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/localize": {
"version": "22.0.4",
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-22.0.4.tgz",
"integrity": "sha512-BJELtGDcTqPjNn10pUa5Ly0Zv+yOyhNOhMl7OA7izRwgK0bWCqgTRnCFh9OWuvn+KyTLfsd9HKVxSDTmtJYEJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.29.7",
"@types/babel__core": "7.20.5",
"tinyglobby": "^0.2.12",
"yargs": "^18.0.0"
},
"bin": {
"localize-extract": "tools/bundles/src/extract/cli.js",
"localize-migrate": "tools/bundles/src/migrate/cli.js",
"localize-translate": "tools/bundles/src/translate/cli.js"
},
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/compiler": "22.0.4",
"@angular/compiler-cli": "22.0.4"
}
},
"node_modules/@angular/localize/node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@angular/localize/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@angular/localize/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "22.0.2", "version": "22.0.2",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.0.2.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.0.2.tgz",
@@ -8317,6 +8391,51 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",

View File

@@ -34,6 +34,7 @@
"@angular/build": "^22.0.4", "@angular/build": "^22.0.4",
"@angular/cli": "^22.0.4", "@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@angular/localize": "^22.0.4",
"@angular/platform-browser-dynamic": "^22.0.0", "@angular/platform-browser-dynamic": "^22.0.0",
"@compodoc/compodoc": "^1.2.1", "@compodoc/compodoc": "^1.2.1",
"@storybook/addon-a11y": "^10.4.6", "@storybook/addon-a11y": "^10.4.6",
@@ -58,6 +59,8 @@
"webpack-dev-server": "^5.2.5", "webpack-dev-server": "^5.2.5",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"uuid": "^11.1.1", "uuid": "^11.1.1",
"eslint": { "ajv": "^6.12.6" } "eslint": {
"ajv": "^6.12.6"
}
} }
} }

View File

@@ -46,15 +46,15 @@ export class AsyncErrorDirective {
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl" <ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" /> [ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
} @else { } @else {
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert> <app-alert type="error">{{ errorText() }}</app-alert>
<div style="margin-top:1rem"> <div style="margin-top:1rem">
<app-button variant="secondary" (click)="retry()">Opnieuw proberen</app-button> <app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
</div> </div>
} }
} }
@case ('Empty') { @case ('Empty') {
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> } @if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> } @else { <p class="rhc-paragraph">{{ emptyText() }}</p> }
} }
@case ('Success') { @case ('Success') {
<ng-container [ngTemplateOutlet]="loadedTpl().tpl" <ng-container [ngTemplateOutlet]="loadedTpl().tpl"
@@ -72,6 +72,12 @@ export class AsyncComponent<T> {
data = input<RemoteData<Error | undefined, T>>(); data = input<RemoteData<Error | undefined, T>>();
isEmpty = input<(v: T) => boolean>(() => false); isEmpty = input<(v: T) => boolean>(() => false);
// Shared/English component: copy lives behind language-agnostic inputs. Defaults are
// localizable via $localize (source = default locale, currently nl); callers may override.
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required(AsyncLoadedDirective); loadedTpl = contentChild.required(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective); loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective); emptyTpl = contentChild(AsyncEmptyDirective);

View File

@@ -1,6 +1,7 @@
/// <reference types="@angular/localize" />
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig).catch((err) => console.error(err));
.catch((err) => console.error(err));

View File

@@ -4,13 +4,8 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": ["@angular/localize"]
}, },
"include": [ "include": ["src/**/*.ts"],
"src/**/*.ts" "exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"]
],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.stories.ts"
]
} }

View File

@@ -4,12 +4,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": ["vitest/globals", "@angular/localize"]
"vitest/globals"
]
}, },
"include": [ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
} }