#!/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. // // node scripts/build-legal-html.mjs auftraege [--check] // dasselbe für die Widerrufsbelehrung der Projektaufträge (Wortlaut aus server/services/auftraege/texte.js) // und die AGB-Kopie (Quelle docs/rechtliches/agb.md) // // node scripts/build-legal-html.mjs funktionen6 [--check] [--nur=] // die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Quellen docs/rechtliches/-v6.md) // im neuen Stil (meta.stil 'neu') unter public/neu/rechtliches/-.html; --nur= begrenzt auf die // genannten Dokumente (news-lb, termine-lb, termine-avv, shop-lb, shop-avv, unterkunft-lb, unterkunft-avv, zeiten-lb, // analyse-lb, analyse-avv; auch der Dateiname ohne Fassung, z. B. avv-webshop). Solange ENTWURF_V6 gilt, tragen die Seiten meta robots noindex. // // Fassung = config.termine.avvVersion (Satz termine), config.shop.fassung (Satz shop) bzw. config.unterkunft.fassung (Satz unterkunft) bzw. TEXTE_FASSUNG und AGB_FASSUNG (Satz auftraege). 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, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { config } from '../server/config.js' import { legalPaths } from '../server/services/legalDocs.js' import { TEXTE_FASSUNG, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } from '../server/services/auftraege/texte.js' import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js' import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.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; } } ` /** * Neue Seitenhülle (Fassung 6, Spec 5.1): weiße Fläche, Text #1a1f36, Zweittext #5e6678, Linien #ebeef1/#d8dde3, Akzent #111, Systemschrift, * keine Karten, kein Lila, kein Gewicht 800, kein Dunkelmodus. Dieselben Tokens wie die Abo-Bedingungen unter public/neu/rechtliches/. */ const STYLE_NEU = (seitenkopf) => ` :root { color-scheme: light; } * { box-sizing: border-box; } body { margin: 0; background: #fff; color: #1a1f36; font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-variant-numeric: tabular-nums; } main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; } .brand { font-weight: 600; letter-spacing: 0.12em; font-size: 13px; color: #111; } h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; font-weight: 600; } h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #ebeef1; font-weight: 600; } h3 { font-size: 16.5px; margin: 22px 0 6px; font-weight: 600; } p, li { margin: 6px 0; } ul { padding-left: 24px; } .meta { color: #5e6678; font-size: 14px; } .box { border-top: 1px solid #d8dde3; border-bottom: 1px solid #d8dde3; padding: 14px 0; margin: 18px 0; } .box.widerspruch { border-top: 2px solid #111; border-bottom: 2px solid #111; } table { border-collapse: collapse; width: 100%; font-size: 15px; } td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #ebeef1; vertical-align: top; text-align: left; } th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #5e6678; background: #f8f9fb; padding-left: 8px; } a { color: #111; text-decoration: underline; text-decoration-color: #d8dde3; text-underline-offset: 2px; } a:hover { background: #f6f8fa; } nav.top { font-size: 14px; margin-bottom: 22px; } .offen { display: inline-block; background: #fcedb9; color: #a82c00; padding: 1px 10px; border-radius: 6px; font-weight: 600; } @media print { @page { size: A4; margin: 18mm 17mm 18mm; @top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #8a93a3; } @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #8a93a3; } } 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 }>, stil?: 'neu', noindex?: boolean }} meta * titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · "; * stil = 'neu' wählt die weiße Hülle der Fassung 6 (ohne stil bleibt die Ausgabe bytegleich zur bisherigen Hülle); * noindex = true ergänzt meta robots noindex (Entwurfsseiten) * @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') if (meta.stil !== undefined && meta.stil !== 'neu') throw new Error(`render: meta.stil ist unbekannt (erlaubt: 'neu' oder weglassen): ${String(meta.stil).slice(0, 30)}`) 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 ` ${meta.noindex === true ? ' \n' : ''} ${esc(meta.titel)} – WEBklar
${bloeckeHtml(bloecke)}
` } // ---------------------------------------------------------------------------------------------------------- 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 UNTERKUNFT_NAMEN = { lb: { titel: 'Leistungsbeschreibung Buchungskalender', seitenkopf: 'Leistungsbeschreibung Buchungskalender' }, avv: { titel: 'Auftragsverarbeitungsvertrag Buchungskalender', seitenkopf: 'AVV Buchungskalender' }, widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' }, } const NAV_NAMEN = { termine: { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' }, shop: { lb: 'Leistungsbeschreibung Webshop', avv: 'AVV Webshop', widerruf: 'Widerrufsbelehrung Funktionen' }, unterkunft: { lb: 'Leistungsbeschreibung Buchungskalender', avv: 'AVV Buchungskalender', 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/.md, Ziel public/rechtliches/-.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) } /** * Die drei Dokumente des Buchungskalenders in der Fassung config.unterkunft.fassung. Die Widerrufsbelehrung hat dieselbe Quelle wie die des * Terminplaners (ein Text für alle Funktionen); sie liegt hier unter der Fassung des Buchungskalenders, weil die Bestellung auf diese * Adresse verweist (legalPaths('unterkunft', …).widerruf). stempel = Fassung, die in der Quelle steht und beim Rendern ersetzt wird. */ export function unterkunftDokumente(fassung = config.unterkunft.fassung) { return dokumenteDesSatzes('unterkunft', fassung, UNTERKUNFT_NAMEN).map((d) => d.doc === 'widerruf' ? { ...d, stempel: config.termine.avvVersion } : d, ) } /** Namen der Funktionen in Titel und Kopfzeile der Fassung 6 (Schlüssel wie in FUNKTIONEN_UNTERLAGEN_V6). */ const FUNKTIONEN6_NAMEN = { news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender', zeiten: 'Öffnungszeiten live', analyse: 'Website-Analyse', } /** * Entwurfsstand der Fassung 6: solange true, tragen die Funktionsseiten meta robots noindex (Nachtrag N45). Bei der Freigabe auf false setzen, * zusammen mit dem Entfernen der Entwurfsmarke aus den Quellen (Ablauf in docs/rechtliches/kundenansicht-entscheidungen.md). */ export const ENTWURF_V6 = true /** * Die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Reihenfolge wie FUNKTIONEN_UNTERLAGEN_V6) im neuen Stil. * doc = '-lb' | '-avv'; Quelle docs/rechtliches/-v6.md, Ziel public/neu/rechtliches/-.html (Adressen aus products.js). * Die Navigation verweist nur auf Seiten im Vorschaupfad, nie auf die eingefrorenen Seiten unter /rechtliches/. */ export function funktionen6Dokumente() { return Object.entries(FUNKTIONEN_UNTERLAGEN_V6).flatMap(([key, unterlagen]) => { const name = FUNKTIONEN6_NAMEN[key] const dokumente = [ { art: 'lb', d: unterlagen.lb, titel: `Leistungsbeschreibung ${name}`, seitenkopf: `Leistungsbeschreibung ${name}` }, ...(unterlagen.avv ? [{ art: 'avv', d: unterlagen.avv, titel: `Auftragsverarbeitungsvertrag ${name}`, seitenkopf: `AVV ${name}` }] : []), ] const nav = [ { href: '/dashboard.html', text: '← Kundenportal' }, { href: '/neu/rechtliches/abo-bedingungen.html', text: 'Abo-Bedingungen' }, ...dokumente.map((x) => ({ href: x.d.url, text: x.seitenkopf })), { href: '/datenschutz.html', text: 'Datenschutzerklärung' }, { href: 'https://webklar.com/impressum', text: 'Impressum' }, ] return dokumente.map((x) => ({ doc: `${key}-${x.art}`, md: `docs/rechtliches/${basename(x.d.url, `-${x.d.version}.html`)}-v6.md`, html: `public${x.d.url}`, meta: { fassung: x.d.version, titel: x.titel, seitenkopf: x.seitenkopf, nav, stil: 'neu', noindex: ENTWURF_V6 }, })) }) } /** Markdown eines Dokuments; trägt die Quelle eine andere Fassung (d.stempel), wird die Fassungszeile auf die des Dokuments gesetzt. */ export function quelleVon(d, root = ROOT) { const md = readFileSync(join(root, d.md), 'utf8') if (!d.stempel || d.stempel === d.meta.fassung) return md const alt = `**Fassung ${d.stempel}**` if (!md.includes(alt)) throw new MarkdownFehler(3, `${d.md}: die Zeile ${alt} fehlt`) return md.replace(alt, `**Fassung ${d.meta.fassung}**`) } // ---------------------------------------------------------------------------------------------------------- Projektaufträge /** Einleitung der Seite zur Widerrufsbelehrung der Projektaufträge (Seitentext, nicht Teil der Belehrung nach dem Muster). */ export const PROJEKT_WIDERRUF_EINLEITUNG = 'Das Widerrufsrecht gilt nur für Verbraucher. Wir fügen die Belehrung und das Muster-Widerrufsformular jeder Auftragsbestätigung an eine Privatperson bei ' + 'und zeigen sie hier zusätzlich allen Kunden. Die Belehrung gilt für den Auftrag, den die jeweilige Auftragsbestätigung bestätigt.' /** * Markdown der Seite „Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)“ aus server/services/auftraege/texte.js: Überschrift, * Fassung, Einleitung, dann die Abschnitte der Belehrung und das Formular, jede Formularzeile als eigener Absatz. */ export function projektWiderrufMarkdown(fassung = TEXTE_FASSUNG, belehrung = WIDERRUFSBELEHRUNG_PROJEKT, formular = WIDERRUFSFORMULAR) { const teile = [ '# Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)', `**Fassung ${fassung}**`, PROJEKT_WIDERRUF_EINLEITUNG, `## ${belehrung.titel}`, ...belehrung.abschnitte.flatMap((a) => [`### ${a.ueberschrift}`, ...a.absaetze]), `## ${formular.titel}`, formular.einleitung, ...formular.zeilen, ] return `${teile.join('\n\n')}\n` } /** * Die zwei Seiten des Satzes auftraege: die Widerrufsbelehrung der Projektaufträge (Fassung TEXTE_FASSUNG, Wortlaut aus texte.js, ohne Markdown-Datei) * und die AGB-Kopie (Fassung AGB_FASSUNG, Quelle docs/rechtliches/agb.md). Beide Seiten verlinken einander. Die Adressen stehen nicht in legalDocs.js: * die Mails und das PDF der Auftragsbestätigung verlinken die versionierten Dateien (public/rechtliches/-.html|pdf) unmittelbar. */ export function auftraegeDokumente(texteFassung = TEXTE_FASSUNG, agbFassung = AGB_FASSUNG) { const widerrufAdresse = `/rechtliches/widerrufsbelehrung-projektauftrag-${texteFassung}.html` const agbAdresse = `/rechtliches/agb-${agbFassung}.html` const nav = [ { href: '/dashboard.html', text: '← Kundenportal' }, { href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' }, { href: widerrufAdresse, text: 'Widerrufsbelehrung Projektaufträge' }, { href: agbAdresse, text: 'AGB' }, { href: '/datenschutz.html', text: 'Datenschutzerklärung' }, { href: 'https://webklar.com/impressum', text: 'Impressum' }, ] return [ { doc: 'widerruf', md: null, markdown: () => projektWiderrufMarkdown(texteFassung), html: `public${widerrufAdresse}`, meta: { fassung: texteFassung, titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)', seitenkopf: 'Widerrufsbelehrung Projektaufträge', nav }, }, { doc: 'agb', md: 'docs/rechtliches/agb.md', html: `public${agbAdresse}`, meta: { fassung: agbFassung, titel: 'Allgemeine Geschäftsbedingungen', seitenkopf: 'AGB', nav }, }, ] } /** Sätze der Kommandozeile: Name => Dokumente. */ const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente, funktionen6: funktionen6Dokumente } // ---------------------------------------------------------------------------------------------------------- Aufruf /** * Kommandozeile: `termine|shop|unterkunft|auftraege [--check]` und `funktionen6 [--check] [--nur=]`. 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') const nurOptionen = optionen.filter((o) => o.startsWith('--nur=')) const usage = () => fehler( 'Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n' + ' node scripts/build-legal-html.mjs auftraege [--check]\n' + ' node scripts/build-legal-html.mjs funktionen6 [--check] [--nur=]', ) if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check' && !o.startsWith('--nur=')) || nurOptionen.length > 1 || (nurOptionen.length === 1 && satz !== 'funktionen6')) { usage() return 2 } let dokumente = SAETZE[satz]() if (nurOptionen.length === 1) { // erlaubt sind der Name des Dokuments (doc, z. B. shop-avv) und der Name seiner Datei ohne Fassung und Endung (z. B. avv-webshop) const namen = (d) => [d.doc, basename(d.html).replace(/-\d{4}-\d{2}-\d{2}\.html$/, '')] const gewuenscht = nurOptionen[0].slice('--nur='.length).split(',').map((x) => x.trim()) if (gewuenscht.some((x) => !dokumente.some((d) => namen(d).includes(x)))) { fehler(`Unbekanntes Dokument in --nur=. Erlaubt: ${dokumente.map((d) => d.doc).join(', ')} (oder der Dateiname ohne Fassung)`) usage() return 2 } dokumente = dokumente.filter((d) => namen(d).some((n) => gewuenscht.includes(n))) } let abweichungen = 0 for (const d of dokumente) { const html = render(d.markdown ? d.markdown() : quelleVon(d, root), 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 { mkdirSync(dirname(ziel), { recursive: true }) 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 } }