@diagrammo/dgmo 0.8.2 → 0.8.4

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 (120) hide show
  1. package/.claude/commands/dgmo-diagram-this.md +60 -0
  2. package/.claude/commands/dgmo-document-project.md +128 -0
  3. package/.claude/commands/dgmo.md +185 -50
  4. package/.cursorrules +32 -37
  5. package/.github/copilot-instructions.md +35 -44
  6. package/.windsurfrules +32 -37
  7. package/README.md +4 -4
  8. package/dist/cli.cjs +189 -194
  9. package/dist/editor.cjs +336 -0
  10. package/dist/editor.cjs.map +1 -0
  11. package/dist/editor.d.cts +27 -0
  12. package/dist/editor.d.ts +27 -0
  13. package/dist/editor.js +305 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/index.cjs +3699 -1564
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +7 -6
  18. package/dist/index.d.ts +7 -6
  19. package/dist/index.js +3699 -1564
  20. package/dist/index.js.map +1 -1
  21. package/docs/language-reference.md +822 -1060
  22. package/gallery/fixtures/arc.dgmo +18 -0
  23. package/gallery/fixtures/area.dgmo +19 -0
  24. package/gallery/fixtures/bar-stacked.dgmo +10 -0
  25. package/gallery/fixtures/bar.dgmo +10 -0
  26. package/gallery/fixtures/c4-full.dgmo +52 -0
  27. package/gallery/fixtures/c4.dgmo +17 -0
  28. package/gallery/fixtures/chord.dgmo +12 -0
  29. package/gallery/fixtures/class-basic.dgmo +14 -0
  30. package/gallery/fixtures/class-full.dgmo +43 -0
  31. package/gallery/fixtures/doughnut.dgmo +8 -0
  32. package/gallery/fixtures/flowchart-basic.dgmo +3 -0
  33. package/gallery/fixtures/flowchart-colors.dgmo +5 -0
  34. package/gallery/fixtures/flowchart-complex.dgmo +17 -0
  35. package/gallery/fixtures/flowchart-decision.dgmo +5 -0
  36. package/gallery/fixtures/flowchart-full.dgmo +13 -0
  37. package/gallery/fixtures/flowchart-groups.dgmo +10 -0
  38. package/gallery/fixtures/flowchart-loop.dgmo +7 -0
  39. package/gallery/fixtures/flowchart-nested.dgmo +7 -0
  40. package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
  41. package/gallery/fixtures/function.dgmo +8 -0
  42. package/gallery/fixtures/funnel.dgmo +7 -0
  43. package/gallery/fixtures/gantt-full.dgmo +49 -0
  44. package/gallery/fixtures/gantt.dgmo +42 -0
  45. package/gallery/fixtures/heatmap.dgmo +8 -0
  46. package/gallery/fixtures/infra-full.dgmo +78 -0
  47. package/gallery/fixtures/infra-overload.dgmo +25 -0
  48. package/gallery/fixtures/infra.dgmo +47 -0
  49. package/gallery/fixtures/initiative-status-full.dgmo +46 -0
  50. package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
  51. package/gallery/fixtures/initiative-status.dgmo +9 -0
  52. package/gallery/fixtures/line.dgmo +19 -0
  53. package/gallery/fixtures/multi-line.dgmo +11 -0
  54. package/gallery/fixtures/org-basic.dgmo +16 -0
  55. package/gallery/fixtures/org-full.dgmo +69 -0
  56. package/gallery/fixtures/org-teams.dgmo +25 -0
  57. package/gallery/fixtures/pie.dgmo +9 -0
  58. package/gallery/fixtures/polar-area.dgmo +8 -0
  59. package/gallery/fixtures/quadrant.dgmo +18 -0
  60. package/gallery/fixtures/radar.dgmo +8 -0
  61. package/gallery/fixtures/sankey.dgmo +31 -0
  62. package/gallery/fixtures/scatter.dgmo +21 -0
  63. package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
  64. package/gallery/fixtures/sequence-tags.dgmo +41 -0
  65. package/gallery/fixtures/sequence.dgmo +35 -0
  66. package/gallery/fixtures/sitemap-basic.dgmo +12 -0
  67. package/gallery/fixtures/sitemap-full.dgmo +156 -0
  68. package/gallery/fixtures/slope.dgmo +8 -0
  69. package/gallery/fixtures/spr-eras.dgmo +62 -0
  70. package/gallery/fixtures/state.dgmo +30 -0
  71. package/gallery/fixtures/timeline-intraday.dgmo +14 -0
  72. package/gallery/fixtures/timeline.dgmo +32 -0
  73. package/gallery/fixtures/venn.dgmo +10 -0
  74. package/gallery/fixtures/wordcloud.dgmo +24 -0
  75. package/package.json +51 -2
  76. package/src/c4/layout.ts +372 -90
  77. package/src/c4/parser.ts +113 -62
  78. package/src/chart.ts +149 -64
  79. package/src/class/parser.ts +84 -28
  80. package/src/class/renderer.ts +2 -2
  81. package/src/cli.ts +179 -77
  82. package/src/completion.ts +381 -182
  83. package/src/d3.ts +1026 -428
  84. package/src/dgmo-mermaid.ts +16 -13
  85. package/src/dgmo-router.ts +70 -24
  86. package/src/echarts.ts +682 -169
  87. package/src/editor/dgmo.grammar +69 -0
  88. package/src/editor/dgmo.grammar.d.ts +2 -0
  89. package/src/editor/dgmo.grammar.js +18 -0
  90. package/src/editor/dgmo.grammar.terms.d.ts +5 -0
  91. package/src/editor/dgmo.grammar.terms.js +35 -0
  92. package/src/editor/highlight.ts +36 -0
  93. package/src/editor/index.ts +28 -0
  94. package/src/editor/keywords.ts +220 -0
  95. package/src/editor/tokens.ts +30 -0
  96. package/src/er/parser.ts +55 -29
  97. package/src/er/renderer.ts +112 -53
  98. package/src/gantt/calculator.ts +91 -29
  99. package/src/gantt/parser.ts +291 -97
  100. package/src/gantt/renderer.ts +1120 -350
  101. package/src/graph/flowchart-parser.ts +48 -75
  102. package/src/graph/state-parser.ts +54 -27
  103. package/src/infra/parser.ts +161 -177
  104. package/src/infra/renderer.ts +723 -271
  105. package/src/infra/types.ts +0 -1
  106. package/src/initiative-status/parser.ts +144 -56
  107. package/src/kanban/parser.ts +27 -19
  108. package/src/org/layout.ts +111 -44
  109. package/src/org/parser.ts +71 -27
  110. package/src/org/resolver.ts +3 -3
  111. package/src/palettes/index.ts +3 -2
  112. package/src/render.ts +1 -2
  113. package/src/sequence/parser.ts +209 -100
  114. package/src/sitemap/parser.ts +73 -44
  115. package/src/utils/arrows.ts +2 -22
  116. package/src/utils/duration.ts +39 -21
  117. package/src/utils/legend-constants.ts +0 -2
  118. package/src/utils/parsing.ts +82 -72
  119. package/src/utils/tag-groups.ts +4 -41
  120. package/src/infra/serialize.ts +0 -67
