@diagrammo/dgmo 0.2.27 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +107 -0
  2. package/.claude/skills/dgmo-flowchart/SKILL.md +61 -0
  3. package/.claude/skills/dgmo-generate/SKILL.md +58 -0
  4. package/.claude/skills/dgmo-sequence/SKILL.md +83 -0
  5. package/.cursorrules +117 -0
  6. package/.github/copilot-instructions.md +117 -0
  7. package/.windsurfrules +117 -0
  8. package/README.md +10 -3
  9. package/dist/cli.cjs +116 -108
  10. package/dist/index.cjs +543 -351
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +39 -24
  13. package/dist/index.d.ts +39 -24
  14. package/dist/index.js +540 -350
  15. package/dist/index.js.map +1 -1
  16. package/docs/ai-integration.md +125 -0
  17. package/docs/language-reference.md +784 -0
  18. package/package.json +10 -3
  19. package/src/c4/parser.ts +90 -74
  20. package/src/c4/renderer.ts +13 -12
  21. package/src/c4/types.ts +6 -4
  22. package/src/chart.ts +3 -2
  23. package/src/class/parser.ts +2 -10
  24. package/src/class/types.ts +1 -1
  25. package/src/cli.ts +130 -19
  26. package/src/d3.ts +1 -1
  27. package/src/dgmo-mermaid.ts +1 -1
  28. package/src/dgmo-router.ts +1 -1
  29. package/src/echarts.ts +33 -13
  30. package/src/er/parser.ts +34 -43
  31. package/src/er/types.ts +1 -1
  32. package/src/graph/flowchart-parser.ts +2 -25
  33. package/src/graph/types.ts +1 -1
  34. package/src/index.ts +5 -0
  35. package/src/initiative-status/parser.ts +36 -7
  36. package/src/initiative-status/types.ts +1 -1
  37. package/src/kanban/parser.ts +32 -53
  38. package/src/kanban/renderer.ts +9 -8
  39. package/src/kanban/types.ts +6 -14
  40. package/src/org/parser.ts +47 -87
  41. package/src/org/resolver.ts +11 -12
  42. package/src/sequence/parser.ts +97 -15
  43. package/src/sequence/renderer.ts +62 -69
  44. package/src/utils/arrows.ts +75 -0
  45. package/src/utils/inline-markdown.ts +75 -0
  46. package/src/utils/parsing.ts +67 -0
  47. package/src/utils/tag-groups.ts +76 -0
@@ -5,6 +5,8 @@
5
5
  import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
+ import { parseArrow } from '../utils/arrows';
9
+ import { measureIndent } from '../utils/parsing';
8
10
 
9
11
  /**
10
12
  * Participant types that can be declared via "Name is a type" syntax.
@@ -60,6 +62,7 @@ export interface SequenceMessage {
60
62
  returnLabel?: string;
61
63
  lineNumber: number;
62
64
  async?: boolean;
65
+ bidirectional?: boolean;
63
66
  }
64
67
 
65
68
  /**
@@ -159,8 +162,9 @@ const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
159
162
  // Section divider pattern — "== Label ==", "== Label(color) ==", or "== Label" (trailing == optional)
160
163
  const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
161
164
 
162
- // Arrow pattern for sequence inference — "A -> B: message" or "A ~> B: message"
163
- const ARROW_PATTERN = /\S+\s*(?:->|~>)\s*\S+/;
165
+ // Arrow pattern for sequence inference — "A -> B: message", "A ~> B: message",
166
+ // "A -label-> B", "A ~label~> B", "A <-> B", "A <~> B"
167
+ const ARROW_PATTERN = /\S+\s*(?:<->|<~>|->|~>|-\S+->|~\S+~>|<-\S+->|<~\S+~>)\s*\S+/;
164
168
 
165
169
  // <- return syntax: "Login <- 200 OK"
166
170
  const ARROW_RETURN_PATTERN = /^(.+?)\s*<-\s*(.+)$/;
@@ -212,19 +216,6 @@ function parseReturnLabel(rawLabel: string): {
212
216
  return { label: rawLabel };
213
217
  }
214
218
 
215
- /**
216
- * Measure leading whitespace of a line, normalizing tabs to 4 spaces.
217
- */
218
- function measureIndent(line: string): number {
219
- let indent = 0;
220
- for (const ch of line) {
221
- if (ch === ' ') indent++;
222
- else if (ch === '\t') indent += 4;
223
- else break;
224
- }
225
- return indent;
226
- }
227
-
228
219
  /**
229
220
  * Parse a .dgmo file with `chart: sequence` into a structured representation.
230
221
  */
