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>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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`);
|
||||
Reference in New Issue
Block a user