Richtlinien & Verfahren (Phase 1): Import, Rendering-Engine, Bibliothek, Coverage
Fundament des VDA-ISA-2027-Richtlinienmoduls (Spec §1–9):
- Datenmodell: PolicyDocument, PolicyRequirement, PolicyVariable (Variablen +
Feature-Flags), PolicyBaselineParam, PolicyEvidence — inkl. RLS + Tenant-Guard
- Seed-Importer (import-policies.ts): liest die echten .md-Dateien (15 Richtlinien
L00/R01–R14 + 11 Verfahren), mapping.json (120 Anforderungen/46 Controls),
variables.schema.json (53 Variablen/Flags), Technische-Sicherheits-Baseline
(31 BL-Parameter) und Nachweisregister; idempotent pro Mandant
- 6 im Vorlagenpaket beschädigte Variablen-Tokens (VA-08/09/10/12/13) repariert
(dokumentiert im README des Übergabepakets)
- Rendering-Engine (policy-render.ts, Handlebars + marked): verschachtelte
{{#if FLAG}}, {{VARIABLE}}, {{LINK:…}}-Deeplinks, Hidden-Anker + BL-Referenzen
im Lesemodus entfernt (Wert bleibt), zentral verwaltete Abschnitte unterdrückt,
wiederholtes „Umsetzung bei <Org>" reduziert (§7a); lenienter Fallback +
Residue-Check über alle Flag-Kombinationen (analog _verify.py)
- UI: Bibliothek mit Typ-Chips/KPIs, Lesemodus-Popup (einklappbare Info-Tabelle,
Control-Chips, Richtlinie↔Verfahren-Verlinkung), Coverage-Matrix
(Control → Richtlinie → MUSS/SOLL → Verfahren → Anforderungs-IDs)
- Nav-Punkt „Richtlinien" aktiviert; de/en-Übersetzungen
Verifiziert: Import 28 Dokumente/120 Anforderungen; Rendering rückstandsfrei
über alle Flag-Kombinationen; Bibliothek, Lesemodus (R08 nested flags), Coverage
im Browser.
Später (Phase 2+): Bearbeiten/Freigabe-Workflow mit Versionierung, verwaltete
Tabellen (Krypto-/Risiko-/Klassifizierungsregister), Anwender-Handbuch,
DOCX/PDF-Export, Word-Upload, KI-Wizard, zentrale Baseline-/Variablen-Einstellseite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import Handlebars from "handlebars";
|
||||
import { marked } from "marked";
|
||||
|
||||
/**
|
||||
* Rendering-Engine für Richtlinien & Verfahren (VDA-ISA 2027 Vorlagenpaket).
|
||||
*
|
||||
* Pipeline (§7 der Spezifikation):
|
||||
* 1. {{LINK:…}} vorab auflösen (Doppelpunkt/#-Ziele würde Handlebars sonst brechen).
|
||||
* 2. Handlebars mit noEscape:true → verschachtelte {{#if FLAG}} und {{VARIABLE}}.
|
||||
* 3. Lesemodus-Nachbearbeitung: Hidden-Anker entfernen, wiederholtes
|
||||
* „Umsetzung bei <Org>" reduzieren, zentral verwaltete Abschnitte
|
||||
* unterdrücken (§7a.3), BL-Referenzen entfernen (Wert bleibt, §7).
|
||||
* 4. Markdown → HTML (marked).
|
||||
*/
|
||||
|
||||
export type RenderContext = Record<string, string | boolean>;
|
||||
|
||||
/** Baut den Render-Kontext (Variablen + Flags) aus den PolicyVariable-Zeilen. */
|
||||
export function buildContext(vars: { key: string; kind: string; value: string }[]): RenderContext {
|
||||
const ctx: RenderContext = {};
|
||||
for (const v of vars) ctx[v.key] = v.kind === "boolean" ? v.value === "true" : v.value;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Abschnitte, die das Tool zentral verwaltet und daher im Lese-/Exportmodus ausblendet (§7a.3)
|
||||
const SUPPRESS_SECTIONS = [
|
||||
"Verwandte Dokumente",
|
||||
"Nachweise",
|
||||
"Überprüfung und Aktualisierung",
|
||||
"Verbindlichkeit",
|
||||
];
|
||||
|
||||
/** {{LINK:ZIEL}} → interner Deep-Link nach /policies. */
|
||||
export function resolveLink(target: string): { href: string; label: string } {
|
||||
const [code, anchor] = target.split("#");
|
||||
const q = (c: string) => `/policies?doc=${encodeURIComponent(c)}${anchor ? `#${anchor}` : ""}`;
|
||||
switch (code) {
|
||||
case "NACHWEISREGISTER":
|
||||
return { href: "/policies?doc=NACHWEIS", label: "Nachweisregister" };
|
||||
case "ISA_MAPPING":
|
||||
return { href: "/policies?view=coverage", label: "ISA-Mapping-Matrix" };
|
||||
case "BASELINE":
|
||||
return { href: "/policies?doc=BASELINE", label: "Technische Sicherheits-Baseline" };
|
||||
default:
|
||||
return { href: q(code), label: anchor ? `${code} §${anchor}` : code };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLinks(md: string): string {
|
||||
return md.replace(/\{\{LINK:([^}]+)\}\}/g, (_m, target: string) => {
|
||||
const { href, label } = resolveLink(target.trim());
|
||||
return `[${label}](${href})`;
|
||||
});
|
||||
}
|
||||
|
||||
function stripHiddenAnchors(md: string): string {
|
||||
return md.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
/** §7a.1 — wiederkehrende Zwischenüberschrift „Umsetzung bei <Org>" auf ein dezentes „Umsetzung:" reduzieren. */
|
||||
function reduceImplHeadings(md: string): string {
|
||||
return md.replace(/^\*\*Umsetzung bei .+?\*\*\s*$/gm, "_Umsetzung:_");
|
||||
}
|
||||
|
||||
/** §7a.3 — zentral verwaltete Abschnitte ausblenden (## bis zur nächsten ##-Überschrift). */
|
||||
function suppressSections(md: string): string {
|
||||
const lines = md.split("\n");
|
||||
const out: string[] = [];
|
||||
let skipping = false;
|
||||
for (const line of lines) {
|
||||
const h2 = line.match(/^##\s+(?:\d+\.?\s*)?(.+?)\s*$/);
|
||||
if (h2) {
|
||||
const title = h2[1].trim();
|
||||
skipping = SUPPRESS_SECTIONS.some((s) => title.toLowerCase().startsWith(s.toLowerCase()));
|
||||
}
|
||||
if (!skipping) out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* §7 — Baseline-Referenzen (BL-…) im Lesemodus entfernen; nur der konkrete
|
||||
* Variablenwert bleibt. Behandelt Klammerzusätze „(BL-IAM-01)", Kombiformen
|
||||
* „(BL-CRY-01/04)", einleitende Wendungen „nach/gemäß/laut BL-…" und Resttokens.
|
||||
*/
|
||||
function stripBaselineTokens(md: string): string {
|
||||
// "(… BL-XXX-01 …)" — Klammern, die BL-Referenzen enthalten, ganz entfernen
|
||||
md = md.replace(/\s*\([^()]*BL-[A-Z]+-\d+[^()]*\)/g, "");
|
||||
// "nach/gemäß/laut BL-XXX-01" (auch /04-Kombi)
|
||||
md = md.replace(/\s*(?:nach|gemäß|gemaess|laut|per)\s+BL-[A-Z]+-\d+(?:\/\d+)?/gi, "");
|
||||
// verbleibende bloße Tokens
|
||||
md = md.replace(/\s*BL-[A-Z]+-\d+(?:\/\d+)?/g, "");
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Nachsichtiger Fallback-Renderer (rekursiv, verschachtelungsfähig) für defekte Vorlagen. */
|
||||
function lenientRender(md: string, ctx: RenderContext): string {
|
||||
const ifPat = /\{\{#if (\w+)\}\}((?:(?!\{\{#if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g;
|
||||
let prev = "";
|
||||
while (prev !== md) {
|
||||
prev = md;
|
||||
md = md.replace(ifPat, (_m, flag: string, inner: string) => (ctx[flag] ? inner : ""));
|
||||
}
|
||||
return md.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => (key in ctx ? String(ctx[key]) : ""));
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
readMode?: boolean; // Default true — Lesefassung ohne Anker/BL/verwaltete Abschnitte
|
||||
stripBaseline?: boolean; // Default = readMode; für das Baseline-Register selbst false
|
||||
}
|
||||
|
||||
/** Vorlagen-Markdown → gerenderte Markdown-Lesefassung. */
|
||||
export function renderPolicyMarkdown(raw: string, ctx: RenderContext, opts: RenderOptions = {}): string {
|
||||
const readMode = opts.readMode ?? true;
|
||||
const stripBaseline = opts.stripBaseline ?? readMode;
|
||||
|
||||
let md = resolveLinks(raw);
|
||||
try {
|
||||
const tpl = Handlebars.compile(md, { noEscape: true });
|
||||
md = tpl(ctx);
|
||||
} catch {
|
||||
// Defensiv: beschädigte Tokens dürfen keine ganze Seite zerlegen —
|
||||
// nachsichtiger, verschachtelungsfähiger Fallback (analog _verify.py).
|
||||
md = lenientRender(md, ctx);
|
||||
}
|
||||
|
||||
if (readMode) {
|
||||
md = stripHiddenAnchors(md);
|
||||
md = reduceImplHeadings(md);
|
||||
md = suppressSections(md);
|
||||
if (stripBaseline) md = stripBaselineTokens(md);
|
||||
}
|
||||
return md.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
/** Vorlagen-Markdown → HTML (Lesemodus). */
|
||||
export function renderPolicyHtml(raw: string, ctx: RenderContext, opts: RenderOptions = {}): string {
|
||||
const md = renderPolicyMarkdown(raw, ctx, opts);
|
||||
return marked.parse(md, { async: false }) as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt ein Dokument in Titel, Info-Tabelle (Kopf) und Rumpf (ab erster
|
||||
* `## `-Überschrift). Ermöglicht die einklappbare Dokumenten-Info (§7a.5).
|
||||
*/
|
||||
export function splitPolicyDoc(raw: string): { title: string; infoMd: string; bodyMd: string } {
|
||||
const titleM = raw.match(/^#\s+(.+?)\s*$/m);
|
||||
const title = titleM ? titleM[1].trim() : "";
|
||||
const idx = raw.search(/^##\s+/m);
|
||||
const head = idx >= 0 ? raw.slice(0, idx) : raw;
|
||||
const bodyMd = idx >= 0 ? raw.slice(idx) : "";
|
||||
const tableM = head.match(/(\|[^\n]*\|\n(?:\|[^\n]*\|\n?)+)/);
|
||||
return { title, infoMd: tableM ? tableM[1] : "", bodyMd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft eine gerenderte Fassung auf Rückstände (offene {{…}}, «MISS»-Marker).
|
||||
* Referenz: `_verify.py`-Gate über alle Flag-Kombinationen.
|
||||
*/
|
||||
export function findRenderResidue(rendered: string): string[] {
|
||||
const issues: string[] = [];
|
||||
const open = rendered.match(/\{\{[^}]*\}\}/g);
|
||||
if (open) issues.push(...open);
|
||||
return issues;
|
||||
}
|
||||
Reference in New Issue
Block a user