@@ -519,6 +510,97 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
519
510
  continue;
520
511
  }
521
512
 
513
+ // ---- Labeled arrows: -label->, ~label~>, <-label->, <~label~> ----
514
+ // Must be checked BEFORE plain arrow patterns to avoid partial matches
515
+ const labeledArrow = parseArrow(trimmed);
516
+ if (labeledArrow && 'error' in labeledArrow) {
517
+ pushError(lineNumber, labeledArrow.error);
518
+ continue;
519
+ }
520
+ if (labeledArrow) {
521
+ contentStarted = true;
522
+ const { from, to, label, async: isAsync, bidirectional } = labeledArrow;
523
+ lastMsgFrom = from;
524
+
525
+ const msg: SequenceMessage = {
526
+ from,
527
+ to,
528
+ label,
529
+ returnLabel: undefined,
530
+ lineNumber,
531
+ ...(isAsync ? { async: true } : {}),
532
+ ...(bidirectional ? { bidirectional: true } : {}),
533
+ };
534
+ result.messages.push(msg);
535
+ currentContainer().push(msg);
536
+
537
+ // Auto-register participants
538
+ if (!result.participants.some((p) => p.id === from)) {
539
+ result.participants.push({
540
+ id: from,
541
+ label: from,
542
+ type: inferParticipantType(from),
543
+ lineNumber,
544
+ });
545
+ }
546
+ if (!result.participants.some((p) => p.id === to)) {
547
+ result.participants.push({
548
+ id: to,
549
+ label: to,
550
+ type: inferParticipantType(to),
551
+ lineNumber,
552
+ });
553
+ }
554
+ continue;
555
+ }
556
+
557
+ // ---- Plain bidi arrows: <-> and <~> ----
558
+ // Must be checked BEFORE unidirectional plain arrows
559
+ const bidiSyncMatch = trimmed.match(
560
+ /^(\S+)\s*<->\s*([^\s:]+)\s*(?::\s*(.+))?$/
561
+ );
562
+ const bidiAsyncMatch = trimmed.match(
563
+ /^(\S+)\s*<~>\s*([^\s:]+)\s*(?::\s*(.+))?$/
564
+ );
565
+ const bidiMatch = bidiSyncMatch || bidiAsyncMatch;
566
+ if (bidiMatch) {
567
+ contentStarted = true;
568
+ const from = bidiMatch[1];
569
+ const to = bidiMatch[2];
570
+ lastMsgFrom = from;
571
+ const rawLabel = bidiMatch[3]?.trim() || '';
572
+ const isBidiAsync = !!bidiAsyncMatch;
573
+
574
+ const msg: SequenceMessage = {
575
+ from,
576
+ to,
577
+ label: rawLabel,
578
+ lineNumber,
579
+ bidirectional: true,
580
+ ...(isBidiAsync ? { async: true } : {}),
581
+ };
582
+ result.messages.push(msg);
583
+ currentContainer().push(msg);
584
+
585
+ if (!result.participants.some((p) => p.id === from)) {
586
+ result.participants.push({
587
+ id: from,
588
+ label: from,
589
+ type: inferParticipantType(from),
590
+ lineNumber,
591
+ });
592
+ }
593
+ if (!result.participants.some((p) => p.id === to)) {
594
+ result.participants.push({
595
+ id: to,
596
+ label: to,
597
+ type: inferParticipantType(to),
598
+ lineNumber,
599
+ });
600
+ }
601
+ continue;
602
+ }
603
+
522
604
  // Match ~> (async arrow) or -> (sync arrow)
