@diagrammo/dgmo 0.8.19 → 0.8.21

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 (74) hide show
  1. package/dist/cli.cjs +92 -131
  2. package/dist/editor.cjs +13 -1
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +13 -1
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +13 -1
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +13 -1
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +4524 -1511
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +427 -186
  13. package/dist/index.d.ts +427 -186
  14. package/dist/index.js +4526 -1503
  15. package/dist/index.js.map +1 -1
  16. package/docs/guide/chart-mindmap.md +198 -0
  17. package/docs/guide/chart-sequence.md +23 -1
  18. package/docs/guide/chart-wireframe.md +100 -0
  19. package/docs/guide/index.md +8 -0
  20. package/docs/language-reference.md +210 -2
  21. package/package.json +22 -9
  22. package/src/boxes-and-lines/collapse.ts +21 -3
  23. package/src/boxes-and-lines/layout.ts +51 -9
  24. package/src/boxes-and-lines/parser.ts +16 -4
  25. package/src/boxes-and-lines/renderer.ts +121 -23
  26. package/src/boxes-and-lines/types.ts +1 -0
  27. package/src/c4/parser.ts +8 -7
  28. package/src/class/parser.ts +6 -0
  29. package/src/cli.ts +1 -9
  30. package/src/completion.ts +26 -0
  31. package/src/d3.ts +169 -266
  32. package/src/dgmo-router.ts +103 -5
  33. package/src/diagnostics.ts +16 -6
  34. package/src/echarts.ts +43 -10
  35. package/src/editor/keywords.ts +12 -0
  36. package/src/er/parser.ts +22 -2
  37. package/src/gantt/renderer.ts +2 -2
  38. package/src/graph/flowchart-parser.ts +89 -52
  39. package/src/graph/layout.ts +73 -9
  40. package/src/graph/state-collapse.ts +78 -0
  41. package/src/graph/state-parser.ts +60 -35
  42. package/src/graph/state-renderer.ts +139 -34
  43. package/src/index.ts +41 -16
  44. package/src/infra/parser.ts +9 -2
  45. package/src/kanban/renderer.ts +305 -59
  46. package/src/mindmap/collapse.ts +88 -0
  47. package/src/mindmap/layout.ts +605 -0
  48. package/src/mindmap/parser.ts +379 -0
  49. package/src/mindmap/renderer.ts +543 -0
  50. package/src/mindmap/text-wrap.ts +207 -0
  51. package/src/mindmap/types.ts +55 -0
  52. package/src/palettes/color-utils.ts +4 -12
  53. package/src/palettes/index.ts +0 -4
  54. package/src/render.ts +31 -20
  55. package/src/sequence/parser.ts +7 -2
  56. package/src/sequence/renderer.ts +141 -21
  57. package/src/sharing.ts +2 -0
  58. package/src/sitemap/layout.ts +35 -12
  59. package/src/sitemap/renderer.ts +1 -6
  60. package/src/utils/arrows.ts +180 -11
  61. package/src/utils/d3-types.ts +4 -0
  62. package/src/utils/export-container.ts +3 -2
  63. package/src/utils/legend-constants.ts +0 -4
  64. package/src/utils/legend-d3.ts +1 -0
  65. package/src/utils/legend-layout.ts +2 -2
  66. package/src/utils/parsing.ts +2 -0
  67. package/src/utils/time-ticks.ts +213 -0
  68. package/src/wireframe/layout.ts +460 -0
  69. package/src/wireframe/parser.ts +956 -0
  70. package/src/wireframe/renderer.ts +1293 -0
  71. package/src/wireframe/types.ts +110 -0
  72. package/src/branding.ts +0 -67
  73. package/src/dgmo-mermaid.ts +0 -262
  74. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -60,6 +60,11 @@ const NOTE_CHARS_PER_LINE = Math.floor(
60
60
  );
61
61
  const COLLAPSED_NOTE_H = 20;
62
62
  const COLLAPSED_NOTE_W = 40;
63
+ const ACTIVATION_WIDTH = 10;
64
+ const SELF_CALL_HEIGHT = 25;
65
+ const SELF_CALL_WIDTH = 30;
66
+ // Max note width that keeps a note within one participant lane
67
+ const NOTE_LANE_MAX = PARTICIPANT_GAP - ACTIVATION_WIDTH - NOTE_GAP; // 135px
63
68
 
