@zombie-mermaid/mermaid-parser 3.1.0 → 4.0.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.
@@ -0,0 +1,141 @@
1
+ import type {
2
+ Direction,
3
+ MermaidEdge,
4
+ MermaidGraph,
5
+ MermaidNode,
6
+ MermaidSubgraph,
7
+ NodeShape,
8
+ } from '@zombie-mermaid/core'
9
+ import type {
10
+ ArchitectureDiagram,
11
+ ArchitectureEdge,
12
+ ArchitecturePort,
13
+ } from './types.ts'
14
+
15
+ // ============================================================================
16
+ // Architecture -> flowchart lowering
17
+ //
18
+ // `architecture-beta` is boxes in nested boxes joined by edges, which the
19
+ // flowchart pipeline already lays out and draws (ELK for SVG, the grid router
20
+ // for ASCII). Like the other lowered types it becomes a `MermaidGraph`:
21
+ //
22
+ // group -> subgraph (nested via `in`)
23
+ // service -> node; the icon picks a shape
24
+ // junction -> small filled circle, no label
25
+ // edge -> edge; `<`/`>` become arrowheads
26
+ // `{group}` end -> the edge attaches to the service's enclosing group
27
+ //
28
+ // What is not carried over: Mermaid places services on a grid from the edge
29
+ // ports, so `a:R -- L:b` means "b is right of a". Here ports only choose the
30
+ // flow direction (majority axis) and which way each edge points in the
31
+ // layout; exact placement is the flowchart layout's. Icons are not drawn
32
+ // (only the database/disk shapes differ) and `align` is ignored.
33
+ // ============================================================================
34
+
35
+ /** Built-in Mermaid icons that have a distinct flowchart shape. */
36
+ const ICON_SHAPE: Record<string, NodeShape> = {
37
+ database: 'cylinder',
38
+ disk: 'cylinder',
39
+ cloud: 'stadium',
40
+ internet: 'circle',
41
+ }
42
+
43
+ const HORIZONTAL: ReadonlySet<ArchitecturePort> = new Set(['L', 'R'])
44
+
45
+ /** Flow direction: vertical only when more edges leave via T/B than L/R. */
46
+ function pickDirection(edges: readonly ArchitectureEdge[]): Direction {
47
+ let horizontal = 0
48
+ let vertical = 0
49
+ for (const e of edges) {
50
+ for (const p of [e.sourcePort, e.targetPort]) {
51
+ if (HORIZONTAL.has(p)) horizontal++
52
+ else vertical++
53
+ }
54
+ }
55
+ return vertical > horizontal ? 'TB' : 'LR'
56
+ }
57
+
58
+ /**
59
+ * Whether the edge, as written, runs against the layout flow. `a:L -- R:b`
60
+ * puts `a` to the right of `b`, so in a left-to-right layout the edge has to
61
+ * be emitted reversed for `a` to land on the right.
62
+ */
63
+ function runsBackwards(e: ArchitectureEdge, direction: Direction): boolean {
64
+ const horizontal = direction === 'LR' || direction === 'RL'
65
+ const [back, forward]: ArchitecturePort[] = horizontal
66
+ ? ['L', 'R']
67
+ : ['T', 'B']
68
+ // Source port on the far side of the flow, or target port on the near side.
69
+ if (e.sourcePort === forward || e.targetPort === back) return false
70
+ return e.sourcePort === back || e.targetPort === forward
71
+ }
72
+
73
+ export function architectureToGraph(
74
+ diagram: ArchitectureDiagram,
75
+ ): MermaidGraph {
76
+ const nodes = new Map<string, MermaidNode>()
77
+ const parentOf = new Map<string, string | undefined>()
78
+
79
+ for (const s of diagram.services) {
80
+ nodes.set(s.id, {
81
+ id: s.id,
82
+ label: s.title,
83
+ shape: (s.icon && ICON_SHAPE[s.icon]) || 'rectangle',
84
+ })
85
+ parentOf.set(s.id, s.parent)
86
+ }
87
+ for (const j of diagram.junctions) {
88
+ nodes.set(j.id, { id: j.id, label: '', shape: 'filled-circle' })
89
+ parentOf.set(j.id, j.parent)
90
+ }
91
+
92
+ // Subgraphs, nested. Children are attached in declaration order.
93
+ const subgraphs = new Map<string, MermaidSubgraph>()
94
+ for (const g of diagram.groups) {
95
+ subgraphs.set(g.id, {
96
+ id: g.id,
97
+ label: g.title,
98
+ nodeIds: [],
99
+ children: [],
100
+ })
101
+ }
102
+ const roots: MermaidSubgraph[] = []
103
+ for (const g of diagram.groups) {
104
+ const sg = subgraphs.get(g.id)!
105
+ const parent = g.parent ? subgraphs.get(g.parent) : undefined
106
+ if (parent) parent.children.push(sg)
107
+ else roots.push(sg)
108
+ }
109
+ for (const [id, parent] of parentOf) {
110
+ subgraphs.get(parent ?? '')?.nodeIds.push(id)
111
+ }
112
+
113
+ const direction = pickDirection(diagram.edges)
114
+ const edges: MermaidEdge[] = diagram.edges.map((e) => {
115
+ const end = (id: string, viaGroup: boolean): string =>
116
+ (viaGroup ? parentOf.get(id) : undefined) ?? id
117
+ const forward = !runsBackwards(e, direction)
118
+ const [source, target] = forward
119
+ ? [end(e.source, e.sourceGroup), end(e.target, e.targetGroup)]
120
+ : [end(e.target, e.targetGroup), end(e.source, e.sourceGroup)]
121
+ return {
122
+ source,
123
+ target,
124
+ style: 'solid',
125
+ hasArrowStart: forward ? e.arrowStart : e.arrowEnd,
126
+ hasArrowEnd: forward ? e.arrowEnd : e.arrowStart,
127
+ }
128
+ })
129
+
130
+ return {
131
+ direction,
132
+ nodes,
133
+ edges,
134
+ subgraphs: roots,
135
+ classDefs: new Map(),
136
+ classAssignments: new Map(),
137
+ nodeStyles: new Map(),
138
+ linkStyles: new Map(),
139
+ interactions: new Map(),
140
+ }
141
+ }
@@ -0,0 +1,49 @@
1
+ // ============================================================================
2
+ // Architecture diagram types (Mermaid `architecture-beta`)
3
+ // ============================================================================
4
+
5
+ /** A port on a service/junction: left, right, top, bottom. */
6
+ export type ArchitecturePort = 'L' | 'R' | 'T' | 'B'
7
+
8
+ export interface ArchitectureGroup {
9
+ id: string
10
+ /** Icon name from `(icon)`; kept for consumers, not drawn (see to-graph). */
11
+ icon?: string
12
+ /** `[Title]`; falls back to the id. */
13
+ title: string
14
+ /** Enclosing group id, from `in <parent>`. */
15
+ parent?: string
16
+ }
17
+
18
+ export interface ArchitectureService {
19
+ id: string
20
+ icon?: string
21
+ title: string
22
+ parent?: string
23
+ }
24
+
25
+ export interface ArchitectureJunction {
26
+ id: string
27
+ parent?: string
28
+ }
29
+
30
+ export interface ArchitectureEdge {
31
+ source: string
32
+ sourcePort: ArchitecturePort
33
+ /** `{group}` modifier: the edge attaches to the enclosing group's border. */
34
+ sourceGroup: boolean
35
+ target: string
36
+ targetPort: ArchitecturePort
37
+ targetGroup: boolean
38
+ /** `<--` / `<-->`: arrowhead at the source end. */
39
+ arrowStart: boolean
40
+ /** `-->` / `<-->`: arrowhead at the target end. */
41
+ arrowEnd: boolean
42
+ }
43
+
44
+ export interface ArchitectureDiagram {
45
+ groups: ArchitectureGroup[]
46
+ services: ArchitectureService[]
47
+ junctions: ArchitectureJunction[]
48
+ edges: ArchitectureEdge[]
49
+ }
@@ -0,0 +1,120 @@
1
+ import type { C4Boundary, C4Element, C4Relationship } from './types.ts'
2
+
3
+ // ============================================================================
4
+ // C4 text and layout-hint helpers shared by the SVG and ASCII renderers, so
5
+ // both draw the same words and honor the same `Rel_U/D/L/R` hints.
6
+ // ============================================================================
7
+
8
+ /** Greedy word wrap; a word longer than `width` stays on its own line. */
9
+ export function wrapC4Text(text: string, width: number): string[] {
10
+ const out: string[] = []
11
+ for (const paragraph of text.split('\n')) {
12
+ let cur = ''
13
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
14
+ if (cur && cur.length + 1 + word.length > width) {
15
+ out.push(cur)
16
+ cur = word
17
+ } else {
18
+ cur = cur ? `${cur} ${word}` : word
19
+ }
20
+ }
21
+ out.push(cur)
22
+ }
23
+ return out
24
+ }
25
+
26
+ const STEREOTYPES: Record<C4Element['kind'], string> = {
27
+ person: 'Person',
28
+ system: 'Software System',
29
+ container: 'Container',
30
+ component: 'Component',
31
+ }
32
+
33
+ /**
34
+ * The bracketed type line under an element's name, as Mermaid draws it:
35
+ * `[Person]`, `[Software System]`, `[Container: PostgreSQL]`. External,
36
+ * database and queue variants share their base kind's line; the fill and the
37
+ * shape tell them apart.
38
+ */
39
+ export function c4TypeLine(el: C4Element): string {
40
+ const tech =
41
+ (el.kind === 'container' || el.kind === 'component') && el.technology
42
+ ? `: ${el.technology}`
43
+ : ''
44
+ return `[${STEREOTYPES[el.kind]}${tech}]`
45
+ }
46
+
47
+ /** A boundary's type line (`[ENTERPRISE]`, `[Ubuntu]`), when it has a type. */
48
+ export function c4BoundaryTypeLine(b: C4Boundary): string | undefined {
49
+ return b.type ? `[${b.type}]` : undefined
50
+ }
51
+
52
+ /**
53
+ * The text lines of a relationship label: the label itself (prefixed with its
54
+ * sequence number in a C4Dynamic diagram), then the technology in brackets on its
55
+ * own line. Empty when the relationship has neither.
56
+ */
57
+ export function c4RelLabelLines(rel: C4Relationship): string[] {
58
+ const lines: string[] = []
59
+ const head = [rel.index ? `${rel.index}:` : '', rel.label]
60
+ .filter(Boolean)
61
+ .join(' ')
62
+ if (head) lines.push(head)
63
+ if (rel.technology) lines.push(`[${rel.technology}]`)
64
+ return lines
65
+ }
66
+
67
+ /**
68
+ * How a relationship constrains placement. `source` is meant to sit before
69
+ * `target` along `axis`: above it for `'vertical'`, left of it for
70
+ * `'horizontal'`. `Rel_U`/`Rel_L` reverse the pair (the target sits above /
71
+ * left of the source); plain `Rel` is a vertical hint, since C4 diagrams flow
72
+ * top to bottom by default.
73
+ */
74
+ export interface C4Placement {
75
+ source: string
76
+ target: string
77
+ axis: 'vertical' | 'horizontal'
78
+ /** True when the hint came from an explicit `Rel_U/D/L/R`. */
79
+ explicit: boolean
80
+ }
81
+
82
+ export function c4Placement(rel: C4Relationship): C4Placement {
83
+ switch (rel.layout) {
84
+ case 'up':
85
+ return {
86
+ source: rel.to,
87
+ target: rel.from,
88
+ axis: 'vertical',
89
+ explicit: true,
90
+ }
91
+ case 'left':
92
+ return {
93
+ source: rel.to,
94
+ target: rel.from,
95
+ axis: 'horizontal',
96
+ explicit: true,
97
+ }
98
+ case 'right':
99
+ return {
100
+ source: rel.from,
101
+ target: rel.to,
102
+ axis: 'horizontal',
103
+ explicit: true,
104
+ }
105
+ case 'down':
106
+ return {
107
+ source: rel.from,
108
+ target: rel.to,
109
+ axis: 'vertical',
110
+ explicit: true,
111
+ }
112
+ default:
113
+ return {
114
+ source: rel.from,
115
+ target: rel.to,
116
+ axis: 'vertical',
117
+ explicit: false,
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,271 @@
1
+ import { normalizeBrTags, type Statement } from '@zombie-mermaid/core'
2
+ import type {
3
+ C4Boundary,
4
+ C4Diagram,
5
+ C4Element,
6
+ C4ElementKind,
7
+ C4LayoutHint,
8
+ C4ElementShape,
9
+ C4Relationship,
10
+ C4Variant,
11
+ } from './types.ts'
12
+
13
+ // ============================================================================
14
+ // C4 diagram parser
15
+ //
16
+ // Supported syntax (Mermaid C4 macros):
17
+ // Person / Person_Ext (alias, label, descr)
18
+ // System[Db|Queue][_Ext] (alias, label, descr)
19
+ // Container[Db|Queue][_Ext] (alias, label, techn, descr)
20
+ // Component[Db|Queue][_Ext] (alias, label, techn, descr)
21
+ // Boundary / Enterprise_Boundary / System_Boundary / Container_Boundary
22
+ // (alias, label[, type]) { ... }
23
+ // Deployment_Node / Node / Node_L / Node_R (alias, label, type, descr) { ... }
24
+ // Rel / BiRel / Rel_U|Up|D|Down|L|Left|R|Right|Back (from, to, label, techn)
25
+ // RelIndex (index, from, to, label, techn)
26
+ // title <text>
27
+ //
28
+ // `Rel_U/D/L/R` record a placement hint (`C4Relationship.layout`) the
29
+ // renderers use to steer layout. Accepted and ignored (styling / layout
30
+ // config this renderer does not honor): UpdateElementStyle, UpdateRelStyle, UpdateLayoutConfig, LAYOUT_*,
31
+ // SHOW_LEGEND, SHOW_FLOATING_LEGEND, AddElementTag, AddRelTag,
32
+ // AddBoundaryTag, RoleTag. Named arguments (`$tags="x"`, `$link=...`) are
33
+ // dropped.
34
+ // ============================================================================
35
+
36
+ const HEADERS: Record<string, C4Variant> = {
37
+ c4context: 'context',
38
+ c4container: 'container',
39
+ c4component: 'component',
40
+ c4dynamic: 'dynamic',
41
+ c4deployment: 'deployment',
42
+ }
43
+
44
+ const IGNORED_MACRO =
45
+ /^(?:UpdateElementStyle|UpdateRelStyle|UpdateLayoutConfig|LAYOUT_[A-Z_]+|SHOW_LEGEND|SHOW_FLOATING_LEGEND|AddElementTag|AddRelTag|AddBoundaryTag|RoleTag)\b/
46
+
47
+ const ELEMENT_NAME = /^(Person|System|Container|Component)(Db|Queue)?(_Ext)?$/
48
+ const BOUNDARY_NAME =
49
+ /^(?:Boundary|Enterprise_Boundary|System_Boundary|Container_Boundary)$/
50
+ const NODE_NAME = /^(?:Deployment_Node|Node|Node_L|Node_R)$/
51
+ const REL_NAME =
52
+ /^(Rel|BiRel|Rel_U|Rel_Up|Rel_D|Rel_Down|Rel_L|Rel_Left|Rel_R|Rel_Right|Rel_Back|RelIndex)$/
53
+
54
+ const BOUNDARY_TYPES: Record<string, string> = {
55
+ Enterprise_Boundary: 'ENTERPRISE',
56
+ System_Boundary: 'SYSTEM',
57
+ Container_Boundary: 'CONTAINER',
58
+ }
59
+
60
+ const REL_HINTS: Record<string, C4LayoutHint> = {
61
+ Rel_U: 'up',
62
+ Rel_Up: 'up',
63
+ Rel_D: 'down',
64
+ Rel_Down: 'down',
65
+ Rel_L: 'left',
66
+ Rel_Left: 'left',
67
+ Rel_R: 'right',
68
+ Rel_Right: 'right',
69
+ }
70
+
71
+ function unquote(a: string): string {
72
+ const t = a.trim()
73
+ const inner =
74
+ t.length >= 2 && t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t
75
+ return normalizeBrTags(inner)
76
+ }
77
+
78
+ /**
79
+ * Split a macro's argument list on top-level commas. Double-quoted strings
80
+ * may contain commas and parentheses; `$name=value` named arguments are
81
+ * dropped. Returns null when a quote is unbalanced.
82
+ */
83
+ function splitArgs(inner: string): string[] | null {
84
+ const raw: string[] = []
85
+ let cur = ''
86
+ let inQuote = false
87
+ for (const ch of inner) {
88
+ if (ch === '"') inQuote = !inQuote
89
+ if (ch === ',' && !inQuote) {
90
+ raw.push(cur)
91
+ cur = ''
92
+ } else {
93
+ cur += ch
94
+ }
95
+ }
96
+ if (inQuote) return null
97
+ raw.push(cur)
98
+ return raw
99
+ .map((a) => a.trim())
100
+ .filter((a) => !a.startsWith('$'))
101
+ .map(unquote)
102
+ }
103
+
104
+ /** `Name(args)` optionally followed by ` {`. */
105
+ const MACRO = /^([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*(\{)?\s*$/
106
+
107
+ function fail(stmt: Statement, msg: string): never {
108
+ throw new Error(`C4 diagram, line ${stmt.line}: ${msg} — "${stmt.text}"`)
109
+ }
110
+
111
+ /**
112
+ * Parse a Mermaid C4 diagram. Expects the first statement to be a
113
+ * `C4Context` / `C4Container` / `C4Component` / `C4Dynamic` /
114
+ * `C4Deployment` header.
115
+ */
116
+ export function parseC4Diagram(lines: Statement[]): C4Diagram {
117
+ const variant = HEADERS[lines[0]?.text.trim().toLowerCase() ?? '']
118
+ if (!variant) {
119
+ throw new Error(
120
+ `C4 diagram: expected a header of C4Context, C4Container, C4Component, C4Dynamic or C4Deployment, got "${lines[0]?.text ?? ''}"`,
121
+ )
122
+ }
123
+
124
+ const diagram: C4Diagram = {
125
+ variant,
126
+ elements: [],
127
+ boundaries: [],
128
+ relationships: [],
129
+ }
130
+ const aliases = new Set<string>()
131
+ const stack: C4Boundary[] = []
132
+ /** Boundary declared on the previous statement without an inline `{`. */
133
+ let pendingBoundary: C4Boundary | undefined
134
+
135
+ const claim = (stmt: Statement, alias: string | undefined): string => {
136
+ if (!alias) fail(stmt, 'missing alias (first argument)')
137
+ if (aliases.has(alias)) fail(stmt, `duplicate alias "${alias}"`)
138
+ aliases.add(alias)
139
+ return alias
140
+ }
141
+
142
+ for (let i = 1; i < lines.length; i++) {
143
+ const stmt = lines[i]!
144
+ const line = stmt.text
145
+
146
+ if (line === '}') {
147
+ if (stack.length === 0) fail(stmt, 'unmatched "}"')
148
+ stack.pop()
149
+ continue
150
+ }
151
+ // Brace on its own line, directly after a boundary macro.
152
+ if (line === '{') {
153
+ if (!pendingBoundary) fail(stmt, 'unexpected "{"')
154
+ stack.push(pendingBoundary)
155
+ pendingBoundary = undefined
156
+ continue
157
+ }
158
+ pendingBoundary = undefined
159
+
160
+ const titleMatch = line.match(/^title(?:\s+(.*))?$/i)
161
+ if (titleMatch) {
162
+ diagram.title = normalizeBrTags((titleMatch[1] ?? '').trim())
163
+ continue
164
+ }
165
+
166
+ if (IGNORED_MACRO.test(line)) continue
167
+
168
+ const m = line.match(MACRO)
169
+ if (!m) fail(stmt, 'unrecognized statement')
170
+ const name = m[1]!
171
+ const opensBlock = m[3] === '{'
172
+ const args = splitArgs(m[2]!)
173
+ if (!args) fail(stmt, 'unbalanced quotes')
174
+
175
+ const el = name.match(ELEMENT_NAME)
176
+ if (el) {
177
+ if (opensBlock) fail(stmt, `"${name}" cannot contain a block`)
178
+ const kind = el[1]!.toLowerCase() as C4ElementKind
179
+ const shape: C4ElementShape =
180
+ el[2] === 'Db' ? 'db' : el[2] === 'Queue' ? 'queue' : 'default'
181
+ const hasTech = kind === 'container' || kind === 'component'
182
+ const alias = claim(stmt, args[0])
183
+ const element: C4Element = {
184
+ alias,
185
+ kind,
186
+ shape,
187
+ external: el[3] !== undefined,
188
+ label: args[1] || alias,
189
+ }
190
+ const technology = hasTech ? args[2] : undefined
191
+ const description = hasTech ? args[3] : args[2]
192
+ if (technology) element.technology = technology
193
+ if (description) element.description = description
194
+ diagram.elements.push(element)
195
+ stack.at(-1)?.elementAliases.push(alias)
196
+ continue
197
+ }
198
+
199
+ if (BOUNDARY_NAME.test(name) || NODE_NAME.test(name)) {
200
+ const isNode = NODE_NAME.test(name)
201
+ const alias = claim(stmt, args[0])
202
+ const boundary: C4Boundary = {
203
+ alias,
204
+ label: args[1] || alias,
205
+ elementAliases: [],
206
+ children: [],
207
+ }
208
+ // Mermaid labels every frame with a type: the macro's own for the
209
+ // typed boundaries, else the explicit argument, else a default.
210
+ // (System_/Container_/Enterprise_Boundary take no type arg.)
211
+ const typed = BOUNDARY_TYPES[name]
212
+ if (typed) boundary.type = typed
213
+ else if (args[2] && (isNode || name === 'Boundary'))
214
+ boundary.type = args[2]
215
+ else boundary.type = isNode ? 'node' : 'system'
216
+ if (isNode && args[3]) boundary.description = args[3]
217
+ const parent = stack.at(-1)
218
+ if (parent) parent.children.push(boundary)
219
+ else diagram.boundaries.push(boundary)
220
+ if (opensBlock) stack.push(boundary)
221
+ else pendingBoundary = boundary
222
+ continue
223
+ }
224
+
225
+ const rel = name.match(REL_NAME)
226
+ if (rel) {
227
+ const indexed = name === 'RelIndex'
228
+ const a = indexed ? args.slice(1) : args
229
+ if (!a[0] || !a[1]) {
230
+ fail(stmt, 'a relationship needs "from" and "to" aliases')
231
+ }
232
+ const r: C4Relationship = {
233
+ from: a[0],
234
+ to: a[1],
235
+ label: a[2] ?? '',
236
+ bidirectional: name === 'BiRel',
237
+ }
238
+ if (a[3]) r.technology = a[3]
239
+ if (name === 'Rel_Back') r.reversed = true
240
+ const hint = REL_HINTS[name]
241
+ if (hint) r.layout = hint
242
+ diagram.relationships.push(r)
243
+ continue
244
+ }
245
+
246
+ fail(stmt, `unknown C4 macro "${name}"`)
247
+ }
248
+
249
+ if (stack.length > 0) {
250
+ throw new Error(
251
+ `C4 diagram: boundary "${stack.at(-1)!.alias}" is missing its closing "}"`,
252
+ )
253
+ }
254
+ // Mermaid ignores RelIndex's index argument: in C4Dynamic every relationship
255
+ // is numbered by its position in the source, and no other variant numbers.
256
+ if (diagram.variant === 'dynamic') {
257
+ diagram.relationships.forEach((r, i) => {
258
+ r.index = String(i + 1)
259
+ })
260
+ }
261
+ for (const r of diagram.relationships) {
262
+ for (const end of [r.from, r.to]) {
263
+ if (!aliases.has(end)) {
264
+ throw new Error(
265
+ `C4 diagram: relationship refers to undeclared alias "${end}"`,
266
+ )
267
+ }
268
+ }
269
+ }
270
+ return diagram
271
+ }