Files
logbuch/scripts/build-anleitung.mjs
T
admin b3fbc391b1 feat: Tab „Führungen" für ausgefallene und verschobene Sonderführungen — Version 1.14.0
Fällt eine Sonderführung aus oder wird sie verschoben, gibt es dafür keinen
Logbuch-Eintrag. Der neue Tab listet die kommenden zugesagten Führungen aus
SoFue2 und bietet je Zeile „Ausgefallen" und „Verschoben auf …" — offen für
jeden angemeldeten BEO.

Absage setzt status=3 und lässt den Termin stehen. Beim Verschieben wandert
der bisherige wtermin in die bis dahin ungenutzte Spalte 'verlegt'; beide
Aktionen hängen eine datierte Zeile mit dem Kürzel an 'bemerkung' an, statt
sie zu ersetzen. Damit ist die Terminhistorie erstmals nachvollziehbar.

Die Anleitung wird nun aus ANLEITUNG.md erzeugt (scripts/build-anleitung.mjs,
Design in scripts/anleitung.template.html), automatisch vor jedem Build.
public/anleitung.html ist deshalb nicht mehr in git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:03:43 +02:00

110 lines
4.6 KiB
JavaScript

/**
* Erzeugt public/anleitung.html aus ANLEITUNG.md.
*
* ANLEITUNG.md ist die einzige Quelle des Handbuchs; die HTML-Fassung wird nie von Hand
* bearbeitet (sie steht in .gitignore). Aufruf: `npm run anleitung` — läuft zusätzlich
* automatisch vor jedem `npm run build` (prebuild) und damit auch im Docker-Image.
*
* Abbildung Markdown → HTML:
* # Titel → Kopfzeile der Seite (vor „–" = H1, danach = Untertitel)
* ## Inhaltsverzeichnis → nav.toc, die Sprungziele werden auf #s1..#sN umgeschrieben
* ## N. Titel → <section id="sN"> mit nummeriertem Abschnittskopf
* --- → entfällt (reine Trennlinie in der Markdown-Ansicht)
* > Text → Hinweisbox .callout
* > [!WARNUNG] Text → .callout warn (auch [!WARNING])
* > [!ACHTUNG] Text → .callout danger (auch [!CAUTION])
* > [!HINWEIS] Text → .callout (auch [!NOTE])
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { marked } from 'marked';
const wurzel = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUELLE = join(wurzel, 'ANLEITUNG.md');
const VORLAGE = join(wurzel, 'scripts', 'anleitung.template.html');
const ZIEL = join(wurzel, 'public', 'anleitung.html');
const ALERTS = {
WARNUNG: 'callout warn', WARNING: 'callout warn',
ACHTUNG: 'callout danger', CAUTION: 'callout danger', DANGER: 'callout danger',
HINWEIS: 'callout', NOTE: 'callout',
};
/** Blockquotes werden zu Hinweisboxen, optional mit Variante über [!WARNUNG] o. ä. */
function calloutsUmschreiben(html) {
return html.replace(/<blockquote>\s*([\s\S]*?)\s*<\/blockquote>/g, (_, inhalt) => {
let klasse = 'callout';
const markierung = inhalt.match(/^<p>\s*\[!([A-ZÄÖÜ]+)\]\s*/);
if (markierung && ALERTS[markierung[1]]) {
klasse = ALERTS[markierung[1]];
inhalt = inhalt.replace(markierung[0], '<p>');
}
// Eine einzelne Textzeile braucht kein <p> — das spart eine Zeile Abstand in der Box.
const einzeln = inhalt.match(/^<p>([\s\S]*?)<\/p>$/);
return `<div class="${klasse}">${einzeln ? einzeln[1] : inhalt}</div>`;
});
}
/** Markdown-Sprungziele wie #7-drucken auf die Abschnitts-IDs #s7 umschreiben. */
function ankerUmschreiben(html) {
return html.replace(/href="#(\d+)-[^"]*"/g, (_, nummer) => `href="#s${nummer}"`);
}
function einrücken(html, stufe = ' ') {
return html.split('\n').map((z) => (z.trim() ? stufe + z : z)).join('\n');
}
const markdown = readFileSync(QUELLE, 'utf8');
// 1. Seitentitel aus der H1
const h1 = markdown.match(/^#\s+(.+)$/m);
if (!h1) throw new Error('ANLEITUNG.md: H1-Überschrift (# Titel) fehlt');
const [titelKopf, titelUnter = ''] = h1[1].split(/\s+[–—-]\s+/, 2);
// 2. In Blöcke je H2 zerlegen
const bloecke = markdown
.split(/^##\s+/m)
.slice(1)
.map((b) => {
const umbruch = b.indexOf('\n');
return {
titel: b.slice(0, umbruch).trim(),
rumpf: b.slice(umbruch + 1).replace(/^\s*---\s*$/gm, '').trim(),
};
});
// 3. Inhaltsverzeichnis: die Nummern der Listenpunkte werden zu #s<N>
const tocBlock = bloecke.find((b) => b.titel.toLowerCase() === 'inhaltsverzeichnis');
if (!tocBlock) throw new Error('ANLEITUNG.md: Abschnitt „## Inhaltsverzeichnis" fehlt');
const toc = [...tocBlock.rumpf.matchAll(/^\s*(\d+)\.\s*\[([^\]]+)\]/gm)]
.map(([, nr, text]) => ` <li><a href="#s${nr}">${text}</a></li>`)
.join('\n');
if (!toc) throw new Error('ANLEITUNG.md: Inhaltsverzeichnis enthält keine Einträge');
// 4. Nummerierte Abschnitte
const abschnitte = [];
for (const block of bloecke) {
const kopf = block.titel.match(/^(\d+)\.\s+(.*)$/);
if (!kopf) continue; // Inhaltsverzeichnis und alles ohne Nummer
const [, nummer, titel] = kopf;
const rumpf = ankerUmschreiben(calloutsUmschreiben(marked.parse(block.rumpf)));
abschnitte.push(
` <!-- ── ${nummer}. ${titel} ── -->\n` +
` <section id="s${nummer}">\n` +
` <h2 class="section-title"><span class="num">${nummer}</span> ${titel}</h2>\n` +
`${einrücken(rumpf.trim())}\n` +
` </section>`
);
}
if (!abschnitte.length) throw new Error('ANLEITUNG.md: keine nummerierten Abschnitte (## 1. …) gefunden');
const html = readFileSync(VORLAGE, 'utf8')
.replace('{{H1}}', titelKopf.trim())
.replace('{{SUBTITLE}}', titelUnter.trim())
.replace('{{TOC}}', toc)
.replace('{{SECTIONS}}', abschnitte.join('\n\n'));
writeFileSync(ZIEL, html, 'utf8');
console.log(`anleitung.html erzeugt — ${abschnitte.length} Abschnitte, ${html.length} Zeichen`);