@oh-my-pi/pi-utils 16.0.7 → 16.0.8
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/CHANGELOG.md +10 -0
- package/dist/types/mermaid-ascii.d.ts +1 -1
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
- package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
- package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
- package/package.json +2 -3
- package/src/mermaid-ascii.ts +1 -1
- package/src/vendor/mermaid-ascii/NOTICE +33 -0
- package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
- package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
- package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
- package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
- package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
- package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
- package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
- package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
- package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
- package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
- package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +215 -0
- package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
- package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
- package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
- package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
- package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
- package/src/vendor/mermaid-ascii/class/types.ts +121 -0
- package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
- package/src/vendor/mermaid-ascii/er/types.ts +91 -0
- package/src/vendor/mermaid-ascii/index.ts +14 -0
- package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
- package/src/vendor/mermaid-ascii/parser.ts +645 -0
- package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
- package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
- package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
- package/src/vendor/mermaid-ascii/types.ts +164 -0
- package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
- package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
- 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
|
+
}
|