@diagrammo/dgmo 0.2.5 → 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.
- package/README.md +213 -57
- package/dist/cli.cjs +92 -86
- package/dist/index.cjs +1337 -194
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +107 -2
- package/dist/index.d.ts +107 -2
- package/dist/index.js +1332 -193
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/d3.ts +59 -1
- package/src/dgmo-router.ts +5 -1
- package/src/echarts.ts +2 -2
- package/src/graph/flowchart-parser.ts +499 -0
- package/src/graph/flowchart-renderer.ts +503 -0
- package/src/graph/layout.ts +222 -0
- package/src/graph/types.ts +44 -0
- package/src/index.ts +24 -0
- package/src/sequence/parser.ts +229 -37
- package/src/sequence/renderer.ts +310 -16
package/src/sequence/parser.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
127
|
-
const GROUP_HEADING_PATTERN = /^##\s+(
|
|
151
|
+
// Group heading pattern — "## Backend", "## API Services(blue)", "## Backend(#hex)"
|
|
152
|
+
const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
|
|
128
153
|
|
|
129
|
-
// Section divider pattern — "== Label =="
|
|
130
|
-
const SECTION_PATTERN = /^==\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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
|
|
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:
|
|
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('
|
|
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(/^(.+?)\((
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
503
|
+
let isAsync = false;
|
|
504
|
+
const asyncArrowMatch = trimmed.match(
|
|
413
505
|
/^(\S+)\s*~>\s*([^\s:]+)\s*(?::\s*(.+))?$/
|
|
414
506
|
);
|
|
415
|
-
const syncArrowMatch =
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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('
|
|
738
|
+
if (trimmed.startsWith('//')) return false;
|
|
547
739
|
return ARROW_PATTERN.test(trimmed);
|
|
548
740
|
});
|
|
549
741
|
}
|