feat(legal): readable legal pages and corrected legal texts
Legal pages rendered admin text as bare paragraphs. LegalPage now has a honeycomb header, a sticky table of contents with scroll tracking (a collapsible one on mobile), anchor links, a reading progress bar and a revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives lists, cookie/provider entries with labelled facts and safe links from plain text, so the pages stay editable in the admin without markup. Legal corrections (German law as of 2026): - Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu Art. 246a EGBGB): recipient name/address/e-mail filled from company data, deadline rule, return period, refund details, diminished value and the model withdrawal form. The incomplete notice extended the withdrawal period to up to 12 months and 14 days. Honey is no longer declared perishable. - Impressum: TMG references replaced by DDG; editorial contact gains an address. - Datenschutz: removes the self-hosted vs. hosting-provider contradiction, adds PayPal/Klarna, reviews, back-in-stock notifications, storage period, no automated decisions, withdrawal of consent and the Art. 21 objection notice; newsletter double opt-in. - AGB: payment methods and delivery area point to the live shipping page; adds contract-text and input-correction information. - Cookie policy: storage period and provider for every entry. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
86cc0ad310
commit
4ab1a7edae
9 files changed
+707
-84
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractRevision, parseLegalParagraph, sectionAnchors, splitSentences, tokenizeInline } from "./legal-blocks";
|
||||
|
||||
describe("parseLegalParagraph", () => {
|
||||
it("keeps ordinary prose as a text block", () => {
|
||||
expect(parseLegalParagraph("Cookies sind kleine Textdateien. Sie speichern Einstellungen.")).toEqual({
|
||||
kind: "text",
|
||||
text: "Cookies sind kleine Textdateien. Sie speichern Einstellungen.",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a paragraph whose lines all start with a dash into a list", () => {
|
||||
expect(parseLegalParagraph("- Auskunft\n- Berichtigung\n• Löschung")).toEqual({
|
||||
kind: "list",
|
||||
items: ["Auskunft", "Berichtigung", "Löschung"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a single dashed line mixed with prose as a list", () => {
|
||||
expect(parseLegalParagraph("Einleitung\n- Punkt").kind).toBe("text");
|
||||
});
|
||||
|
||||
it("recognises a storage entry and pulls labelled facts out of it", () => {
|
||||
const block = parseLegalParagraph(
|
||||
"wabenhain_session – Hält dich angemeldet. Inhalt: signierte Kennung, keine Passwörter. Speicherdauer: 30 Tage; wird beim Abmelden gelöscht."
|
||||
);
|
||||
expect(block).toEqual({
|
||||
kind: "entry",
|
||||
name: "wabenhain_session",
|
||||
note: null,
|
||||
text: "Hält dich angemeldet.",
|
||||
facts: [
|
||||
{ label: "Inhalt", value: "signierte Kennung, keine Passwörter." },
|
||||
{ label: "Speicherdauer", value: "30 Tage; wird beim Abmelden gelöscht." },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps abbreviations such as Art. and Abs. inside one fact", () => {
|
||||
const block = parseLegalParagraph(
|
||||
"theme – Speichert die Darstellung. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. f DSGVO. Wird sonst gelöscht."
|
||||
);
|
||||
expect(block.kind === "entry" && block.facts).toEqual([
|
||||
{ label: "Rechtsgrundlage", value: "§ 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. f DSGVO." },
|
||||
]);
|
||||
expect(block.kind === "entry" && block.text).toBe("Speichert die Darstellung. Wird sonst gelöscht.");
|
||||
});
|
||||
|
||||
it("recognises a provider entry written as 'Name (Zweck): Text'", () => {
|
||||
const block = parseLegalParagraph("Stripe (Zahlungsabwicklung): Lädt das Formular. Anbieter: Stripe Payments Europe, Ltd., Dublin.");
|
||||
expect(block).toMatchObject({ kind: "entry", name: "Stripe", note: "Zahlungsabwicklung", text: "Lädt das Formular." });
|
||||
expect(block.kind === "entry" && block.facts).toEqual([{ label: "Anbieter", value: "Stripe Payments Europe, Ltd., Dublin." }]);
|
||||
});
|
||||
|
||||
it("recognises English fact labels", () => {
|
||||
const block = parseLegalParagraph("locale – Stores your language. Storage period: 1 year.");
|
||||
expect(block.kind === "entry" && block.facts).toEqual([{ label: "Storage period", value: "1 year." }]);
|
||||
});
|
||||
|
||||
it("does not mistake a sentence with a spaced dash for an entry", () => {
|
||||
expect(parseLegalParagraph("Wir versenden schnell – meist in zwei Tagen.").kind).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitSentences", () => {
|
||||
it("does not split after z. B. or Art.", () => {
|
||||
expect(splitSentences("Etwa z. B. Chrome. Nach Art. 6 DSGVO. Ende.")).toEqual(["Etwa z. B. Chrome.", "Nach Art. 6 DSGVO.", "Ende."]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRevision", () => {
|
||||
it("moves a trailing 'Stand:' line out of the sections", () => {
|
||||
const { sections, revision } = extractRevision([
|
||||
{ heading: "A", paragraphs: ["Text"] },
|
||||
{ heading: "B", paragraphs: ["Mehr", "Stand: September 2026"] },
|
||||
]);
|
||||
expect(revision).toBe("September 2026");
|
||||
expect(sections).toEqual([
|
||||
{ heading: "A", paragraphs: ["Text"] },
|
||||
{ heading: "B", paragraphs: ["Mehr"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a section that only held the revision line", () => {
|
||||
const { sections, revision } = extractRevision([
|
||||
{ heading: "A", paragraphs: ["Text"] },
|
||||
{ heading: "Stand", paragraphs: ["Last updated: May 2026"] },
|
||||
]);
|
||||
expect(revision).toBe("May 2026");
|
||||
expect(sections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns null when no revision is given", () => {
|
||||
expect(extractRevision([{ heading: "A", paragraphs: ["Text"] }]).revision).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionAnchors", () => {
|
||||
it("builds readable, unique anchors from headings", () => {
|
||||
expect(sectionAnchors(["1. Verantwortlicher", "Deine Rechte", "Deine Rechte", "Über uns & mehr"])).toEqual([
|
||||
"verantwortlicher",
|
||||
"deine-rechte",
|
||||
"deine-rechte-2",
|
||||
"ueber-uns-mehr",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to a numbered anchor for headings without letters", () => {
|
||||
expect(sectionAnchors(["§§", ""])).toEqual(["abschnitt-1", "abschnitt-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokenizeInline", () => {
|
||||
it("links web addresses with a path and e-mail addresses", () => {
|
||||
expect(tokenizeInline("Siehe stripe.com/de/privacy oder schreib an [email protected].")).toEqual([
|
||||
{ kind: "text", value: "Siehe " },
|
||||
{ kind: "link", value: "stripe.com/de/privacy", href: "https://stripe.com/de/privacy" },
|
||||
{ kind: "text", value: " oder schreib an " },
|
||||
{ kind: "link", value: "[email protected]", href: "mailto:[email protected]" },
|
||||
{ kind: "text", value: "." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps explicit https links and never links other schemes", () => {
|
||||
expect(tokenizeInline("https://example.org/a javascript:alert(1)")).toEqual([
|
||||
{ kind: "link", value: "https://example.org/a", href: "https://example.org/a" },
|
||||
{ kind: "text", value: " javascript:alert(1)" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a bare domain without path as text", () => {
|
||||
expect(tokenizeInline("wabenhain.com")).toEqual([{ kind: "text", value: "wabenhain.com" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
|
||||
|
||||
export function sectionAnchors(headings: string[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
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<>".,;:)])|((?<![\w@/.])(?:[a-z0-9-]+\.)+[a-z]{2,}\/[^\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;
|
||||
}
|
||||
Reference in new issue
Block a user