@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.
- package/dist/index.cjs +3 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +241 -1
- package/dist/index.d.ts +241 -1
- package/dist/index.js +466 -144
- 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/__tests__/xychart-colors.test.ts +34 -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/class/parser.ts +8 -13
- package/src/class/types.ts +13 -0
- package/src/index.ts +8 -0
- package/src/sequence/types.ts +2 -0
- package/src/xychart/colors.ts +10 -4
|
@@ -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
|
+
}
|
package/src/c4/format.ts
ADDED
|
@@ -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
|
+
}
|
package/src/c4/parser.ts
ADDED
|
@@ -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
|
+
}
|