@diagrammo/dgmo 0.2.28 → 0.3.1

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.
@@ -44,6 +44,41 @@ Set via CLI: `dgmo diagram.dgmo --palette catppuccin --theme dark`
44
44
 
45
45
  Text fields support: `*italic*`, `**bold**`, `` `code` ``, `[link text](url)`. Bare URLs are auto-linked.
46
46
 
47
+ ### Multi-line Values
48
+
49
+ Properties that accept comma-separated lists (`series`, `columns`, `rows`, `x-axis`, `y-axis`) also accept an indented multi-line format. Leave the value after the colon empty and list each value on its own indented line:
50
+
51
+ ```
52
+ // Single-line (still works)
53
+ series: Rum, Spices, Silk, Gold
54
+
55
+ // Multi-line equivalent
56
+ series:
57
+ Rum
58
+ Spices
59
+ Silk
60
+ Gold
61
+ ```
62
+
63
+ Multi-line blocks support blank lines and `//` comments within the block. Trailing commas on values are stripped for convenience.
64
+
65
+ ```
66
+ series:
67
+ Rum (red)
68
+ Spices (green)
69
+ // gold last
70
+ Gold (yellow)
71
+ ```
72
+
73
+ Works with `columns:` and `rows:` in heatmaps:
74
+
75
+ ```
76
+ columns:
77
+ January
78
+ February
79
+ March
80
+ ```
81
+
47
82
  ---
48
83
 
49
84
  ## Chart Types
@@ -441,7 +476,10 @@ Animal
441
476
  + speak(): void
442
477
 
443
478
  Dog extends Animal
479
+ + breed: string
480
+
444
481
  Cat extends Animal
482
+ + indoor: boolean
445
483
  ```
446
484
 
447
485
  Full example:
@@ -453,38 +491,37 @@ title: Type Hierarchy
453
491
  Printable [interface]
454
492
  + print(): void
455
493
 
456
- Shape [abstract]
494
+ Shape implements Printable [abstract]
457
495
  # x: number
458
496
  # y: number
459
497
  + area(): number
460
- {static} count: number
498
+ count: number {static}
461
499
 
462
- Circle
500
+ Circle extends Shape
463
501
  - radius: number
464
502
  + area(): number
465
503
 
466
- Rectangle
504
+ Rectangle extends Shape
467
505
  - width: number
468
506
  - height: number
469
507
 
470
- Circle extends Shape
471
- Rectangle extends Shape
472
- Shape implements Printable
473
508
  Shape *-- Circle : contains
474
509
  ```
475
510
 
476
511
  **Class modifiers**: `[abstract]`, `[interface]`, `[enum]`
477
512
 
513
+ **Inheritance**: `ClassName extends Parent` or `ClassName implements Interface` — declared inline in the class header. Members are indented below.
514
+
478
515
  **Member visibility**: `+` public, `#` protected, `-` private. Static: `{static}`.
479
516
 
480
- **Relationships** (keyword or arrow):
481
- - Inheritance: `A extends B` or `A --|> B`
482
- - Implementation: `A implements B` or `A ..|> B`
483
- - Composition: `A contains B` or `A *-- B`
484
- - Aggregation: `A has B` or `A o-- B`
485
- - Dependency: `A uses B` or `A ..> B`
517
+ **Relationships** (arrow syntax):
518
+ - Inheritance: `A --|> B`
519
+ - Implementation: `A ..|> B`
520
+ - Composition: `A *-- B`
521
+ - Aggregation: `A o-- B`
522
+ - Dependency: `A ..> B`
486
523
  - Association: `A -> B`
487
- - Optional label: `A extends B : description`
524
+ - Optional label: `A *-- B : description`
488
525
 
489
526
  ### er
490
527
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.2.28",
3
+ "version": "0.3.1",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,11 +50,11 @@
50
50
  "d3-scale": "^4.0.2",
51
51
  "d3-selection": "^3.0.0",
52
52
  "d3-shape": "^3.2.0",
53
- "echarts": "^5.6.0",
53
+ "echarts": "^6.0.0",
54
54
  "lz-string": "^1.5.0"
55
55
  },
