@zombie-mermaid/mermaid-parser 3.1.0 → 3.2.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,340 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseC4Diagram } from '../index.ts'
4
+
5
+ // Fixtures ported from lukilabs/beautiful-mermaid#71 (c4-parser.test.ts) by
6
+ // Victor Palma (devx), (MIT-licensed), adapted to this
7
+ // fork's `parseC4Diagram` model (lowercase `variant`/`kind`, `shape`,
8
+ // `children`/`elementAliases`). Assertions this design cannot satisfy are kept
9
+ // as `it.skip` with the reason, so the gap stays visible.
10
+
11
+ const parse = (src: string) => parseC4Diagram(splitStatements(src))
12
+
13
+ describe('upstream #71: diagram types', () => {
14
+ it.each([
15
+ ['C4Context', 'context'],
16
+ ['C4Container', 'container'],
17
+ ['C4Component', 'component'],
18
+ ['C4Dynamic', 'dynamic'],
19
+ ] as const)('parses %s as variant %s', (header, variant) => {
20
+ const d = parse(`${header}\n Person(user, "User", "A user")`)
21
+ expect(d.variant).toBe(variant)
22
+ })
23
+
24
+ it('parses C4Deployment with an empty Deployment_Node block', () => {
25
+ const d = parse(`C4Deployment
26
+ Deployment_Node(aws, "AWS", "Cloud") {
27
+ }`)
28
+ expect(d.variant).toBe('deployment')
29
+ expect(d.boundaries[0]).toMatchObject({ alias: 'aws', type: 'Cloud' })
30
+ })
31
+
32
+ // Upstream defaults an unknown header to C4Context; here it is an error.
33
+ it('rejects an unknown header instead of defaulting to C4Context', () => {
34
+ expect(() =>
35
+ parse('UnknownDiagram\n Person(user, "User", "A user")'),
36
+ ).toThrow(/expected a header of C4Context/)
37
+ })
38
+ })
39
+
40
+ describe('upstream #71: element declarations', () => {
41
+ it('parses Person with alias, label, description', () => {
42
+ const d = parse(`C4Context
43
+ Person(user, "User", "A person who uses the system")`)
44
+ expect(d.elements).toHaveLength(1)
45
+ expect(d.elements[0]).toMatchObject({
46
+ kind: 'person',
47
+ alias: 'user',
48
+ label: 'User',
49
+ description: 'A person who uses the system',
50
+ external: false,
51
+ })
52
+ })
53
+
54
+ it('parses Person_Ext as external', () => {
55
+ const d = parse('C4Context\n Person_Ext(admin, "Admin", "External admin")')
56
+ expect(d.elements[0]).toMatchObject({ kind: 'person', external: true })
57
+ })
58
+
59
+ it('parses System with alias and label only', () => {
60
+ const d = parse('C4Context\n System(sys, "My System")')
61
+ expect(d.elements[0]).toMatchObject({
62
+ kind: 'system',
63
+ alias: 'sys',
64
+ label: 'My System',
65
+ })
66
+ expect(d.elements[0]!.description).toBeUndefined()
67
+ })
68
+
69
+ it('parses System_Ext as external', () => {
70
+ const d = parse(
71
+ 'C4Context\n System_Ext(ext, "External System", "Third party")',
72
+ )
73
+ expect(d.elements[0]).toMatchObject({
74
+ external: true,
75
+ label: 'External System',
76
+ })
77
+ })
78
+
79
+ it.each([
80
+ [
81
+ 'Container(web, "Web App", "Java/Spring", "Serves web pages")',
82
+ 'container',
83
+ 'default',
84
+ 'Java/Spring',
85
+ ],
86
+ [
87
+ 'ContainerDb(db, "Database", "PostgreSQL", "Stores data")',
88
+ 'container',
89
+ 'db',
90
+ 'PostgreSQL',
91
+ ],
92
+ [
93
+ 'ContainerQueue(q, "Message Queue", "RabbitMQ", "Async")',
94
+ 'container',
95
+ 'queue',
96
+ 'RabbitMQ',
97
+ ],
98
+ [
99
+ 'Component(ctrl, "Controller", "Spring MVC", "Handles HTTP")',
100
+ 'component',
101
+ 'default',
102
+ 'Spring MVC',
103
+ ],
104
+ [
105
+ 'ComponentDb(repo, "Repository", "JPA", "Data access")',
106
+ 'component',
107
+ 'db',
108
+ 'JPA',
109
+ ],
110
+ [
111
+ 'ComponentQueue(h, "Event Handler", "Spring AMQP", "Events")',
112
+ 'component',
113
+ 'queue',
114
+ 'Spring AMQP',
115
+ ],
116
+ ] as const)('parses %s with technology', (line, kind, shape, technology) => {
117
+ const d = parse(`C4Container\n ${line}`)
118
+ expect(d.elements[0]).toMatchObject({ kind, shape, technology })
119
+ })
120
+
121
+ it('parses _Ext variants for Container types', () => {
122
+ const d = parse(
123
+ 'C4Container\n Container_Ext(ext, "External API", "REST", "Third party")',
124
+ )
125
+ expect(d.elements[0]).toMatchObject({ kind: 'container', external: true })
126
+ })
127
+
128
+ it('parses multiple elements', () => {
129
+ const d = parse(`C4Context
130
+ Person(user, "User", "End user")
131
+ System(sys, "System", "Main system")
132
+ System_Ext(ext, "Email", "Sends emails")`)
133
+ expect(d.elements).toHaveLength(3)
134
+ })
135
+ })
136
+
137
+ describe('upstream #71: title', () => {
138
+ it('parses a title directive', () => {
139
+ const d = parse(`C4Context
140
+ title System Context Diagram
141
+ Person(user, "User", "End user")`)
142
+ expect(d.title).toBe('System Context Diagram')
143
+ })
144
+
145
+ it('parses a quoted title', () => {
146
+ const d = parse(`C4Context
147
+ title "My C4 Diagram"
148
+ Person(user, "User", "End user")`)
149
+ expect(d.title).toBe('My C4 Diagram')
150
+ })
151
+
152
+ it('has no title when not specified', () => {
153
+ const d = parse('C4Context\n Person(user, "User", "End user")')
154
+ expect(d.title).toBeUndefined()
155
+ })
156
+ })
157
+
158
+ describe('upstream #71: boundaries', () => {
159
+ it('parses System_Boundary with child elements', () => {
160
+ const d = parse(`C4Context
161
+ System_Boundary(sb, "System Boundary") {
162
+ Container(web, "Web App", "Java", "Serves pages")
163
+ }`)
164
+ expect(d.boundaries).toHaveLength(1)
165
+ expect(d.boundaries[0]).toMatchObject({
166
+ alias: 'sb',
167
+ label: 'System Boundary',
168
+ elementAliases: ['web'],
169
+ })
170
+ })
171
+
172
+ it.each(['Container_Boundary', 'Enterprise_Boundary', 'Boundary'])(
173
+ 'parses %s',
174
+ (macro) => {
175
+ const d = parse(`C4Context
176
+ ${macro}(b, "Label") {
177
+ System(sys, "Internal System", "Core system")
178
+ }`)
179
+ expect(d.boundaries[0]).toMatchObject({ alias: 'b', label: 'Label' })
180
+ },
181
+ )
182
+
183
+ it('parses Deployment_Node as a boundary', () => {
184
+ const d = parse(`C4Deployment
185
+ Deployment_Node(aws, "AWS") {
186
+ Container(web, "Web App", "Docker", "Runs in container")
187
+ }`)
188
+ expect(d.boundaries[0]).toMatchObject({
189
+ alias: 'aws',
190
+ elementAliases: ['web'],
191
+ })
192
+ })
193
+
194
+ it('parses nested boundaries', () => {
195
+ const d = parse(`C4Context
196
+ Enterprise_Boundary(eb, "Enterprise") {
197
+ System_Boundary(sb, "System") {
198
+ Container(web, "Web App", "Java", "Serves pages")
199
+ }
200
+ }`)
201
+ expect(d.boundaries).toHaveLength(1)
202
+ expect(d.boundaries[0]!.children).toHaveLength(1)
203
+ expect(d.boundaries[0]!.children[0]!.elementAliases).toEqual(['web'])
204
+ })
205
+
206
+ it('places an element in its innermost boundary only', () => {
207
+ const d = parse(`C4Context
208
+ Enterprise_Boundary(eb, "Enterprise") {
209
+ System_Boundary(sb, "System") {
210
+ Container(web, "Web", "Java", "App")
211
+ }
212
+ }`)
213
+ expect(d.boundaries[0]!.elementAliases).toEqual([])
214
+ expect(d.boundaries[0]!.children[0]!.alias).toBe('sb')
215
+ expect(d.boundaries[0]!.children[0]!.elementAliases).toEqual(['web'])
216
+ })
217
+
218
+ // Known gap: the model keeps no boundary macro kind and no parent pointers;
219
+ // nesting is expressed through `children` / `elementAliases` instead.
220
+ it.skip('records the boundary macro kind (System_Boundary, Enterprise_Boundary, ...)', () => {
221
+ const d = parse(`C4Context
222
+ System_Boundary(sb, "S") {
223
+ System(s, "S")
224
+ }`)
225
+ expect(d.boundaries[0]).toMatchObject({ kind: 'System_Boundary' })
226
+ })
227
+
228
+ it.skip('sets parentBoundary on nested elements and child boundaries', () => {
229
+ const d = parse(`C4Context
230
+ System_Boundary(sb, "System") {
231
+ Container(web, "Web App", "Java", "Serves pages")
232
+ }`)
233
+ expect(d.elements.find((e) => e.alias === 'web')).toMatchObject({
234
+ parentBoundary: 'sb',
235
+ })
236
+ })
237
+ })
238
+
239
+ describe('upstream #71: relationships', () => {
240
+ const two = 'System(a, "A", "System A")\n System(b, "B", "System B")'
241
+
242
+ it('parses Rel with from, to, label', () => {
243
+ const d = parse(`C4Context\n ${two}\n Rel(a, b, "Uses")`)
244
+ expect(d.relationships).toHaveLength(1)
245
+ expect(d.relationships[0]).toMatchObject({
246
+ from: 'a',
247
+ to: 'b',
248
+ label: 'Uses',
249
+ })
250
+ })
251
+
252
+ it('parses Rel with technology', () => {
253
+ const d = parse(
254
+ `C4Context\n ${two}\n Rel(a, b, "Makes API calls", "JSON/HTTPS")`,
255
+ )
256
+ expect(d.relationships[0]!.technology).toBe('JSON/HTTPS')
257
+ })
258
+
259
+ it.each(['Rel_D', 'Rel_U', 'Rel_L', 'Rel_R'])(
260
+ 'accepts the %s layout-hint macro as a plain relationship',
261
+ (macro) => {
262
+ const d = parse(`C4Context\n ${two}\n ${macro}(a, b, "Calls")`)
263
+ expect(d.relationships).toHaveLength(1)
264
+ expect(d.relationships[0]).toMatchObject({ from: 'a', to: 'b' })
265
+ },
266
+ )
267
+
268
+ // Known gap: the direction hint is accepted and dropped; the flowchart
269
+ // pipeline lays out by rank, so there is nothing to record it for.
270
+ it.skip('records Rel_U/D/L/R direction hints', () => {
271
+ const d = parse(`C4Context\n ${two}\n Rel_D(a, b, "Uses")`)
272
+ expect(d.relationships[0]).toMatchObject({ direction: 'D' })
273
+ })
274
+
275
+ it('parses Rel_Back keeping the declared endpoints, flagged reversed', () => {
276
+ const d = parse(`C4Context\n ${two}\n Rel_Back(a, b, "Returns")`)
277
+ expect(d.relationships[0]).toMatchObject({
278
+ from: 'a',
279
+ to: 'b',
280
+ reversed: true,
281
+ })
282
+ })
283
+
284
+ it('parses BiRel as one bidirectional relationship', () => {
285
+ const d = parse(`C4Context\n ${two}\n BiRel(a, b, "Exchanges data")`)
286
+ expect(d.relationships).toHaveLength(1)
287
+ expect(d.relationships[0]).toMatchObject({
288
+ from: 'a',
289
+ to: 'b',
290
+ label: 'Exchanges data',
291
+ bidirectional: true,
292
+ })
293
+ })
294
+
295
+ it('parses multiple relationships', () => {
296
+ const d = parse(`C4Context
297
+ Person(user, "User", "End user")
298
+ System(web, "Web", "Frontend")
299
+ System(api, "API", "Backend")
300
+ Rel(user, web, "Visits")
301
+ Rel(web, api, "Calls")
302
+ Rel(api, web, "Returns data")`)
303
+ expect(d.relationships).toHaveLength(3)
304
+ })
305
+ })
306
+
307
+ describe('upstream #71: full diagrams', () => {
308
+ it('parses a complete C4 context diagram', () => {
309
+ const d = parse(`C4Context
310
+ title System Context Diagram
311
+ Person(customer, "Customer", "A customer of the bank")
312
+ System(banking, "Internet Banking System", "Allows customers to manage accounts")
313
+ System_Ext(email, "E-mail System", "Sends emails")
314
+ System_Ext(mainframe, "Mainframe Banking System", "Stores account info")
315
+ Rel(customer, banking, "Views account balances", "HTTPS")
316
+ Rel(banking, email, "Sends emails using", "SMTP")
317
+ Rel(banking, mainframe, "Gets account info from", "XML/HTTPS")`)
318
+ expect(d.variant).toBe('context')
319
+ expect(d.title).toBe('System Context Diagram')
320
+ expect(d.elements).toHaveLength(4)
321
+ expect(d.relationships).toHaveLength(3)
322
+ expect(d.elements.find((e) => e.alias === 'customer')!.kind).toBe('person')
323
+ expect(d.elements.find((e) => e.alias === 'email')!.external).toBe(true)
324
+ })
325
+
326
+ it('parses a C4 container diagram with boundaries', () => {
327
+ const d = parse(`C4Container
328
+ Person(user, "User", "End user")
329
+ System_Boundary(sb, "Internet Banking System") {
330
+ Container(web, "Web Application", "Java/Spring", "Delivers content")
331
+ ContainerDb(db, "Database", "PostgreSQL", "Stores user data")
332
+ }
333
+ Rel(user, web, "Visits", "HTTPS")
334
+ Rel(web, db, "Reads/writes", "JDBC")`)
335
+ expect(d.elements).toHaveLength(3)
336
+ expect(d.boundaries).toHaveLength(1)
337
+ expect(d.boundaries[0]!.elementAliases).toHaveLength(2)
338
+ expect(d.relationships).toHaveLength(2)
339
+ })
340
+ })
@@ -0,0 +1,187 @@
1
+ import { normalizeBrTags, type Statement } from '@zombie-mermaid/core'
2
+ import type {
3
+ ArchitectureDiagram,
4
+ ArchitectureEdge,
5
+ ArchitecturePort,
6
+ } from './types.ts'
7
+
8
+ // ============================================================================
9
+ // Architecture diagram parser
10
+ //
11
+ // Parses Mermaid's `architecture-beta` diagram
12
+ // (https://mermaid.ai/open-source/syntax/architecture.html):
13
+ //
14
+ // architecture-beta
15
+ // group api(cloud)[API]
16
+ // service db(database)[Database] in api
17
+ // service server(server)[Server] in api
18
+ // junction j1 in api
19
+ // db:L -- R:server
20
+ // server{group}:B --> T:client{group}
21
+ //
22
+ // Grammar:
23
+ // group <id>[(icon)][[Title]] [in <parent>]
24
+ // service <id>[(icon)][[Title]] [in <parent>]
25
+ // junction <id> [in <parent>]
26
+ // <id>[{group}]:<L|R|T|B> <--|-->|<-->|--> <L|R|T|B>:<id>[{group}]
27
+ // align row|column <id> <id> ... (accepted, no effect: see to-graph)
28
+ //
29
+ // Unlike Mermaid's own parser, every malformed statement throws with its line
30
+ // number rather than being dropped.
31
+ // ============================================================================
32
+
33
+ const ID = String.raw`[\w-]+`
34
+ const DECL_RE = new RegExp(
35
+ String.raw`^(group|service)\s+(${ID})\s*(?:\(([^)]*)\))?\s*(?:\[([^\]]*)\])?(?:\s+in\s+(${ID}))?$`,
36
+ )
37
+ const JUNCTION_RE = new RegExp(
38
+ String.raw`^junction\s+(${ID})(?:\s+in\s+(${ID}))?$`,
39
+ )
40
+ const EDGE_RE = new RegExp(
41
+ String.raw`^(${ID})(\{group\})?:([LRTB])\s+(<)?--(>)?\s+([LRTB]):(${ID})(\{group\})?$`,
42
+ )
43
+ const ALIGN_RE = new RegExp(
44
+ String.raw`^align\s+(?:row|column)\s+${ID}(?:\s+${ID})+$`,
45
+ )
46
+
47
+ type Kind = 'group' | 'service' | 'junction'
48
+
49
+ function fail(stmt: Statement, msg: string): never {
50
+ throw new Error(
51
+ `Architecture diagram, line ${stmt.line}: ${msg} — "${stmt.text}"`,
52
+ )
53
+ }
54
+
55
+ /** Strip the optional quotes around a custom icon name like `"logos:aws"`. */
56
+ function cleanIcon(raw: string | undefined): string | undefined {
57
+ const icon = raw?.trim().replace(/^"(.*)"$/, '$1')
58
+ return icon || undefined
59
+ }
60
+
61
+ /**
62
+ * Parse a Mermaid architecture diagram.
63
+ * Expects the first statement to be the `architecture-beta` header.
64
+ */
65
+ export function parseArchitecture(lines: Statement[]): ArchitectureDiagram {
66
+ const diagram: ArchitectureDiagram = {
67
+ groups: [],
68
+ services: [],
69
+ junctions: [],
70
+ edges: [],
71
+ }
72
+ const kinds = new Map<string, Kind>()
73
+ // `in` may name a group declared later, so parents and edge endpoints are
74
+ // resolved after the loop.
75
+ const parentRefs: { stmt: Statement; parent: string }[] = []
76
+ const pendingEdges: { stmt: Statement; edge: ArchitectureEdge }[] = []
77
+
78
+ const declare = (
79
+ stmt: Statement,
80
+ id: string,
81
+ kind: Kind,
82
+ parent: string | undefined,
83
+ ): void => {
84
+ if (kinds.has(id)) fail(stmt, `duplicate id "${id}"`)
85
+ if (parent === id) fail(stmt, `"${id}" cannot be inside itself`)
86
+ kinds.set(id, kind)
87
+ if (parent) parentRefs.push({ stmt, parent })
88
+ }
89
+
90
+ for (let i = 1; i < lines.length; i++) {
91
+ const stmt = lines[i]!
92
+ const text = stmt.text
93
+
94
+ const decl = text.match(DECL_RE)
95
+ if (decl) {
96
+ const kind = decl[1] as 'group' | 'service'
97
+ const id = decl[2]!
98
+ const title = decl[4]
99
+ const parent = decl[5]
100
+ declare(stmt, id, kind, parent)
101
+ const entry = {
102
+ id,
103
+ icon: cleanIcon(decl[3]),
104
+ title: title !== undefined ? normalizeBrTags(title.trim()) : id,
105
+ parent,
106
+ }
107
+ if (kind === 'group') diagram.groups.push(entry)
108
+ else diagram.services.push(entry)
109
+ continue
110
+ }
111
+
112
+ const junction = text.match(JUNCTION_RE)
113
+ if (junction) {
114
+ const id = junction[1]!
115
+ const parent = junction[2]
116
+ declare(stmt, id, 'junction', parent)
117
+ diagram.junctions.push({ id, parent })
118
+ continue
119
+ }
120
+
121
+ const edge = text.match(EDGE_RE)
122
+ if (edge) {
123
+ pendingEdges.push({
124
+ stmt,
125
+ edge: {
126
+ source: edge[1]!,
127
+ sourceGroup: edge[2] !== undefined,
128
+ sourcePort: edge[3] as ArchitecturePort,
129
+ arrowStart: edge[4] !== undefined,
130
+ arrowEnd: edge[5] !== undefined,
131
+ targetPort: edge[6] as ArchitecturePort,
132
+ target: edge[7]!,
133
+ targetGroup: edge[8] !== undefined,
134
+ },
135
+ })
136
+ continue
137
+ }
138
+
139
+ // Alignment constraints (Mermaid v11.16+) have no flowchart equivalent.
140
+ if (ALIGN_RE.test(text)) continue
141
+
142
+ fail(stmt, 'unrecognized statement')
143
+ }
144
+
145
+ for (const { stmt, parent } of parentRefs) {
146
+ if (kinds.get(parent) !== 'group') {
147
+ fail(stmt, `"in ${parent}" must name a declared group`)
148
+ }
149
+ }
150
+
151
+ const parentOf = new Map<string, string>()
152
+ for (const g of diagram.groups) if (g.parent) parentOf.set(g.id, g.parent)
153
+ for (const g of diagram.groups) {
154
+ const seen = new Set([g.id])
155
+ for (let p = parentOf.get(g.id); p; p = parentOf.get(p)) {
156
+ if (seen.has(p)) {
157
+ throw new Error(
158
+ `Architecture diagram: group "${g.id}" is nested inside itself`,
159
+ )
160
+ }
161
+ seen.add(p)
162
+ }
163
+ }
164
+
165
+ for (const { stmt, edge } of pendingEdges) {
166
+ const ends: [string, boolean][] = [
167
+ [edge.source, edge.sourceGroup],
168
+ [edge.target, edge.targetGroup],
169
+ ]
170
+ for (const [id, viaGroup] of ends) {
171
+ const kind = kinds.get(id)
172
+ if (!kind) fail(stmt, `unknown id "${id}"`)
173
+ if (kind === 'group') {
174
+ fail(stmt, `edges connect services or junctions, not the group "${id}"`)
175
+ }
176
+ const parent = [...diagram.services, ...diagram.junctions].find(
177
+ (n) => n.id === id,
178
+ )?.parent
179
+ if (viaGroup && !parent) {
180
+ fail(stmt, `"{group}" needs "${id}" to be declared inside a group`)
181
+ }
182
+ }
183
+ diagram.edges.push(edge)
184
+ }
185
+
186
+ return diagram
187
+ }
@@ -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
+ }