/** * Liest aus dem Klartext der Rechtsseiten (Admin-Bereich → Inhalte) eine * leichte Struktur heraus, ohne dass im Admin eine Auszeichnungssprache * noetig wird. Erkannt wird nur, was eindeutig ist — alles andere bleibt * ein normaler Absatz, damit eigener Text nie verfaelscht dargestellt wird: * * - Absatz, dessen Zeilen ALLE mit "- " oder "• " beginnen → Aufzaehlung * - "cookie_name – Beschreibung. Speicherdauer: 1 Jahr." → Eintrag mit Fakten * - "Anbieter (Zweck): Beschreibung. Anbieter: …" → Eintrag mit Fakten * - "Stand: September 2026" als eigener Absatz → Datum im Seitenkopf */ interface LegalSectionContent { heading: string; paragraphs: string[]; } export interface LegalFact { label: string; value: string; } export type LegalBlock = | { kind: "text"; text: string } | { kind: "list"; items: string[] } | { kind: "entry"; name: string; note: string | null; text: string; facts: LegalFact[] }; export type InlineToken = { kind: "text"; value: string } | { kind: "link"; value: string; href: string }; const LIST_LINE = /^\s*[-•]\s+(.+)$/; // Technischer Name ohne Leerzeichen, dann " – " (Halbgeviertstrich mit // Leerzeichen): so schreibt die Cookie-Richtlinie ihre Eintraege. const CODE_ENTRY = /^([A-Za-z0-9_.-]{3,40}) – ([\s\S]+)$/; // "Stripe (Zahlungsabwicklung): …" — Anbieter mit Zweck in Klammern. const LABEL_ENTRY = /^([A-ZÄÖÜ][\wÄÖÜäöüß .&-]{1,30}?) \(([^()]{2,60})\): ([\s\S]+)$/; const FACT_LABELS = [ "Inhalt", "Speicherdauer", "Rechtsgrundlage", "Anbieter", "Weitere Informationen", "Content", "Storage period", "Legal basis", "Provider", "More information", ]; const FACT = new RegExp(`^(${FACT_LABELS.join("|")}):\\s+([\\s\\S]+)$`); const REVISION = /^(Stand|Zuletzt aktualisiert|Last updated)\s*:\s*(.+)$/i; // Kuerzel, nach denen ein Punkt KEIN Satzende ist (Art. 6, Abs. 1, z. B.). const ABBREVIATIONS = new Set(["art", "abs", "nr", "lit", "bzw", "ggf", "vgl", "ca", "inkl", "evtl", "usw", "etc", "ltd", "inc", "co", "no", "st", "str"]); export function splitSentences(text: string): string[] { const sentences: string[] = []; let start = 0; for (const match of text.matchAll(/[.!?]\s+/g)) { const end = match.index + 1; const next = text[match.index + match[0].length] ?? ""; const word = /([\p{L}\d]+)$/u.exec(text.slice(start, match.index))?.[1] ?? ""; const isAbbreviation = word.length === 1 || /^\d+$/.test(word) || ABBREVIATIONS.has(word.toLowerCase()); if (isAbbreviation || !/[\p{Lu}„"§]/u.test(next)) continue; sentences.push(text.slice(start, end).trim()); start = match.index + match[0].length; } const rest = text.slice(start).trim(); if (rest) sentences.push(rest); return sentences; } function toEntry(name: string, note: string | null, body: string): LegalBlock { const facts: LegalFact[] = []; const prose: string[] = []; for (const sentence of splitSentences(body)) { const fact = FACT.exec(sentence); if (fact) facts.push({ label: fact[1], value: fact[2] }); else prose.push(sentence); } return { kind: "entry", name, note, text: prose.join(" "), facts }; } export function parseLegalParagraph(paragraph: string): LegalBlock { const text = paragraph.trim(); const lines = text.split("\n"); if (lines.length > 1 && lines.every((line) => LIST_LINE.test(line))) { return { kind: "list", items: lines.map((line) => LIST_LINE.exec(line)![1].trim()) }; } const code = CODE_ENTRY.exec(text); if (code) return toEntry(code[1], null, code[2]); const labelled = LABEL_ENTRY.exec(text); if (labelled) return toEntry(labelled[1], labelled[2], labelled[3]); return { kind: "text", text }; } /** Holt "Stand: …" aus dem Text, damit es im Seitenkopf statt als loser Satz am Ende steht. */ export function extractRevision(sections: LegalSectionContent[]): { sections: LegalSectionContent[]; revision: string | null } { let revision: string | null = null; const cleaned = sections .map((section) => ({ ...section, paragraphs: section.paragraphs.filter((p) => { const match = p.includes("\n") ? null : REVISION.exec(p.trim()); if (match && revision === null) revision = match[2].trim(); return !match; }), })) .filter((section) => section.paragraphs.length > 0); return { sections: cleaned, revision }; } const UMLAUTS: Record = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" }; export function sectionAnchors(headings: string[]): string[] { const seen = new Map(); return headings.map((heading, i) => { const base = heading .toLowerCase() .replace(/^\s*\d+[.)]?\s*/, "") .replace(/[äöüß]/g, (c) => UMLAUTS[c]) .normalize("NFKD") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || `abschnitt-${i + 1}`; const count = (seen.get(base) ?? 0) + 1; seen.set(base, count); return count === 1 ? base : `${base}-${count}`; }); } // Nur https/http-Adressen, Domains MIT Pfad (stripe.com/de/privacy) und // E-Mail-Adressen werden verlinkt. Nackte Domains bleiben Text, andere // Schemata (javascript:, data:) werden nie zu Links. const INLINE_LINK = /(https?:\/\/[^\s<>"]*[^\s<>".,;:)])|((?"]*[^\s<>".,;:)])|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g; export function tokenizeInline(text: string): InlineToken[] { const tokens: InlineToken[] = []; let last = 0; for (const match of text.matchAll(INLINE_LINK)) { if (match.index > last) tokens.push({ kind: "text", value: text.slice(last, match.index) }); const [value, url, domainPath, email] = match; const href = url ?? (domainPath ? `https://${domainPath}` : `mailto:${email}`); tokens.push({ kind: "link", value, href }); last = match.index + value.length; } if (last < text.length) tokens.push({ kind: "text", value: text.slice(last) }); return tokens; }