One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
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;
|
|
}
|