@oh-my-pi/pi-utils 16.0.7 → 16.0.9

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 (87) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/types/mermaid-ascii.d.ts +1 -1
  3. package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
  4. package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
  5. package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
  6. package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
  7. package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
  8. package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
  9. package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
  10. package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
  11. package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
  12. package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
  13. package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
  14. package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
  15. package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
  16. package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
  17. package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
  18. package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
  19. package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
  20. package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
  21. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
  22. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
  23. package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
  24. package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
  25. package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
  26. package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
  27. package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
  28. package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
  29. package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
  30. package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
  31. package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
  32. package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
  33. package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
  34. package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
  35. package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
  36. package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
  37. package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
  38. package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
  39. package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
  40. package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
  41. package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
  42. package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
  43. package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
  44. package/package.json +2 -3
  45. package/src/mermaid-ascii.ts +1 -1
  46. package/src/vendor/mermaid-ascii/NOTICE +33 -0
  47. package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
  48. package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
  49. package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
  50. package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
  51. package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
  52. package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
  53. package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
  54. package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
  55. package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
  56. package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
  57. package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
  58. package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +215 -0
  59. package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
  60. package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
  61. package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
  62. package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
  63. package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
  64. package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
  65. package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
  66. package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
  67. package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
  68. package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
  69. package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
  70. package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
  71. package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
  72. package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
  73. package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
  74. package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
  75. package/src/vendor/mermaid-ascii/class/types.ts +121 -0
  76. package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
  77. package/src/vendor/mermaid-ascii/er/types.ts +91 -0
  78. package/src/vendor/mermaid-ascii/index.ts +14 -0
  79. package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
  80. package/src/vendor/mermaid-ascii/parser.ts +645 -0
  81. package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
  82. package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
  83. package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
  84. package/src/vendor/mermaid-ascii/types.ts +164 -0
  85. package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
  86. package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
  87. package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
