@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.
- package/README.md +213 -57
- package/dist/cli.cjs +91 -85
- package/dist/index.cjs +1362 -194
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -2
- package/dist/index.d.ts +108 -2
- package/dist/index.js +1357 -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 +221 -37
- package/src/sequence/renderer.ts +342 -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,23 @@ 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
|
+
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"
|
|
127
|
-
const GROUP_HEADING_PATTERN = /^##\s+(
|
|
152
|
+
// Group heading pattern — "## Backend", "## API Services(blue)", "## Backend(#hex)"
|
|
153
|
+
const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
|
|
128
154
|
|
|
129
|
-
// Section divider pattern — "== Label =="
|
|
130
|
-
const SECTION_PATTERN = /^==\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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
|
|
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:
|
|
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('
|
|
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(/^(.+?)\((
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
504
|
+
let isAsync = false;
|
|
505
|
+
const asyncArrowMatch = trimmed.match(
|
|
413
506
|
/^(\S+)\s*~>\s*([^\s:]+)\s*(?::\s*(.+))?$/
|
|
414
507
|
);
|
|
415
|
-
const syncArrowMatch =
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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('
|
|
730
|
+
if (trimmed.startsWith('//')) return false;
|
|
547
731
|
return ARROW_PATTERN.test(trimmed);
|
|
548
732
|
});
|
|
549
733
|
}
|