#!/usr/bin/env node // HTML-Generator der Rechtstexte: rendert die Markdown-Quellen unter docs/rechtliches/ deterministisch in die Seiten unter // public/rechtliches/-.html (dieselbe Seitenhülle wie die Seiten der Website-Analyse). Die PDFs entstehen // danach aus diesen Seiten (scripts/build-legal-pdfs.sh termine). // // node scripts/build-legal-html.mjs termine schreibt die drei Seiten des Terminplaners // node scripts/build-legal-html.mjs termine --check vergleicht mit den eingecheckten Seiten (Exit 1 bei Abweichung, schreibt nichts) // // Fassung = config.termine.avvVersion. Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und // von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ### // (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen). // Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab, // damit nie eine Seite mit falsch dargestelltem Rechtstext entsteht. import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { basename, join } from 'node:path' import { fileURLToPath } from 'node:url' import { config } from '../server/config.js' import { legalPaths } from '../server/services/legalDocs.js' const ROOT = fileURLToPath(new URL('..', import.meta.url)) const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/ export class MarkdownFehler extends Error { constructor(zeile, was) { super(`Markdown, Zeile ${zeile}: nicht unterstützt (${was})`) this.name = 'MarkdownFehler' } } const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } /** HTML-Escape für Text und Attributwerte. */ export const esc = (text) => String(text).replace(/[&<>"']/g, (z) => ESCAPES[z]) /** Zeichenkette für ein CSS-content (in doppelten Anführungszeichen). */ const cssText = (text) => String(text).replace(/[\\"\n]/g, (z) => (z === '\n' ? '\\A ' : `\\${z}`)) // ---------------------------------------------------------------------------------------------------------- Markdown /** Text außerhalb von `code`: rohes HTML (ein "<" unmittelbar vor Buchstabe, "/", "!" oder "?") bricht ab, alles andere wird maskiert. */ function klartext(text, zeile) { if (/<[A-Za-z/!?]/.test(text)) throw new MarkdownFehler(zeile, 'rohes HTML') return esc(text) } /** Inline-Teil: **fett**, `code`, sonst Text. Nur Text ohne Verschachtelung; alles andere wird escaped oder bricht ab. */ function inline(text, zeile) { if (/!\[|\]\(/.test(text)) throw new MarkdownFehler(zeile, 'Bild oder Link') if (text.includes('***')) throw new MarkdownFehler(zeile, '***') let aus = '' let i = 0 while (i < text.length) { if (text.startsWith('**', i)) { const ende = text.indexOf('**', i + 2) if (ende < 0) throw new MarkdownFehler(zeile, 'offenes **') const innen = text.slice(i + 2, ende) if (innen === '' || innen.includes('`')) throw new MarkdownFehler(zeile, 'leeres oder verschachteltes **fett**') aus += `${klartext(innen, zeile)}` i = ende + 2 } else if (text[i] === '`') { const ende = text.indexOf('`', i + 1) if (ende < 0) throw new MarkdownFehler(zeile, 'offenes `') if (ende === i + 1) throw new MarkdownFehler(zeile, 'leerer Code') aus += `${esc(text.slice(i + 1, ende))}` i = ende + 1 } else { let ende = i while (ende < text.length && text[ende] !== '`' && !text.startsWith('**', ende)) ende += 1 aus += klartext(text.slice(i, ende), zeile) i = ende } } return aus } /** Zerlegt Markdown in Blöcke { art: 'h'|'p'|'ul'|'table', … }; unbekannte Syntax wirft MarkdownFehler. */ export function parse(md) { if (typeof md !== 'string') throw new TypeError('parse: Markdown als Text erwartet') const zeilen = md.replace(/\r/g, '').split('\n') const bloecke = [] let offen = null // der Block, an den die nächste Zeile anschließt (p | ul | table) const schliesse = () => { offen = null } zeilen.forEach((roh, index) => { const nr = index + 1 if (roh.trim() === '') return schliesse() if (/^\s/.test(roh)) throw new MarkdownFehler(nr, 'Einrückung') if (/^(```|~~~)/.test(roh)) throw new MarkdownFehler(nr, 'Codeblock') if (roh.startsWith('>')) throw new MarkdownFehler(nr, 'Zitat') if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(roh)) throw new MarkdownFehler(nr, 'Trennlinie') if (/^[*+]\s/.test(roh)) throw new MarkdownFehler(nr, 'Liste mit * oder +') if (/^\d+[.)]\s/.test(roh)) throw new MarkdownFehler(nr, 'nummerierte Liste') if (roh.startsWith('#')) { const m = /^(#{1,3}) (\S.*)$/.exec(roh) if (!m) throw new MarkdownFehler(nr, /^#{4,}/.test(roh) ? 'Überschrift ab Ebene 4' : 'Überschrift ohne Leerzeichen') schliesse() bloecke.push({ art: 'h', ebene: m[1].length, html: inline(m[2].trim(), nr), zeile: nr }) return } if (roh.startsWith('- ')) { const text = roh.slice(2).trim() if (text === '') throw new MarkdownFehler(nr, 'leerer Listenpunkt') if (offen?.art !== 'ul') { offen = { art: 'ul', punkte: [], zeile: nr } bloecke.push(offen) } offen.punkte.push(inline(text, nr)) return } if (roh.startsWith('|')) { if (!roh.trimEnd().endsWith('|')) throw new MarkdownFehler(nr, 'Tabellenzeile ohne schließendes |') const zellen = roh.trim().slice(1, -1).split('|').map((z) => z.trim()) if (offen?.art !== 'table') { offen = { art: 'table', kopf: zellen.map((z) => inline(z, nr)), trenn: false, zeilen: [], zeile: nr } bloecke.push(offen) return } if (!offen.trenn) { if (zellen.length !== offen.kopf.length || !zellen.every((z) => /^:?-{3,}:?$/.test(z)) || zellen.some((z) => z.includes(':'))) { throw new MarkdownFehler(nr, 'Tabelle: zweite Zeile muss die Trennzeile |---|---| sein (ohne Ausrichtung)') } offen.trenn = true return } if (zellen.length !== offen.kopf.length) throw new MarkdownFehler(nr, 'Tabellenzeile mit anderer Spaltenzahl') offen.zeilen.push(zellen.map((z) => inline(z, nr))) return } // Absatz (aufeinanderfolgende Zeilen gehören zusammen) if (offen?.art === 'p') { offen.html += ` ${inline(roh.trim(), nr)}` return } offen = { art: 'p', html: inline(roh.trim(), nr), zeile: nr } bloecke.push(offen) }) for (const b of bloecke) { if (b.art === 'table' && !b.trenn) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Trennzeile') if (b.art === 'table' && b.zeilen.length === 0) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Zeilen') } return bloecke } // ---------------------------------------------------------------------------------------------------------- Seitenhülle const STYLE = (seitenkopf) => ` :root { color-scheme: light; } * { box-sizing: border-box; } body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; } .brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; } h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; } h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; } h3 { font-size: 16.5px; margin: 22px 0 6px; } p, li { margin: 6px 0; } ul { padding-left: 24px; } .meta { color: #6b6b70; font-size: 14px; } .box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; } .box.widerspruch { border: 2px solid #6d1fd1; } table { border-collapse: collapse; width: 100%; font-size: 15px; } td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; } th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; } a { color: #6d1fd1; } nav.top { font-size: 14px; margin-bottom: 22px; } .offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; } @media print { @page { size: A4; margin: 18mm 17mm 18mm; @top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #888; } @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } } body { background: #fff; font-size: 10pt; line-height: 1.45; } main { max-width: none; padding: 0; } nav.top { display: none; } h1 { font-size: 20pt; } h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; } a { color: inherit; text-decoration: none; } .box, tr, ol.abs > li { break-inside: avoid; } } ` /** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https; kein Rückwärtsschrägstrich (Browser lesen ihn wie "/"). */ function pruefeHref(href) { if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>\\]/.test(href)) { throw new Error(`render: ungültige Adresse in der Navigation: ${String(href).slice(0, 60)}`) } return href } function bloeckeHtml(bloecke) { const aus = [] bloecke.forEach((b, i) => { if (b.art === 'h') { if (b.ebene === 1) { if (i !== 0) throw new MarkdownFehler(b.zeile, '# nur als erste Zeile') aus.push(`
WEBKLAR
\n

