@diagrammo/dgmo 0.2.6 → 0.2.7

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