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:
@@ -54,8 +54,10 @@ export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFA
|
||||
return r.ok && r.value < scholingThreshold;
|
||||
}
|
||||
|
||||
// #region showcase:steps
|
||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||
// #endregion showcase:steps
|
||||
|
||||
/** Per-field error map: one message per question, since a step holds several. */
|
||||
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
|
||||
* an invalid draft, a success screen with errors) are unrepresentable.
|
||||
*/
|
||||
// #region showcase:machine
|
||||
export type ChangeRequestState =
|
||||
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Editing'; draft: Draft; errors: Errors } // draft/errors exist ONLY while editing
|
||||
| { tag: 'Submitting'; data: Valid } // carries the parsed value, no errors
|
||||
| { tag: 'Submitted'; data: Valid; referentie: string }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
// #endregion showcase:machine
|
||||
|
||||
export const initial: ChangeRequestState = {
|
||||
tag: 'Editing',
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* old flat interface allowed that impossible combination — this makes it
|
||||
* unrepresentable.
|
||||
*/
|
||||
// #region showcase:union
|
||||
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: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
|
||||
// #endregion showcase:union
|
||||
|
||||
/** Just the discriminant — for atoms that only need the label/color. */
|
||||
export type StatusTag = RegistrationStatus['tag'];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
*/
|
||||
export type Postcode = Brand<string, 'Postcode'>;
|
||||
|
||||
// #region showcase:parse
|
||||
export function parsePostcode(raw: string): Result<string, Postcode> {
|
||||
const t = raw.trim().toUpperCase();
|
||||
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.
|
||||
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' };
|
||||
}
|
||||
|
||||
// #region showcase:fold
|
||||
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||
export function foldRemote<E, T, R>(
|
||||
rd: RemoteData<E, T>,
|
||||
@@ -42,9 +43,10 @@ export function foldRemote<E, T, R>(
|
||||
case 'Success':
|
||||
return h.success(rd.value);
|
||||
default:
|
||||
return assertNever(rd);
|
||||
return assertNever(rd); // add a variant → compile error until handled
|
||||
}
|
||||
}
|
||||
// #endregion showcase:fold
|
||||
|
||||
// --- Combinators -----------------------------------------------------------
|
||||
// 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 { Registration } from '@registratie/domain/registration';
|
||||
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. */
|
||||
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);
|
||||
color: var(--rhc-color-hemelblauw-700, #00567d);
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -182,13 +196,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
<div class="cols">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — vlakke interface</p>
|
||||
<pre [innerHTML]="unionBad"></pre>
|
||||
<pre [innerHTML]="code['unionBad']"></pre>
|
||||
<p class="note">
|
||||
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<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" />
|
||||
<p class="note">
|
||||
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 class="card card--good">
|
||||
<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">
|
||||
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
||||
afhandelt.
|
||||
@@ -250,6 +271,10 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<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
|
||||
inputId="pc"
|
||||
[ngModel]="raw()"
|
||||
@@ -261,14 +286,18 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
@let r = parsed();
|
||||
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
||||
@if (r.ok) {
|
||||
<p class="tag good">ok</p>
|
||||
<pre>Postcode ="{{ r.value }}"</pre>
|
||||
<p class="note">
|
||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||
</p>
|
||||
<div animate.enter="app-item-enter">
|
||||
<p class="tag good">ok</p>
|
||||
<pre>Postcode ="{{ r.value }}"</pre>
|
||||
<p class="note">
|
||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ r.error }}</pre>
|
||||
<div animate.enter="app-item-enter">
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ r.error }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,13 +313,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
<div class="cols">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — losse booleans</p>
|
||||
<pre [innerHTML]="machineBad"></pre>
|
||||
<pre [innerHTML]="code['machineBad']"></pre>
|
||||
<p class="note">
|
||||
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<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">
|
||||
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
||||
<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="card card--good">
|
||||
<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">
|
||||
@for (s of iw.steps; track s; let last = $last) {
|
||||
<span class="pill">{{ s }}</span>
|
||||
@@ -357,20 +394,30 @@ export class ConceptsPage {
|
||||
raw = signal('');
|
||||
parsed = computed(() => parsePostcode(this.raw()));
|
||||
|
||||
unionBad = `<span class="k">interface</span> Registration {
|
||||
status: <span class="s">'Geregistreerd'</span> | <span class="s">'Doorgehaald'</span>;
|
||||
herregistratieDatum: string; <span class="c">// altijd aanwezig 😬</span>
|
||||
}`;
|
||||
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
||||
private readonly illustrations: Record<string, string> = {
|
||||
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, {
|
||||
loading: () => spinner,
|
||||
empty: () => <span class="s">'geen data'</span>,
|
||||
failure: (e) => alert(e),
|
||||
success: (v) => render(v),
|
||||
}); <span class="c">// mist er één → compile-fout</span>`;
|
||||
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
|
||||
by gen:snippets — they can't drift), the illustrations are authored above. */
|
||||
protected readonly code: Record<string, string> = Object.fromEntries(
|
||||
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
|
||||
);
|
||||
|
||||
machineBad = `submitting = <span class="k">signal</span>(false);
|
||||
submitted = <span class="k">signal</span>(false);
|
||||
errors = <span class="k">signal</span><...>({});
|
||||
<span class="c">// submitting === true && errors.size > 0 ? 🤷</span>`;
|
||||
/** The real file each linked snippet is extracted from (shown as a caption). */
|
||||
protected readonly src: Record<string, string> = {
|
||||
union: 'registratie/domain/registration.ts',
|
||||
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