notation: \ am Zeilenende setzt die Zeile fort (SPEC §1, D59)

Eine Zeile trägt alles auf einmal — im mitgelieferten Plan ist die längste
122 Zeichen lang, und seit das Textfeld nicht mehr umbricht (D49) muss man
dafür schieben. Ein `\` am Zeilenende verteilt sie jetzt auf mehrere
Textzeilen, ohne dass ein neuer Knoten entsteht.

Leerraum davor ist Pflicht (`… \`, nicht `…\`) — bewusst gegen die
Shell-Gewohnheit: Ohne die Regel verschluckt ein Label wie `C:\temp\` stumm
den folgenden Knoten, mit ihr bekommt man nur keine Fortsetzung, was man
sofort sieht. Der laute Fehler ist der bessere.

Umgesetzt als Vor-Durchlauf `logicalLines()`: Die Folgezeile ist Teil der
Zeile, bevor irgendetwas aus ihr gelesen wird — die Extraktionsreihenfolge
aus §1 bleibt unangetastet. Alles gehört zur ersten Zeile: Einrückung,
Warnungs-Zeilennummer, Rückschreiber; der Cursor in der Fortsetzung wählt
ihren Knoten aus, wie bei Beschreibungszeilen.

SPEC §1/§9, llms.md, Legende in neun Sprachen, D59; 20 neue Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 13:17:26 +02:00
co-authored by Claude Opus 5
parent 257aab08dd
commit 410dacdc6c
8 changed files with 331 additions and 6 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-24 ## 2026-08-24
- A line ending in a space and `\` continues on the next line, so a long node no longer has to fit into one
- A star in the header opens What's new: the changes of the last few days, and a link per day that shows the nodes it touched in the diagram - A star in the header opens What's new: the changes of the last few days, and a link per day that shows the nodes it touched in the diagram
- A warning triangle replaces the question mark as the pointer over faulty line numbers - A warning triangle replaces the question mark as the pointer over faulty line numbers
- The node window replaces the browser tooltip everywhere — at the pointer, on keyboard focus and on touch - The node window replaces the browser tooltip everywhere — at the pointer, on keyboard focus and on touch
+73
View File
@@ -4202,3 +4202,76 @@ Besuchsvergleich darunter bleibt damit das erste, worauf der Blick fällt.
nichts. Der Schlüssel ist trotzdem in allen neun Sprachen angelegt, auch auf nichts. Der Schlüssel ist trotzdem in allen neun Sprachen angelegt, auch auf
Englisch: Ein Loch in der Tabelle lädt dazu ein, beim nächsten Durchsehen für Englisch: Ein Loch in der Tabelle lädt dazu ein, beim nächsten Durchsehen für
einen Fehler gehalten und „repariert" zu werden. einen Fehler gehalten und „repariert" zu werden.
## D59 — Fortsetzungszeilen: `\` am Zeilenende, Leerraum davor Pflicht
Eine Zeile trägt in dieser Notation alles auf einmal — Einrückung, Zeichen,
Statusbox, Label, Größe, URL, Tags, ID, Abhängigkeiten, Fokusmarke, Kommentar.
Im mitgelieferten Plan ist die längste Zeile 122 Zeichen lang (D49), und seit
das Textfeld nicht mehr umbricht, muss man dafür waagerecht schieben. Ein `\`
am Zeilenende verteilt sie jetzt auf mehrere Textzeilen, ohne dass ein neuer
Knoten entsteht.
**Leerraum vor dem `\` ist Pflicht** — `… \` setzt fort, `…\` nicht. Das ist
die entscheidende Festlegung, und sie geht bewusst gegen die Gewohnheit aus
Shell, C und Makefile, wo `foo\` fortsetzt. Der Grund ist die **Asymmetrie der
Fehlerfälle**:
- Ohne die Regel verschluckt ein Label, das selbst auf einen Backslash endet
(`C:\temp\`), **stumm den folgenden Knoten**. Ein Knoten verschwindet aus dem
Diagramm, und im Text sieht die Zeile richtig aus.
- Mit der Regel bekommt, wer aus der Shell `foo\` schreibt, **keine
Fortsetzung**. Die Zeile bleibt stehen, der `\` ist sichtbar, der Fehler
erklärt sich beim Hinsehen.
Der zweite Fehler ist häufiger, der erste ist schlimmer — und dieses Projekt
zieht durchweg den lauten dem stillen vor (SPEC §4, D40). Die Regel ist zudem
keine neue Sorte: `=`, `>`/`<` und `"` verlangen alle Leerraum, nur auf der
**anderen** Seite des Zeichens. Ein `\\`-Escape als Alternative wäre die dritte
Möglichkeit gewesen — verworfen, weil die Notation sonst nirgends escapt und
eine einzige Escape-Regel für einen Randfall mehr Erklärung kostet, als sie
wert ist.
**Verbunden wird mit genau einem Leerzeichen.** Damit ist ein Token nicht über
den Umbruch trennbar — eine zerschnittene URL bleibt zerschnitten. Das ist eine
echte Einschränkung und trotzdem richtig herum: Ohne das Leerzeichen führen
`Backend \` + `Frontend` zu `BackendFrontend`, und *das* wäre die stille
Variante. Wer eine lange URL hat, lässt sie in ihrer Zeile.
**Alles gehört zur ersten Zeile.** Ihre Einrückung bestimmt die Ebene, ihre
Nummer nennen die Warnungen, und alles, was zurückschreibt, fasst nur sie an —
`setFoldMark()` (D38) und `expandShortIds()` (D55) finden Gate und Statusbox
dort. `expandShortIds()` musste dafür lernen, Fortsetzungszeilen zu
**überspringen**: Sonst hätte es eine solche Zeile für eine Wurzelzeile
gehalten (sie hat kein Gate) und den Vorfahren-Stapel verdorben.
**Der Cursor in einer Fortsetzungszeile wählt ihren Knoten aus** — dieselbe
Regel wie bei Beschreibungszeilen (D40-Nachtrag 2), und aus demselben Grund:
Die Zeile trägt keinen eigenen Knoten, gehört aber zu einem, und wer darin
schreibt, arbeitet an genau diesem. Getragen wird das von `node.descLines`, das
damit nicht mehr nur Beschreibungen führt; der Name bleibt, weil eine
Umbenennung durch Renderer, Tests und Snapshots nichts hinzufügte, was der
Kommentar nicht sagt.
**Gilt nur im Baumteil.** Hinter dem `---`-Trenner ist der Zeilenumbruch
Absatzstruktur (§1), und der Inhalt ist ausdrücklich Freitext — ein `\` bleibt
dort gewöhnlicher Text.
**Reihenfolge gegenüber dem Kommentar:** Kommentare fallen zuerst weg (§1,
Schritt 1), das Verbinden ist Schritt 1b. Also setzt `- A \ %% Notiz` fort und
`- A %% Notiz \` nicht — beides ist die natürliche Lesart, und sie fällt ohne
Sonderregel richtig aus.
**Umgesetzt als Vor-Durchlauf** (`logicalLines()` in parser.js): Er liefert
statt roher Zeilen `{raw, line, cont}`, und `parse()` arbeitet unverändert
darauf weiter. Dadurch gibt es genau **eine** Stelle, die die Regel kennt, und
die Extraktionsreihenfolge aus §1 bleibt unangetastet — die Folgezeile ist
schon Teil der Zeile, bevor irgendetwas aus ihr gelesen wird. Ein Sonderfall
steckt darin: Bleibt von der ersten Zeile nur die **Einrückung** übrig (` \`),
darf sie nicht mit weggeputzt werden — sie trägt die Ebene.
**Nachgemessen** im laufenden Editor: `- Ein Knoten mit einem \` + `sehr langen
Titel (L) @anna` ergibt einen Knoten mit `data-line="2"` und
`data-desc-lines="3"`, Größe `L` und Tag `anna` von der zweiten Zeile gelesen,
0 Warnungen; der Cursor auf Zeile 3 hebt denselben Knoten hervor wie auf Zeile
2. 20 neue Tests; die Gegenprobe (Leerraum-Pflicht aus dem Regex entfernt)
lässt genau die zwei danach benannten Zusicherungen fallen.
+38 -2
View File
@@ -14,6 +14,8 @@ Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in
dieser Reihenfolge (wichtig für Kollisionsfreiheit): dieser Reihenfolge (wichtig für Kollisionsfreiheit):
1. Kommentar entfernen: alles ab `%%` bis Zeilenende. 1. Kommentar entfernen: alles ab `%%` bis Zeilenende.
1b. Fortsetzung: Endet die Zeile jetzt auf Leerraum + `\`, gehört die
Folgezeile noch dazu (siehe unten). Erst danach ist die Zeile vollständig.
2. Einrückung, Zeichen (`-` / `+` / `|` / `=`), Statusbox `[…]` und Faltmarke 2. Einrückung, Zeichen (`-` / `+` / `|` / `=`), Statusbox `[…]` und Faltmarke
(`>` / `<`) per Zeilen-Regex; `=` nur mit folgendem Leerraum (§3), (`>` / `<`) per Zeilen-Regex; `=` nur mit folgendem Leerraum (§3),
die Faltmarke ebenso (siehe unten). die Faltmarke ebenso (siehe unten).
@@ -25,6 +27,38 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit):
8. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten). 8. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten).
9. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren. 9. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren.
**Fortsetzungszeile `\`** — eine lange Zeile darf auf mehrere Textzeilen
verteilt werden, ohne dass ein neuer Knoten entsteht:
- Endet eine Zeile — **nach** dem Entfernen des Kommentars — auf **Leerraum und
dann `\`** als letztem Zeichen, wird die **Folgezeile angehängt**: Der `\`
entfällt, die Einrückung der Folgezeile entfällt, verbunden wird mit **genau
einem Leerzeichen**. Mehrere `\` hintereinander setzen die Zeile über
entsprechend viele Textzeilen fort.
- **Leerraum davor ist Pflicht** (`… \`, nicht `…\`) — dieselbe Sorte Regel wie
bei `=`, `>`/`<` und `"`, nur an der anderen Seite des Zeichens. Sie hält
Labels heraus, die selbst auf einen Backslash enden (`C:\temp\`): Ohne sie
verschluckte so eine Zeile stumm den folgenden Knoten. Der Preis ist die
umgekehrte Verwechslung — wer aus der Shell `…\` gewohnt ist, bekommt keine
Fortsetzung —, und die ist die harmlosere: Die Zeile bleibt stehen, was man
sofort sieht.
- **Alles gehört zur ersten Zeile:** Ihre Einrückung bestimmt die Ebene (§2),
ihre Nummer nennen die Warnungen, und Werkzeuge, die zurückschreiben
(Faltmarke §9, ID-Kurzform §9), fassen nur sie an. Der Cursor in einer
Fortsetzungszeile wählt den Knoten der ersten aus (§9, Sprung).
- Ein Token darf **nicht** über den Umbruch hinweg getrennt werden — verbunden
wird mit einem Leerzeichen, eine zerschnittene URL bleibt zerschnitten.
- Gilt nur im **Baumteil**. Im Beschreibungsteil hinter `---` (siehe unten) sind
Zeilenumbrüche Absatzstruktur; ein `\` bleibt dort gewöhnlicher Text.
- Steht der `\` in der **letzten** Zeile der Datei, gibt es nichts anzuhängen:
Er entfällt, die Zeile bleibt für sich.
Referenz-Regex (Schritt 1b, geprüft auf der kommentarfreien Zeile):
```
(^|[ \t])\\[ \t]*$
```
**Faltmarke `>` / `<`** — bestimmt, wie das Dokument **eröffnet** wird: **Faltmarke `>` / `<`** — bestimmt, wie das Dokument **eröffnet** wird:
- Steht **unmittelbar vor dem Label**, also hinter der Statusbox - Steht **unmittelbar vor dem Label**, also hinter der Statusbox
@@ -709,8 +743,10 @@ verknüpft (siehe D25):
„hinschauen": Er holt **keine Bildschirmtastatur** herauf — die erscheint erst, „hinschauen": Er holt **keine Bildschirmtastatur** herauf — die erscheint erst,
wenn das Textfeld selbst angetippt wird. wenn das Textfeld selbst angetippt wird.
- **Text → Diagramm:** Der Knoten der **Cursor-Zeile** wird im Diagramm - **Text → Diagramm:** Der Knoten der **Cursor-Zeile** wird im Diagramm
hervorgehoben und beim Zeilenwechsel ins Bild gescrollt. **Beschreibungszeilen hervorgehoben und beim Zeilenwechsel ins Bild gescrollt. **Beschreibungs- und
zählen zu ihrem Knoten**: Steht der Cursor in einer `"`-Zeile (§1) oder in Fortsetzungszeilen (§1)
zählen zu ihrem Knoten**: Steht der Cursor in einer `"`-Zeile (§1), in einer
Fortsetzung hinter `\` oder in
einem ID-Block des `---`-Beschreibungsteils (Kopfzeile eingeschlossen), gilt einem ID-Block des `---`-Beschreibungsteils (Kopfzeile eingeschlossen), gilt
der beschriebene Knoten als ausgewählt — solche Zeilen tragen keinen eigenen der beschriebene Knoten als ausgewählt — solche Zeilen tragen keinen eigenen
Knoten, gehören aber zu einem, und wer darin schreibt, arbeitet an genau Knoten, gehören aber zu einem, und wer darin schreibt, arbeitet an genau
+7
View File
@@ -8,6 +8,7 @@
- [^] #not.status: Status box, eight states (S) - [^] #not.status: Status box, eight states (S)
- [^] #not.size: T-shirt size and the "decompose from M" rule (S) - [^] #not.size: T-shirt size and the "decompose from M" rule (S)
- [^] #not.people: People tags, bare URLs, %% comments (XS) - [^] #not.people: People tags, bare URLs, %% comments (XS)
- [x] #not.cont: A trailing backslash continues the line (XS) %% space before it is required
- [^] #not.gates: And/or decomposition (S) - [^] #not.gates: And/or decomposition (S)
- [^] #not.opt: Optional nodes — neither required nor an alternative (S) - [^] #not.opt: Optional nodes — neither required nor an alternative (S)
- [^] #not.id: Node IDs, (#auth:) before the title (S) %% often the ticket number, see SPEC §11 - [^] #not.id: Node IDs, (#auth:) before the title (S) %% often the ticket number, see SPEC §11
@@ -206,6 +207,12 @@
label, size, URL, tags, comment. The order in which those are extracted is label, size, URL, tags, comment. The order in which those are extracted is
fixed, which is what keeps them from colliding with each other. fixed, which is what keeps them from colliding with each other.
#not.cont
A long line may be spread over several text lines: ending one with a space
and a backslash makes the next line part of it, so no new node begins. The
space is required, or a label like C:\temp\ would silently swallow the node
below it.
#not.status #not.status
Eight states from vague idea to live in production, written as an extended Eight states from vague idea to live in production, written as an extended
Markdown task box. The colours follow risk rather than progress: money spent Markdown task box. The colours follow risk rather than progress: money spent
+19
View File
@@ -18,6 +18,25 @@ https://werkbaum.javagil.de renders the notation as a diagram;
One node per line. Everything except the label is optional. One node per line. Everything except the label is optional.
### Line continuation (`\` at the end)
- A line ending in **whitespace followed by `\`** continues on the next line:
no new node begins. The `\` and the next line's indentation are dropped, the
two parts are joined with **exactly one space**. Several `\` in a row extend
the line further.
- The whitespace before the `\` is **required**`C:\temp\` stays a label and
does not swallow the following node. Note this differs from shell habit,
where `foo\` continues.
- Everything belongs to the **first** line: its indentation sets the level, its
number is the one warnings report, and tools that write back (fold mark, id
shorthand) only touch it.
- A token must not be split across the break — the join inserts a space, so a
cut URL stays cut.
- Only in the tree part. Behind the `---` divider line breaks are paragraph
structure, and `\` is ordinary text.
- Comments are removed **first**: `- A \ %% note` continues, `- A %% note \`
does not.
### Hierarchy (indentation) ### Hierarchy (indentation)
- Indentation defines the tree: a node's parent is the nearest line **above** - Indentation defines the tree: a node's parent is the nearest line **above**
+10
View File
@@ -1943,6 +1943,7 @@ const I18N = {
hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:", hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:",
hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.", hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.",
hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.", hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.",
hint_cont:"Leerzeichen und \\ am Zeilenende — die nächste Zeile gehört noch dazu.",
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.", hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.",
hint_id:"Knoten-ID mit #name: vor dem Titel — erscheint im Tooltip des Knotens.", hint_id:"Knoten-ID mit #name: vor dem Titel — erscheint im Tooltip des Knotens.",
hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.", hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.",
@@ -2043,6 +2044,7 @@ const I18N = {
hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:", hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:",
hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.", hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.",
hint_comment:"Comments with %% — whole line or at the end of a line.", hint_comment:"Comments with %% — whole line or at the end of a line.",
hint_cont:"A space and \\ at the end of a line — the next line still belongs to it.",
hint_people:"People with @name — shown at the bottom-right of the node.", hint_people:"People with @name — shown at the bottom-right of the node.",
hint_id:"Node ID with #name: before the title — shown in the node's tooltip.", hint_id:"Node ID with #name: before the title — shown in the node's tooltip.",
hint_deps:"Dependencies with :#name,#name — shown in the tooltip.", hint_deps:"Dependencies with :#name,#name — shown in the tooltip.",
@@ -2143,6 +2145,7 @@ const I18N = {
hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:", hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:",
hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.", hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.",
hint_comment:"Comentarios con %% — línea completa o al final de la línea.", hint_comment:"Comentarios con %% — línea completa o al final de la línea.",
hint_cont:"Un espacio y \\ al final de una línea — la línea siguiente sigue perteneciendo a ella.",
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.", hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.",
hint_id:"ID de nodo con #nombre: delante del título — visible en el tooltip del nodo.", hint_id:"ID de nodo con #nombre: delante del título — visible en el tooltip del nodo.",
hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.", hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.",
@@ -2243,6 +2246,7 @@ const I18N = {
hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :", hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :",
hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.", hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.",
hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.", hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.",
hint_cont:"Une espace et \\ en fin de ligne — la ligne suivante en fait encore partie.",
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.", hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.",
hint_id:"ID de nœud avec #nom : devant le titre — visible dans linfobulle du nœud.", hint_id:"ID de nœud avec #nom : devant le titre — visible dans linfobulle du nœud.",
hint_deps:"Dépendances avec :#nom,#nom — visibles dans linfobulle.", hint_deps:"Dépendances avec :#nom,#nom — visibles dans linfobulle.",
@@ -2343,6 +2347,7 @@ const I18N = {
hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:", hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:",
hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.", hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.",
hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.", hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.",
hint_cont:"Spacja i \\ na końcu wiersza — następny wiersz nadal do niego należy.",
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.", hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.",
hint_id:"ID węzła przez #nazwa: przed tytułem — widoczne w podpowiedzi węzła.", hint_id:"ID węzła przez #nazwa: przed tytułem — widoczne w podpowiedzi węzła.",
hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.", hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.",
@@ -2443,6 +2448,7 @@ const I18N = {
hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:", hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:",
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.", hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
hint_comment:"Комментарии через %% — вся строка или в конце строки.", hint_comment:"Комментарии через %% — вся строка или в конце строки.",
hint_cont:"Пробел и \\ в конце строки — следующая строка всё ещё относится к ней.",
hint_people:"Люди через @имя — показываются справа внизу узла.", hint_people:"Люди через @имя — показываются справа внизу узла.",
hint_id:"ID узла через #имя: перед заголовком — виден во всплывающей подсказке узла.", hint_id:"ID узла через #имя: перед заголовком — виден во всплывающей подсказке узла.",
hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.", hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.",
@@ -2543,6 +2549,7 @@ const I18N = {
hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:", hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:",
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।", hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।", hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।",
hint_cont:"पंक्ति के अंत में स्पेस और \\ — अगली पंक्ति उसी की बनी रहती है।",
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।", hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
hint_id:"शीर्षक से पहले #नाम: के रूप में नोड आईडी — नोड के टूलटिप में दिखती है।", hint_id:"शीर्षक से पहले #नाम: के रूप में नोड आईडी — नोड के टूलटिप में दिखती है।",
hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।", hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।",
@@ -2643,6 +2650,7 @@ const I18N = {
hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:", hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:",
hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。", hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。",
hint_comment:"用 %% 注释——整行或行尾。", hint_comment:"用 %% 注释——整行或行尾。",
hint_cont:"行尾的空格加 \\ —— 下一行仍属于这一行。",
hint_people:"用 @姓名 表示人员——显示在节点右下角。", hint_people:"用 @姓名 表示人员——显示在节点右下角。",
hint_id:"用 #名称: 写在标题前指定节点 ID——显示在节点提示中。", hint_id:"用 #名称: 写在标题前指定节点 ID——显示在节点提示中。",
hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。", hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。",
@@ -2743,6 +2751,7 @@ const I18N = {
hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:", hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:",
hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。", hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。",
hint_comment:"%% でコメント — 行全体または行末。", hint_comment:"%% でコメント — 行全体または行末。",
hint_cont:"行末の空白と \\ — 次の行も同じ行に属します。",
hint_people:"@名前 で担当者 — ノードの右下に表示されます。", hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
hint_id:"タイトルの前に #名前: でノード ID — ノードのツールチップに表示されます。", hint_id:"タイトルの前に #名前: でノード ID — ノードのツールチップに表示されます。",
hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。", hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。",
@@ -2780,6 +2789,7 @@ function buildHint(){
<code>- [ ] Backend (L) https://…</code><br> <code>- [ ] Backend (L) https://…</code><br>
${esc(t('hint_break'))}<br> ${esc(t('hint_break'))}<br>
${esc(t('hint_comment'))} ${esc(t('hint_comment'))}
${esc(t('hint_cont'))}
${esc(t('hint_people'))} ${esc(t('hint_people'))}
${esc(t('hint_id'))} ${esc(t('hint_id'))}
${esc(t('hint_deps'))} ${esc(t('hint_deps'))}
+55 -4
View File
@@ -38,6 +38,41 @@ export function setFoldMark(line, mark){
const RE_LINE = /^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/; const RE_LINE = /^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/;
const RE_ID_TOKEN = /(^|\s)#([\p{L}\p{N}._-]+)/u; const RE_ID_TOKEN = /(^|\s)#([\p{L}\p{N}._-]+)/u;
/* Fortsetzungszeile (SPEC §1): Leerraum, dann `\` als letztes Zeichen. Der
Leerraum davor ist Pflicht ohne ihn verschluckte ein Label wie `C:\temp\`
stumm den folgenden Knoten. */
const RE_CONT = /(^|[ \t])\\[ \t]*$/;
const RE_SEP = /^[ \t]*-{3,}[ \t]*$/;
/* Text logische Zeilen: `%%`-Kommentare weg (SPEC §1, Schritt 1), dann
Fortsetzungen anhängen (Schritt 1b). Ergebnis je Eintrag:
`{raw, line, cont}` `line` ist die Nummer der ERSTEN Textzeile (sie trägt
Einrückung, Gate und Statusbox und wird von allen Rückschreibern angefasst),
`cont` sind die Nummern der angehängten.
Verbunden wird mit genau einem Leerzeichen, die Einrückung der Folgezeile
entfällt bis auf den Fall, dass von der ersten Zeile nur die Einrückung
übrig bleibt (` \`): Dann trägt sie die Ebene und muss stehen bleiben.
Hinter dem `---`-Trenner wird nicht mehr verbunden; dort ist der
Zeilenumbruch Absatzstruktur (SPEC §1). */
export function logicalLines(text){
const lines = text.split('\n');
const out = [];
let inDesc = false;
for(let i = 0; i < lines.length; i++){
const rec = {raw: lines[i].replace(/%%.*$/, ''), line: i + 1, cont: []};
if(!inDesc && RE_SEP.test(rec.raw)) inDesc = true;
while(!inDesc && RE_CONT.test(rec.raw)){
rec.raw = rec.raw.replace(RE_CONT, '$1');
if(i + 1 >= lines.length) break; /* letzte Zeile: der `\` entfällt einfach */
if(rec.raw && !/[ \t]$/.test(rec.raw)) rec.raw += ' ';
rec.raw += lines[++i].replace(/%%.*$/, '').replace(/^[ \t]*/, '');
rec.cont.push(i + 1);
}
out.push(rec);
}
return out;
}
/* Kurzschreibweise der Knoten-ID auflösen: `#.kc` unter `#prod-stage` wird zu /* Kurzschreibweise der Knoten-ID auflösen: `#.kc` unter `#prod-stage` wird zu
`#prod-stage.kc` (D55). Das ist eine **Eingabehilfe**, keine Notation die `#prod-stage.kc` (D55). Das ist eine **Eingabehilfe**, keine Notation die
@@ -55,12 +90,19 @@ export function expandShortIds(text){
const lines = text.split('\n'); const lines = text.split('\n');
const stack = []; /* {width, id} — auch Knoten OHNE ID stehen drin */ const stack = []; /* {width, id} — auch Knoten OHNE ID stehen drin */
let changed = false; let changed = false;
let fortsetzung = false; /* die vorige Zeile endete auf `\` (SPEC §1) */
for(let i = 0; i < lines.length; i++){ for(let i = 0; i < lines.length; i++){
const raw = lines[i]; const raw = lines[i];
if(/^\s*-{3,}\s*$/.test(raw)) break; if(/^\s*-{3,}\s*$/.test(raw)) break;
const k = raw.indexOf('%%'); const k = raw.indexOf('%%');
const head = k === -1 ? raw : raw.slice(0, k); const head = k === -1 ? raw : raw.slice(0, k);
const tail = k === -1 ? '' : raw.slice(k); const tail = k === -1 ? '' : raw.slice(k);
/* Eine Fortsetzungszeile trägt keinen eigenen Knoten: Sie darf weder den
Vorfahren-Stapel verändern noch als Ort einer Kurzform gelten die ID
steht an der ersten Zeile. */
const warFortsetzung = fortsetzung;
fortsetzung = RE_CONT.test(head);
if(warFortsetzung) continue;
const m = head.match(RE_LINE); const m = head.match(RE_LINE);
if(!m) continue; if(!m) continue;
const body = m[6]; const body = m[6];
@@ -100,8 +142,10 @@ const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
`desc` (SPEC §11/D40) ist der Beschreibungstext: `"`-Zeilen unter dem `desc` (SPEC §11/D40) ist der Beschreibungstext: `"`-Zeilen unter dem
Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform), Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform),
in Dokumentreihenfolge mit Zeilenumbrüchen zusammengefügt; null ohne. in Dokumentreihenfolge mit Zeilenumbrüchen zusammengefügt; null ohne.
`descLines` sind die ZEILENNUMMERN dieser Beschreibung (SPEC §9): Steht der `descLines` sind die ZEILENNUMMERN der Zeilen, die zu diesem Knoten gehören,
Cursor dort, gilt dieser Knoten als ausgewählt. ohne einen eigenen zu tragen (SPEC §9): die der Beschreibung und die der
Fortsetzungen hinter `\`. Steht der Cursor dort, gilt dieser Knoten als
ausgewählt.
`fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung `fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung
den wirksamen Zustand rechnet `initialCollapsed()` in model.js. den wirksamen Zustand rechnet `initialCollapsed()` in model.js.
`deps` sind ID-Strings, keine Knoten-Referenzen aufgelöst wird erst beim `deps` sind ID-Strings, keine Knoten-Referenzen aufgelöst wird erst beim
@@ -147,8 +191,11 @@ export function parse(text){
arr.push(text); arr.push(text);
}; };
text.split('\n').forEach((raw, i) => { /* Kommentare sind bereits entfernt und Fortsetzungszeilen angehängt
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */ (`logicalLines`, SPEC §1 Schritt 1 und 1b). `i` ist ab hier die Nummer der
ERSTEN Textzeile einer logischen Zeile, minus eins. */
logicalLines(text).forEach(rec => {
const raw = rec.raw, i = rec.line - 1;
/* Trenner `---` (SPEC §11, D40): drei oder mehr Bindestriche, umgebender /* Trenner `---` (SPEC §11, D40): drei oder mehr Bindestriche, umgebender
Leerraum erlaubt ab hier gilt der Beschreibungsteil. Es gibt keinen Leerraum erlaubt ab hier gilt der Beschreibungsteil. Es gibt keinen
Schlusszaun; weitere Trennzeilen darin haben keine Bedeutung. */ Schlusszaun; weitere Trennzeilen darin haben keine Bedeutung. */
@@ -265,6 +312,10 @@ export function parse(text){
parent.children.push(node); parent.children.push(node);
stack.push({node, width}); stack.push({node, width});
lastNode = node; lastNode = node;
/* Die Fortsetzungszeilen gehören diesem Knoten (SPEC §1/§9) genau wie
Beschreibungszeilen tragen sie keinen eigenen und wählen ihn deshalb
aus, wenn der Cursor darin steht. */
rec.cont.forEach(n => ownLine(node, n - 1));
if(id != null && !idNodes.has(id)) idNodes.set(id, node); if(id != null && !idNodes.has(id)) idNodes.set(id, node);
}); });
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { parse, logicalLines, expandShortIds } from '../src/parser.js';
/* Fortsetzungszeilen (SPEC §1): Endet eine Zeile auf Leerraum + `\`, gehört
die Folgezeile noch dazu es beginnt kein neuer Knoten. */
const baum = t => parse(t).roots;
const labels = nodes => nodes.map(n => n.label);
describe('logicalLines — Zeilen zusammenfügen', () => {
it('hängt die Folgezeile mit genau einem Leerzeichen an', () => {
expect(logicalLines('- Ein sehr langes \\\n Label').map(r => r.raw))
.toEqual(['- Ein sehr langes Label']);
});
it('merkt sich die erste Zeilennummer und die angehängten', () => {
const r = logicalLines('a\n- b \\\n c \\\n d\ne');
expect(r.map(x => ({line: x.line, cont: x.cont})))
.toEqual([{line: 1, cont: []}, {line: 2, cont: [3, 4]}, {line: 5, cont: []}]);
});
/* Von der ersten Zeile bleibt nur die Einrückung übrig sie trägt die
Ebene (§2) und muss stehen bleiben. */
it('erhält die Einrückung, wenn die erste Zeile nur aus ihr besteht', () => {
expect(logicalLines(' \\\n- Kind').map(r => r.raw)).toEqual([' - Kind']);
});
it('lässt eine Zeile ohne Leerraum vor dem Backslash in Ruhe', () => {
expect(logicalLines('- C:\\temp\\\n- Zweiter').map(r => r.raw))
.toEqual(['- C:\\temp\\', '- Zweiter']);
});
/* Der Kommentar fällt zuerst weg (Schritt 1) ein `\` DAHINTER wirkt also
nicht, einer davor schon. */
it('achtet auf die Reihenfolge mit dem Kommentar', () => {
expect(logicalLines('- A %% Notiz \\\n- B').map(r => r.raw)).toEqual(['- A ', '- B']);
expect(logicalLines('- A \\ %% Notiz\n- B').map(r => r.raw)).toEqual(['- A - B']);
});
it('lässt am Dateiende einfach den Backslash weg', () => {
expect(logicalLines('- A \\').map(r => r.raw)).toEqual(['- A ']);
});
it('hängt auch eine leere Folgezeile an, ohne etwas zu verlieren', () => {
expect(logicalLines('- A \\\n\n- B').map(r => r.raw)).toEqual(['- A ', '- B']);
});
/* Hinter dem Trenner ist der Zeilenumbruch Absatzstruktur (SPEC §1). */
it('verbindet im Beschreibungsteil nicht', () => {
const r = logicalLines('- A #x\n---\n#x\n erste \\\n zweite');
expect(r.map(x => x.raw)).toEqual(['- A #x', '---', '#x', ' erste \\', ' zweite']);
});
});
describe('parse — die fortgesetzte Zeile ergibt EINEN Knoten', () => {
it('macht aus zwei Textzeilen einen Knoten', () => {
const r = baum('- Backend mit einem \\\n sehr langen Titel');
expect(labels(r)).toEqual(['Backend mit einem sehr langen Titel']);
});
it('liest Größe, Tag, ID und URL auch von der Folgezeile', () => {
const r = parse('- #api: Backend \\\n (L) @anna https://example.org/x');
const n = r.roots[0];
expect(n.label).toBe('Backend');
expect(n.size).toBe('L');
expect(n.tags).toEqual(['anna']);
expect(n.id).toBe('api');
expect(n.url).toBe('https://example.org/x');
expect(r.warnings).toEqual([]);
});
/* Die Einrückung der ERSTEN Zeile bestimmt die Ebene die der Folgezeile
ist bedeutungslos, auch wenn sie tiefer steht. */
it('nimmt die Ebene von der ersten Zeile', () => {
const r = baum('Wurzel\n - Kind \\\n Fortsetzung\n - Zweites');
expect(labels(r)).toEqual(['Wurzel']);
expect(labels(r[0].children)).toEqual(['Kind Fortsetzung', 'Zweites']);
});
it('zählt die Zeile der ersten Textzeile', () => {
const r = baum('\n\n- Knoten \\\n weiter');
expect(r[0].line).toBe(3);
});
/* Warnungen nennen die Zeile, an der man eingreift. */
it('meldet eine Warnung an der ersten Zeile', () => {
const w = parse('- [z] Knoten \\\n weiter').warnings;
expect(w).toEqual([{type: 'unknownStatus', line: 1, code: 'z'}]);
});
/* Fortsetzungszeilen tragen keinen eigenen Knoten und wählen deshalb den
ihren aus, wenn der Cursor darin steht (SPEC §9, wie Beschreibungen). */
it('ordnet die Fortsetzungszeilen ihrem Knoten zu', () => {
const n = baum('- Knoten \\\n weiter \\\n und weiter')[0];
expect(n.descLines).toEqual([2, 3]);
});
it('setzt eine Zeile über mehrere Umbrüche fort', () => {
expect(labels(baum('- a \\\n b \\\n c \\\n d'))).toEqual(['a b c d']);
});
it('lässt einen Backslash ohne Leerraum davor im Label stehen', () => {
expect(labels(baum('- C:\\temp\\\n- Zweiter'))).toEqual(['C:\\temp\\', 'Zweiter']);
});
it('fügt kein Token über den Umbruch zusammen', () => {
/* Verbunden wird mit einem Leerzeichen eine zerschnittene URL bleibt
zerschnitten, und das ist die ehrliche Auskunft. */
expect(parse('- Titel https://exa \\\n mple.org').roots[0].url).toBe('https://exa');
});
it('verbindet eine Beschreibungszeile ebenso', () => {
const n = parse('- Knoten\n " erster Teil \\\n zweiter Teil').roots[0];
expect(n.desc).toBe('erster Teil zweiter Teil');
});
});
describe('expandShortIds — Fortsetzungszeilen sind keine Knotenzeilen', () => {
it('nimmt eine Fortsetzung nicht in den Vorfahren-Stapel auf', () => {
const text = ['#a: A', ' - Zwischen \\', ' weiter', ' - #.c: C'].join('\n');
expect(expandShortIds(text)).toContain('#a.c: C');
});
it('fasst eine Kurzform in einer Fortsetzungszeile nicht an', () => {
const text = ['#a: A', ' - Kind \\', ' #.x bleibt'].join('\n');
expect(expandShortIds(text)).toBe(text);
});
});