@diagrammo/dgmo 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "gallery": "pnpm build && node scripts/generate-gallery.mjs"
37
37
  },
38
38
  "dependencies": {
39
+ "@dagrejs/dagre": "^2.0.4",
39
40
  "@resvg/resvg-js": "^2.6.2",
40
41
  "d3-array": "^3.2.4",
41
42
  "d3-cloud": "^1.2.7",
@@ -51,6 +52,7 @@
51
52
  "@types/d3-scale": "^4.0.8",
52
53
  "@types/d3-selection": "^3.0.11",
53
54
  "@types/d3-shape": "^3.1.7",
55
+ "@types/dagre": "^0.7.53",
54
56
  "@types/jsdom": "^21.1.7",
55
57
  "tsup": "^8.5.1",
56
58
  "typescript": "^5.7.3",
package/src/d3.ts CHANGED
@@ -2146,7 +2146,17 @@ export function computeTimeTicks(
2146
2146
  const firstYear = Math.ceil(domainMin);
2147
2147
  const lastYear = Math.floor(domainMax);
2148
2148
  if (lastYear >= firstYear + 1) {
2149
- for (let y = firstYear; y <= lastYear; y++) {
2149
+ // Decimate ticks for long spans so labels don't overlap
2150
+ const yearSpan = lastYear - firstYear;
2151
+ let step = 1;
2152
+ if (yearSpan > 80) step = 20;
2153
+ else if (yearSpan > 40) step = 10;
2154
+ else if (yearSpan > 20) step = 5;
2155
+ else if (yearSpan > 10) step = 2;
2156
+
2157
+ // Align to step boundary so ticks land on round years (1700, 1710, …)
2158
+ const alignedFirst = Math.ceil(firstYear / step) * step;
2159
+ for (let y = alignedFirst; y <= lastYear; y += step) {
2150
2160
  ticks.push({ pos: scale(y), label: String(y) });
2151
2161
  }
2152
2162
  } else if (span > 0.25) {
@@ -4972,6 +4982,54 @@ export async function renderD3ForExport(
4972
4982
  theme: 'light' | 'dark' | 'transparent',
4973
4983
  palette?: PaletteColors
4974
4984
  ): Promise<string> {
4985
+ // Flowchart uses its own parser pipeline — intercept before parseD3()
4986
+ const { parseDgmoChartType } = await import('./dgmo-router');
4987
+ const detectedType = parseDgmoChartType(content);
4988
+ if (detectedType === 'flowchart') {
4989
+ const { parseFlowchart } = await import('./graph/flowchart-parser');
4990
+ const { layoutGraph } = await import('./graph/layout');
4991
+ const { renderFlowchart } = await import('./graph/flowchart-renderer');
4992
+
4993
+ const isDark = theme === 'dark';
4994
+ const { getPalette } = await import('./palettes');
4995
+ const effectivePalette =
4996
+ palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
4997
+
4998
+ const fcParsed = parseFlowchart(content, effectivePalette);
4999
+ if (fcParsed.error || fcParsed.nodes.length === 0) return '';
5000
+
5001
+ const layout = layoutGraph(fcParsed);
5002
+ const container = document.createElement('div');
5003
+ container.style.width = `${EXPORT_WIDTH}px`;
5004
+ container.style.height = `${EXPORT_HEIGHT}px`;
5005
+ container.style.position = 'absolute';
5006
+ container.style.left = '-9999px';
5007
+ document.body.appendChild(container);
5008
+
5009
+ try {
5010
+ renderFlowchart(container, fcParsed, layout, effectivePalette, isDark, undefined, {
5011
+ width: EXPORT_WIDTH,
5012
+ height: EXPORT_HEIGHT,
5013
+ });
5014
+
5015
+ const svgEl = container.querySelector('svg');
5016
+ if (!svgEl) return '';
5017
+
5018
+ if (theme === 'transparent') {
5019
+ svgEl.style.background = 'none';
5020
+ } else if (!svgEl.style.background) {
5021
+ svgEl.style.background = effectivePalette.bg;
5022
+ }
5023
+
5024
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5025
+ svgEl.style.fontFamily = FONT_FAMILY;
5026
+
5027
+ return svgEl.outerHTML;
5028
+ } finally {
5029
+ document.body.removeChild(container);
5030
+ }
5031
+ }
5032
+
4975
5033
  const parsed = parseD3(content, palette);
4976
5034
  // Allow sequence diagrams through even if parseD3 errors —
4977
5035
  // sequence is parsed by its own dedicated parser (parseSequenceDgmo)
@@ -3,6 +3,7 @@
3
3
  // ============================================================
4
4
 
5
5
  import { looksLikeSequence } from './sequence/parser';
6
+ import { looksLikeFlowchart } from './graph/flowchart-parser';
6
7
 
7
8
  /**
8
9
  * Framework identifiers used by the .dgmo router.
@@ -44,6 +45,7 @@ export const DGMO_CHART_TYPE_MAP: Record<string, DgmoFramework> = {
44
45
  venn: 'd3',
45
46
  quadrant: 'd3',
46
47
  sequence: 'd3',
48
+ flowchart: 'd3',
47
49
  };
48
50
 
49
51
  /**
@@ -69,8 +71,10 @@ export function parseDgmoChartType(content: string): string | null {
69
71
  if (match) return match[1].trim().toLowerCase();
70
72
  }
71
73
 
72
- // Infer sequence chart type when content contains arrow patterns
74
+ // Infer chart type from content patterns (sequence before flowchart —
75
+ // both use `->` but sequence uses bare names while flowchart uses shape delimiters)
73
76
  if (looksLikeSequence(content)) return 'sequence';
77
+ if (looksLikeFlowchart(content)) return 'flowchart';
74
78
 
75
79
  return null;
76
80
  }
package/src/echarts.ts CHANGED
@@ -395,7 +395,7 @@ export function buildEChartsOption(
395
395
  ): EChartsOption {
396
396
  const textColor = palette.text;
397
397
  const axisLineColor = palette.border;
398
- const gridOpacity = isDark ? 0.7 : 0.4;
398
+ const gridOpacity = isDark ? 0.7 : 0.55;
399
399
  const colors = getSeriesColors(palette);
400
400
 
401
401
  if (parsed.error) {
@@ -1300,7 +1300,7 @@ export function buildEChartsOptionFromChart(
1300
1300
  const textColor = palette.text;
1301
1301
  const axisLineColor = palette.border;
1302
1302
  const splitLineColor = palette.border;
1303
- const gridOpacity = isDark ? 0.7 : 0.4;
1303
+ const gridOpacity = isDark ? 0.7 : 0.55;
1304
1304
  const colors = getSeriesColors(palette);
1305
1305
 
1306
1306
  const titleConfig = parsed.title
@@ -0,0 +1,499 @@
1
+ import { resolveColor } from '../colors';
2
+ import type { PaletteColors } from '../palettes';
3
+ import type {
4
+ ParsedGraph,
5
+ GraphNode,
6
+ GraphEdge,
7
+ GraphGroup,
8
+ GraphShape,
9
+ GraphDirection,
10
+ } from './types';
11
+
12
+ // ============================================================
13
+ // Helpers
14
+ // ============================================================
15
+
16
+ function measureIndent(line: string): number {
17
+ let indent = 0;
18
+ for (const ch of line) {
19
+ if (ch === ' ') indent++;
20
+ else if (ch === '\t') indent += 4;
21
+ else break;
22
+ }
23
+ return indent;
24
+ }
25
+
26
+ function nodeId(shape: GraphShape, label: string): string {
27
+ return `${shape}:${label.toLowerCase().trim()}`;
28
+ }
29
+
30
+ interface NodeRef {
31
+ id: string;
32
+ label: string;
33
+ shape: GraphShape;
34
+ color?: string;
35
+ }
36
+
37
+ const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
38
+
39
+ function extractColor(
40
+ label: string,
41
+ palette?: PaletteColors
42
+ ): { label: string; color?: string } {
43
+ const m = label.match(COLOR_SUFFIX_RE);
44
+ if (!m) return { label };
45
+ const colorName = m[1].trim();
46
+ return {
47
+ label: label.substring(0, m.index!).trim(),
48
+ color: resolveColor(colorName, palette),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Try to parse a node reference from a text fragment.
54
+ * Order matters: subroutine & document before process.
55
+ */
56
+ function parseNodeRef(
57
+ text: string,
58
+ palette?: PaletteColors
59
+ ): NodeRef | null {
60
+ const t = text.trim();
61
+ if (!t) return null;
62
+
63
+ // Subroutine: [[Label]]
64
+ let m = t.match(/^\[\[([^\]]+)\]\]$/);
65
+ if (m) {
66
+ const { label, color } = extractColor(m[1].trim(), palette);
67
+ return { id: nodeId('subroutine', label), label, shape: 'subroutine', color };
68
+ }
69
+
70
+ // Document: [Label~]
71
+ m = t.match(/^\[([^\]]+)~\]$/);
72
+ if (m) {
73
+ const { label, color } = extractColor(m[1].trim(), palette);
74
+ return { id: nodeId('document', label), label, shape: 'document', color };
75
+ }
76
+
77
+ // Process: [Label]
78
+ m = t.match(/^\[([^\]]+)\]$/);
79
+ if (m) {
80
+ const { label, color } = extractColor(m[1].trim(), palette);
81
+ return { id: nodeId('process', label), label, shape: 'process', color };
82
+ }
83
+
84
+ // Terminal: (Label) — use .+ (greedy) so (Label(color)) matches outermost parens
85
+ m = t.match(/^\((.+)\)$/);
86
+ if (m) {
87
+ const { label, color } = extractColor(m[1].trim(), palette);
88
+ return { id: nodeId('terminal', label), label, shape: 'terminal', color };
89
+ }
90
+
91
+ // Decision: <Label>
92
+ m = t.match(/^<([^>]+)>$/);
93
+ if (m) {
94
+ const { label, color } = extractColor(m[1].trim(), palette);
95
+ return { id: nodeId('decision', label), label, shape: 'decision', color };
96
+ }
97
+
98
+ // I/O: /Label/
99
+ m = t.match(/^\/([^/]+)\/$/);
100
+ if (m) {
101
+ const { label, color } = extractColor(m[1].trim(), palette);
102
+ return { id: nodeId('io', label), label, shape: 'io', color };
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Split a line into segments around arrow tokens.
110
+ * Arrows: `->`, `-label->`, `-(color)->`, `-label(color)->`
111
+ *
112
+ * Returns alternating: [nodeText, arrowText, nodeText, arrowText, nodeText, ...]
113
+ * Where arrowText is the full arrow token like `-yes->` or `->`.
114
+ */
115
+ function splitArrows(line: string): string[] {
116
+ const segments: string[] = [];
117
+ // Match: optional `-label(color)->` or just `->`
118
+ // We scan left to right looking for `->` and work backwards to find the `-` start.
119
+ const arrowRe = /(?:^|\s)-([^>\s(][^(>]*?)?\s*(?:\(([^)]+)\))?\s*->|(?:^|\s)->/g;
120
+
121
+ let lastIndex = 0;
122
+ // Simpler approach: find all `->` positions, then determine if there's a label prefix
123
+ const arrowPositions: { start: number; end: number; label?: string; color?: string }[] = [];
124
+
125
+ // Find all -> occurrences
126
+ let searchFrom = 0;
127
+ while (searchFrom < line.length) {
128
+ const idx = line.indexOf('->', searchFrom);
129
+ if (idx === -1) break;
130
+
131
+ // Look backwards from idx to find the start of the arrow (the `-` that starts the label)
132
+ let arrowStart = idx;
133
+ let label: string | undefined;
134
+ let color: string | undefined;
135
+
136
+ // Check if there's content between a preceding `-` and this `->` (e.g., `-yes->`)
137
+ // Walk backwards from idx-1 to find another `-` that could be the arrow start
138
+ if (idx > 0 && line[idx - 1] !== ' ' && line[idx - 1] !== '\t') {
139
+ // There might be label/color content attached: e.g. `-yes->` or `-(blue)->`
140
+ // The arrow token starts with `-` followed by optional label, optional (color), then `->`
141
+ // We need to find the opening `-` before any label text
142
+ // Scan backwards to find a `-` preceded by whitespace or start-of-line
143
+ let scanBack = idx - 1;
144
+ while (scanBack > 0 && line[scanBack] !== '-') {
145
+ scanBack--;
146
+ }
147
+ // Check if this `-` could be the start of the arrow
148
+ if (line[scanBack] === '-' && (scanBack === 0 || /\s/.test(line[scanBack - 1]))) {
149
+ // Content between opening `-` and `->` (strip trailing `-` that is part of `->`)
150
+ let arrowContent = line.substring(scanBack + 1, idx);
151
+ if (arrowContent.endsWith('-')) arrowContent = arrowContent.slice(0, -1);
152
+ // Parse label and color from arrow content
153
+ const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
154
+ if (colorMatch) {
155
+ color = colorMatch[1].trim();
156
+ const labelPart = arrowContent.substring(0, colorMatch.index!).trim();
157
+ if (labelPart) label = labelPart;
158
+ } else {
159
+ const labelPart = arrowContent.trim();
160
+ if (labelPart) label = labelPart;
161
+ }
162
+ arrowStart = scanBack;
163
+ }
164
+ }
165
+
166
+ arrowPositions.push({ start: arrowStart, end: idx + 2, label, color });
167
+ searchFrom = idx + 2;
168
+ }
169
+
170
+ if (arrowPositions.length === 0) {
171
+ return [line];
172
+ }
173
+
174
+ // Build segments
175
+ for (let i = 0; i < arrowPositions.length; i++) {
176
+ const arrow = arrowPositions[i];
177
+ const beforeText = line.substring(lastIndex, arrow.start).trim();
178
+ if (beforeText || i === 0) {
179
+ segments.push(beforeText);
180
+ }
181
+ // Arrow marker
182
+ let arrowToken = '->';
183
+ if (arrow.label && arrow.color) arrowToken = `-${arrow.label}(${arrow.color})->`;
184
+ else if (arrow.label) arrowToken = `-${arrow.label}->`;
185
+ else if (arrow.color) arrowToken = `-(${arrow.color})->`;
186
+ segments.push(arrowToken);
187
+ lastIndex = arrow.end;
188
+ }
189
+ // Remaining text after last arrow
190
+ const remaining = line.substring(lastIndex).trim();
191
+ if (remaining) {
192
+ segments.push(remaining);
193
+ }
194
+
195
+ return segments;
196
+ }
197
+
198
+ interface ArrowInfo {
199
+ label?: string;
200
+ color?: string;
201
+ }
202
+
203
+ function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
204
+ if (token === '->') return {};
205
+ // Color-only: -(color)->
206
+ const colorOnly = token.match(/^-\(([^)]+)\)->$/);
207
+ if (colorOnly) {
208
+ return { color: resolveColor(colorOnly[1].trim(), palette) };
209
+ }
210
+ // -label(color)-> or -label->
211
+ const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
212
+ if (m) {
213
+ const label = m[1]?.trim() || undefined;
214
+ const color = m[2] ? resolveColor(m[2].trim(), palette) : undefined;
215
+ return { label, color };
216
+ }
217
+ return {};
218
+ }
219
+
220
+ // ============================================================
221
+ // Group heading pattern
222
+ // ============================================================
223
+ const GROUP_HEADING_RE = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
224
+
225
+ // ============================================================
226
+ // Main parser
227
+ // ============================================================
228
+
229
+ export function parseFlowchart(
230
+ content: string,
231
+ palette?: PaletteColors
232
+ ): ParsedGraph {
233
+ const lines = content.split('\n');
234
+ const result: ParsedGraph = {
235
+ type: 'flowchart',
236
+ direction: 'TB',
237
+ nodes: [],
238
+ edges: [],
239
+ };
240
+
241
+ const nodeMap = new Map<string, GraphNode>();
242
+ const indentStack: { nodeId: string; indent: number }[] = [];
243
+ let currentGroup: GraphGroup | null = null;
244
+ const groups: GraphGroup[] = [];
245
+ let contentStarted = false;
246
+
247
+ function getOrCreateNode(ref: NodeRef, lineNumber: number): GraphNode {
248
+ const existing = nodeMap.get(ref.id);
249
+ if (existing) return existing;
250
+
251
+ const node: GraphNode = {
252
+ id: ref.id,
253
+ label: ref.label,
254
+ shape: ref.shape,
255
+ lineNumber,
256
+ ...(ref.color && { color: ref.color }),
257
+ ...(currentGroup && { group: currentGroup.id }),
258
+ };
259
+ nodeMap.set(ref.id, node);
260
+ result.nodes.push(node);
261
+
262
+ // Add to current group
263
+ if (currentGroup && !currentGroup.nodeIds.includes(ref.id)) {
264
+ currentGroup.nodeIds.push(ref.id);
265
+ }
266
+
267
+ return node;
268
+ }
269
+
270
+ function addEdge(
271
+ sourceId: string,
272
+ targetId: string,
273
+ lineNumber: number,
274
+ label?: string,
275
+ color?: string
276
+ ): void {
277
+ const edge: GraphEdge = {
278
+ source: sourceId,
279
+ target: targetId,
280
+ lineNumber,
281
+ ...(label && { label }),
282
+ ...(color && { color }),
283
+ };
284
+ result.edges.push(edge);
285
+ }
286
+
287
+ /**
288
+ * Process a content line that may contain nodes and arrows.
289
+ * Returns the last node ID encountered (for indent stack tracking).
290
+ */
291
+ function processContentLine(
292
+ trimmed: string,
293
+ lineNumber: number,
294
+ indent: number
295
+ ): string | null {
296
+ contentStarted = true;
297
+
298
+ // Determine implicit source from indent stack
299
+ // Pop stack entries that are at same or deeper indent level
300
+ while (indentStack.length > 0) {
301
+ const top = indentStack[indentStack.length - 1];
302
+ if (top.indent >= indent) {
303
+ indentStack.pop();
304
+ } else {
305
+ break;
306
+ }
307
+ }
308
+
309
+ const implicitSourceId =
310
+ indentStack.length > 0
311
+ ? indentStack[indentStack.length - 1].nodeId
312
+ : null;
313
+
314
+ // Split line into segments around arrows
315
+ const segments = splitArrows(trimmed);
316
+
317
+ if (segments.length === 1) {
318
+ // Single node reference, no arrows
319
+ const ref = parseNodeRef(segments[0], palette);
320
+ if (ref) {
321
+ const node = getOrCreateNode(ref, lineNumber);
322
+ indentStack.push({ nodeId: node.id, indent });
323
+ return node.id;
324
+ }
325
+ return null;
326
+ }
327
+
328
+ // Process chain: alternating nodeText / arrowToken / nodeText / ...
329
+ let lastNodeId: string | null = null;
330
+ let pendingArrow: ArrowInfo | null = null;
331
+
332
+ for (let i = 0; i < segments.length; i++) {
333
+ const seg = segments[i];
334
+
335
+ // Check if this is an arrow token
336
+ if (seg === '->' || /^-.+->$/.test(seg)) {
337
+ pendingArrow = parseArrowToken(seg, palette);
338
+ continue;
339
+ }
340
+
341
+ // This is a node text segment
342
+ const ref = parseNodeRef(seg, palette);
343
+ if (!ref) continue;
344
+
345
+ const node = getOrCreateNode(ref, lineNumber);
346
+
347
+ if (pendingArrow !== null) {
348
+ const sourceId = lastNodeId ?? implicitSourceId;
349
+ if (sourceId) {
350
+ addEdge(
351
+ sourceId,
352
+ node.id,
353
+ lineNumber,
354
+ pendingArrow.label,
355
+ pendingArrow.color
356
+ );
357
+ }
358
+ pendingArrow = null;
359
+ } else if (lastNodeId === null && implicitSourceId === null) {
360
+ // First node in chain, no arrow yet — just register
361
+ }
362
+
363
+ lastNodeId = node.id;
364
+ }
365
+
366
+ // If we ended with a pending arrow but no target node, that's an edge-only line
367
+ // handled by: the arrow was at the start with implicit source
368
+ if (pendingArrow !== null && lastNodeId === null && implicitSourceId) {
369
+ // Edge-only line like ` -> ` with no target — ignore
370
+ }
371
+
372
+ // If line started with an arrow and we have an implicit source
373
+ // but no explicit first node, the first segment was empty
374
+ if (
375
+ segments.length >= 2 &&
376
+ segments[0] === '' &&
377
+ implicitSourceId &&
378
+ lastNodeId
379
+ ) {
380
+ // Already handled above — the implicit source was used
381
+ }
382
+
383
+ if (lastNodeId) {
384
+ indentStack.push({ nodeId: lastNodeId, indent });
385
+ }
386
+
387
+ return lastNodeId;
388
+ }
389
+
390
+ // === Main loop ===
391
+ for (let i = 0; i < lines.length; i++) {
392
+ const raw = lines[i];
393
+ const trimmed = raw.trim();
394
+ const lineNumber = i + 1;
395
+ const indent = measureIndent(raw);
396
+
397
+ // Skip empty lines
398
+ if (!trimmed) continue;
399
+
400
+ // Skip comments
401
+ if (trimmed.startsWith('//')) continue;
402
+
403
+ // Group headings
404
+ const groupMatch = trimmed.match(GROUP_HEADING_RE);
405
+ if (groupMatch) {
406
+ const groupLabel = groupMatch[1].trim();
407
+ const groupColorName = groupMatch[2]?.trim();
408
+ const groupColor = groupColorName
409
+ ? resolveColor(groupColorName, palette)
410
+ : undefined;
411
+
412
+ currentGroup = {
413
+ id: `group:${groupLabel.toLowerCase()}`,
414
+ label: groupLabel,
415
+ nodeIds: [],
416
+ lineNumber,
417
+ ...(groupColor && { color: groupColor }),
418
+ };
419
+ groups.push(currentGroup);
420
+ continue;
421
+ }
422
+
423
+ // Metadata directives (before content)
424
+ if (!contentStarted && trimmed.includes(':') && !trimmed.includes('->')) {
425
+ const colonIdx = trimmed.indexOf(':');
426
+ const key = trimmed.substring(0, colonIdx).trim().toLowerCase();
427
+ const value = trimmed.substring(colonIdx + 1).trim();
428
+
429
+ if (key === 'chart') {
430
+ if (value.toLowerCase() !== 'flowchart') {
431
+ result.error = `Line ${lineNumber}: Expected chart type "flowchart", got "${value}"`;
432
+ return result;
433
+ }
434
+ continue;
435
+ }
436
+
437
+ if (key === 'title') {
438
+ result.title = value;
439
+ continue;
440
+ }
441
+
442
+ if (key === 'direction') {
443
+ const dir = value.toUpperCase() as GraphDirection;
444
+ if (dir === 'TB' || dir === 'LR') {
445
+ result.direction = dir;
446
+ }
447
+ continue;
448
+ }
449
+
450
+ // Unknown metadata — skip
451
+ continue;
452
+ }
453
+
454
+ // Content line (nodes and edges)
455
+ processContentLine(trimmed, lineNumber, indent);
456
+ }
457
+
458
+ if (groups.length > 0) result.groups = groups;
459
+
460
+ // Validation: no nodes found
461
+ if (result.nodes.length === 0 && !result.error) {
462
+ result.error = 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).';
463
+ }
464
+
465
+ return result;
466
+ }
467
+
468
+ // ============================================================
469
+ // Detection helper
470
+ // ============================================================
471
+
472
+ /**
473
+ * Detect if content looks like a flowchart (without explicit `chart: flowchart` header).
474
+ * Checks for shape delimiters combined with `->` arrows.
475
+ * Avoids false-positives on sequence diagrams (which use bare names with `->`)
476
+ */
477
+ export function looksLikeFlowchart(content: string): boolean {
478
+ // Must have -> arrows
479
+ if (!content.includes('->')) return false;
480
+
481
+ // Must have at least one shape delimiter pattern
482
+ // Shape delimiters: [...], (...), <...>, /.../, [[...]], [...~]
483
+ // Sequence diagrams use bare names like "Alice -> Bob: msg" — no delimiters around names
484
+ const hasShapeDelimiter =
485
+ /\[[^\]]+\]/.test(content) ||
486
+ /\([^)]+\)/.test(content) ||
487
+ /<[^>]+>/.test(content) ||
488
+ /\/[^/]+\//.test(content);
489
+
490
+ if (!hasShapeDelimiter) return false;
491
+
492
+ // Check that shape delimiters appear near arrows (not just random brackets)
493
+ // Look for patterns like `[X] ->` or `-> [X]` or `(X) ->` etc.
494
+ const shapeNearArrow =
495
+ /[\])][ \t]*-.*->/.test(content) || // shape ] or ) followed by arrow
496
+ /->[ \t]*[\[(<\/]/.test(content); // arrow followed by shape opener
497
+
498
+ return shapeNearArrow;
499
+ }