@@ -0,0 +1,30 @@
1
+ // ============================================================================
2
+ // Label normalization
3
+ //
4
+ // Shared by the diagram parsers (flowchart/state, class, ER, sequence) to
5
+ // normalize raw Mermaid label text before it reaches the ASCII renderers.
6
+ // The SVG-only multi-line tspan renderers from upstream are not vendored.
7
+ // ============================================================================
8
+
9
+ /**
10
+ * Normalize label text for terminal ASCII output: strip surrounding quotes,
11
+ * convert <br> tags and literal newline escapes to newlines, and reduce
12
+ * inline formatting (HTML bold/italic/underline/strike tags and the markdown
13
+ * bold, italic, and strikethrough markers) to plain text. The ASCII renderer
14
+ * has no styled spans, so preserving the markup would print raw tags and
15
+ * markers inside node boxes.
16
+ */
17
+ export function normalizeBrTags(label: string): string {
18
+ // Strip surrounding double quotes (Mermaid uses them for special chars in labels)
19
+ const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label
20
+ return unquoted
21
+ .replace(/<br\s*\/?>/gi, '\n')
22
+ .replace(/\\n/g, '\n')
23
+ .replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '')
24
+ // Drop inline HTML formatting tags — ASCII output has no styled spans
25
+ .replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '')
26
+ // Reduce markdown emphasis to its inner text (order matters: ** before *)
27
+ .replace(/\*\*(.+?)\*\*/g, '$1')
28
+ .replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, '$1')
29
+ .replace(/~~(.+?)~~/g, '$1')
30
+ }
@@ -0,0 +1,645 @@
1
+ import type { MermaidGraph, MermaidNode, MermaidEdge, MermaidSubgraph, Direction, NodeShape, EdgeStyle } from './types'
2
+ import { normalizeBrTags } from './multiline-utils'
3
+
4
+ // ============================================================================
5
+ // Mermaid parser — flowcharts and state diagrams
6
+ //
7
+ // Supports:
8
+ // Flowcharts: graph TD / flowchart LR
9
+ // State diagrams: stateDiagram-v2
10
+ //
11
+ // Line-by-line regex approach — the grammar is regular enough
12
+ // that we don't need a grammar generator or full parser combinator.
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Parse Mermaid text into a logical graph structure.
17
+ * Auto-detects diagram type (flowchart or state diagram).
18
+ * Throws on invalid/unsupported input.
19
+ */
20
+ export function parseMermaid(text: string): MermaidGraph {
21
+ const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))
22
+
23
+ if (lines.length === 0) {
24
+ throw new Error('Empty mermaid diagram')
25
+ }
26
+
27
+ // Detect diagram type from header
28
+ const header = lines[0]!
29
+
30
+ // State diagram: "stateDiagram-v2" or "stateDiagram"
31
+ if (/^stateDiagram(-v2)?\s*$/i.test(header)) {
32
+ return parseStateDiagram(lines)
33
+ }
34
+
35
+ // Flowchart: "graph TD" or "flowchart LR"
36
+ return parseFlowchart(lines)
37
+ }
38
+
39
+ // ============================================================================
40
+ // Flowchart parser
41
+ // ============================================================================
42
+
43
+ function parseFlowchart(lines: string[]): MermaidGraph {
44
+ const headerMatch = lines[0]!.match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i)
45
+ if (!headerMatch) {
46
+ throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`)
47
+ }
48
+
49
+ const direction = headerMatch[1]!.toUpperCase() as Direction
50
+
51
+ const graph: MermaidGraph = {
52
+ direction,
53
+ nodes: new Map(),
54
+ edges: [],
55
+ subgraphs: [],
56
+ classDefs: new Map(),
57
+ classAssignments: new Map(),
58
+ nodeStyles: new Map(),
59
+ linkStyles: new Map(),
60
+ }
61
+
62
+ // Subgraph stack for nested subgraphs.
63
+ const subgraphStack: MermaidSubgraph[] = []
64
+
65
+ for (let i = 1; i < lines.length; i++) {
66
+ const line = lines[i]!
67
+
68
+ // --- classDef: `classDef name prop:val,prop:val` ---
69
+ const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/)
70
+ if (classDefMatch) {
71
+ const name = classDefMatch[1]!
72
+ const propsStr = classDefMatch[2]!
73
+ const props = parseStyleProps(propsStr)
74
+ graph.classDefs.set(name, props)
75
+ continue
76
+ }
77
+
78
+ // --- class assignment: `class A,B className` ---
79
+ const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/)
80
+ if (classAssignMatch) {
81
+ const nodeIds = classAssignMatch[1]!.split(',').map(s => s.trim())
82
+ const className = classAssignMatch[2]!
83
+ for (const id of nodeIds) {
84
+ graph.classAssignments.set(id, className)
85
+ }
86
+ continue
87
+ }
88
+
89
+ // --- style statement: `style A,B fill:#f00,stroke:#333` ---
90
+ const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/)
91
+ if (styleMatch) {
92
+ const nodeIds = styleMatch[1]!.split(',').map(s => s.trim())
93
+ const props = parseStyleProps(styleMatch[2]!)
94
+ for (const id of nodeIds) {
95
+ graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props })
96
+ }
97
+ continue
98
+ }
99
+
100
+ // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` ---
101
+ const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/)
102
+ if (linkStyleMatch) {
103
+ const target = linkStyleMatch[1]!.trim()
104
+ const props = parseStyleProps(linkStyleMatch[2]!)
105
+ if (target === 'default') {
106
+ graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props })
107
+ } else {
108
+ const indices = target.split(',').map(s => parseInt(s.trim(), 10))
109
+ for (const idx of indices) {
110
+ if (!isNaN(idx)) {
111
+ graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props })
112
+ }
113
+ }
114
+ }
115
+ continue
116
+ }
117
+
118
+ // --- direction override inside subgraph: `direction LR` ---
119
+ const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i)
120
+ if (dirMatch && subgraphStack.length > 0) {
121
+ subgraphStack[subgraphStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction
122
+ continue
123
+ }
124
+
125
+ // --- subgraph start: `subgraph Label` or `subgraph id [Label]` ---
126
+ const subgraphMatch = line.match(/^subgraph\s+(.+)$/)
127
+ if (subgraphMatch) {
128
+ const rest = subgraphMatch[1]!.trim()
129
+ // Check for "subgraph id [Label]" form
130
+ // ID can contain hyphens (e.g. "us-east"), so use [\w-]+ not \w+
131
+ const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/)
132
+ let id: string
133
+ let label: string
134
+ if (bracketMatch) {
135
+ id = bracketMatch[1]!
136
+ label = normalizeBrTags(bracketMatch[2]!)
137
+ } else {
138
+ // Use the label text as id (slugified)
139
+ label = normalizeBrTags(rest)
140
+ id = rest.replace(/\s+/g, '_').replace(/[^\w]/g, '')
141
+ }
142
+ const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] }
143
+ subgraphStack.push(sg)
144
+ continue
145
+ }
146
+
147
+ // --- subgraph end ---
148
+ if (line === 'end') {
149
+ const completed = subgraphStack.pop()
150
+ if (completed) {
151
+ if (subgraphStack.length > 0) {
152
+ subgraphStack[subgraphStack.length - 1]!.children.push(completed)
153
+ } else {
154
+ graph.subgraphs.push(completed)
155
+ }
156
+ }
157
+ continue
158
+ }
159
+
160
+ // --- Edge/node definitions ---
161
+ parseEdgeLine(line, graph, subgraphStack)
162
+ }
163
+
164
+ return graph
165
+ }
166
+
167
+ // ============================================================================
168
+ // State diagram parser
169
+ //
170
+ // Supported syntax:
171
+ // stateDiagram-v2
172
+ // s1 : Description
173
+ // state "Description" as s1
174
+ // s1 --> s2 : label
175
+ // [*] --> s1 (start pseudostate)
176
+ // s1 --> [*] (end pseudostate)
177
+ // state CompositeState {
178
+ // inner1 --> inner2
179
+ // }
180
+ // ============================================================================
181
+
182
+ function parseStateDiagram(lines: string[]): MermaidGraph {
183
+ const graph: MermaidGraph = {
184
+ direction: 'TD',
185
+ nodes: new Map(),
186
+ edges: [],
187
+ subgraphs: [],
188
+ classDefs: new Map(),
189
+ classAssignments: new Map(),
190
+ nodeStyles: new Map(),
191
+ linkStyles: new Map(),
192
+ }
193
+
194
+ // Track composite state nesting (like subgraphs)
195
+ const compositeStack: MermaidSubgraph[] = []
196
+ // Track all composite state IDs to avoid creating duplicate nodes
197
+ const compositeStateIds = new Set<string>()
198
+ // Counter for unique [*] pseudostate IDs
199
+ let startCount = 0
200
+ let endCount = 0
201
+
202
+ for (let i = 1; i < lines.length; i++) {
203
+ const line = lines[i]!
204
+
205
+ // --- direction override ---
206
+ const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i)
207
+ if (dirMatch) {
208
+ if (compositeStack.length > 0) {
209
+ compositeStack[compositeStack.length - 1]!.direction = dirMatch[1]!.toUpperCase() as Direction
210
+ } else {
211
+ graph.direction = dirMatch[1]!.toUpperCase() as Direction
212
+ }
213
+ continue
214
+ }
215
+
216
+ // --- linkStyle: `linkStyle 0 stroke:#f00` or `linkStyle default stroke:#f00` ---
217
+ const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/)
218
+ if (linkStyleMatch) {
219
+ const target = linkStyleMatch[1]!.trim()
220
+ const props = parseStyleProps(linkStyleMatch[2]!)
221
+ if (target === 'default') {
222
+ graph.linkStyles.set('default', { ...graph.linkStyles.get('default'), ...props })
223
+ } else {
224
+ const indices = target.split(',').map(s => parseInt(s.trim(), 10))
225
+ for (const idx of indices) {
226
+ if (!isNaN(idx)) {
227
+ graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props })
228
+ }
229
+ }
230
+ }
231
+ continue
232
+ }
233
+
234
+ // --- composite state start: `state CompositeState {` ---
235
+ const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u)
236
+ if (compositeMatch) {
237
+ const label = compositeMatch[1] ?? compositeMatch[2]!
238
+ const id = compositeMatch[2]!
239
+ const sg: MermaidSubgraph = { id, label, nodeIds: [], children: [] }
240
+ compositeStack.push(sg)
241
+ // Track this ID to avoid creating a duplicate node for the composite state
242
+ compositeStateIds.add(id)
243
+ // Remove any existing node that was created when parsing transitions before
244
+ // this composite state definition (e.g., "A --> Processing" before "state Processing {")
245
+ graph.nodes.delete(id)
246
+ continue
247
+ }
248
+
249
+ // --- composite state end ---
250
+ if (line === '}') {
251
+ const completed = compositeStack.pop()
252
+ if (completed) {
253
+ if (compositeStack.length > 0) {
254
+ compositeStack[compositeStack.length - 1]!.children.push(completed)
255
+ } else {
256
+ graph.subgraphs.push(completed)
257
+ }
258
+ }
259
+ continue
260
+ }
261
+
262
+ // --- state alias: `state "Description" as s1` (without brace) ---
263
+ const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u)
264
+ if (stateAliasMatch) {
265
+ const label = normalizeBrTags(stateAliasMatch[1]!)
266
+ const id = stateAliasMatch[2]!
267
+ registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' })
268
+ continue
269
+ }
270
+
271
+ // --- transition: `s1 --> s2` or `s1 --> s2 : label` or `[*] --> s1` ---
272
+ const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u)
273
+ if (transitionMatch) {
274
+ let sourceId = transitionMatch[1]!
275
+ let targetId = transitionMatch[3]!
276
+ const rawTransitionLabel = transitionMatch[4]?.trim()
277
+ const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : undefined
278
+
279
+ // Handle [*] pseudostates — each occurrence gets a unique ID
280
+ if (sourceId === '[*]') {
281
+ startCount++
282
+ sourceId = `_start${startCount > 1 ? startCount : ''}`
283
+ registerStateNode(graph, compositeStack, { id: sourceId, label: '', shape: 'state-start' })
284
+ } else if (!compositeStateIds.has(sourceId)) {
285
+ // Only create a node if this isn't a composite state
286
+ ensureStateNode(graph, compositeStack, sourceId)
287
+ }
288
+
289
+ if (targetId === '[*]') {
290
+ endCount++
291
+ targetId = `_end${endCount > 1 ? endCount : ''}`
292
+ registerStateNode(graph, compositeStack, { id: targetId, label: '', shape: 'state-end' })
293
+ } else if (!compositeStateIds.has(targetId)) {
294
+ // Only create a node if this isn't a composite state
295
+ ensureStateNode(graph, compositeStack, targetId)
296
+ }
297
+
298
+ graph.edges.push({
299
+ source: sourceId,
300
+ target: targetId,
301
+ label: edgeLabel,
302
+ style: 'solid',
303
+ hasArrowStart: false,
304
+ hasArrowEnd: true,
305
+ })
306
+ continue
307
+ }
308
+
309
+ // --- state description: `s1 : Description` ---
310
+ const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u)
311
+ if (stateDescMatch) {
312
+ const id = stateDescMatch[1]!
313
+ const label = normalizeBrTags(stateDescMatch[2]!.trim())
314
+ registerStateNode(graph, compositeStack, { id, label, shape: 'rounded' })
315
+ continue
316
+ }
317
+ }
318
+
319
+ return graph
320
+ }
321
+
322
+ /** Register a state node and track in composite state if applicable */
323
+ function registerStateNode(
324
+ graph: MermaidGraph,
325
+ compositeStack: MermaidSubgraph[],
326
+ node: MermaidNode
327
+ ): void {
328
+ const isNew = !graph.nodes.has(node.id)
329
+ if (isNew) {
330
+ graph.nodes.set(node.id, node)
331
+ }
332
+ if (compositeStack.length > 0) {
333
+ const current = compositeStack[compositeStack.length - 1]!
334
+ if (!current.nodeIds.includes(node.id)) {
335
+ current.nodeIds.push(node.id)
336
+ }
337
+ }
338
+ }
339
+
340
+ /** Ensure a state node exists with default rounded shape */
341
+ function ensureStateNode(
342
+ graph: MermaidGraph,
343
+ compositeStack: MermaidSubgraph[],
344
+ id: string
345
+ ): void {
346
+ if (!graph.nodes.has(id)) {
347
+ registerStateNode(graph, compositeStack, { id, label: id, shape: 'rounded' })
348
+ } else {
349
+ // Track in composite if applicable
350
+ if (compositeStack.length > 0) {
351
+ const current = compositeStack[compositeStack.length - 1]!
352
+ if (!current.nodeIds.includes(id)) {
353
+ current.nodeIds.push(id)
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ // ============================================================================
360
+ // Shared utilities
361
+ // ============================================================================
362
+
363
+ /** Parse "fill:#f00,stroke:#333" style property strings into a Record */
364
+ function parseStyleProps(propsStr: string): Record<string, string> {
365
+ // Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`)
366
+ const cleaned = propsStr.replace(/;\s*$/, '')
367
+ const props: Record<string, string> = {}
368
+ for (const pair of cleaned.split(',')) {
369
+ const colonIdx = pair.indexOf(':')
370
+ if (colonIdx > 0) {
371
+ const key = pair.slice(0, colonIdx).trim()
372
+ const val = pair.slice(colonIdx + 1).trim()
373
+ if (key && val) {
374
+ props[key] = val
375
+ }
376
+ }
377
+ }
378
+ return props
379
+ }
380
+
381
+ // ============================================================================
382
+ // Flowchart edge line parser
383
+ //
384
+ // Handles chained edges like: A[Label] --> B(Label) -.-> C{Label}
385
+ // Also handles & parallel links: A & B --> C & D
386
+ // ============================================================================
387
+
388
+ /**
389
+ * Arrow regex — matches all arrow operators with optional labels.
390
+ *
391
+ * Supported operators:
392
+ * --> --- solid arrow / solid line
393
+ * -.-> -.- dotted arrow / dotted line
394
+ * ==> === thick arrow / thick line
395
+ * <--> <-.-> <==> bidirectional variants
396
+ *
397
+ * Optional label: -->|label text|
398
+ */
399
+ const ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/
400
+
401
+ /**
402
+ * Text-embedded label regex — matches "-- label -->", "-. label .->", "== label ==>" syntax.
403
+ * Tried as fallback when ARROW_REGEX doesn't match.
404
+ *
405
+ * Based on PR #36 by @liuxiaopai-ai (https://github.com/lukilabs/beautiful-mermaid/pull/36)
406
+ */
407
+ const TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/
408
+
409
+ /**
410
+ * Node shape patterns — ordered from most specific delimiters to least.
411
+ * Multi-char delimiters must be tried before single-char to avoid false matches.
412
+ */
413
+ const NODE_PATTERNS: Array<{ regex: RegExp; shape: NodeShape }> = [
414
+ // Triple delimiters (must be first)
415
+ { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: 'doublecircle' }, // A(((text)))
416
+
417
+ // Double delimiters with mixed brackets
418
+ { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: 'stadium' }, // A([text])
419
+ { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: 'circle' }, // A((text))
420
+ { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: 'subroutine' }, // A[[text]]
421
+ { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: 'cylinder' }, // A[(text)]
422
+
423
+ // Trapezoid variants — must come before plain [text]
424
+ { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: 'trapezoid' }, // A[/text\]
425
+ { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: 'trapezoid-alt' }, // A[\text/]
426
+
427
+ // Asymmetric flag shape
428
+ { regex: /^([\w-]+)>(.+?)\]/, shape: 'asymmetric' }, // A>text]
429
+
430
+ // Double curly braces (hexagon) — must come before single {text}
431
+ { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: 'hexagon' }, // A{{text}}
432
+
433
+ // Single-char delimiters (last — most common, least specific)
434
+ { regex: /^([\w-]+)\[(.+?)\]/, shape: 'rectangle' }, // A[text]
435
+ { regex: /^([\w-]+)\((.+?)\)/, shape: 'rounded' }, // A(text)
436
+ { regex: /^([\w-]+)\{(.+?)\}/, shape: 'diamond' }, // A{text}
437
+ ]
438
+
439
+ /** Regex for a bare node reference (just an ID, no shape brackets) */
440
+ const BARE_NODE_REGEX = /^([\w-]+)/
441
+
442
+ /** Regex for ::: class shorthand suffix — matches :::className immediately after a node */
443
+ const CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/
444
+
445
+ /**
446
+ * Parse a line that contains node definitions and edges.
447
+ * Handles chaining: A --> B --> C produces edges A→B and B→C.
448
+ * Handles parallel links: A & B --> C & D produces 4 edges.
449
+ */
450
+ function parseEdgeLine(
451
+ line: string,
452
+ graph: MermaidGraph,
453
+ subgraphStack: MermaidSubgraph[]
454
+ ): void {
455
+ let remaining = line.trim()
456
+
457
+ // Parse the first node group (possibly with & separators)
458
+ const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack)
459
+ if (!firstGroup || firstGroup.ids.length === 0) return
460
+
461
+ remaining = firstGroup.remaining.trim()
462
+ let prevGroupIds = firstGroup.ids
463
+
464
+ // Parse arrow + node-group pairs until the line is exhausted
465
+ while (remaining.length > 0) {
466
+ let hasArrowStart: boolean
467
+ let style: EdgeStyle
468
+ let hasArrowEnd: boolean
469
+ let edgeLabel: string | undefined
470
+
471
+ const arrowMatch = remaining.match(ARROW_REGEX)
472
+ if (arrowMatch) {
473
+ hasArrowStart = Boolean(arrowMatch[1])
474
+ const arrowOp = arrowMatch[2]!
475
+ const rawEdgeLabel = arrowMatch[3]?.trim()
476
+ edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : undefined
477
+ remaining = remaining.slice(arrowMatch[0].length).trim()
478
+ style = arrowStyleFromOp(arrowOp)
479
+ hasArrowEnd = arrowOp.endsWith('>')
480
+ } else {
481
+ // Fallback: text-embedded label syntax (-- Yes -->, -. Maybe .->, == Sure ==>)
482
+ const textMatch = remaining.match(TEXT_ARROW_REGEX)
483
+ if (!textMatch) break
484
+ hasArrowStart = Boolean(textMatch[1])
485
+ const rawLabel = textMatch[3]!.trim()
486
+ edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : undefined
487
+ const openOp = textMatch[2]!
488
+ const closeOp = textMatch[4]!
489
+ remaining = remaining.slice(textMatch[0].length).trim()
490
+ style = textArrowStyleFromOps(openOp, closeOp)
491
+ hasArrowEnd = closeOp.endsWith('>')
492
+ }
493
+
494
+ // Parse the next node group
495
+ const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack)
496
+ if (!nextGroup || nextGroup.ids.length === 0) break
497
+
498
+ remaining = nextGroup.remaining.trim()
499
+
500
+ // Emit Cartesian product of edges: every source × every target
501
+ for (const sourceId of prevGroupIds) {
502
+ for (const targetId of nextGroup.ids) {
503
+ graph.edges.push({
504
+ source: sourceId,
505
+ target: targetId,
506
+ label: edgeLabel,
507
+ style,
508
+ hasArrowStart,
509
+ hasArrowEnd,
510
+ })
511
+ }
512
+ }
513
+
514
+ prevGroupIds = nextGroup.ids
515
+ }
516
+ }
517
+
518
+ interface ConsumedNodeGroup {
519
+ ids: string[]
520
+ remaining: string
521
+ }
522
+
523
+ /**
524
+ * Consume one or more nodes separated by `&`.
525
+ * E.g. "A & B & C --> ..." returns ids: ['A', 'B', 'C']
526
+ */
527
+ function consumeNodeGroup(
528
+ text: string,
529
+ graph: MermaidGraph,
530
+ subgraphStack: MermaidSubgraph[]
531
+ ): ConsumedNodeGroup | null {
532
+ const first = consumeNode(text, graph, subgraphStack)
533
+ if (!first) return null
534
+
535
+ const ids = [first.id]
536
+ let remaining = first.remaining.trim()
537
+
538
+ // Check for & separators
539
+ while (remaining.startsWith('&')) {
540
+ remaining = remaining.slice(1).trim()
541
+ const next = consumeNode(remaining, graph, subgraphStack)
542
+ if (!next) break
543
+ ids.push(next.id)
544
+ remaining = next.remaining.trim()
545
+ }
546
+
547
+ return { ids, remaining }
548
+ }
549
+
550
+ interface ConsumedNode {
551
+ id: string
552
+ remaining: string
553
+ }
554
+
555
+ /**
556
+ * Try to consume a node definition from the start of `text`.
557
+ * If the node has a shape+label (e.g. A[Text]), it's registered in the graph.
558
+ * If it's a bare reference (e.g. A), we look it up or create a default.
559
+ * Also handles ::: class shorthand suffix.
560
+ */
561
+ function consumeNode(
562
+ text: string,
563
+ graph: MermaidGraph,
564
+ subgraphStack: MermaidSubgraph[]
565
+ ): ConsumedNode | null {
566
+ let id: string | null = null
567
+ let remaining: string = text
568
+
569
+ // Try each node pattern (shape-qualified)
570
+ for (const { regex, shape } of NODE_PATTERNS) {
571
+ const match = text.match(regex)
572
+ if (match) {
573
+ id = match[1]!
574
+ const label = normalizeBrTags(match[2]!)
575
+ registerNode(graph, subgraphStack, { id, label, shape })
576
+ remaining = text.slice(match[0].length)
577
+ break
578
+ }
579
+ }
580
+
581
+ // Bare node reference — only register if node doesn't exist yet.
582
+ // If it already exists, do NOT track it in the current subgraph;
583
+ // nodes belong to the subgraph where they're first defined.
584
+ if (id === null) {
585
+ const bareMatch = text.match(BARE_NODE_REGEX)
586
+ if (bareMatch) {
587
+ id = bareMatch[1]!
588
+ if (!graph.nodes.has(id)) {
589
+ registerNode(graph, subgraphStack, { id, label: id, shape: 'rectangle' })
590
+ }
591
+ remaining = text.slice(bareMatch[0].length)
592
+ }
593
+ }
594
+
595
+ if (id === null) return null
596
+
597
+ // Check for ::: class shorthand suffix immediately after the node
598
+ const classMatch = remaining.match(CLASS_SHORTHAND_REGEX)
599
+ if (classMatch) {
600
+ graph.classAssignments.set(id, classMatch[1]!)
601
+ remaining = remaining.slice(classMatch[0].length)
602
+ }
603
+
604
+ return { id, remaining }
605
+ }
606
+
607
+ /** Register a node in the graph and track it in the current subgraph */
608
+ function registerNode(
609
+ graph: MermaidGraph,
610
+ subgraphStack: MermaidSubgraph[],
611
+ node: MermaidNode
612
+ ): void {
613
+ const isNew = !graph.nodes.has(node.id)
614
+ if (isNew) {
615
+ graph.nodes.set(node.id, node)
616
+ }
617
+ trackInSubgraph(subgraphStack, node.id)
618
+ }
619
+
620
+ /** Add node ID to the innermost subgraph if we're inside one */
621
+ function trackInSubgraph(subgraphStack: MermaidSubgraph[], nodeId: string): void {
622
+ if (subgraphStack.length > 0) {
623
+ const current = subgraphStack[subgraphStack.length - 1]!
624
+ if (!current.nodeIds.includes(nodeId)) {
625
+ current.nodeIds.push(nodeId)
626
+ }
627
+ }
628
+ }
629
+
630
+ /** Map arrow operator string to edge style (ignoring direction) */
631
+ function arrowStyleFromOp(op: string): EdgeStyle {
632
+ if (op === '-.->') return 'dotted'
633
+ if (op === '-.-') return 'dotted'
634
+ if (op === '==>') return 'thick'
635
+ if (op === '===') return 'thick'
636
+ // '-->'' and '---' are both solid
637
+ return 'solid'
638
+ }
639
+
640
+ /** Map text-embedded arrow open/close operators to edge style */
641
+ function textArrowStyleFromOps(openOp: string, closeOp: string): EdgeStyle {
642
+ if (openOp === '-.' || closeOp === '.->' || closeOp === '-.-') return 'dotted'
643
+ if (openOp === '==' || closeOp === '==>' || closeOp === '===') return 'thick'
644
+ return 'solid'
645
+ }