feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
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 { parseBsn } from '@shared/kernel/bsn';
|
||||
import { maskBsn } from '@shared/kernel/pii';
|
||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||
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> {
|
||||
return {
|
||||
value: () => value as T,
|
||||
status: () => status,
|
||||
error: () => error,
|
||||
hasValue: () => value !== undefined,
|
||||
reload: () => {},
|
||||
} as unknown as Resource<T>;
|
||||
}
|
||||
|
||||
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
|
||||
with the"after" where the type system rules it out. Composition-only. */
|
||||
@Component({
|
||||
selector: 'app-concepts-page',
|
||||
imports: [
|
||||
FormsModule,
|
||||
PageShellComponent,
|
||||
HeadingComponent,
|
||||
TextInputComponent,
|
||||
...ASYNC,
|
||||
SkeletonComponent,
|
||||
RegistrationSummaryComponent,
|
||||
HerregistratieWizardComponent,
|
||||
IntakeWizardComponent,
|
||||
MaskedValueComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
.section {
|
||||
margin: 0 0 3rem;
|
||||
}
|
||||
.lead {
|
||||
color: var(--rhc-color-grijs-700);
|
||||
max-width: 46rem;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
}
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--rhc-color-grijs-200, #e5e5e5);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
background: #fff;
|
||||
}
|
||||
.card--bad {
|
||||
border-color: var(--rhc-color-rood-300, #f0b4b4);
|
||||
}
|
||||
.card--good {
|
||||
border-color: var(--rhc-color-groen-300, #b4e0b4);
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.tag::before {
|
||||
content: '';
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tag.bad {
|
||||
color: var(--rhc-color-rood-600, #a30000);
|
||||
}
|
||||
.tag.bad::before {
|
||||
background: var(--rhc-color-rood-500, #d52b1e);
|
||||
}
|
||||
.tag.good {
|
||||
color: var(--rhc-color-groen-700, #277337);
|
||||
}
|
||||
.tag.good::before {
|
||||
background: var(--rhc-color-groen-500, #39870c);
|
||||
}
|
||||
.tag.plain {
|
||||
color: var(--rhc-color-grijs-700);
|
||||
}
|
||||
.tag.plain::before {
|
||||
display: none;
|
||||
}
|
||||
pre {
|
||||
background: #1e2430;
|
||||
color: #e6e9ef;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
pre .k {
|
||||
color: #c792ea;
|
||||
}
|
||||
pre .s {
|
||||
color: #c3e88d;
|
||||
}
|
||||
pre .c {
|
||||
color: #7e8aa0;
|
||||
font-style: italic;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.9rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
/* live state diagram */
|
||||
.machine {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.node {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rhc-color-grijs-300, #ccc);
|
||||
font-size: 0.82rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.node.on {
|
||||
background: var(--rhc-color-hemelblauw-100, #e5f1fb);
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.pill {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
background: var(--rhc-color-grijs-100, #f3f3f3);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.pill.extra {
|
||||
background: var(--rhc-color-geel-100, #fff6d6);
|
||||
border: 1px dashed var(--rhc-color-geel-600, #c79a00);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--rhc-color-grijs-400, #999);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
||||
<p class="lead">
|
||||
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
|
||||
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
|
||||
</p>
|
||||
|
||||
<!-- 1. Discriminated unions -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
||||
<div class="cols">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — vlakke interface</p>
|
||||
<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
|
||||
simpelweg niet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. RemoteData fold -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||
<p class="lead">
|
||||
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Vier toestanden, één molecuul</p>
|
||||
<p class="tag plain">Loading</p>
|
||||
<app-async [resource]="loadingRes"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
||||
><ng-template appAsyncLoading
|
||||
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
||||
></app-async>
|
||||
<p class="tag plain">Empty</p>
|
||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||
>
|
||||
<p class="tag plain">Failure</p>
|
||||
<app-async [resource]="errorRes"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||
>
|
||||
<p class="tag plain">Success</p>
|
||||
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
||||
><ng-template appAsyncLoaded
|
||||
><ul>
|
||||
@for (i of successRes.value(); track i) {
|
||||
<li>{{ i }}</li>
|
||||
}
|
||||
</ul></ng-template
|
||||
></app-async
|
||||
>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De exhaustieve fold</p>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Parse, don't validate -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
||||
<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()"
|
||||
(ngModelChange)="raw.set($event)"
|
||||
name="pc"
|
||||
placeholder="Typ een postcode, bijv. 1234 AB"
|
||||
/>
|
||||
</div>
|
||||
@let r = parsed();
|
||||
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
||||
@if (r.ok) {
|
||||
<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 {
|
||||
<div animate.enter="app-item-enter">
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ r.error }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. State machine / wizard (live state diagram) -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||
<p class="lead">
|
||||
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
|
||||
huidige.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — losse booleans</p>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<app-herregistratie-wizard #w />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Fixed steps, questions revealed inline -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2"
|
||||
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
||||
>
|
||||
<p class="lead">
|
||||
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
||||
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of
|
||||
vul weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N"
|
||||
blijft gelijk.
|
||||
</p>
|
||||
<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>
|
||||
@if (!last) {
|
||||
<span class="arrow">→</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="note">
|
||||
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
||||
of verdwijnen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De wizard</p>
|
||||
<app-intake-wizard #iw />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. PII: mask + parse -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
||||
<p class="lead">
|
||||
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
|
||||
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
|
||||
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Maskeren — atom</p>
|
||||
<p>
|
||||
BSN:
|
||||
<app-masked-value
|
||||
[value]="bsnShown()"
|
||||
[canReveal]="true"
|
||||
revealLabel="Toon BSN"
|
||||
(reveal)="bsnRevealed.set(true)"
|
||||
/>
|
||||
</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['mask']"></pre>
|
||||
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
|
||||
</figure>
|
||||
<p class="note">
|
||||
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
||||
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
||||
bij elke gebruiker.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="tag good">Parse (elfproef) → Result</p>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
[ngModel]="bsnRaw()"
|
||||
(ngModelChange)="bsnRaw.set($event)"
|
||||
name="bsn"
|
||||
placeholder="Typ een BSN, bijv. 123456782"
|
||||
/>
|
||||
@let b = bsnParsed();
|
||||
@if (bsnRaw()) {
|
||||
<div animate.enter="app-item-enter">
|
||||
@if (b.ok) {
|
||||
<p class="tag good">ok</p>
|
||||
<pre>Bsn ="{{ b.value }}"</pre>
|
||||
} @else {
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ b.error }}</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['parseBsn']"></pre>
|
||||
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class ConceptsPage {
|
||||
isEmpty = (v: string[]) => !v || v.length === 0;
|
||||
|
||||
doorgehaald: Registration = {
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Dr. A. (Anna) de Vries',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
|
||||
};
|
||||
|
||||
loadingRes = fakeResource<string[]>('loading');
|
||||
emptyRes = fakeResource<string[]>('resolved', []);
|
||||
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
||||
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
||||
|
||||
raw = signal('');
|
||||
parsed = computed(() => parsePostcode(this.raw()));
|
||||
|
||||
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
|
||||
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
||||
demoBsn = '123456782';
|
||||
bsnRevealed = signal(false);
|
||||
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
||||
bsnRaw = signal('');
|
||||
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
||||
|
||||
// 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 ? 🤷`,
|
||||
};
|
||||
|
||||
/** 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)]),
|
||||
);
|
||||
|
||||
/** 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',
|
||||
parseBsn: 'shared/kernel/bsn.ts',
|
||||
mask: 'shared/kernel/pii.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,11 @@
|
||||
// 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 };",
|
||||
"mask": "/** Keep the last `keep` characters, mask the rest with `*`. */\nexport function maskTail(value: string, keep: number): string {\n if (value.length <= keep) return '*'.repeat(value.length);\n return '*'.repeat(value.length - keep) + value.slice(-keep);\n}\n\n/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */\nexport function maskBsn(value: string): string {\n return maskTail(value, 3);\n}",
|
||||
"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}",
|
||||
"parseBsn": "export function parseBsn(raw: string): Result<string, Bsn> {\n const t = raw.trim();\n if (!/^\\d{9}$/.test(t)) {\n return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);\n }\n const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);\n if (t === '000000000' || sum % 11 !== 0) {\n return err(\n $localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,\n );\n }\n return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef\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