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:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,6 +47,11 @@ const TENANT_MODELS = new Set<string>([
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"MaturityAssessment",
|
||||
"PolicyDocument",
|
||||
"PolicyRequirement",
|
||||
"PolicyVariable",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user