From 33b7a4ce0ff869767b62ee582393ea4b0d827ea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:55:41 +0000 Subject: [PATCH] Rechtstexte: Generator-Satz funktionen6 im neuen Stil Co-Authored-By: Claude Sonnet 5.5 --- scripts/build-legal-html.mjs | 136 +++++++++++++++++++++++++++++++---- test/legalV6Build.test.js | 51 +++++++++++++ 2 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 test/legalV6Build.test.js diff --git a/scripts/build-legal-html.mjs b/scripts/build-legal-html.mjs index 63bbc0d..0fb6a02 100755 --- a/scripts/build-legal-html.mjs +++ b/scripts/build-legal-html.mjs @@ -18,18 +18,25 @@ // 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). Trägt die Fassungszeile der Quelle die Entwurfsmarke, bekommt die Seite 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, readFileSync, writeFileSync } from 'node:fs' -import { basename, join } from 'node:path' +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}$/ @@ -190,6 +197,44 @@ const STYLE = (seitenkopf) => ` :root { color-scheme: light; } } ` +/** + * 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)) { @@ -231,8 +276,10 @@ function bloeckeHtml(bloecke) { /** * 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 · " + * @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) { @@ -241,6 +288,7 @@ export function render(md, meta) { 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 #') @@ -254,9 +302,9 @@ export function render(md, meta) { - ${esc(meta.titel)} – WEBklar +${meta.noindex === true ? ' \n' : ''} ${esc(meta.titel)} – WEBklar +${(meta.stil === 'neu' ? STYLE_NEU : STYLE)(meta.seitenkopf)}
@@ -338,7 +386,46 @@ export function unterkunftDokumente(fassung = config.unterkunft.fassung) { ) } -/** Sätze der Kommandozeile: Name => Dokumente. */ +/** 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', +} + +/** Entwurfsmarke in der Fassungszeile der Quellen: solange sie dort steht, bekommt die Seite meta robots noindex (Nachtrag N45). */ +export const ENTWURFSMARKE = 'Entwurf – Prüfung durch Kanzlei offen' + +/** + * 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' }, + })) + }) +} /** 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) { @@ -407,30 +494,53 @@ export function auftraegeDokumente(texteFassung = TEXTE_FASSUNG, agbFassung = AG } /** Sätze der Kommandozeile: Name => Dokumente. */ -const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente } +const SAETZE = { termine: termineDokumente, shop: shopDokumente, unterkunft: unterkunftDokumente, auftraege: auftraegeDokumente, funktionen6: funktionen6Dokumente } // ---------------------------------------------------------------------------------------------------------- Aufruf /** - * Kommandozeile: `termine|shop|unterkunft|auftraege [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien). + * 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') - if (!Object.hasOwn(SAETZE, satz ?? '') || optionen.some((o) => o !== '--check')) { - fehler('Aufruf: node scripts/build-legal-html.mjs termine|shop|unterkunft [--check]\n node scripts/build-legal-html.mjs auftraege [--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 SAETZE[satz]()) { - const html = render(d.markdown ? d.markdown() : quelleVon(d, root), d.meta) + for (const d of dokumente) { + const md = d.markdown ? d.markdown() : quelleVon(d, root) + // Entwurfsseiten (Marke in der Fassungszeile der Quelle) sind nicht indexierbar; die Marke zu entfernen gibt die Seite frei + const meta = d.meta.stil === 'neu' && md.includes(ENTWURFSMARKE) ? { ...d.meta, noindex: true } : d.meta + const html = render(md, 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}`) } diff --git a/test/legalV6Build.test.js b/test/legalV6Build.test.js new file mode 100644 index 0000000..922986a --- /dev/null +++ b/test/legalV6Build.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { useTestEnv } from './support/testEnv.js' + +// Generator der Rechtstexte: neuer Stil und Satz funktionen6 (Fassung 6, Entwurf). Ergänzt test/buildLegalHtml.test.js. + +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) +const { render, lauf, funktionen6Dokumente } = await import('../scripts/build-legal-html.mjs') + +const F = '2026-10-16' +const SLUGS = ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'] +test('funktionen6Dokumente: zehn Seiten unter public/neu/rechtliches, Quellen *-v6.md, Navigation im Vorschaupfad', () => { + const d = funktionen6Dokumente() + assert.deepEqual(d.map((x) => x.doc), ['news-lb', 'termine-lb', 'termine-avv', 'shop-lb', 'shop-avv', 'unterkunft-lb', 'unterkunft-avv', 'zeiten-lb', 'analyse-lb', 'analyse-avv']) + assert.deepEqual(d.map((x) => [x.md, x.html]), SLUGS.map((s) => [`docs/rechtliches/${s}-v6.md`, `public/neu/rechtliches/${s}-${F}.html`])) + for (const x of d) { + assert.deepEqual([x.meta.stil, x.meta.fassung], ['neu', F]) + const hrefs = x.meta.nav.map((l) => l.href) + assert.ok(hrefs.includes('/neu/rechtliches/abo-bedingungen.html') && hrefs.includes(`/${x.html.slice('public/'.length)}`), x.doc) + assert.ok(hrefs.every((h) => !h.startsWith('/rechtliches/')), 'keine Verweise auf eingefrorene Seiten') + } +}) +test('render mit stil neu: weiß und schwarz ohne Lila und Verlauf; ohne stil bleibt die alte Hülle', () => { + const meta = { fassung: F, titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] } + const md = `# Titel\n\n**Fassung ${F}** · Entwurf – Prüfung durch Kanzlei offen\n\n## 1. Geltung\n\n(1) Text.\n` + const neu = render(md, { ...meta, stil: 'neu' }), alt = render(md, meta) + for (const t of ['#1a1f36', '#ebeef1', '#f8f9fb', '#111', '-apple-system']) assert.ok(neu.includes(t), t) + assert.doesNotMatch(neu, /#6d1fd1|gradient|#f7f6f3/) + assert.ok(alt.includes('#6d1fd1') && alt.includes('#f7f6f3')) + assert.equal(neu.replace(/