@@ -6,7 +6,14 @@ import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
8
  import { parseArrow } from '../utils/arrows';
9
- import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
9
+ import {
10
+ measureIndent,
11
+ extractColor,
12
+ parsePipeMetadata,
13
+ MULTIPLE_PIPE_ERROR,
14
+ parseFirstLine,
15
+ OPTION_NOCOLON_RE,
16
+ } from '../utils/parsing';
10
17
  import type { TagGroup } from '../utils/tag-groups';
11
18
  import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
12
19
 
@@ -185,21 +192,24 @@ const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
185
192
  // Arrow pattern for sequence inference — detects any arrow form
186
193
  const ARROW_PATTERN = /\S+\s*(?:<-\S+-|<~\S+~|-\S+->|~\S+~>|->|~>|<-|<~)\s*\S+/;
187
194
 
188
- // Note patterns — colon-free syntax
195
+ // Note patterns — colon-free syntax only
189
196
  // Single-line: "note text", "note left text", "note right of X text", "note left X text"
190
197
  // Multi-line: "note", "note right", "note right of X", "note left X" (body indented below)
191
- // Also supports legacy colon syntax: "note: text", "note right of X: text"
192
198
  //
193
199
  // The colon-free positioned form requires participant resolution — the parser
194
200
  // already has participant collection infrastructure, so we match the general
195
201
  // structure here and resolve participant vs text in the parsing logic.
196
- const NOTE_SINGLE_COLON = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*:\s*(.+)$/i;
197
202
  const NOTE_BARE = /^note\s+(.+)$/i;
