@diagrammo/dgmo 0.2.27 → 0.3.0

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 (49) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +107 -0
  2. package/.claude/skills/dgmo-flowchart/SKILL.md +61 -0
  3. package/.claude/skills/dgmo-generate/SKILL.md +58 -0
  4. package/.claude/skills/dgmo-sequence/SKILL.md +83 -0
  5. package/.cursorrules +117 -0
  6. package/.github/copilot-instructions.md +117 -0
  7. package/.windsurfrules +117 -0
  8. package/README.md +10 -3
  9. package/dist/cli.cjs +366 -918
  10. package/dist/index.cjs +581 -396
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +39 -24
  13. package/dist/index.d.ts +39 -24
  14. package/dist/index.js +578 -395
  15. package/dist/index.js.map +1 -1
  16. package/docs/ai-integration.md +125 -0
  17. package/docs/language-reference.md +786 -0
  18. package/package.json +15 -8
  19. package/src/c4/parser.ts +90 -74
  20. package/src/c4/renderer.ts +13 -12
  21. package/src/c4/types.ts +6 -4
  22. package/src/chart.ts +3 -2
  23. package/src/class/layout.ts +17 -12
  24. package/src/class/parser.ts +22 -52
  25. package/src/class/renderer.ts +44 -46
  26. package/src/class/types.ts +1 -1
  27. package/src/cli.ts +130 -19
  28. package/src/d3.ts +1 -1
  29. package/src/dgmo-mermaid.ts +1 -1
  30. package/src/dgmo-router.ts +1 -1
  31. package/src/echarts.ts +33 -13
  32. package/src/er/parser.ts +34 -43
  33. package/src/er/types.ts +1 -1
  34. package/src/graph/flowchart-parser.ts +2 -25
  35. package/src/graph/types.ts +1 -1
  36. package/src/index.ts +5 -0
  37. package/src/initiative-status/parser.ts +36 -7
  38. package/src/initiative-status/types.ts +1 -1
  39. package/src/kanban/parser.ts +32 -53
  40. package/src/kanban/renderer.ts +9 -8
  41. package/src/kanban/types.ts +6 -14
  42. package/src/org/parser.ts +47 -87
  43. package/src/org/resolver.ts +11 -12
  44. package/src/sequence/parser.ts +97 -15
  45. package/src/sequence/renderer.ts +62 -69
  46. package/src/utils/arrows.ts +75 -0
  47. package/src/utils/inline-markdown.ts +75 -0
  48. package/src/utils/parsing.ts +67 -0
  49. package/src/utils/tag-groups.ts +76 -0
package/src/org/parser.ts CHANGED
@@ -1,26 +1,25 @@
1
- import { resolveColor } from '../colors';
2
1
  import type { PaletteColors } from '../palettes';
3
2
  import type { DgmoError } from '../diagnostics';
4
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
5
+ import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
6
+ import {
7
+ measureIndent,
8
+ extractColor,
9
+ parsePipeMetadata,
10
+ CHART_TYPE_RE,
11
+ TITLE_RE,
12
+ OPTION_RE,
13
+ } from '../utils/parsing';
5
14
 
6
15
  // ============================================================
7
16
  // Types
8
17
  // ============================================================
9
18
 
10
- export interface OrgTagEntry {
11
- value: string;
12
- color: string;
13
- lineNumber: number;
14
- }
15
-
16
- export interface OrgTagGroup {
17
- name: string;
18
- alias?: string;
19
- entries: OrgTagEntry[];
20
- /** Value of the entry marked `default` (nodes without metadata get this) */
21
- defaultValue?: string;
22
- lineNumber: number;
23
- }
19
+ /** @deprecated Use `TagEntry` from `utils/tag-groups` */
20
+ export type OrgTagEntry = TagEntry;
21
+ /** @deprecated Use `TagGroup` from `utils/tag-groups` */
22
+ export type OrgTagGroup = TagGroup;
24
23
 
