559 lines
30 KiB
JavaScript
Executable File
559 lines
30 KiB
JavaScript
Executable File
#!/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)
|
||
// 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=<doc,doc>]
|
||
// die zehn Funktionsunterlagen der Fassung 6 (Leistungsbeschreibungen und AVV, Quellen docs/rechtliches/<slug>-v6.md)
|
||
// im neuen Stil (meta.stil 'neu') unter public/neu/rechtliches/<slug>-<Fassung>.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 += `<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; }
|
||
}
|
||
`
|
||
|
||
/**
|
||
* 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(`<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 }>, 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(`<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)}">
|
||
${meta.noindex === true ? ' <meta name="robots" content="noindex">\n' : ''} <title>${esc(meta.titel)} – WEBklar</title>
|
||
<style>
|
||
${(meta.stil === 'neu' ? STYLE_NEU : 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 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/<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)
|
||
}
|
||
|
||
/**
|
||
* 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 = '<key>-lb' | '<key>-avv'; Quelle docs/rechtliches/<slug>-v6.md, Ziel public/neu/rechtliches/<slug>-<Fassung>.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/<slug>-<Fassung>.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=<doc,doc>]`. 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=<doc,doc>]',
|
||
)
|
||
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
|
||
}
|
||
}
|