feat: Knoten-IDs (#auth) parsen — doppelte ID warnt (SPEC §1, D36)

Extraktionsschritt 6 (nach den Tags): das erste alleinstehend angesetzte
`#name`-Token wird die Knoten-ID (Zeichenmenge wie `@name`); weitere
`#`-Token bleiben im Label (reservierte Ticket-Referenzen), `:#a,#b`
und `C#` werden nicht gefressen. Doppelte ID → Warnung duplicateId an
der späteren Zeile mit Nennung der ersten; die spätere gilt trotzdem.
Sichtbar im Tooltip (erste Position) und als a11yId im aria-label —
noch keine eigene Diagramm-Darstellung. Legendenzeile + Warntext in
allen 9 Sprachen; die drei `#`-Erwähnungen im mitgelieferten Plan sind
eingeklammert, damit sie Erwähnungen bleiben. 12 neue Tests
(tests/ids.test.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 16:15:15 +02:00
co-authored by Claude Fable 5
parent d276c94840
commit 6ecf3e4c69
11 changed files with 229 additions and 25 deletions
+20 -3
View File
@@ -26,19 +26,21 @@ const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
/* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size,
tags, focus, children, line}.
tags, id, focus, children, line}.
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
Gemischt-Warnung unverändert richtig — sie schlägt an, wenn `|` oder `=`
mit `-`/`+` (oder untereinander) gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen);
-> Tags -> Knoten-ID -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite
(Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
export function parse(text){
const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}];
const warnings = [];
const idLines = new Map(); /* Knoten-ID -> Zeile der ersten Vergabe (D36) */
text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
@@ -59,6 +61,13 @@ export function parse(text){
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
/* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT — „C#"
bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen.
Nur der ERSTE Treffer (kein /g): weitere `#`-Token bleiben im Label
stehen, dort wohnt die reservierte Ticket-Referenz. Zeichenmenge wie bei
`@name`; kein Lookbehind (Safari erst ab 16.4). */
let id = null;
rest = rest.replace(/(^|\s)#([\p{L}\p{N}._-]+)/u, (s, pre, g) => { id = g; return pre; });
/* Fokusmarke `!!!` (SPEC §1) — nur ALLEINSTEHEND, damit „Achtung!!!" ein
gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4):
der führende Leerraum wird mitgefangen und wieder eingesetzt. */
@@ -73,10 +82,18 @@ export function parse(text){
if(!status) warnings.push({type:'unknownStatus', line:i+1, code:boxChar});
}
/* Doppelte ID (SPEC §1): Warnung an der späteren Zeile, mit Nennung der
ersten; die spätere ID gilt trotzdem am Knoten (fehlertolerant). Erst
hier — eine Zeile ohne Label ist schon zurückgekehrt und belegt nichts. */
if(id != null){
if(idLines.has(id)) warnings.push({type:'duplicateId', line:i+1, id, firstLine:idLines.get(id)});
else idLines.set(id, i+1);
}
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, optional, status, url, size, tags, focus, children:[], line:i+1};
const node = {label, type, optional, status, url, size, tags, id, focus, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});