25
24
  export interface OrgNode {
26
25
  id: string;
@@ -47,48 +46,19 @@ export interface ParsedOrg {
47
46
  // Helpers
48
47
  // ============================================================
49
48
 
50
- function measureIndent(line: string): number {
51
- let indent = 0;
52
- for (const ch of line) {
53
- if (ch === ' ') indent++;
54
- else if (ch === '\t') indent += 4;
55
- else break;
56
- }
57
- return indent;
58
- }
59
-
60
- const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
61
-
62
- function extractColor(
63
- label: string,
64
- palette?: PaletteColors
65
- ): { label: string; color?: string } {
66
- const m = label.match(COLOR_SUFFIX_RE);
67
- if (!m) return { label };
68
- const colorName = m[1].trim();
69
- return {
70
- label: label.substring(0, m.index!).trim(),
71
- color: resolveColor(colorName, palette),
72
- };
73
- }
74
-
75
- const GROUP_HEADING_RE = /^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
76
49
  const CONTAINER_RE = /^\[([^\]]+)\]$/;
77
50
  const METADATA_RE = /^([^:]+):\s*(.+)$/;
78
- const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
79
- const TITLE_RE = /^title\s*:\s*(.+)/i;
80
- const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
81
51
 
82
52
  // ============================================================
83
53
  // Inference
84
54
  // ============================================================
85
55
 
86
- /** Returns true if content contains tag group headings (`## ...`), suggesting an org chart. */
56
+ /** Returns true if content contains tag group headings (`tag: …` or `## …`), suggesting an org chart. */
87
57
  export function looksLikeOrg(content: string): boolean {
88
58
  for (const line of content.split('\n')) {
89
59
  const trimmed = line.trim();
90
60
  if (!trimmed || trimmed.startsWith('//')) continue;
91
- if (GROUP_HEADING_RE.test(trimmed)) return true;
61
+ if (isTagBlockHeading(trimmed)) return true;
92
62
  }
93
63
  return false;
94
64
  }
@@ -125,6 +95,11 @@ export function parseOrg(
125
95
  if (!result.error) result.error = formatDgmoError(diag);
126
96
  };
127
97
 
98
+ /** Push a non-fatal warning (does not set result.error). */
99
+ const pushWarning = (line: number, message: string): void => {
100
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
101
+ };
102
+
128
103
  if (!content || !content.trim()) {
129
104
  return fail(0, 'No content provided');
130
105
  }
@@ -189,41 +164,43 @@ export function parseOrg(
189
164
  }
190
165
  }
191
166
 
