Files
Webklar-Kundenbereich/scripts/build-legal-html.mjs
T

352 lines
16 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// HTML-Generator der Rechtstexte: rendert die Markdown-Quellen unter docs/rechtliches/ deterministisch in die Seiten unter
// public/rechtliches/<slug>-<Fassung>.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)
//
// Fassung = config.termine.avvVersion (Satz termine) bzw. config.shop.fassung (Satz shop). 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
/** 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 += `<strong>${klartext(innen, zeile)}</strong>`
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 += `<code>${esc(text.slice(i + 1, ende))}</code>`
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(`<div class="brand">WEBKLAR</div>\n <h1>${b.html}</h1>`)
} else {
aus.push(`<h${b.ebene}>${b.html}</h${b.ebene}>`)
}
} else if (b.art === 'p') {
aus.push(`<p${i === 1 ? ' class="meta"' : ''}>${b.html}</p>`)
} else if (b.art === 'ul') {
aus.push(`<ul>\n${b.punkte.map((p) => ` <li>${p}</li>`).join('\n')}\n </ul>`)
} else {
aus.push(
[
'<table>',
`<thead><tr>${b.kopf.map((z) => `<th>${z}</th>`).join('')}</tr></thead>`,
'<tbody>',
...b.zeilen.map((zeile) => `<tr>${zeile.map((z) => `<td>${z}</td>`).join('')}</tr>`),
'</tbody>',
'</table>',
].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(`<strong>Fassung ${meta.fassung}</strong>`)) {
throw new MarkdownFehler(3, `der Absatz nach der Überschrift muss mit **Fassung ${meta.fassung}** beginnen`)
}
const nav = meta.nav.map((l) => `<a href="${esc(pruefeHref(l.href))}">${esc(l.text)}</a>`).join(' · ')
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="${esc(meta.fassung)}">
<title>${esc(meta.titel)} – WEBklar</title>
<style>
${STYLE(meta.seitenkopf)} </style>
</head>
<body>
<main>
<nav class="top">${nav}</nav>
${bloeckeHtml(bloecke)}
</main>
</body>
</html>
`
}
// ---------------------------------------------------------------------------------------------------------- Dokumentsätze
/** Namen je Dokument (Schlüssel wie in legalPaths: lb, avv, widerruf): Seitentitel und Kopfzeile des Drucks. */
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 SHOP_NAMEN = {
lb: { titel: 'Leistungsbeschreibung Webshop', seitenkopf: 'Leistungsbeschreibung Webshop' },
avv: { titel: 'Auftragsverarbeitungsvertrag Webshop', seitenkopf: 'AVV Webshop' },
}
const NAV_NAMEN = {
termine: { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' },
shop: { lb: 'Leistungsbeschreibung Webshop', avv: 'AVV Webshop', widerruf: 'Widerrufsbelehrung Funktionen' },
}
/**
* Dokumente eines Satzes in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts.
* namen = die Dokumente, die dieser Satz erzeugt (Schlüssel von legalPaths); die Navigation verlinkt alle Dokumente des Satzes.
* Quelle docs/rechtliches/<slug>.md, Ziel public/rechtliches/<slug>-<Fassung>.html (Slugs und Adressen aus legalDocs.js).
*/
function dokumenteDesSatzes(satz, fassung, namen) {
const adressen = legalPaths(satz, 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[satz][doc] })),
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
]
return Object.keys(namen).map((doc) => ({
doc,
md: `docs/rechtliches/${basename(adressen[doc], `-${fassung}.html`)}.md`,
html: `public${adressen[doc]}`,
meta: { fassung, ...namen[doc], nav },
}))
}
/** Die drei Dokumente des Terminplaners (lb, avv, widerruf) in der Fassung config.termine.avvVersion. */
export function termineDokumente(fassung = config.termine.avvVersion) {
return dokumenteDesSatzes('termine', fassung, TERMINE_NAMEN)
}
/**
* Die Dokumente des Webshops in der Fassung config.shop.fassung: lb und avv. Die Widerrufsbelehrung der Funktionen (dritte Adresse
* des Satzes shop) ist dieselbe Datei wie die des Terminplaners und wird nur vom Satz termine erzeugt; die Navigation verlinkt sie.
*/
export function shopDokumente(fassung = config.shop.fassung) {
return dokumenteDesSatzes('shop', fassung, SHOP_NAMEN)
}
/** Sätze der Kommandozeile: Name => Dokumente. */
const SAETZE = { termine: termineDokumente, shop: shopDokumente }
// ---------------------------------------------------------------------------------------------------------- Aufruf
/**
* Kommandozeile: `termine|shop [--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 (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) {
fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop [--check]')
return 2
}
let abweichungen = 0
for (const d of SAETZE[satz]()) {
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
}
}