523
605
  let isAsync = false;
524
606
  const asyncArrowMatch = trimmed.match(
@@ -5,6 +5,13 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import type { PaletteColors } from '../palettes';
7
7
  import { resolveColor } from '../colors';
8
+ import {
9
+ parseInlineMarkdown,
10
+ truncateBareUrl,
11
+ renderInlineText,
12
+ } from '../utils/inline-markdown';
13
+ export type { InlineSpan } from '../utils/inline-markdown';
14
+ export { parseInlineMarkdown, truncateBareUrl };
8
15
  import { FONT_FAMILY } from '../fonts';
9
16
  import type {
10
17
  ParsedSequenceDgmo,
@@ -44,74 +51,6 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
44
51
  const COLLAPSED_NOTE_H = 20;
45
52
  const COLLAPSED_NOTE_W = 40;
46
53
 
47
- interface InlineSpan {
48
- text: string;
49
- bold?: boolean;
50
- italic?: boolean;
51
- code?: boolean;
52
- href?: string;
53
- }
54
-
55
- export function parseInlineMarkdown(text: string): InlineSpan[] {
56
- const spans: InlineSpan[] = [];
57
- const regex =
58
- /\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|(https?:\/\/[^\s)>\]]+|www\.[^\s)>\]]+)|([^*_`[]+?(?=https?:\/\/|www\.|$)|[^*_`[]+)/g;
59
- let match;
60
- while ((match = regex.exec(text)) !== null) {
61
- if (match[1]) spans.push({ text: match[1], bold: true }); // **bold**
62
- else if (match[2]) spans.push({ text: match[2], bold: true }); // __bold__
63
- else if (match[3]) spans.push({ text: match[3], italic: true }); // *italic*
64
- else if (match[4]) spans.push({ text: match[4], italic: true }); // _italic_
65
- else if (match[5]) spans.push({ text: match[5], code: true }); // `code`
66
- else if (match[6]) spans.push({ text: match[6], href: match[7] }); // [text](url)
67
- else if (match[8]) { // bare URL
68
- const url = match[8];
69
- const href = url.startsWith('www.') ? `https://${url}` : url;
70
- spans.push({ text: url, href });
71
- } else if (match[9]) spans.push({ text: match[9] }); // plain text
72
- }
73
- return spans;
74
- }
75
-
76
- const BARE_URL_MAX_DISPLAY = 35;
77
-
78
- export function truncateBareUrl(url: string): string {
79
- const stripped = url.replace(/^https?:\/\//, '').replace(/^www\./, '');
80
- if (stripped.length <= BARE_URL_MAX_DISPLAY) return stripped;
81
- return stripped.slice(0, BARE_URL_MAX_DISPLAY - 1) + '\u2026';
82
- }
83
-
84
- function renderInlineText(
85
- textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
86
- text: string,
87
- palette: PaletteColors,
88
- fontSize?: number
89
- ): void {
90
- const spans = parseInlineMarkdown(text);
91
- for (const span of spans) {
92
- if (span.href) {
93
- // Bare URLs (text === href or href with https:// prepended) get truncated display;
94
- // markdown links [text](url) keep their user-chosen text as-is.
95
- const isBareUrl =
96
- span.text === span.href ||
97
- `https://${span.text}` === span.href;
98
- const display = isBareUrl ? truncateBareUrl(span.text) : span.text;
99
- const a = textEl.append('a').attr('href', span.href);
100
- a.append('tspan')
101
- .text(display)
102
- .attr('fill', palette.primary)
103
- .style('text-decoration', 'underline');
104
- } else {
105
- const tspan = textEl.append('tspan').text(span.text);
106
- if (span.bold) tspan.attr('font-weight', 'bold');
107
- if (span.italic) tspan.attr('font-style', 'italic');
108
- if (span.code) {
109
- tspan.attr('font-family', 'monospace');
110
- if (fontSize) tspan.attr('font-size', fontSize - 1);
111
- }
112
- }
113
- }
114
- }
115
54
 
116
55
  function wrapTextLines(text: string, maxChars: number): string[] {
117
56
  const rawLines = text.split('\n');
@@ -599,6 +538,7 @@ export interface RenderStep {
599
538
  label: string;
600
539
  messageIndex: number;
601
540
  async?: boolean;
541
+ bidirectional?: boolean;
602
542
  }
603
543
 
604
544
  /**
@@ -639,8 +579,14 @@ export function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {
639
579
  label: msg.label,
640
580
  messageIndex: mi,
641
581
  ...(msg.async ? { async: true } : {}),
582
+ ...(msg.bidirectional ? { bidirectional: true } : {}),
642
583
  });
643
584
 
585
+ // Bidirectional messages: no activation bar, no return
586
+ if (msg.bidirectional) {
587
+ continue;
588
+ }
589
+
644
590
  // Async messages: no return arrow, no activation on target
645
591
  if (msg.async) {
646
592
  continue;
@@ -1400,6 +1346,42 @@ export function renderSequenceDiagram(
1400
1346
  .attr('stroke', palette.text)
1401
1347
  .attr('stroke-width', 1.2);
1402
1348
 
1349
+ // Filled reverse arrowhead for bidirectional sync arrows (marker-start)
1350
+ defs
1351
+ .append('marker')
1352
+ .attr('id', 'seq-arrowhead-reverse')
1353
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1354
+ .attr('refX', 0)
1355
+ .attr('refY', ARROWHEAD_SIZE / 2)
1356
+ .attr('markerWidth', ARROWHEAD_SIZE)
1357
+ .attr('markerHeight', ARROWHEAD_SIZE)
1358
+ .attr('orient', 'auto')
1359
+ .append('polygon')
1360
+ .attr(
1361
+ 'points',
1362
+ `${ARROWHEAD_SIZE},0 0,${ARROWHEAD_SIZE / 2} ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE}`
1363
+ )
1364
+ .attr('fill', palette.text);
1365
+
1366
+ // Open reverse arrowhead for bidirectional async arrows (marker-start)
1367
+ defs
1368
+ .append('marker')
1369
+ .attr('id', 'seq-arrowhead-async-reverse')
1370
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1371
+ .attr('refX', 0)
1372
+ .attr('refY', ARROWHEAD_SIZE / 2)
1373
+ .attr('markerWidth', ARROWHEAD_SIZE)
1374
+ .attr('markerHeight', ARROWHEAD_SIZE)
1375
+ .attr('orient', 'auto')
1376
+ .append('polyline')
1377
+ .attr(
1378
+ 'points',
1379
+ `${ARROWHEAD_SIZE},0 0,${ARROWHEAD_SIZE / 2} ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE}`
1380
+ )
1381
+ .attr('fill', 'none')
1382
+ .attr('stroke', palette.text)
1383
+ .attr('stroke-width', 1.2);
1384
+
1403
1385
  // Render title
1404
1386
  if (title) {
1405
1387
  const titleEl = svg
@@ -1954,7 +1936,12 @@ export function renderSequenceDiagram(
1954
1936
  const markerRef = step.async
1955
1937
  ? 'url(#seq-arrowhead-async)'
1956
1938
  : 'url(#seq-arrowhead)';
1957
- svg
1939
+ const markerStartRef = step.bidirectional
1940
+ ? step.async
1941
+ ? 'url(#seq-arrowhead-async-reverse)'
1942
+ : 'url(#seq-arrowhead-reverse)'
1943
+ : null;
1944
+ const line = svg
1958
1945
  .append('line')
1959
1946
  .attr('x1', x1)
1960
1947
  .attr('y1', y)
@@ -1970,6 +1957,12 @@ export function renderSequenceDiagram(
1970
1957
  )
1971
1958
  .attr('data-msg-index', String(step.messageIndex))
1972
1959
  .attr('data-step-index', String(i));
1960
+ if (markerStartRef) {
1961
+ line.attr('marker-start', markerStartRef);
1962
+ }
1963
+ if (step.bidirectional && step.async) {
1964
+ line.attr('stroke-dasharray', '6 4');
1965
+ }
1973
1966
 
1974
1967
  if (step.label) {
1975
1968
  const midX = (x1 + x2) / 2;
@@ -0,0 +1,75 @@
1
+ // ============================================================
2
+ // Shared Arrow Parsing Utility
3
+ // ============================================================
4
+ //
5
+ // Labeled arrow syntax: `-label->`, `~label~>`, `<-label->`, `<~label~>`
6
+ // Used by sequence, C4, and init-status parsers.
7
+
8
+ export interface ParsedArrow {
9
+ from: string;
10
+ to: string;
11
+ label: string;
12
+ async: boolean;
13
+ bidirectional: boolean;
14
+ }
15
+
16
+ // Bidi patterns checked FIRST — longer prefix avoids partial match
17
+ const BIDI_SYNC_LABELED_RE = /^(\S+)\s+<-(.+)->\s+(\S+)$/;
18
+ const BIDI_ASYNC_LABELED_RE = /^(\S+)\s+<~(.+)~>\s+(\S+)$/;
19
+ const SYNC_LABELED_RE = /^(\S+)\s+-(.+)->\s+(\S+)$/;
20
+ const ASYNC_LABELED_RE = /^(\S+)\s+~(.+)~>\s+(\S+)$/;
21
+
22
+ const ARROW_CHARS = ['->', '~>', '<->', '<~>'];
23
+
24
+ /**
25
+ * Try to parse a labeled arrow from a trimmed line.
26
+ *
27
+ * Returns:
28
+ * - `ParsedArrow` if matched and valid
29
+ * - `{ error: string }` if matched but label contains arrow chars
30
+ * - `null` if not a labeled arrow (caller should fall through to plain patterns)
31
+ */
32
+ export function parseArrow(
33
+ line: string,
34
+ ): ParsedArrow | { error: string } | null {
35
+ // Order: bidi first (longer prefix), then unidirectional
36
+ const patterns: {
37
+ re: RegExp;
38
+ async: boolean;
39
+ bidirectional: boolean;
40
+ }[] = [
41
+ { re: BIDI_SYNC_LABELED_RE, async: false, bidirectional: true },
42
+ { re: BIDI_ASYNC_LABELED_RE, async: true, bidirectional: true },
43
+ { re: SYNC_LABELED_RE, async: false, bidirectional: false },
44
+ { re: ASYNC_LABELED_RE, async: true, bidirectional: false },
45
+ ];
46
+
47
+ for (const { re, async: isAsync, bidirectional } of patterns) {
48
+ const m = line.match(re);
49
+ if (!m) continue;
50
+
51
+ const label = m[2].trim();
52
+
53
+ // Empty label (e.g. `--> B`) — fall through to plain arrow handling
54
+ if (!label) return null;
55
+
56
+ // Validate: no arrow chars inside label
57
+ for (const arrow of ARROW_CHARS) {
58
+ if (label.includes(arrow)) {
59
+ return {
60
+ error: 'Arrow characters (->, ~>) are not allowed inside labels',
61
+ };
62
+ }
63
+ }
64
+
65
+ return {
66
+ from: m[1],
67
+ to: m[3],
68
+ label,
69
+ async: isAsync,
70
+ bidirectional,
71
+ };
72
+ }
73
+
74
+ return null;
75
+ }
@@ -0,0 +1,75 @@
1
+ // ============================================================
2
+ // Inline Markdown — shared parsing + SVG rendering for text fields
3
+ // ============================================================
4
+
5
+ import * as d3Selection from 'd3-selection';
6
+ import type { PaletteColors } from '../palettes';
7
+
8
+ export interface InlineSpan {
9
+ text: string;
10
+ bold?: boolean;
11
+ italic?: boolean;
12
+ code?: boolean;
13
+ href?: string;
14
+ }
15
+
16
+ export function parseInlineMarkdown(text: string): InlineSpan[] {
17
+ const spans: InlineSpan[] = [];
18
+ const regex =
19
+ /\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|(https?:\/\/[^\s)>\]]+|www\.[^\s)>\]]+)|([^*_`[]+?(?=https?:\/\/|www\.|$)|[^*_`[]+)/g;
20
+ let match;
21
+ while ((match = regex.exec(text)) !== null) {
22
+ if (match[1]) spans.push({ text: match[1], bold: true }); // **bold**
23
+ else if (match[2]) spans.push({ text: match[2], bold: true }); // __bold__
24
+ else if (match[3]) spans.push({ text: match[3], italic: true }); // *italic*
25
+ else if (match[4]) spans.push({ text: match[4], italic: true }); // _italic_
26
+ else if (match[5]) spans.push({ text: match[5], code: true }); // `code`
27
+ else if (match[6]) spans.push({ text: match[6], href: match[7] }); // [text](url)
28
+ else if (match[8]) { // bare URL
29
+ const url = match[8];
30
+ const href = url.startsWith('www.') ? `https://${url}` : url;
31
+ spans.push({ text: url, href });
32
+ } else if (match[9]) spans.push({ text: match[9] }); // plain text
33
+ }
34
+ return spans;
35
+ }
36
+
37
+ const BARE_URL_MAX_DISPLAY = 35;
38
+
39
+ export function truncateBareUrl(url: string): string {
40
+ const stripped = url.replace(/^https?:\/\//, '').replace(/^www\./, '');
41
+ if (stripped.length <= BARE_URL_MAX_DISPLAY) return stripped;
42
+ return stripped.slice(0, BARE_URL_MAX_DISPLAY - 1) + '\u2026';
43
+ }
44
+
45
+ export function renderInlineText(
46
+ textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
47
+ text: string,
48
+ palette: PaletteColors,
49
+ fontSize?: number
50
+ ): void {
51
+ const spans = parseInlineMarkdown(text);
52
+ for (const span of spans) {
53
+ if (span.href) {
54
+ // Bare URLs (text === href or href with https:// prepended) get truncated display;
55
+ // markdown links [text](url) keep their user-chosen text as-is.
56
+ const isBareUrl =
57
+ span.text === span.href ||
58
+ `https://${span.text}` === span.href;
59
+ const display = isBareUrl ? truncateBareUrl(span.text) : span.text;
60
+ const a = textEl.append('a').attr('href', span.href);
61
+ a.append('tspan')
62
+ .text(display)
63
+ .attr('fill', palette.primary)
64
+ .style('text-decoration', 'underline');
65
+ } else {
66
+ const tspan = textEl.append('tspan').text(span.text);
67
+ if (span.bold) tspan.attr('font-weight', 'bold');
68
+ if (span.italic) tspan.attr('font-style', 'italic');
69
+ if (span.code) {
70
+ tspan.attr('font-family', 'monospace');
71
+ if (fontSize) tspan.attr('font-size', fontSize - 1);
72
+ }
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared parser utilities — extracted from individual parsers to eliminate
3
+ * duplication of measureIndent, extractColor, header regexes, and
4
+ * pipe-metadata parsing.
5
+ */
6
+
7
+ import { resolveColor } from '../colors';
8
+ import type { PaletteColors } from '../palettes';
9
+
10
+ /** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
11
+ export function measureIndent(line: string): number {
12
+ let indent = 0;
13
+ for (const ch of line) {
14
+ if (ch === ' ') indent++;
15
+ else if (ch === '\t') indent += 4;
16
+ else break;
17
+ }
18
+ return indent;
19
+ }
20
+
21
+ /** Matches a trailing `(colorName)` suffix on a label. */
22
+ export const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
23
+
24
+ /** Extract an optional trailing color suffix from a label, resolving via palette. */
25
+ export function extractColor(
26
+ label: string,
27
+ palette?: PaletteColors,
28
+ ): { label: string; color?: string } {
29
+ const m = label.match(COLOR_SUFFIX_RE);
30
+ if (!m) return { label };
31
+ const colorName = m[1].trim();
32
+ return {
33
+ label: label.substring(0, m.index!).trim(),
34
+ color: resolveColor(colorName, palette),
35
+ };
36
+ }
37
+
38
+ /** Matches `chart: <type>` header lines. */
39
+ export const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
40
+
41
+ /** Matches `title: <text>` header lines. */
42
+ export const TITLE_RE = /^title\s*:\s*(.+)/i;
43
+
44
+ /** Matches `option: value` header lines. */
45
+ export const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
46
+
47
+ /** Parse pipe-delimited metadata from segments after the first (name) segment. */
48
+ export function parsePipeMetadata(
49
+ segments: string[],
50
+ aliasMap: Map<string, string> = new Map(),
51
+ ): Record<string, string> {
52
+ const metadata: Record<string, string> = {};
53
+ for (let j = 1; j < segments.length; j++) {
54
+ for (const part of segments[j].split(',')) {
55
+ const trimmedPart = part.trim();
56
+ if (!trimmedPart) continue;
57
+ const colonIdx = trimmedPart.indexOf(':');
58
+ if (colonIdx > 0) {
59
+ const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
60
+ const key = aliasMap.get(rawKey) ?? rawKey;
61
+ const value = trimmedPart.substring(colonIdx + 1).trim();
62
+ metadata[key] = value;
63
+ }
64
+ }
65
+ }
66
+ return metadata;
67
+ }
@@ -0,0 +1,76 @@
1
+ // ============================================================
2
+ // Shared tag-group types, regexes, and matchers
3
+ // ============================================================
4
+
5
+ /** A single entry inside a tag group: `Value(color)` */
6
+ export interface TagEntry {
7
+ value: string;
8
+ color: string;
9
+ lineNumber: number;
10
+ }
11
+
12
+ /** A tag group block: heading + entries */
13
+ export interface TagGroup {
14
+ name: string;
15
+ alias?: string;
16
+ entries: TagEntry[];
17
+ /** Value of the entry marked `default` (nodes without metadata get this) */
18
+ defaultValue?: string;
19
+ lineNumber: number;
20
+ }
21
+
22
+ /** Result of matching a tag block heading */
23
+ export interface TagBlockMatch {
24
+ name: string;
25
+ alias: string | undefined;
26
+ colorHint: string | undefined;
27
+ /** true when the heading used `## …` (deprecated) */
28
+ deprecated: boolean;
29
+ }
30
+
31
+ // ── Regexes ─────────────────────────────────────────────────
32
+
33
+ /** New canonical syntax: `tag: GroupName [alias X] [(color)]` (case-insensitive) */
34
+ export const TAG_BLOCK_RE =
35
+ /^tag:\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/i;
36
+
37
+ /** Legacy syntax: `## GroupName [alias X] [(color)]` */
38
+ export const GROUP_HEADING_RE =
39
+ /^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
40
+
41
+ // ── Matchers ────────────────────────────────────────────────
42
+
43
+ /** Returns true if `trimmed` is a tag block heading in either syntax. */
44
+ export function isTagBlockHeading(trimmed: string): boolean {
45
+ return TAG_BLOCK_RE.test(trimmed) || GROUP_HEADING_RE.test(trimmed);
46
+ }
47
+
48
+ /**
49
+ * Parse a tag block heading line into structured data.
50
+ * Returns `null` if the line is not a tag block heading.
51
+ */
52
+ export function matchTagBlockHeading(trimmed: string): TagBlockMatch | null {
53
+ // Try new syntax first
54
+ const tagMatch = trimmed.match(TAG_BLOCK_RE);
55
+ if (tagMatch) {
56
+ return {
57
+ name: tagMatch[1].trim(),
58
+ alias: tagMatch[2] || undefined,
59
+ colorHint: tagMatch[3] || undefined,
60
+ deprecated: false,
61
+ };
62
+ }
63
+
64
+ // Fall back to legacy syntax
65
+ const groupMatch = trimmed.match(GROUP_HEADING_RE);
66
+ if (groupMatch) {
67
+ return {
68
+ name: groupMatch[1].trim(),
69
+ alias: groupMatch[2] || undefined,
70
+ colorHint: groupMatch[3] || undefined,
71
+ deprecated: true,
72
+ };
73
+ }
74
+
75
+ return null;
76
+ }