frontend: Parser nach src/parser.js auslagern (headless)

TASKS Phase 1. `parse`, `STATUS_BY_CODE` und `SIZE_RANK` wandern unverändert
in ein eigenes, DOM-freies Modul (SPEC §1–§8 normativ); app.js importiert sie.
Vorbereitung für die Parser-Unit-Tests (nächste Checkbox) und den SVG-/Mermaid-
Renderer, die den Parser headless nutzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-21 20:49:45 +02:00
co-authored by Claude Opus 4.8
parent 78d1000f61
commit 80d05a33a2
3 changed files with 60 additions and 46 deletions
+3 -1
View File
@@ -10,7 +10,9 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen.
einer self-contained `dist/index.html` (file://-tauglich). Gerüst steht einer self-contained `dist/index.html` (file://-tauglich). Gerüst steht
(aktuell `src/app.js` + `src/style.css`); Parser/Renderer werden in den (aktuell `src/app.js` + `src/style.css`); Parser/Renderer werden in den
folgenden Checkboxen herausgelöst. folgenden Checkboxen herausgelöst.
- [ ] Parser extrahieren; Verhalten exakt wie in `docs/SPEC.md` §1–§8. - [x] Parser extrahieren; Verhalten exakt wie in `docs/SPEC.md` §1–§8.
`src/parser.js` exportiert `parse`, `STATUS_BY_CODE`, `SIZE_RANK`
(headless, kein DOM); `app.js` importiert sie.
- [ ] Unit-Tests für den Parser (Vitest): kanonisches Beispiel aus SPEC §10 - [ ] Unit-Tests für den Parser (Vitest): kanonisches Beispiel aus SPEC §10
als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung, als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung,
URL mit `@`, mehrere Wurzeln, leere Labels, `%%` am Zeilenanfang/-ende. URL mit `@`, mehrere Wurzeln, leere Labels, `%%` am Zeilenanfang/-ende.
+1 -45
View File
@@ -1,4 +1,5 @@
import './style.css'; import './style.css';
import { SIZE_RANK, STATUS_BY_CODE, parse } from './parser.js';
const INITIAL = `%% Project structure Sprint 14 const INITIAL = `%% Project structure Sprint 14
[~] Website relaunch (XL) https://wiki.example.com/relaunch [~] Website relaunch (XL) https://wiki.example.com/relaunch
@@ -21,18 +22,6 @@ const INITIAL = `%% Project structure Sprint 14
| Cooperative Community Cloud https://hostsharing.net | Cooperative Community Cloud https://hostsharing.net
| On-premise`; | On-premise`;
const SIZE_RANK = {XS:0, S:1, M:2, L:3, XL:4, XXL:5};
const STATUS_BY_CODE = {
'?': {key:'idee', name:'Idee'},
' ': {key:'geplant', name:'geplant'},
'~': {key:'arbeit', name:'in Arbeit'},
'/': {key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {key:'fertig', name:'fertig'},
'^': {key:'prod', name:'in Produktion'},
'-': {key:'verworfen', name:'verworfen'}
};
const src = document.getElementById('src'); const src = document.getElementById('src');
const out = document.getElementById('out'); const out = document.getElementById('out');
const warnBox = document.getElementById('warn'); const warnBox = document.getElementById('warn');
@@ -41,39 +30,6 @@ function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
/* ---------- Parser ---------- */
function parse(text){
const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}];
const warnings = [];
text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
if(!raw.trim()) return;
const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([ ?~xX^\/-])]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and';
const status = m[3] ? STATUS_BY_CODE[m[3].toLowerCase()] : null;
let rest = m[4], url = null, size = null;
const tags = [];
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 ''; });
const label = rest.replace(/\s+/g, ' ').trim();
if(!label) return;
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, status, url, size, tags, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});
return {roots: virtualRoot.children, warnings};
}
/* ---------- Renderer ---------- */ /* ---------- Renderer ---------- */
function gateOf(children){ function gateOf(children){
return children.length && children[0].type === 'or' ? 'or' : 'and'; return children.length && children[0].type === 'or' ? 'or' : 'and';
+56
View File
@@ -0,0 +1,56 @@
/* Werkbaum-Parser — headless, ohne DOM/UI nutzbar.
Setzt docs/SPEC.md §1–§8 um: Zeilenformat, Hierarchie (Einrückung),
Zerlegungsart (Gate), Status, Größe, Links, Personen-Tags, Kommentare.
Verhalten ist normativ gegen SPEC — Änderungen zuerst dort dokumentieren. */
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 };
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {key, name}.
`name` ist der deutsche Anzeigename (Quellsprache). */
export const STATUS_BY_CODE = {
'?': {key:'idee', name:'Idee'},
' ': {key:'geplant', name:'geplant'},
'~': {key:'arbeit', name:'in Arbeit'},
'/': {key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {key:'fertig', name:'fertig'},
'^': {key:'prod', name:'in Produktion'},
'-': {key:'verworfen', name:'verworfen'}
};
/* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or', status, url, size, tags, children, line}.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> 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 = [];
text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
if(!raw.trim()) return;
const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([ ?~xX^\/-])]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and';
const status = m[3] ? STATUS_BY_CODE[m[3].toLowerCase()] : null;
let rest = m[4], url = null, size = null;
const tags = [];
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 ''; });
const label = rest.replace(/\s+/g, ' ').trim();
if(!label) return;
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, status, url, size, tags, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});
return {roots: virtualRoot.children, warnings};
}