56
56
  "optionalDependencies": {
57
- "jsdom": "^26.0.0"
57
+ "jsdom": "^28.1.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/d3-array": "^3.2.1",
@@ -63,11 +63,11 @@
63
63
  "@types/d3-scale": "^4.0.8",
64
64
  "@types/d3-selection": "^3.0.11",
65
65
  "@types/d3-shape": "^3.1.7",
66
- "@types/dagre": "^0.7.53",
67
- "@types/jsdom": "^21.1.7",
66
+ "@types/dagre": "^0.7.54",
67
+ "@types/jsdom": "^28.0.0",
68
68
  "jscpd": "^4.0.8",
69
69
  "tsup": "^8.5.1",
70
70
  "typescript": "^5.7.3",
71
- "vitest": "^3.0.0"
71
+ "vitest": "^4.0.18"
72
72
  }
73
73
  }
package/src/chart.ts CHANGED
@@ -47,6 +47,7 @@ export interface ParsedChart {
47
47
  import { resolveColor } from './colors';
48
48
  import type { PaletteColors } from './palettes';
49
49
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
50
+ import { collectIndentedValues } from './utils/parsing';
50
51
 
51
52
  // ============================================================
52
53
  // Parser
@@ -181,12 +182,17 @@ export function parseChart(
181
182
  }
182
183
 
183
184
  if (key === 'series') {
184
- result.series = value;
185
- // Parse comma-separated series names for multi-series chart types
186
- const rawNames = value
187
- .split(',')
188
- .map((s) => s.trim())
189
- .filter(Boolean);
185
+ // Parse series names — comma-separated on one line, or indented multi-line
186
+ let rawNames: string[];
187
+ if (value) {
188
+ result.series = value;
189
+ rawNames = value.split(',').map((s) => s.trim()).filter(Boolean);
190
+ } else {
191
+ const collected = collectIndentedValues(lines, i);
192
+ i = collected.newIndex;
193
+ rawNames = collected.values;
194
+ result.series = rawNames.join(', ');
195
+ }
190
196
  const names: string[] = [];
191
197
  const nameColors: (string | undefined)[] = [];
192
198
  for (const raw of rawNames) {
@@ -87,7 +87,7 @@ function computeNodeDimensions(node: ClassNode): {
87
87
  const headerHeight = HEADER_BASE + (node.modifier ? MODIFIER_BADGE : 0);
88
88
 
89
89
  // Fields compartment
90
- let fieldsHeight = 0;
90
+ let fieldsHeight: number;
91
91
  if (isEnum) {
92
92
  // Enum values go in fields compartment
93
93
  const enumValues = node.members; // all members are enum values
@@ -96,6 +96,8 @@ function computeNodeDimensions(node: ClassNode): {
96
96
  COMPARTMENT_PADDING_Y * 2 +
97
97
  enumValues.length * MEMBER_LINE_HEIGHT +
98
98
  SEPARATOR_HEIGHT;
99
+ } else {
100
+ fieldsHeight = SEPARATOR_HEIGHT + COMPARTMENT_PADDING_Y;
99
101
  }
100
102
  } else {
101
103
  if (fields.length > 0) {
@@ -103,24 +105,27 @@ function computeNodeDimensions(node: ClassNode): {
103
105
  COMPARTMENT_PADDING_Y * 2 +
104
106
  fields.length * MEMBER_LINE_HEIGHT +
105
107
  SEPARATOR_HEIGHT;
108
+ } else {
109
+ // UML: always show attributes compartment
110
+ fieldsHeight = SEPARATOR_HEIGHT + COMPARTMENT_PADDING_Y;
106
111
  }
107
112
  }
108
113
 
109
114
  // Methods compartment (not for enums)
110
115
  let methodsHeight = 0;
111
- if (!isEnum && methods.length > 0) {
112
- methodsHeight =
113
- COMPARTMENT_PADDING_Y * 2 +
114
- methods.length * MEMBER_LINE_HEIGHT +
115
- SEPARATOR_HEIGHT;
116
+ if (!isEnum) {
117
+ if (methods.length > 0) {
118
+ methodsHeight =
119
+ COMPARTMENT_PADDING_Y * 2 +
120
+ methods.length * MEMBER_LINE_HEIGHT +
121
+ SEPARATOR_HEIGHT;
122
+ } else {
123
+ // UML: always show methods compartment
124
+ methodsHeight = SEPARATOR_HEIGHT + COMPARTMENT_PADDING_Y;
125
+ }
116
126
  }
117
127
 
118
- // If no members at all, add minimal padding
119
- const height =
120
- headerHeight +
121
- fieldsHeight +
122
- methodsHeight +
123
- (fieldsHeight === 0 && methodsHeight === 0 ? 4 : 0);
128
+ const height = headerHeight + fieldsHeight + methodsHeight;
124
129
 
125
130
  return { width, height, headerHeight, fieldsHeight, methodsHeight };
126
131
  }
@@ -6,7 +6,6 @@ import type {
6
6
  ParsedClassDiagram,
7
7
  ClassNode,
8
8
  ClassMember,
9
- ClassRelationship,
10
9
  ClassModifier,
11
10
  MemberVisibility,
12
11
  RelationshipType,
@@ -24,14 +23,9 @@ function classId(name: string): string {
24
23
  // Regex patterns
25
24
  // ============================================================
26
25
 
27
- // Class declaration: ClassName [modifier] (color)
26
+ // Class declaration: ClassName [extends|implements ParentClass] [modifier] (color)
28
27
  const CLASS_DECL_RE =
29
- /^([A-Z][A-Za-z0-9_]*)(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
30
-
31
- // Relationship — keyword syntax:
32
- // ClassName extends|implements|contains|has|uses TargetClass : label
33
- const REL_KEYWORD_RE =
34
- /^([A-Z][A-Za-z0-9_]*)\s+(extends|implements|contains|has|uses)\s+([A-Z][A-Za-z0-9_]*)(?:\s*:\s*(.+))?$/;
28
+ /^([A-Z][A-Za-z0-9_]*)(?:\s+(extends|implements)\s+([A-Z][A-Za-z0-9_]*))?(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
35
29
 
36
30
  // Relationship — arrow syntax:
37
31
  // ClassName --|> TargetClass : label
@@ -45,14 +39,6 @@ const STATIC_SUFFIX_RE = /\{static\}\s*$/;
45
39
  const METHOD_RE = /^(.+?)\(([^)]*)\)(?:\s*:\s*(.+))?$/;
46
40
  const FIELD_RE = /^(.+?)\s*:\s*(.+)$/;
47
41
 
48
- const KEYWORD_TO_TYPE: Record<string, RelationshipType> = {
49
- extends: 'extends',
50
- implements: 'implements',
51
- contains: 'composes',
52
- has: 'aggregates',
53
- uses: 'depends',
54
- };
55
-
56
42
  const ARROW_TO_TYPE: Record<string, RelationshipType> = {
57
43
  '--|>': 'extends',
58
44
  '..|>': 'implements',
@@ -257,28 +243,6 @@ export function parseClassDiagram(
257
243
  currentClass = null;
258
244
  contentStarted = true;
259
245
 
260
- // Try relationship — keyword syntax
261
- const relKeyword = trimmed.match(REL_KEYWORD_RE);
262
- if (relKeyword) {
263
- const sourceName = relKeyword[1];
264
- const keyword = relKeyword[2].toLowerCase();
265
- const targetName = relKeyword[3];
266
- const label = relKeyword[4]?.trim();
267
-
268
- // Ensure both classes exist
269
- getOrCreateClass(sourceName, lineNumber);
270
- getOrCreateClass(targetName, lineNumber);
271
-
272
- result.relationships.push({
273
- source: classId(sourceName),
274
- target: classId(targetName),
275
- type: KEYWORD_TO_TYPE[keyword],
276
- ...(label && { label }),
277
- lineNumber,
278
- });
279
- continue;
280
- }
281
-
282
246
  // Try relationship — arrow syntax
283
247
  const relArrow = trimmed.match(REL_ARROW_RE);
284
248
  if (relArrow) {
@@ -305,8 +269,10 @@ export function parseClassDiagram(
305
269
  const classDecl = trimmed.match(CLASS_DECL_RE);
306
270
  if (classDecl) {
307
271
  const name = classDecl[1];
308
- const modifier = classDecl[2] as ClassModifier | undefined;
309
- const colorName = classDecl[3]?.trim();
272
+ const relKeyword = classDecl[2] as 'extends' | 'implements' | undefined;
273
+ const parentName = classDecl[3];
274
+ const modifier = classDecl[4] as ClassModifier | undefined;
275
+ const colorName = classDecl[5]?.trim();
310
276
  const color = colorName ? resolveColor(colorName, palette) : undefined;
311
277
 
312
278
  const node = getOrCreateClass(name, lineNumber);
@@ -315,6 +281,17 @@ export function parseClassDiagram(
315
281
  // Update line number to the declaration line (may have been created by relationship)
316
282
  node.lineNumber = lineNumber;
317
283
 
284
+ // Inline extends/implements creates a relationship
285
+ if (relKeyword && parentName) {
286
+ getOrCreateClass(parentName, lineNumber);
287
+ result.relationships.push({
288
+ source: classId(name),
289
+ target: classId(parentName),
290
+ type: relKeyword as RelationshipType,
291
+ lineNumber,
292
+ });
293
+ }
294
+
318
295
  currentClass = node;
319
296
  continue;
320
297
  }
@@ -376,9 +353,10 @@ export function looksLikeClassDiagram(content: string): boolean {
376
353
  hasModifier = true;
377
354
  hasClassDecl = true;
378
355
  }
379
- // Check for relationship keywords
380
- if (REL_KEYWORD_RE.test(trimmed)) {
356
+ // Check for inline extends/implements in class declaration
357
+ if (/^[A-Z][A-Za-z0-9_]*\s+(extends|implements)\s+[A-Z]/.test(trimmed)) {
381
358
  hasRelationship = true;
359
+ hasClassDecl = true;
382
360
  }
383
361
  // Check for relationship arrows
384
362
  if (REL_ARROW_RE.test(trimmed)) {
@@ -406,43 +406,42 @@ export function renderClassDiagram(
406
406
  const methods = node.members.filter((m) => m.isMethod);
407
407
 
408
408
  if (isEnum) {
409
- // Enum: all members as values
410
- if (node.members.length > 0) {
411
- // Separator
412
- nodeG.append('line')
413
- .attr('x1', -w / 2)
414
- .attr('y1', yPos)
415
- .attr('x2', w / 2)
416
- .attr('y2', yPos)
417
- .attr('stroke', stroke)
418
- .attr('stroke-width', 0.5)
419
- .attr('stroke-opacity', 0.5);
420
-
421
- let memberY = yPos + COMPARTMENT_PADDING_Y;
422
- for (const member of node.members) {
423
- nodeG.append('text')
424
- .attr('x', -w / 2 + MEMBER_PADDING_X)
425
- .attr('y', memberY + MEMBER_LINE_HEIGHT / 2)
426
- .attr('dominant-baseline', 'central')
427
- .attr('fill', palette.text)
428
- .attr('font-size', MEMBER_FONT_SIZE)
429
- .text(member.name);
430
- memberY += MEMBER_LINE_HEIGHT;
431
- }
409
+ // Enum: single values compartment
410
+ // Separator
411
+ nodeG.append('line')
412
+ .attr('x1', -w / 2)
413
+ .attr('y1', yPos)
414
+ .attr('x2', w / 2)
415
+ .attr('y2', yPos)
416
+ .attr('stroke', stroke)
417
+ .attr('stroke-width', 0.5)
418
+ .attr('stroke-opacity', 0.5);
419
+
420
+ let memberY = yPos + COMPARTMENT_PADDING_Y;
421
+ for (const member of node.members) {
422
+ nodeG.append('text')
423
+ .attr('x', -w / 2 + MEMBER_PADDING_X)
424
+ .attr('y', memberY + MEMBER_LINE_HEIGHT / 2)
425
+ .attr('dominant-baseline', 'central')
426
+ .attr('fill', palette.text)
427
+ .attr('font-size', MEMBER_FONT_SIZE)
428
+ .text(member.name);
429
+ memberY += MEMBER_LINE_HEIGHT;
432
430
  }
433
431
  } else {
434
- // Fields compartment
435
- if (fields.length > 0) {
436
- // Separator
437
- nodeG.append('line')
438
- .attr('x1', -w / 2)
439
- .attr('y1', yPos)
440
- .attr('x2', w / 2)
441
- .attr('y2', yPos)
442
- .attr('stroke', stroke)
443
- .attr('stroke-width', 0.5)
444
- .attr('stroke-opacity', 0.5);
432
+ // UML 3-compartment layout: always show both separators
433
+
434
+ // Fields separator
435
+ nodeG.append('line')
436
+ .attr('x1', -w / 2)
437
+ .attr('y1', yPos)
438
+ .attr('x2', w / 2)
439
+ .attr('y2', yPos)
440
+ .attr('stroke', stroke)
441
+ .attr('stroke-width', 0.5)
442
+ .attr('stroke-opacity', 0.5);
445
443
 
444
+ if (fields.length > 0) {
446
445
  let memberY = yPos + COMPARTMENT_PADDING_Y;
447
446
  for (const field of fields) {
448
447
  const vis = visibilitySymbol(field.visibility);
@@ -463,21 +462,20 @@ export function renderClassDiagram(
463
462
 
464
463
  memberY += MEMBER_LINE_HEIGHT;
465
464
  }
466
- yPos += node.fieldsHeight;
467
465
  }
466
+ yPos += node.fieldsHeight;
467
+
468
+ // Methods separator
469
+ nodeG.append('line')
470
+ .attr('x1', -w / 2)
471
+ .attr('y1', yPos)
472
+ .attr('x2', w / 2)
473
+ .attr('y2', yPos)
474
+ .attr('stroke', stroke)
475
+ .attr('stroke-width', 0.5)
476
+ .attr('stroke-opacity', 0.5);
468
477
 
469
- // Methods compartment
470
478
  if (methods.length > 0) {
471
- // Separator
472
- nodeG.append('line')
473
- .attr('x1', -w / 2)
474
- .attr('y1', yPos)
475
- .attr('x2', w / 2)
476
- .attr('y2', yPos)
477
- .attr('stroke', stroke)
478
- .attr('stroke-width', 0.5)
479
- .attr('stroke-opacity', 0.5);
480
-
481
479
  let memberY = yPos + COMPARTMENT_PADDING_Y;
482
480
  for (const method of methods) {
483
481
  const vis = visibilitySymbol(method.visibility);
package/src/d3.ts CHANGED
@@ -180,6 +180,7 @@ import type { PaletteColors } from './palettes';
180
180
  import { getSeriesColors } from './palettes';
181
181
  import type { DgmoError } from './diagnostics';
182
182
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
183
+ import { collectIndentedValues } from './utils/parsing';
183
184
 
184
185
  // ============================================================
185
186
  // Timeline Date Helper
@@ -517,10 +518,18 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
517
518
 
518
519
  // Quadrant-specific parsing
519
520
  if (result.type === 'quadrant') {
520
- // x-axis: Low, High
521
- const xAxisMatch = line.match(/^x-axis\s*:\s*(.+)/i);
521
+ // x-axis: Low, High — or indented multi-line
522
+ const xAxisMatch = line.match(/^x-axis\s*:\s*(.*)/i);
522
523
  if (xAxisMatch) {
523
- const parts = xAxisMatch[1].split(',').map((s) => s.trim());
524
+ const val = xAxisMatch[1].trim();
525
+ let parts: string[];
526
+ if (val) {
527
+ parts = val.split(',').map((s) => s.trim());
528
+ } else {
529
+ const collected = collectIndentedValues(lines, i);
530
+ i = collected.newIndex;
531
+ parts = collected.values;
532
+ }
524
533
  if (parts.length >= 2) {
525
534
  result.quadrantXAxis = [parts[0], parts[1]];
526
535
  result.quadrantXAxisLineNumber = lineNumber;
@@ -528,10 +537,18 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
528
537
  continue;
529
538
  }
530
539
 
531
- // y-axis: Low, High
532
- const yAxisMatch = line.match(/^y-axis\s*:\s*(.+)/i);
540
+ // y-axis: Low, High — or indented multi-line
541
+ const yAxisMatch = line.match(/^y-axis\s*:\s*(.*)/i);
533
542
  if (yAxisMatch) {
534
- const parts = yAxisMatch[1].split(',').map((s) => s.trim());
543
+ const val = yAxisMatch[1].trim();
544
+ let parts: string[];
545
+ if (val) {
546
+ parts = val.split(',').map((s) => s.trim());
547
+ } else {
548
+ const collected = collectIndentedValues(lines, i);
549
+ i = collected.newIndex;
550
+ parts = collected.values;
551
+ }
535
552
  if (parts.length >= 2) {
536
553
  result.quadrantYAxis = [parts[0], parts[1]];
537
554
  result.quadrantYAxisLineNumber = lineNumber;
package/src/echarts.ts CHANGED
@@ -88,6 +88,7 @@ import { getSeriesColors, getSegmentColors } from './palettes';
88
88
  import { parseChart } from './chart';
89
89
  import type { ParsedChart } from './chart';
90
90
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
91
+ import { collectIndentedValues } from './utils/parsing';
91
92
 
92
93
  // ============================================================
93
94
  // Parser
@@ -193,11 +194,16 @@ export function parseEChart(
193
194
  }
194
195
 
195
196
  if (key === 'series') {
196
- result.series = value;
197
- const rawNames = value
198
- .split(',')
199
- .map((s) => s.trim())
200
- .filter(Boolean);
197
+ let rawNames: string[];
198
+ if (value) {
199
+ result.series = value;
200
+ rawNames = value.split(',').map((s) => s.trim()).filter(Boolean);
201
+ } else {
202
+ const collected = collectIndentedValues(lines, i);
203
+ i = collected.newIndex;
204
+ rawNames = collected.values;
205
+ result.series = rawNames.join(', ');
206
+ }
201
207
  const names: string[] = [];
202
208
  const nameColors: (string | undefined)[] = [];
203
209
  for (const raw of rawNames) {
@@ -241,12 +247,24 @@ export function parseEChart(
241
247
 
242
248
  // Heatmap columns and rows headers
243
249
  if (key === 'columns') {
244
- result.columns = value.split(',').map((s) => s.trim());
250
+ if (value) {
251
+ result.columns = value.split(',').map((s) => s.trim());
252
+ } else {
253
+ const collected = collectIndentedValues(lines, i);
254
+ i = collected.newIndex;
255
+ result.columns = collected.values;
256
+ }
245
257
  continue;
246
258
  }
247
259
 
248
260
  if (key === 'rows') {
249
- result.rows = value.split(',').map((s) => s.trim());
261
+ if (value) {
262
+ result.rows = value.split(',').map((s) => s.trim());
263
+ } else {
264
+ const collected = collectIndentedValues(lines, i);
265
+ i = collected.newIndex;
266
+ result.rows = collected.values;
267
+ }
250
268
  continue;
251
269
  }
252
270
 
@@ -63,6 +63,8 @@ export interface SequenceMessage {
63
63
  lineNumber: number;
64
64
  async?: boolean;
65
65
  bidirectional?: boolean;
66
+ /** Standalone return — the message itself IS a return (dashed arrow, no call). */
67
+ standaloneReturn?: boolean;
66
68
  }
67
69
 
68
70
  /**
@@ -184,10 +186,20 @@ const NOTE_MULTI = /^note(?:\s+(right|left)\s+of\s+([^\s:]+))?\s*:?\s*$/i;
184
186
  function parseReturnLabel(rawLabel: string): {
185
187
  label: string;
186
188
  returnLabel?: string;
189
+ standaloneReturn?: boolean;
187
190
  } {
188
191
  if (!rawLabel) return { label: '' };
189
192
 
190
- // Check <- syntax first
193
+ // Standalone return: label starts with `<-` (no forward label)
194
+ const standaloneMatch = rawLabel.match(/^<-\s*(.*)$/);
195
+ if (standaloneMatch) {
196
+ return {
197
+ label: standaloneMatch[1].trim(),
198
+ standaloneReturn: true,
199
+ };
200
+ }
201
+
202
+ // Check <- syntax first (separates forward label from return label)
191
203
  const arrowReturn = rawLabel.match(ARROW_RETURN_PATTERN);
192
204
  if (arrowReturn) {
193
205
  return { label: arrowReturn[1].trim(), returnLabel: arrowReturn[2].trim() };
@@ -620,8 +632,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
620
632
  const rawLabel = arrowMatch[3]?.trim() || '';
621
633
 
622
634
  // Extract return label — skip for async messages
623
- const { label, returnLabel } = isAsync
624
- ? { label: rawLabel, returnLabel: undefined }
635
+ const { label, returnLabel, standaloneReturn } = isAsync
636
+ ? { label: rawLabel, returnLabel: undefined, standaloneReturn: undefined }
625
637
  : parseReturnLabel(rawLabel);
626
638
 
627
639
  const msg: SequenceMessage = {
@@ -631,6 +643,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
631
643
  returnLabel,
632
644
  lineNumber,
633
645
  ...(isAsync ? { async: true } : {}),
646
+ ...(standaloneReturn ? { standaloneReturn: true } : {}),
634
647
  };
635
648
  result.messages.push(msg);
636
649
  currentContainer().push(msg);
@@ -571,6 +571,28 @@ export function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {
571
571
  });
572
572
  }
573
573
 
574
+ // Standalone return: emit as a return step directly (no call, no stack).
575
+ // Also pop the matching pending call from the stack so it doesn't
576
+ // generate a duplicate empty return later.
577
+ if (msg.standaloneReturn) {
578
+ // Find and remove the stack entry this return satisfies
579
+ // (the pending call where from→to matches to→from of this return)
580
+ for (let si = stack.length - 1; si >= 0; si--) {
581
+ if (stack[si].from === msg.to && stack[si].to === msg.from) {
582
+ stack.splice(si, 1);
583
+ break;
584
+ }
585
+ }
586
+ steps.push({
587
+ type: 'return',
588
+ from: msg.from,
589
+ to: msg.to,
590
+ label: msg.label,
591
+ messageIndex: mi,
592
+ });
593
+ continue;
594
+ }
595
+
574
596
  // Emit call
575
597
  steps.push({
576
598
  type: 'call',
@@ -44,6 +44,37 @@ export const TITLE_RE = /^title\s*:\s*(.+)/i;
44
44
  /** Matches `option: value` header lines. */
45
45
  export const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
46
46
 
47
+ /**
48
+ * Collect indented continuation lines as individual values.
49
+ * Used when a property like `series:` has an empty value — subsequent
50
+ * indented lines each become one value entry.
51
+ *
52
+ * - Skips blank lines and `//` comment lines within the block
53
+ * - Stops at first non-indented non-empty line (or EOF)
54
+ * - Strips trailing commas from values (user habit tolerance)
55
+ * - Returns `newIndex` so caller does `i = newIndex` and the loop's `i++` lands correctly
56
+ */
57
+ export function collectIndentedValues(
58
+ lines: string[],
59
+ startIndex: number,
60
+ ): { values: string[]; newIndex: number } {
61
+ const values: string[] = [];
62
+ let j = startIndex + 1;
63
+ for (; j < lines.length; j++) {
64
+ const raw = lines[j];
65
+ const trimmed = raw.trim();
66
+ // Skip blank lines within the block
67
+ if (!trimmed) continue;
68
+ // Skip comment lines within the block
69
+ if (trimmed.startsWith('//')) continue;
70
+ // Stop at non-indented lines (first char is not whitespace)
71
+ if (raw[0] !== ' ' && raw[0] !== '\t') break;
72
+ // Strip trailing comma and collect
73
+ values.push(trimmed.replace(/,\s*$/, ''));
74
+ }
75
+ return { values, newIndex: j - 1 };
76
+ }
77
+
47
78
  /** Parse pipe-delimited metadata from segments after the first (name) segment. */
48
79
  export function parsePipeMetadata(
49
80
  segments: string[],