198
- const NOTE_MULTI = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*:?\s*$/i;
203
+ const NOTE_MULTI = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*$/i;
199
204
 
200
205
  /** Result of parseNoteLine — indicates what the parser should do. */
201
206
  type NoteParseResult =
202
- | { kind: 'single'; position: 'right' | 'left'; participantId: string; text: string }
207
+ | {
208
+ kind: 'single';
209
+ position: 'right' | 'left';
210
+ participantId: string;
211
+ text: string;
212
+ }
203
213
  | { kind: 'multi-head'; position: 'right' | 'left'; participantId: string }
204
214
  | { kind: 'skip' }
205
215
  | null; // not a note line at all
@@ -208,42 +218,30 @@ type NoteParseResult =
208
218
  * Parse a note line, resolving participant names from the known participants list.
209
219
  *
210
220
  * Supports:
211
- * - `note: text` / `note text` — default position (right), last msg sender
212
- * - `note left of X: text` / `note left of X text` / `note left X text`
213
- * - `note right:` / `note right` — multi-line head
214
- * - `note right of X:` / `note right of X` / `note left X` — multi-line head
221
+ * - `note text` — default position (right), last msg sender
222
+ * - `note left of X text` / `note left X text`
223
+ * - `note right` — multi-line head
224
+ * - `note right of X` / `note left X` — multi-line head
215
225
  * - Quoted participant: `note left "Auth Service" text`
216
226
  */
