feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+432
View File
@@ -0,0 +1,432 @@
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import {
SCHRITT_TYPEN,
VARIABLEN_TYPEN,
VariableSchema,
platzhalterDerStruktur,
platzhalterFinden,
type SchrittKonfig,
type Struktur,
} from "@/lib/vorlagen-format";
/**
* Der Aufbau einer Version, wie ihn der Editor im Zustand haelt (#010).
*
* Framework-frei und ohne React, damit sich Umsortieren, Nummerierung, der
* Umgang mit `konfig` beim Typwechsel und die Beispielwerte einzeln pruefen
* lassen — fuer Komponenten gibt es keine DOM-Testumgebung.
*
* Unterschied zur `Struktur` aus `vorlagen-format.ts`: jeder Eintrag traegt eine
* `kennung`. Die Reihenfolge einer Liste ist die fachliche Reihenfolge, aber
* React und `@dnd-kit` brauchen einen Schluessel, der beim Umsortieren am
* Eintrag bleibt und nicht am Index haengt. Die Kennung entsteht im Browser und
* wird nie gespeichert.
*/
export type SchrittTyp = (typeof SCHRITT_TYPEN)[number];
export type VariablenTyp = (typeof VARIABLEN_TYPEN)[number];
export type EditorSchritt = {
readonly kennung: string;
readonly titel: string;
/** Leer statt `undefined`: ein Formularfeld hat immer einen Wert. */
readonly hilfetext: string;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: SchrittKonfig;
readonly dokuUrl: string;
};
export type EditorAbschnitt = {
readonly kennung: string;
readonly titel: string;
readonly schritte: readonly EditorSchritt[];
};
export type EditorVariable = {
readonly kennung: string;
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: VariablenTyp;
readonly optionen: readonly string[];
readonly pflicht: boolean;
};
export type EditorAufbau = {
readonly variablen: readonly EditorVariable[];
readonly abschnitte: readonly EditorAbschnitt[];
};
let zaehler = 0;
/**
* Eine Kennung, die nur im Browser gilt.
*
* Sie dient als React-`key` und als `id` fuer `@dnd-kit` und wird **nie** in ein
* Attribut gerendert. Deshalb darf sie sich zwischen Vorrendern auf dem Server
* und Hydrieren im Browser unterscheiden — der Zaehler steht im Modul und laeuft
* im Serverprozess ueber alle Anfragen weiter, im Browser faengt er bei null an.
* Gebraucht wird nur Eindeutigkeit innerhalb eines Editors, und die liefert er.
*/
export function neueKennung(praefix: string): string {
zaehler += 1;
return `${praefix}-${zaehler}`;
}
/** Die angezeigte Abschnittsnummer, `01` fuer den ersten. Aus der Position, nie gespeichert. */
export function abschnittNummer(index: number): string {
return String(index + 1).padStart(2, "0");
}
/**
* Eine vorangestellte Nummer aus einem Abschnittstitel entfernen.
*
* Die Nummer wird aus der Position abgeleitet und daneben angezeigt. Traegt ein
* importierter Titel sie zusaetzlich im Text — `01 · Vorbereitung`, wie es die
* Doku bisher vorschlug —, stuende sonst „01 · 01 · Vorbereitung“ da, und beim
* Verschieben wuerde die falsche Nummer mitwandern.
*
* Erkannt wird **nur** der Mittelpunkt als Trenner. Mit `.`, `)` oder `-` waere
* aus „2. Etage prüfen“ stillschweigend „Etage prüfen“ geworden: echter
* Textverlust an einer Stelle, die niemand kontrolliert.
*
* Und auch beim Mittelpunkt muss hinter dem Trenner etwas stehen, das weder
* Ziffer noch Leerraum ist: „24 · 7 Bereitschaft“ ist eine Servicezeit und keine
* Nummerierung. Der Leerraum gehoert in die Bedingung, weil `\s*` sonst
* zurueckspringt und die Vorausschau am Leerzeichen statt an der Ziffer prueft.
* Ohne diese Bedingung waere daraus „7 Bereitschaft“ geworden — derselbe stille
* Verlust, nur seltener.
*/
export function titelOhneNummer(titel: string): string {
return titel.replace(/^\s*\d{1,2}\s*·\s*(?=[^\s\d])/u, "");
}
/**
* Einen Eintrag von einer Position auf eine andere schieben.
*
* Gibt die unveraenderte Liste zurueck, wenn nichts zu tun oder der Index
* ausserhalb ist — so koennen die Knoepfe „nach oben“ und „nach unten“ ohne
* eigene Randpruefung rechnen.
*/
export function umsortieren<T>(liste: readonly T[], von: number, nach: number): readonly T[] {
if (von === nach) return liste;
if (von < 0 || nach < 0 || von >= liste.length || nach >= liste.length) return liste;
const kopie = [...liste];
const [eintrag] = kopie.splice(von, 1);
// Kann nicht eintreten, der Index ist oben geprueft; TypeScript weiss das nicht.
if (eintrag === undefined) return liste;
kopie.splice(nach, 0, eintrag);
return kopie;
}
/**
* Welche `konfig`-Schluessel zu welchem Schritt-Typ gehoeren.
*
* Dieselbe Aufteilung, die `docs/vorlagen-format.md` beschreibt — hier als
* Daten, damit das typabhaengige Formular und die Aufraeumung beim Typwechsel
* aus einer Quelle lesen.
*/
export const KONFIG_SCHLUESSEL: Record<SchrittTyp, readonly string[]> = {
HAKEN: [],
TEXT: ["mehrzeilig"],
ZAHL: ["min", "max", "einheit"],
AUSWAHL: ["optionen"],
DATUM: [],
BEFEHL: ["shell", "befehl"],
};
const ALLE_KONFIG_SCHLUESSEL: ReadonlySet<string> = new Set(Object.values(KONFIG_SCHLUESSEL).flat());
/**
* Die `konfig` beim Wechsel des Schritt-Typs aufraeumen.
*
* Entfernt wird, was einem der bekannten Typen gehoert, aber nicht dem neuen:
* bliebe der `befehl` an einem Schritt haengen, der inzwischen `HAKEN` ist,
* waere er unsichtbarer Ballast — und seine Platzhalter zaehlten weiter zur
* Warnung, ohne dass irgendwo ein Feld zu sehen ist, das man korrigieren
* koennte. Das Format erlaubt fremde Schluessel ausdruecklich (sie sollen einen
* Rundlauf durch eine aeltere Fassung ueberleben), deshalb bleibt alles
* Unbekannte stehen: es gehoert keinem Typ, den diese Fassung kennt, und nichts
* hier wertet es aus.
*/
export function konfigFuerTyp(konfig: SchrittKonfig, typ: SchrittTyp): SchrittKonfig {
const erlaubt = new Set(KONFIG_SCHLUESSEL[typ]);
const gesaeubert: Record<string, unknown> = {};
for (const [schluessel, wert] of Object.entries(konfig)) {
if (erlaubt.has(schluessel) || !ALLE_KONFIG_SCHLUESSEL.has(schluessel)) {
gesaeubert[schluessel] = wert;
}
}
return gesaeubert as SchrittKonfig;
}
/**
* Beschriftungen der Typen — in `src/lib`, weil Editor und Vorschau sie im
* Browser brauchen und nichts aus `src/server` dorthin gehoert.
*/
export const SCHRITT_TYP_TEXT: Record<SchrittTyp, string> = {
HAKEN: "Haken",
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
BEFEHL: "Befehl",
};
export const VARIABLEN_TYP_TEXT: Record<VariablenTyp, string> = {
TEXT: "Text",
ZAHL: "Zahl",
AUSWAHL: "Auswahl",
DATUM: "Datum",
};
/**
* Die Shell, die ein `BEFEHL`-Schritt ohne Angabe bedeutet.
*
* Formular und Vorschau greifen auf dieselbe Annahme zurueck, damit die Vorschau
* nicht etwas anderes zeigt als das Feld darueber. Gespeichert wird sie, sobald
* jemand den Typ auf `BEFEHL` stellt; bei einer importierten Datei ohne `shell`
* bleibt sie eine Anzeige-Annahme.
*/
export const STANDARD_SHELL = "powershell";
/** Ein leerer Schritt, wie ihn „Schritt hinzufügen“ anlegt. */
export function neuerSchritt(): EditorSchritt {
return {
kennung: neueKennung("schritt"),
titel: "",
hilfetext: "",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: "",
};
}
export function neuerAbschnitt(): EditorAbschnitt {
return { kennung: neueKennung("abschnitt"), titel: "", schritte: [] };
}
export function neueVariable(): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "",
beschriftung: "",
typ: "TEXT",
optionen: [],
pflicht: false,
};
}
/** Die geladene Struktur in den Editor-Zustand uebernehmen. */
export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
return {
variablen: struktur.variablen.map((variable) => ({
kennung: neueKennung("variable"),
schluessel: variable.schluessel,
beschriftung: variable.beschriftung,
typ: variable.typ,
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: struktur.abschnitte.map((abschnitt) => ({
kennung: neueKennung("abschnitt"),
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel),
schritte: abschnitt.schritte.map((schritt) => ({
kennung: neueKennung("schritt"),
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? "",
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "",
})),
})),
};
}
/**
* Der Zustand zurueck in die Form, die gespeichert und exportiert wird.
*
* Kennungen fallen weg, leere Textfelder werden zu `undefined` und damit im
* JSON weggelassen — ein leerer Doku-Link ist keine Adresse, und das Schema
* wuerde ihn zu Recht beanstanden.
*/
export function alsStruktur(aufbau: EditorAufbau): Struktur {
return {
variablen: aufbau.variablen.map((variable) => ({
schluessel: variable.schluessel.trim(),
beschriftung: variable.beschriftung,
typ: variable.typ,
// Optionen gehoeren nur zu AUSWAHL; sonst waeren sie beim Typwechsel
// derselbe unsichtbare Ballast wie eine nicht aufgeraeumte konfig.
optionen: variable.typ === "AUSWAHL" ? [...variable.optionen] : [],
pflicht: variable.pflicht,
})),
abschnitte: aufbau.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext.trim() === "" ? undefined : schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
})),
})),
};
}
/** Alle Platzhalter, die im aktuellen Zustand vorkommen. */
export function verwendetePlatzhalter(aufbau: EditorAufbau): readonly string[] {
return platzhalterDerStruktur(alsStruktur(aufbau));
}
/**
* Ist der Variablenschluessel gueltig — und wenn nicht, warum?
*
* Fragt das Schema selbst, statt sein Muster abzuschreiben: sonst gaebe es zwei
* Regeln, und die im Editor waere die, die niemand nachzieht.
*/
export function schluesselFehler(schluessel: string): string | undefined {
const geprueft = VariableSchema.shape.schluessel.safeParse(schluessel);
if (geprueft.success) return undefined;
return geprueft.error.issues[0]?.message ?? "Der Schlüssel ist ungültig.";
}
/** Die Regel als Satz, fuer den Hinweis unter dem Feld. Kommt aus demselben Schema. */
export const SCHLUESSEL_REGEL: string = schluesselFehler("") ?? "";
/**
* Schluessel, die mehr als einmal vorkommen.
*
* Der Dienst lehnt das beim Speichern ab. Besser, es faellt schon beim Tippen
* auf — mit der doppelten Zeile im Blick.
*/
export function doppelteSchluessel(variablen: readonly EditorVariable[]): readonly string[] {
const gesehen = new Set<string>();
const doppelt = new Set<string>();
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel === "") continue;
if (gesehen.has(schluessel)) doppelt.add(schluessel);
gesehen.add(schluessel);
}
return [...doppelt];
}
/**
* Beispieltext fuer `{{generiertes_passwort}}`.
*
* Fest und erkennbar unecht. Ein echtes Passwort darf hier nicht entstehen: es
* wird je Anzeige neu erzeugt und nirgends gespeichert (#012), und eine
* Vorschau, die eines zeigt, faengt an, sich auf etwas zu verlassen, das es
* noch nicht gibt.
*/
export const BEISPIEL_PASSWORT = "BEISPIEL-PASSWORT-NICHT-ECHT";
/**
* Beispielwerte der eingebauten Platzhalter.
*
* Erkennbar erfunden, aber in der richtigen Form: ein `dn` sieht aus wie ein
* `dn`, damit in der Vorschau eines PowerShell-Befehls zu sehen ist, was der
* Techniker spaeter wirklich vor sich hat. `datum` fehlt hier bewusst und kommt
* von aussen — siehe `beispielwerte`.
*/
const BEISPIELWERTE: Record<string, string> = {
hostname: "NB-MUSTER-014",
seriennummer: "SN-2026-000123",
inventarnummer: "INV-004711",
ticket: "T-12345",
techniker: "Tina Technik",
kunde: "Muster GmbH",
kundenkuerzel: "MUS",
mitarbeiter: "Anna Beispiel",
benutzer: "Anna Beispiel",
vorname: "Anna",
nachname: "Beispiel",
upn: "anna.beispiel@muster.example",
sam: "a.beispiel",
mail: "anna.beispiel@muster.example",
abteilung: "Vertrieb",
titel: "Sachbearbeiterin",
vorgesetzter: "Bernd Beispiel",
vorgesetzter_sam: "b.beispiel",
dn: "CN=Anna Beispiel,OU=Vertrieb,DC=muster,DC=example",
ou: "OU=Vertrieb,DC=muster,DC=example",
gruppen: "G-Vertrieb, G-Drucker-EG",
generiertes_passwort: BEISPIEL_PASSWORT,
};
function beispielFuerVariable(variable: EditorVariable, beispielDatum: string): string {
switch (variable.typ) {
case "AUSWAHL":
return variable.optionen[0] ?? "Beispielauswahl";
case "ZAHL":
return "42";
case "DATUM":
return beispielDatum;
default:
return `Beispiel-${variable.schluessel.trim() || "wert"}`;
}
}
/**
* Die Beispielwerte fuer die Vorschau.
*
* `beispielDatum` kommt von der Serverseite und nicht aus `new Date()`: der
* Editor wird serverseitig vorgerendert, und zwei verschiedene Uhrzeiten in
* denselben Text ergaeben einen Hydrierungsfehler.
*
* Gibt es einen Schluessel als Variable **und** als eingebauten Wert — etwa
* `abteilung` —, zeigt die Vorschau den Beispielwert der Variablen: sie wird im
* Startformular des Laufs abgefragt und ist damit die konkretere Angabe. Fuer
* Beispieltexte ist der Unterschied ohnehin nur ein anderer erfundener Name.
*/
export function beispielwerte(
variablen: readonly EditorVariable[],
beispielDatum: string,
): Record<string, string> {
const werte: Record<string, string> = { ...BEISPIELWERTE, datum: beispielDatum };
for (const variable of variablen) {
const schluessel = variable.schluessel.trim();
if (schluessel !== "") werte[schluessel] = beispielFuerVariable(variable, beispielDatum);
}
return werte;
}
/**
* Platzhalter im Text durch Beispielwerte ersetzen.
*
* Ein unbekannter Platzhalter bleibt als `{{schluessel}}` stehen — genau so wie
* im Lauf, und die Warnung im Editor sagt dazu, was fehlt. Die Namen kommen aus
* `platzhalterFinden` und bestehen damit nur aus Kleinbuchstaben, Ziffern und
* Unterstrich; im **Suchausdruck** ist daran nichts zu entschaerfen.
*
* Im **Ersatztext** sehr wohl: `String.replace` deutet dort `$&`, `` $` ``, `$'`
* und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ haette den
* gefundenen Platzhalter wieder eingesetzt. Eine Ersatzfunktion liefert den Wert
* unveraendert — wichtig spaetestens fuer #012, wo derselbe Weg einen
* PowerShell-Befehl zusammensetzt.
*/
export function mitBeispielwerten(text: string, werte: Record<string, string>): string {
let ergebnis = text;
for (const name of platzhalterFinden(text)) {
const wert = werte[name];
if (wert === undefined) continue;
ergebnis = ergebnis.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, "g"), () => wert);
}
return ergebnis;
}
/** Die eingebauten Platzhalter nach Quelle gruppiert — fuer die Hilfe im Editor. */
export function platzhalterNachQuelle(): ReadonlyMap<string, readonly EingebauterPlatzhalter[]> {
const gruppen = new Map<string, EingebauterPlatzhalter[]>();
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const vorhanden = gruppen.get(platzhalter.quelle);
if (vorhanden) vorhanden.push(platzhalter);
else gruppen.set(platzhalter.quelle, [platzhalter]);
}
return gruppen;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export type NavEintrag = {
export const NAVIGATION: readonly NavEintrag[] = [
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: false, ausIssue: 13 },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: false, ausIssue: 10 },
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
{
pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte",
+115
View File
@@ -0,0 +1,115 @@
/**
* Die eingebauten Platzhalter (#010, aufgeloest in #012).
*
* Ein Platzhalter `{{schluessel}}` in Titel, Hilfetext oder Befehl wird beim
* Abarbeiten eines Laufs ersetzt. Woraus, steht hier: entweder aus einer
* Variablen der Vorlage oder aus einer der eingebauten Quellen. Ein Schluessel,
* der zu keiner von beiden passt, bleibt im Lauf als `{{schluessel}}` stehen —
* sichtbar, aber nutzlos. Deshalb warnt der Editor davor.
*
* Die Liste ist bewusst Daten und kein Code: der Editor zeigt sie als Hilfe an,
* die Warnung vergleicht dagegen, und #012 baut die Aufloesung darauf auf. Eine
* zweite Aufzaehlung an anderer Stelle wuerde unweigerlich auseinanderlaufen.
*/
export type PlatzhalterQuelle = "GERAET_LAUF" | "MITARBEITER" | "ABTEILUNG" | "BESONDERS";
export type EingebauterPlatzhalter = {
readonly schluessel: string;
readonly quelle: PlatzhalterQuelle;
readonly erklaerung: string;
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN bleibt der Wert leer. */
readonly nurDienstleistung?: true;
};
export const EINGEBAUTE_PLATZHALTER: readonly EingebauterPlatzhalter[] = [
// Aus Geraet und Lauf
{ schluessel: "hostname", quelle: "GERAET_LAUF", erklaerung: "Hostname des Geräts im Lauf" },
{ schluessel: "seriennummer", quelle: "GERAET_LAUF", erklaerung: "Seriennummer des Geräts" },
{ schluessel: "inventarnummer", quelle: "GERAET_LAUF", erklaerung: "Inventarnummer des Geräts" },
{ schluessel: "ticket", quelle: "GERAET_LAUF", erklaerung: "Ticketnummer am Lauf" },
{ schluessel: "datum", quelle: "GERAET_LAUF", erklaerung: "heutiges Datum" },
{ schluessel: "techniker", quelle: "GERAET_LAUF", erklaerung: "Name des Bearbeiters" },
{
schluessel: "kunde",
quelle: "GERAET_LAUF",
erklaerung: "Name des Kunden",
nurDienstleistung: true,
},
{
schluessel: "kundenkuerzel",
quelle: "GERAET_LAUF",
erklaerung: "Kürzel des Kunden",
nurDienstleistung: true,
},
// Aus dem Mitarbeiter
{ schluessel: "mitarbeiter", quelle: "MITARBEITER", erklaerung: "vollständiger Name" },
{ schluessel: "benutzer", quelle: "MITARBEITER", erklaerung: "gleichbedeutend mit mitarbeiter" },
{ schluessel: "vorname", quelle: "MITARBEITER", erklaerung: "Vorname" },
{ schluessel: "nachname", quelle: "MITARBEITER", erklaerung: "Nachname" },
{ schluessel: "upn", quelle: "MITARBEITER", erklaerung: "User Principal Name" },
{ schluessel: "sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName, höchstens 20 Zeichen" },
{ schluessel: "mail", quelle: "MITARBEITER", erklaerung: "Mail-Adresse" },
{ schluessel: "abteilung", quelle: "MITARBEITER", erklaerung: "Name der Abteilung" },
{ schluessel: "titel", quelle: "MITARBEITER", erklaerung: "Funktion oder Titel" },
{ schluessel: "vorgesetzter", quelle: "MITARBEITER", erklaerung: "Name des Vorgesetzten" },
{ schluessel: "vorgesetzter_sam", quelle: "MITARBEITER", erklaerung: "sAMAccountName des Vorgesetzten" },
{ schluessel: "dn", quelle: "MITARBEITER", erklaerung: "Distinguished Name im Verzeichnis" },
// Aus der Abteilung
{
schluessel: "ou",
quelle: "ABTEILUNG",
erklaerung: "Ziel-Container: Eltern-Container des dn, sonst die Ziel-OU der Abteilung",
},
{ schluessel: "gruppen", quelle: "ABTEILUNG", erklaerung: "Standardgruppen, kommagetrennt" },
// Besonders
{
schluessel: "generiertes_passwort",
quelle: "BESONDERS",
erklaerung:
"20 Zeichen, bei jeder Anzeige neu erzeugt und nirgends gespeichert — auch nicht im Protokoll",
},
];
export const QUELLEN_TEXT: Record<PlatzhalterQuelle, string> = {
GERAET_LAUF: "Gerät und Lauf",
MITARBEITER: "Mitarbeiter",
ABTEILUNG: "Abteilung",
BESONDERS: "Besonders",
};
const EINGEBAUTE_SCHLUESSEL: ReadonlySet<string> = new Set(
EINGEBAUTE_PLATZHALTER.map((platzhalter) => platzhalter.schluessel),
);
export function istEingebaut(schluessel: string): boolean {
return EINGEBAUTE_SCHLUESSEL.has(schluessel);
}
/**
* Die Platzhalter, die weder eingebaut noch als Variable definiert sind.
*
* Das ist eine **Warnung**, keine Ablehnung: ein Tippfehler faellt so im Editor
* auf, statt erst dem Techniker im Lauf. Speichern und Veroeffentlichen bleiben
* erlaubt — es kann gute Gruende geben, einen Platzhalter stehen zu lassen, etwa
* wenn die Variable noch kommt.
*/
export function unbekanntePlatzhalter(
verwendete: readonly string[],
variablenSchluessel: readonly string[],
): readonly string[] {
const bekannt = new Set(variablenSchluessel);
return verwendete.filter((schluessel) => !bekannt.has(schluessel) && !istEingebaut(schluessel));
}
/** Variablen, die definiert sind, aber nirgends verwendet werden. */
export function unbenutzteVariablen(
verwendete: readonly string[],
variablenSchluessel: readonly string[],
): readonly string[] {
const benutzt = new Set(verwendete);
return variablenSchluessel.filter((schluessel) => !benutzt.has(schluessel));
}
+25
View File
@@ -104,6 +104,9 @@ export const AbschnittSchema = z.object({
.default([]),
});
/** Ein Abschnitt so, wie ihn ein Editor oder eine Datei liefert – vor dem Pruefen. */
export type AbschnittEingabe = z.input<typeof AbschnittSchema>;
export const VariableSchema = z.object({
/**
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
@@ -176,6 +179,28 @@ export type Austausch = z.infer<typeof AustauschSchema>;
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
* gehoert. Das Ersetzen selbst kommt in #012.
*/
/**
* Aus einem Zod-Pfad eine Stelle machen, die man auf dem Bildschirm wiederfindet.
*
* `struktur.abschnitte.2.schritte.0.titel` ist fuer den Aufbau richtig und fuer
* den Redakteur nutzlos — er muesste von null an zaehlen und wuesste immer noch
* nicht, ob „2“ den dritten Abschnitt meint. Daraus wird „Abschnitt 03,
* Schritt 1: “. Der Rest der Meldung kommt unveraendert aus dem Schema.
*/
export function stelleBenennen(pfad: readonly PropertyKey[]): string {
const teile: string[] = [];
for (let i = 0; i < pfad.length; i += 1) {
const name = String(pfad[i]);
const index = Number(pfad[i + 1]);
if (!Number.isInteger(index)) continue;
if (name === "abschnitte") teile.push(`Abschnitt ${String(index + 1).padStart(2, "0")}`);
else if (name === "schritte") teile.push(`Schritt ${index + 1}`);
else if (name === "variablen") teile.push(`Variable ${index + 1}`);
else if (name === "optionen") teile.push(`Option ${index + 1}`);
}
return teile.length > 0 ? `${teile.join(", ")}: ` : "";
}
export function platzhalterFinden(text: string): readonly string[] {
const gefunden = new Set<string>();
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {