import { RichTextBlock } from '@shared/kernel/rich-text'; /** * Placeholder fields and the PURE linter over them. * * `lintPlaceholders` is a total, effect-free function of (content, valid set). It * runs identically on the client (for live UX) and could run on the server (for * authority) — same rules, same config, so the two agree by construction. The FE * never STORES its output: diagnostics are a `computed()` over content (see * `brief.ts` selectors), the same "derive, don't store" discipline as wizard step * validity. */ /** A placeholder field the template knows about. `fillable`/`deprecated` default to the healthy case; the seed flips them to exercise the not-fillable/deprecated rules. */ export interface PlaceholderDef { readonly key: string; // e.g. 'naam_zorgverlener' readonly label: string; // human label for the insert menu, e.g. 'Naam zorgverlener' readonly autoResolvable: boolean; // server can fill from case data (name, date, …) readonly fillable?: boolean; // default true; false → not resolvable for this beroep/case type readonly deprecated?: boolean; // default false; true → retired but still referenceable in old snapshots } export type DiagnosticSeverity = 'error' | 'warning'; export type DiagnosticCode = | 'malformed' // raw braces in a text node (a paste that should have been a chip) | 'unknown-placeholder' // well-formed key not in the valid set | 'not-fillable' // key exists but isn't resolvable for this case type / beroep | 'deprecated' // key was valid once but the template no longer offers it | 'unresolved-at-send'; // manual (non-auto-resolvable) placeholder still to be filled export interface DiagnosticLocation { readonly blockId: string; readonly paragraphIndex: number; readonly nodeIndex: number; } export interface Diagnostic { readonly severity: DiagnosticSeverity; readonly code: DiagnosticCode; readonly placeholderKey?: string; readonly location: DiagnosticLocation; readonly message: string; // human-readable, Dutch } /** `error` blocks save (author time) and send (send time); `warning` is surfaced but allowed. */ export function severityOf(code: DiagnosticCode): DiagnosticSeverity { switch (code) { case 'malformed': case 'unknown-placeholder': case 'not-fillable': return 'error'; case 'deprecated': case 'unresolved-at-send': return 'warning'; } } // Raw `{{` or `}}` in text — the only way a malformed placeholder can exist, since // menu insertion always produces a proper placeholder NODE. Paste safety net. const RAW_BRACES = /\{\{|\}\}/; function messageFor(code: DiagnosticCode, key?: string): string { switch (code) { case 'malformed': return $localize`:@@brief.lint.malformed:Deze tekst bevat losse accolades ({{ of }}). Voeg een veld toe via het menu in plaats van het te typen.`; case 'unknown-placeholder': return $localize`:@@brief.lint.unknown:Onbekend veld “${key}:key:”. Dit veld hoort niet bij dit sjabloon.`; case 'not-fillable': return $localize`:@@brief.lint.notFillable:Veld “${key}:key:” kan niet worden ingevuld voor dit beroep.`; case 'deprecated': return $localize`:@@brief.lint.deprecated:Veld “${key}:key:” is verouderd en wordt niet meer aangeboden.`; case 'unresolved-at-send': return $localize`:@@brief.lint.unresolved:Veld “${key}:key:” wordt handmatig ingevuld en is nog leeg.`; } } function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic { return { severity: severityOf(code), code, placeholderKey: key, location, message: messageFor(code, key) }; } /** * Lint one block against the template's placeholder set. Pure: given the same * content + valid set + blockId it always returns the same diagnostics, in * document order. One diagnostic per node at most (most-severe wins). */ export function lintPlaceholders( content: RichTextBlock, valid: readonly PlaceholderDef[], blockId: string, ): Diagnostic[] { const byKey = new Map(valid.map((p) => [p.key, p])); const out: Diagnostic[] = []; content.paragraphs.forEach((p, paragraphIndex) => { p.nodes.forEach((n, nodeIndex) => { const location: DiagnosticLocation = { blockId, paragraphIndex, nodeIndex }; if (n.type === 'text') { if (RAW_BRACES.test(n.text)) out.push(diag('malformed', location)); return; } if (n.type !== 'placeholder') return; // lineBreak — nothing to check const def = byKey.get(n.key); if (!def) out.push(diag('unknown-placeholder', location, n.key)); else if (def.fillable === false) out.push(diag('not-fillable', location, n.key)); else if (def.deprecated) out.push(diag('deprecated', location, n.key)); else if (!def.autoResolvable) out.push(diag('unresolved-at-send', location, n.key)); // auto-resolvable & fillable & not deprecated → clean (filled by the server at send) }); }); return out; }