192
- // Generic header options (key: value lines before content/tag groups)
193
- // Only match non-indented lines with simple hyphenated keys
194
- if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
195
- const optMatch = trimmed.match(OPTION_RE);
196
- if (optMatch && !trimmed.startsWith('##')) {
197
- const key = optMatch[1].trim().toLowerCase();
198
- if (key !== 'chart' && key !== 'title') {
199
- result.options[key] = optMatch[2].trim();
200
- continue;
201
- }
202
- }
203
- }
204
-
205
- // ## Tag group heading
206
- const groupMatch = trimmed.match(GROUP_HEADING_RE);
207
- if (groupMatch) {
167
+ // Tag group heading — `tag: Name` (new) or `## Name` (deprecated)
168
+ // Must be checked BEFORE OPTION_RE to prevent `tag: Rank` being swallowed as option `tag=Rank`
169
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
170
+ if (tagBlockMatch) {
208
171
  if (contentStarted) {
209
- pushError(lineNumber, 'Tag groups (##) must appear before org content');
172
+ pushError(lineNumber, 'Tag groups must appear before org content');
210
173
  continue;
211
174
  }
212
- const groupName = groupMatch[1].trim();
213
- const alias = groupMatch[2] || undefined;
175
+ if (tagBlockMatch.deprecated) {
176
+ pushWarning(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
177
+ }
214
178
  currentTagGroup = {
215
- name: groupName,
216
- alias,
179
+ name: tagBlockMatch.name,
180
+ alias: tagBlockMatch.alias,
217
181
  entries: [],
218
182
  lineNumber,
219
183
  };
220
- if (alias) {
221
- aliasMap.set(alias.toLowerCase(), groupName.toLowerCase());
184
+ if (tagBlockMatch.alias) {
185
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
222
186
  }
223
187
  result.tagGroups.push(currentTagGroup);
224
188
  continue;
225
189
  }
226
190
 
191
+ // Generic header options (key: value lines before content/tag groups)
192
+ // Only match non-indented lines with simple hyphenated keys
193
+ if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
194
+ const optMatch = trimmed.match(OPTION_RE);
195
+ if (optMatch) {
196
+ const key = optMatch[1].trim().toLowerCase();
197
+ if (key !== 'chart' && key !== 'title') {
198
+ result.options[key] = optMatch[2].trim();
199
+ continue;
200
+ }
201
+ }
202
+ }
203
+
227
204
  // Tag group entries (indented Value(color) [default] under ## heading)
228
205
  if (currentTagGroup && !contentStarted) {
229
206
  const indent = measureIndent(line);
@@ -342,24 +319,7 @@ function parseNodeLabel(
342
319
  let rawLabel = segments[0];
343
320
  const { label, color } = extractColor(rawLabel, palette);
344
321
 
345
- const metadata: Record<string, string> = {};
346
- // Collect all metadata parts: split pipe segments further on commas
347
- const metaParts: string[] = [];
348
- for (let j = 1; j < segments.length; j++) {
349
- for (const part of segments[j].split(',')) {
350
- const trimmedPart = part.trim();
351
- if (trimmedPart) metaParts.push(trimmedPart);
352
- }
353
- }
354
- for (const part of metaParts) {
355
- const colonIdx = part.indexOf(':');
356
- if (colonIdx > 0) {
357
- const rawKey = part.substring(0, colonIdx).trim().toLowerCase();
358
- const key = aliasMap.get(rawKey) ?? rawKey;
359
- const value = part.substring(colonIdx + 1).trim();
360
- metadata[key] = value;
361
- }
362
- }
322
+ const metadata = parsePipeMetadata(segments, aliasMap);
363
323
 
364
324
  return {
365
325
  id: `node-${counter}`,
@@ -1,5 +1,6 @@
1
1
  import type { DgmoError } from '../diagnostics';
2
2
  import { makeDgmoError } from '../diagnostics';
3
+ import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
3
4
 
4
5
  // ============================================================
5
6
  // Types
@@ -25,7 +26,6 @@ const IMPORT_RE = /^(\s+)import:\s+(.+\.dgmo)\s*$/i;
25
26
  const TAGS_RE = /^tags:\s+(.+\.dgmo)\s*$/i;
26
27
  const HEADER_RE = /^(chart|title)\s*:/i;
27
28
  const OPTION_RE = /^[a-z][a-z0-9-]*\s*:/i;
28
- const GROUP_HEADING_RE = /^##\s+/;
29
29
 
30
30
  // ============================================================
31
31
  // Path Helpers (pure string ops — no Node `path` dependency)
@@ -67,10 +67,9 @@ function extractTagGroups(lines: string[]): TagGroupBlock[] {
67
67
 
68
68
  for (const line of lines) {
69
69
  const trimmed = line.trim();
70
- if (GROUP_HEADING_RE.test(trimmed)) {
71
- // Extract group name (everything after "## " up to optional alias/color)
72
- const nameMatch = trimmed.match(/^##\s+(.+?)(?:\s+alias\s+\w+)?(?:\s*\([^)]+\))?\s*$/);
73
- const name = nameMatch ? nameMatch[1].trim().toLowerCase() : trimmed.substring(3).trim().toLowerCase();
70
+ const headingMatch = matchTagBlockHeading(trimmed);
71
+ if (headingMatch) {
72
+ const name = headingMatch.name.toLowerCase();
74
73
  current = { name, lines: [line] };
75
74
  blocks.push(current);
76
75
  } else if (current) {
@@ -144,7 +143,7 @@ function parseFileHeader(lines: string[]): ParsedHeader {
144
143
  }
145
144
 
146
145
  // Other option-like header lines (non-indented key: value)
147
- if (OPTION_RE.test(trimmed) && !trimmed.startsWith('##') && !lines[i].match(/^\s/)) {
146
+ if (OPTION_RE.test(trimmed) && !isTagBlockHeading(trimmed) && !lines[i].match(/^\s/)) {
148
147
  // Check it's not a content line (node with metadata)
149
148
  const key = trimmed.split(':')[0].trim().toLowerCase();
150
149
  if (key !== 'chart' && key !== 'title' && !trimmed.includes('|')) {
@@ -207,7 +206,7 @@ async function resolveFile(
207
206
  headerLines.push(lines[i]);
208
207
  continue;
209
208
  }
210
- if (GROUP_HEADING_RE.test(trimmed)) continue; // skip inline tag group headings
209
+ if (isTagBlockHeading(trimmed)) continue; // skip inline tag group headings
211
210
  if (lines[i] !== trimmed) continue; // skip tag group entries (indented lines)
212
211
 
213
212
  const tagsMatch = trimmed.match(TAGS_RE);
@@ -248,7 +247,7 @@ async function resolveFile(
248
247
  if (!importMatch) {
249
248
  // Pass through — skip inline tag group lines (already extracted above)
250
249
  const trimmed = line.trim();
251
- if (GROUP_HEADING_RE.test(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
250
+ if (isTagBlockHeading(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
252
251
  continue;
253
252
  }
254
253
  resolvedBodyLines.push(line);
@@ -386,7 +385,7 @@ function findBodyStart(lines: string[]): number {
386
385
  }
387
386
 
388
387
  // Tag group heading
389
- if (GROUP_HEADING_RE.test(trimmed)) {
388
+ if (isTagBlockHeading(trimmed)) {
390
389
  inTagGroup = true;
391
390
  continue;
392
391
  }
@@ -405,7 +404,7 @@ function findBodyStart(lines: string[]): number {
405
404
  if (TAGS_RE.test(trimmed)) continue;
406
405
 
407
406
  // Option-like lines (non-indented key: value before content)
408
- if (OPTION_RE.test(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
407
+ if (OPTION_RE.test(trimmed) && !isTagBlockHeading(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
409
408
  const key = trimmed.split(':')[0].trim().toLowerCase();
410
409
  if (key !== 'chart' && key !== 'title') {
411
410
  continue;
@@ -420,7 +419,7 @@ function findBodyStart(lines: string[]): number {
420
419
  }
421
420
 
422
421
  /**
423
- * Check if a line is a tag group entry (indented line under a ## heading).
422
+ * Check if a line is a tag group entry (indented line under a tag block heading).
424
423
  */
425
424
  function isTagGroupEntry(line: string, allLines: string[], index: number): boolean {
426
425
  if (!line.match(/^\s+/)) return false;
@@ -428,7 +427,7 @@ function isTagGroupEntry(line: string, allLines: string[], index: number): boole
428
427
  for (let i = index - 1; i >= 0; i--) {
429
428
  const prev = allLines[i].trim();
430
429
  if (prev === '' || prev.startsWith('//')) continue;
431
- if (GROUP_HEADING_RE.test(prev)) return true;
430
+ if (isTagBlockHeading(prev)) return true;
432
431
  if (allLines[i].match(/^\s+/)) continue; // another entry
433
432
  return false;
434
433
  }
@@ -5,6 +5,8 @@
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';
9
+ import { measureIndent } from '../utils/parsing';
8
10
 
9
11
  /**
10
12
  * Participant types that can be declared via "Name is a type" syntax.
@@ -60,6 +62,7 @@ export interface SequenceMessage {
60
62
  returnLabel?: string;
61
63
  lineNumber: number;
62
64
  async?: boolean;
65
+ bidirectional?: boolean;
63
66
  }
64
67
 
65
68
  /**
@@ -159,8 +162,9 @@ const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
159
162
  // Section divider pattern — "== Label ==", "== Label(color) ==", or "== Label" (trailing == optional)
160
163
  const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
161
164
 
162
- // Arrow pattern for sequence inference — "A -> B: message" or "A ~> B: message"
163
- const ARROW_PATTERN = /\S+\s*(?:->|~>)\s*\S+/;
165
+ // Arrow pattern for sequence inference — "A -> B: message", "A ~> B: message",
166
+ // "A -label-> B", "A ~label~> B", "A <-> B", "A <~> B"
167
+ const ARROW_PATTERN = /\S+\s*(?:<->|<~>|->|~>|-\S+->|~\S+~>|<-\S+->|<~\S+~>)\s*\S+/;
164
168
 
165
169
  // <- return syntax: "Login <- 200 OK"
166
170
  const ARROW_RETURN_PATTERN = /^(.+?)\s*<-\s*(.+)$/;
@@ -212,19 +216,6 @@ function parseReturnLabel(rawLabel: string): {
212
216
  return { label: rawLabel };
213
217
  }
214
218
 
215
- /**
216
- * Measure leading whitespace of a line, normalizing tabs to 4 spaces.
217
- */
218
- function measureIndent(line: string): number {
219
- let indent = 0;
220
- for (const ch of line) {
221
- if (ch === ' ') indent++;
222
- else if (ch === '\t') indent += 4;
223
- else break;
224
- }
225
- return indent;
226
- }
227
-
228
219
  /**
229
220
  * Parse a .dgmo file with `chart: sequence` into a structured representation.
230
221
  */
@@ -519,6 +510,97 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
519
510
  continue;
520
511
  }
521
512
 
513
+ // ---- Labeled arrows: -label->, ~label~>, <-label->, <~label~> ----
514
+ // Must be checked BEFORE plain arrow patterns to avoid partial matches
515
+ const labeledArrow = parseArrow(trimmed);
516
+ if (labeledArrow && 'error' in labeledArrow) {
517
+ pushError(lineNumber, labeledArrow.error);
518
+ continue;
519
+ }
520
+ if (labeledArrow) {
521
+ contentStarted = true;
522
+ const { from, to, label, async: isAsync, bidirectional } = labeledArrow;
523
+ lastMsgFrom = from;
524
+
525
+ const msg: SequenceMessage = {
526
+ from,
527
+ to,
528
+ label,
529
+ returnLabel: undefined,
530
+ lineNumber,
531
+ ...(isAsync ? { async: true } : {}),
532
+ ...(bidirectional ? { bidirectional: true } : {}),
533
+ };
534
+ result.messages.push(msg);
535
+ currentContainer().push(msg);
536
+
537
+ // Auto-register participants
538
+ if (!result.participants.some((p) => p.id === from)) {
539
+ result.participants.push({
540
+ id: from,
541
+ label: from,
542
+ type: inferParticipantType(from),
543
+ lineNumber,
544
+ });
545
+ }
546
+ if (!result.participants.some((p) => p.id === to)) {
547
+ result.participants.push({
548
+ id: to,
549
+ label: to,
550
+ type: inferParticipantType(to),
551
+ lineNumber,
552
+ });
553
+ }
554
+ continue;
555
+ }
556
+
557
+ // ---- Plain bidi arrows: <-> and <~> ----
558
+ // Must be checked BEFORE unidirectional plain arrows
559
+ const bidiSyncMatch = trimmed.match(
560
+ /^(\S+)\s*<->\s*([^\s:]+)\s*(?::\s*(.+))?$/
561
+ );
562
+ const bidiAsyncMatch = trimmed.match(
563
+ /^(\S+)\s*<~>\s*([^\s:]+)\s*(?::\s*(.+))?$/
564
+ );
565
+ const bidiMatch = bidiSyncMatch || bidiAsyncMatch;
566
+ if (bidiMatch) {
567
+ contentStarted = true;
568
+ const from = bidiMatch[1];
569
+ const to = bidiMatch[2];
570
+ lastMsgFrom = from;
571
+ const rawLabel = bidiMatch[3]?.trim() || '';
572
+ const isBidiAsync = !!bidiAsyncMatch;
573
+
574
+ const msg: SequenceMessage = {
575
+ from,
576
+ to,
577
+ label: rawLabel,
578
+ lineNumber,
579
+ bidirectional: true,
580
+ ...(isBidiAsync ? { async: true } : {}),
581
+ };
582
+ result.messages.push(msg);
583
+ currentContainer().push(msg);
584
+
585
+ if (!result.participants.some((p) => p.id === from)) {
586
+ result.participants.push({
587
+ id: from,
588
+ label: from,
589
+ type: inferParticipantType(from),
590
+ lineNumber,
591
+ });
592
+ }
593
+ if (!result.participants.some((p) => p.id === to)) {
594
+ result.participants.push({
595
+ id: to,
596
+ label: to,
597
+ type: inferParticipantType(to),
598
+ lineNumber,
599
+ });
600
+ }
601
+ continue;
602
+ }
603
+
522
604
  // Match ~> (async arrow) or -> (sync arrow)
523
605
  let isAsync = false;
524
606
  const asyncArrowMatch = trimmed.match(
@@ -5,6 +5,13 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import type { PaletteColors } from '../palettes';
7
7
  import { resolveColor } from '../colors';
8
+ import {
9
+ parseInlineMarkdown,
10
+ truncateBareUrl,
11
+ renderInlineText,
12
+ } from '../utils/inline-markdown';
13
+ export type { InlineSpan } from '../utils/inline-markdown';
14
+ export { parseInlineMarkdown, truncateBareUrl };
8
15
  import { FONT_FAMILY } from '../fonts';
9
16
  import type {
10
17
  ParsedSequenceDgmo,
@@ -44,74 +51,6 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
44
51
  const COLLAPSED_NOTE_H = 20;
45
52
  const COLLAPSED_NOTE_W = 40;
46
53
 
47
- interface InlineSpan {
48
- text: string;
49
- bold?: boolean;
50
- italic?: boolean;
51
- code?: boolean;
52
- href?: string;
53
- }
54
-
55
- export function parseInlineMarkdown(text: string): InlineSpan[] {
56
- const spans: InlineSpan[] = [];
57
- const regex =
58
- /\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|(https?:\/\/[^\s)>\]]+|www\.[^\s)>\]]+)|([^*_`[]+?(?=https?:\/\/|www\.|$)|[^*_`[]+)/g;
59
- let match;
60
- while ((match = regex.exec(text)) !== null) {
61
- if (match[1]) spans.push({ text: match[1], bold: true }); // **bold**
62
- else if (match[2]) spans.push({ text: match[2], bold: true }); // __bold__
63
- else if (match[3]) spans.push({ text: match[3], italic: true }); // *italic*
64
- else if (match[4]) spans.push({ text: match[4], italic: true }); // _italic_
65
- else if (match[5]) spans.push({ text: match[5], code: true }); // `code`
66
- else if (match[6]) spans.push({ text: match[6], href: match[7] }); // [text](url)
67
- else if (match[8]) { // bare URL
68
- const url = match[8];
69
- const href = url.startsWith('www.') ? `https://${url}` : url;
70
- spans.push({ text: url, href });
71
- } else if (match[9]) spans.push({ text: match[9] }); // plain text
72
- }
73
- return spans;
74
- }
75
-
76
- const BARE_URL_MAX_DISPLAY = 35;
77
-
78
- export function truncateBareUrl(url: string): string {
79
- const stripped = url.replace(/^https?:\/\//, '').replace(/^www\./, '');
80
- if (stripped.length <= BARE_URL_MAX_DISPLAY) return stripped;
81
- return stripped.slice(0, BARE_URL_MAX_DISPLAY - 1) + '\u2026';
82
- }
83
-
84
- function renderInlineText(
85
- textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
86
- text: string,
87
- palette: PaletteColors,
88
- fontSize?: number
89
- ): void {
90
- const spans = parseInlineMarkdown(text);
91
- for (const span of spans) {
92
- if (span.href) {
93
- // Bare URLs (text === href or href with https:// prepended) get truncated display;
94
- // markdown links [text](url) keep their user-chosen text as-is.
95
- const isBareUrl =
96
- span.text === span.href ||
97
- `https://${span.text}` === span.href;
98
- const display = isBareUrl ? truncateBareUrl(span.text) : span.text;
99
- const a = textEl.append('a').attr('href', span.href);
100
- a.append('tspan')
101
- .text(display)
102
- .attr('fill', palette.primary)
103
- .style('text-decoration', 'underline');
104
- } else {
105
- const tspan = textEl.append('tspan').text(span.text);
106
- if (span.bold) tspan.attr('font-weight', 'bold');
107
- if (span.italic) tspan.attr('font-style', 'italic');
108
- if (span.code) {
109
- tspan.attr('font-family', 'monospace');
110
- if (fontSize) tspan.attr('font-size', fontSize - 1);
111
- }
112
- }
113
- }
114
- }
115
54
 
116
55
  function wrapTextLines(text: string, maxChars: number): string[] {
117
56
  const rawLines = text.split('\n');
@@ -599,6 +538,7 @@ export interface RenderStep {
599
538
  label: string;
600
539
  messageIndex: number;
601
540
  async?: boolean;
541
+ bidirectional?: boolean;
602
542
  }
603
543
 
604
544
  /**
@@ -639,8 +579,14 @@ export function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {
639
579
  label: msg.label,
640
580
  messageIndex: mi,
641
581
  ...(msg.async ? { async: true } : {}),
582
+ ...(msg.bidirectional ? { bidirectional: true } : {}),
642
583
  });
643
584
 
585
+ // Bidirectional messages: no activation bar, no return
586
+ if (msg.bidirectional) {
587
+ continue;
588
+ }
589
+
644
590
  // Async messages: no return arrow, no activation on target
645
591
  if (msg.async) {
646
592
  continue;
@@ -1400,6 +1346,42 @@ export function renderSequenceDiagram(
1400
1346
  .attr('stroke', palette.text)
1401
1347
  .attr('stroke-width', 1.2);
1402
1348
 
1349
+ // Filled reverse arrowhead for bidirectional sync arrows (marker-start)
1350
+ defs
1351
+ .append('marker')
1352
+ .attr('id', 'seq-arrowhead-reverse')
1353
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1354
+ .attr('refX', 0)
1355
+ .attr('refY', ARROWHEAD_SIZE / 2)
1356
+ .attr('markerWidth', ARROWHEAD_SIZE)
1357
+ .attr('markerHeight', ARROWHEAD_SIZE)
1358
+ .attr('orient', 'auto')
1359
+ .append('polygon')
1360
+ .attr(
1361
+ 'points',
1362
+ `${ARROWHEAD_SIZE},0 0,${ARROWHEAD_SIZE / 2} ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE}`
1363
+ )
1364
+ .attr('fill', palette.text);
1365
+
1366
+ // Open reverse arrowhead for bidirectional async arrows (marker-start)
1367
+ defs
1368
+ .append('marker')
1369
+ .attr('id', 'seq-arrowhead-async-reverse')
1370
+ .attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
1371
+ .attr('refX', 0)
1372
+ .attr('refY', ARROWHEAD_SIZE / 2)
1373
+ .attr('markerWidth', ARROWHEAD_SIZE)
1374
+ .attr('markerHeight', ARROWHEAD_SIZE)
1375
+ .attr('orient', 'auto')
1376
+ .append('polyline')
1377
+ .attr(
1378
+ 'points',
1379
+ `${ARROWHEAD_SIZE},0 0,${ARROWHEAD_SIZE / 2} ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE}`
1380
+ )
1381
+ .attr('fill', 'none')
1382
+ .attr('stroke', palette.text)
1383
+ .attr('stroke-width', 1.2);
1384
+
1403
1385
  // Render title
1404
1386
  if (title) {
1405
1387
  const titleEl = svg
@@ -1954,7 +1936,12 @@ export function renderSequenceDiagram(
1954
1936
  const markerRef = step.async
1955
1937
  ? 'url(#seq-arrowhead-async)'
1956
1938
  : 'url(#seq-arrowhead)';
1957
- svg
1939
+ const markerStartRef = step.bidirectional
1940
+ ? step.async
1941
+ ? 'url(#seq-arrowhead-async-reverse)'
1942
+ : 'url(#seq-arrowhead-reverse)'
1943
+ : null;
1944
+ const line = svg
1958
1945
  .append('line')
1959
1946
  .attr('x1', x1)
1960
1947
  .attr('y1', y)
@@ -1970,6 +1957,12 @@ export function renderSequenceDiagram(
1970
1957
  )
1971
1958
  .attr('data-msg-index', String(step.messageIndex))
1972
1959
  .attr('data-step-index', String(i));
1960
+ if (markerStartRef) {
1961
+ line.attr('marker-start', markerStartRef);
1962
+ }
1963
+ if (step.bidirectional && step.async) {
1964
+ line.attr('stroke-dasharray', '6 4');
1965
+ }
1973
1966
 
1974
1967
  if (step.label) {
1975
1968
  const midX = (x1 + x2) / 2;
@@ -0,0 +1,75 @@
1
+ // ============================================================
2
+ // Shared Arrow Parsing Utility
3
+ // ============================================================
4
+ //
5
+ // Labeled arrow syntax: `-label->`, `~label~>`, `<-label->`, `<~label~>`
6
+ // Used by sequence, C4, and init-status parsers.
7
+
8
+ export interface ParsedArrow {
9
+ from: string;
10
+ to: string;
11
+ label: string;
12
+ async: boolean;
13
+ bidirectional: boolean;
14
+ }
15
+
16
+ // Bidi patterns checked FIRST — longer prefix avoids partial match
17
+ const BIDI_SYNC_LABELED_RE = /^(\S+)\s+<-(.+)->\s+(\S+)$/;
18
+ const BIDI_ASYNC_LABELED_RE = /^(\S+)\s+<~(.+)~>\s+(\S+)$/;
19
+ const SYNC_LABELED_RE = /^(\S+)\s+-(.+)->\s+(\S+)$/;
20
+ const ASYNC_LABELED_RE = /^(\S+)\s+~(.+)~>\s+(\S+)$/;
21
+
22
+ const ARROW_CHARS = ['->', '~>', '<->', '<~>'];
23
+
24
+ /**
25
+ * Try to parse a labeled arrow from a trimmed line.
26
+ *
27
+ * Returns:
28
+ * - `ParsedArrow` if matched and valid
29
+ * - `{ error: string }` if matched but label contains arrow chars
30
+ * - `null` if not a labeled arrow (caller should fall through to plain patterns)
31
+ */
32
+ export function parseArrow(
33
+ line: string,
34
+ ): ParsedArrow | { error: string } | null {
35
+ // Order: bidi first (longer prefix), then unidirectional
36
+ const patterns: {
37
+ re: RegExp;
38
+ async: boolean;
39
+ bidirectional: boolean;
40
+ }[] = [
41
+ { re: BIDI_SYNC_LABELED_RE, async: false, bidirectional: true },
42
+ { re: BIDI_ASYNC_LABELED_RE, async: true, bidirectional: true },
43
+ { re: SYNC_LABELED_RE, async: false, bidirectional: false },
44
+ { re: ASYNC_LABELED_RE, async: true, bidirectional: false },
45
+ ];
46
+
47
+ for (const { re, async: isAsync, bidirectional } of patterns) {
48
+ const m = line.match(re);
49
+ if (!m) continue;
50
+
51
+ const label = m[2].trim();
52
+
53
+ // Empty label (e.g. `--> B`) — fall through to plain arrow handling
54
+ if (!label) return null;
55
+
56
+ // Validate: no arrow chars inside label
57
+ for (const arrow of ARROW_CHARS) {
58
+ if (label.includes(arrow)) {
59
+ return {
60
+ error: 'Arrow characters (->, ~>) are not allowed inside labels',
61
+ };
62
+ }
63
+ }
64
+
65
+ return {
66
+ from: m[1],
67
+ to: m[3],
68
+ label,
69
+ async: isAsync,
70
+ bidirectional,
71
+ };
72
+ }
73
+
74
+ return null;
75
+ }