@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.
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zombie-mermaid/mermaid-parser",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Parsers for class, ER, sequence, and XY chart diagrams — the per-type parse functions both svg-renderer and ascii-renderer import directly.",
|
|
6
6
|
"repository": {
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
"LICENSE"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@zombie-mermaid/core": "3.
|
|
38
|
+
"@zombie-mermaid/core": "3.2.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
+
import { parseArchitecture } from '../index.ts'
|
|
4
|
+
|
|
5
|
+
const parse = (src: string) => parseArchitecture(splitStatements(src))
|
|
6
|
+
const body = (b: string) => parse(`architecture-beta\n${b}`)
|
|
7
|
+
|
|
8
|
+
describe('parseArchitecture', () => {
|
|
9
|
+
it('accepts both header spellings', () => {
|
|
10
|
+
expect(parse('architecture-beta\nservice a').services).toHaveLength(1)
|
|
11
|
+
expect(parse('architecture\nservice a').services).toHaveLength(1)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('parses a group with icon, title and no parent', () => {
|
|
15
|
+
expect(body('group api(cloud)[API]').groups).toEqual([
|
|
16
|
+
{ id: 'api', icon: 'cloud', title: 'API', parent: undefined },
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('parses nested groups, including a parent declared later', () => {
|
|
21
|
+
const d = body('group inner in outer\ngroup outer')
|
|
22
|
+
expect(d.groups.map((g) => [g.id, g.parent])).toEqual([
|
|
23
|
+
['inner', 'outer'],
|
|
24
|
+
['outer', undefined],
|
|
25
|
+
])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('parses services with and without icon/title/parent', () => {
|
|
29
|
+
const d = body(
|
|
30
|
+
'group g\nservice a(database)[DB] in g\nservice b[Bee]\nservice c(server)\nservice d',
|
|
31
|
+
)
|
|
32
|
+
expect(d.services).toEqual([
|
|
33
|
+
{ id: 'a', icon: 'database', title: 'DB', parent: 'g' },
|
|
34
|
+
{ id: 'b', icon: undefined, title: 'Bee', parent: undefined },
|
|
35
|
+
{ id: 'c', icon: 'server', title: 'c', parent: undefined },
|
|
36
|
+
{ id: 'd', icon: undefined, title: 'd', parent: undefined },
|
|
37
|
+
])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('strips quotes from custom icon names and keeps hyphenated ids', () => {
|
|
41
|
+
const d = body('service my-svc("logos:aws")[AWS]')
|
|
42
|
+
expect(d.services[0]).toMatchObject({ id: 'my-svc', icon: 'logos:aws' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('parses junctions with and without a parent', () => {
|
|
46
|
+
const d = body('group g\njunction j1 in g\njunction j2')
|
|
47
|
+
expect(d.junctions).toEqual([
|
|
48
|
+
{ id: 'j1', parent: 'g' },
|
|
49
|
+
{ id: 'j2', parent: undefined },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ignores align statements', () => {
|
|
54
|
+
const d = body('service a\nservice b\nalign row a b\nalign column a b')
|
|
55
|
+
expect(d.edges).toEqual([])
|
|
56
|
+
expect(d.services).toHaveLength(2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
['--', false, false],
|
|
61
|
+
['-->', false, true],
|
|
62
|
+
['<--', true, false],
|
|
63
|
+
['<-->', true, true],
|
|
64
|
+
])('parses the %s edge operator', (op, start, end) => {
|
|
65
|
+
const [e] = body(`service a\nservice b\na:R ${op} L:b`).edges
|
|
66
|
+
expect(e).toMatchObject({ arrowStart: start, arrowEnd: end })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it.each(['L', 'R', 'T', 'B'] as const)(
|
|
70
|
+
'parses source port %s and target port %s',
|
|
71
|
+
(p) => {
|
|
72
|
+
for (const q of ['L', 'R', 'T', 'B'] as const) {
|
|
73
|
+
const [e] = body(`service a\nservice b\na:${p} -- ${q}:b`).edges
|
|
74
|
+
expect(e).toMatchObject({
|
|
75
|
+
source: 'a',
|
|
76
|
+
sourcePort: p,
|
|
77
|
+
target: 'b',
|
|
78
|
+
targetPort: q,
|
|
79
|
+
sourceGroup: false,
|
|
80
|
+
targetGroup: false,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
it('parses {group} modifiers on either end', () => {
|
|
87
|
+
const d = body(
|
|
88
|
+
'group g\ngroup h\nservice a in g\nservice b in h\na{group}:R -- L:b\na:R -- L:b{group}\na{group}:R -- L:b{group}',
|
|
89
|
+
)
|
|
90
|
+
expect(d.edges.map((e) => [e.sourceGroup, e.targetGroup])).toEqual([
|
|
91
|
+
[true, false],
|
|
92
|
+
[false, true],
|
|
93
|
+
[true, true],
|
|
94
|
+
])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('allows edges to junctions and {group} on a junction', () => {
|
|
98
|
+
const d = body('group g\njunction j in g\nservice a\na:R -- L:j{group}')
|
|
99
|
+
expect(d.edges[0]).toMatchObject({ target: 'j', targetGroup: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('allows an edge to precede the declaration it refers to', () => {
|
|
103
|
+
const d = body('a:R -- L:b\nservice a\nservice b')
|
|
104
|
+
expect(d.edges).toHaveLength(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('splits statements on semicolons and ignores comments', () => {
|
|
108
|
+
const d = parse(
|
|
109
|
+
'architecture-beta\n%% a comment\nservice a; service b\na:R -- L:b',
|
|
110
|
+
)
|
|
111
|
+
expect(d.services.map((s) => s.id)).toEqual(['a', 'b'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('converts <br> in titles to newlines', () => {
|
|
115
|
+
const d = body('service a[Line1<br/>Line2]')
|
|
116
|
+
expect(d.services[0]?.title).toBe('Line1\nLine2')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('errors', () => {
|
|
120
|
+
it.each([
|
|
121
|
+
['service a\nservice a', /line 3.*duplicate id "a"/s],
|
|
122
|
+
['group a\nservice a', /line 3.*duplicate id "a"/s],
|
|
123
|
+
['service a\njunction a', /line 3.*duplicate id "a"/s],
|
|
124
|
+
['service a in nope', /line 2.*must name a declared group/s],
|
|
125
|
+
['service a\nservice b in a', /line 3.*must name a declared group/s],
|
|
126
|
+
['junction j in nope', /line 2.*must name a declared group/s],
|
|
127
|
+
['group g in g', /line 2.*cannot be inside itself/s],
|
|
128
|
+
['group a in b\ngroup b in c\ngroup c in a', /nested inside itself/],
|
|
129
|
+
['service a\na:R -- L:zzz', /line 3.*unknown id "zzz"/s],
|
|
130
|
+
['service a\nzzz:R -- L:a', /line 3.*unknown id "zzz"/s],
|
|
131
|
+
['group g\nservice a\na:R -- L:g', /line 4.*not the group "g"/s],
|
|
132
|
+
['group g\nservice a\ng:R -- L:a', /line 4.*not the group "g"/s],
|
|
133
|
+
['service a\nservice b\na{group}:R -- L:b', /line 4.*needs "a" to be/s],
|
|
134
|
+
['service a\nservice b\na:R -- L:b{group}', /line 4.*needs "b" to be/s],
|
|
135
|
+
['service a\nbogus line', /line 3.*unrecognized statement/s],
|
|
136
|
+
['service a\nservice b\na:X -- L:b', /line 4.*unrecognized/s],
|
|
137
|
+
['service a\nservice b\na:R - L:b', /line 4.*unrecognized/s],
|
|
138
|
+
['service a\nservice b\na R -- L:b', /line 4.*unrecognized/s],
|
|
139
|
+
['align row a', /line 2.*unrecognized/s],
|
|
140
|
+
['group', /line 2.*unrecognized/s],
|
|
141
|
+
])('rejects %j', (b, message) => {
|
|
142
|
+
expect(() => body(b)).toThrow(message)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
+
import { parseArchitecture, architectureToGraph } from '../index.ts'
|
|
4
|
+
|
|
5
|
+
const lower = (b: string) =>
|
|
6
|
+
architectureToGraph(
|
|
7
|
+
parseArchitecture(splitStatements(`architecture-beta\n${b}`)),
|
|
8
|
+
)
|
|
9
|
+
const two = 'service a\nservice b\n'
|
|
10
|
+
|
|
11
|
+
describe('architectureToGraph', () => {
|
|
12
|
+
it('maps icons to shapes and unknown icons to rectangles', () => {
|
|
13
|
+
const g = lower(
|
|
14
|
+
'service a(database)\nservice b(disk)\nservice c(cloud)\nservice d(internet)\nservice e(server)\nservice f("logos:aws")\nservice g',
|
|
15
|
+
)
|
|
16
|
+
const shapes = [...g.nodes.values()].map((n) => n.shape)
|
|
17
|
+
expect(shapes).toEqual([
|
|
18
|
+
'cylinder',
|
|
19
|
+
'cylinder',
|
|
20
|
+
'stadium',
|
|
21
|
+
'circle',
|
|
22
|
+
'rectangle',
|
|
23
|
+
'rectangle',
|
|
24
|
+
'rectangle',
|
|
25
|
+
])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('nests groups three deep and places nodes in their own group', () => {
|
|
29
|
+
const g = lower(
|
|
30
|
+
'group a\ngroup b in a\ngroup c in b\nservice s in c\nservice t in a\njunction j in b',
|
|
31
|
+
)
|
|
32
|
+
const a = g.subgraphs[0]!
|
|
33
|
+
expect(g.subgraphs).toHaveLength(1)
|
|
34
|
+
expect(a.nodeIds).toEqual(['t'])
|
|
35
|
+
expect(a.children[0]?.id).toBe('b')
|
|
36
|
+
expect(a.children[0]?.nodeIds).toEqual(['j'])
|
|
37
|
+
expect(a.children[0]?.children[0]).toMatchObject({
|
|
38
|
+
id: 'c',
|
|
39
|
+
nodeIds: ['s'],
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps sibling top-level groups as separate roots', () => {
|
|
44
|
+
const g = lower('group a\ngroup b\nservice s in a\nservice t in b')
|
|
45
|
+
expect(g.subgraphs.map((s) => s.id)).toEqual(['a', 'b'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('draws a junction as an unlabelled filled circle', () => {
|
|
49
|
+
expect(lower('junction j').nodes.get('j')).toEqual({
|
|
50
|
+
id: 'j',
|
|
51
|
+
label: '',
|
|
52
|
+
shape: 'filled-circle',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('{group} edges', () => {
|
|
57
|
+
it('attaches the source end to the enclosing group', () => {
|
|
58
|
+
const [e] = lower(
|
|
59
|
+
'group g\nservice a in g\nservice b\na{group}:R -- L:b',
|
|
60
|
+
).edges
|
|
61
|
+
expect(e).toMatchObject({ source: 'g', target: 'b' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('attaches the target end to the enclosing group', () => {
|
|
65
|
+
const [e] = lower(
|
|
66
|
+
'group g\nservice a in g\nservice b\nb:R -- L:a{group}',
|
|
67
|
+
).edges
|
|
68
|
+
expect(e).toMatchObject({ source: 'b', target: 'g' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('attaches both ends, and uses the immediate (innermost) group', () => {
|
|
72
|
+
const [e] = lower(
|
|
73
|
+
'group o\ngroup i in o\nservice a in i\njunction j in o\na{group}:R -- L:j{group}',
|
|
74
|
+
).edges
|
|
75
|
+
expect(e).toMatchObject({ source: 'i', target: 'o' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('follows an edge reversal', () => {
|
|
79
|
+
const [e] = lower(
|
|
80
|
+
'group g\nservice a in g\nservice b\na{group}:L -- R:b',
|
|
81
|
+
).edges
|
|
82
|
+
expect(e).toMatchObject({ source: 'b', target: 'g' })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('flow direction', () => {
|
|
87
|
+
it.each([
|
|
88
|
+
['a:R -- L:b', 'LR'],
|
|
89
|
+
['a:L -- R:b', 'LR'],
|
|
90
|
+
['a:B -- T:b', 'TB'],
|
|
91
|
+
['a:T -- B:b', 'TB'],
|
|
92
|
+
// Tie goes to LR.
|
|
93
|
+
['a:R -- T:b', 'LR'],
|
|
94
|
+
['a:R -- L:b\na:R -- B:b\na:T -- B:b', 'LR'],
|
|
95
|
+
['a:B -- T:b\na:R -- B:b', 'TB'],
|
|
96
|
+
])('%j -> %s', (edges, dir) => {
|
|
97
|
+
expect(lower(two + edges).direction).toBe(dir)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('defaults to LR with no edges', () => {
|
|
101
|
+
expect(lower('service a').direction).toBe('LR')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('edge reversal', () => {
|
|
106
|
+
it.each([
|
|
107
|
+
// [edge, source, target, arrowStart, arrowEnd]
|
|
108
|
+
['a:R -- L:b', 'a', 'b'],
|
|
109
|
+
['a:L -- R:b', 'b', 'a'],
|
|
110
|
+
['a:R -- R:b', 'a', 'b'],
|
|
111
|
+
// A back-side target port wins: the edge is not reversed.
|
|
112
|
+
['a:L -- L:b', 'a', 'b'],
|
|
113
|
+
])('LR: %s runs %s -> %s', (edge, source, target) => {
|
|
114
|
+
const [e] = lower(two + edge).edges
|
|
115
|
+
expect([e?.source, e?.target]).toEqual([source, target])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it.each([
|
|
119
|
+
['a:B -- T:b', 'a', 'b'],
|
|
120
|
+
['a:T -- B:b', 'b', 'a'],
|
|
121
|
+
])('TB: %s runs %s -> %s', (edge, source, target) => {
|
|
122
|
+
const [e] = lower(two + edge).edges
|
|
123
|
+
expect([e?.source, e?.target]).toEqual([source, target])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not reverse an edge whose ports are on the other axis', () => {
|
|
127
|
+
// Direction is TB (two vertical edges outweigh one horizontal), and
|
|
128
|
+
// L/R ports say nothing about top/bottom order.
|
|
129
|
+
const g = lower(two + 'a:L -- R:b\na:B -- T:b\na:B -- T:b')
|
|
130
|
+
expect(g.direction).toBe('TB')
|
|
131
|
+
expect([g.edges[0]?.source, g.edges[0]?.target]).toEqual(['a', 'b'])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it.each([
|
|
135
|
+
['a:R -- L:b', false, false],
|
|
136
|
+
['a:R --> L:b', false, true],
|
|
137
|
+
['a:R <-- L:b', true, false],
|
|
138
|
+
['a:R <--> L:b', true, true],
|
|
139
|
+
])('keeps arrowheads on %s', (edge, start, end) => {
|
|
140
|
+
expect(lower(two + edge).edges[0]).toMatchObject({
|
|
141
|
+
hasArrowStart: start,
|
|
142
|
+
hasArrowEnd: end,
|
|
143
|
+
style: 'solid',
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it.each([
|
|
148
|
+
// Reversed edges swap which end carries the arrowhead.
|
|
149
|
+
['a:L --> R:b', true, false],
|
|
150
|
+
['a:L <-- R:b', false, true],
|
|
151
|
+
['a:L <--> R:b', true, true],
|
|
152
|
+
['a:L -- R:b', false, false],
|
|
153
|
+
])('swaps arrowheads when reversing %s', (edge, start, end) => {
|
|
154
|
+
expect(lower(two + edge).edges[0]).toMatchObject({
|
|
155
|
+
source: 'b',
|
|
156
|
+
target: 'a',
|
|
157
|
+
hasArrowStart: start,
|
|
158
|
+
hasArrowEnd: end,
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('carries no flowchart styling', () => {
|
|
164
|
+
const g = lower(two + 'a:R -- L:b')
|
|
165
|
+
expect(g.classDefs.size).toBe(0)
|
|
166
|
+
expect(g.nodeStyles.size).toBe(0)
|
|
167
|
+
expect(g.linkStyles.size).toBe(0)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { splitStatements } from '@zombie-mermaid/core'
|
|
3
|
+
import { parseC4Diagram } from '../index.ts'
|
|
4
|
+
|
|
5
|
+
const parse = (src: string) => parseC4Diagram(splitStatements(src))
|
|
6
|
+
|
|
7
|
+
describe('parseC4Diagram', () => {
|
|
8
|
+
it('parses every variant header', () => {
|
|
9
|
+
const cases = [
|
|
10
|
+
['C4Context', 'context'],
|
|
11
|
+
['C4Container', 'container'],
|
|
12
|
+
['C4Component', 'component'],
|
|
13
|
+
['C4Dynamic', 'dynamic'],
|
|
14
|
+
['C4Deployment', 'deployment'],
|
|
15
|
+
] as const
|
|
16
|
+
for (const [header, variant] of cases) {
|
|
17
|
+
expect(parse(header).variant).toBe(variant)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('rejects a non-C4 header', () => {
|
|
22
|
+
expect(() => parse('flowchart TD')).toThrow(
|
|
23
|
+
/expected a header of C4Context/,
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('parses persons, systems and external variants', () => {
|
|
28
|
+
const d = parse(`C4Context
|
|
29
|
+
title Bank
|
|
30
|
+
Person(c, "Customer", "A user")
|
|
31
|
+
Person_Ext(x, "Auditor")
|
|
32
|
+
System(s, "Banking", "Core")
|
|
33
|
+
SystemDb_Ext(db, "Ledger", "Books")
|
|
34
|
+
SystemQueue(q, "Bus")`)
|
|
35
|
+
expect(d.title).toBe('Bank')
|
|
36
|
+
expect(
|
|
37
|
+
d.elements.map((e) => [e.alias, e.kind, e.shape, e.external]),
|
|
38
|
+
).toEqual([
|
|
39
|
+
['c', 'person', 'default', false],
|
|
40
|
+
['x', 'person', 'default', true],
|
|
41
|
+
['s', 'system', 'default', false],
|
|
42
|
+
['db', 'system', 'db', true],
|
|
43
|
+
['q', 'system', 'queue', false],
|
|
44
|
+
])
|
|
45
|
+
expect(d.elements[0]).toMatchObject({
|
|
46
|
+
label: 'Customer',
|
|
47
|
+
description: 'A user',
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reads technology before description for containers and components', () => {
|
|
52
|
+
const d = parse(`C4Container
|
|
53
|
+
Container(api, "API", "Node.js", "Serves JSON")
|
|
54
|
+
ComponentDb(store, "Store", "Postgres")`)
|
|
55
|
+
expect(d.elements[0]).toMatchObject({
|
|
56
|
+
technology: 'Node.js',
|
|
57
|
+
description: 'Serves JSON',
|
|
58
|
+
})
|
|
59
|
+
expect(d.elements[1]).toMatchObject({
|
|
60
|
+
kind: 'component',
|
|
61
|
+
shape: 'db',
|
|
62
|
+
technology: 'Postgres',
|
|
63
|
+
})
|
|
64
|
+
expect(d.elements[1]!.description).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('keeps commas and parentheses inside quoted arguments', () => {
|
|
68
|
+
const d = parse('C4Context\nSystem(s, "A, B (C)", "one, two")')
|
|
69
|
+
expect(d.elements[0]).toMatchObject({
|
|
70
|
+
label: 'A, B (C)',
|
|
71
|
+
description: 'one, two',
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('drops $named arguments', () => {
|
|
76
|
+
const d = parse(
|
|
77
|
+
'C4Context\nSystem(s, "S", "d", $tags="t", $link="https://x.test")',
|
|
78
|
+
)
|
|
79
|
+
expect(d.elements[0]).toMatchObject({ label: 'S', description: 'd' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('nests boundaries and assigns elements to the innermost one', () => {
|
|
83
|
+
const d = parse(`C4Container
|
|
84
|
+
Enterprise_Boundary(e, "Corp") {
|
|
85
|
+
System_Boundary(sb, "Sys") {
|
|
86
|
+
Container(a, "A", "Go")
|
|
87
|
+
}
|
|
88
|
+
Person(p, "P")
|
|
89
|
+
}
|
|
90
|
+
Container(out, "Out", "Go")`)
|
|
91
|
+
expect(d.boundaries).toHaveLength(1)
|
|
92
|
+
const e = d.boundaries[0]!
|
|
93
|
+
expect(e.elementAliases).toEqual(['p'])
|
|
94
|
+
expect(e.children[0]).toMatchObject({ alias: 'sb', elementAliases: ['a'] })
|
|
95
|
+
expect(d.elements.map((x) => x.alias)).toEqual(['a', 'p', 'out'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepts a brace on its own line and a boundary type', () => {
|
|
99
|
+
const d = parse(`C4Context
|
|
100
|
+
Boundary(b, "B", "region")
|
|
101
|
+
{
|
|
102
|
+
System(s, "S")
|
|
103
|
+
}`)
|
|
104
|
+
expect(d.boundaries[0]).toMatchObject({
|
|
105
|
+
alias: 'b',
|
|
106
|
+
type: 'region',
|
|
107
|
+
elementAliases: ['s'],
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('parses deployment nodes as boundaries with type and description', () => {
|
|
112
|
+
const d = parse(`C4Deployment
|
|
113
|
+
Deployment_Node(n, "Server", "Ubuntu", "Prod box") {
|
|
114
|
+
Container(app, "App", "Java")
|
|
115
|
+
}`)
|
|
116
|
+
expect(d.boundaries[0]).toMatchObject({
|
|
117
|
+
type: 'Ubuntu',
|
|
118
|
+
description: 'Prod box',
|
|
119
|
+
elementAliases: ['app'],
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('parses relationships, BiRel, directional variants and RelIndex', () => {
|
|
124
|
+
const d = parse(`C4Dynamic
|
|
125
|
+
System(a, "A")
|
|
126
|
+
System(b, "B")
|
|
127
|
+
Rel(a, b, "Calls", "HTTPS")
|
|
128
|
+
BiRel(a, b, "Syncs")
|
|
129
|
+
Rel_Back(b, a, "Replies")
|
|
130
|
+
RelIndex(1, a, b, "First")`)
|
|
131
|
+
expect(d.relationships).toEqual([
|
|
132
|
+
{
|
|
133
|
+
from: 'a',
|
|
134
|
+
to: 'b',
|
|
135
|
+
label: 'Calls',
|
|
136
|
+
technology: 'HTTPS',
|
|
137
|
+
bidirectional: false,
|
|
138
|
+
index: '1',
|
|
139
|
+
},
|
|
140
|
+
{ from: 'a', to: 'b', label: 'Syncs', bidirectional: true, index: '2' },
|
|
141
|
+
{
|
|
142
|
+
from: 'b',
|
|
143
|
+
to: 'a',
|
|
144
|
+
label: 'Replies',
|
|
145
|
+
bidirectional: false,
|
|
146
|
+
reversed: true,
|
|
147
|
+
index: '3',
|
|
148
|
+
},
|
|
149
|
+
{ from: 'a', to: 'b', label: 'First', bidirectional: false, index: '4' },
|
|
150
|
+
])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('numbers relationships by position in C4Dynamic only, ignoring the RelIndex argument (as Mermaid does)', () => {
|
|
154
|
+
const src = (head: string) => `${head}
|
|
155
|
+
System(a, "A")
|
|
156
|
+
System(b, "B")
|
|
157
|
+
RelIndex(9, a, b, "First")
|
|
158
|
+
Rel(b, a, "Second")`
|
|
159
|
+
const dyn = parse(src('C4Dynamic')).relationships.map((r) => r.index)
|
|
160
|
+
expect(dyn).toEqual(['1', '2'])
|
|
161
|
+
const ctx = parse(src('C4Context')).relationships.map((r) => r.index)
|
|
162
|
+
expect(ctx).toEqual([undefined, undefined])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("gives every boundary Mermaid's type text", () => {
|
|
166
|
+
const d = parse(`C4Deployment
|
|
167
|
+
Enterprise_Boundary(e, "E") {
|
|
168
|
+
System_Boundary(s, "S") {
|
|
169
|
+
Container_Boundary(c, "C") {
|
|
170
|
+
Boundary(g, "G")
|
|
171
|
+
Boundary(t, "T", "region")
|
|
172
|
+
Node(n, "N")
|
|
173
|
+
Deployment_Node(m, "M", "Ubuntu")
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}`)
|
|
177
|
+
const types = new Map<string, string | undefined>()
|
|
178
|
+
const walk = (bs: typeof d.boundaries): void =>
|
|
179
|
+
bs.forEach((b) => {
|
|
180
|
+
types.set(b.alias, b.type)
|
|
181
|
+
walk(b.children)
|
|
182
|
+
})
|
|
183
|
+
walk(d.boundaries)
|
|
184
|
+
expect(Object.fromEntries(types)).toEqual({
|
|
185
|
+
e: 'ENTERPRISE',
|
|
186
|
+
s: 'SYSTEM',
|
|
187
|
+
c: 'CONTAINER',
|
|
188
|
+
g: 'system',
|
|
189
|
+
t: 'region',
|
|
190
|
+
n: 'node',
|
|
191
|
+
m: 'Ubuntu',
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('records Rel_U/D/L/R placement hints and leaves the rest unhinted', () => {
|
|
196
|
+
const d = parse(`C4Context
|
|
197
|
+
System(a, "A")
|
|
198
|
+
System(b, "B")
|
|
199
|
+
Rel_U(a, b, "1")
|
|
200
|
+
Rel_Up(a, b, "2")
|
|
201
|
+
Rel_D(a, b, "3")
|
|
202
|
+
Rel_Down(a, b, "4")
|
|
203
|
+
Rel_L(a, b, "5")
|
|
204
|
+
Rel_Left(a, b, "6")
|
|
205
|
+
Rel_R(a, b, "7")
|
|
206
|
+
Rel_Right(a, b, "8")
|
|
207
|
+
Rel(a, b, "9")
|
|
208
|
+
Rel_Back(a, b, "10")`)
|
|
209
|
+
expect(d.relationships.map((r) => r.layout)).toEqual([
|
|
210
|
+
'up',
|
|
211
|
+
'up',
|
|
212
|
+
'down',
|
|
213
|
+
'down',
|
|
214
|
+
'left',
|
|
215
|
+
'left',
|
|
216
|
+
'right',
|
|
217
|
+
'right',
|
|
218
|
+
undefined,
|
|
219
|
+
undefined,
|
|
220
|
+
])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('silently ignores style and layout macros', () => {
|
|
224
|
+
const d = parse(`C4Context
|
|
225
|
+
System(a, "A")
|
|
226
|
+
UpdateElementStyle(a, $bgColor="red")
|
|
227
|
+
UpdateLayoutConfig($c4ShapeInRow="2")
|
|
228
|
+
SHOW_LEGEND()`)
|
|
229
|
+
expect(d.elements).toHaveLength(1)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('errors', () => {
|
|
233
|
+
it('reports duplicate aliases with a line number', () => {
|
|
234
|
+
expect(() => parse('C4Context\nSystem(a, "A")\nSystem(a, "B")')).toThrow(
|
|
235
|
+
/line 3: duplicate alias "a"/,
|
|
236
|
+
)
|
|
237
|
+
})
|
|
238
|
+
it('reports unknown macros', () => {
|
|
239
|
+
expect(() => parse('C4Context\nWidget(a, "A")')).toThrow(
|
|
240
|
+
/unknown C4 macro "Widget"/,
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
it('reports unrecognized statements', () => {
|
|
244
|
+
expect(() => parse('C4Context\nnonsense here')).toThrow(
|
|
245
|
+
/unrecognized statement/,
|
|
246
|
+
)
|
|
247
|
+
})
|
|
248
|
+
it('reports relationships to undeclared aliases', () => {
|
|
249
|
+
expect(() =>
|
|
250
|
+
parse('C4Context\nSystem(a, "A")\nRel(a, ghost, "x")'),
|
|
251
|
+
).toThrow(/undeclared alias "ghost"/)
|
|
252
|
+
})
|
|
253
|
+
it('reports unbalanced braces', () => {
|
|
254
|
+
expect(() =>
|
|
255
|
+
parse('C4Context\nBoundary(b, "B") {\nSystem(a, "A")'),
|
|
256
|
+
).toThrow(/missing its closing/)
|
|
257
|
+
expect(() => parse('C4Context\n}')).toThrow(/unmatched "}"/)
|
|
258
|
+
})
|
|
259
|
+
it('reports unbalanced quotes', () => {
|
|
260
|
+
expect(() => parse('C4Context\nSystem(a, "A)')).toThrow(
|
|
261
|
+
/unbalanced quotes/,
|
|
262
|
+
)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
})
|