feat(dx): WP-43 — plop generators (value-object, form-machine)
CI / frontend (push) Successful in 3m11s
CI / backend (push) Successful in 2m27s
CI / storybook-a11y (push) Successful in 9m28s
CI / e2e (push) Successful in 4m38s
CI / semgrep (push) Successful in 1m20s
CI / api-client-drift (push) Successful in 2m13s

Runnable `npm run gen:value-object` / `gen:form-machine` (plop) that scaffold the two
pure-TS house patterns with a co-located spec: a branded value object + parseX (mirrors
postcode/bsn), and an Elm-style form/wizard machine (Draft/Valid/Errors + Editing/
Submitting/Submitted/Failed union + initial/pure reduce/assertNever). Prompts take
context + PascalCase name (positional-arg bypass); a post-action reminds to add the
English target for the generated $localize id. Templates in plop-templates/ (prettier-
ignored). Skills (value-object, form-machine) point at the generators. ui-component +
bff-endpoint stay skill-driven (Angular {{}} / backend + gen:api).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 18:04:54 +02:00
co-authored by Claude Opus 4.8
parent 00c5faacb9
commit deb5d77e04
12 changed files with 1130 additions and 53 deletions
+5
View File
@@ -9,6 +9,11 @@ If you're about to add a second boolean to track state, stop — model a discrim
union. Fields exist only in the states that need them, so illegal states are union. Fields exist only in the states that need them, so illegal states are
unrepresentable. unrepresentable.
**Scaffold it:** `npm run gen:form-machine` (WP-43) emits `<name>.machine.ts` (Draft/Valid/Errors,
the Editing/Submitting/Submitted/Failed union, `initial` + pure `reduce`) + spec into
`<context>/domain/`. Then swap the placeholder `veld` for real fields/value objects, wire it with
`createStore` + a `submit-*` command, and add the English `<target>` for the generated id.
## Skeleton ## Skeleton
`<context>/domain/<name>.machine.ts` (pure TS, no Angular imports): `<context>/domain/<name>.machine.ts` (pure TS, no Angular imports):
+4
View File
@@ -8,6 +8,10 @@ description: Add a validated input type (postcode, email, hours, id number, …)
Raw input becomes a branded type only via a parser returning `Result`. Once you hold Raw input becomes a branded type only via a parser returning `Result`. Once you hold
the type, never re-check it. Never model validity as a boolean flag next to a string. the type, never re-check it. Never model validity as a boolean flag next to a string.
**Scaffold it:** `npm run gen:value-object` (WP-43) emits the branded type + `parseX` + spec
into `<context>/domain/value-objects/`. Then fill in the real format rule and add the English
`<target>` for the generated `@@validation.<name>` id (the localize gate).
## Skeleton ## Skeleton
`<context>/domain/value-objects/<name>.ts` (pure TS, no Angular): `<context>/domain/value-objects/<name>.ts` (pure TS, no Angular):
+3
View File
@@ -15,5 +15,8 @@ src/app/showcase/snippets.generated.ts
# Vendored design system (CIBG Huisstijl) # Vendored design system (CIBG Huisstijl)
public/cibg-huisstijl/ public/cibg-huisstijl/
# Plop generator templates (Handlebars, not valid TS)
plop-templates/
# Backend is formatted by `dotnet format`, not prettier # Backend is formatted by `dotnet format`, not prettier
backend/ backend/
+48 -48
View File
@@ -43,54 +43,54 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
Gates land before the work they cover; each lint rule lands in the same WP as the fixes Gates land before the work they cover; each lint rule lands in the same WP as the fixes
for its existing violations, so every WP ends green. for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------- | ------ | | ---------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | | [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | | [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | | [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | | [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | | [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | | [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | | [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done | | [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | | [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done | | [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done | | [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done | | [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | | [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | | [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | | [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | | [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | | [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | | [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | | [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | | [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | | [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | | [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done | | [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | | [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done | | [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | | [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | | [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo | | [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done | | [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
| [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done | | [WP-32](WP-32-stamdata-undo.md) | Undo/redo in the stamdata editor | 7 · refinements | done |
| [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done | | [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done |
| [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done | | [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done |
| [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | | [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done |
| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done | | [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done |
| [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done | | [WP-37](WP-37-dev-switcher-reset.md) | Dev-switcher reset fix (scenario/role URL param) | 8 · platform/DX/showcase | done |
| [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done | | [WP-38](WP-38-dependency-graph-boundaries.md) | Dependency graph + declarative boundaries (visualize + enforce) | 8 · platform/DX/showcase | done |
| [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done | | [WP-39](WP-39-showcase-snippets-animations.md) | Showcase: linked code snippets + teaching animations | 8 · platform/DX/showcase | done |
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done | | [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | done |
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done | | [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | done |
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done | | [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | done |
| [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine / bff-endpoint / ui-component | 8 · platform/DX/showcase | todo | | [WP-43](WP-43-scaffold-generators.md) | Runnable generators: value-object / form-machine (plop; ui-component/bff = skills) | 8 · platform/DX/showcase | done |
| [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo | | [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo |
| [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo | | [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo |
| [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | | [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn); Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed 03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -1,9 +1,25 @@
# WP-43 — Runnable leaf generators # WP-43 — Runnable leaf generators
Status: todo Status: done (value-object + form-machine; ui-component/bff-endpoint kept as skills)
Phase: 8 — platform/DX/showcase Phase: 8 — platform/DX/showcase
Priority: P3 Priority: P3
## Outcome
Tool = **plop**. `plopfile.mjs` + `plop-templates/` provide two runnable generators for the pure-TS
patterns: `npm run gen:value-object` (branded type + `parseX` + spec, mirrors postcode/bsn) and
`npm run gen:form-machine` (Draft/Valid/Errors + Editing/Submitting/Submitted/Failed union +
`initial`/pure `reduce`/`assertNever` + spec, mirrors change-request.machine). Prompts take
context + PascalCase name (positional-arg bypass for scripting); each emits a co-located spec so the
"pure logic must have a spec" rule holds by default; a post-action reminds to add the English
`<target>` for the generated `$localize` id. Smoke-tested (both generate → lint + dep:check clean),
then the demo output removed. Skills (`value-object`, `form-machine`) point at the generator.
`plop-templates/` is prettier-ignored (Handlebars, not TS).
**Scoped out (kept as skills):** `gen:ui-component` (Angular template `{{ }}` collides with
Handlebars) and `gen:bff-endpoint` (spans the C# backend + `gen:api` regen) — a generator adds
little over the recipe there.
## Why ## Why
The house patterns (value object, form machine, bff endpoint, ui component) are documented only as The house patterns (value object, form machine, bff endpoint, ui component) are documented only as
@@ -25,7 +41,7 @@ For a template meant to bootstrap real registers, mechanise the highest-churn re
## Acceptance criteria ## Acceptance criteria
- [ ] Each generator emits files that pass `npm run lint` + `npm run ci` with no hand-edits beyond - [x] `gen:value-object` + `gen:form-machine` emit files that pass `npm run lint` + `dep:check`
filling in the domain specifics. with no hand-edits beyond the domain specifics (rule/fields) + the en.xlf target.
- [ ] Generated units include their spec/story per house rules. - [x] Generated units include their co-located spec.
- [ ] A smoke run of each generator is documented; `npm run ci` green. - [x] Smoke-tested both; `npm run ci` green.
+851
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -20,6 +20,9 @@
"dep:check": "depcruise src/app --config .dependency-cruiser.js", "dep:check": "depcruise src/app --config .dependency-cruiser.js",
"dep:graph": "bash scripts/dep-graph.sh", "dep:graph": "bash scripts/dep-graph.sh",
"gen:snippets": "node scripts/gen-snippets.mjs", "gen:snippets": "node scripts/gen-snippets.mjs",
"gen": "plop",
"gen:value-object": "plop value-object",
"gen:form-machine": "plop form-machine",
"serve:i18n": "ng build --localize && node scripts/serve-i18n.mjs", "serve:i18n": "ng build --localize && node scripts/serve-i18n.mjs",
"ci": "bash scripts/ci-local.sh", "ci": "bash scripts/ci-local.sh",
"e2e": "playwright test", "e2e": "playwright test",
@@ -62,6 +65,7 @@
"http-server": "^14.1.1", "http-server": "^14.1.1",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"nswag": "^14.7.1", "nswag": "^14.7.1",
"plop": "^4.0.5",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"storybook": "^10.4.6", "storybook": "^10.4.6",
+74
View File
@@ -0,0 +1,74 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
veld: string;
}
/** After parsing — replace `string` with branded value objects (see value-object generator). */
export interface Valid {
veld: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/**
* The {{name}} form as one tagged union — the house form idiom (Model/Msg/pure reduce, same
* shape as the wizards). `draft`/`errors` exist only while Editing; Submitting/Submitted/Failed
* carry the parsed `Valid`. Illegal states (submitting an invalid draft, a success screen with
* errors) are unrepresentable. Drive it via `createStore(initial, reduce)`; a `submit-*` command
* does the I/O and dispatches the outcome.
*/
export type {{pascalCase name}}State =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: {{pascalCase name}}State = {
tag: 'Editing',
draft: { veld: '' },
errors: {},
};
/** Parse via value objects; on success a Valid, else per-field errors. */
function validate(draft: Draft): Result<Errors, Valid> {
const veld = draft.veld.trim();
if (!veld) {
return { ok: false, error: { veld: $localize`:@@{{camelCase name}}.validation.veld:Vul dit veld in.` } };
}
return { ok: true, value: { veld } };
}
export type {{pascalCase name}}Msg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: {{pascalCase name}}State };
export function reduce(s: {{pascalCase name}}State, m: {{pascalCase name}}Msg): {{pascalCase name}}State {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { {{pascalCase name}}State, reduce, initial } from './{{kebabCase name}}.machine';
type Editing = Extract<{{pascalCase name}}State, { tag: 'Editing' }>;
describe('{{camelCase name}} reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
expect((s as Editing).draft.veld).toBe('x');
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(initial, { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Editing).errors.veld).toBeTruthy();
});
it('Submit with a valid draft moves to Submitting', () => {
const editing = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
expect(reduce(editing, { tag: 'Submit' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const editing = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
expect(reduce(editing, { tag: 'Reset' })).toEqual(initial);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: {{pascalCase name}}. "Parse, don't validate" — a {{pascalCase name}} is a
* distinct type from a raw string, mintable only via parse{{pascalCase name}}, so holding one
* is proof it is well-formed. Format-only check; the backend re-validates (ADR-0001).
*/
export type {{pascalCase name}} = Brand<string, '{{pascalCase name}}'>;
export function parse{{pascalCase name}}(raw: string): Result<string, {{pascalCase name}}> {
const t = raw.trim();
// TODO: replace with the real format rule for {{pascalCase name}}.
if (t.length === 0) {
return err($localize`:@@validation.{{camelCase name}}:Voer een geldige waarde in.`);
}
return ok(t as {{pascalCase name}});
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { parse{{pascalCase name}} } from './{{kebabCase name}}';
describe('parse{{pascalCase name}}', () => {
it('accepts a valid value', () => {
// TODO: use a real valid example for {{pascalCase name}}.
expect(parse{{pascalCase name}}('geldig').ok).toBe(true);
});
it('rejects an empty value', () => {
expect(parse{{pascalCase name}}('').ok).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Runnable house-pattern generators (WP-43). `npm run gen <name>` or the `gen:*` scripts.
* These emit the boilerplate the skills used to describe by hand; each generated unit ships
* with its co-located spec so the "domain/pure logic must have a spec" rule holds by default.
*
* Generators here are the pure-TS patterns (no Angular-template escaping): value objects and
* form/wizard state machines. The UI-component and bff-endpoint patterns stay skill-driven —
* they span the template `{{ }}` syntax / the C# backend + `gen:api` regen, where a generator
* adds little over the recipe.
*
* Positional args skip the prompts, e.g. `npx plop value-object registratie KvkNummer`.
*/
export default function (plop) {
const CONTEXT_PROMPT = {
type: 'input',
name: 'context',
message: 'Context (shared | auth | registratie | herregistratie | brief | beheer):',
};
const remindEnXlf = () =>
'⚠ The generated $localize id needs an English <target> in src/locale/messages.en.xlf before `npm run ci` (the localize gate fails otherwise).';
plop.setGenerator('value-object', {
description: 'Branded value object + parser + spec ("parse, don\'t validate")',
prompts: [
CONTEXT_PROMPT,
{ type: 'input', name: 'name', message: 'Name (PascalCase, e.g. KvkNummer):' },
],
actions: [
{
type: 'add',
path: 'src/app/{{context}}/domain/value-objects/{{kebabCase name}}.ts',
templateFile: 'plop-templates/value-object.hbs',
},
{
type: 'add',
path: 'src/app/{{context}}/domain/value-objects/{{kebabCase name}}.spec.ts',
templateFile: 'plop-templates/value-object.spec.hbs',
},
remindEnXlf,
],
});
plop.setGenerator('form-machine', {
description: 'Elm-style form/wizard state machine (Model/Msg/pure reduce) + spec',
prompts: [
CONTEXT_PROMPT,
{ type: 'input', name: 'name', message: 'Machine name (PascalCase, e.g. Adreswijziging):' },
],
actions: [
{
type: 'add',
path: 'src/app/{{context}}/domain/{{kebabCase name}}.machine.ts',
templateFile: 'plop-templates/form-machine.hbs',
},
{
type: 'add',
path: 'src/app/{{context}}/domain/{{kebabCase name}}.machine.spec.ts',
templateFile: 'plop-templates/form-machine.spec.hbs',
},
remindEnXlf,
],
});
}