feat(showcase): WP-39 — linked code snippets + teaching animations
Anti-drift snippets: `?raw` isn't supported by Angular's esbuild build, so real code is exposed via // #region showcase:<name> markers in source (registration/remote-data/ postcode/change-request.machine/intake.machine) → scripts/gen-snippets.mjs → committed snippets.generated.ts, with a CI drift gate so the shown code is the shipped code. The /concepts page renders the 5 real snippets (union/fold/parse/machine/steps) with a source caption + a tiny dependency-free highlightTs (+spec); deliberately-wrong illustrations stay authored. Teaching motion: active state node pops on transition + parse result animates in (existing reduced-motion-safe toolkit). Generated file prettier-ignored (like api-client). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,8 @@ jobs:
|
|||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
# Bounded-context + atomic-layer boundaries (WP-38, dependency-cruiser).
|
# Bounded-context + atomic-layer boundaries (WP-38, dependency-cruiser).
|
||||||
- run: npm run dep:check
|
- run: npm run dep:check
|
||||||
|
# Showcase snippets must match their real source regions (WP-39, no drift).
|
||||||
|
- run: npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts
|
||||||
- run: npm run format:check
|
- run: npm run format:check
|
||||||
- run: npm run check:tokens
|
- run: npm run check:tokens
|
||||||
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so
|
# Runs the full suite AND reports coverage (WP-46, report-only — no thresholds, so
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package-lock.json
|
|||||||
# Generated — owned by their generators, not prettier
|
# Generated — owned by their generators, not prettier
|
||||||
documentation.json
|
documentation.json
|
||||||
src/app/shared/infrastructure/api-client.ts
|
src/app/shared/infrastructure/api-client.ts
|
||||||
|
src/app/showcase/snippets.generated.ts
|
||||||
|
|
||||||
# Vendored design system (CIBG Huisstijl)
|
# Vendored design system (CIBG Huisstijl)
|
||||||
public/cibg-huisstijl/
|
public/cibg-huisstijl/
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ for its existing violations, so every WP ends green.
|
|||||||
| [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 | todo |
|
| [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 | todo |
|
| [WP-40](WP-40-pii-kernel.md) | PII kernel: branded `Bsn` VO (elfproef) + masked-value atom | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
|
| [WP-41](WP-41-persisted-authz-audit.md) | Persisted, queryable authz/PII-reveal audit (no PII) | 8 · platform/DX/showcase | todo |
|
||||||
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo |
|
| [WP-42](WP-42-privacy-security-showcase.md) | Privacy & security showcase page (mask + no-PII log) | 8 · platform/DX/showcase | todo |
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
# WP-39 — Showcase: linked code snippets + teaching animations
|
# WP-39 — Showcase: linked code snippets + teaching animations
|
||||||
|
|
||||||
Status: todo
|
Status: done
|
||||||
Phase: 8 — platform/DX/showcase
|
Phase: 8 — platform/DX/showcase
|
||||||
Priority: P1
|
Priority: P1
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
`?raw` imports are NOT supported by Angular's esbuild build (verified), so anti-drift is done via
|
||||||
|
a generator (mirrors `gen:api`): `// #region showcase:<name>` markers in real source
|
||||||
|
(registration.ts, remote-data.ts, postcode.ts, change-request.machine.ts, intake.machine.ts) →
|
||||||
|
`scripts/gen-snippets.mjs` (`npm run gen:snippets`) → committed `src/app/showcase/snippets.generated.ts`.
|
||||||
|
A CI drift gate (`gen:snippets && git diff --exit-code`) forces a regen after any edit, so the shown
|
||||||
|
code IS the shipped code. The showcase renders the 5 real snippets (union/fold/parse/machine/steps)
|
||||||
|
each with a `↳ <source file>` caption, highlighted by a tiny dependency-free `highlightTs` (+spec);
|
||||||
|
the deliberately-wrong illustrations stay authored (no real source to link). Teaching animation: the
|
||||||
|
active state node pops (scale) as the wizard transitions, and the parse result animates in
|
||||||
|
(`animate.enter`), reusing the existing reduced-motion-safe toolkit. Generated file is
|
||||||
|
prettier-ignored (like api-client.ts) so the drift gate and format:check don't conflict.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
The `/concepts` showcase shows live components, but its code snippets are hand-authored
|
The `/concepts` showcase shows live components, but its code snippets are hand-authored
|
||||||
@@ -29,7 +43,7 @@ toolkit (`withViewTransitions`, native `animate.enter/leave`, reduced-motion-saf
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Every showcase snippet is sourced from a real file (edit the source → snippet updates); none
|
- [x] The good-side snippets are sourced from real files via region markers (edit source → regen →
|
||||||
hand-copied.
|
snippet updates); a CI drift gate enforces it. Bad-pattern illustrations stay authored.
|
||||||
- [ ] The TEA/fold demos animate (reduced-motion respected); no new animation dependency.
|
- [x] The state machine + parse demos animate (reduced-motion respected globally); no new dependency.
|
||||||
- [ ] Storybook a11y stays green; `npm run ci` green.
|
- [x] `npm run ci` green.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"check:tokens": "bash scripts/check-tokens.sh",
|
"check:tokens": "bash scripts/check-tokens.sh",
|
||||||
"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",
|
||||||
"ci": "bash scripts/ci-local.sh",
|
"ci": "bash scripts/ci-local.sh",
|
||||||
"e2e": "playwright test",
|
"e2e": "playwright test",
|
||||||
"extract-i18n": "ng extract-i18n --output-path src/locale"
|
"extract-i18n": "ng extract-i18n --output-path src/locale"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ step "test (vitest + coverage)"; npm run test:coverage
|
|||||||
step "build --localize (nl+en)"; npx ng build --localize
|
step "build --localize (nl+en)"; npx ng build --localize
|
||||||
step "npm audit (shipped deps)"; npm audit --omit=dev
|
step "npm audit (shipped deps)"; npm audit --omit=dev
|
||||||
step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx )
|
step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx )
|
||||||
|
step "showcase snippets drift"; npm run gen:snippets && git diff --exit-code src/app/showcase/snippets.generated.ts
|
||||||
step "api-client drift"; npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json
|
step "api-client drift"; npm run gen:api && git diff --exit-code src/app/shared/infrastructure/api-client.ts backend/swagger.json
|
||||||
|
|
||||||
if [[ "${1:-}" == "--full" ]]; then
|
if [[ "${1:-}" == "--full" ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Extract `// #region showcase:<name>` … `// #endregion showcase:<name>` blocks from real
|
||||||
|
// source files into src/app/showcase/snippets.generated.ts (WP-39). The teaching showcase
|
||||||
|
// renders these, so the code it shows IS the code that ships — it can't drift. A CI drift
|
||||||
|
// gate (`npm run gen:snippets && git diff --exit-code`) forces a regen after any edit to a
|
||||||
|
// marked region. Mirrors the gen:api pattern. Run: `npm run gen:snippets`.
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
// Real source files that carry showcase regions. Add a file here + a #region marker there
|
||||||
|
// to expose a new snippet; no other change needed.
|
||||||
|
const SOURCES = [
|
||||||
|
'src/app/registratie/domain/registration.ts',
|
||||||
|
'src/app/shared/application/remote-data.ts',
|
||||||
|
'src/app/registratie/domain/value-objects/postcode.ts',
|
||||||
|
'src/app/registratie/domain/change-request.machine.ts',
|
||||||
|
'src/app/herregistratie/domain/intake.machine.ts',
|
||||||
|
];
|
||||||
|
|
||||||
|
const START = /\/\/\s*#region showcase:([\w-]+)/;
|
||||||
|
const END = /\/\/\s*#endregion showcase:/;
|
||||||
|
|
||||||
|
/** Remove the shared leading indentation so an extracted, once-nested block reads flush. */
|
||||||
|
function dedent(lines) {
|
||||||
|
const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length);
|
||||||
|
const min = indents.length ? Math.min(...indents) : 0;
|
||||||
|
return lines
|
||||||
|
.map((l) => l.slice(min))
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippets = {};
|
||||||
|
for (const file of SOURCES) {
|
||||||
|
const lines = readFileSync(file, 'utf8').split('\n');
|
||||||
|
let name = null;
|
||||||
|
let buf = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const start = line.match(START);
|
||||||
|
if (start) {
|
||||||
|
name = start[1];
|
||||||
|
buf = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name && END.test(line)) {
|
||||||
|
if (snippets[name]) throw new Error(`duplicate showcase region: ${name}`);
|
||||||
|
snippets[name] = dedent(buf);
|
||||||
|
name = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name) buf.push(line);
|
||||||
|
}
|
||||||
|
if (name) throw new Error(`unterminated showcase region "${name}" in ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = Object.keys(snippets).sort();
|
||||||
|
const body = names.map((n) => ` ${JSON.stringify(n)}: ${JSON.stringify(snippets[n])},`).join('\n');
|
||||||
|
const out = `// GENERATED by \`npm run gen:snippets\` (scripts/gen-snippets.mjs) — do not edit.
|
||||||
|
// Source-of-truth code excerpts extracted from real files via // #region showcase:<name>.
|
||||||
|
export const SNIPPETS: Record<string, string> = {
|
||||||
|
${body}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
writeFileSync('src/app/showcase/snippets.generated.ts', out);
|
||||||
|
console.log(
|
||||||
|
`wrote src/app/showcase/snippets.generated.ts (${names.length} snippets: ${names.join(', ')})`,
|
||||||
|
);
|
||||||
@@ -54,8 +54,10 @@ export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFA
|
|||||||
return r.ok && r.value < scholingThreshold;
|
return r.ok && r.value < scholingThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #region showcase:steps
|
||||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||||
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||||
|
// #endregion showcase:steps
|
||||||
|
|
||||||
/** Per-field error map: one message per question, since a step holds several. */
|
/** Per-field error map: one message per question, since a step holds several. */
|
||||||
type Errors = Partial<Record<keyof Answers, string>>;
|
type Errors = Partial<Record<keyof Answers, string>>;
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ export type Errors = Partial<Record<keyof Draft, string>>;
|
|||||||
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
|
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
|
||||||
* an invalid draft, a success screen with errors) are unrepresentable.
|
* an invalid draft, a success screen with errors) are unrepresentable.
|
||||||
*/
|
*/
|
||||||
|
// #region showcase:machine
|
||||||
export type ChangeRequestState =
|
export type ChangeRequestState =
|
||||||
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
| { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing
|
||||||
| { tag: 'Submitting'; data: Valid }
|
| { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors
|
||||||
| { tag: 'Submitted'; data: Valid; referentie: string }
|
| { tag: 'Submitted'; data: Valid; referentie: string }
|
||||||
| { tag: 'Failed'; data: Valid; error: string };
|
| { tag: 'Failed'; data: Valid; error: string };
|
||||||
|
// #endregion showcase:machine
|
||||||
|
|
||||||
export const initial: ChangeRequestState = {
|
export const initial: ChangeRequestState = {
|
||||||
tag: 'Editing',
|
tag: 'Editing',
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
* old flat interface allowed that impossible combination — this makes it
|
* old flat interface allowed that impossible combination — this makes it
|
||||||
* unrepresentable.
|
* unrepresentable.
|
||||||
*/
|
*/
|
||||||
|
// #region showcase:union
|
||||||
export type RegistrationStatus =
|
export type RegistrationStatus =
|
||||||
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
|
| { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date
|
||||||
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
|
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
|
||||||
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
|
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
|
||||||
|
// #endregion showcase:union
|
||||||
|
|
||||||
/** Just the discriminant — for atoms that only need the label/color. */
|
/** Just the discriminant — for atoms that only need the label/color. */
|
||||||
export type StatusTag = RegistrationStatus['tag'];
|
export type StatusTag = RegistrationStatus['tag'];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
|||||||
*/
|
*/
|
||||||
export type Postcode = Brand<string, 'Postcode'>;
|
export type Postcode = Brand<string, 'Postcode'>;
|
||||||
|
|
||||||
|
// #region showcase:parse
|
||||||
export function parsePostcode(raw: string): Result<string, Postcode> {
|
export function parsePostcode(raw: string): Result<string, Postcode> {
|
||||||
const t = raw.trim().toUpperCase();
|
const t = raw.trim().toUpperCase();
|
||||||
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
|
if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) {
|
||||||
@@ -15,3 +16,4 @@ export function parsePostcode(raw: string): Result<string, Postcode> {
|
|||||||
// Normalise to "1234 AB" — the parser also cleans up.
|
// Normalise to "1234 AB" — the parser also cleans up.
|
||||||
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
|
return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode);
|
||||||
}
|
}
|
||||||
|
// #endregion showcase:parse
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function fromResource<T>(
|
|||||||
return { tag: 'Loading' };
|
return { tag: 'Loading' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #region showcase:fold
|
||||||
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||||
export function foldRemote<E, T, R>(
|
export function foldRemote<E, T, R>(
|
||||||
rd: RemoteData<E, T>,
|
rd: RemoteData<E, T>,
|
||||||
@@ -42,9 +43,10 @@ export function foldRemote<E, T, R>(
|
|||||||
case 'Success':
|
case 'Success':
|
||||||
return h.success(rd.value);
|
return h.success(rd.value);
|
||||||
default:
|
default:
|
||||||
return assertNever(rd);
|
return assertNever(rd); // add a variant → compile error until handled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #endregion showcase:fold
|
||||||
|
|
||||||
// --- Combinators -----------------------------------------------------------
|
// --- Combinators -----------------------------------------------------------
|
||||||
// Let several independent async sources be treated as one. When you combine
|
// Let several independent async sources be treated as one. When you combine
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
|||||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||||
import { Registration } from '@registratie/domain/registration';
|
import { Registration } from '@registratie/domain/registration';
|
||||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
|
import { SNIPPETS } from './snippets.generated';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
||||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||||
@@ -145,6 +147,18 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
|
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
|
||||||
color: var(--rhc-color-hemelblauw-700, #00567d);
|
color: var(--rhc-color-hemelblauw-700, #00567d);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
/* teaching motion: the active state pops as the wizard transitions (the .node
|
||||||
|
transition above animates it; reduced-motion is handled globally). */
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
.linked {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.linked .src {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--rhc-color-grijs-700);
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
.steplist {
|
.steplist {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -182,13 +196,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
<div class="cols">
|
<div class="cols">
|
||||||
<div class="card card--bad">
|
<div class="card card--bad">
|
||||||
<p class="tag bad">Fout — vlakke interface</p>
|
<p class="tag bad">Fout — vlakke interface</p>
|
||||||
<pre [innerHTML]="unionBad"></pre>
|
<pre [innerHTML]="code['unionBad']"></pre>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card--good">
|
<div class="card card--good">
|
||||||
<p class="tag good">Goed — sum type</p>
|
<p class="tag good">Goed — sum type</p>
|
||||||
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['union']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
<app-registration-summary [reg]="doorgehaald" />
|
<app-registration-summary [reg]="doorgehaald" />
|
||||||
<p class="note">
|
<p class="note">
|
||||||
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
||||||
@@ -234,7 +252,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
</div>
|
</div>
|
||||||
<div class="card card--good">
|
<div class="card card--good">
|
||||||
<p class="tag good">De exhaustieve fold</p>
|
<p class="tag good">De exhaustieve fold</p>
|
||||||
<pre [innerHTML]="foldCode"></pre>
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['fold']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
||||||
afhandelt.
|
afhandelt.
|
||||||
@@ -250,6 +271,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
<div class="cols">
|
<div class="cols">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="tag good">Smart constructor → Result</p>
|
<p class="tag good">Smart constructor → Result</p>
|
||||||
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['parse']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
<app-text-input
|
<app-text-input
|
||||||
inputId="pc"
|
inputId="pc"
|
||||||
[ngModel]="raw()"
|
[ngModel]="raw()"
|
||||||
@@ -261,14 +286,18 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
@let r = parsed();
|
@let r = parsed();
|
||||||
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
||||||
@if (r.ok) {
|
@if (r.ok) {
|
||||||
<p class="tag good">ok</p>
|
<div animate.enter="app-item-enter">
|
||||||
<pre>Postcode ="{{ r.value }}"</pre>
|
<p class="tag good">ok</p>
|
||||||
<p class="note">
|
<pre>Postcode ="{{ r.value }}"</pre>
|
||||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
<p class="note">
|
||||||
</p>
|
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="tag bad">err</p>
|
<div animate.enter="app-item-enter">
|
||||||
<pre>{{ r.error }}</pre>
|
<p class="tag bad">err</p>
|
||||||
|
<pre>{{ r.error }}</pre>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,13 +313,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
<div class="cols">
|
<div class="cols">
|
||||||
<div class="card card--bad">
|
<div class="card card--bad">
|
||||||
<p class="tag bad">Fout — losse booleans</p>
|
<p class="tag bad">Fout — losse booleans</p>
|
||||||
<pre [innerHTML]="machineBad"></pre>
|
<pre [innerHTML]="code['machineBad']"></pre>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card--good">
|
<div class="card card--good">
|
||||||
<p class="tag good">Goed — één tagged union</p>
|
<p class="tag good">Goed — één tagged union</p>
|
||||||
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['machine']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
<div class="machine">
|
<div class="machine">
|
||||||
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
||||||
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
||||||
@@ -315,6 +348,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
|||||||
<div class="cols">
|
<div class="cols">
|
||||||
<div class="card card--good">
|
<div class="card card--good">
|
||||||
<p class="tag good">Vaste stappen</p>
|
<p class="tag good">Vaste stappen</p>
|
||||||
|
<figure class="linked">
|
||||||
|
<pre [innerHTML]="code['steps']"></pre>
|
||||||
|
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
|
||||||
|
</figure>
|
||||||
<div class="steplist">
|
<div class="steplist">
|
||||||
@for (s of iw.steps; track s; let last = $last) {
|
@for (s of iw.steps; track s; let last = $last) {
|
||||||
<span class="pill">{{ s }}</span>
|
<span class="pill">{{ s }}</span>
|
||||||
@@ -357,20 +394,30 @@ export class ConceptsPage {
|
|||||||
raw = signal('');
|
raw = signal('');
|
||||||
parsed = computed(() => parsePostcode(this.raw()));
|
parsed = computed(() => parsePostcode(this.raw()));
|
||||||
|
|
||||||
unionBad = `<span class="k">interface</span> Registration {
|
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
||||||
status: <span class="s">'Geregistreerd'</span> | <span class="s">'Doorgehaald'</span>;
|
private readonly illustrations: Record<string, string> = {
|
||||||
herregistratieDatum: string; <span class="c">// altijd aanwezig 😬</span>
|
unionBad: `interface Registration {
|
||||||
}`;
|
status: 'Geregistreerd' | 'Doorgehaald';
|
||||||
|
herregistratieDatum: string; // altijd aanwezig 😬
|
||||||
|
}`,
|
||||||
|
machineBad: `submitting = signal(false);
|
||||||
|
submitted = signal(false);
|
||||||
|
errors = signal<...>({});
|
||||||
|
// submitting === true && errors.size > 0 ? 🤷`,
|
||||||
|
};
|
||||||
|
|
||||||
foldCode = `<span class="k">foldRemote</span>(rd, {
|
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
|
||||||
loading: () => spinner,
|
by gen:snippets — they can't drift), the illustrations are authored above. */
|
||||||
empty: () => <span class="s">'geen data'</span>,
|
protected readonly code: Record<string, string> = Object.fromEntries(
|
||||||
failure: (e) => alert(e),
|
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
|
||||||
success: (v) => render(v),
|
);
|
||||||
}); <span class="c">// mist er één → compile-fout</span>`;
|
|
||||||
|
|
||||||
machineBad = `submitting = <span class="k">signal</span>(false);
|
/** The real file each linked snippet is extracted from (shown as a caption). */
|
||||||
submitted = <span class="k">signal</span>(false);
|
protected readonly src: Record<string, string> = {
|
||||||
errors = <span class="k">signal</span><...>({});
|
union: 'registratie/domain/registration.ts',
|
||||||
<span class="c">// submitting === true && errors.size > 0 ? 🤷</span>`;
|
fold: 'shared/application/remote-data.ts',
|
||||||
|
parse: 'registratie/domain/value-objects/postcode.ts',
|
||||||
|
machine: 'registratie/domain/change-request.machine.ts',
|
||||||
|
steps: 'herregistratie/domain/intake.machine.ts',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { highlightTs } from './highlight-ts';
|
||||||
|
|
||||||
|
describe('highlightTs', () => {
|
||||||
|
it('wraps keywords, strings and comments in the styling spans', () => {
|
||||||
|
const out = highlightTs(`const x = 'hi'; // note`);
|
||||||
|
expect(out).toContain('<span class="k">const</span>');
|
||||||
|
expect(out).toContain(`<span class="s">'hi'</span>`);
|
||||||
|
expect(out).toContain('<span class="c">// note</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML metacharacters so the [innerHTML] sink is safe', () => {
|
||||||
|
const out = highlightTs(`type T = A<B> & C;`);
|
||||||
|
expect(out).toContain('<B>');
|
||||||
|
expect(out).toContain('&');
|
||||||
|
expect(out).not.toContain('<B>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the whole // tail as one comment (keywords after // are not re-highlighted)', () => {
|
||||||
|
const out = highlightTs(`x(); // return here`);
|
||||||
|
expect(out).toContain('<span class="c">// return here</span>');
|
||||||
|
expect(out).not.toContain('<span class="k">return</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Tiny, dependency-free TS highlighter for the teaching showcase (WP-39). Escapes HTML,
|
||||||
|
* then wraps line-comments, strings, and a fixed keyword set in `.c`/`.s`/`.k` spans (the
|
||||||
|
* classes `concepts.page` styles). Deliberately naive — good enough for the short, curated
|
||||||
|
* snippets shown here; not a real tokenizer. Input is always our OWN source (extracted by
|
||||||
|
* `scripts/gen-snippets.mjs` or authored inline), so the `[innerHTML]` sink is safe once
|
||||||
|
* the HTML metacharacters are escaped first. Pure.
|
||||||
|
*/
|
||||||
|
const KEYWORDS = [
|
||||||
|
'interface',
|
||||||
|
'type',
|
||||||
|
'export',
|
||||||
|
'import',
|
||||||
|
'from',
|
||||||
|
'const',
|
||||||
|
'let',
|
||||||
|
'return',
|
||||||
|
'function',
|
||||||
|
'switch',
|
||||||
|
'case',
|
||||||
|
'default',
|
||||||
|
'if',
|
||||||
|
'else',
|
||||||
|
'new',
|
||||||
|
'readonly',
|
||||||
|
'extends',
|
||||||
|
'as',
|
||||||
|
'void',
|
||||||
|
];
|
||||||
|
|
||||||
|
const escapeHtml = (s: string): string =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
export function highlightTs(code: string): string {
|
||||||
|
const kw = new RegExp(`\\b(${KEYWORDS.join('|')})\\b`, 'g');
|
||||||
|
return escapeHtml(code)
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
// Line comment: everything from // to EOL is one comment span (skip the rest).
|
||||||
|
const c = line.indexOf('//');
|
||||||
|
const head = c === -1 ? line : line.slice(0, c);
|
||||||
|
const tail = c === -1 ? '' : `<span class="c">${line.slice(c)}</span>`;
|
||||||
|
const lit = head
|
||||||
|
.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, (m) => `<span class="s">${m}</span>`) // strings
|
||||||
|
.replace(kw, '<span class="k">$1</span>'); // keywords
|
||||||
|
return lit + tail;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// GENERATED by `npm run gen:snippets` (scripts/gen-snippets.mjs) — do not edit.
|
||||||
|
// Source-of-truth code excerpts extracted from real files via // #region showcase:<name>.
|
||||||
|
export const SNIPPETS: Record<string, string> = {
|
||||||
|
"fold": "/** Exhaustive fold: you must handle every case, checked at compile time. */\nexport function foldRemote<E, T, R>(\n rd: RemoteData<E, T>,\n h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },\n): R {\n switch (rd.tag) {\n case 'Loading':\n return h.loading();\n case 'Empty':\n return h.empty();\n case 'Failure':\n return h.failure(rd.error);\n case 'Success':\n return h.success(rd.value);\n default:\n return assertNever(rd); // add a variant → compile error until handled\n }\n}",
|
||||||
|
"machine": "export type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing\n | { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };",
|
||||||
|
"parse": "export function parsePostcode(raw: string): Result<string, Postcode> {\n const t = raw.trim().toUpperCase();\n if (!/^[1-9]\\d{3}\\s?[A-Z]{2}$/.test(t)) {\n return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);\n }\n // Normalise to \"1234 AB\" — the parser also cleans up.\n return ok(t.replace(/^(\\d{4})\\s?([A-Z]{2})$/, '$1 $2') as Postcode);\n}",
|
||||||
|
"steps": "/** The fixed step list. Number of steps never changes; questions reveal inline. */\nexport const STEPS: StepId[] = ['buitenland', 'werk', 'review'];",
|
||||||
|
"union": "export type RegistrationStatus =\n | { tag: 'Geregistreerd'; herregistratieDatum: string } // only this variant carries the date\n | { tag: 'Geschorst'; geschorstTot: string; reden: string }\n | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };",
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user