#!/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)
// node scripts/build-legal-html.mjs shop [--check] dasselbe für Leistungsbeschreibung und AVV des Webshops (die Widerrufsbelehrung der
// Funktionen gehört zum Satz termine und wird von shop nicht angefasst)
// node scripts/build-legal-html.mjs unterkunft [--check]
// Leistungsbeschreibung, AVV und Widerrufsbelehrung des Buchungskalenders in der Fassung
// config.unterkunft.fassung; die Widerrufsbelehrung ist derselbe Text wie beim Satz termine
// (docs/rechtliches/widerrufsbelehrung-funktionen.md), nur mit der Fassung des Buchungskalenders gestempelt
// Achtung: Ändert sich die Widerrufsbelehrung der Funktionen (neue Fassung bei termine/shop), braucht auch unterkunft
// eine neue Fassung (config.unterkunft.fassung); eine ausgelieferte Datei wird nie an Ort und Stelle neu gebaut.
//
// Fassung = config.termine.avvVersion (Satz termine) bzw. config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft). 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(`