Baustein 2: Inhalte einlesen und pruefen, Handouts, Suche, Glossar

Die fachlichen Inhalte liegen als Dateien unter content/ und werden beim
Start eingelesen, geprueft und in den Speicher geladen.

- Inhaltslader mit Zod-Schemata und Querverweispruefung
- Fehlermeldungen nennen Datei und Zeile, gesammelt statt beim ersten
  Fehler geworfen; die Anwendung startet bei Fehlern nicht
- npm run inhalte:pruefen prueft ohne Start
- Handout-Ansicht mit festem Aufbau, Uebersicht nach Themenbloecken
- Volltextsuche mit Gewichtung und Trefferausschnitt
- Gelesen-Markierung je Handout
- Glossar mit Suche und automatischer Verlinkung in den Handouts
- Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben,
  4 Checklisten, 2 Lernzielkontrollen, 24 Glossareintraege
- rahmenplan.yaml aus FIAusbV 2020 und KMK-Rahmenlehrplan, mit Fundstellen
- gray-matter entfernt, YAML-Kopf wird selbst herausgeloest
- npm run screenshots nimmt die Bilder fuer die Anleitung auf
- 39 neue Tests, insgesamt 68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 07:56:38 +02:00
co-authored by Claude Opus 5
parent e856349cd4
commit 1af5613e9b
70 changed files with 4779 additions and 124 deletions
@@ -0,0 +1,38 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
export function GlossarSuche({ anfangswert }: { anfangswert: string }) {
const [wert, setzeWert] = useState(anfangswert);
const router = useRouter();
const erstesMal = useRef(true);
useEffect(() => {
if (erstesMal.current) {
erstesMal.current = false;
return;
}
const zeitgeber = setTimeout(() => {
router.replace(wert.trim() ? `/glossar?suche=${encodeURIComponent(wert.trim())}` : '/glossar', { scroll: false });
}, 200);
return () => clearTimeout(zeitgeber);
}, [wert, router]);
return (
<form action="/glossar" method="get" role="search" className="nicht-drucken">
<label htmlFor="glossar-suche" className="nur-lesehilfe">
Glossar durchsuchen
</label>
<input
id="glossar-suche"
name="suche"
type="search"
placeholder="Begriff suchen – z. B. Subnetzmaske"
value={wert}
onChange={(e) => setzeWert(e.target.value)}
autoComplete="off"
/>
</form>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
import { normalisiere } from '@/lib/inhalte/laden';
import { GlossarSuche } from './GlossarSuche';
export const metadata: Metadata = { title: 'Glossar' };
export const dynamic = 'force-dynamic';
export default async function GlossarSeite({ searchParams }: { searchParams: Promise<{ suche?: string }> }) {
await erfordereBenutzer();
const parameter = await searchParams;
const alle = inhalte();
const suchbegriff = (parameter.suche ?? '').trim();
const gesucht = normalisiere(suchbegriff);
const eintraege = [...alle.glossar].sort((a, b) => a.begriff.localeCompare(b.begriff, 'de-DE'));
const gefiltert = gesucht
? eintraege.filter(
(e) =>
normalisiere(e.begriff).includes(gesucht) ||
normalisiere(e.steht_fuer ?? '').includes(gesucht) ||
normalisiere(e.erklaerung).includes(gesucht) ||
e.formen.some((f) => normalisiere(f).includes(gesucht)),
)
: eintraege;
return (
<div className="stapel">
<div>
<h1>Glossar</h1>
<p className="leise">
{alle.glossar.length} Begriffe. In den Handouts sind sie verlinkt – ein Klick führt hierher.
</p>
</div>
<GlossarSuche anfangswert={suchbegriff} />
{gefiltert.length === 0 ? (
<p className="karte">Kein Begriff passt zu &bdquo;{suchbegriff}&ldquo;.</p>
) : (
<dl className="glossar">
{gefiltert.map((eintrag) => (
<div key={eintrag.begriff} id={glossarAnker(eintrag.begriff)} className="glossar-eintrag karte">
<dt>
{eintrag.begriff}
{eintrag.steht_fuer ? <span className="glossar-langform">{eintrag.steht_fuer}</span> : null}
</dt>
<dd>
<p style={{ marginBottom: eintrag.siehe_auch.length > 0 ? '0.5rem' : 0 }}>{eintrag.erklaerung}</p>
{eintrag.siehe_auch.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Siehe auch:{' '}
{eintrag.siehe_auch.map((verweis, index) => (
<span key={verweis}>
{index > 0 ? ', ' : ''}
<Link href={`#${glossarAnker(verweis)}`}>{verweis}</Link>
</span>
))}
</p>
) : null}
</dd>
</div>
))}
</dl>
)}
</div>
);
}
@@ -0,0 +1,51 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
/**
* Suchfeld für die Handouts. Sucht beim Tippen mit kurzer Verzögerung, damit
* nicht bei jedem Buchstaben eine Anfrage losgeht, und funktioniert auch ohne
* JavaScript – dann eben erst beim Absenden.
*/
export function Suchfeld({ anfangswert }: { anfangswert: string }) {
const [wert, setzeWert] = useState(anfangswert);
const router = useRouter();
const erstesMal = useRef(true);
useEffect(() => {
if (erstesMal.current) {
erstesMal.current = false;
return;
}
const zeitgeber = setTimeout(() => {
const ziel = wert.trim() ? `/handouts?suche=${encodeURIComponent(wert.trim())}` : '/handouts';
router.replace(ziel, { scroll: false });
}, 250);
return () => clearTimeout(zeitgeber);
}, [wert, router]);
return (
<form action="/handouts" method="get" role="search" className="nicht-drucken">
<label htmlFor="suche" className="nur-lesehilfe">
Handouts durchsuchen
</label>
<div className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<input
id="suche"
name="suche"
type="search"
placeholder="Suchen – z. B. Subnetting, SMART, Vererbung"
value={wert}
onChange={(e) => setzeWert(e.target.value)}
autoComplete="off"
/>
<button type="submit" className="knopf knopf-zweit" style={{ flexShrink: 0 }}>
Suchen
</button>
</div>
</form>
);
}
@@ -0,0 +1,29 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsGelesenMerken, alsUngelesenMerken } from '@/lib/repos/fortschritt';
/**
* Gelesen-Markierung umschalten. Nur der Azubi führt seinen eigenen Fortschritt –
* der Ausbilder liest mit, hakt aber nichts für ihn ab.
*/
export async function gelesenUmschalten(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const handoutId = String(formular.get('handoutId') ?? '');
if (!inhalte().handoutNach.has(handoutId)) return;
const gelesen = String(formular.get('gelesen') ?? '') === '1';
if (gelesen) {
alsGelesenMerken(benutzer.id, handoutId);
} else {
alsUngelesenMerken(benutzer.id, handoutId);
}
revalidatePath(`/handouts/${handoutId}`);
revalidatePath('/handouts');
revalidatePath('/');
}
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { gelesenAm } from '@/lib/repos/fortschritt';
import { alsZeitpunktText } from '@/lib/zeit';
import { gelesenUmschalten } from './aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const handout = inhalte().handoutNach.get(id.toUpperCase());
return { title: handout ? `${handout.id} – ${handout.titel}` : 'Handout' };
}
export default async function HandoutSeite({ params }: { params: Promise<{ id: string }> }) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const alle = inhalte();
const handout = alle.handoutNach.get(id.toUpperCase());
if (!handout) notFound();
const block = alle.blockNach.get(handout.block);
const lernfeld = alle.lernfeldNach.get(handout.lernfeld);
const positionen = handout.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const woche = alle.wocheNach.get(handout.woche);
const istAzubi = benutzer.rolle === 'azubi';
const gelesen = istAzubi ? gelesenAm(benutzer.id, handout.id) : null;
const desBlocks = alle.handoutsNachBlock.get(handout.block) ?? [];
const stelle = desBlocks.findIndex((h) => h.id === handout.id);
const vorher = stelle > 0 ? desBlocks[stelle - 1] : undefined;
const nachher = stelle < desBlocks.length - 1 ? desBlocks[stelle + 1] : undefined;
const wesentlichHtml = handout.wesentlich ? verlinkeGlossar(handout.wesentlich.html, alle.glossar) : '';
return (
<article className="stapel handout">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/handouts">Handouts</Link>
{block ? (
<>
{' › '}
<Link href={`/handouts?block=${block.id}`}>{block.titel}</Link>
</>
) : null}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="handout-kuerzel handout-kuerzel-gross">{handout.id}</span>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen am {alsZeitpunktText(gelesen)}
</span>
) : null}
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{handout.titel}</h1>
<p className="handout-worum">{handout.worum}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.length === 0
? '–'
: positionen.map((p) => (
<span key={p.id} title={`${p.titel} (${p.verordnung})`} className="zuordnung-marke">
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke" title={`${lernfeld.stunden} Stunden im ${lernfeld.jahr}. Jahr`}>
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
<div>
<dt>Woche</dt>
<dd>
{woche ? (
<span className="zuordnung-marke">
<strong>{woche.nummer}</strong> {woche.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{handout.wesentlich ? (
<section aria-labelledby="wesentlich">
<h2 id="wesentlich">Das Wesentliche</h2>
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: wesentlichHtml }} />
</section>
) : null}
{handout.uebersicht ? (
<section aria-labelledby="uebersicht">
<h2 id="uebersicht">Übersicht</h2>
<div className="tabelle-huelle fliesstext" dangerouslySetInnerHTML={{ __html: handout.uebersicht.html }} />
</section>
) : null}
<aside className="merksatz" aria-label="Merksatz">
<span className="merksatz-kennzeichnung">Merksatz</span>
<p>{handout.merksatz}</p>
</aside>
{handout.pruefungsfrage ? (
<details className="pruefungsfrage">
<summary>Prüfungsfrage anzeigen</summary>
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: handout.pruefungsfrage.html }} />
</details>
) : null}
<section aria-labelledby="karten" className="karte stapel-eng">
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
Merkkarten zu diesem Handout
</h2>
<p className="leise" style={{ margin: 0 }}>
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
nach Fälligkeit auf.
</p>
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
{handout.karten.map((karte, index) => (
<li key={index}>
<strong>{karte.frage}</strong>
<br />
<span className="leise">{karte.antwort}</span>
</li>
))}
</ol>
</section>
{istAzubi ? (
<form action={gelesenUmschalten} className="nicht-drucken">
<input type="hidden" name="handoutId" value={handout.id} />
<input type="hidden" name="gelesen" value={gelesen ? '0' : '1'} />
<button type="submit" className={`knopf ${gelesen ? 'knopf-zweit' : 'knopf-akzent'}`}>
{gelesen ? 'Doch noch nicht gelesen' : 'Als gelesen markieren'}
</button>
</form>
) : null}
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
{vorher ? (
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
← {vorher.id} {vorher.titel}
</Link>
) : (
<span />
)}
{nachher ? (
<Link href={`/handouts/${nachher.id}`} className="knopf knopf-still">
{nachher.id} {nachher.titel} →
</Link>
) : (
<span />
)}
</nav>
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{handout.datei}</code>
</p>
</article>
);
}
+144
View File
@@ -0,0 +1,144 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { sucheHandouts } from '@/lib/inhalte/suche';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { Suchfeld } from './Suchfeld';
export const metadata: Metadata = { title: 'Handouts' };
export const dynamic = 'force-dynamic';
export default async function HandoutListe({
searchParams,
}: {
searchParams: Promise<{ suche?: string; block?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const parameter = await searchParams;
const alle = inhalte();
const gelesen = benutzer.rolle === 'azubi' ? gelesenSet(benutzer.id) : new Set<string>();
const suchbegriff = (parameter.suche ?? '').trim();
const treffer = suchbegriff ? sucheHandouts(alle, suchbegriff) : null;
const gefilterterBlock = parameter.block && alle.blockNach.has(parameter.block) ? parameter.block : null;
return (
<div className="stapel">
<div>
<h1>Handouts</h1>
<p className="leise">
{alle.handouts.length} Lerneinheiten in {alle.bloecke.length} Themenblöcken
{benutzer.rolle === 'azubi' ? ` · ${gelesen.size} gelesen` : ''}
</p>
</div>
<Suchfeld anfangswert={suchbegriff} />
{treffer ? (
<section className="stapel" aria-labelledby="treffer-ueberschrift">
<h2 id="treffer-ueberschrift">
{treffer.length === 0
? `Nichts gefunden für „${suchbegriff}"`
: `${treffer.length} ${treffer.length === 1 ? 'Treffer' : 'Treffer'} für „${suchbegriff}"`}
</h2>
{treffer.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Versuch es mit einem einzelnen Wort. Gesucht wird in Titel, Merksatz, Merkkarten und im gesamten Text.
</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{treffer.map(({ handout, ausschnitt }) => (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`} className="handout-treffer">
<span className="handout-kuerzel">{handout.id}</span>
<span>
<strong>{handout.titel}</strong>
<br />
<span className="leise">{ausschnitt}</span>
</span>
{gelesen.has(handout.id) ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</Link>
</li>
))}
</ul>
)}
</section>
) : (
<>
<nav className="reihe nicht-drucken" aria-label="Themenblöcke">
<Link href="/handouts" className={`knopf ${gefilterterBlock ? 'knopf-zweit' : ''}`} scroll={false}>
Alle
</Link>
{alle.bloecke.map((block) => (
<Link
key={block.id}
href={`/handouts?block=${block.id}`}
className={`knopf ${gefilterterBlock === block.id ? '' : 'knopf-zweit'}`}
scroll={false}
>
{block.titel}
</Link>
))}
</nav>
{alle.bloecke
.filter((block) => !gefilterterBlock || block.id === gefilterterBlock)
.map((block) => {
const desBlocks = alle.handoutsNachBlock.get(block.id) ?? [];
if (desBlocks.length === 0) return null;
const gelesenImBlock = desBlocks.filter((h) => gelesen.has(h.id)).length;
return (
<section key={block.id} className="stapel" aria-labelledby={`block-${block.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`block-${block.id}`} style={{ margin: 0 }}>
{block.titel}
</h2>
{benutzer.rolle === 'azubi' ? (
<span className="leise">
{gelesenImBlock} von {desBlocks.length} gelesen
</span>
) : (
<span className="leise">{desBlocks.length} Handouts</span>
)}
</div>
<ul className="raster" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{desBlocks.map((handout) => (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`} className="handout-kachel">
<span className="reihe" style={{ justifyContent: 'space-between', gap: '0.5rem' }}>
<span className="handout-kuerzel">{handout.id}</span>
{gelesen.has(handout.id) ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : (
<span className="leise" style={{ fontSize: '0.8rem' }}>
Woche {handout.woche}
</span>
)}
</span>
<strong style={{ display: 'block', marginTop: '0.4rem' }}>{handout.titel}</strong>
<span className="leise" style={{ display: 'block', marginTop: '0.2rem' }}>
{handout.worum}
</span>
</Link>
</li>
))}
</ul>
</section>
);
})}
</>
)}
</div>
);
}
+7 -1
View File
@@ -6,10 +6,16 @@ export const dynamic = 'force-dynamic';
// Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
// die es auch wirklich gibt.
const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }];
const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
];
const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+2 -1
View File
@@ -5,6 +5,7 @@ import { erfordereAusbilder } from '@/lib/auth/rechte';
import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { alsDatumText } from '@/lib/zeit';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
@@ -39,7 +40,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
});
revalidatePath('/verwaltung');
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` };
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
}
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
+211 -3
View File
@@ -323,9 +323,7 @@ select:disabled {
color: #f2f8fa;
text-decoration: none;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.marke:hover { color: #fff; }
.marke .marke-akzent { color: #e5a552; }
@@ -488,3 +486,213 @@ table.tabelle th {
white-space: nowrap;
border: 0;
}
/* --- Handouts --- */
.handout-kuerzel {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.6rem;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-klein);
background: var(--petrol);
color: var(--text-auf-petrol);
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.handout-kuerzel-gross {
font-size: 1rem;
padding: 0.25rem 0.7rem;
}
.handout-kachel {
display: block;
height: 100%;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.9rem 1rem;
text-decoration: none;
color: var(--text);
}
.handout-kachel:hover {
background: var(--flaeche-kraeftig);
border-color: var(--rand);
color: var(--text);
}
.handout-treffer {
display: flex;
gap: 0.75rem;
align-items: flex-start;
background: var(--flaeche);
border: 1px solid var(--rand-zart);
border-radius: var(--radius);
padding: 0.8rem 1rem;
text-decoration: none;
color: var(--text);
}
.handout-treffer:hover {
background: var(--flaeche-kraeftig);
color: var(--text);
}
.handout-worum {
font-size: 1.05rem;
color: var(--text-leise);
margin: 0;
}
.handout-zuordnung dl {
margin: 0;
display: grid;
gap: 0.6rem;
}
.handout-zuordnung dt {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-leise);
font-weight: 650;
margin-bottom: 0.2rem;
}
.handout-zuordnung dd {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.zuordnung-marke {
display: inline-flex;
gap: 0.35rem;
align-items: baseline;
background: var(--grund);
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
padding: 0.15rem 0.5rem;
font-size: 0.87rem;
}
.zuordnung-marke strong {
color: var(--petrol);
}
.fliesstext > *:first-child { margin-top: 0; }
.fliesstext > *:last-child { margin-bottom: 0; }
.fliesstext ul,
.fliesstext ol {
padding-left: 1.3rem;
margin: 0 0 1em;
}
.fliesstext li + li { margin-top: 0.4rem; }
.fliesstext table {
border-collapse: collapse;
width: 100%;
font-size: 0.94rem;
}
.fliesstext th,
.fliesstext td {
border: 1px solid var(--rand-zart);
padding: 0.45rem 0.65rem;
text-align: left;
vertical-align: top;
}
.fliesstext th {
background: var(--flaeche);
font-weight: 650;
}
.merksatz {
border-left: 5px solid var(--bernstein);
background: var(--warnung-flaeche);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 0.9rem 1.1rem;
}
.merksatz-kennzeichnung {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
color: var(--warnung);
margin-bottom: 0.2rem;
}
.merksatz p {
margin: 0;
font-size: 1.08rem;
font-weight: 600;
}
.pruefungsfrage {
border: 1px solid var(--rand);
border-radius: var(--radius);
background: var(--grund);
}
.pruefungsfrage > summary {
cursor: pointer;
padding: 0.7rem 1rem;
font-weight: 650;
list-style: none;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
}
.pruefungsfrage > summary::-webkit-details-marker { display: none; }
.pruefungsfrage > summary::before {
content: "▸";
color: var(--petrol);
font-size: 0.9rem;
}
.pruefungsfrage[open] > summary::before { content: "▾"; }
.pruefungsfrage[open] > summary { border-bottom: 1px solid var(--rand-zart); }
.pruefungsfrage > div { padding: 0.9rem 1rem; }
a.glossarbegriff {
color: inherit;
text-decoration: underline dotted var(--petrol);
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
cursor: help;
}
a.glossarbegriff:hover {
color: var(--petrol);
text-decoration-style: solid;
}
/* --- Glossar --- */
.glossar {
display: grid;
gap: 0.75rem;
margin: 0;
}
.glossar-eintrag {
scroll-margin-top: 1rem;
}
.glossar-eintrag dt {
font-weight: 700;
font-size: 1.05rem;
margin-bottom: 0.3rem;
}
.glossar-langform {
display: block;
font-weight: 400;
font-size: 0.85rem;
color: var(--text-leise);
}
.glossar-eintrag dd { margin: 0; }
.glossar-eintrag:target {
outline: 3px solid var(--bernstein);
outline-offset: 2px;
}
@media print {
.pruefungsfrage > div { display: block !important; }
.handout-zuordnung { border: 1px solid #999; }
a.glossarbegriff { text-decoration: none; }
}
+2
View File
@@ -5,6 +5,8 @@ export const metadata: Metadata = {
title: { default: 'Lern-Bar', template: '%s · Lern-Bar' },
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
applicationName: 'Lern-Bar',
// Sonst macht der Browser aus Zahlenkolonnen in Tabellen Telefonnummern.
formatDetection: { telephone: false, date: false, address: false, email: false },
};
export const viewport: Viewport = {
+8 -1
View File
@@ -8,6 +8,13 @@ export async function register() {
const { rohVerbindung } = await import('./lib/db');
rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an
console.log('[lernbar] Datenbank bereit.');
const { inhalteEinlesen } = await import('./lib/inhalte');
const alle = inhalteEinlesen();
console.log(
`[lernbar] Inhalte gelesen: ${alle.wochen.length} Wochen, ${alle.handouts.length} Handouts, ` +
`${alle.karten.length} Merkkarten, ${alle.aufgaben.length} Aufgaben, ${alle.checklisten.length} Checklisten, ` +
`${alle.kontrollen.length} Lernzielkontrollen, ${alle.glossar.length} Glossareinträge.`,
);
}
+13
View File
@@ -0,0 +1,13 @@
-- Baustein 2: gelesene Handouts.
-- Der Inhalt selbst liegt als Datei im Repository; hier steht nur, wer was
-- gelesen hat. Die Handout-Kennung ist deshalb ein freier Text ohne
-- Fremdschlüssel – die Prüfung, dass es sie gibt, macht der Inhaltslader.
CREATE TABLE handout_gelesen (
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
handout_id TEXT NOT NULL,
gelesen_am TEXT NOT NULL,
PRIMARY KEY (benutzer_id, handout_id)
);
CREATE INDEX handout_gelesen_benutzer_idx ON handout_gelesen (benutzer_id);
+15 -2
View File
@@ -1,4 +1,4 @@
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
/** Rollen – bewusst nur zwei. */
export const rollen = ['ausbilder', 'azubi'] as const;
@@ -51,9 +51,22 @@ export const ausbildung = sqliteTable(
(t) => [uniqueIndex('ausbildung_azubi_idx').on(t.azubiId), index('ausbildung_ausbilder_idx').on(t.ausbilderId)],
);
export const handoutGelesen = sqliteTable(
'handout_gelesen',
{
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
handoutId: text('handout_id').notNull(),
gelesenAm: text('gelesen_am').notNull(),
},
(t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { benutzer, sitzung, ausbildung };
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen };
+73
View File
@@ -0,0 +1,73 @@
/**
* Fehler beim Einlesen der Inhalte.
*
* Der Anspruch: Wer eine Inhaltsdatei kaputt macht, bekommt Datei, Zeile und
* einen Satz, der sagt, was zu tun ist – nicht einen Stapelabzug.
*/
export type Fundstelle = {
datei: string;
zeile?: number;
spalte?: number;
feld?: string;
};
export type Einzelfehler = Fundstelle & { meldung: string };
export class InhaltsFehler extends Error {
readonly fehler: Einzelfehler[];
constructor(fehler: Einzelfehler[]) {
super(InhaltsFehler.formatiere(fehler));
this.name = 'InhaltsFehler';
this.fehler = fehler;
}
static formatiere(fehler: Einzelfehler[]): string {
const zeilen = fehler.map((f) => {
const ort = [f.datei, f.zeile, f.spalte].filter((t) => t !== undefined).join(':');
const feld = f.feld ? ` (${f.feld})` : '';
return ` ${ort}${feld}\n ${f.meldung}`;
});
const anzahl = fehler.length === 1 ? '1 Fehler' : `${fehler.length} Fehler`;
return [
'',
`Die Inhalte unter content/ sind nicht in Ordnung – ${anzahl}:`,
'',
...zeilen,
'',
'Die Anwendung startet erst, wenn alle Inhalte gültig sind.',
'Zum Prüfen ohne Start: npm run inhalte:pruefen',
'',
].join('\n');
}
}
/** Sammelt Fehler, statt beim ersten abzubrechen – so sieht man alle auf einmal. */
export class Fehlersammler {
private readonly gesammelt: Einzelfehler[] = [];
melde(fehler: Einzelfehler): void {
this.gesammelt.push(fehler);
}
get anzahl(): number {
return this.gesammelt.length;
}
get leer(): boolean {
return this.gesammelt.length === 0;
}
alle(): Einzelfehler[] {
return [...this.gesammelt];
}
/** Wirft, sobald mindestens ein Fehler gesammelt wurde. */
wirfWennNoetig(): void {
if (this.gesammelt.length > 0) {
throw new InhaltsFehler(this.gesammelt);
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { GlossarEintrag } from './schema';
/**
* Verlinkt Glossarbegriffe im fertigen HTML eines Handouts.
*
* Bewusst zurückhaltend: jeder Begriff höchstens einmal je Seite, nie innerhalb
* von Code, Überschriften oder bestehenden Verweisen. Ein Text, in dem jedes
* zweite Wort unterstrichen ist, liest sich schlechter als einer ganz ohne.
*
* Der Verweis ist ein echter Link auf das Glossar und trägt die Erklärung
* zusätzlich im title-Attribut – reines Überfahren mit der Maus wäre auf dem
* Handy und mit der Tastatur nicht bedienbar.
*/
const gemiedeneElemente = new Set(['code', 'pre', 'a', 'h1', 'h2', 'h3', 'h4', 'th']);
export function glossarAnker(begriff: string): string {
return begriff
.toLocaleLowerCase('de-DE')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function maskiere(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fuerRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
type Suchmuster = { muster: RegExp; eintrag: GlossarEintrag };
/** Baut die Suchmuster einmal auf – Begriff und alle angegebenen Formen. */
export function musterFuer(glossar: GlossarEintrag[]): Suchmuster[] {
const muster: Suchmuster[] = [];
for (const eintrag of glossar) {
const formen = [eintrag.begriff, ...eintrag.formen];
for (const form of formen) {
muster.push({
// Wortgrenzen von Hand, weil \b bei Umlauten und Bindestrichen daneben liegt.
muster: new RegExp(`(^|[^\\p{L}\\p{N}_-])(${fuerRegex(form)})(?=$|[^\\p{L}\\p{N}_-])`, 'iu'),
eintrag,
});
}
}
// Längere Formen zuerst, damit „MAC-Adresse" nicht als „Adresse" endet.
return muster.sort((a, b) => b.muster.source.length - a.muster.source.length);
}
export function verlinkeGlossar(html: string, glossar: GlossarEintrag[]): string {
if (glossar.length === 0) return html;
const muster = musterFuer(glossar);
const bereitsVerlinkt = new Set<string>();
const teile = html.split(/(<[^>]+>)/);
const offen: string[] = [];
for (let i = 0; i < teile.length; i += 1) {
const teil = teile[i]!;
if (teil.startsWith('<')) {
const treffer = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)/.exec(teil);
if (treffer) {
const [, schrägstrich, name] = treffer;
const kleinName = name!.toLowerCase();
if (schrägstrich) {
const stelle = offen.lastIndexOf(kleinName);
if (stelle !== -1) offen.splice(stelle, 1);
} else if (!teil.endsWith('/>')) {
offen.push(kleinName);
}
}
continue;
}
if (teil.trim() === '') continue;
if (offen.some((element) => gemiedeneElemente.has(element))) continue;
let text = teil;
for (const { muster: regex, eintrag } of muster) {
if (bereitsVerlinkt.has(eintrag.begriff)) continue;
const gefunden = regex.exec(text);
if (!gefunden) continue;
const erklaerung = eintrag.steht_fuer
? `${eintrag.steht_fuer} – ${eintrag.erklaerung}`
: eintrag.erklaerung;
text =
text.slice(0, gefunden.index) +
gefunden[1] +
`<a class="glossarbegriff" href="/glossar#${glossarAnker(eintrag.begriff)}" title="${maskiere(erklaerung)}">${gefunden[2]}</a>` +
text.slice(gefunden.index + gefunden[0].length);
bereitsVerlinkt.add(eintrag.begriff);
}
teile[i] = text;
}
return teile.join('');
}
+77
View File
@@ -0,0 +1,77 @@
import fs from 'node:fs';
import { inhaltWurzel } from '../pfade';
import { ladeInhalte, type Inhalte } from './laden';
/**
* Die Inhalte werden einmal eingelesen und liegen danach im Speicher.
*
* Im Entwicklungsbetrieb wird das Verzeichnis beobachtet: Ändert sich eine
* Datei, wird beim nächsten Zugriff neu eingelesen. Schlägt das Einlesen dabei
* fehl, bleibt der bisherige Stand stehen und der Fehler wird ins Protokoll
* geschrieben – im laufenden Entwicklungsbetrieb ist ein Absturz beim Tippen
* nicht hilfreich. Beim Start dagegen bricht die Anwendung ab.
*/
type Halter = {
inhalte?: Inhalte;
beobachter?: fs.FSWatcher;
veraltet?: boolean;
};
const halter = globalThis as unknown as { __lernbarInhalte?: Halter };
halter.__lernbarInhalte ??= {};
const imEntwicklungsbetrieb = process.env.NODE_ENV !== 'production';
function beobachte(): void {
const zustand = halter.__lernbarInhalte!;
if (zustand.beobachter || !imEntwicklungsbetrieb) return;
if (!fs.existsSync(inhaltWurzel)) return;
try {
zustand.beobachter = fs.watch(inhaltWurzel, { recursive: true }, () => {
zustand.veraltet = true;
});
zustand.beobachter.unref?.();
} catch {
// Auf manchen Dateisystemen gibt es kein rekursives Beobachten. Dann eben nicht.
}
}
/** Die eingelesenen Inhalte. Liest beim ersten Aufruf ein. */
export function inhalte(): Inhalte {
const zustand = halter.__lernbarInhalte!;
if (!zustand.inhalte) {
zustand.inhalte = ladeInhalte(inhaltWurzel);
beobachte();
return zustand.inhalte;
}
if (zustand.veraltet) {
zustand.veraltet = false;
try {
zustand.inhalte = ladeInhalte(inhaltWurzel);
console.log('[lernbar] Inhalte neu eingelesen.');
} catch (fehler) {
console.error(`[lernbar] Inhalte konnten nicht neu eingelesen werden – der bisherige Stand bleibt aktiv.`);
console.error((fehler as Error).message);
}
}
return zustand.inhalte;
}
/** Erzwingt das Einlesen – beim Start, damit Fehler sofort auffallen. */
export function inhalteEinlesen(): Inhalte {
const zustand = halter.__lernbarInhalte!;
zustand.inhalte = ladeInhalte(inhaltWurzel);
zustand.veraltet = false;
beobachte();
return zustand.inhalte;
}
export { ladeInhalte };
export type { Inhalte, Handout, Aufgabe, Merkkarte } from './laden';
export * from './schema';
export { InhaltsFehler } from './fehler';
+251
View File
@@ -0,0 +1,251 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ladeInhalte } from './laden';
import { InhaltsFehler } from './fehler';
/**
* Der Inhaltslader wird gegen ein winziges, vollständiges Beispielverzeichnis
* geprüft, das je Test gezielt kaputt gemacht wird. So lässt sich prüfen, ob
* die Meldung wirklich auf Datei und Zeile zeigt.
*/
const aufraeumen: string[] = [];
afterEach(() => {
while (aufraeumen.length > 0) {
fs.rmSync(aufraeumen.pop()!, { recursive: true, force: true });
}
});
const RAHMENPLAN = `quellen:
betrieb: { titel: FIAusbV, kurz: FIAusbV, vom: "2020-02-28", fundstelle: Anlage }
schule: { titel: Rahmenlehrplan, kurz: KMK, vom: "2019-12-13" }
berufsbildpositionen:
- { id: A1, gruppe: allgemein, titel: Arbeitsaufgaben planen, kurz: Planen, verordnung: "A, Nr. 1" }
- { id: B2, gruppe: si, titel: Netzwerke installieren, kurz: Netzwerke, verordnung: "C, Nr. 2" }
gruppen:
allgemein: Allgemein
si: Systemintegration
lernfelder:
- { id: LF3, titel: Clients in Netzwerke einbinden, jahr: 1, stunden: 80 }
bloecke:
- { id: netzwerk, kuerzel: N, titel: Netzwerk }
`;
const WOCHE = `nummer: 1
titel: Netzwerkgrundlagen
block: netzwerk
leitfrage: Wo suchst du zuerst?
ziel: Du kannst Netze bestimmen.
handouts: [N1]
aufgaben: []
checklisten: []
lernzielkontrolle: null
tage:
- { tag: Montag, theorie: [N1], praxis: Netzplan zeichnen }
- { tag: Dienstag, theorie: [], praxis: Adressen aufnehmen }
- { tag: Mittwoch, theorie: [], praxis: Rechnen }
- { tag: Donnerstag, theorie: [], praxis: Fehlersuche }
- { tag: Freitag, theorie: [], praxis: Rückblick }
`;
const HANDOUT = `---
id: N1
titel: Subnetting rechnen
block: netzwerk
woche: 1
worum: Adresse und Maske ergeben Netz und Broadcast.
rahmenplan: [B2]
lernfeld: LF3
merksatz: Blockgröße finden.
karten:
- frage: Was ist die Blockgröße bei /27?
antwort: 32 Adressen, davon 30 nutzbar.
---
## Das Wesentliche
- Blockgröße bestimmen
## Übersicht
| CIDR | Maske |
|---|---|
| /27 | 255.255.255.224 |
## Prüfungsfrage
**Frage:** Und?
**Antwort:** So.
`;
const GLOSSAR = `- begriff: Subnetzmaske
formen: [Maske]
erklaerung: Trennt Netz und Rechner.
siehe_auch: []
`;
function baueBeispiel(aenderungen: Record<string, string | null> = {}): string {
const wurzel = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-inhalte-'));
aufraeumen.push(wurzel);
const dateien: Record<string, string> = {
'rahmenplan.yaml': RAHMENPLAN,
'glossar.yaml': GLOSSAR,
'wochen/01-netzwerk.yaml': WOCHE,
'handouts/N1-subnetting.md': HANDOUT,
};
for (const [relativ, inhalt] of Object.entries({ ...dateien, ...aenderungen })) {
const ziel = path.join(wurzel, relativ);
if (inhalt === null) {
fs.rmSync(ziel, { force: true });
continue;
}
fs.mkdirSync(path.dirname(ziel), { recursive: true });
fs.writeFileSync(ziel, inhalt, 'utf8');
}
return wurzel;
}
function fehlerVon(wurzel: string) {
try {
ladeInhalte(wurzel);
return null;
} catch (fehler) {
if (fehler instanceof InhaltsFehler) return fehler;
throw fehler;
}
}
describe('Inhalte einlesen', () => {
it('liest ein vollständiges Beispiel ohne Beanstandung', () => {
const alle = ladeInhalte(baueBeispiel());
expect(alle.handouts).toHaveLength(1);
expect(alle.handouts[0]!.id).toBe('N1');
expect(alle.wochen).toHaveLength(1);
expect(alle.karten).toHaveLength(1);
expect(alle.karten[0]!.id).toBe('N1-1');
});
it('zerlegt das Handout in seine festen Abschnitte', () => {
const alle = ladeInhalte(baueBeispiel());
const handout = alle.handouts[0]!;
expect(handout.wesentlich?.titel).toBe('Das Wesentliche');
expect(handout.uebersicht?.html).toContain('<table>');
expect(handout.pruefungsfrage?.html).toContain('Antwort');
});
});
describe('Verweise werden geprüft', () => {
it('meldet eine unbekannte Berufsbildposition mit Datei und Zeile', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('[B2]', '[B9]') }));
expect(fehler).not.toBeNull();
const treffer = fehler!.fehler.find((f) => f.meldung.includes('B9'));
expect(treffer).toBeDefined();
expect(treffer!.datei).toMatch(/\/handouts\/N1-subnetting\.md$/);
expect(treffer!.zeile).toBe(7); // Zeile mit rahmenplan:
expect(treffer!.meldung).toContain('rahmenplan.yaml');
});
it('meldet ein unbekanntes Lernfeld', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('LF3', 'LF9') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('LF9'))).toBe(true);
});
it('meldet ein Handout, dessen Woche es nicht gibt', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('woche: 1', 'woche: 7') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('keine Woche 7'))).toBe(true);
});
it('meldet ein Handout, das in seiner Woche nicht aufgeführt ist', () => {
const fehler = fehlerVon(baueBeispiel({ 'wochen/01-netzwerk.yaml': WOCHE.replace('handouts: [N1]', 'handouts: []') }));
// Sowohl das Handout beschwert sich als auch der Wochenplan über den Theorietag.
expect(fehler!.fehler.some((f) => f.meldung.includes('nicht unter handouts: aufgeführt'))).toBe(true);
});
it('meldet ein Kürzel, das nicht zum Themenblock passt', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('id: N1', 'id: H1') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Anfangsbuchstaben N'))).toBe(true);
});
it('meldet doppelte Kürzel und nennt die andere Datei', () => {
const fehler = fehlerVon(
baueBeispiel({ 'handouts/N1-zweitfassung.md': HANDOUT.replace('titel: Subnetting rechnen', 'titel: Noch mal') }),
);
expect(fehler!.fehler.some((f) => f.meldung.includes('gibt es schon in'))).toBe(true);
});
it('meldet einen Glossarverweis ins Leere', () => {
const fehler = fehlerVon(baueBeispiel({ 'glossar.yaml': GLOSSAR.replace('siehe_auch: []', 'siehe_auch: [Gateway]') }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Gateway'))).toBe(true);
});
it('meldet eine Woche ohne fünf Tage', () => {
const gekuerzt = WOCHE.split('\n').slice(0, -3).join('\n');
const fehler = fehlerVon(baueBeispiel({ 'wochen/01-netzwerk.yaml': gekuerzt }));
expect(fehler!.fehler.some((f) => f.meldung.includes('genau fünf Tage'))).toBe(true);
});
it('meldet ein Handout ohne Merkkarte', () => {
const ohneKarten = HANDOUT.replace(/karten:\n( - .*\n| .*\n)+/, 'karten: []\n');
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': ohneKarten }));
expect(fehler!.fehler.some((f) => f.meldung.includes('mindestens eine Merkkarte'))).toBe(true);
});
it('meldet ein Handout ohne den Abschnitt „Das Wesentliche"', () => {
const ohne = HANDOUT.replace('## Das Wesentliche', '## Irgendwas anderes');
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': ohne }));
expect(fehler!.fehler.some((f) => f.meldung.includes('Das Wesentliche'))).toBe(true);
});
});
describe('Fehlerdarstellung', () => {
it('sammelt alle Fehler statt beim ersten abzubrechen', () => {
const kaputt = HANDOUT.replace('[B2]', '[B9]').replace('LF3', 'LF9');
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': kaputt }));
expect(fehler!.fehler.length).toBeGreaterThanOrEqual(2);
});
it('nennt in der Meldung Datei und Zeile', () => {
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('[B2]', '[B9]') }));
expect(fehler!.message).toMatch(/handouts\/N1-subnetting\.md:7/);
expect(fehler!.message).toContain('npm run inhalte:pruefen');
});
it('meldet kaputtes YAML mit der Zeile, statt einen Stapelabzug zu werfen', () => {
const kaputt = RAHMENPLAN.replace('lernfelder:', 'lernfelder:\n - [ das ist kein gültiges YAML');
const fehler = fehlerVon(baueBeispiel({ 'rahmenplan.yaml': kaputt }));
expect(fehler).not.toBeNull();
expect(fehler!.fehler[0]!.datei).toMatch(/rahmenplan\.yaml$/);
expect(fehler!.fehler[0]!.zeile).toBeGreaterThan(0);
});
});
describe('Die echten Inhalte des Repositorys', () => {
it('lassen sich einlesen und sind in sich stimmig', () => {
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
expect(alle.wochen.length).toBeGreaterThanOrEqual(4);
expect(alle.handouts.length).toBeGreaterThanOrEqual(10);
expect(alle.karten.length).toBeGreaterThanOrEqual(10);
expect(alle.glossar.length).toBeGreaterThanOrEqual(20);
expect(alle.positionen.map((p) => p.id)).toContain('A1');
expect(alle.positionen.map((p) => p.id)).toContain('B2');
expect(alle.positionen.map((p) => p.id)).toContain('C4');
expect(alle.lernfelder).toHaveLength(12);
});
it('vergibt jeder Merkkarte eine eindeutige Kennung', () => {
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
const ids = alle.karten.map((k) => k.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
+695
View File
@@ -0,0 +1,695 @@
import path from 'node:path';
import fs from 'node:fs';
import { Fehlersammler } from './fehler';
import { dateienIn, leseMarkdown, leseYaml, type GeleseneDatei } from './lesen';
import { abschnitt, inAbschnitte, nurText, type Abschnitt } from './markdown';
import {
aufgabeKopfSchema,
checklisteSchema,
glossarSchema,
handoutKopfSchema,
kontrolleSchema,
rahmenplanSchema,
wocheSchema,
type AufgabeKopf,
type Berufsbildposition,
type Checkliste,
type GlossarEintrag,
type HandoutKopf,
type Kontrolle,
type Lernfeld,
type Rahmenplan,
type Themenblock,
type Woche,
} from './schema';
/**
* Liest alles unter content/ ein, prüft es und baut die Verweise auf.
*
* Grundsatz: Fehler werden gesammelt, nicht beim ersten geworfen. Wer zehn
* Tippfehler eingebaut hat, will sie in einem Durchgang sehen.
*/
export type Handout = HandoutKopf & {
datei: string;
rumpf: string;
abschnitte: Abschnitt[];
wesentlich: Abschnitt | undefined;
uebersicht: Abschnitt | undefined;
pruefungsfrage: Abschnitt | undefined;
suchtext: string;
};
export type Aufgabe = AufgabeKopf & {
datei: string;
rumpf: string;
abschnitte: Abschnitt[];
suchtext: string;
};
export type Merkkarte = {
id: string;
handoutId: string;
handoutTitel: string;
block: string;
woche: number;
frage: string;
antwort: string;
};
export type Inhalte = {
rahmenplan: Rahmenplan;
positionen: Berufsbildposition[];
positionNach: Map<string, Berufsbildposition>;
lernfelder: Lernfeld[];
lernfeldNach: Map<string, Lernfeld>;
bloecke: Themenblock[];
blockNach: Map<string, Themenblock>;
wochen: Woche[];
wocheNach: Map<number, Woche>;
handouts: Handout[];
handoutNach: Map<string, Handout>;
handoutsNachBlock: Map<string, Handout[]>;
karten: Merkkarte[];
karteNach: Map<string, Merkkarte>;
aufgaben: Aufgabe[];
aufgabeNach: Map<string, Aufgabe>;
checklisten: Checkliste[];
checklisteNach: Map<string, Checkliste>;
kontrollen: Kontrolle[];
kontrolleNach: Map<string, Kontrolle>;
glossar: GlossarEintrag[];
glossarNach: Map<string, GlossarEintrag>;
/** Alle Dateien, die eingelesen wurden – für das Neuladen im Entwicklungsbetrieb. */
dateien: string[];
gelesenAm: string;
};
function anzeige(wurzel: string, absolut: string): string {
return path.relative(path.dirname(wurzel), absolut).split(path.sep).join('/');
}
export function ladeInhalte(wurzel: string): Inhalte {
const sammler = new Fehlersammler();
const dateien: string[] = [];
if (!fs.existsSync(wurzel)) {
sammler.melde({
datei: wurzel,
meldung: 'Das Inhaltsverzeichnis gibt es nicht. Erwartet wird ein Ordner content/ neben package.json.',
});
sammler.wirfWennNoetig();
}
const zeigeName = (absolut: string) => anzeige(wurzel, absolut);
// --- rahmenplan.yaml ----------------------------------------------------
const rahmenplanDatei = path.join(wurzel, 'rahmenplan.yaml');
dateien.push(rahmenplanDatei);
const rahmenplanGelesen = leseYaml(rahmenplanDatei, zeigeName(rahmenplanDatei), rahmenplanSchema, sammler);
if (!rahmenplanGelesen) {
// Ohne Rahmenplan lässt sich nichts weiter prüfen.
sammler.wirfWennNoetig();
throw new Error('unerreichbar');
}
const rahmenplan = rahmenplanGelesen.werte;
const positionNach = new Map(rahmenplan.berufsbildpositionen.map((p) => [p.id, p]));
const lernfeldNach = new Map(rahmenplan.lernfelder.map((l) => [l.id, l]));
const blockNach = new Map(rahmenplan.bloecke.map((b) => [b.id, b]));
pruefeDoppelte(rahmenplan.berufsbildpositionen.map((p) => p.id), rahmenplanGelesen, 'berufsbildpositionen', 'Berufsbildposition', sammler);
pruefeDoppelte(rahmenplan.lernfelder.map((l) => l.id), rahmenplanGelesen, 'lernfelder', 'Lernfeld', sammler);
pruefeDoppelte(rahmenplan.bloecke.map((b) => b.id), rahmenplanGelesen, 'bloecke', 'Themenblock', sammler);
for (const [index, position] of rahmenplan.berufsbildpositionen.entries()) {
if (!rahmenplan.gruppen[position.gruppe]) {
sammler.melde({
datei: rahmenplanGelesen.datei,
zeile: rahmenplanGelesen.zeileVon(['berufsbildpositionen', index, 'gruppe']),
feld: `berufsbildpositionen[${index}].gruppe`,
meldung: `Die Gruppe „${position.gruppe}" steht nicht unter gruppen:. Bekannt sind: ${Object.keys(rahmenplan.gruppen).join(', ')}.`,
});
}
}
// --- wochen/*.yaml ------------------------------------------------------
const wochen: Woche[] = [];
const wochenQuelle = new Map<number, GeleseneDatei<Woche>>();
for (const absolut of dateienIn(path.join(wurzel, 'wochen'), ['.yaml', '.yml'])) {
dateien.push(absolut);
const gelesen = leseYaml(absolut, zeigeName(absolut), wocheSchema, sammler);
if (!gelesen) continue;
if (wochenQuelle.has(gelesen.werte.nummer)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['nummer']),
feld: 'nummer',
meldung: `Die Wochennummer ${gelesen.werte.nummer} gibt es schon in ${wochenQuelle.get(gelesen.werte.nummer)!.datei}.`,
});
continue;
}
wochenQuelle.set(gelesen.werte.nummer, gelesen);
wochen.push(gelesen.werte);
}
wochen.sort((a, b) => a.nummer - b.nummer);
const wocheNach = new Map(wochen.map((w) => [w.nummer, w]));
// --- handouts/*.md ------------------------------------------------------
const handouts: Handout[] = [];
const handoutQuelle = new Map<string, GeleseneDatei<HandoutKopf>>();
for (const absolut of dateienIn(path.join(wurzel, 'handouts'), '.md')) {
dateien.push(absolut);
const gelesen = leseMarkdown(absolut, zeigeName(absolut), handoutKopfSchema, sammler);
if (!gelesen) continue;
const kopf = gelesen.werte;
if (handoutQuelle.has(kopf.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Das Kürzel ${kopf.id} gibt es schon in ${handoutQuelle.get(kopf.id)!.datei}. Kürzel müssen eindeutig sein.`,
});
continue;
}
handoutQuelle.set(kopf.id, gelesen);
const abschnitte = inAbschnitte(gelesen.rumpf);
const wesentlich = abschnitt(abschnitte, 'Das Wesentliche');
if (!wesentlich) {
sammler.melde({
datei: gelesen.datei,
meldung: 'Es fehlt der Abschnitt „## Das Wesentliche". Jedes Handout hat diesen festen Aufbau.',
});
}
handouts.push({
...kopf,
datei: gelesen.datei,
rumpf: gelesen.rumpf,
abschnitte,
wesentlich,
uebersicht: abschnitt(abschnitte, 'Übersicht'),
pruefungsfrage: abschnitt(abschnitte, 'Prüfungsfrage'),
suchtext: normalisiere(
[kopf.id, kopf.titel, kopf.worum, kopf.merksatz, ...kopf.karten.flatMap((k) => [k.frage, k.antwort]), nurText(gelesen.rumpf)].join(
' \n ',
),
),
});
}
handouts.sort((a, b) => vergleicheKuerzel(a.id, b.id));
const handoutNach = new Map(handouts.map((h) => [h.id, h]));
// --- aufgaben/*.md ------------------------------------------------------
const aufgaben: Aufgabe[] = [];
const aufgabeQuelle = new Map<string, GeleseneDatei<AufgabeKopf>>();
for (const absolut of dateienIn(path.join(wurzel, 'aufgaben'), '.md')) {
dateien.push(absolut);
const gelesen = leseMarkdown(absolut, zeigeName(absolut), aufgabeKopfSchema, sammler);
if (!gelesen) continue;
const kopf = gelesen.werte;
if (aufgabeQuelle.has(kopf.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Die Aufgabenkennung ${kopf.id} gibt es schon in ${aufgabeQuelle.get(kopf.id)!.datei}.`,
});
continue;
}
aufgabeQuelle.set(kopf.id, gelesen);
const abschnitte = inAbschnitte(gelesen.rumpf);
aufgaben.push({
...kopf,
datei: gelesen.datei,
rumpf: gelesen.rumpf,
abschnitte,
suchtext: normalisiere([kopf.id, kopf.titel, kopf.ziel, ...kopf.kriterien, nurText(gelesen.rumpf)].join(' \n ')),
});
}
aufgaben.sort((a, b) => a.id.localeCompare(b.id, 'de-DE', { numeric: true }));
const aufgabeNach = new Map(aufgaben.map((a) => [a.id, a]));
// --- checklisten/*.yaml -------------------------------------------------
const checklisten: Checkliste[] = [];
const checklisteQuelle = new Map<string, GeleseneDatei<Checkliste>>();
for (const absolut of dateienIn(path.join(wurzel, 'checklisten'), ['.yaml', '.yml'])) {
dateien.push(absolut);
const gelesen = leseYaml(absolut, zeigeName(absolut), checklisteSchema, sammler);
if (!gelesen) continue;
const liste = gelesen.werte;
if (checklisteQuelle.has(liste.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Die Checklistenkennung ${liste.id} gibt es schon in ${checklisteQuelle.get(liste.id)!.datei}.`,
});
continue;
}
checklisteQuelle.set(liste.id, gelesen);
const schrittIds = liste.gruppen.flatMap((g) => g.schritte.map((s) => s.id));
pruefeDoppelte(schrittIds, gelesen, 'gruppen', 'Schrittkennung', sammler);
checklisten.push(liste);
}
checklisten.sort((a, b) => a.id.localeCompare(b.id, 'de-DE', { numeric: true }));
const checklisteNach = new Map(checklisten.map((c) => [c.id, c]));
// --- lernzielkontrollen/*.yaml ------------------------------------------
const kontrollen: Kontrolle[] = [];
const kontrolleQuelle = new Map<string, GeleseneDatei<Kontrolle>>();
for (const absolut of dateienIn(path.join(wurzel, 'lernzielkontrollen'), ['.yaml', '.yml'])) {
dateien.push(absolut);
const gelesen = leseYaml(absolut, zeigeName(absolut), kontrolleSchema, sammler);
if (!gelesen) continue;
const kontrolle = gelesen.werte;
if (kontrolleQuelle.has(kontrolle.id)) {
sammler.melde({
datei: gelesen.datei,
zeile: gelesen.zeileVon(['id']),
feld: 'id',
meldung: `Die Kennung ${kontrolle.id} gibt es schon in ${kontrolleQuelle.get(kontrolle.id)!.datei}.`,
});
continue;
}
kontrolleQuelle.set(kontrolle.id, gelesen);
pruefeDoppelte(kontrolle.fragen.map((f) => f.id), gelesen, 'fragen', 'Fragenkennung', sammler);
kontrollen.push(kontrolle);
}
kontrollen.sort((a, b) => a.woche - b.woche);
const kontrolleNach = new Map(kontrollen.map((k) => [k.id, k]));
// --- glossar.yaml -------------------------------------------------------
const glossarDatei = path.join(wurzel, 'glossar.yaml');
dateien.push(glossarDatei);
const glossarGelesen = leseYaml(glossarDatei, zeigeName(glossarDatei), glossarSchema, sammler);
const glossar = glossarGelesen?.werte ?? [];
const glossarNach = new Map(glossar.map((g) => [g.begriff.toLocaleLowerCase('de-DE'), g]));
if (glossarGelesen) {
pruefeDoppelte(glossar.map((g) => g.begriff.toLocaleLowerCase('de-DE')), glossarGelesen, '', 'Glossarbegriff', sammler);
for (const [index, eintrag] of glossar.entries()) {
for (const [j, verweis] of eintrag.siehe_auch.entries()) {
if (!glossarNach.has(verweis.toLocaleLowerCase('de-DE'))) {
sammler.melde({
datei: glossarGelesen.datei,
zeile: glossarGelesen.zeileVon([index, 'siehe_auch', j]),
feld: `${eintrag.begriff}.siehe_auch`,
meldung: `„${verweis}" steht nicht im Glossar.`,
});
}
}
}
}
// --- Verweise prüfen ----------------------------------------------------
pruefeVerweise({
sammler,
rahmenplan,
positionNach,
lernfeldNach,
blockNach,
wochen,
wochenQuelle,
handouts,
handoutQuelle,
handoutNach,
aufgaben,
aufgabeQuelle,
aufgabeNach,
checklisteNach,
kontrolleNach,
kontrollen,
kontrolleQuelle,
wocheNach,
});
sammler.wirfWennNoetig();
// --- Ableitungen --------------------------------------------------------
const handoutsNachBlock = new Map<string, Handout[]>();
for (const block of rahmenplan.bloecke) handoutsNachBlock.set(block.id, []);
for (const handout of handouts) {
handoutsNachBlock.get(handout.block)!.push(handout);
}
const karten: Merkkarte[] = handouts.flatMap((handout) =>
handout.karten.map((karte, index) => ({
id: `${handout.id}-${index + 1}`,
handoutId: handout.id,
handoutTitel: handout.titel,
block: handout.block,
woche: handout.woche,
frage: karte.frage,
antwort: karte.antwort,
})),
);
return {
rahmenplan,
positionen: rahmenplan.berufsbildpositionen,
positionNach,
lernfelder: rahmenplan.lernfelder,
lernfeldNach,
bloecke: rahmenplan.bloecke,
blockNach,
wochen,
wocheNach,
handouts,
handoutNach,
handoutsNachBlock,
karten,
karteNach: new Map(karten.map((k) => [k.id, k])),
aufgaben,
aufgabeNach,
checklisten,
checklisteNach,
kontrollen,
kontrolleNach,
glossar,
glossarNach,
dateien,
gelesenAm: new Date().toISOString(),
};
}
// --- Hilfsmittel ----------------------------------------------------------
function pruefeDoppelte(
werte: string[],
quelle: GeleseneDatei<unknown>,
feld: string,
bezeichnung: string,
sammler: Fehlersammler,
): void {
const gesehen = new Set<string>();
for (const [index, wert] of werte.entries()) {
if (gesehen.has(wert)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(feld ? [feld, index] : [index]),
feld: feld || undefined,
meldung: `${bezeichnung} „${wert}" kommt mehrfach vor.`,
});
}
gesehen.add(wert);
}
}
/** Sortiert N2 vor N10 – reine Zeichenkettensortierung tut das nicht. */
function vergleicheKuerzel(a: string, b: string): number {
const zerlege = (k: string) => [k.slice(0, 1), Number(k.slice(1))] as const;
const [buchstabeA, zahlA] = zerlege(a);
const [buchstabeB, zahlB] = zerlege(b);
if (buchstabeA !== buchstabeB) return buchstabeA.localeCompare(buchstabeB, 'de-DE');
return zahlA - zahlB;
}
export function normalisiere(text: string): string {
return text
.toLocaleLowerCase('de-DE')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/\s+/g, ' ');
}
type Pruefkontext = {
sammler: Fehlersammler;
rahmenplan: Rahmenplan;
positionNach: Map<string, Berufsbildposition>;
lernfeldNach: Map<string, Lernfeld>;
blockNach: Map<string, Themenblock>;
wochen: Woche[];
wocheNach: Map<number, Woche>;
wochenQuelle: Map<number, GeleseneDatei<Woche>>;
handouts: Handout[];
handoutQuelle: Map<string, GeleseneDatei<HandoutKopf>>;
handoutNach: Map<string, Handout>;
aufgaben: Aufgabe[];
aufgabeQuelle: Map<string, GeleseneDatei<AufgabeKopf>>;
aufgabeNach: Map<string, Aufgabe>;
checklisteNach: Map<string, Checkliste>;
kontrollen: Kontrolle[];
kontrolleQuelle: Map<string, GeleseneDatei<Kontrolle>>;
kontrolleNach: Map<string, Kontrolle>;
};
function pruefeVerweise(k: Pruefkontext): void {
const { sammler } = k;
const bekanntePositionen = [...k.positionNach.keys()].join(', ');
const bekannteLernfelder = [...k.lernfeldNach.keys()].join(', ');
const bekannteBloecke = [...k.blockNach.keys()].join(', ');
// --- Handouts ---
for (const handout of k.handouts) {
const quelle = k.handoutQuelle.get(handout.id)!;
const block = k.blockNach.get(handout.block);
if (!block) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['block']),
feld: 'block',
meldung: `Der Themenblock „${handout.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
});
} else if (!handout.id.startsWith(block.kuerzel)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['id']),
feld: 'id',
meldung: `Das Kürzel ${handout.id} passt nicht zum Block „${handout.block}" – dieser verlangt den Anfangsbuchstaben ${block.kuerzel}.`,
});
}
for (const [index, position] of handout.rahmenplan.entries()) {
if (!k.positionNach.has(position)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['rahmenplan', index]),
feld: 'rahmenplan',
meldung: `Die Berufsbildposition „${position}" steht nicht in rahmenplan.yaml. Bekannt: ${bekanntePositionen}.`,
});
}
}
if (!k.lernfeldNach.has(handout.lernfeld)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['lernfeld']),
feld: 'lernfeld',
meldung: `Das Lernfeld „${handout.lernfeld}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteLernfelder}.`,
});
}
const woche = k.wocheNach.get(handout.woche);
if (!woche) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Es gibt keine Woche ${handout.woche}. Jedes Handout muss zu einer Woche gehören.`,
});
} else if (!woche.handouts.includes(handout.id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Das Handout nennt Woche ${handout.woche}, wird dort aber nicht unter handouts: aufgeführt. Trage ${handout.id} in ${k.wochenQuelle.get(handout.woche)!.datei} nach.`,
});
}
}
// --- Aufgaben ---
for (const aufgabe of k.aufgaben) {
const quelle = k.aufgabeQuelle.get(aufgabe.id)!;
if (!k.blockNach.has(aufgabe.block)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['block']),
feld: 'block',
meldung: `Der Themenblock „${aufgabe.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
});
}
for (const [index, position] of aufgabe.rahmenplan.entries()) {
if (!k.positionNach.has(position)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['rahmenplan', index]),
feld: 'rahmenplan',
meldung: `Die Berufsbildposition „${position}" steht nicht in rahmenplan.yaml. Bekannt: ${bekanntePositionen}.`,
});
}
}
if (!k.lernfeldNach.has(aufgabe.lernfeld)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['lernfeld']),
feld: 'lernfeld',
meldung: `Das Lernfeld „${aufgabe.lernfeld}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteLernfelder}.`,
});
}
const woche = k.wocheNach.get(aufgabe.woche);
if (!woche) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Es gibt keine Woche ${aufgabe.woche}.`,
});
} else if (!woche.aufgaben.includes(aufgabe.id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Die Aufgabe nennt Woche ${aufgabe.woche}, wird dort aber nicht unter aufgaben: aufgeführt.`,
});
}
}
// --- Lernzielkontrollen ---
for (const kontrolle of k.kontrollen) {
const quelle = k.kontrolleQuelle.get(kontrolle.id)!;
if (!k.wocheNach.has(kontrolle.woche)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['woche']),
feld: 'woche',
meldung: `Es gibt keine Woche ${kontrolle.woche}.`,
});
}
}
// --- Wochen ---
for (const woche of k.wochen) {
const quelle = k.wochenQuelle.get(woche.nummer)!;
if (!k.blockNach.has(woche.block)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['block']),
feld: 'block',
meldung: `Der Themenblock „${woche.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
});
}
for (const [index, id] of woche.handouts.entries()) {
if (!k.handoutNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['handouts', index]),
feld: 'handouts',
meldung: `Es gibt kein Handout mit dem Kürzel ${id}.`,
});
}
}
for (const [index, id] of woche.aufgaben.entries()) {
if (!k.aufgabeNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['aufgaben', index]),
feld: 'aufgaben',
meldung: `Es gibt keine Aufgabe mit der Kennung ${id}.`,
});
}
}
for (const [index, id] of woche.checklisten.entries()) {
if (!k.checklisteNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['checklisten', index]),
feld: 'checklisten',
meldung: `Es gibt keine Checkliste mit der Kennung ${id}.`,
});
}
}
if (woche.lernzielkontrolle && !k.kontrolleNach.has(woche.lernzielkontrolle)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['lernzielkontrolle']),
feld: 'lernzielkontrolle',
meldung: `Es gibt keine Lernzielkontrolle mit der Kennung „${woche.lernzielkontrolle}".`,
});
}
for (const [tagIndex, tag] of woche.tage.entries()) {
for (const [index, id] of tag.theorie.entries()) {
if (!k.handoutNach.has(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['tage', tagIndex, 'theorie', index]),
feld: `tage[${tagIndex}].theorie`,
meldung: `Es gibt kein Handout mit dem Kürzel ${id}.`,
});
} else if (!woche.handouts.includes(id)) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['tage', tagIndex, 'theorie', index]),
feld: `tage[${tagIndex}].theorie`,
meldung: `${id} steht am ${tag.tag} im Plan, fehlt aber in der Liste handouts: dieser Woche.`,
});
}
}
}
const tage = woche.tage.map((t) => t.tag);
const erwartet = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'];
if (tage.join(',') !== erwartet.join(',')) {
sammler.melde({
datei: quelle.datei,
zeile: quelle.zeileVon(['tage']),
feld: 'tage',
meldung: `Die Tage müssen in der Reihenfolge ${erwartet.join(', ')} stehen – gefunden: ${tage.join(', ')}.`,
});
}
}
// --- Lücken in der Wochennummerierung ---
for (let nummer = 1; nummer <= (k.wochen.at(-1)?.nummer ?? 0); nummer += 1) {
if (!k.wocheNach.has(nummer)) {
sammler.melde({
datei: 'content/wochen/',
meldung: `Woche ${nummer} fehlt, obwohl es spätere Wochen gibt. Die Nummerierung muss lückenlos sein.`,
});
}
}
}
+200
View File
@@ -0,0 +1,200 @@
import fs from 'node:fs';
import path from 'node:path';
import { LineCounter, parseDocument, type Document } from 'yaml';
import type { z } from 'zod';
import type { Einzelfehler, Fehlersammler } from './fehler';
/**
* Liest YAML- und Markdown-Dateien so ein, dass jeder Prüffehler auf eine Zeile
* in der Ursprungsdatei zeigt. Dafür wird nicht `YAML.parse` benutzt, sondern
* das Dokumentmodell – nur das kennt die Positionen der einzelnen Knoten.
*/
export type GeleseneDatei<T> = {
datei: string;
werte: T;
/** Findet die Zeile zu einem Pfad wie ['karten', 0, 'frage']. */
zeileVon: (pfad: (string | number)[]) => number | undefined;
};
type Leseergebnis = {
dokument: Document.Parsed;
zaehler: LineCounter;
/** Anzahl der Zeilen, die vor dem YAML stehen (bei Markdown der `---`-Anfang). */
versatz: number;
};
function leseYamlDokument(text: string, versatz: number): Leseergebnis {
const zaehler = new LineCounter();
const dokument = parseDocument(text, { lineCounter: zaehler, keepSourceTokens: false });
return { dokument, zaehler, versatz };
}
function zeileFuerPfad(ergebnis: Leseergebnis, pfad: (string | number)[]): number | undefined {
const { dokument, zaehler, versatz } = ergebnis;
// Vom vollständigen Pfad rückwärts kürzen, bis ein Knoten gefunden wird.
// So landet man bei einem Tippfehler im Feldnamen wenigstens beim Elternknoten.
for (let laenge = pfad.length; laenge >= 0; laenge -= 1) {
const teil = pfad.slice(0, laenge);
let knoten: unknown;
try {
knoten = teil.length === 0 ? dokument.contents : dokument.getIn(teil, true);
} catch {
continue;
}
const bereich = (knoten as { range?: [number, number, number] } | undefined)?.range;
if (bereich) {
const stelle = zaehler.linePos(bereich[0]);
return stelle.line + versatz;
}
}
return undefined;
}
/** Liest eine reine YAML-Datei und prüft sie gegen ein Zod-Schema. */
export function leseYaml<S extends z.ZodTypeAny>(
absolut: string,
anzeigepfad: string,
schema: S,
sammler: Fehlersammler,
): GeleseneDatei<z.output<S>> | null {
let text: string;
try {
text = fs.readFileSync(absolut, 'utf8');
} catch (fehler) {
sammler.melde({ datei: anzeigepfad, meldung: `Datei nicht lesbar: ${(fehler as Error).message}` });
return null;
}
let ergebnis: Leseergebnis;
try {
ergebnis = leseYamlDokument(text, 0);
} catch (fehler) {
sammler.melde({ datei: anzeigepfad, meldung: `YAML nicht lesbar: ${(fehler as Error).message}` });
return null;
}
for (const fehler of ergebnis.dokument.errors) {
const stelle = ergebnis.zaehler.linePos(fehler.pos[0]);
sammler.melde({
datei: anzeigepfad,
zeile: stelle.line,
spalte: stelle.col,
meldung: fehler.message,
});
}
if (ergebnis.dokument.errors.length > 0) return null;
return pruefe(ergebnis.dokument.toJS(), ergebnis, anzeigepfad, schema, sammler);
}
/** Liest eine Markdown-Datei mit YAML-Kopf. Der Rumpf landet als `rumpf` im Ergebnis. */
export function leseMarkdown<S extends z.ZodTypeAny>(
absolut: string,
anzeigepfad: string,
schema: S,
sammler: Fehlersammler,
): (GeleseneDatei<z.output<S>> & { rumpf: string }) | null {
let text: string;
try {
text = fs.readFileSync(absolut, 'utf8');
} catch (fehler) {
sammler.melde({ datei: anzeigepfad, meldung: `Datei nicht lesbar: ${(fehler as Error).message}` });
return null;
}
if (!text.startsWith('---')) {
sammler.melde({
datei: anzeigepfad,
zeile: 1,
meldung: 'Die Datei beginnt nicht mit einem YAML-Kopf. Erwartet wird eine Zeile mit genau ---.',
});
return null;
}
const getrennt = trenneKopfUndRumpf(text);
if (!getrennt) {
sammler.melde({
datei: anzeigepfad,
zeile: 1,
meldung: 'Der YAML-Kopf wird nicht abgeschlossen. Erwartet wird eine weitere Zeile mit genau ---.',
});
return null;
}
// Der YAML-Kopf beginnt in Zeile 2 – die erste Zeile ist das ---.
const ergebnis = leseYamlDokument(getrennt.kopf, 1);
for (const fehler of ergebnis.dokument.errors) {
const stelle = ergebnis.zaehler.linePos(fehler.pos[0]);
sammler.melde({
datei: anzeigepfad,
zeile: stelle.line + 1,
spalte: stelle.col,
meldung: fehler.message,
});
}
if (ergebnis.dokument.errors.length > 0) return null;
const geprueft = pruefe(ergebnis.dokument.toJS(), ergebnis, anzeigepfad, schema, sammler);
if (!geprueft) return null;
return { ...geprueft, rumpf: getrennt.rumpf.trim() };
}
/**
* Trennt den YAML-Kopf vom Markdown-Rumpf. Bewusst von Hand statt über eine
* Bibliothek: Nur so ist sicher, dass der Zeilenversatz für die Fehlermeldungen
* stimmt – und es ist eine Abhängigkeit weniger.
*/
function trenneKopfUndRumpf(text: string): { kopf: string; rumpf: string } | null {
const treffer = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!treffer) return null;
return { kopf: treffer[1] ?? '', rumpf: text.slice(treffer[0].length) };
}
function pruefe<S extends z.ZodTypeAny>(
rohwerte: unknown,
ergebnis: Leseergebnis,
anzeigepfad: string,
schema: S,
sammler: Fehlersammler,
): GeleseneDatei<z.output<S>> | null {
const ausgang = schema.safeParse(rohwerte ?? {});
if (!ausgang.success) {
for (const problem of ausgang.error.issues) {
const zeile = zeileFuerPfad(ergebnis, problem.path as (string | number)[]);
sammler.melde({
datei: anzeigepfad,
zeile,
feld: problem.path.length > 0 ? problem.path.join('.') : undefined,
meldung: problem.message,
});
}
return null;
}
return {
datei: anzeigepfad,
werte: ausgang.data,
zeileVon: (pfad) => zeileFuerPfad(ergebnis, pfad),
};
}
/** Alle Dateien eines Verzeichnisses mit passender Endung, nach Namen sortiert. */
export function dateienIn(verzeichnis: string, endung: string | string[]): string[] {
if (!fs.existsSync(verzeichnis)) return [];
const endungen = Array.isArray(endung) ? endung : [endung];
return fs
.readdirSync(verzeichnis)
.filter((d) => endungen.some((e) => d.endsWith(e)))
.sort()
.map((d) => path.join(verzeichnis, d));
}
/** Erzeugt aus einer Fundstelle einen Einzelfehler – Kurzform für die Prüfschritte. */
export function fehlerAn(datei: string, zeile: number | undefined, feld: string, meldung: string): Einzelfehler {
return { datei, zeile, feld, meldung };
}
+74
View File
@@ -0,0 +1,74 @@
import MarkdownIt from 'markdown-it';
/**
* Markdown wird beim Einlesen einmal nach HTML übersetzt und liegt dann fertig
* im Speicher. Die Inhalte stammen aus dem Repository, sind also vertrauenswürdig
* – trotzdem bleibt rohes HTML abgeschaltet, damit sich niemand daran gewöhnt.
*/
const uebersetzer = new MarkdownIt({
html: false,
linkify: false,
typographer: false,
breaks: false,
});
export function nachHtml(markdown: string): string {
return uebersetzer.render(markdown).trim();
}
export function nachHtmlInline(markdown: string): string {
return uebersetzer.renderInline(markdown).trim();
}
/** Entfernt Auszeichnung, damit der Text durchsucht werden kann. */
export function nurText(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/^[#>\-*+|]+\s?/gm, ' ')
.replace(/[*_~]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export type Abschnitt = { titel: string; markdown: string; html: string };
/**
* Zerlegt den Rumpf eines Handouts an den Überschriften der zweiten Ebene.
* Der feste Aufbau eines Handouts – Das Wesentliche, Übersicht, Prüfungsfrage –
* wird dadurch für die Darstellung einzeln greifbar.
*/
export function inAbschnitte(rumpf: string): Abschnitt[] {
const zeilen = rumpf.split('\n');
const abschnitte: Abschnitt[] = [];
let titel = '';
let puffer: string[] = [];
const ablegen = () => {
const markdown = puffer.join('\n').trim();
if (titel || markdown) {
abschnitte.push({ titel, markdown, html: nachHtml(markdown) });
}
puffer = [];
};
for (const zeile of zeilen) {
const treffer = /^##\s+(.+?)\s*$/.exec(zeile);
if (treffer) {
ablegen();
titel = treffer[1]!;
} else {
puffer.push(zeile);
}
}
ablegen();
return abschnitte.filter((a) => a.titel !== '' || a.markdown !== '');
}
/** Sucht einen Abschnitt anhand seiner Überschrift, unabhängig von Groß- und Kleinschreibung. */
export function abschnitt(abschnitte: Abschnitt[], titel: string): Abschnitt | undefined {
const gesucht = titel.toLocaleLowerCase('de-DE');
return abschnitte.find((a) => a.titel.toLocaleLowerCase('de-DE') === gesucht);
}
+186
View File
@@ -0,0 +1,186 @@
import { z } from 'zod';
/**
* Die Gestalt der Inhaltsdateien. Fehlermeldungen sind bewusst so formuliert,
* dass sie in der Startmeldung direkt lesbar sind – der Leser sitzt in VS Code
* und will wissen, was er tippen soll.
*/
const nichtLeer = (was: string) => z.string({ required_error: `${was} fehlt.` }).trim().min(1, `${was} darf nicht leer sein.`);
/** Kürzel eines Handouts: ein Buchstabe für den Block, dann eine Zahl. */
export const handoutKuerzel = z
.string()
.regex(/^[A-Z]\d{1,2}$/, 'Kürzel muss aus einem Großbuchstaben und ein bis zwei Ziffern bestehen, z. B. N3.');
export const rahmenplanKuerzel = z
.string()
.regex(/^[ABC]\d{1,2}$/, 'Berufsbildposition muss wie A1, B2 oder C4 aussehen.');
export const lernfeldKuerzel = z.string().regex(/^LF\d{1,2}$/, 'Lernfeld muss wie LF3 aussehen.');
// --- rahmenplan.yaml ------------------------------------------------------
export const berufsbildpositionSchema = z.object({
id: rahmenplanKuerzel,
gruppe: nichtLeer('Die Gruppe'),
titel: nichtLeer('Der Titel'),
kurz: nichtLeer('Die Kurzform'),
verordnung: nichtLeer('Die Fundstelle in der Verordnung'),
});
export const lernfeldSchema = z.object({
id: lernfeldKuerzel,
titel: nichtLeer('Der Titel'),
jahr: z.number().int().min(1).max(3),
stunden: z.number().int().positive(),
});
export const blockSchema = z.object({
id: nichtLeer('Die Kennung des Blocks'),
kuerzel: z.string().regex(/^[A-Z]$/, 'Das Blockkürzel ist genau ein Großbuchstabe.'),
titel: nichtLeer('Der Titel'),
farbe: z.string().optional(),
});
export const rahmenplanSchema = z.object({
quellen: z.object({
betrieb: z.object({ titel: z.string(), kurz: z.string(), vom: z.coerce.string(), fundstelle: z.string() }),
schule: z.object({ titel: z.string(), kurz: z.string(), vom: z.coerce.string() }),
}),
berufsbildpositionen: z.array(berufsbildpositionSchema).min(1, 'Es muss mindestens eine Berufsbildposition geben.'),
gruppen: z.record(z.string()),
lernfelder: z.array(lernfeldSchema).min(1, 'Es muss mindestens ein Lernfeld geben.'),
bloecke: z.array(blockSchema).min(1, 'Es muss mindestens einen Themenblock geben.'),
});
// --- handouts/*.md --------------------------------------------------------
export const karteSchema = z.object({
frage: nichtLeer('Die Frage der Merkkarte'),
antwort: nichtLeer('Die Antwort der Merkkarte'),
});
export const handoutKopfSchema = z.object({
id: handoutKuerzel,
titel: nichtLeer('Der Titel'),
block: nichtLeer('Der Themenblock'),
woche: z.number().int().positive('Die Wochennummer muss eine positive ganze Zahl sein.'),
worum: nichtLeer('Das Feld „worum" – ein Satz, worum es geht'),
rahmenplan: z.array(rahmenplanKuerzel).min(1, 'Mindestens eine Berufsbildposition angeben.'),
lernfeld: lernfeldKuerzel,
merksatz: nichtLeer('Der Merksatz'),
karten: z.array(karteSchema).min(1, 'Zu jedem Handout gehört mindestens eine Merkkarte.'),
});
// --- aufgaben/*.md --------------------------------------------------------
export const aufgabeKopfSchema = z.object({
id: z.coerce.string().regex(/^\d+\.\d+$/, 'Die Aufgabenkennung sieht aus wie 2.2 – Woche, Punkt, laufende Nummer.'),
titel: nichtLeer('Der Titel'),
woche: z.number().int().positive(),
block: nichtLeer('Der Themenblock'),
ziel: nichtLeer('Das Ziel der Aufgabe'),
zeitrahmen: nichtLeer('Der Zeitrahmen'),
rahmenplan: z.array(rahmenplanKuerzel).min(1, 'Mindestens eine Berufsbildposition angeben.'),
lernfeld: lernfeldKuerzel,
material: z.array(z.string().min(1)).default([]),
kriterien: z.array(z.string().min(1)).min(1, 'Ohne Bewertungskriterien lässt sich die Aufgabe nicht abnehmen.'),
});
// --- wochen/*.yaml --------------------------------------------------------
export const wochentagSchema = z.object({
tag: z.enum(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'], {
errorMap: () => ({ message: 'Der Tag muss Montag, Dienstag, Mittwoch, Donnerstag oder Freitag heißen.' }),
}),
theorie: z.array(handoutKuerzel).default([]),
praxis: z.string().default(''),
});
export const wocheSchema = z.object({
nummer: z.number().int().positive('Die Wochennummer muss eine positive ganze Zahl sein.'),
titel: nichtLeer('Der Titel der Woche'),
block: nichtLeer('Der Themenblock'),
leitfrage: nichtLeer('Die Leitfrage'),
ziel: nichtLeer('Das Wochenziel'),
handouts: z.array(handoutKuerzel).default([]),
aufgaben: z.array(z.coerce.string()).default([]),
checklisten: z.array(z.string()).default([]),
lernzielkontrolle: z.string().nullish().default(null),
tage: z.array(wochentagSchema).length(5, 'Eine Woche hat genau fünf Tage – Montag bis Freitag.'),
});
// --- checklisten/*.yaml ---------------------------------------------------
export const schrittSchema = z.object({
id: nichtLeer('Die Schrittkennung'),
text: nichtLeer('Der Text des Schrittes'),
});
export const gruppenKennungen = ['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'] as const;
export const checklistenGruppeSchema = z.object({
id: z.enum(gruppenKennungen, {
errorMap: () => ({ message: 'Gruppe muss vorbereiten, durchfuehren, pruefen oder abschliessen heißen.' }),
}),
titel: nichtLeer('Der Titel der Gruppe'),
schritte: z.array(schrittSchema).min(1, 'Eine Gruppe ohne Schritte ergibt keinen Sinn.'),
});
export const checklisteSchema = z.object({
id: nichtLeer('Die Kennung der Checkliste'),
titel: nichtLeer('Der Titel'),
block: nichtLeer('Der Themenblock'),
worum: nichtLeer('Ein Satz, worum es geht'),
rahmenplan: z.array(rahmenplanKuerzel).default([]),
lernfeld: lernfeldKuerzel.optional(),
mit_ticket: z.boolean().default(false),
gruppen: z.array(checklistenGruppeSchema).min(1, 'Mindestens eine Gruppe angeben.'),
});
// --- lernzielkontrollen/*.yaml -------------------------------------------
export const kontrollfrageSchema = z.object({
id: nichtLeer('Die Fragenkennung'),
frage: nichtLeer('Der Fragetext'),
punkte: z.number().int().positive('Punkte je Frage müssen eine positive ganze Zahl sein.'),
musterloesung: nichtLeer('Die Musterlösung'),
});
export const kontrolleSchema = z.object({
id: nichtLeer('Die Kennung der Lernzielkontrolle'),
woche: z.number().int().positive(),
titel: nichtLeer('Der Titel'),
dauer_minuten: z.number().int().positive().optional(),
hinweis: z.string().default(''),
fragen: z.array(kontrollfrageSchema).min(1, 'Eine Lernzielkontrolle ohne Fragen ergibt keinen Sinn.'),
});
// --- glossar.yaml ---------------------------------------------------------
export const glossarEintragSchema = z.object({
begriff: nichtLeer('Der Begriff'),
steht_fuer: z.string().optional(),
formen: z.array(z.string().min(1)).default([]),
erklaerung: nichtLeer('Die Erklärung'),
siehe_auch: z.array(z.string()).default([]),
});
export const glossarSchema = z.array(glossarEintragSchema).min(1, 'Das Glossar ist leer.');
// --- Typen ----------------------------------------------------------------
export type Berufsbildposition = z.infer<typeof berufsbildpositionSchema>;
export type Lernfeld = z.infer<typeof lernfeldSchema>;
export type Themenblock = z.infer<typeof blockSchema>;
export type Rahmenplan = z.infer<typeof rahmenplanSchema>;
export type Karte = z.infer<typeof karteSchema>;
export type HandoutKopf = z.infer<typeof handoutKopfSchema>;
export type AufgabeKopf = z.infer<typeof aufgabeKopfSchema>;
export type Woche = z.infer<typeof wocheSchema>;
export type Checkliste = z.infer<typeof checklisteSchema>;
export type ChecklistenGruppe = z.infer<typeof checklistenGruppeSchema>;
export type Kontrolle = z.infer<typeof kontrolleSchema>;
export type Kontrollfrage = z.infer<typeof kontrollfrageSchema>;
export type GlossarEintrag = z.infer<typeof glossarEintragSchema>;
+141
View File
@@ -0,0 +1,141 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { ladeInhalte } from './laden';
import { sucheHandouts, suchbegriffe } from './suche';
import { musterFuer, verlinkeGlossar, glossarAnker } from './glossarverweise';
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
describe('Suchbegriffe zerlegen', () => {
it('trennt an Leerzeichen und ignoriert einzelne Buchstaben', () => {
expect(suchbegriffe('subnetting rechnen a')).toEqual(['subnetting', 'rechnen']);
});
it('hält eine Wortgruppe in Anführungszeichen zusammen', () => {
expect(suchbegriffe('"das wesentliche"')).toEqual(['das wesentliche']);
});
it('macht Umlaute und Großschreibung unerheblich', () => {
expect(suchbegriffe('BLOCKGRÖSSE')).toEqual(['blockgroesse']);
});
});
describe('Handouts durchsuchen', () => {
it('findet über den Titel', () => {
const treffer = sucheHandouts(alle, 'subnetting');
expect(treffer[0]!.handout.id).toBe('N3');
});
it('findet über das Kürzel', () => {
const treffer = sucheHandouts(alle, 'N3');
expect(treffer.map((t) => t.handout.id)).toContain('N3');
});
it('findet auch Text, der nur im Rumpf steht', () => {
const treffer = sucheHandouts(alle, 'Kondensatoren');
expect(treffer.map((t) => t.handout.id)).toContain('H1');
});
it('findet über den Text einer Merkkarte', () => {
const treffer = sucheHandouts(alle, 'Wearout');
expect(treffer.map((t) => t.handout.id)).toContain('H2');
});
it('verlangt, dass alle Begriffe vorkommen', () => {
const einer = sucheHandouts(alle, 'netz');
const beide = sucheHandouts(alle, 'netz kondensatoren');
expect(beide.length).toBeLessThan(einer.length);
expect(beide.every((t) => einer.some((e) => e.handout.id === t.handout.id))).toBe(true);
});
it('gewichtet den Titel höher als den Fließtext', () => {
const treffer = sucheHandouts(alle, 'subnetting');
const punkteN3 = treffer.find((t) => t.handout.id === 'N3')!.punkte;
const andere = treffer.filter((t) => t.handout.id !== 'N3');
for (const t of andere) expect(punkteN3).toBeGreaterThan(t.punkte);
});
it('gibt bei leerer Eingabe nichts zurück statt alles', () => {
expect(sucheHandouts(alle, '')).toEqual([]);
expect(sucheHandouts(alle, ' ')).toEqual([]);
});
it('ist unempfindlich gegen Umlaute und Großschreibung', () => {
const mit = sucheHandouts(alle, 'Blockgröße');
const ohne = sucheHandouts(alle, 'blockgroesse');
expect(mit.map((t) => t.handout.id)).toEqual(ohne.map((t) => t.handout.id));
expect(mit.length).toBeGreaterThan(0);
});
it('liefert zu jedem Treffer einen Ausschnitt', () => {
for (const treffer of sucheHandouts(alle, 'netz')) {
expect(treffer.ausschnitt.length).toBeGreaterThan(10);
}
});
});
describe('Glossarbegriffe verlinken', () => {
const glossar = alle.glossar;
it('verlinkt einen bekannten Begriff', () => {
const html = verlinkeGlossar('<p>Die Subnetzmaske entscheidet.</p>', glossar);
expect(html).toContain('href="/glossar#subnetzmaske"');
expect(html).toContain('>Subnetzmaske</a>');
});
it('verlinkt jeden Begriff höchstens einmal', () => {
const html = verlinkeGlossar('<p>DNS hier, DNS da, DNS überall.</p>', glossar);
expect(html.match(/glossarbegriff/g)).toHaveLength(1);
});
it('lässt Code, Überschriften und bestehende Verweise in Ruhe', () => {
expect(verlinkeGlossar('<code>DNS</code>', glossar)).toBe('<code>DNS</code>');
expect(verlinkeGlossar('<h2>DNS</h2>', glossar)).toBe('<h2>DNS</h2>');
expect(verlinkeGlossar('<a href="/x">DNS</a>', glossar)).toBe('<a href="/x">DNS</a>');
expect(verlinkeGlossar('<pre>DNS</pre>', glossar)).toBe('<pre>DNS</pre>');
});
it('trifft nur ganze Wörter', () => {
// „Ports" enthält „Port", ist aber ein anderes Wort mit Endung – hier soll
// nichts passieren, weil die Form nicht angegeben ist.
const html = verlinkeGlossar('<p>Transportweg und Portweite.</p>', glossar);
expect(html).not.toContain('glossarbegriff');
});
it('erkennt zusätzlich angegebene Formen', () => {
const html = verlinkeGlossar('<p>Die Maske sagt es.</p>', glossar);
expect(html).toContain('href="/glossar#subnetzmaske"');
expect(html).toContain('>Maske</a>');
});
it('bevorzugt die längere Form – MAC-Adresse wird nicht zu Adresse', () => {
const html = verlinkeGlossar('<p>Die MAC-Adresse steht fest.</p>', glossar);
expect(html).toContain('>MAC-Adresse</a>');
});
it('maskiert Anführungszeichen in der Erklärung', () => {
const html = verlinkeGlossar('<p>Der Begriff Test.</p>', [
{ begriff: 'Test', formen: [], erklaerung: 'Ein "Anführungszeichen" und <spitze> Klammern.', siehe_auch: [] },
]);
expect(html).toContain('&quot;Anf');
expect(html).toContain('&lt;spitze&gt;');
expect(html).not.toContain('title="Ein "Anführungszeichen"');
});
it('lässt HTML unverändert, wenn das Glossar leer ist', () => {
expect(verlinkeGlossar('<p>DNS</p>', [])).toBe('<p>DNS</p>');
});
it('bildet Anker ohne Umlaute und Sonderzeichen', () => {
expect(glossarAnker('Blockgröße')).toBe('blockgroesse');
expect(glossarAnker('MAC-Adresse')).toBe('mac-adresse');
expect(glossarAnker('Prinzip der geringsten Rechte')).toBe('prinzip-der-geringsten-rechte');
});
it('ordnet die Suchmuster von lang nach kurz', () => {
const muster = musterFuer(glossar);
for (let i = 1; i < muster.length; i += 1) {
expect(muster[i - 1]!.muster.source.length).toBeGreaterThanOrEqual(muster[i]!.muster.source.length);
}
});
});
+82
View File
@@ -0,0 +1,82 @@
import { normalisiere, type Handout, type Inhalte } from './laden';
/**
* Volltextsuche über die Handouts.
*
* Bewusst kein Suchindex und keine Bibliothek: Bei 52 Handouts ist ein
* Durchlauf über alle Texte im Mikrosekundenbereich, und ein Suchindex wäre
* eine zweite Wahrheit, die man pflegen müsste.
*/
export type Fundstueck = {
handout: Handout;
punkte: number;
ausschnitt: string;
};
/** Zerlegt die Eingabe in Wörter; Anführungszeichen halten eine Wortgruppe zusammen. */
export function suchbegriffe(eingabe: string): string[] {
const treffer = eingabe.match(/"[^"]+"|\S+/g) ?? [];
return treffer.map((t) => normalisiere(t.replace(/^"|"$/g, ''))).filter((t) => t.length > 1);
}
export function sucheHandouts(alle: Inhalte, eingabe: string, grenze = 30): Fundstueck[] {
const begriffe = suchbegriffe(eingabe);
if (begriffe.length === 0) return [];
const gefunden: Fundstueck[] = [];
for (const handout of alle.handouts) {
const titel = normalisiere(`${handout.id} ${handout.titel}`);
const worum = normalisiere(handout.worum);
const merksatz = normalisiere(handout.merksatz);
let punkte = 0;
let alleEnthalten = true;
for (const begriff of begriffe) {
let treffer = 0;
if (titel.includes(begriff)) treffer += 10;
if (worum.includes(begriff)) treffer += 5;
if (merksatz.includes(begriff)) treffer += 4;
if (handout.suchtext.includes(begriff)) treffer += 1;
if (treffer === 0) {
alleEnthalten = false;
break;
}
punkte += treffer;
}
if (!alleEnthalten) continue;
gefunden.push({ handout, punkte, ausschnitt: ausschnittUm(handout, begriffe[0]!) });
}
return gefunden
.sort((a, b) => b.punkte - a.punkte || a.handout.id.localeCompare(b.handout.id, 'de-DE'))
.slice(0, grenze);
}
/**
* Ein Stück Text rund um den ersten Treffer, damit man in der Trefferliste
* sieht, warum etwas gefunden wurde.
*/
function ausschnittUm(handout: Handout, begriff: string, laenge = 160): string {
const stelle = handout.suchtext.indexOf(begriff);
if (stelle === -1) return handout.worum;
// Der normalisierte Text hat dieselbe Länge wie das Original, weil nur
// kleingeschrieben und Leerraum vereinheitlicht wird – bis auf Umlaute.
// Für einen Ausschnitt reicht das allemal.
const roh = `${handout.id} ${handout.titel} ${handout.worum} ${handout.merksatz} ${handout.rumpf}`
.replace(/\s+/g, ' ')
.trim();
const mitte = Math.min(stelle, Math.max(0, roh.length - 1));
const von = Math.max(0, mitte - Math.floor(laenge / 3));
const bis = Math.min(roh.length, von + laenge);
const teil = roh.slice(von, bis).trim();
return `${von > 0 ? '… ' : ''}${teil}${bis < roh.length ? ' …' : ''}`;
}
+43
View File
@@ -0,0 +1,43 @@
import { and, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { jetzt } from '../zeit';
/** Fortschritt beim Lesen der Handouts. */
export function gelesenSet(benutzerId: string): Set<string> {
const zeilen = datenbank()
.select({ handoutId: schema.handoutGelesen.handoutId })
.from(schema.handoutGelesen)
.where(eq(schema.handoutGelesen.benutzerId, benutzerId))
.all();
return new Set(zeilen.map((z) => z.handoutId));
}
export function gelesenAm(benutzerId: string, handoutId: string): string | null {
const zeile = datenbank()
.select({ gelesenAm: schema.handoutGelesen.gelesenAm })
.from(schema.handoutGelesen)
.where(and(eq(schema.handoutGelesen.benutzerId, benutzerId), eq(schema.handoutGelesen.handoutId, handoutId)))
.get();
return zeile?.gelesenAm ?? null;
}
export function alsGelesenMerken(benutzerId: string, handoutId: string): void {
datenbank()
.insert(schema.handoutGelesen)
.values({ benutzerId, handoutId, gelesenAm: jetzt() })
.onConflictDoNothing()
.run();
}
export function alsUngelesenMerken(benutzerId: string, handoutId: string): void {
datenbank()
.delete(schema.handoutGelesen)
.where(and(eq(schema.handoutGelesen.benutzerId, benutzerId), eq(schema.handoutGelesen.handoutId, handoutId)))
.run();
}
/** Anzahl gelesener Handouts – für die Fortschrittsanzeige. */
export function anzahlGelesen(benutzerId: string): number {
return gelesenSet(benutzerId).size;
}