@diagrammo/dgmo 0.8.18 → 0.8.20

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.
Files changed (42) hide show
  1. package/dist/cli.cjs +89 -130
  2. package/dist/index.cjs +1202 -993
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +216 -114
  5. package/dist/index.d.ts +216 -114
  6. package/dist/index.js +1211 -985
  7. package/dist/index.js.map +1 -1
  8. package/docs/language-reference.md +73 -0
  9. package/package.json +22 -9
  10. package/src/boxes-and-lines/parser.ts +8 -3
  11. package/src/c4/parser.ts +8 -7
  12. package/src/class/parser.ts +6 -0
  13. package/src/cli.ts +1 -9
  14. package/src/d3.ts +16 -234
  15. package/src/dgmo-router.ts +97 -5
  16. package/src/diagnostics.ts +16 -6
  17. package/src/echarts.ts +43 -10
  18. package/src/er/parser.ts +22 -2
  19. package/src/gantt/renderer.ts +153 -91
  20. package/src/graph/flowchart-parser.ts +89 -52
  21. package/src/graph/state-parser.ts +60 -35
  22. package/src/index.ts +23 -18
  23. package/src/infra/parser.ts +9 -2
  24. package/src/kanban/renderer.ts +2 -2
  25. package/src/palettes/color-utils.ts +4 -12
  26. package/src/palettes/index.ts +0 -4
  27. package/src/render.ts +30 -16
  28. package/src/sequence/collapse.ts +169 -0
  29. package/src/sequence/parser.ts +21 -4
  30. package/src/sequence/renderer.ts +198 -52
  31. package/src/sharing.ts +86 -49
  32. package/src/sitemap/renderer.ts +1 -6
  33. package/src/utils/arrows.ts +180 -11
  34. package/src/utils/d3-types.ts +4 -0
  35. package/src/utils/legend-constants.ts +11 -4
  36. package/src/utils/legend-d3.ts +171 -0
  37. package/src/utils/legend-layout.ts +140 -13
  38. package/src/utils/legend-types.ts +45 -0
  39. package/src/utils/time-ticks.ts +213 -0
  40. package/src/branding.ts +0 -67
  41. package/src/dgmo-mermaid.ts +0 -262
  42. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -2,6 +2,7 @@ import { resolveColorWithDiagnostic } from '../colors';
2
2
  import type { DgmoError } from '../diagnostics';
3
3
  import type { PaletteColors } from '../palettes';
4
4
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
+ import { parseInArrowLabel, matchColorParens } from '../utils/arrows';
5
6
  import {
6
7
  measureIndent,
7
8
  extractColor,
@@ -31,6 +32,8 @@ const GROUP_BRACKET_RE = /^\[([^\]]+)\](?:\(([^)]+)\))?\s*$/;
31
32
  * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
32
33
  */
