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:
2026-07-07 13:35:59 +02:00
co-authored by Claude Opus 4.8
parent 2706424c08
commit 89b0e3a5a5
52 changed files with 7627 additions and 1 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ export default async function AppLayout({
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: false },
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: true },
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: true },
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
+233
View File
@@ -0,0 +1,233 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
import { FilterTabs } from "@/components/filter-tabs";
import { PolicyReadModal } from "@/components/policy-modals";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const DOC_TYPES = ["LEITLINIE", "RICHTLINIE", "VERFAHREN", "REGISTER"] as const;
const TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie",
RICHTLINIE: "Richtlinie",
VERFAHREN: "Verfahren",
REGISTER: "Register",
HANDBUCH: "Handbuch",
EIGENES: "Eigenes",
};
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
FREIGEGEBEN: "ok",
IN_FREIGABE: "info",
ENTWURF: "warn",
ARCHIVIERT: "mut",
};
const STATUS_LABEL: Record<string, string> = {
FREIGEGEBEN: "Freigegeben",
IN_FREIGABE: "In Freigabe",
ENTWURF: "Entwurf",
ARCHIVIERT: "Archiviert",
};
export default async function PoliciesPage({
searchParams,
}: {
searchParams: Promise<{ type?: string; q?: string; doc?: string; view?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "policy:read");
const t = await getTranslations("policies");
const db = dbForTenant(session.user.tenantId);
const params = await searchParams;
const [docs, requirements] = await Promise.all([
db.policyDocument.findMany({ orderBy: { orderIdx: "asc" } }),
db.policyRequirement.findMany({ orderBy: [{ control: "asc" }, { reqId: "asc" }] }),
]);
// Ableitung: je Richtlinie ihre Controls + operationalisierende Verfahren
const byPolicy = new Map<string, { controls: Set<string>; vas: Set<string> }>();
for (const r of requirements) {
if (!byPolicy.has(r.policyCode)) byPolicy.set(r.policyCode, { controls: new Set(), vas: new Set() });
const e = byPolicy.get(r.policyCode)!;
e.controls.add(r.control);
r.vaCodes.forEach((v) => e.vas.add(v));
}
/* ── Modal (Lesemodus) ── */
const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null;
const modalData = modalDoc
? {
doc: modalDoc,
variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(),
relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(),
}
: null;
const isCoverage = params.view === "coverage";
const head = (
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("sub")}
actions={
<FilterTabs
tabs={[
{ href: "/policies", label: t("library"), active: !isCoverage },
{ href: "/policies?view=coverage", label: t("coverage"), active: isCoverage },
]}
/>
}
/>
);
/* ── Coverage-Matrix (Control → Richtlinie → Verfahren) ── */
if (isCoverage) {
const byControl = new Map<string, { policy: string; must: number; should: number; ids: string[]; vas: Set<string> }>();
for (const r of requirements) {
if (!byControl.has(r.control)) byControl.set(r.control, { policy: r.policyCode, must: 0, should: 0, ids: [], vas: new Set() });
const e = byControl.get(r.control)!;
if (r.obligation === "MUSS") e.must++;
else e.should++;
e.ids.push(r.reqId.split("-").slice(1).join("-"));
r.vaCodes.forEach((v) => e.vas.add(v));
}
const rows = [...byControl.entries()];
return (
<main className="flex-1 p-6">
{head}
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: rows.length, reqs: requirements.length })}</p>
<div className="shadow-card mt-3 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("control")}</TableHead>
<TableHead>{t("policy")}</TableHead>
<TableHead>MUSS</TableHead>
<TableHead>SOLL</TableHead>
<TableHead>{t("procedures")}</TableHead>
<TableHead>{t("reqIds")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(([control, e]) => (
<TableRow key={control}>
<TableCell className="font-bold">{control}</TableCell>
<TableCell>
<Link href={`/policies?doc=${e.policy}`} className="font-semibold hover:underline">{e.policy}</Link>
</TableCell>
<TableCell>{e.must}</TableCell>
<TableCell className="text-muted-foreground">{e.should || ""}</TableCell>
<TableCell>
<span className="flex flex-wrap gap-1">
{e.vas.size === 0 ? (
<span className="text-muted-foreground"></span>
) : (
[...e.vas].sort().map((va) => (
<Link key={va} href={`/policies?doc=${va}`}>
<Pill tone="info">{va}</Pill>
</Link>
))
)}
</span>
</TableCell>
<TableCell className="max-w-64 truncate text-[12px] text-muted-foreground">{e.ids.join(", ")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalData && <PolicyReadModal data={modalData} backHref="/policies?view=coverage" />}
</main>
);
}
/* ── Bibliothek ── */
const activeType = DOC_TYPES.includes(params.type as (typeof DOC_TYPES)[number]) ? params.type : null;
const q = params.q?.toLowerCase();
const filtered = docs.filter(
(d) =>
(!activeType || d.type === activeType) &&
(!q || d.title.toLowerCase().includes(q) || d.code.toLowerCase().includes(q))
);
const kpiControls = new Set(requirements.map((r) => r.control)).size;
const kpiMust = requirements.filter((r) => r.obligation === "MUSS").length;
const kpiShould = requirements.length - kpiMust;
const typeHref = (v: string | null) => `/policies${v ? `?type=${v}` : ""}`;
return (
<main className="flex-1 p-6">
{head}
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label={t("kpiDocs")} value={docs.length} trend={t("kpiDocsTrend")} />
<KpiCard label={t("kpiControls")} value={kpiControls} trend={t("kpiControlsTrend")} />
<KpiCard label={t("kpiMust")} value={kpiMust} trend="MUSS" />
<KpiCard label={t("kpiShould")} value={kpiShould} trend="SOLL" />
</div>
<div className="mt-4">
<FilterTabs
tabs={[
{ href: typeHref(null), label: t("all"), active: !activeType },
...DOC_TYPES.map((v) => ({ href: typeHref(v), label: TYPE_LABEL[v], active: activeType === v })),
]}
/>
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("code")}</TableHead>
<TableHead>{t("docTitle")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("version")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("coverageCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
</TableRow>
)}
{filtered.map((d) => {
const cov = byPolicy.get(d.code);
return (
<TableRow key={d.id}>
<TableCell className="text-muted-foreground">{d.code}</TableCell>
<TableCell>
<Link href={`/policies?doc=${d.code}`} className="font-bold hover:underline">{d.title}</Link>
</TableCell>
<TableCell><Tag>{TYPE_LABEL[d.type]}</Tag></TableCell>
<TableCell className="text-muted-foreground">v{d.version}</TableCell>
<TableCell><Pill tone={STATUS_TONE[d.status]}>{STATUS_LABEL[d.status]}</Pill></TableCell>
<TableCell className="text-[12px] text-muted-foreground">
{d.type === "RICHTLINIE" && cov ? t("controlsN", { n: cov.controls.size })
: d.type === "VERFAHREN" && d.policyCode ? `${d.policyCode}`
: ""}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{modalData && <PolicyReadModal data={modalData} backHref="/policies" />}
</main>
);
}
+86
View File
@@ -203,3 +203,89 @@ body {
.react-flow__edge-text {
fill: var(--muted-foreground);
}
/* Gerenderte Richtlinien/Verfahren (Lesemodus) — Typografie ohne Plugin */
.policy-prose {
font-size: 13.5px;
line-height: 1.7;
color: var(--foreground);
}
.policy-prose h2 {
font-family: var(--font-heading, inherit);
font-size: 16px;
font-weight: 700;
margin: 1.6em 0 0.5em;
padding-top: 0.9em;
border-top: 1px solid var(--border);
scroll-margin-top: 1rem;
}
.policy-prose h2:first-child {
border-top: 0;
padding-top: 0;
margin-top: 0;
}
.policy-prose h3 {
font-family: var(--font-heading, inherit);
font-size: 14px;
font-weight: 700;
margin: 1.3em 0 0.4em;
}
.policy-prose h4 {
font-size: 13px;
font-weight: 700;
margin: 1em 0 0.3em;
}
.policy-prose p {
margin: 0.55em 0;
}
.policy-prose ul,
.policy-prose ol {
margin: 0.5em 0;
padding-left: 1.3em;
list-style: revert;
}
.policy-prose li {
margin: 0.25em 0;
}
.policy-prose strong {
font-weight: 700;
}
.policy-prose a {
color: var(--info);
text-decoration: none;
}
.policy-prose a:hover {
text-decoration: underline;
}
.policy-prose blockquote {
border-left: 3px solid var(--band-brd);
background: var(--band);
color: var(--band-text);
padding: 0.5em 0.9em;
margin: 0.8em 0;
border-radius: 0 8px 8px 0;
font-size: 12.5px;
}
.policy-prose table {
width: 100%;
border-collapse: collapse;
margin: 0.9em 0;
font-size: 12.5px;
}
.policy-prose th,
.policy-prose td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
vertical-align: top;
}
.policy-prose th {
background: var(--elevated);
font-weight: 700;
}
.policy-prose code {
background: var(--elevated);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
}
+110
View File
@@ -0,0 +1,110 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { PolicyDocument, PolicyVariable } from "@prisma/client";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill, Tag } from "@/components/mockup-ui";
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
const TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie",
RICHTLINIE: "Richtlinie",
VERFAHREN: "Verfahren",
REGISTER: "Register",
HANDBUCH: "Handbuch",
EIGENES: "Eigenes",
};
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
FREIGEGEBEN: "ok",
IN_FREIGABE: "info",
ENTWURF: "warn",
ARCHIVIERT: "mut",
};
const STATUS_LABEL: Record<string, string> = {
FREIGEGEBEN: "Freigegeben",
IN_FREIGABE: "In Freigabe",
ENTWURF: "Entwurf",
ARCHIVIERT: "Archiviert",
};
export interface PolicyReadData {
doc: PolicyDocument;
variables: PolicyVariable[];
/** ISA-Controls dieser Richtlinie (für Chips). */
controls: string[];
/** Operationalisierende Verfahren (Codes) — aus den Anforderungen der Richtlinie. */
relatedVas: string[];
}
/** Lesemodus-Popup: gerendertes Dokument aus der echten Markdown-Vorlage. */
export async function PolicyReadModal({ data, backHref }: { data: PolicyReadData; backHref: string }) {
const t = await getTranslations("policies");
const { doc, variables, controls, relatedVas } = data;
const ctx = buildContext(variables);
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
const stripBaseline = doc.code !== "BASELINE";
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
return (
<Modal
title={`${doc.code} · ${doc.title}`}
sub={TYPE_LABEL[doc.type]}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{TYPE_LABEL[doc.type]}</Tag>
<Pill tone={STATUS_TONE[doc.status]}>{STATUS_LABEL[doc.status]}</Pill>
<span className="text-[12px] text-muted-foreground">v{doc.version}</span>
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>}
>
<div className="max-h-[72vh] overflow-y-auto p-5">
{/* Kontext: Controls / operationalisiert-durch */}
{(controls.length > 0 || doc.policyCode || relatedVas.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2 text-[12px]">
{doc.type === "VERFAHREN" && doc.policyCode && (
<span className="text-muted-foreground">
{t("operationalizes")}{" "}
<Link href={`/policies?doc=${doc.policyCode}`} className="font-semibold text-[var(--info)] hover:underline">
{doc.policyCode}
</Link>
</span>
)}
{controls.map((c) => (
<Pill key={c} tone="violet">
ISA {c}
</Pill>
))}
{doc.type !== "VERFAHREN" && relatedVas.length > 0 && (
<span className="ml-1 flex flex-wrap items-center gap-1.5 text-muted-foreground">
{t("procedures")}:
{relatedVas.map((va) => (
<Link key={va} href={`/policies?doc=${va}`} className="font-semibold text-[var(--info)] hover:underline">
{va}
</Link>
))}
</span>
)}
</div>
)}
{/* Einklappbare Dokumenten-Info (§7a.5, Standard: eingeklappt) */}
{infoMd && (
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
<summary className="cursor-pointer list-none px-4 py-2.5 text-[12.5px] font-semibold text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">
{t("docInfo")}
</summary>
<div className="policy-prose border-t px-4 py-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />
</details>
)}
{/* Gerendertes Dokument */}
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</div>
</Modal>
);
}
+165
View File
@@ -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;
}
+5
View File
@@ -47,6 +47,11 @@ const TENANT_MODELS = new Set<string>([
"Subcontractor",
"ManagementDecision",
"MaturityAssessment",
"PolicyDocument",
"PolicyRequirement",
"PolicyVariable",
"PolicyBaselineParam",
"PolicyEvidence",
]);
/**