${b.html}

`) } else { aus.push(`${b.html}`) } } else if (b.art === 'p') { aus.push(`${b.html}

`) } else if (b.art === 'ul') { aus.push(`
    \n${b.punkte.map((p) => `
  • ${p}
  • `).join('\n')}\n
`) } else { aus.push( [ '', `${b.kopf.map((z) => ``).join('')}`, '', ...b.zeilen.map((zeile) => `${zeile.map((z) => ``).join('')}`), '', '
${z}
${z}
', ].join('\n '), ) } }) return aus.join('\n\n ') } /** * Rendert ein Dokument in die Seitenhülle der Rechtstexte. * @param {string} md Markdown (Teilmenge, siehe Kopf); erste Zeile "# Titel", danach der Absatz "**Fassung JJJJ-MM-TT**[ · Zusatz]" * @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }> }} meta * titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · " * @returns {string} HTML mit abschließendem Zeilenumbruch */ export function render(md, meta) { if (!meta || !FASSUNG_RE.test(String(meta.fassung))) throw new Error('render: meta.fassung muss JJJJ-MM-TT sein') for (const feld of ['titel', 'seitenkopf']) { if (typeof meta[feld] !== 'string' || meta[feld].trim() === '') throw new Error(`render: meta.${feld} fehlt`) } if (!Array.isArray(meta.nav) || meta.nav.length === 0) throw new Error('render: meta.nav fehlt') const bloecke = parse(md) if (bloecke[0]?.art !== 'h' || bloecke[0].ebene !== 1) throw new MarkdownFehler(1, 'die erste Zeile muss "# Titel" sein') if (bloecke.filter((b) => b.art === 'h' && b.ebene === 1).length !== 1) throw new MarkdownFehler(1, 'genau eine Überschrift #') if (bloecke[1]?.art !== 'p' || !bloecke[1].html.startsWith(`Fassung ${meta.fassung}`)) { throw new MarkdownFehler(3, `der Absatz nach der Überschrift muss mit **Fassung ${meta.fassung}** beginnen`) } const nav = meta.nav.map((l) => `${esc(l.text)}`).join(' · ') return ` ${esc(meta.titel)} – WEBklar
${bloeckeHtml(bloecke)}
` } // ---------------------------------------------------------------------------------------------------------- Dokumentsätze /** Namen je Dokument des Terminplaners (Schlüssel wie in legalPaths: lb, avv, widerruf). */ const TERMINE_NAMEN = { lb: { titel: 'Leistungsbeschreibung Terminplaner', seitenkopf: 'Leistungsbeschreibung Terminplaner' }, avv: { titel: 'Auftragsverarbeitungsvertrag Terminplaner', seitenkopf: 'AVV Terminplaner' }, widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' }, } const NAV_NAMEN = { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' } /** * Die drei Dokumente des Terminplaners in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts. * Quelle docs/rechtliches/.md, Ziel public/rechtliches/-.html (Slugs und Adressen aus legalDocs.js). */ export function termineDokumente(fassung = config.termine.avvVersion) { const adressen = legalPaths('termine', fassung) const nav = [ { href: '/dashboard.html', text: '← Kundenportal' }, { href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' }, ...Object.keys(adressen).map((doc) => ({ href: adressen[doc], text: NAV_NAMEN[doc] })), { href: '/datenschutz.html', text: 'Datenschutzerklärung' }, { href: 'https://webklar.com/impressum', text: 'Impressum' }, ] return Object.entries(adressen).map(([doc, adresse]) => ({ doc, md: `docs/rechtliches/${basename(adresse, `-${fassung}.html`)}.md`, html: `public${adresse}`, meta: { fassung, ...TERMINE_NAMEN[doc], nav }, })) } // ---------------------------------------------------------------------------------------------------------- Aufruf /** * Kommandozeile: `termine [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien). * => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf */ export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) { const [satz, ...optionen] = argv const pruefen = optionen.includes('--check') if (satz !== 'termine' || optionen.some((o) => o !== '--check')) { fehler('Aufruf: node scripts/build-legal-html.mjs termine [--check]') return 2 } let abweichungen = 0 for (const d of termineDokumente()) { const html = render(readFileSync(join(root, d.md), 'utf8'), d.meta) const ziel = join(root, d.html) if (pruefen) { const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html log(`${gleich ? 'ok ' : 'ABWEICHT'} ${d.html}`) if (!gleich) abweichungen += 1 } else { writeFileSync(ziel, html) log(`-> ${d.html}`) } } return abweichungen > 0 ? 1 : 0 } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { process.exitCode = lauf(process.argv.slice(2)) } catch (err) { console.error(err.message) process.exitCode = 1 } }