217
227
  function parseNoteLine(
218
228
  trimmed: string,
219
229
  participants: SequenceParticipant[],
220
- lastMsgFrom: string | null,
230
+ lastMsgFrom: string | null
221
231
  ): NoteParseResult {
222
232
  const lower = trimmed.toLowerCase();
223
233
  if (!lower.startsWith('note')) return null;
224
234
  // Must be exactly "note" or "note " — not "notebook" etc.
225
- if (trimmed.length > 4 && trimmed[4] !== ' ' && trimmed[4] !== ':') return null;
226
-
227
- // 1. Try legacy colon-based syntax first
228
- const colonMatch = trimmed.match(NOTE_SINGLE_COLON);
229
- if (colonMatch) {
230
- const position = (colonMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
231
- let participantId = colonMatch[2] || null;
232
- if (!participantId) {
233
- if (!lastMsgFrom) return { kind: 'skip' };
234
- participantId = lastMsgFrom;
235
- }
236
- if (!participants.some((p) => p.id === participantId)) return { kind: 'skip' };
237
- return { kind: 'single', position, participantId, text: colonMatch[3].trim() };
238
- }
235
+ if (trimmed.length > 4 && trimmed[4] !== ' ') return null;
239
236
 
240
- // 2. Try multi-line head (no text after note): `note`, `note right`, `note right of X`, `note left X`
237
+ // 1. Try multi-line head (no text after note): `note`, `note right`, `note right of X`, `note left X`
241
238
  // NOTE: NOTE_MULTI's (.+?) can greedily capture "participant text" as one group.
242
- // Only trust this match if the captured participant actually exists. Otherwise, fall
243
- // through to the bare-note handler which does proper participant-aware splitting.
239
+ // Only trust this match if the captured participant actually exists. Otherwise,
240
+ // fall through to the bare-note handler which does proper participant-aware splitting.
244
241
  const multiMatch = trimmed.match(NOTE_MULTI);
245
242
  if (multiMatch) {
246
- const position = (multiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
243
+ const position =
244
+ (multiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
247
245
  let participantId = multiMatch[2] || null;
248
246
  if (!participantId) {
249
247
  if (!lastMsgFrom) return { kind: 'skip' };
@@ -255,7 +253,7 @@ function parseNoteLine(
255
253
  // Participant not found — fall through to bare-note handler for proper resolution
256
254
  }
257
255
 
258
- // 3. Bare note (colon-free): `note text` or `note left [of] X text`
256
+ // 2. Bare note: `note text` or `note left [of] X text`
259
257
  const bareMatch = trimmed.match(NOTE_BARE);
260
258
  if (bareMatch) {
261
259
  const rest = bareMatch[1].trim();
@@ -277,7 +275,8 @@ function parseNoteLine(
277
275
  if (!afterPos) {
278
276
  // Just `note left` or `note right` — multi-line head
279
277
  if (!lastMsgFrom) return { kind: 'skip' };
280
- if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
278
+ if (!participants.some((p) => p.id === lastMsgFrom))
279
+ return { kind: 'skip' };
281
280
  return { kind: 'multi-head', position, participantId: lastMsgFrom };
282
281
  }
283
282
 
@@ -285,10 +284,19 @@ function parseNoteLine(
285
284
  const resolved = resolveParticipantAndText(afterPos, participants);
286
285
  if (resolved) {
287
286
  if (resolved.text) {
288
- return { kind: 'single', position, participantId: resolved.participantId, text: resolved.text };
287
+ return {
288
+ kind: 'single',
289
+ position,
290
+ participantId: resolved.participantId,
291
+ text: resolved.text,
292
+ };
289
293
  } else {
290
294
  // No text after participant — multi-line head
291
- return { kind: 'multi-head', position, participantId: resolved.participantId };
295
+ return {
296
+ kind: 'multi-head',
297
+ position,
298
+ participantId: resolved.participantId,
299
+ };
292
300
  }
293
301
  }
294
302
 
@@ -299,14 +307,26 @@ function parseNoteLine(
299
307
 
300
308
  // Without `of`, treat remaining text as note content on the last-msg sender
301
309
  if (!lastMsgFrom) return { kind: 'skip' };
302
- if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
303
- return { kind: 'single', position, participantId: lastMsgFrom, text: afterPos };
310
+ if (!participants.some((p) => p.id === lastMsgFrom))
311
+ return { kind: 'skip' };
312
+ return {
313
+ kind: 'single',
314
+ position,
315
+ participantId: lastMsgFrom,
316
+ text: afterPos,
317
+ };
304
318
  }
305
319
 
306
320
  // Plain `note text` — default position, last msg sender
307
321
  if (!lastMsgFrom) return { kind: 'skip' };
308
- if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
309
- return { kind: 'single', position: 'right', participantId: lastMsgFrom, text: rest };
322
+ if (!participants.some((p) => p.id === lastMsgFrom))
323
+ return { kind: 'skip' };
324
+ return {
325
+ kind: 'single',
326
+ position: 'right',
327
+ participantId: lastMsgFrom,
328
+ text: rest,
329
+ };
310
330
  }
311
331
 
312
332
  return null;
@@ -319,7 +339,7 @@ function parseNoteLine(
319
339
  */
320
340
  function resolveParticipantAndText(
321
341
  input: string,
322
- participants: SequenceParticipant[],
342
+ participants: SequenceParticipant[]
323
343
  ): { participantId: string; text: string } | null {
324
344
  // Handle quoted participant: `"Auth Service" text`
325
345
  if (input.startsWith('"') || input.startsWith("'")) {
@@ -422,12 +442,16 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
422
442
  const aliasMap = new Map<string, string>();
423
443
 
424
444
  /** Split pipe metadata from a line: "core | k: v" → { core, meta } */
425
- const splitPipe = (text: string, ln?: number): { core: string; meta?: Record<string, string> } => {
445
+ const splitPipe = (
446
+ text: string,
447
+ ln?: number
448
+ ): { core: string; meta?: Record<string, string> } => {
426
449
  const idx = text.indexOf('|');
427
450
  if (idx < 0) return { core: text };
428
451
  const core = text.substring(0, idx).trimEnd();
429
452
  const segments = text.substring(idx).split('|');
430
- const warnFn = ln != null ? () => pushWarning(ln, MULTIPLE_PIPE_WARNING) : undefined;
453
+ const warnFn =
454
+ ln != null ? () => pushError(ln, MULTIPLE_PIPE_ERROR) : undefined;
431
455
  const meta = parsePipeMetadata(segments, aliasMap, warnFn);
432
456
  return Object.keys(meta).length > 0 ? { core, meta } : { core };
433
457
  };
@@ -475,12 +499,17 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
475
499
  const afterBracket = groupMatch[3]?.trim() || '';
476
500
  if (afterBracket.startsWith('|')) {
477
501
  const segments = afterBracket.split('|');
478
- const meta = parsePipeMetadata(segments, aliasMap, () => pushWarning(lineNumber, MULTIPLE_PIPE_WARNING));
502
+ const meta = parsePipeMetadata(segments, aliasMap, () =>
503
+ pushError(lineNumber, MULTIPLE_PIPE_ERROR)
504
+ );
479
505
  if (Object.keys(meta).length > 0) groupMeta = meta;
480
506
  }
481
507
 
482
508
  if (groupColor) {
483
- pushWarning(lineNumber, `(${groupColor}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`);
509
+ pushWarning(
510
+ lineNumber,
511
+ `(${groupColor}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`
512
+ );
484
513
  }
485
514
  contentStarted = true;
486
515
  activeGroup = {
@@ -499,9 +528,16 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
499
528
  if (fallbackMatch && fallbackMatch[1].includes('|')) {
500
529
  const rawInside = fallbackMatch[1];
501
530
  const pipeIdx = rawInside.indexOf('|');
502
- const cleanName = rawInside.substring(0, pipeIdx).trim().replace(/\([^)]*\)$/, '').trim();
531
+ const cleanName = rawInside
532
+ .substring(0, pipeIdx)
533
+ .trim()
534
+ .replace(/\([^)]*\)$/, '')
535
+ .trim();
503
536
  const metaPart = rawInside.substring(pipeIdx).trim();
504
- pushError(lineNumber, `Pipe metadata must go outside brackets — use '[${cleanName}] ${metaPart}' instead of '[${rawInside.trim()}]'`);
537
+ pushError(
538
+ lineNumber,
539
+ `Pipe metadata must go outside brackets — use '[${cleanName}] ${metaPart}' instead of '[${rawInside.trim()}]'`
540
+ );
505
541
  continue;
506
542
  }
507
543
  }
@@ -512,7 +548,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
512
548
  const name = legacyMatch[1].trim();
513
549
  const color = legacyMatch[2]?.trim();
514
550
  const suggestion = color ? `[${name}(${color})]` : `[${name}]`;
515
- pushError(lineNumber, `'## ${name}' group syntax is no longer supported. Use '${suggestion}' instead`);
551
+ pushError(
552
+ lineNumber,
553
+ `'## ${name}' group syntax is no longer supported. Use '${suggestion}' instead`
554
+ );
516
555
  continue;
517
556
  }
518
557
 
@@ -531,9 +570,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
531
570
  }
532
571
 
533
572
  // ---- Tag group handling ----
534
- // Tag block heading: "tag: Name [alias X]"
573
+ // Tag block heading: "tag Name [alias X]"
535
574
  const tagBlockMatch = matchTagBlockHeading(trimmed);
536
- if (tagBlockMatch && !tagBlockMatch.deprecated) {
575
+ if (tagBlockMatch) {
537
576
  if (contentStarted) {
538
577
  pushError(lineNumber, 'Tag groups must appear before sequence content');
539
578
  continue;
@@ -545,7 +584,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
545
584
  lineNumber,
546
585
  };
547
586
  if (tagBlockMatch.alias) {
548
- aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
587
+ aliasMap.set(
588
+ tagBlockMatch.alias.toLowerCase(),
589
+ tagBlockMatch.name.toLowerCase()
590
+ );
549
591
  }
550
592
  result.tagGroups.push(currentTagGroup);
551
593
  continue;
@@ -556,7 +598,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
556
598
  if (currentTagGroup && !contentStarted && measureIndent(raw) > 0) {
557
599
  const { label, color } = extractColor(trimmed);
558
600
  if (!color) {
559
- pushError(lineNumber, `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`);
601
+ pushError(
602
+ lineNumber,
603
+ `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
604
+ );
560
605
  continue;
561
606
  }
562
607
  // First entry is the default
@@ -585,7 +630,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
585
630
  const labelRaw = sectionMatch[1].trim();
586
631
  const colorMatch = labelRaw.match(/^(.+?)\(([^)]+)\)$/);
587
632
  if (colorMatch) {
588
- pushWarning(lineNumber, `(${colorMatch[2].trim()}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`);
633
+ pushWarning(
634
+ lineNumber,
635
+ `(${colorMatch[2].trim()}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`
636
+ );
589
637
  }
590
638
  contentStarted = true;
591
639
  const section: SequenceSection = {
@@ -601,37 +649,39 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
601
649
  // Parse header key: value lines (always top-level)
602
650
  // Skip 'note' lines — parsed in the indent-aware section below
603
651
  const colonIndex = trimmed.indexOf(':');
604
- if (colonIndex > 0 && !trimmed.includes('->') && !trimmed.includes('~>') && !trimmed.includes('<-') && !trimmed.includes('<~') && !trimmed.includes('|')) {
652
+ if (
653
+ colonIndex > 0 &&
654
+ !trimmed.includes('->') &&
655
+ !trimmed.includes('~>') &&
656
+ !trimmed.includes('<-') &&
657
+ !trimmed.includes('<~') &&
658
+ !trimmed.includes('|')
659
+ ) {
605
660
  const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
606
661
  if (key === 'note' || key.startsWith('note ')) {
607
662
  // Fall through to indent-aware note parsing below
608
663
  } else {
609
- const value = trimmed.substring(colonIndex + 1).trim();
664
+ const value = trimmed.substring(colonIndex + 1).trim();
610
665
 
611
- if (key === 'chart') {
612
- hasExplicitChart = true;
613
- if (value.toLowerCase() !== 'sequence') {
614
- return fail(lineNumber, `Expected chart type "sequence", got "${value}"`);
666
+ // Enforce headers-before-content
667
+ if (contentStarted) {
668
+ pushError(
669
+ lineNumber,
670
+ `Options like '${key}: ${value}' must appear before the first message or declaration`
671
+ );
672
+ continue;
615
673
  }
616
- continue;
617
- }
618
674
 
619
- // Enforce headers-before-content
620
- if (contentStarted) {
621
- pushError(lineNumber, `Options like '${key}: ${value}' must appear before the first message or declaration`);
622
- continue;
623
- }
675
+ if (key === 'title') {
676
+ result.title = value;
677
+ result.titleLineNumber = lineNumber;
678
+ continue;
679
+ }
624
680
 
625
- if (key === 'title') {
626
- result.title = value;
627
- result.titleLineNumber = lineNumber;
681
+ // Store other options
682
+ result.options[key] = value;
628
683
  continue;
629
684
  }
630
-
631
- // Store other options
632
- result.options[key] = value;
633
- continue;
634
- }
635
685
  }
636
686
 
637
687
  // Parse space-separated options (no colon): `activations off`, `no-activations`, `active-tag Priority`
@@ -642,7 +692,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
642
692
  const base = optLower.substring(3);
643
693
  if (KNOWN_SEQ_BOOLEANS.has(base)) {
644
694
  if (contentStarted) {
645
- pushError(lineNumber, `Options like '${trimmed}' must appear before the first message or declaration`);
695
+ pushError(
696
+ lineNumber,
697
+ `Options like '${trimmed}' must appear before the first message or declaration`
698
+ );
646
699
  continue;
647
700
  }
648
701
  result.options[base] = 'off';
@@ -656,7 +709,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
656
709
  const optVal = spaceMatch[2].trim();
657
710
  if (KNOWN_SEQ_OPTIONS.has(optKey) || KNOWN_SEQ_BOOLEANS.has(optKey)) {
658
711
  if (contentStarted) {
659
- pushError(lineNumber, `Options like '${trimmed}' must appear before the first message or declaration`);
712
+ pushError(
713
+ lineNumber,
714
+ `Options like '${trimmed}' must appear before the first message or declaration`
715
+ );
660
716
  continue;
661
717
  }
662
718
  result.options[optKey] = optVal;
@@ -666,8 +722,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
666
722
  }
667
723
 
668
724
  // Parse "Name is a type [aka Alias]" declarations (always top-level)
725
+ // Skip lines starting with 'note' — handled by note parsing below
669
726
  const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);
670
- const isAMatch = isACore.match(IS_A_PATTERN);
727
+ const isAMatch = !/^note(\s|$)/i.test(trimmed)
728
+ ? isACore.match(IS_A_PATTERN)
729
+ : null;
671
730
  if (isAMatch) {
672
731
  contentStarted = true;
673
732
  const id = isAMatch[1];
@@ -703,7 +762,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
703
762
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
704
763
  const existingGroup = participantGroupMap.get(id);
705
764
  if (existingGroup) {
706
- pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
765
+ pushError(
766
+ lineNumber,
767
+ `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
768
+ );
707
769
  } else {
708
770
  activeGroup.participantIds.push(id);
709
771
  participantGroupMap.set(id, activeGroup.name);
@@ -734,7 +796,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
734
796
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
735
797
  const existingGroup = participantGroupMap.get(id);
736
798
  if (existingGroup) {
737
- pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
799
+ pushError(
800
+ lineNumber,
801
+ `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
802
+ );
738
803
  } else {
739
804
  activeGroup.participantIds.push(id);
740
805
  participantGroupMap.set(id, activeGroup.name);
@@ -750,7 +815,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
750
815
  if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
751
816
  const id = coloredMatch[1];
752
817
  const color = coloredMatch[2].trim();
753
- pushError(lineNumber, `'${id}(${color})' syntax is no longer supported — use 'tag:' groups for coloring`);
818
+ pushError(
819
+ lineNumber,
820
+ `'${id}(${color})' syntax is no longer supported — use 'tag:' groups for coloring`
821
+ );
754
822
  contentStarted = true;
755
823
  if (!result.participants.some((p) => p.id === id)) {
756
824
  result.participants.push({
@@ -764,7 +832,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
764
832
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
765
833
  const existingGroup = participantGroupMap.get(id);
766
834
  if (existingGroup) {
767
- pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
835
+ pushError(
836
+ lineNumber,
837
+ `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
838
+ );
768
839
  } else {
769
840
  activeGroup.participantIds.push(id);
770
841
  participantGroupMap.set(id, activeGroup.name);
@@ -778,7 +849,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
778
849
  {
779
850
  const { core: bareCore, meta: bareMeta } = splitPipe(trimmed, lineNumber);
780
851
  const inGroup = activeGroup && measureIndent(raw) > 0;
781
- if (/^\S+$/.test(bareCore) && !ARROW_PATTERN.test(bareCore) && (inGroup || !contentStarted || bareMeta)) {
852
+ if (
853
+ /^\S+$/.test(bareCore) &&
854
+ !ARROW_PATTERN.test(bareCore) &&
855
+ (inGroup || !contentStarted || bareMeta)
856
+ ) {
782
857
  contentStarted = true;
783
858
  const id = bareCore;
784
859
  if (!result.participants.some((p) => p.id === id)) {
@@ -793,7 +868,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
793
868
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
794
869
  const existingGroup = participantGroupMap.get(id);
795
870
  if (existingGroup) {
796
- pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
871
+ pushError(
872
+ lineNumber,
873
+ `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`
874
+ );
797
875
  } else {
798
876
  activeGroup.participantIds.push(id);
799
877
  participantGroupMap.set(id, activeGroup.name);
@@ -897,9 +975,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
897
975
  }
898
976
 
899
977
  // ---- Error: plain bidirectional arrows (A <-> B, A <~> B) ----
900
- const bidiPlainMatch = arrowCore.match(
901
- /^(.+?)\s*(?:<->|<~>)\s*(.+)/
902
- );
978
+ const bidiPlainMatch = arrowCore.match(/^(.+?)\s*(?:<->|<~>)\s*(.+)/);
903
979
  if (bidiPlainMatch) {
904
980
  pushError(
905
981
  lineNumber,
@@ -1016,14 +1092,23 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1016
1092
  // Parse 'else if <label>' keyword (must come before bare 'else')
1017
1093
  const elseIfMatch = trimmed.match(/^else\s+if\s+(.+)$/i);
1018
1094
  if (elseIfMatch) {
1019
- if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
1095
+ if (
1096
+ blockStack.length > 0 &&
1097
+ blockStack[blockStack.length - 1].indent === indent
1098
+ ) {
1020
1099
  const top = blockStack[blockStack.length - 1];
1021
1100
  if (top.block.type === 'parallel') {
1022
- pushError(lineNumber, "parallel blocks don't support else if — list all concurrent messages directly inside the block");
1101
+ pushError(
1102
+ lineNumber,
1103
+ "parallel blocks don't support else if — list all concurrent messages directly inside the block"
1104
+ );
1023
1105
  continue;
1024
1106
  }
1025
1107
  if (top.block.type === 'if') {
1026
- const branch: ElseIfBranch = { label: elseIfMatch[1].trim(), children: [] };
1108
+ const branch: ElseIfBranch = {
1109
+ label: elseIfMatch[1].trim(),
1110
+ children: [],
1111
+ };
1027
1112
  if (!top.block.elseIfBranches) top.block.elseIfBranches = [];
1028
1113
  top.block.elseIfBranches.push(branch);
1029
1114
  top.activeElseIfBranch = branch;
@@ -1035,10 +1120,16 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1035
1120
 
1036
1121
  // Parse 'else' keyword (only applies to 'if' blocks)
1037
1122
  if (trimmed.toLowerCase() === 'else') {
1038
- if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
1123
+ if (
1124
+ blockStack.length > 0 &&
1125
+ blockStack[blockStack.length - 1].indent === indent
1126
+ ) {
1039
1127
  const top = blockStack[blockStack.length - 1];
1040
1128
  if (top.block.type === 'parallel') {
1041
- pushError(lineNumber, "parallel blocks don't support else — list all concurrent messages directly inside the block");
1129
+ pushError(
1130
+ lineNumber,
1131
+ "parallel blocks don't support else — list all concurrent messages directly inside the block"
1132
+ );
1042
1133
  continue;
1043
1134
  }
1044
1135
  if (top.block.type === 'if') {
@@ -1049,14 +1140,17 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1049
1140
  continue;
1050
1141
  }
1051
1142
 
1052
- // ---- Note parsing (colon-free + legacy colon syntax) ----
1143
+ // ---- Note parsing (space-separated only) ----
1053
1144
  // Strategy:
1054
- // 1. Try colon-based syntax: `note right of X: text` (legacy, still supported)
1055
- // 2. Try bare note: `note text` — position defaults, text is everything after `note`
1056
- // 3. For positioned: `note left [of] X text` needs participant lookup to split name vs text
1057
- // 4. Multi-line: `note`, `note right`, `note right [of] X` (body indented below)
1145
+ // 1. Try bare note: `note text` position defaults, text is everything after `note`
1146
+ // 2. For positioned: `note left [of] X text` — needs participant lookup to split name vs text
1147
+ // 3. Multi-line: `note`, `note right`, `note right [of] X` (body indented below)
1058
1148
  {
1059
- const noteParsed = parseNoteLine(trimmed, result.participants, lastMsgFrom);
1149
+ const noteParsed = parseNoteLine(
1150
+ trimmed,
1151
+ result.participants,
1152
+ lastMsgFrom
1153
+ );
1060
1154
  if (noteParsed) {
1061
1155
  if (noteParsed.kind === 'single') {
1062
1156
  const note: SequenceNote = {
@@ -1098,6 +1192,9 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1098
1192
  continue;
1099
1193
  }
1100
1194
  }
1195
+
1196
+ // Catch-all: nothing matched this line
1197
+ pushWarning(lineNumber, `Unexpected line: '${trimmed}'.`);
1101
1198
  }
1102
1199
 
1103
1200
  // Validate: if no explicit chart line, check for arrow-based inference
@@ -1136,7 +1233,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1136
1233
 
1137
1234
  for (const p of result.participants) {
1138
1235
  if (!usedIds.has(p.id)) {
1139
- pushWarning(p.lineNumber, `Participant "${p.label}" is declared but never used in any message or note`);
1236
+ pushWarning(
1237
+ p.lineNumber,
1238
+ `Participant "${p.label}" is declared but never used in any message or note`
1239
+ );
1140
1240
  }
1141
1241
  }
1142
1242
  }
@@ -1144,21 +1244,30 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
1144
1244
  // Warn about empty groups
1145
1245
  for (const group of result.groups) {
1146
1246
  if (group.participantIds.length === 0) {
1147
- pushWarning(group.lineNumber, `Empty group '${group.name}' — did you mean '== ${group.name} ==' for a section divider?`);
1247
+ pushWarning(
1248
+ group.lineNumber,
1249
+ `Empty group '${group.name}' — did you mean '== ${group.name} ==' for a section divider?`
1250
+ );
1148
1251
  }
1149
1252
  }
1150
1253
 
1151
1254
  // Validate tag group values on participants and messages
1152
1255
  if (result.tagGroups.length > 0) {
1153
- const entities: Array<{ metadata: Record<string, string>; lineNumber: number }> = [];
1256
+ const entities: Array<{
1257
+ metadata: Record<string, string>;
1258
+ lineNumber: number;
1259
+ }> = [];
1154
1260
  for (const p of result.participants) {
1155
- if (p.metadata) entities.push({ metadata: p.metadata, lineNumber: p.lineNumber });
1261
+ if (p.metadata)
1262
+ entities.push({ metadata: p.metadata, lineNumber: p.lineNumber });
1156
1263
  }
1157
1264
  for (const m of result.messages) {
1158
- if (m.metadata) entities.push({ metadata: m.metadata, lineNumber: m.lineNumber });
1265
+ if (m.metadata)
1266
+ entities.push({ metadata: m.metadata, lineNumber: m.lineNumber });
1159
1267
  }
1160
1268
  for (const g of result.groups) {
1161
- if (g.metadata) entities.push({ metadata: g.metadata, lineNumber: g.lineNumber });
1269
+ if (g.metadata)
1270
+ entities.push({ metadata: g.metadata, lineNumber: g.lineNumber });
1162
1271
  }
1163
1272
  validateTagValues(entities, result.tagGroups, pushWarning, suggest);
1164
1273
  }