@zombie-mermaid/mermaid-parser 3.0.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.
- package/dist/index.cjs +3 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +225 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +462 -138
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/architecture-parser.test.ts +145 -0
- package/src/__tests__/architecture-to-graph.test.ts +169 -0
- package/src/__tests__/c4-parser.test.ts +265 -0
- package/src/__tests__/c4-upstream-parser.test.ts +340 -0
- package/src/architecture/parser.ts +187 -0
- package/src/architecture/to-graph.ts +141 -0
- package/src/architecture/types.ts +49 -0
- package/src/c4/format.ts +120 -0
- package/src/c4/parser.ts +271 -0
- package/src/c4/types.ts +136 -0
- package/src/index.ts +8 -0
|
@@ -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
|
+
}
|