@diagrammo/dgmo 0.2.6 → 0.2.8

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.
@@ -63,12 +63,18 @@ export interface SequenceMessage {
63
63
  /**
64
64
  * A conditional or loop block in the sequence diagram.
65
65
  */
66
+ export interface ElseIfBranch {
67
+ label: string;
68
+ children: SequenceElement[];
69
+ }
70
+
66
71
  export interface SequenceBlock {
67
72
  kind: 'block';
68
73
  type: 'if' | 'loop' | 'parallel';
69
74
  label: string;
70
75
  children: SequenceElement[];
71
76
  elseChildren: SequenceElement[];
77
+ elseIfBranches?: ElseIfBranch[];
72
78
  lineNumber: number;
73
79
  }
74
80
 
@@ -82,7 +88,23 @@ export interface SequenceSection {
82
88
  lineNumber: number;
83
89
  }
84
90
 
85
- export type SequenceElement = SequenceMessage | SequenceBlock | SequenceSection;
91
+ /**
92
+ * An annotation attached to a message, rendered as a folded-corner box.
93
+ */
94
+ export interface SequenceNote {
95
+ kind: 'note';
96
+ text: string;
97
+ position: 'right' | 'left';
98
+ participantId: string;
99
+ lineNumber: number;
100
+ endLineNumber: number;
101
+ }
102
+
103
+ export type SequenceElement =
104
+ | SequenceMessage
105
+ | SequenceBlock
106
+ | SequenceSection
107
+ | SequenceNote;
86
108
 
87
109
  export function isSequenceBlock(el: SequenceElement): el is SequenceBlock {
88
110
  return 'kind' in el && (el as SequenceBlock).kind === 'block';
@@ -92,6 +114,10 @@ export function isSequenceSection(el: SequenceElement): el is SequenceSection {
92
114
  return 'kind' in el && (el as SequenceSection).kind === 'section';
93
115
  }
94
116
 
117
+ export function isSequenceNote(el: SequenceElement): el is SequenceNote {
118
+ return 'kind' in el && (el as SequenceNote).kind === 'note';
119
+ }
120
+
95
121
  /**
96
122
  * A named group of participants rendered as a labeled box.
97
123
  */
@@ -123,11 +149,11 @@ const IS_A_PATTERN = /^(\S+)\s+is\s+an?\s+(\w+)(?:\s+(.+))?$/i;
123
149
  // Standalone "Name position N" pattern — e.g. "DB position -1"
124
150
  const POSITION_ONLY_PATTERN = /^(\S+)\s+position\s+(-?\d+)$/i;
125
151
 
126
- // Group heading pattern — "## Backend" or "## Backend(blue)"
127
- const GROUP_HEADING_PATTERN = /^##\s+(\S+?)(?:\((\w+)\))?$/;
152
+ // Group heading pattern — "## Backend", "## API Services(blue)", "## Backend(#hex)"
153
+ const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
128
154
 
129
- // Section divider pattern — "== Label ==" or "== Label(color) =="
130
- const SECTION_PATTERN = /^==\s+(.+?)\s*==$/;
155
+ // Section divider pattern — "== Label ==", "== Label(color) ==", or "== Label" (trailing == optional)
156
+ const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
131
157
 
132
158
  // Arrow pattern for sequence inference — "A -> B: message" or "A ~> B: message"
133
159
  const ARROW_PATTERN = /\S+\s*(?:->|~>)\s*\S+/;
@@ -138,6 +164,10 @@ const ARROW_RETURN_PATTERN = /^(.+?)\s*<-\s*(.+)$/;
138
164
  // UML method(args): returnType syntax: "getUser(id): UserObj"
139
165
  const UML_RETURN_PATTERN = /^(\w+\([^)]*\))\s*:\s*(.+)$/;
140
166
 
167
+ // Note patterns — "note: text", "note right of API: text", "note left of User"
168
+ const NOTE_SINGLE = /^note(?:\s+(right|left)\s+of\s+(\S+))?\s*:\s*(.+)$/i;
169
+ const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+([^\s:]+))?\s*:?\s*$/i;
170
+
141
171
  /**
142
172
  * Extract return label from a message label string.
143
173
  * Priority: `<-` syntax first, then UML `method(): return` syntax,
@@ -161,13 +191,17 @@ function parseReturnLabel(rawLabel: string): {
161
191
  return { label: umlReturn[1].trim(), returnLabel: umlReturn[2].trim() };
162
192
  }
163
193
 
164
- // Shorthand request : response syntax (split on last " : ")
165
- const lastSep = rawLabel.lastIndexOf(' : ');
166
- if (lastSep > 0) {
167
- const reqPart = rawLabel.substring(0, lastSep).trim();
168
- const resPart = rawLabel.substring(lastSep + 3).trim();
169
- if (reqPart && resPart) {
170
- return { label: reqPart, returnLabel: resPart };
194
+ // Shorthand colon return syntax (split on last ":")
195
+ // Skip if the colon is part of a URL scheme (followed by //)
196
+ const lastColon = rawLabel.lastIndexOf(':');
197
+ if (lastColon > 0 && lastColon < rawLabel.length - 1) {
198
+ const afterColon = rawLabel.substring(lastColon + 1);
199
+ if (!afterColon.startsWith('//')) {
200
+ const reqPart = rawLabel.substring(0, lastColon).trim();
201
+ const resPart = afterColon.trim();
202
+ if (reqPart && resPart) {
203
+ return { label: reqPart, returnLabel: resPart };
204
+ }
171
205
  }
172
206
  }
173
207
 
@@ -209,22 +243,31 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
209
243
 
210
244
  const lines = content.split('\n');
211
245
  let hasExplicitChart = false;
246
+ let contentStarted = false;
212
247
 
213
248
  // Group parsing state — tracks the active ## group heading
214
249
  let activeGroup: SequenceGroup | null = null;
215
250
 
251
+ // Track participant → group name for duplicate membership detection
252
+ const participantGroupMap = new Map<string, string>();
253
+
216
254
  // Block parsing state
217
255
  const blockStack: {
218
256
  block: SequenceBlock;
219
257
  indent: number;
220
258
  inElse: boolean;
259
+ activeElseIfBranch?: ElseIfBranch;
221
260
  }[] = [];
222
261
  const currentContainer = (): SequenceElement[] => {
223
262
  if (blockStack.length === 0) return result.elements;
224
263
  const top = blockStack[blockStack.length - 1];
264
+ if (top.activeElseIfBranch) return top.activeElseIfBranch.children;
225
265
  return top.inElse ? top.block.elseChildren : top.block.children;
226
266
  };
227
267
 
268
+ // Track last message sender for default note positioning
269
+ let lastMsgFrom: string | null = null;
270
+
228
271
  for (let i = 0; i < lines.length; i++) {
229
272
  const raw = lines[i];
230
273
  const trimmed = raw.trim();
@@ -239,9 +282,15 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
239
282
  // Parse group heading — must be checked before comment skip since ## starts with #
240
283
  const groupMatch = trimmed.match(GROUP_HEADING_PATTERN);
241
284
  if (groupMatch) {
285
+ const groupColor = groupMatch[2]?.trim();
286
+ if (groupColor && groupColor.startsWith('#')) {
287
+ result.error = `Line ${lineNumber}: Use a named color instead of hex (e.g., blue, red, teal)`;
288
+ return result;
289
+ }
290
+ contentStarted = true;
242
291
  activeGroup = {
243
- name: groupMatch[1],
244
- color: groupMatch[2] || undefined,
292
+ name: groupMatch[1].trim(),
293
+ color: groupColor || undefined,
245
294
  participantIds: [],
246
295
  lineNumber,
247
296
  };
@@ -254,8 +303,14 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
254
303
  activeGroup = null;
255
304
  }
256
305
 
257
- // Skip comments
258
- if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
306
+ // Skip comments — only // is supported
307
+ if (trimmed.startsWith('//')) continue;
308
+
309
+ // Reject # as comment syntax (## is for group headings, handled above)
310
+ if (trimmed.startsWith('#') && !trimmed.startsWith('##')) {
311
+ result.error = `Line ${lineNumber}: Use // for comments. # is reserved for group headings (##)`;
312
+ return result;
313
+ }
259
314
 
260
315
  // Parse section dividers — "== Label ==" or "== Label(color) =="
261
316
  // Close blocks first — sections at indent 0 should not nest inside blocks
@@ -268,11 +323,16 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
268
323
  blockStack.pop();
269
324
  }
270
325
  const labelRaw = sectionMatch[1].trim();
271
- const colorMatch = labelRaw.match(/^(.+?)\((\w+)\)$/);
326
+ const colorMatch = labelRaw.match(/^(.+?)\(([^)]+)\)$/);
327
+ if (colorMatch && colorMatch[2].trim().startsWith('#')) {
328
+ result.error = `Line ${lineNumber}: Use a named color instead of hex (e.g., blue, red, teal)`;
329
+ return result;
330
+ }
331
+ contentStarted = true;
272
332
  const section: SequenceSection = {
273
333
  kind: 'section',
274
334
  label: colorMatch ? colorMatch[1].trim() : labelRaw,
275
- color: colorMatch ? colorMatch[2] : undefined,
335
+ color: colorMatch ? colorMatch[2].trim() : undefined,
276
336
  lineNumber,
277
337
  };
278
338
  result.sections.push(section);
@@ -281,9 +341,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
281
341
  }
282
342
 
283
343
  // Parse header key: value lines (always top-level)
344
+ // Skip 'note' lines — parsed in the indent-aware section below
284
345
  const colonIndex = trimmed.indexOf(':');
285
346
  if (colonIndex > 0 && !trimmed.includes('->') && !trimmed.includes('~>')) {
286
347
  const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
348
+ if (key === 'note' || key.startsWith('note ')) {
349
+ // Fall through to indent-aware note parsing below
350
+ } else {
287
351
  const value = trimmed.substring(colonIndex + 1).trim();
288
352
 
289
353
  if (key === 'chart') {
@@ -295,6 +359,12 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
295
359
  continue;
296
360
  }
297
361
 
362
+ // Enforce headers-before-content
363
+ if (contentStarted) {
364
+ result.error = `Line ${lineNumber}: Options like '${key}: ${value}' must appear before the first message or declaration`;
365
+ return result;
366
+ }
367
+
298
368
  if (key === 'title') {
299
369
  result.title = value;
300
370
  continue;
@@ -303,11 +373,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
303
373
  // Store other options
304
374
  result.options[key] = value;
305
375
  continue;
376
+ }
306
377
  }
307
378
 
308
379
  // Parse "Name is a type [aka Alias]" declarations (always top-level)
309
380
  const isAMatch = trimmed.match(IS_A_PATTERN);
310
381
  if (isAMatch) {
382
+ contentStarted = true;
311
383
  const id = isAMatch[1];
312
384
  const typeStr = isAMatch[2].toLowerCase();
313
385
  const remainder = isAMatch[3]?.trim() || '';
@@ -338,7 +410,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
338
410
  }
339
411
  // Track group membership
340
412
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
413
+ const existingGroup = participantGroupMap.get(id);
414
+ if (existingGroup) {
415
+ result.error = `Line ${lineNumber}: Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`;
416
+ return result;
417
+ }
341
418
  activeGroup.participantIds.push(id);
419
+ participantGroupMap.set(id, activeGroup.name);
342
420
  }
343
421
  continue;
344
422
  }
@@ -346,6 +424,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
346
424
  // Parse standalone "Name position N" (no "is a" type)
347
425
  const posOnlyMatch = trimmed.match(POSITION_ONLY_PATTERN);
348
426
  if (posOnlyMatch) {
427
+ contentStarted = true;
349
428
  const id = posOnlyMatch[1];
350
429
  const position = parseInt(posOnlyMatch[2], 10);
351
430
 
@@ -360,13 +439,20 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
360
439
  }
361
440
  // Track group membership
362
441
  if (activeGroup && !activeGroup.participantIds.includes(id)) {
442
+ const existingGroup = participantGroupMap.get(id);
443
+ if (existingGroup) {
444
+ result.error = `Line ${lineNumber}: Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`;
445
+ return result;
446
+ }
363
447
  activeGroup.participantIds.push(id);
448
+ participantGroupMap.set(id, activeGroup.name);
364
449
  }
365
450
  continue;
366
451
  }
367
452
 
368
453
  // Bare participant name inside an active group (single identifier on an indented line)
369
454
  if (activeGroup && measureIndent(raw) > 0 && /^\S+$/.test(trimmed)) {
455
+ contentStarted = true;
370
456
  const id = trimmed;
371
457
  if (!result.participants.some((p) => p.id === id)) {
372
458
  result.participants.push({
@@ -377,7 +463,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
377
463
  });
378
464
  }
379
465
  if (!activeGroup.participantIds.includes(id)) {
466
+ const existingGroup = participantGroupMap.get(id);
467
+ if (existingGroup) {
468
+ result.error = `Line ${lineNumber}: Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`;
469
+ return result;
470
+ }
380
471
  activeGroup.participantIds.push(id);
472
+ participantGroupMap.set(id, activeGroup.name);
381
473
  }
382
474
  continue;
383
475
  }
@@ -389,38 +481,41 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
389
481
  while (blockStack.length > 0) {
390
482
  const top = blockStack[blockStack.length - 1];
391
483
  if (indent > top.indent) break;
484
+ // Keep block on stack when 'else' or 'else if' matches current indent — handled below
392
485
  if (
393
486
  indent === top.indent &&
394
- trimmed.toLowerCase() === 'else' &&
395
- top.block.type === 'if'
396
- )
397
- break;
487
+ (top.block.type === 'if' || top.block.type === 'parallel')
488
+ ) {
489
+ const lower = trimmed.toLowerCase();
490
+ if (lower === 'else' || lower.startsWith('else if ')) break;
491
+ }
398
492
  blockStack.pop();
399
493
  }
400
494
 
401
495
  // Parse message lines first — arrows take priority over keywords
402
- // Detect async prefix: "async A -> B: msg"
403
- let isAsync = false;
404
- let arrowLine = trimmed;
496
+ // Reject "async" keyword prefix use ~> instead
405
497
  const asyncPrefixMatch = trimmed.match(/^async\s+(.+)$/i);
406
- if (asyncPrefixMatch) {
407
- isAsync = true;
408
- arrowLine = asyncPrefixMatch[1];
498
+ if (asyncPrefixMatch && ARROW_PATTERN.test(asyncPrefixMatch[1])) {
499
+ result.error = `Line ${lineNumber}: Use ~> for async messages: A ~> B: message`;
500
+ return result;
409
501
  }
410
502
 
411
503
  // Match ~> (async arrow) or -> (sync arrow)
412
- const asyncArrowMatch = arrowLine.match(
504
+ let isAsync = false;
505
+ const asyncArrowMatch = trimmed.match(
413
506
  /^(\S+)\s*~>\s*([^\s:]+)\s*(?::\s*(.+))?$/
414
507
  );
415
- const syncArrowMatch = arrowLine.match(
508
+ const syncArrowMatch = trimmed.match(
416
509
  /^(\S+)\s*->\s*([^\s:]+)\s*(?::\s*(.+))?$/
417
510
  );
418
511
  const arrowMatch = asyncArrowMatch || syncArrowMatch;
419
512
  if (asyncArrowMatch) isAsync = true;
420
513
 
421
514
  if (arrowMatch) {
515
+ contentStarted = true;
422
516
  const from = arrowMatch[1];
423
517
  const to = arrowMatch[2];
518
+ lastMsgFrom = from;
424
519
  const rawLabel = arrowMatch[3]?.trim() || '';
425
520
 
426
521
  // Extract return label — skip for async messages
@@ -462,6 +557,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
462
557
  // Parse 'if <label>' block keyword
463
558
  const ifMatch = trimmed.match(/^if\s+(.+)$/i);
464
559
  if (ifMatch) {
560
+ contentStarted = true;
465
561
  const block: SequenceBlock = {
466
562
  kind: 'block',
467
563
  type: 'if',
@@ -478,6 +574,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
478
574
  // Parse 'loop <label>' block keyword
479
575
  const loopMatch = trimmed.match(/^loop\s+(.+)$/i);
480
576
  if (loopMatch) {
577
+ contentStarted = true;
481
578
  const block: SequenceBlock = {
482
579
  kind: 'block',
483
580
  type: 'loop',
@@ -494,6 +591,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
494
591
  // Parse 'parallel [label]' block keyword
495
592
  const parallelMatch = trimmed.match(/^parallel(?:\s+(.+))?$/i);
496
593
  if (parallelMatch) {
594
+ contentStarted = true;
497
595
  const block: SequenceBlock = {
498
596
  kind: 'block',
499
597
  type: 'parallel',
@@ -507,15 +605,101 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
507
605
  continue;
508
606
  }
509
607
 
608
+ // Parse 'else if <label>' keyword (must come before bare 'else')
609
+ const elseIfMatch = trimmed.match(/^else\s+if\s+(.+)$/i);
610
+ if (elseIfMatch) {
611
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
612
+ const top = blockStack[blockStack.length - 1];
613
+ if (top.block.type === 'parallel') {
614
+ result.error = `Line ${lineNumber}: parallel blocks don't support else if — list all concurrent messages directly inside the block`;
615
+ return result;
616
+ }
617
+ if (top.block.type === 'if') {
618
+ const branch: ElseIfBranch = { label: elseIfMatch[1].trim(), children: [] };
619
+ if (!top.block.elseIfBranches) top.block.elseIfBranches = [];
620
+ top.block.elseIfBranches.push(branch);
621
+ top.activeElseIfBranch = branch;
622
+ top.inElse = false;
623
+ }
624
+ }
625
+ continue;
626
+ }
627
+
510
628
  // Parse 'else' keyword (only applies to 'if' blocks)
511
629
  if (trimmed.toLowerCase() === 'else') {
512
- if (
513
- blockStack.length > 0 &&
514
- blockStack[blockStack.length - 1].indent === indent &&
515
- blockStack[blockStack.length - 1].block.type === 'if'
516
- ) {
517
- blockStack[blockStack.length - 1].inElse = true;
630
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].indent === indent) {
631
+ const top = blockStack[blockStack.length - 1];
632
+ if (top.block.type === 'parallel') {
633
+ result.error = `Line ${lineNumber}: parallel blocks don't support else — list all concurrent messages directly inside the block`;
634
+ return result;
635
+ }
636
+ if (top.block.type === 'if') {
637
+ top.inElse = true;
638
+ top.activeElseIfBranch = undefined;
639
+ }
640
+ }
641
+ continue;
642
+ }
643
+
644
+ // Parse single-line note — "note: text" or "note right of API: text"
645
+ const noteSingleMatch = trimmed.match(NOTE_SINGLE);
646
+ if (noteSingleMatch) {
647
+ const notePosition =
648
+ (noteSingleMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
649
+ let noteParticipant = noteSingleMatch[2] || null;
650
+ if (!noteParticipant) {
651
+ if (!lastMsgFrom) continue; // incomplete — skip during live typing
652
+ noteParticipant = lastMsgFrom;
653
+ }
654
+ if (!result.participants.some((p) => p.id === noteParticipant)) {
655
+ continue; // unknown participant — skip during live typing
656
+ }
657
+ const note: SequenceNote = {
658
+ kind: 'note',
659
+ text: noteSingleMatch[3].trim(),
660
+ position: notePosition,
661
+ participantId: noteParticipant,
662
+ lineNumber,
663
+ endLineNumber: lineNumber,
664
+ };
665
+ currentContainer().push(note);
666
+ continue;
667
+ }
668
+
669
+ // Parse multi-line note — "note" or "note right of API" (no colon, body indented below)
670
+ const noteMultiMatch = trimmed.match(NOTE_MULTI);
671
+ if (noteMultiMatch) {
672
+ const notePosition =
673
+ (noteMultiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
674
+ let noteParticipant = noteMultiMatch[2] || null;
675
+ if (!noteParticipant) {
676
+ if (!lastMsgFrom) continue; // incomplete — skip during live typing
677
+ noteParticipant = lastMsgFrom;
518
678
  }
679
+ if (!result.participants.some((p) => p.id === noteParticipant)) {
680
+ continue; // unknown participant — skip during live typing
681
+ }
682
+ // Collect indented body lines
683
+ const noteLines: string[] = [];
684
+ while (i + 1 < lines.length) {
685
+ const nextRaw = lines[i + 1];
686
+ const nextTrimmed = nextRaw.trim();
687
+ if (!nextTrimmed) break;
688
+ const nextIndent = measureIndent(nextRaw);
689
+ if (nextIndent <= indent) break;
690
+ noteLines.push(nextTrimmed);
691
+ i++;
692
+ }
693
+ if (noteLines.length === 0) continue; // no body yet — skip during live typing
694
+ const note: SequenceNote = {
695
+ kind: 'note',
696
+ text: noteLines.join('\n'),
697
+ position: notePosition,
698
+ participantId: noteParticipant,
699
+ lineNumber,
700
+ endLineNumber: i + 1, // i has advanced past the body lines (1-based)
701
+ };
702
+ currentContainer().push(note);
519
703
  continue;
520
704
  }
521
705
  }
@@ -543,7 +727,7 @@ export function looksLikeSequence(content: string): boolean {
543
727
  const lines = content.split('\n');
544
728
  return lines.some((line) => {
545
729
  const trimmed = line.trim();
546
- if (trimmed.startsWith('#') || trimmed.startsWith('//')) return false;
730
+ if (trimmed.startsWith('//')) return false;
547
731
  return ARROW_PATTERN.test(trimmed);
548
732
  });
549
733
  }