64
69
  function wrapTextLines(text: string, maxChars: number): string[] {
65
70
  const rawLines = text.split('\n');
@@ -953,6 +958,37 @@ export function renderSequenceDiagram(
953
958
  );
954
959
  if (participants.length === 0) return;
955
960
 
961
+ // Participant index lookup — used to clamp note width within one lane
962
+ const participantIndexMap = new Map<string, number>();
963
+ participants.forEach((p, i) => participantIndexMap.set(p.id, i));
964
+
965
+ // Extra X shift for notes after self-calls
966
+ const SELF_CALL_NOTE_X_SHIFT =
967
+ ACTIVATION_WIDTH / 2 +
968
+ SELF_CALL_WIDTH +
969
+ NOTE_GAP -
970
+ (ACTIVATION_WIDTH + NOTE_GAP); // 25px
971
+
972
+ const noteEffectiveMaxW = (
973
+ participantId: string,
974
+ position: 'right' | 'left',
975
+ afterSelfCall = false
976
+ ): number => {
977
+ const idx = participantIndexMap.get(participantId);
978
+ if (idx === undefined) return NOTE_MAX_W;
979
+ const hasNeighbor =
980
+ position === 'right' ? idx < participants.length - 1 : idx > 0;
981
+ if (!hasNeighbor) return NOTE_MAX_W;
982
+ const laneMax =
983
+ afterSelfCall && position === 'right'
984
+ ? NOTE_LANE_MAX - SELF_CALL_NOTE_X_SHIFT
985
+ : NOTE_LANE_MAX;
986
+ return Math.min(NOTE_MAX_W, laneMax);
987
+ };
988
+
989
+ const charsForWidth = (maxW: number): number =>
990
+ Math.floor((maxW - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W);
991
+
956
992
  const activationsOff = parsedOptions.activations?.toLowerCase() === 'off';
957
993
 
958
994
  // Tag resolution — shared utility handles priority chain:
@@ -1049,7 +1085,39 @@ export function renderSequenceDiagram(
1049
1085
  return msgToLastStep.get(closestMsgIndex) ?? -1;
1050
1086
  };
1051
1087
 
1052
- // Find the first visible message index in an element subtree
1088
+ // Check whether a note's preceding message is a self-call.
1089
+ // Self-call loopback arrows extend SELF_CALL_HEIGHT below the step Y,
1090
+ // so notes after self-calls need a larger vertical offset.
1091
+ const isNoteAfterSelfCall = (note: SequenceNote): boolean => {
1092
+ let closestMsgIndex = -1;
1093
+ let closestLine = -1;
1094
+ for (let mi = 0; mi < messages.length; mi++) {
1095
+ if (
1096
+ messages[mi].lineNumber < note.lineNumber &&
1097
+ messages[mi].lineNumber > closestLine
1098
+ ) {
1099
+ closestLine = messages[mi].lineNumber;
1100
+ closestMsgIndex = mi;
1101
+ }
1102
+ }
1103
+ if (closestMsgIndex < 0) return false;
1104
+ const msg = messages[closestMsgIndex];
1105
+ return msg.from === msg.to;
1106
+ };
1107
+
1108
+ // Extra gap below self-call loop before note starts
1109
+ const SELF_CALL_NOTE_GAP = 8;
1110
+ const noteOffsetBelow = (note: SequenceNote): number =>
1111
+ isNoteAfterSelfCall(note)
1112
+ ? SELF_CALL_HEIGHT + NOTE_OFFSET_BELOW + SELF_CALL_NOTE_GAP
1113
+ : NOTE_OFFSET_BELOW;
1114
+
1115
+ // Find the first visible message index in an element subtree.
1116
+ // Use lineNumber lookup instead of indexOf — collapse projection creates
1117
+ // separate spread copies for messages[] and elements[], breaking reference equality.
1118
+ const msgLineToIdx = new Map<number, number>();
1119
+ messages.forEach((m, i) => msgLineToIdx.set(m.lineNumber, i));
1120
+
1053
1121
  const findFirstMsgIndex = (els: SequenceElement[]): number => {
1054
1122
  for (const el of els) {
1055
1123
  if (isSequenceBlock(el)) {
@@ -1064,7 +1132,7 @@ export function renderSequenceDiagram(
1064
1132
  const elseIdx = findFirstMsgIndex(el.elseChildren);
1065
1133
  if (elseIdx >= 0) return elseIdx;
1066
1134
  } else if (!isSequenceSection(el) && !isSequenceNote(el)) {
1067
- const idx = messages.indexOf(el as SequenceMessage);
1135
+ const idx = msgLineToIdx.get(el.lineNumber) ?? -1;
1068
1136
  if (idx >= 0 && !hiddenMsgIndices.has(idx)) return idx;
1069
1137
  }
1070
1138
  }
@@ -1121,8 +1189,11 @@ export function renderSequenceDiagram(
1121
1189
  // When notes share horizontal space with subsequent arrows, generous vertical clearance
1122
1190
  // is needed so note boxes don't visually cover message labels.
1123
1191
  const NOTE_TRAILING_GAP = 35;
1124
- const computeNoteHeight = (text: string): number => {
1125
- const lines = wrapTextLines(text, NOTE_CHARS_PER_LINE);
1192
+ const computeNoteHeight = (
1193
+ text: string,
1194
+ maxChars: number = NOTE_CHARS_PER_LINE
1195
+ ): number => {
1196
+ const lines = wrapTextLines(text, maxChars);
1126
1197
  return lines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
1127
1198
  };
1128
1199
  let trailingNoteSpace = 0; // extra space for notes at the end with no following message
@@ -1131,15 +1202,18 @@ export function renderSequenceDiagram(
1131
1202
  const el = els[i];
1132
1203
  if (isSequenceNote(el)) {
1133
1204
  // Total vertical extent of notes from the message arrow:
1134
- // NOTE_OFFSET_BELOW (gap above first note)
1205
+ // offset (gap above first note — larger after self-calls)
1135
1206
  // + each note's height + NOTE_OFFSET_BELOW (inter-note gap)
1136
1207
  // + NOTE_TRAILING_GAP (gap below last note — clears next message label)
1137
- let totalExtent = NOTE_OFFSET_BELOW;
1208
+ const firstOffset = noteOffsetBelow(el as SequenceNote);
1209
+ let totalExtent = firstOffset;
1138
1210
  let j = i;
1139
1211
  while (j < els.length && isSequenceNote(els[j])) {
1140
1212
  const note = els[j] as SequenceNote;
1213
+ const sc = isNoteAfterSelfCall(note);
1214
+ const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
1141
1215
  const noteH = isNoteExpanded(note)
1142
- ? computeNoteHeight(note.text)
1216
+ ? computeNoteHeight(note.text, charsForWidth(maxW))
1143
1217
  : COLLAPSED_NOTE_H;
1144
1218
  totalExtent += noteH + NOTE_OFFSET_BELOW;
1145
1219
  j++;
@@ -1401,13 +1475,18 @@ export function renderSequenceDiagram(
1401
1475
  let noteTopY: number;
1402
1476
  if (prevNoteY !== undefined && prevNote) {
1403
1477
  // Stack below previous note
1478
+ const prevMaxW = noteEffectiveMaxW(
1479
+ prevNote.participantId,
1480
+ prevNote.position,
1481
+ isNoteAfterSelfCall(prevNote)
1482
+ );
1404
1483
  const prevNoteH = isNoteExpanded(prevNote)
1405
- ? computeNoteHeight(prevNote.text)
1484
+ ? computeNoteHeight(prevNote.text, charsForWidth(prevMaxW))
1406
1485
  : COLLAPSED_NOTE_H;
1407
1486
  noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
1408
1487
  } else {
1409
- // First note after a message
1410
- noteTopY = stepY(si) + NOTE_OFFSET_BELOW;
1488
+ // First note after a message — use larger offset after self-calls
1489
+ noteTopY = stepY(si) + noteOffsetBelow(el);
1411
1490
  }
1412
1491
  noteYMap.set(el, noteTopY);
1413
1492
  } else if (isSequenceBlock(el)) {
@@ -1435,8 +1514,13 @@ export function renderSequenceDiagram(
1435
1514
  )
1436
1515
  : layoutEndY;
1437
1516
  for (const [note, noteTopY] of noteYMap) {
1517
+ const maxW = noteEffectiveMaxW(
1518
+ note.participantId,
1519
+ note.position,
1520
+ isNoteAfterSelfCall(note)
1521
+ );
1438
1522
  const noteH = isNoteExpanded(note)
1439
- ? computeNoteHeight(note.text)
1523
+ ? computeNoteHeight(note.text, charsForWidth(maxW))
1440
1524
  : COLLAPSED_NOTE_H;
1441
1525
  contentBottomY = Math.max(
1442
1526
  contentBottomY,
@@ -2093,7 +2177,6 @@ export function renderSequenceDiagram(
2093
2177
  }
2094
2178
 
2095
2179
  // Render activation rectangles (behind arrows)
2096
- const ACTIVATION_WIDTH = 10;
2097
2180
  const ACTIVATION_NEST_OFFSET = 6;
2098
2181
  activations.forEach((act) => {
2099
2182
  const px = participantX.get(act.participantId);
@@ -2285,8 +2368,7 @@ export function renderSequenceDiagram(
2285
2368
  }
2286
2369
 
2287
2370
  // Render steps (calls and returns in stack-inferred order)
2288
- const SELF_CALL_WIDTH = 30;
2289
- const SELF_CALL_HEIGHT = 25;
2371
+ // SELF_CALL_WIDTH is now a module-level constant
2290
2372
  renderSteps.forEach((step, i) => {
2291
2373
  const fromX = participantX.get(step.from);
2292
2374
  const toX = participantX.get(step.to);
@@ -2354,6 +2436,10 @@ export function renderSequenceDiagram(
2354
2436
  .attr('y', y + SELF_CALL_HEIGHT / 2 + 4)
2355
2437
  .attr('text-anchor', 'start')
2356
2438
  .attr('fill', arrowColor)
2439
+ .attr('paint-order', 'stroke fill')
2440
+ .attr('stroke', palette.bg)
2441
+ .attr('stroke-width', 4)
2442
+ .attr('stroke-linejoin', 'round')
2357
2443
  .attr('font-size', 12)
2358
2444
  .attr('class', 'message-label')
2359
2445
  .attr(
@@ -2365,7 +2451,9 @@ export function renderSequenceDiagram(
2365
2451
  if (tagKey && msgTagValue) {
2366
2452
  labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2367
2453
  }
2368
- renderInlineText(labelEl, step.label, palette);
2454
+ // TD-1: in-arrow labels render as plain text (no markdown interpretation).
2455
+ // Fixes the `location[]`-style silent character drop.
2456
+ labelEl.text(step.label);
2369
2457
  }
2370
2458
  } else {
2371
2459
  // Normal call arrow — snap to activation box edges
@@ -2422,6 +2510,10 @@ export function renderSequenceDiagram(
2422
2510
  .attr('y', y - 8)
2423
2511
  .attr('text-anchor', 'middle')
2424
2512
  .attr('fill', arrowColor)
2513
+ .attr('paint-order', 'stroke fill')
2514
+ .attr('stroke', palette.bg)
2515
+ .attr('stroke-width', 4)
2516
+ .attr('stroke-linejoin', 'round')
2425
2517
  .attr('font-size', 12)
2426
2518
  .attr('class', 'message-label')
2427
2519
  .attr(
@@ -2433,7 +2525,9 @@ export function renderSequenceDiagram(
2433
2525
  if (tagKey && msgTagValue) {
2434
2526
  labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2435
2527
  }
2436
- renderInlineText(labelEl, step.label, palette);
2528
+ // TD-1: in-arrow labels render as plain text (no markdown interpretation).
2529
+ // Fixes the `location[]`-style silent character drop.
2530
+ labelEl.text(step.label);
2437
2531
  }
2438
2532
  }
2439
2533
  } else {
@@ -2494,6 +2588,10 @@ export function renderSequenceDiagram(
2494
2588
  .attr('y', y - 6)
2495
2589
  .attr('text-anchor', 'middle')
2496
2590
  .attr('fill', returnColor)
2591
+ .attr('paint-order', 'stroke fill')
2592
+ .attr('stroke', palette.bg)
2593
+ .attr('stroke-width', 4)
2594
+ .attr('stroke-linejoin', 'round')
2497
2595
  .attr('font-size', 11)
2498
2596
  .attr('class', 'message-label')
2499
2597
  .attr(
@@ -2505,7 +2603,12 @@ export function renderSequenceDiagram(
2505
2603
  if (tagKey && msgTagValue) {
2506
2604
  labelEl.attr(`data-tag-${tagKey}`, msgTagValue.toLowerCase());
2507
2605
  }
2508
- renderInlineText(labelEl, step.label, palette);
2606
+ // TD-1: in-arrow labels render as plain text (no markdown
2607
+ // interpretation). Return-arrow labels are currently always empty
2608
+ // (buildRenderSequence sets them to '') but this path is kept in
2609
+ // sync with the call/self-call sites above to prevent a future
2610
+ // change resurrecting the location[] silent-drop bug.
2611
+ labelEl.text(step.label);
2509
2612
  }
2510
2613
  }
2511
2614
  });
@@ -2530,15 +2633,27 @@ export function renderSequenceDiagram(
2530
2633
 
2531
2634
  if (expanded) {
2532
2635
  // --- Expanded note: full folded-corner box with wrapped text ---
2533
- const wrappedLines = wrapTextLines(el.text, NOTE_CHARS_PER_LINE);
2636
+ const afterSelfCall = isNoteAfterSelfCall(el);
2637
+ const maxW = noteEffectiveMaxW(
2638
+ el.participantId,
2639
+ el.position,
2640
+ afterSelfCall
2641
+ );
2642
+ const maxChars = charsForWidth(maxW);
2643
+ const wrappedLines = wrapTextLines(el.text, maxChars);
2534
2644
  const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
2535
2645
  const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));
2536
2646
  const noteW = Math.min(
2537
- NOTE_MAX_W,
2647
+ maxW,
2538
2648
  Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)
2539
2649
  );
2650
+ // Shift notes past self-call loopback when applicable
2651
+ const rightOffset =
2652
+ afterSelfCall && isRight
2653
+ ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2654
+ : ACTIVATION_WIDTH + NOTE_GAP;
2540
2655
  const noteX = isRight
2541
- ? px + ACTIVATION_WIDTH + NOTE_GAP
2656
+ ? px + rightOffset
2542
2657
  : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
2543
2658
 
2544
2659
  const noteG = svg
@@ -2612,8 +2727,13 @@ export function renderSequenceDiagram(
2612
2727
  } else {
2613
2728
  // --- Collapsed note: compact indicator ---
2614
2729
  const cFold = 6;
2730
+ const afterSelfCallC = isNoteAfterSelfCall(el);
2731
+ const rightOffsetC =
2732
+ afterSelfCallC && isRight
2733
+ ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2734
+ : ACTIVATION_WIDTH + NOTE_GAP;
2615
2735
  const noteX = isRight
2616
- ? px + ACTIVATION_WIDTH + NOTE_GAP
2736
+ ? px + rightOffsetC
2617
2737
  : px - ACTIVATION_WIDTH - NOTE_GAP - COLLAPSED_NOTE_W;
2618
2738
 
2619
2739
  const noteG = svg
package/src/sharing.ts CHANGED
@@ -30,6 +30,8 @@ export interface CompactViewState {
30
30
  rps?: number; // RPS multiplier (infra)
31
31
  spd?: number; // playback speed (infra)
32
32
  io?: Record<string, number>; // instance overrides (infra)
33
+ hd?: boolean; // hide descriptions (mindmap)
34
+ cbd?: boolean; // color by depth (mindmap)
33
35
  }
34
36
 
35
37
  export interface DecodedDiagramUrl {
@@ -396,15 +396,15 @@ export function layoutSitemap(
396
396
  const pageNodeIds = new Set<string>();
397
397
  const collapsedContainerIds = new Set<string>();
398
398
 
399
- // Identify containers vs pages, and detect collapsed (empty) containers
399
+ // Identify containers vs pages, and detect collapsed containers.
400
+ // A container is collapsed iff hiddenCounts records it with a positive count
401
+ // (meaning collapseSitemapTree pruned its descendants). Source-level empty
402
+ // containers (never had children) are NOT treated as collapsed.
400
403
  for (const flat of flatNodes) {
401
404
  if (flat.sitemapNode.isContainer) {
402
405
  containerIds.add(flat.sitemapNode.id);
403
- // A container is "collapsed" if it has no children at all in the flat list
404
- const hasAnyChild = flatNodes.some(
405
- (f) => f.parentContainerId === flat.sitemapNode.id
406
- );
407
- if (!hasAnyChild) {
406
+ const hidden = hiddenCounts?.get(flat.sitemapNode.id) ?? 0;
407
+ if (hidden > 0) {
408
408
  collapsedContainerIds.add(flat.sitemapNode.id);
409
409
  }
410
410
  } else {
@@ -412,16 +412,31 @@ export function layoutSitemap(
412
412
  }
413
413
  }
414
414
 
415
+ // Sibling-page floor for collapsed containers — prevents collapsed containers
416
+ // from rendering smaller than meta-rich page cards in the same layout.
417
+ let pageMaxW = 0;
418
+ let pageMaxH = 0;
419
+ for (const f of flatNodes) {
420
+ if (!f.sitemapNode.isContainer) {
421
+ if (f.width > pageMaxW) pageMaxW = f.width;
422
+ if (f.height > pageMaxH) pageMaxH = f.height;
423
+ }
424
+ }
425
+
415
426
  // Add nodes to dagre
416
427
  for (const flat of flatNodes) {
417
428
  const node = flat.sitemapNode;
418
429
  if (node.isContainer) {
419
430
  if (collapsedContainerIds.has(node.id)) {
420
- // Collapsed container — regular node with explicit dimensions
431
+ // Collapsed container — regular node with explicit dimensions.
432
+ // Floor to max page-card dims so collapsed containers never look
433
+ // smaller than sibling page cards.
434
+ const flooredW = Math.max(flat.width, pageMaxW);
435
+ const flooredH = Math.max(flat.height, pageMaxH);
421
436
  g.setNode(node.id, {
422
437
  label: node.label,
423
- width: flat.width,
424
- height: flat.height,
438
+ width: flooredW,
439
+ height: flooredH,
425
440
  });
426
441
  } else {
427
442
  // Regular container — compound node with padding for child layout
@@ -546,7 +561,15 @@ export function layoutSitemap(
546
561
  node.children.length > 0 || (hc != null && hc > 0) || undefined,
547
562
  });
548
563
  } else {
549
- // Fallback
564
+ // Fallback — still apply the floor for consistency
565
+ const isCollapsed = collapsedContainerIds.has(node.id);
566
+ const flooredW = isCollapsed
567
+ ? Math.max(flat.width, pageMaxW)
568
+ : flat.width;
569
+ const fallbackH = isCollapsed
570
+ ? flat.height
571
+ : labelHeight + CONTAINER_PAD_BOTTOM;
572
+ const flooredH = isCollapsed ? Math.max(fallbackH, pageMaxH) : fallbackH;
550
573
  layoutContainers.push({
551
574
  nodeId: node.id,
552
575
  label: node.label,
@@ -556,8 +579,8 @@ export function layoutSitemap(
556
579
  tagMetadata: flat.fullMeta,
557
580
  x: MARGIN,
558
581
  y: MARGIN,
559
- width: flat.width,
560
- height: labelHeight + CONTAINER_PAD_BOTTOM,
582
+ width: flooredW,
583
+ height: flooredH,
561
584
  labelHeight,
562
585
  hiddenCount: hc,
563
586
  hasChildren:
@@ -704,8 +704,6 @@ export async function renderSitemapForExport(
704
704
  const { parseSitemap } = await import('./parser');
705
705
  const { layoutSitemap } = await import('./layout');
706
706
  const { getPalette } = await import('../palettes');
707
- const { injectBranding } = await import('../branding');
708
-
709
707
  const isDark = theme === 'dark';
710
708
  const effectivePalette =
711
709
  palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
@@ -756,8 +754,5 @@ export async function renderSitemapForExport(
756
754
 
757
755
  const svgHtml = svgEl.outerHTML;
758
756
  document.body.removeChild(container);
759
-
760
- const brandColor =
761
- theme === 'transparent' ? '#888' : effectivePalette.textMuted;
762
- return injectBranding(svgHtml, brandColor);
757
+ return svgHtml;
763
758
  }
@@ -5,6 +5,24 @@
5
5
  // Labeled arrow syntax (always left-to-right):
6
6
  // Sync: `-label->`
7
7
  // Async: `~label~>`
8
+ //
9
+ // In-arrow label character-set contract (see docs/dgmo-language-spec.md
10
+ // §"In-Arrow Message Labels"):
11
+ // - Allowed: any codepoint except the forbidden substrings and forbidden
12
+ // control characters below.
13
+ // - Forbidden substrings: "->", "~>" (arrow-token lookalikes inside labels).
14
+ // Use the post-colon form for labels that need these symbols:
15
+ // `A -> B: uses -> to chain`
16
+ // - Forbidden characters: C0 control chars U+0000–U+001F EXCEPT U+0009 (tab),
17
+ // and U+007F (DEL).
18
+ // - Whitespace: leading/trailing trimmed; internal runs (incl. tab, NBSP,
19
+ // ZWSP) preserved — never collapsed.
20
+ // - Plain text only: no markdown interpretation. `*`, `_`, backticks,
21
+ // `[`, `]`, `{`, `}` are literal characters.
22
+
23
+ import type { DgmoError } from '../diagnostics';
24
+ import { makeDgmoError } from '../diagnostics';
25
+ import { RECOGNIZED_COLOR_NAMES } from '../colors';
8
26
 
9
27
  interface ParsedArrow {
10
28
  from: string;
@@ -13,6 +31,160 @@ interface ParsedArrow {
13
31
  async: boolean;
14
32
  }
15
33
 
34
+ // ============================================================
35
+ // Diagnostic codes (TD-16)
36
+ // ============================================================
37
+
38
+ /**
39
+ * Stable diagnostic codes for in-arrow label parsing errors.
40
+ *
41
+ * **Active codes** — emitted by the parser pipeline today:
42
+ * - `ARROW_SUBSTRING_IN_LABEL` (TD-13)
43
+ * - `CONTROL_CHAR_IN_LABEL` (TD-14)
44
+ *
45
+ * **Reserved codes** — declared but NOT currently emitted. These are
46
+ * placeholders for future tightening of the arrow-tokenization rules
47
+ * described in TD-9. Today's chart parsers catch these cases through
48
+ * their own regex machinery with different diagnostics. A follow-up
49
+ * spec that introduces a dedicated tokenizer can start emitting them
50
+ * without changing the public code shape:
51
+ * - `TRAILING_ARROW_TEXT` — extra `->`/`~>` after the primary arrow
52
+ * - `MIXED_ARROW_DELIMITERS` — opening delim type doesn't match arrow
53
+ *
54
+ * See `docs/dgmo-language-spec-decisions.md` → TD-16 for the rationale.
55
+ */
56
+ export const ARROW_DIAGNOSTIC_CODES = {
57
+ /** Active: label contains `->` or `~>` substring (TD-13). */
58
+ ARROW_SUBSTRING_IN_LABEL: 'E_ARROW_SUBSTRING_IN_LABEL',
59
+ /** Active: label contains a forbidden control character (TD-14). */
60
+ CONTROL_CHAR_IN_LABEL: 'E_CONTROL_CHAR_IN_LABEL',
61
+ /** Reserved: not currently emitted by any parser. See JSDoc above. */
62
+ TRAILING_ARROW_TEXT: 'E_TRAILING_ARROW_TEXT',
63
+ /** Reserved: not currently emitted by any parser. See JSDoc above. */
64
+ MIXED_ARROW_DELIMITERS: 'E_MIXED_ARROW_DELIMITERS',
65
+ } as const;
66
+
67
+ // ============================================================
68
+ // validateLabelCharacters (TD-13, TD-14)
69
+ // ============================================================
70
+
71
+ /**
72
+ * Validate an in-arrow label against the TD-13 and TD-14 character-set
73
+ * contract. Returns diagnostics (possibly empty). Does NOT mutate the label —
74
+ * callers that want a normalized label should trim before calling.
75
+ *
76
+ * TD-13: label must not contain the substrings "->" or "~>".
77
+ * TD-14: label must not contain C0 control chars other than tab, and no DEL.
78
+ */
79
+ export function validateLabelCharacters(
80
+ label: string,
81
+ lineNumber: number
82
+ ): DgmoError[] {
83
+ const out: DgmoError[] = [];
84
+
85
+ // TD-13: forbidden substrings
86
+ if (label.includes('->') || label.includes('~>')) {
87
+ out.push(
88
+ makeDgmoError(
89
+ lineNumber,
90
+ 'Arrow symbols (-> or ~>) are not allowed inside a label. ' +
91
+ 'Move the label after the arrow: "A -> B: uses -> to chain". ' +
92
+ 'See "In-Arrow Message Labels" → Forbidden.',
93
+ 'error',
94
+ ARROW_DIAGNOSTIC_CODES.ARROW_SUBSTRING_IN_LABEL
95
+ )
96
+ );
97
+ }
98
+
99
+ // TD-14: control chars (iterate codepoints to handle surrogate pairs)
100
+ for (const ch of label) {
101
+ const cp = ch.codePointAt(0)!;
102
+ const isC0 = cp >= 0x00 && cp <= 0x1f && cp !== 0x09; // allow tab
103
+ const isDel = cp === 0x7f;
104
+ if (isC0 || isDel) {
105
+ const hex = cp.toString(16).toUpperCase().padStart(4, '0');
106
+ out.push(
107
+ makeDgmoError(
108
+ lineNumber,
109
+ `Label contains a control character (U+${hex}). ` +
110
+ 'Remove it and use plain text.',
111
+ 'error',
112
+ ARROW_DIAGNOSTIC_CODES.CONTROL_CHAR_IN_LABEL
113
+ )
114
+ );
115
+ break; // one diagnostic per label is enough
116
+ }
117
+ }
118
+
119
+ return out;
120
+ }
121
+
122
+ // ============================================================
123
+ // parseInArrowLabel (TD-1, TD-8, TD-10, TD-13, TD-14)
124
+ // ============================================================
125
+
126
+ export interface ParseInArrowLabelResult {
127
+ /** Cleaned label (trimmed; `undefined` if empty after trim per TD-10). */
128
+ label: string | undefined;
129
+ diagnostics: DgmoError[];
130
+ }
131
+
132
+ /**
133
+ * Normalize and validate a raw in-arrow label.
134
+ *
135
+ * Behavior:
136
+ * - Trims leading/trailing whitespace (TD-8: internal whitespace preserved).
137
+ * - Empty-after-trim → `{ label: undefined }` (TD-10 normalization).
138
+ * - TD-13: emits `E_ARROW_SUBSTRING_IN_LABEL` if `->` or `~>` is present.
139
+ * - TD-14: emits `E_CONTROL_CHAR_IN_LABEL` for forbidden control chars.
140
+ *
141
+ * This helper is intentionally chart-agnostic: it operates on an already
142
+ * extracted label string, leaving each chart's existing arrow-finding
143
+ * tokenization in place. TD-11 color-parens is handled inside the
144
+ * flowchart and state `parseArrowToken` functions because those are the
145
+ * only charts that interpret `-(color)->` as a colored edge; they use
146
+ * `matchColorParens()` from this module for the shared lookup.
147
+ */
148
+ export function parseInArrowLabel(
149
+ rawLabel: string,
150
+ lineNumber: number
151
+ ): ParseInArrowLabelResult {
152
+ const trimmed = rawLabel.trim();
153
+
154
+ // TD-10: empty/whitespace-only label normalizes to undefined
155
+ if (trimmed.length === 0) {
156
+ return { label: undefined, diagnostics: [] };
157
+ }
158
+
159
+ // TD-13 / TD-14 validation
160
+ const diagnostics = validateLabelCharacters(trimmed, lineNumber);
161
+
162
+ return { label: trimmed, diagnostics };
163
+ }
164
+
165
+ // ============================================================
166
+ // matchColorParens — shared TD-11 helper for flowchart and state
167
+ // ============================================================
168
+
169
+ /**
170
+ * Test whether a string matches the TD-11 color-parens form `(colorName)`
171
+ * where `colorName` is one of the 11 recognized palette color names from
172
+ * `src/colors.ts:RECOGNIZED_COLOR_NAMES`. Returns the lowercase color name
173
+ * on a match, or `null` on fall-through (whole string becomes a label).
174
+ *
175
+ * Used by flowchart and state parsers to keep the color-parens recognition
176
+ * rule in one place — do NOT re-implement the regex in chart parsers.
177
+ */
178
+ export function matchColorParens(content: string): string | null {
179
+ const m = content.match(/^\(([A-Za-z]+)\)$/);
180
+ if (!m) return null;
181
+ const candidate = m[1].toLowerCase();
182
+ if ((RECOGNIZED_COLOR_NAMES as readonly string[]).includes(candidate)) {
183
+ return candidate;
184
+ }
185
+ return null;
186
+ }
187
+
16
188
  // Forward (call) patterns — participant names may contain spaces, so use non-greedy (.+?)
17
189
  const SYNC_LABELED_RE = /^(.+?)\s*-(.+)->\s*(.+)$/;
18
190
  const ASYNC_LABELED_RE = /^(.+?)\s*~(.+)~>\s*(.+)$/;
@@ -23,8 +195,6 @@ const RETURN_ASYNC_LABELED_RE = /^(.+?)\s*<~(.+)~\s*(.+)$/;
23
195
  const BIDI_SYNC_RE = /^(.+?)\s*<-(.+)->\s*(.+)$/;
24
196
  const BIDI_ASYNC_RE = /^(.+?)\s*<~(.+)~>\s*(.+)$/;
25
197
 
26
- const ARROW_CHARS = ['->', '~>'];
27
-
28
198
  /**
29
199
  * Try to parse a labeled arrow from a trimmed line.
30
200
  *
@@ -32,6 +202,14 @@ const ARROW_CHARS = ['->', '~>'];
32
202
  * - `ParsedArrow` if matched and valid
33
203
  * - `{ error: string }` if matched but invalid (deprecated syntax)
34
204
  * - `null` if not a labeled arrow (caller should fall through to bare patterns)
205
+ *
206
+ * Note: arrow-char-in-label validation (TD-13) is NOT performed here —
207
+ * callers must route the returned `label` through `parseInArrowLabel` or
208
+ * `validateLabelCharacters` to get the unified `E_ARROW_SUBSTRING_IN_LABEL`
209
+ * diagnostic with the correct code. In practice this path is unreachable
210
+ * because arrow regexes are greedy enough to absorb inner `->`/`~>` tokens
211
+ * into the source/destination captures, but the check remains at the
212
+ * validator level for defense in depth.
35
213
  */
36
214
  export function parseArrow(
37
215
  line: string
@@ -73,15 +251,6 @@ export function parseArrow(
73
251
  // Empty label (e.g. `--> B`) — fall through to plain arrow handling
74
252
  if (!label) return null;
75
253
 
76
- // Validate: no arrow chars inside label
77
- for (const arrow of ARROW_CHARS) {
78
- if (label.includes(arrow)) {
79
- return {
80
- error: 'Arrow characters (->, ~>) are not allowed inside labels',
81
- };
82
- }
83
- }
84
-
85
254
  return {
86
255
  from: m[1],
87
256
  to: m[3],
@@ -0,0 +1,4 @@
1
+ export interface D3ExportDimensions {
2
+ width?: number;
3
+ height?: number;
4
+ }
@@ -7,7 +7,7 @@ import { FONT_FAMILY } from '../fonts';
7
7
  export function runInExportContainer<T>(
8
8
  width: number,
9
9
  height: number,
10
- fn: (container: HTMLDivElement) => T,
10
+ fn: (container: HTMLDivElement) => T
11
11
  ): T {
12
12
  const container = document.createElement('div');
13
13
  container.style.width = `${width}px`;
@@ -29,12 +29,13 @@ export function runInExportContainer<T>(
29
29
  */
30
30
  export function extractExportSvg(
31
31
  container: HTMLElement,
32
- theme: 'light' | 'dark' | 'transparent',
32
+ theme: 'light' | 'dark' | 'transparent'
33
33
  ): string {
34
34
  const svgEl = container.querySelector('svg');
35
35
  if (!svgEl) return '';
36
36
  if (theme === 'transparent') svgEl.style.background = 'none';
37
37
  svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
38
38
  svgEl.style.fontFamily = FONT_FAMILY;
39
+ svgEl.querySelectorAll('[data-export-ignore]').forEach((el) => el.remove());
39
40
  return svgEl.outerHTML;
40
41
  }
@@ -16,10 +16,6 @@ export const LEGEND_EYE_SIZE = 14;
16
16
  export const LEGEND_EYE_GAP = 6;
17
17
  export const LEGEND_ICON_W = 20;
18
18
 
19
- // ── Spacing constants (centralized legend system) ───────────
20
- export const LEGEND_TOP_PAD = 12;
21
- export const LEGEND_TITLE_GAP = 8;
22
- export const LEGEND_CONTENT_GAP = 12;
23
19
  export const LEGEND_MAX_ENTRY_ROWS = 3;
24
20
 
25
21
  // ── Proportional text measurement ────────────────────────────