33
34
  function splitArrows(line: string): string[] {
35
+ // Mirrors flowchart-parser.ts splitArrows. TD-9 longest-match: arrow token
36
+ // is the maximal run of `-+>`. See that file for the full algorithm rationale.
34
37
  const segments: string[] = [];
35
38
  const arrowPositions: {
36
39
  start: number;
@@ -40,41 +43,52 @@ function splitArrows(line: string): string[] {
40
43
  }[] = [];
41
44
 
42
45
  let searchFrom = 0;
46
+ let scanFloor = 0;
43
47
  while (searchFrom < line.length) {
44
48
  const idx = line.indexOf('->', searchFrom);
45
49
  if (idx === -1) break;
46
50
 
47
- let arrowStart = idx;
51
+ let runStart = idx;
52
+ while (runStart > scanFloor && line[runStart - 1] === '-') runStart--;
53
+ const arrowEnd = idx + 2;
54
+
55
+ let arrowStart: number;
48
56
  let label: string | undefined;
49
57
  let color: string | undefined;
50
58
 
51
- if (idx > 0 && line[idx - 1] !== ' ' && line[idx - 1] !== '\t') {
52
- let scanBack = idx - 1;
53
- while (scanBack > 0 && line[scanBack] !== '-') {
54
- scanBack--;
59
+ let openingStart = -1;
60
+ for (let i = scanFloor; i < runStart; i++) {
61
+ if (line[i] !== '-') continue;
62
+ const prevIsWsOrFloor =
63
+ i === 0 || i === scanFloor || /\s/.test(line[i - 1]);
64
+ if (prevIsWsOrFloor) {
65
+ openingStart = i;
66
+ break;
55
67
  }
56
- if (
57
- line[scanBack] === '-' &&
58
- (scanBack === 0 || /\s/.test(line[scanBack - 1]))
59
- ) {
60
- let arrowContent = line.substring(scanBack + 1, idx);
61
- if (arrowContent.endsWith('-'))
62
- arrowContent = arrowContent.slice(0, -1);
63
- const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
64
- if (colorMatch) {
65
- color = colorMatch[1].trim();
66
- const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
67
- if (labelPart) label = labelPart;
68
- } else {
69
- const labelPart = arrowContent.trim();
70
- if (labelPart) label = labelPart;
71
- }
72
- arrowStart = scanBack;
68
+ }
69
+
70
+ if (openingStart !== -1) {
71
+ let openingEnd = openingStart;
72
+ while (openingEnd < runStart && line[openingEnd] === '-') openingEnd++;
73
+
74
+ const arrowContent = line.substring(openingEnd, runStart);
75
+ const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
76
+ if (colorMatch) {
77
+ color = colorMatch[1].trim();
78
+ const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
79
+ if (labelPart) label = labelPart;
80
+ } else {
81
+ const labelPart = arrowContent.trim();
82
+ if (labelPart) label = labelPart;
73
83
  }
84
+ arrowStart = openingStart;
85
+ } else {
86
+ arrowStart = runStart;
74
87
  }
75
88
 
76
- arrowPositions.push({ start: arrowStart, end: idx + 2, label, color });
77
- searchFrom = idx + 2;
89
+ arrowPositions.push({ start: arrowStart, end: arrowEnd, label, color });
90
+ searchFrom = arrowEnd;
91
+ scanFloor = arrowEnd;
78
92
  }
79
93
 
80
94
  if (arrowPositions.length === 0) return [line];
@@ -111,19 +125,30 @@ function parseArrowToken(
111
125
  diagnostics: DgmoError[]
112
126
  ): ArrowInfo {
113
127
  if (token === '->') return {};
114
- const colorOnly = token.match(/^-\(([^)]+)\)->$/);
115
- if (colorOnly)
116
- return {
117
- color: resolveColorWithDiagnostic(
118
- colorOnly[1].trim(),
119
- lineNumber,
120
- diagnostics,
121
- palette
122
- ),
123
- };
128
+ // TD-11: `-(X)->` is a color if and only if X is a recognized palette
129
+ // color; otherwise the whole `(X)` becomes the label. Delegate recognition
130
+ // to the shared `matchColorParens` helper.
131
+ const bareParen = token.match(/^-(\([A-Za-z]+\))->$/);
132
+ if (bareParen) {
133
+ const colorName = matchColorParens(bareParen[1]);
134
+ if (colorName) {
135
+ return {
136
+ color: resolveColorWithDiagnostic(
137
+ colorName,
138
+ lineNumber,
139
+ diagnostics,
140
+ palette
141
+ ),
142
+ };
143
+ }
144
+ // fall through — whole `(X)` becomes label
145
+ }
124
146
  const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
125
147
  if (m) {
126
- const label = m[1]?.trim() || undefined;
148
+ const rawLabel = m[1] ?? '';
149
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
150
+ diagnostics.push(...labelResult.diagnostics);
151
+ const label = labelResult.label;
127
152
  const color = m[2]
128
153
  ? resolveColorWithDiagnostic(
129
154
  m[2].trim(),
package/src/index.ts CHANGED
@@ -5,6 +5,18 @@
5
5
  export { makeDgmoError, formatDgmoError } from './diagnostics';
6
6
  export type { DgmoError, DgmoSeverity } from './diagnostics';
7
7
 
8
+ // ============================================================
9
+ // Arrow helpers (in-arrow label validation)
10
+ // ============================================================
11
+
12
+ export {
13
+ parseInArrowLabel,
14
+ validateLabelCharacters,
15
+ matchColorParens,
16
+ ARROW_DIAGNOSTIC_CODES,
17
+ } from './utils/arrows';
18
+ export type { ParseInArrowLabelResult } from './utils/arrows';
19
+
8
20
  // ============================================================
9
21
  // Unified API
10
22
  // ============================================================
@@ -38,9 +50,9 @@ export {
38
50
  orderArcNodes,
39
51
  parseTimelineDate,
40
52
  addDurationToDate,
41
- computeTimeTicks,
42
53
  formatDateLabel,
43
54
  } from './d3';
55
+ export { computeTimeTicks } from './utils/time-ticks';
44
56
  export type {
45
57
  ParsedVisualization,
46
58
  VisualizationType,
@@ -73,9 +85,6 @@ export {
73
85
  RULE_COUNT,
74
86
  } from './sequence/participant-inference';
75
87
 
76
- export { parseQuadrant } from './dgmo-mermaid';
77
- export type { ParsedQuadrant } from './dgmo-mermaid';
78
-
79
88
  export { parseFlowchart, looksLikeFlowchart } from './graph/flowchart-parser';
80
89
 
81
90
  export { parseState, looksLikeState } from './graph/state-parser';
@@ -378,8 +387,6 @@ export type {
378
387
  LegendHandle,
379
388
  LegendPalette,
380
389
  } from './utils/legend-types';
381
- export { buildMermaidQuadrant } from './dgmo-mermaid';
382
-
383
390
  // ============================================================
384
391
  // Renderers (produce SVG output)
385
392
  // ============================================================
@@ -410,6 +417,9 @@ export type {
410
417
  SequenceRenderOptions,
411
418
  } from './sequence/renderer';
412
419
 
420
+ export { applyCollapseProjection } from './sequence/collapse';
421
+ export type { CollapsedView } from './sequence/collapse';
422
+
413
423
  // ============================================================
414
424
  // Colors & Palettes
415
425
  // ============================================================
@@ -434,7 +444,6 @@ export {
434
444
  hexToHSL,
435
445
  hslToHex,
436
446
  hexToHSLString,
437
- mute,
438
447
  tint,
439
448
  shade,
440
449
  getSeriesColors,
@@ -450,9 +459,6 @@ export {
450
459
  boldPalette,
451
460
  draculaPalette,
452
461
  monokaiPalette,
453
- // Mermaid bridge
454
- buildMermaidThemeVars,
455
- buildThemeCSS,
456
462
  } from './palettes';
457
463
 
458
464
  export type { PaletteConfig, PaletteColors } from './palettes';
@@ -461,11 +467,16 @@ export type { PaletteConfig, PaletteColors } from './palettes';
461
467
  // Sharing (URL encoding/decoding)
462
468
  // ============================================================
463
469
 
464
- export { encodeDiagramUrl, decodeDiagramUrl } from './sharing';
470
+ export {
471
+ encodeDiagramUrl,
472
+ decodeDiagramUrl,
473
+ encodeViewState,
474
+ decodeViewState,
475
+ } from './sharing';
465
476
  export type {
466
477
  EncodeDiagramUrlOptions,
467
478
  EncodeDiagramUrlResult,
468
- DiagramViewState,
479
+ CompactViewState,
469
480
  DecodedDiagramUrl,
470
481
  } from './sharing';
471
482
 
@@ -492,9 +503,3 @@ export type {
492
503
  } from './completion';
493
504
 
494
505
  export { parseFirstLine, ALL_CHART_TYPES } from './utils/parsing';
495
-
496
- // ============================================================
497
- // Branding
498
- // ============================================================
499
-
500
- export { injectBranding } from './branding';
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
10
10
  import { resolveColorWithDiagnostic } from '../colors';
11
+ import { parseInArrowLabel } from '../utils/arrows';
11
12
  import {
12
13
  measureIndent,
13
14
  parseFirstLine,
@@ -477,7 +478,10 @@ export function parseInfra(content: string): ParsedInfra {
477
478
  // Async labeled connection: ~label~> Target
478
479
  const asyncConnMatch = trimmed.match(ASYNC_CONNECTION_RE);
479
480
  if (asyncConnMatch) {
480
- const label = asyncConnMatch[1]?.trim() || '';
481
+ const rawLabel = asyncConnMatch[1] ?? '';
482
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
483
+ result.diagnostics.push(...labelResult.diagnostics);
484
+ const label = labelResult.label ?? '';
481
485
  const targetRaw = asyncConnMatch[2].trim();
482
486
  const pipeMeta = extractPipeMetadata(targetRaw);
483
487
  const targetName = pipeMeta.clean || targetRaw;
@@ -551,7 +555,10 @@ export function parseInfra(content: string): ParsedInfra {
551
555
  // Labeled connection: -label-> Target | split: N%, fanout: 3
552
556
  const connMatch = trimmed.match(CONNECTION_RE);
553
557
  if (connMatch) {
554
- const label = connMatch[1]?.trim() || '';
558
+ const rawLabel = connMatch[1] ?? '';
559
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
560
+ result.diagnostics.push(...labelResult.diagnostics);
561
+ const label = labelResult.label ?? '';
555
562
  const targetRaw = connMatch[2].trim();
556
563
  const pipeMeta = extractPipeMetadata(targetRaw);
557
564
  const targetName = pipeMeta.clean || targetRaw;
@@ -28,7 +28,7 @@ import type {
28
28
  // Public options object
29
29
  // ============================================================
30
30
 
31
- export interface KanbanInteractiveOptions {
31
+ interface KanbanInteractiveOptions {
32
32
  onNavigateToLine?: (line: number) => void;
33
33
  exportDims?: { width: number; height: number };
34
34
  activeTagGroup?: string | null;
@@ -608,7 +608,7 @@ interface SwimlaneBucket {
608
608
  cellsByColumn: Record<string, KanbanCard[]>;
609
609
  }
610
610
 
611
- export function bucketCardsBySwimlane(
611
+ function bucketCardsBySwimlane(
612
612
  columns: KanbanColumn[],
613
613
  swimlaneGroup: KanbanTagGroup
614
614
  ): SwimlaneBucket[] {
@@ -84,17 +84,6 @@ export function hexToHSLString(hex: string): string {
84
84
  // Color Manipulation
85
85
  // ============================================================
86
86
 
87
- /**
88
- * Derive a muted (desaturated, darkened) variant of a color.
89
- * Used by the Mermaid theme generator for dark-mode fills.
90
- *
91
- * Algorithm: cap saturation at 35% and lightness at 36%.
92
- */
93
- export function mute(hex: string): string {
94
- const { h, s, l } = hexToHSL(hex);
95
- return hslToHex(h, Math.min(s, 35), Math.min(l, 36));
96
- }
97
-
98
87
  /**
99
88
  * Blend a color toward white (light mode quadrant fills).
100
89
  * amount: 0 = original, 1 = white
@@ -232,7 +221,10 @@ export function getSeriesColors(palette: PaletteColors): string[] {
232
221
  * saturation and lightness, guaranteeing every segment gets a unique,
233
222
  * perceptually distinct color regardless of segment count.
234
223
  */
235
- export function getSegmentColors(palette: PaletteColors, count: number): string[] {
224
+ export function getSegmentColors(
225
+ palette: PaletteColors,
226
+ count: number
227
+ ): string[] {
236
228
  const base = getSeriesColors(palette);
237
229
  const unique = [...new Set(base)];
238
230
  const hsls = unique.map(hexToHSL);
@@ -14,7 +14,6 @@ export {
14
14
  hexToHSL,
15
15
  hslToHex,
16
16
  hexToHSLString,
17
- mute,
18
17
  tint,
19
18
  shade,
20
19
  getSeriesColors,
@@ -34,6 +33,3 @@ export { tokyoNightPalette } from './tokyo-night';
34
33
 
35
34
  export { draculaPalette } from './dracula';
36
35
  export { monokaiPalette } from './monokai';
37
-
38
- // Re-export Mermaid bridge
39
- export { buildMermaidThemeVars, buildThemeCSS } from './mermaid-bridge';
package/src/render.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { renderForExport } from './d3';
2
2
  import { renderExtendedChartForExport } from './echarts';
3
- import { parseDgmoChartType, getRenderCategory } from './dgmo-router';
3
+ import {
4
+ parseDgmoChartType,
5
+ getRenderCategory,
6
+ parseDgmo,
7
+ } from './dgmo-router';
8
+ import type { DgmoError } from './diagnostics';
4
9
  import { getPalette } from './palettes/registry';
5
10
 
6
11
  /**
@@ -45,13 +50,13 @@ async function ensureDom(): Promise<void> {
45
50
  *
46
51
  * @param content - DGMO source text
47
52
  * @param options - Optional theme and palette settings
48
- * @returns SVG string, or empty string on error
53
+ * @returns Object with `svg` (SVG string, empty on error) and `diagnostics` (parse errors/warnings)
49
54
  *
50
55
  * @example
51
56
  * ```ts
52
57
  * import { render } from '@diagrammo/dgmo';
53
58
  *
54
- * const svg = await render(`pie Languages
59
+ * const { svg, diagnostics } = await render(`pie Languages
55
60
  * TypeScript: 45
56
61
  * Python: 30
57
62
  * Rust: 25`);
@@ -62,7 +67,6 @@ export async function render(
62
67
  options?: {
63
68
  theme?: 'light' | 'dark' | 'transparent';
64
69
  palette?: string;
65
- branding?: boolean;
66
70
  c4Level?: 'context' | 'containers' | 'components' | 'deployment';
67
71
  c4System?: string;
68
72
  c4Container?: string;
@@ -70,14 +74,15 @@ export async function render(
70
74
  /** Legend state for export — controls which tag group is shown in exported SVG. */
71
75
  legendState?: { activeGroup?: string; hiddenAttributes?: string[] };
72
76
  }
73
- ): Promise<string> {
77
+ ): Promise<{ svg: string; diagnostics: DgmoError[] }> {
74
78
  const theme = options?.theme ?? 'light';
75
79
  const paletteName = options?.palette ?? 'nord';
76
- const branding = options?.branding ?? false;
77
80
 
78
81
  const paletteColors =
79
82
  getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
80
83
 
84
+ const { diagnostics } = parseDgmo(content);
85
+
81
86
  const chartType = parseDgmoChartType(content);
82
87
  const category = chartType ? getRenderCategory(chartType) : null;
83
88
 
@@ -92,18 +97,27 @@ export async function render(
92
97
  : undefined;
93
98
 
94
99
  if (category === 'data-chart') {
95
- return renderExtendedChartForExport(content, theme, paletteColors, {
96
- branding,
97
- });
100
+ const svg = await renderExtendedChartForExport(
101
+ content,
102
+ theme,
103
+ paletteColors
104
+ );
105
+ return { svg, diagnostics };
98
106
  }
99
107
 
100
108
  // Visualization/diagram and unknown/null types all go through the unified renderer
101
109
  await ensureDom();
102
- return renderForExport(content, theme, paletteColors, legendExportState, {
103
- branding,
104
- c4Level: options?.c4Level,
105
- c4System: options?.c4System,
106
- c4Container: options?.c4Container,
107
- tagGroup: options?.tagGroup,
108
- });
110
+ const svg = await renderForExport(
111
+ content,
112
+ theme,
113
+ paletteColors,
114
+ legendExportState,
115
+ {
116
+ c4Level: options?.c4Level,
117
+ c4System: options?.c4System,
118
+ c4Container: options?.c4Container,
119
+ tagGroup: options?.tagGroup,
120
+ }
121
+ );
122
+ return { svg, diagnostics };
109
123
  }
@@ -0,0 +1,169 @@
1
+ // ============================================================
2
+ // Collapse Projection for Sequence Diagram Groups
3
+ // ============================================================
4
+ //
5
+ // Pure projection function that transforms a parsed sequence diagram
6
+ // by collapsing specified groups into single virtual participants.
7
+ // The parsed AST (ParsedSequenceDgmo) stays immutable.
8
+
9
+ import type {
10
+ ParsedSequenceDgmo,
11
+ SequenceElement,
12
+ SequenceGroup,
13
+ SequenceMessage,
14
+ SequenceParticipant,
15
+ } from './parser';
16
+ import { isSequenceBlock, isSequenceNote, isSequenceSection } from './parser';
17
+
18
+ export interface CollapsedView {
19
+ participants: SequenceParticipant[];
20
+ messages: SequenceMessage[];
21
+ elements: SequenceElement[];
22
+ groups: SequenceGroup[];
23
+ /** Maps member participant ID → collapsed group name */
24
+ collapsedGroupIds: Map<string, string>;
25
+ }
26
+
27
+ /**
28
+ * Project a parsed sequence diagram into a collapsed view.
29
+ *
30
+ * @param parsed - The immutable parsed sequence diagram
31
+ * @param collapsedGroups - Set of group lineNumbers that should be collapsed
32
+ * @returns A new CollapsedView with remapped participants, messages, elements, and groups
33
+ */
34
+ export function applyCollapseProjection(
35
+ parsed: ParsedSequenceDgmo,
36
+ collapsedGroups: Set<number>
37
+ ): CollapsedView {
38
+ if (collapsedGroups.size === 0) {
39
+ return {
40
+ participants: parsed.participants,
41
+ messages: parsed.messages,
42
+ elements: parsed.elements,
43
+ groups: parsed.groups,
44
+ collapsedGroupIds: new Map(),
45
+ };
46
+ }
47
+
48
+ // Build memberToGroup map: participantId → group name
49
+ const memberToGroup = new Map<string, string>();
50
+ const collapsedGroupNames = new Set<string>();
51
+ for (const group of parsed.groups) {
52
+ if (collapsedGroups.has(group.lineNumber)) {
53
+ collapsedGroupNames.add(group.name);
54
+ for (const memberId of group.participantIds) {
55
+ memberToGroup.set(memberId, group.name);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Participants: remove members of collapsed groups, insert virtual participant per group
61
+ // Skip non-member participants that collide with a collapsed group name
62
+ const participants: SequenceParticipant[] = [];
63
+ const insertedGroups = new Set<string>();
64
+
65
+ for (const p of parsed.participants) {
66
+ const groupName = memberToGroup.get(p.id);
67
+ if (groupName) {
68
+ // Replace first occurrence with virtual group participant
69
+ if (!insertedGroups.has(groupName)) {
70
+ insertedGroups.add(groupName);
71
+ const group = parsed.groups.find(
72
+ (g) => g.name === groupName && collapsedGroups.has(g.lineNumber)
73
+ )!;
74
+ participants.push({
75
+ id: groupName,
76
+ label: groupName,
77
+ type: 'default',
78
+ lineNumber: group.lineNumber,
79
+ });
80
+ }
81
+ // Skip member — it's absorbed into the group
82
+ } else if (collapsedGroupNames.has(p.id)) {
83
+ // Skip — participant name collides with a collapsed group name;
84
+ // the virtual group participant takes precedence
85
+ } else {
86
+ participants.push(p);
87
+ }
88
+ }
89
+
90
+ // Remap helper
91
+ const remap = (id: string): string => memberToGroup.get(id) ?? id;
92
+
93
+ // Messages: remap from/to, preserving order
94
+ const messages: SequenceMessage[] = parsed.messages.map((msg) => ({
95
+ ...msg,
96
+ from: remap(msg.from),
97
+ to: remap(msg.to),
98
+ }));
99
+
100
+ // Elements: deep clone with remapping and internal return suppression
101
+ const elements = remapElements(parsed.elements, memberToGroup);
102
+
103
+ // Groups: remove collapsed groups (they're now virtual participants)
104
+ const groups = parsed.groups.filter(
105
+ (g) => !collapsedGroups.has(g.lineNumber)
106
+ );
107
+
108
+ return {
109
+ participants,
110
+ messages,
111
+ elements,
112
+ groups,
113
+ collapsedGroupIds: memberToGroup,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Deep clone and remap elements, suppressing internal returns within collapsed groups.
119
+ */
120
+ function remapElements(
121
+ elements: SequenceElement[],
122
+ memberToGroup: Map<string, string>
123
+ ): SequenceElement[] {
124
+ const remap = (id: string): string => memberToGroup.get(id) ?? id;
125
+ const result: SequenceElement[] = [];
126
+
127
+ for (const el of elements) {
128
+ if (isSequenceSection(el)) {
129
+ // Sections have no participant references — pass through unchanged
130
+ result.push(el);
131
+ } else if (isSequenceNote(el)) {
132
+ // Remap note participant
133
+ result.push({
134
+ ...el,
135
+ participantId: remap(el.participantId),
136
+ });
137
+ } else if (isSequenceBlock(el)) {
138
+ // Recurse into block children
139
+ result.push({
140
+ ...el,
141
+ children: remapElements(el.children, memberToGroup),
142
+ elseChildren: remapElements(el.elseChildren, memberToGroup),
143
+ ...(el.elseIfBranches
144
+ ? {
145
+ elseIfBranches: el.elseIfBranches.map((branch) => ({
146
+ ...branch,
147
+ children: remapElements(branch.children, memberToGroup),
148
+ })),
149
+ }
150
+ : {}),
151
+ });
152
+ } else {
153
+ // Message element
154
+ const msg = el as SequenceMessage;
155
+ const from = remap(msg.from);
156
+ const to = remap(msg.to);
157
+
158
+ // Suppress internal return: both endpoints in same collapsed group
159
+ // and this is a return message (unlabeled response)
160
+ if (from === to && from !== msg.from && !msg.label) {
161
+ continue; // internal return suppressed
162
+ }
163
+
164
+ result.push({ ...msg, from, to });
165
+ }
166
+ }
167
+
168
+ return result;
169
+ }
@@ -5,7 +5,7 @@
5
5
  import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
- import { parseArrow } from '../utils/arrows';
8
+ import { parseArrow, parseInArrowLabel } from '../utils/arrows';
9
9
  import {
10
10
  measureIndent,
11
11
  extractColor,
@@ -154,6 +154,8 @@ export interface SequenceGroup {
154
154
  lineNumber: number;
155
155
  /** Pipe-delimited tag metadata (e.g. `[Backend | t: Product]`) */
156
156
  metadata?: Record<string, string>;
157
+ /** Whether this group is collapsed by default */
158
+ collapsed?: boolean;
157
159
  }
158
160
 
159
161
  /**
@@ -502,8 +504,17 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
502
504
  const groupColor = groupMatch[2]?.trim();
503
505
  let groupMeta: Record<string, string> | undefined;
504
506
 
505
- // Parse pipe metadata AFTER the closing bracket
506
- const afterBracket = groupMatch[3]?.trim() || '';
507
+ // Parse collapse keyword and pipe metadata AFTER the closing bracket
508
+ let afterBracket = groupMatch[3]?.trim() || '';
509
+ let isCollapsed = false;
510
+
511
+ // Extract `collapse` keyword (before any pipe metadata)
512
+ const collapseMatch = afterBracket.match(/^collapse\b/i);
513
+ if (collapseMatch) {
514
+ isCollapsed = true;
515
+ afterBracket = afterBracket.slice(collapseMatch[0].length).trim();
516
+ }
517
+
507
518
  if (afterBracket.startsWith('|')) {
508
519
  const segments = afterBracket.split('|');
509
520
  const meta = parsePipeMetadata(segments, aliasMap, () =>
@@ -524,6 +535,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
524
535
  participantIds: [],
525
536
  lineNumber,
526
537
  ...(groupMeta ? { metadata: groupMeta } : {}),
538
+ ...(isCollapsed ? { collapsed: true } : {}),
527
539
  };
528
540
  result.groups.push(activeGroup);
529
541
  continue;
@@ -933,9 +945,14 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
933
945
  }
934
946
  if (labeledArrow) {
935
947
  contentStarted = true;
936
- const { from, to, label, async: isAsync } = labeledArrow;
948
+ const { from, to, label: rawLabel, async: isAsync } = labeledArrow;
937
949
  lastMsgFrom = from;
938
950
 
951
+ // TD-13/TD-14: validate in-arrow label characters
952
+ const labelResult = parseInArrowLabel(rawLabel, lineNumber);
953
+ labelResult.diagnostics.forEach((d) => result.diagnostics.push(d));
954
+ const label = labelResult.label ?? rawLabel;
955
+
939
956
  const msg: SequenceMessage = {
940
957
  from,
941
958
  to,