dsh-model-everything 0.4.0 → 0.5.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/README.en-US.md +63 -19
- package/README.md +373 -345
- package/cordis.patch.yml +9 -0
- package/diagram/package.json +5 -0
- package/lib/client.js +1273 -0
- package/lib/diagram-diff-tool.js +90 -0
- package/lib/diagram-render-tool.js +102 -0
- package/lib/diagram-review-tool.js +99 -0
- package/lib/diagram.js +43 -0
- package/lib/gate.js +14 -12
- package/lib/presentation.js +10 -0
- package/lib/render/budget.js +99 -0
- package/lib/render/collapse.js +163 -0
- package/lib/render/diff.js +138 -0
- package/lib/render/from-mermaid.js +211 -0
- package/lib/render/from-plantuml.js +158 -0
- package/lib/render/index.js +42 -0
- package/lib/render/layout.js +52 -0
- package/lib/render/model.js +17 -0
- package/lib/render/paint.js +194 -0
- package/lib/render/palette.js +127 -0
- package/lib/render/place.js +210 -0
- package/lib/render/svg.js +107 -0
- package/lib/render/viewmodel.js +126 -0
- package/lib/render/write.js +14 -0
- package/lib/scan.js +248 -8
- package/lib/structure-tool.js +2 -2
- package/lib/types/client.d.ts +83 -0
- package/lib/types/diagram-diff-tool.d.ts +8 -0
- package/lib/types/diagram-render-tool.d.ts +8 -0
- package/lib/types/diagram-review-tool.d.ts +8 -0
- package/lib/types/diagram.d.ts +34 -0
- package/lib/types/presentation.d.ts +7 -0
- package/lib/types/render/budget.d.ts +55 -0
- package/lib/types/render/collapse.d.ts +23 -0
- package/lib/types/render/diff.d.ts +44 -0
- package/lib/types/render/from-mermaid.d.ts +27 -0
- package/lib/types/render/from-plantuml.d.ts +27 -0
- package/lib/types/render/index.d.ts +38 -0
- package/lib/types/render/layout.d.ts +22 -0
- package/lib/types/render/model.d.ts +56 -0
- package/lib/types/render/paint.d.ts +94 -0
- package/lib/types/render/palette.d.ts +41 -0
- package/lib/types/render/place.d.ts +39 -0
- package/lib/types/render/svg.d.ts +19 -0
- package/lib/types/render/viewmodel.d.ts +20 -0
- package/lib/types/render/write.d.ts +14 -0
- package/lib/types/scan.d.ts +10 -2
- package/lib/types/structure.d.ts +1 -0
- package/package.json +148 -131
- package/skills/model-everything/SKILL.md +454 -454
- package/skills/model-everything/references/architecture-domains.md +137 -0
- package/skills/model-everything/references/architecture-views.md +119 -0
- package/skills/model-everything-diagram/SKILL.md +82 -0
- package/src/client.ts +307 -0
- package/src/diagram-diff-tool.ts +94 -0
- package/src/diagram-render-tool.ts +108 -0
- package/src/diagram-review-tool.ts +109 -0
- package/src/diagram.ts +61 -0
- package/src/gate.ts +14 -12
- package/src/presentation.ts +12 -0
- package/src/render/budget.ts +135 -0
- package/src/render/collapse.ts +155 -0
- package/src/render/diff.ts +162 -0
- package/src/render/from-mermaid.ts +236 -0
- package/src/render/from-plantuml.ts +178 -0
- package/src/render/index.ts +73 -0
- package/src/render/layout.ts +62 -0
- package/src/render/model.ts +67 -0
- package/src/render/paint.ts +290 -0
- package/src/render/palette.ts +164 -0
- package/src/render/place.ts +234 -0
- package/src/render/svg.ts +127 -0
- package/src/render/viewmodel.ts +136 -0
- package/src/render/write.ts +17 -0
- package/src/scan.ts +807 -564
- package/src/structure-tool.ts +2 -2
- package/src/structure.ts +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapsing a group: the drawing with one boundary folded into a single box.
|
|
3
|
+
*
|
|
4
|
+
* A fold is a transform on the model, not a mode of the painter. That keeps the painter's rules unchanged -
|
|
5
|
+
* it still draws boxes and edges it is given - and it makes the behaviour testable without a browser: feed a
|
|
6
|
+
* diagram in, assert what came out.
|
|
7
|
+
*
|
|
8
|
+
* Folding is what makes a hierarchy usable: a reader who has understood a package wants it out of the way, and
|
|
9
|
+
* an architecture diagram that cannot be folded is read at one zoom level forever.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Diagram, DiagramEdge, DiagramGroup, DiagramNode } from './model.js'
|
|
13
|
+
|
|
14
|
+
/** How many nodes a group holds, counting the whole subtree - what the folded box offers to unfold. */
|
|
15
|
+
function subtreeSize(diagram: Diagram, groupId: string, seen: Set<string>): number {
|
|
16
|
+
if (seen.has(groupId)) return 0
|
|
17
|
+
seen.add(groupId)
|
|
18
|
+
const groups = diagram.groups ?? []
|
|
19
|
+
const group = groups.find((entry) => entry.id === groupId)
|
|
20
|
+
if (group === undefined) return 0
|
|
21
|
+
const children = groups.filter((entry) => entry.parent === groupId)
|
|
22
|
+
return group.members.length + children.reduce((total, child) => total + subtreeSize(diagram, child.id, seen), 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The group that directly holds a node, when one does. */
|
|
26
|
+
function ownerOf(diagram: Diagram, nodeId: string): string | undefined {
|
|
27
|
+
return (diagram.groups ?? []).find((group) => group.members.includes(nodeId))?.id
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fold the named groups.
|
|
32
|
+
*
|
|
33
|
+
* A folded group becomes a node carrying its name and how many nodes it holds; its members - and every group
|
|
34
|
+
* inside it - leave the drawing. An edge that pointed at something inside the fold is re-aimed at the fold's
|
|
35
|
+
* box, and an edge between two members of the same fold disappears: both ends are now the same box, and a
|
|
36
|
+
* self-loop would be a claim the source never made.
|
|
37
|
+
*
|
|
38
|
+
* Ids nobody declared are ignored, and folding a group whose contents are already gone changes nothing, so the
|
|
39
|
+
* function is total on any diagram and any set of names.
|
|
40
|
+
*/
|
|
41
|
+
export function collapse(diagram: Diagram, folded: Iterable<string>): Diagram {
|
|
42
|
+
const asked = new Set(folded)
|
|
43
|
+
const groups: DiagramGroup[] = diagram.groups ?? []
|
|
44
|
+
if (asked.size === 0 || groups.length === 0) return diagram
|
|
45
|
+
|
|
46
|
+
// Only groups that exist, and that still have contents, can be folded.
|
|
47
|
+
const byId = new Map(groups.map((group) => [group.id, group] as const))
|
|
48
|
+
const targets = new Set<string>()
|
|
49
|
+
for (const id of asked) {
|
|
50
|
+
const group = byId.get(id)
|
|
51
|
+
if (group !== undefined && subtreeSize(diagram, id, new Set()) > 0) targets.add(id)
|
|
52
|
+
}
|
|
53
|
+
if (targets.size === 0) return diagram
|
|
54
|
+
|
|
55
|
+
// A fold inside another fold is served by the outer one: the inner box would not be drawn anyway.
|
|
56
|
+
for (const id of [...targets]) {
|
|
57
|
+
let cursor = byId.get(id)?.parent
|
|
58
|
+
const seen = new Set<string>()
|
|
59
|
+
while (cursor !== undefined && !seen.has(cursor)) {
|
|
60
|
+
seen.add(cursor)
|
|
61
|
+
if (targets.has(cursor)) {
|
|
62
|
+
targets.delete(id)
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
cursor = byId.get(cursor)?.parent
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const removedNodes = new Set<string>()
|
|
70
|
+
const removedGroups = new Set<string>()
|
|
71
|
+
const descend = (groupId: string, seen: Set<string>): void => {
|
|
72
|
+
if (seen.has(groupId)) return
|
|
73
|
+
seen.add(groupId)
|
|
74
|
+
removedGroups.add(groupId)
|
|
75
|
+
const group = byId.get(groupId)
|
|
76
|
+
if (group === undefined) return
|
|
77
|
+
for (const member of group.members) removedNodes.add(member)
|
|
78
|
+
for (const child of groups.filter((entry) => entry.parent === groupId)) descend(child.id, seen)
|
|
79
|
+
}
|
|
80
|
+
for (const id of targets) descend(id, new Set())
|
|
81
|
+
|
|
82
|
+
/** The innermost fold that swallowed this id, if any. */
|
|
83
|
+
const foldOf = (id: string): string | undefined => {
|
|
84
|
+
for (const target of targets) {
|
|
85
|
+
const inSubtree = (groupId: string, seen: Set<string>): boolean => {
|
|
86
|
+
if (seen.has(groupId)) return false
|
|
87
|
+
seen.add(groupId)
|
|
88
|
+
if (groupId === target) return true
|
|
89
|
+
return groups.filter((entry) => entry.parent === groupId).some((child) => inSubtree(child.id, seen))
|
|
90
|
+
}
|
|
91
|
+
const group = byId.get(target) as DiagramGroup
|
|
92
|
+
if (group.members.includes(id) || inSubtree(ownerOf(diagram, id) ?? '', new Set())) return target
|
|
93
|
+
}
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The box that replaces a fold takes the place of its first member, so the drawing keeps the order the source
|
|
98
|
+
// gave it: a folded package belongs where the package was, not at the end of the page.
|
|
99
|
+
const nodes: DiagramNode[] = []
|
|
100
|
+
const emitted = new Set<string>()
|
|
101
|
+
for (const node of diagram.nodes) {
|
|
102
|
+
if (!removedNodes.has(node.id)) {
|
|
103
|
+
nodes.push(node)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const fold = foldOf(node.id)
|
|
107
|
+
if (fold === undefined || emitted.has(fold)) continue
|
|
108
|
+
emitted.add(fold)
|
|
109
|
+
const group = byId.get(fold) as DiagramGroup
|
|
110
|
+
nodes.push({ id: fold, label: group.label + ' (' + String(subtreeSize(diagram, fold, new Set())) + ')', role: 'normal' })
|
|
111
|
+
}
|
|
112
|
+
// A fold whose members are not in `nodes` at all still deserves a box rather than vanishing.
|
|
113
|
+
for (const id of targets) {
|
|
114
|
+
if (emitted.has(id)) continue
|
|
115
|
+
const group = byId.get(id) as DiagramGroup
|
|
116
|
+
nodes.push({ id, label: group.label + ' (' + String(subtreeSize(diagram, id, new Set())) + ')', role: 'normal' })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const edges: DiagramEdge[] = []
|
|
120
|
+
const seenEdges = new Set<string>()
|
|
121
|
+
for (const edge of diagram.edges) {
|
|
122
|
+
const from = removedNodes.has(edge.from) ? foldOf(edge.from) : edge.from
|
|
123
|
+
const to = removedNodes.has(edge.to) ? foldOf(edge.to) : edge.to
|
|
124
|
+
if (from === undefined || to === undefined) continue
|
|
125
|
+
if (from === to) continue
|
|
126
|
+
const key = from + '>' + to + ':' + edge.label
|
|
127
|
+
if (seenEdges.has(key)) continue
|
|
128
|
+
seenEdges.add(key)
|
|
129
|
+
edges.push({ ...edge, from, to })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A folded box stands where its group stood, so it belongs to that group's parent: without this the box that
|
|
133
|
+
// replaced an inner boundary would be drawn outside the outer one, which reads as "it left the package".
|
|
134
|
+
const adopted = new Map<string, string[]>()
|
|
135
|
+
for (const id of targets) {
|
|
136
|
+
const parent = byId.get(id)?.parent
|
|
137
|
+
if (parent === undefined || targets.has(parent) || removedGroups.has(parent)) continue
|
|
138
|
+
adopted.set(parent, [...(adopted.get(parent) ?? []), id])
|
|
139
|
+
}
|
|
140
|
+
const remaining = groups
|
|
141
|
+
.filter((group) => !removedGroups.has(group.id) && targets.has(group.id) === false)
|
|
142
|
+
.map((group) => {
|
|
143
|
+
const extra = adopted.get(group.id)
|
|
144
|
+
if (extra === undefined) return group
|
|
145
|
+
return { ...group, members: [...group.members.filter((member) => !removedNodes.has(member)), ...extra] }
|
|
146
|
+
})
|
|
147
|
+
const caption = diagram.caption + (targets.size === 1 ? ', 1 group folded' : ', ' + String(targets.size) + ' groups folded')
|
|
148
|
+
return {
|
|
149
|
+
...diagram,
|
|
150
|
+
nodes,
|
|
151
|
+
edges,
|
|
152
|
+
...(remaining.length === 0 ? { groups: undefined } : { groups: remaining }),
|
|
153
|
+
caption,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural diff between two drawings.
|
|
3
|
+
*
|
|
4
|
+
* A text diff of two diagrams tells a reader that a line moved; it does not tell them that `Cache` left the
|
|
5
|
+
* `Platform` package, or that `Core` gained a nested boundary. Those are the changes a design review is about,
|
|
6
|
+
* and they are decided by comparing the containment tree, not the source.
|
|
7
|
+
*
|
|
8
|
+
* The diff judges movement, not merit: a group that moved, a member that changed owner, a boundary added or
|
|
9
|
+
* removed. Whether the new arrangement is better is the reader's call, so the verdict only says whether anything
|
|
10
|
+
* changed and whether the comparison could run at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Diagram, DiagramGroup } from './model.js'
|
|
14
|
+
|
|
15
|
+
export type ChangeKind = 'added' | 'removed' | 'renamed' | 'moved' | 'regrouped'
|
|
16
|
+
|
|
17
|
+
export interface StructuralChange {
|
|
18
|
+
code: string
|
|
19
|
+
severity: 'error' | 'warning' | 'info'
|
|
20
|
+
kind: ChangeKind
|
|
21
|
+
/** What changed, by id: a group id or a node id. */
|
|
22
|
+
subject: string
|
|
23
|
+
message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiffReport {
|
|
27
|
+
verdict: 'pass' | 'pass_with_findings' | 'fail'
|
|
28
|
+
changes: StructuralChange[]
|
|
29
|
+
/** Both sides, so an empty change list cannot be confused with a comparison that never ran. */
|
|
30
|
+
measured: { before: { nodes: number; groups: number }; after: { nodes: number; groups: number } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The group that directly holds a node, from the side's own declarations. */
|
|
34
|
+
function ownerOf(groups: DiagramGroup[], nodeId: string): string | undefined {
|
|
35
|
+
return groups.find((group) => group.members.includes(nodeId))?.id
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function labels(groups: DiagramGroup[]): Map<string, string> {
|
|
39
|
+
return new Map(groups.map((group) => [group.id, group.label] as const))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parents(groups: DiagramGroup[]): Map<string, string | undefined> {
|
|
43
|
+
return new Map(groups.map((group) => [group.id, group.parent] as const))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How a group is named in a finding: its label, falling back to its id when the other side has no label. */
|
|
47
|
+
function nameOf(labelsById: Map<string, string>, id: string): string {
|
|
48
|
+
return labelsById.get(id) ?? id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compare the containment structure of two drawings.
|
|
53
|
+
*
|
|
54
|
+
* Renames are reported by id: a group that kept its id but changed its text is a rename, while a group whose id
|
|
55
|
+
* is gone and another whose id is new are an unrelated removal and addition, because the ids are what a caller
|
|
56
|
+
* tracks across versions.
|
|
57
|
+
*/
|
|
58
|
+
export function diffStructure(before: Diagram, after: Diagram): DiffReport {
|
|
59
|
+
const beforeGroups = before.groups ?? []
|
|
60
|
+
const afterGroups = after.groups ?? []
|
|
61
|
+
const changes: StructuralChange[] = []
|
|
62
|
+
|
|
63
|
+
const beforeIds = new Set(beforeGroups.map((group) => group.id))
|
|
64
|
+
const afterIds = new Set(afterGroups.map((group) => group.id))
|
|
65
|
+
const beforeLabels = labels(beforeGroups)
|
|
66
|
+
const afterLabels = labels(afterGroups)
|
|
67
|
+
const beforeParents = parents(beforeGroups)
|
|
68
|
+
const afterParents = parents(afterGroups)
|
|
69
|
+
|
|
70
|
+
for (const group of afterGroups) {
|
|
71
|
+
if (beforeIds.has(group.id)) continue
|
|
72
|
+
changes.push({
|
|
73
|
+
code: 'H1_GROUP_ADDED',
|
|
74
|
+
severity: 'info',
|
|
75
|
+
kind: 'added',
|
|
76
|
+
subject: group.id,
|
|
77
|
+
message: 'group "' + group.label + '" is new' + (group.parent === undefined ? '' : ' inside "' + nameOf(afterLabels, group.parent) + '"'),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
for (const group of beforeGroups) {
|
|
81
|
+
if (afterIds.has(group.id)) continue
|
|
82
|
+
changes.push({
|
|
83
|
+
code: 'H2_GROUP_REMOVED',
|
|
84
|
+
severity: 'info',
|
|
85
|
+
kind: 'removed',
|
|
86
|
+
subject: group.id,
|
|
87
|
+
message: 'group "' + group.label + '" is gone',
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const group of afterGroups) {
|
|
92
|
+
if (!beforeIds.has(group.id)) continue
|
|
93
|
+
const wasCalled = nameOf(beforeLabels, group.id)
|
|
94
|
+
if (wasCalled !== group.label) {
|
|
95
|
+
changes.push({
|
|
96
|
+
code: 'H3_GROUP_RENAMED',
|
|
97
|
+
severity: 'info',
|
|
98
|
+
kind: 'renamed',
|
|
99
|
+
subject: group.id,
|
|
100
|
+
message: 'group "' + wasCalled + '" is now called "' + group.label + '"',
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
const was = beforeParents.get(group.id)
|
|
104
|
+
const now = group.parent
|
|
105
|
+
if (was !== now) {
|
|
106
|
+
const from = was === undefined ? 'the top level' : '"' + nameOf(afterLabels, was) + '"'
|
|
107
|
+
const to = now === undefined ? 'the top level' : '"' + nameOf(afterLabels, now) + '"'
|
|
108
|
+
changes.push({
|
|
109
|
+
code: 'H4_GROUP_MOVED',
|
|
110
|
+
severity: 'warning',
|
|
111
|
+
kind: 'moved',
|
|
112
|
+
subject: group.id,
|
|
113
|
+
message: 'group "' + group.label + '" moved from ' + from + ' to ' + to,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const beforeNodes = new Set(before.nodes.map((node) => node.id))
|
|
119
|
+
const afterNodes = new Set(after.nodes.map((node) => node.id))
|
|
120
|
+
for (const node of after.nodes) {
|
|
121
|
+
if (beforeNodes.has(node.id)) continue
|
|
122
|
+
changes.push({ code: 'H5_NODE_ADDED', severity: 'info', kind: 'added', subject: node.id, message: 'node "' + node.id + '" is new' })
|
|
123
|
+
}
|
|
124
|
+
for (const node of before.nodes) {
|
|
125
|
+
if (afterNodes.has(node.id)) continue
|
|
126
|
+
changes.push({ code: 'H6_NODE_REMOVED', severity: 'info', kind: 'removed', subject: node.id, message: 'node "' + node.id + '" is gone' })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Movement is only meaningful for a node that exists on both sides.
|
|
130
|
+
for (const node of after.nodes) {
|
|
131
|
+
if (!beforeNodes.has(node.id)) continue
|
|
132
|
+
const was = ownerOf(beforeGroups, node.id)
|
|
133
|
+
const now = ownerOf(afterGroups, node.id)
|
|
134
|
+
if (was === now) continue
|
|
135
|
+
const from = was === undefined ? 'no group' : '"' + nameOf(beforeLabels, was) + '"'
|
|
136
|
+
const to = now === undefined ? 'no group' : '"' + nameOf(afterLabels, now) + '"'
|
|
137
|
+
changes.push({
|
|
138
|
+
code: 'H7_NODE_REGROUPED',
|
|
139
|
+
severity: 'warning',
|
|
140
|
+
kind: 'regrouped',
|
|
141
|
+
subject: node.id,
|
|
142
|
+
message: 'node "' + node.id + '" moved from ' + from + ' to ' + to,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const measured = {
|
|
147
|
+
before: { nodes: before.nodes.length, groups: beforeGroups.length },
|
|
148
|
+
after: { nodes: after.nodes.length, groups: afterGroups.length },
|
|
149
|
+
}
|
|
150
|
+
// Nothing to compare is not a pass: an empty report from an empty pair would read as "unchanged".
|
|
151
|
+
if (before.nodes.length === 0 && after.nodes.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
verdict: 'fail',
|
|
154
|
+
changes: [
|
|
155
|
+
{ code: 'D0_NO_NODES', severity: 'error', kind: 'removed', subject: '-', message: 'neither side holds a node, so there was nothing to compare' },
|
|
156
|
+
...changes,
|
|
157
|
+
],
|
|
158
|
+
measured,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { verdict: changes.length === 0 ? 'pass' : 'pass_with_findings', changes, measured }
|
|
162
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid text to a diagram, for the subset this renderer can actually draw.
|
|
3
|
+
*
|
|
4
|
+
* The subset is chosen by what a reader of an architecture document writes: a flowchart of boxes and
|
|
5
|
+
* arrows, a state diagram with a start marker, and the group syntax that turns a flat graph into a layered
|
|
6
|
+
* one. Everything outside it is refused **with its line number** rather than half-drawn: a picture that
|
|
7
|
+
* silently drops a lifeline or an interaction is worse than no picture, because it looks complete.
|
|
8
|
+
*
|
|
9
|
+
* Supported:
|
|
10
|
+
*
|
|
11
|
+
* - `graph TD|TB|LR` and `flowchart TD|TB|LR`, with direction carried into the layout as a hint;
|
|
12
|
+
* - node declarations `A[text]`, `A(text)`, `A{text}`, `A((text))`, and a bare `A`;
|
|
13
|
+
* - edges `-->`, `---`, `-.->`, `==>`, with a label as `-->|text|` or `-- text -->`;
|
|
14
|
+
* - `stateDiagram` / `stateDiagram-v2`: `[*] --> A`, `A --> B: event`, `state "text" as id`;
|
|
15
|
+
* - `subgraph name ... end`, which becomes a group;
|
|
16
|
+
* - `%%` comments and blank lines.
|
|
17
|
+
*
|
|
18
|
+
* Refused, by name: `sequenceDiagram`, `classDiagram`, `erDiagram`, `gantt`, `pie`, `journey`, `mindmap`,
|
|
19
|
+
* `timeline`, `quadrantChart`, `gitGraph`, `C4Context`, and `click`/`style`/`classDef` directives.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { emptyDiagram, type Diagram, type DiagramEdge, type DiagramGroup, type DiagramNode } from './model.js'
|
|
23
|
+
|
|
24
|
+
export interface Parsed {
|
|
25
|
+
diagram: Diagram
|
|
26
|
+
/** Lines the parser refused to interpret, with the reason. Empty when the whole source was understood. */
|
|
27
|
+
refused: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CURVED_EDGE = /^(.+?)\s*(-->|---|-\.->|==>)\s*(?:\|([^|]*)\|\s*)?(.+)$/
|
|
31
|
+
const LABELLED_EDGE = /^(.+?)\s*--\s*([^->]+?)\s*-->\s*(.+)$/
|
|
32
|
+
const NODE = /^([A-Za-z_][\w-]*)\s*(?:\[\((.*)\)\]|\[\[(.*)\]\]|\[(.*)\]|\((.*)\)|\{(.*)\}|<<(.*)>>)?$/
|
|
33
|
+
const TRANSITION = /^([A-Za-z_][\w-]*|\[\*\])\s*-->\s*([A-Za-z_][\w-]*|\[\*\])\s*(?::\s*(.*))?$/
|
|
34
|
+
const STATE_ALIAS = /^state\s+["'](.+?)["']\s+as\s+([A-Za-z_][\w-]*)$/
|
|
35
|
+
const DIRECTIVE = /^(click|style|classDef|class|linkStyle|accTitle|accDescr)\b/
|
|
36
|
+
const UNSUPPORTED = new Set([
|
|
37
|
+
'sequencediagram',
|
|
38
|
+
'classdiagram',
|
|
39
|
+
'erdiagram',
|
|
40
|
+
'gantt',
|
|
41
|
+
'pie',
|
|
42
|
+
'journey',
|
|
43
|
+
'mindmap',
|
|
44
|
+
'timeline',
|
|
45
|
+
'quadrantchart',
|
|
46
|
+
'gitgraph',
|
|
47
|
+
'c4context',
|
|
48
|
+
'requirementdiagram',
|
|
49
|
+
'sankey-beta',
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
interface Builder {
|
|
53
|
+
nodes: Map<string, DiagramNode>
|
|
54
|
+
edges: DiagramEdge[]
|
|
55
|
+
groups: DiagramGroup[]
|
|
56
|
+
/** The boundaries currently open, outermost first. */
|
|
57
|
+
open: DiagramGroup[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensure(builder: Builder, id: string, label?: string): void {
|
|
61
|
+
const existing = builder.nodes.get(id)
|
|
62
|
+
if (existing === undefined) {
|
|
63
|
+
builder.nodes.set(id, { id, role: 'normal', ...(label === undefined ? {} : { label }) })
|
|
64
|
+
// Membership is decided where the node is first written: the innermost open boundary. Naming it again
|
|
65
|
+
// later from an outer boundary does not move it - the outer boundary holds it through `parent`.
|
|
66
|
+
const innermost = builder.open[builder.open.length - 1]
|
|
67
|
+
if (innermost !== undefined && !innermost.members.includes(id)) innermost.members.push(id)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (label !== undefined && (existing.label === undefined || existing.label === '')) existing.label = label
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function nodeFrom(token: string): { id: string; label?: string } | undefined {
|
|
74
|
+
const match = NODE.exec(token.trim())
|
|
75
|
+
if (match === null) return undefined
|
|
76
|
+
const id = match[1]
|
|
77
|
+
const label = [match[2], match[3], match[4], match[5], match[6], match[7]].find((value) => value !== undefined && value.trim() !== '')
|
|
78
|
+
return { id, ...(label === undefined ? {} : { label: label.trim().replace(/^["']|["']$/g, '') }) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function fromMermaid(source: string, title = 'mermaid'): Parsed {
|
|
82
|
+
const lines = source.split(/\r?\n/)
|
|
83
|
+
const refused: string[] = []
|
|
84
|
+
const builder: Builder = { nodes: new Map(), edges: [], groups: [], open: [] }
|
|
85
|
+
let mode: 'none' | 'graph' | 'state' = 'none'
|
|
86
|
+
let startId: string | undefined
|
|
87
|
+
let endCount = 0
|
|
88
|
+
|
|
89
|
+
const push = (edge: DiagramEdge): void => {
|
|
90
|
+
builder.edges.push(edge)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
lines.forEach((raw, index) => {
|
|
94
|
+
const line = raw.replace(/%%(.*)$/, '').trim()
|
|
95
|
+
if (line === '') return
|
|
96
|
+
const where = 'line ' + String(index + 1) + ': '
|
|
97
|
+
const lower = line.toLowerCase()
|
|
98
|
+
const keyword = lower.split(/[\s{}]/)[0] ?? ''
|
|
99
|
+
|
|
100
|
+
if (UNSUPPORTED.has(keyword)) {
|
|
101
|
+
refused.push(where + '`' + keyword + '` is not a diagram this renderer draws')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (DIRECTIVE.test(line)) {
|
|
105
|
+
refused.push(where + '`' + (line.split(/\s/)[0] ?? '') + '` is a directive this renderer ignores')
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (/^(graph|flowchart)\b/.test(lower)) {
|
|
109
|
+
mode = 'graph'
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (/^statediagram(-v2)?\b/.test(lower)) {
|
|
113
|
+
mode = 'state'
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (/^subgraph\b/.test(lower)) {
|
|
117
|
+
const label = line.replace(/^subgraph\b/i, '').trim().replace(/^["']|["']$/g, '')
|
|
118
|
+
const id = 'subgraph-' + String(++endCount)
|
|
119
|
+
builder.open.push({ id, label: label === '' ? id : label, members: [] })
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (lower === 'end') {
|
|
123
|
+
const closed = builder.open.pop()
|
|
124
|
+
if (closed !== undefined) {
|
|
125
|
+
const container = builder.open[builder.open.length - 1]
|
|
126
|
+
if (container !== undefined) closed.parent = container.id
|
|
127
|
+
builder.groups.push(closed)
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (mode === 'state') {
|
|
133
|
+
const alias = STATE_ALIAS.exec(line)
|
|
134
|
+
if (alias !== null) {
|
|
135
|
+
ensure(builder, alias[2], alias[1])
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const transition = TRANSITION.exec(line)
|
|
139
|
+
if (transition === null) {
|
|
140
|
+
// A bare `state X` declaration, or a state named on its own line.
|
|
141
|
+
const bare = /^state\s+([A-Za-z_][\w-]*)$/.exec(line)
|
|
142
|
+
if (bare !== null) {
|
|
143
|
+
ensure(builder, bare[1])
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (/^[A-Za-z_][\w-]*$/.test(line)) {
|
|
147
|
+
ensure(builder, line)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
refused.push(where + '`' + line + '` is not a state transition this renderer draws')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const from = transition[1]
|
|
154
|
+
const to = transition[2]
|
|
155
|
+
const label = (transition[3] ?? '').trim()
|
|
156
|
+
if (from === '[*]') {
|
|
157
|
+
// A start marker names the state it enters, not a box of its own.
|
|
158
|
+
ensure(builder, to)
|
|
159
|
+
if (startId === undefined) startId = to
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (to === '[*]') {
|
|
163
|
+
ensure(builder, from)
|
|
164
|
+
push({ from, to: from, label: label === '' ? 'end' : label, dashed: true })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
ensure(builder, from)
|
|
168
|
+
ensure(builder, to)
|
|
169
|
+
push({ from, to, label })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const labelled = LABELLED_EDGE.exec(line)
|
|
174
|
+
if (labelled !== null) {
|
|
175
|
+
const left = nodeFrom(labelled[1])
|
|
176
|
+
const right = nodeFrom(labelled[3])
|
|
177
|
+
if (left === undefined || right === undefined) {
|
|
178
|
+
refused.push(where + '`' + line + '` does not name two nodes')
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
ensure(builder, left.id, left.label)
|
|
182
|
+
ensure(builder, right.id, right.label)
|
|
183
|
+
push({ from: left.id, to: right.id, label: labelled[2].trim() })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const edge = CURVED_EDGE.exec(line)
|
|
188
|
+
if (edge !== null) {
|
|
189
|
+
const left = nodeFrom(edge[1])
|
|
190
|
+
const right = nodeFrom(edge[4])
|
|
191
|
+
if (left === undefined || right === undefined) {
|
|
192
|
+
refused.push(where + '`' + line + '` does not name two nodes')
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
ensure(builder, left.id, left.label)
|
|
196
|
+
ensure(builder, right.id, right.label)
|
|
197
|
+
push({
|
|
198
|
+
from: left.id,
|
|
199
|
+
to: right.id,
|
|
200
|
+
label: (edge[3] ?? '').trim(),
|
|
201
|
+
...(edge[2] === '-.->' || edge[2] === '==>' ? { dashed: true } : {}),
|
|
202
|
+
})
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const single = nodeFrom(line)
|
|
207
|
+
if (single !== undefined) {
|
|
208
|
+
ensure(builder, single.id, single.label)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
refused.push(where + '`' + line + '` is not something this renderer draws')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
if (mode === 'none') {
|
|
216
|
+
return { diagram: emptyDiagram(title, 'no mermaid diagram found'), refused }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (builder.nodes.size === 0) {
|
|
220
|
+
return { diagram: emptyDiagram(title, 'no nodes found'), refused }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const diagram: Diagram = {
|
|
224
|
+
kind: mode === 'state' ? 'machine' : 'graph',
|
|
225
|
+
title,
|
|
226
|
+
...(startId === undefined ? {} : { init: startId }),
|
|
227
|
+
nodes: [...builder.nodes.values()],
|
|
228
|
+
edges: builder.edges,
|
|
229
|
+
...(builder.groups.length === 0 ? {} : { groups: builder.groups }),
|
|
230
|
+
caption:
|
|
231
|
+
mode + ', ' + String(builder.nodes.size) + ' box' + (builder.nodes.size === 1 ? '' : 'es') + ', ' +
|
|
232
|
+
String(builder.edges.length) + ' arrow' + (builder.edges.length === 1 ? '' : 's') +
|
|
233
|
+
(refused.length === 0 ? '' : ', ' + String(refused.length) + ' line(s) refused'),
|
|
234
|
+
}
|
|
235
|
+
return { diagram, refused }
|
|
236
|
+
}
|