dsh-model-everything 0.5.0 → 0.5.1

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.
Files changed (62) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2437 -216
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +764 -67
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +143 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/wrap.js +87 -0
  23. package/lib/types/render/channels.d.ts +63 -0
  24. package/lib/types/render/components.d.ts +57 -0
  25. package/lib/types/render/coords.d.ts +64 -0
  26. package/lib/types/render/cycle-break.d.ts +45 -0
  27. package/lib/types/render/dummy.d.ts +32 -0
  28. package/lib/types/render/from-plantuml.d.ts +12 -6
  29. package/lib/types/render/layer.d.ts +28 -0
  30. package/lib/types/render/layout.d.ts +43 -11
  31. package/lib/types/render/model.d.ts +17 -0
  32. package/lib/types/render/network-simplex.d.ts +54 -0
  33. package/lib/types/render/order.d.ts +27 -0
  34. package/lib/types/render/paint.d.ts +42 -9
  35. package/lib/types/render/place.d.ts +35 -19
  36. package/lib/types/render/route.d.ts +77 -0
  37. package/lib/types/render/svg.d.ts +1 -1
  38. package/lib/types/render/symbol.d.ts +73 -0
  39. package/lib/types/render/text.d.ts +38 -0
  40. package/lib/types/render/wrap.d.ts +27 -0
  41. package/package.json +148 -148
  42. package/skills/model-everything-diagram/SKILL.md +84 -82
  43. package/src/render/channels.ts +309 -0
  44. package/src/render/collapse.ts +150 -155
  45. package/src/render/components.ts +126 -0
  46. package/src/render/coords.ts +328 -0
  47. package/src/render/cycle-break.ts +164 -0
  48. package/src/render/dummy.ts +65 -0
  49. package/src/render/from-mermaid.ts +261 -236
  50. package/src/render/from-plantuml.ts +469 -178
  51. package/src/render/layer.ts +98 -0
  52. package/src/render/layout.ts +101 -62
  53. package/src/render/model.ts +86 -67
  54. package/src/render/network-simplex.ts +290 -0
  55. package/src/render/order.ts +139 -0
  56. package/src/render/paint.ts +1006 -290
  57. package/src/render/place.ts +527 -234
  58. package/src/render/route.ts +269 -0
  59. package/src/render/svg.ts +270 -127
  60. package/src/render/symbol.ts +137 -0
  61. package/src/render/text.ts +86 -0
  62. package/src/render/wrap.ts +103 -0
@@ -1,178 +1,469 @@
1
- /**
2
- * PlantUML text to a diagram, for the subset this renderer can actually draw.
3
- *
4
- * PlantUML is a large language, and a renderer that accepted all of it would be a re-implementation of
5
- * PlantUML. This one accepts what an architecture document actually contains - boxes, groups and arrows -
6
- * and refuses the rest **by line**, because a component diagram that quietly loses a lifeline reads as
7
- * complete and is not.
8
- *
9
- * Supported:
10
- *
11
- * - `@startuml` / `@enduml` (optional) and `title ...`;
12
- * - `component X`, `component "text" as X`, `[text] as X`, `[text]`;
13
- * - `package "text" { ... }`, `node`, `folder`, `cloud`, `database` - a group, closed by `}`;
14
- * - `X --> Y`, `X ---> Y`, `X ..> Y`, `X --> Y : label`, arrow either direction;
15
- * - `state X`, `state "text" as X`, and `[*] --> X` for a start marker;
16
- * - `'` comments and `skinparam`/`!theme` directives.
17
- *
18
- * Refused, by name: `sequence`, `actor`, `participant`, `usecase`, `class`, `object`, `note`, `legend`,
19
- * `alt`/`opt`/`loop`/`par` and any other fragment keyword.
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 ARROW = /^(.+?)\s*(<?)(-{2,}|\.{2,})(>?)\s*(.+?)(?:\s*:\s*(.+))?$/
31
- const DECLARED = /^(component|package|node|folder|cloud|database|state|artifact|frame|rectangle)\s+(.+)$/
32
- const QUOTED_ALIAS = /^["'](.+?)["']\s+as\s+([A-Za-z_][\w-]*)$/
33
- const BRACKET_ALIAS = /^\[(.+?)\]\s+as\s+([A-Za-z_][\w-]*)$/
34
- const BRACKET = /^\[(.+?)\]$/
35
- const UNSUPPORTED = /^(actor|participant|usecase|class|object|note|legend|alt|opt|loop|par|group\s|ref\s|activate|deactivate|destroy|create|newpage)\b/
36
- const IGNORED = /^(skinparam|!theme|!define|!include|hide|show|scale|left to right direction|top to bottom direction|together)\b/
37
-
38
- interface Builder {
39
- nodes: Map<string, DiagramNode>
40
- edges: DiagramEdge[]
41
- groups: DiagramGroup[]
42
- open: DiagramGroup[]
43
- }
44
-
45
- function ensure(builder: Builder, id: string, label?: string): void {
46
- const existing = builder.nodes.get(id)
47
- if (existing === undefined) {
48
- builder.nodes.set(id, { id, role: 'normal', ...(label === undefined ? {} : { label }) })
49
- // Membership is decided where the node is first written: the innermost open boundary.
50
- const innermost = builder.open[builder.open.length - 1]
51
- if (innermost !== undefined && !innermost.members.includes(id)) innermost.members.push(id)
52
- return
53
- }
54
- if (label !== undefined && (existing.label === undefined || existing.label === '')) existing.label = label
55
- }
56
-
57
- /** A component reference: `[text]`, `X`, or a quoted name. Returns the id and the label it carried. */
58
- function reference(token: string): { id: string; label?: string } {
59
- const text = token.trim()
60
- const bracket = BRACKET.exec(text)
61
- if (bracket !== null) {
62
- const label = bracket[1].trim()
63
- return { id: label, label }
64
- }
65
- const quoted = /^["'](.+?)["']$/.exec(text)
66
- if (quoted !== null) return { id: quoted[1], label: quoted[1] }
67
- return { id: text }
68
- }
69
-
70
- export function fromPlantUml(source: string, title = 'plantuml'): Parsed {
71
- const lines = source.split(/\r?\n/)
72
- const refused: string[] = []
73
- const builder: Builder = { nodes: new Map(), edges: [], groups: [], open: [] }
74
- let startId: string | undefined
75
- let seenAny = false
76
- let groupCount = 0
77
-
78
- lines.forEach((raw, index) => {
79
- const line = raw.replace(/'.*$/, '').trim()
80
- if (line === '') return
81
- const where = 'line ' + String(index + 1) + ': '
82
-
83
- if (/^@(start|end)uml\b/.test(line)) return
84
- seenAny = true
85
- if (/^title\b/.test(line)) return
86
- if (IGNORED.test(line)) return
87
- if (UNSUPPORTED.test(line)) {
88
- refused.push(where + '`' + (line.split(/\s/)[0] ?? '') + '` is not a diagram this renderer draws')
89
- return
90
- }
91
-
92
- if (line === '}') {
93
- const closing = builder.open.pop()
94
- if (closing !== undefined) {
95
- const container = builder.open[builder.open.length - 1]
96
- if (container !== undefined) closing.parent = container.id
97
- builder.groups.push(closing)
98
- }
99
- return
100
- }
101
-
102
- if (line.endsWith('{')) {
103
- const declaration = line.slice(0, -1).trim()
104
- const declared = DECLARED.exec(declaration)
105
- const label = declared === null ? declaration.replace(/^["']|["']$/g, '') : declared[2].replace(/^["']|["']$/g, '')
106
- groupCount += 1
107
- builder.open.push({ id: 'group-' + String(groupCount), label, members: [] })
108
- return
109
- }
110
-
111
- const declared = DECLARED.exec(line)
112
- if (declared !== null) {
113
- const rest = declared[2].trim()
114
- const quoted = QUOTED_ALIAS.exec(rest)
115
- if (quoted !== null) {
116
- ensure(builder, quoted[2], quoted[1])
117
- return
118
- }
119
- const bracketed = BRACKET_ALIAS.exec(rest)
120
- if (bracketed !== null) {
121
- ensure(builder, bracketed[2], bracketed[1])
122
- return
123
- }
124
- if (/^["'](.+?)["']$/.test(rest)) return // a group heading with no alias: nothing to draw
125
- const reference_ = reference(rest)
126
- ensure(builder, reference_.id, reference_.label)
127
- return
128
- }
129
-
130
- if (line === '[*] --> [*]') return
131
-
132
- const startMarker = /^\[\*\]\s*-->\s*(.+)$/.exec(line)
133
- if (startMarker !== null) {
134
- const target = reference(startMarker[1])
135
- ensure(builder, target.id, target.label)
136
- if (startId === undefined) startId = target.id
137
- return
138
- }
139
-
140
- const arrow = ARROW.exec(line)
141
- if (arrow !== null) {
142
- const left = arrow[2] === '<' ? arrow[5] : arrow[1]
143
- const right = arrow[2] === '<' ? arrow[1] : arrow[5]
144
- const from = reference(left)
145
- const to = reference(right)
146
- ensure(builder, from.id, from.label)
147
- ensure(builder, to.id, to.label)
148
- builder.edges.push({ from: from.id, to: to.id, label: (arrow[6] ?? '').trim(), ...(arrow[3].startsWith('.') ? { dashed: true } : {}) })
149
- return
150
- }
151
-
152
- const single = BRACKET.exec(line)
153
- if (single !== null) {
154
- ensure(builder, single[1].trim(), single[1].trim())
155
- return
156
- }
157
-
158
- refused.push(where + '`' + line + '` is not something this renderer draws')
159
- })
160
-
161
- if (!seenAny || builder.nodes.size === 0) {
162
- return { diagram: emptyDiagram(title, 'no nodes found'), refused }
163
- }
164
-
165
- const diagram: Diagram = {
166
- kind: startId === undefined ? 'graph' : 'machine',
167
- title,
168
- ...(startId === undefined ? {} : { init: startId }),
169
- nodes: [...builder.nodes.values()],
170
- edges: builder.edges,
171
- ...(builder.groups.length === 0 ? {} : { groups: builder.groups }),
172
- caption:
173
- 'plantuml, ' + String(builder.nodes.size) + ' box' + (builder.nodes.size === 1 ? '' : 'es') + ', ' +
174
- String(builder.edges.length) + ' arrow' + (builder.edges.length === 1 ? '' : 's') +
175
- (refused.length === 0 ? '' : ', ' + String(refused.length) + ' line(s) refused'),
176
- }
177
- return { diagram, refused }
178
- }
1
+ /**
2
+ * PlantUML text to a diagram, for the subset this renderer can actually draw.
3
+ *
4
+ * PlantUML is a large language, and a renderer that accepted all of it would be a re-implementation of
5
+ * PlantUML. This one accepts what an architecture document actually contains - boxes, groups and arrows -
6
+ * and refuses the rest **by line**, because a component diagram that quietly loses a lifeline reads as
7
+ * complete and is not.
8
+ *
9
+ * Supported:
10
+ *
11
+ * - `@startuml` / `@enduml` (optional) and `title ...`;
12
+ * - elements: `component`, `node`, `rectangle`, `artifact` and `state` (a rectangle), `database` and
13
+ * `entity` (a cylinder), `usecase` (an ellipse), `[text]`, each with a `"text"` label and `as` alias;
14
+ * - boundaries: `package`, `folder`, `node`, `component`, `rectangle` and `artifact` blocks, closed by `}`;
15
+ * - arrows, either direction: association `-->`/`<--`, dependency `..>`/`<..`, generalisation `--|>`/`<|--`,
16
+ * realisation `..|>`/`<|..`, composition `*--`/`--*`, aggregation `o--`/`--o`, labelled after `:`;
17
+ * - an edge end that names a declared block attaches to that block's rectangle, the way an external
18
+ * dependency on a package is drawn; a name that is not a declared block is an implicit element;
19
+ * - `[*] --> X` for a start marker;
20
+ * - `'` comments and `skinparam`/`!theme` directives.
21
+ *
22
+ * Refused, by line and by name: the element keywords this renderer has no symbol for (`actor`, `class`,
23
+ * `cloud`, ...), the diagram kinds it does not draw (`sequence`), and an edge it cannot draw as written (a
24
+ * line with no arrow end, a mark at both ends, a dotted diamond, an edge between two boundaries, or an edge
25
+ * into a boundary that is nested or empty).
26
+ */
27
+
28
+ import { emptyDiagram, type Diagram, type DiagramEdge, type DiagramGroup, type DiagramNode, type EdgeArrow, type NodeShape } from './model.js'
29
+
30
+ export interface Parsed {
31
+ diagram: Diagram
32
+ /** Lines the parser refused to interpret, with the reason. Empty when the whole source was understood. */
33
+ refused: string[]
34
+ }
35
+
36
+ /** The element keywords this renderer draws, and the symbol each one becomes. */
37
+ const SHAPES: Record<string, NodeShape> = {
38
+ component: 'rect',
39
+ node: 'rect',
40
+ rectangle: 'rect',
41
+ artifact: 'rect',
42
+ state: 'rect',
43
+ database: 'cylinder',
44
+ // PlantUML draws an entity as a cylinder in a component diagram; `entity` in an ER diagram is a different
45
+ // symbol, and that diagram family is not one this renderer accepts.
46
+ entity: 'cylinder',
47
+ // A use case is an ellipse. The `circle` symbol is written as an ellipse whose radii follow the box, so it
48
+ // reads as the familiar use-case oval; `stadium` is the pill a terminal state is drawn as.
49
+ usecase: 'circle',
50
+ }
51
+
52
+ /** The keywords that open a boundary around what is written inside them. */
53
+ const BOUNDARY = new Set(['package', 'folder', 'node', 'component', 'rectangle', 'artifact'])
54
+
55
+ /** Element keywords this renderer deliberately does not draw, with the reason it gives for each. */
56
+ const NOT_DRAWN: Record<string, string> = {
57
+ actor: 'an actor is a stick figure, not one of the shapes this renderer draws',
58
+ participant: 'a participant is a lifeline, not one of the shapes this renderer draws',
59
+ class: 'a class box has compartments this renderer does not draw',
60
+ object: 'an object box has compartments this renderer does not draw',
61
+ interface: 'an interface is a circle or a boxed stereotype, neither of which this renderer draws',
62
+ enum: 'an enum box has compartments this renderer does not draw',
63
+ annotation: 'an annotation is not one of the shapes this renderer draws',
64
+ cloud: 'a cloud outline is not one of the shapes this renderer draws',
65
+ frame: 'a frame tab is not one of the shapes this renderer draws',
66
+ card: 'a card is not one of the shapes this renderer draws',
67
+ file: 'a file outline is not one of the shapes this renderer draws',
68
+ agent: 'an agent is not one of the shapes this renderer draws',
69
+ boundary: 'the boundary symbol is not one of the shapes this renderer draws',
70
+ control: 'the control symbol is not one of the shapes this renderer draws',
71
+ collections: 'a collections symbol is not one of the shapes this renderer draws',
72
+ queue: 'a queue is not one of the shapes this renderer draws',
73
+ stack: 'a stack is not one of the shapes this renderer draws',
74
+ storage: 'a storage symbol is not one of the shapes this renderer draws',
75
+ note: 'a note is not one of the shapes this renderer draws',
76
+ legend: 'a legend is not one of the shapes this renderer draws',
77
+ }
78
+
79
+ /** A sequence fragment or lifeline event: lines that only make sense in a diagram this renderer refuses. */
80
+ const FRAGMENT = new Set(['sequence', 'alt', 'opt', 'loop', 'par', 'group', 'ref', 'break', 'critical', 'activate', 'deactivate', 'destroy', 'create', 'newpage', 'autonumber'])
81
+
82
+ const ARROW = /^(.+?)\s*(<\|?|[*o])?(-{2,}|\.{2,})(\|?>|[*o])?\s*(.+?)(?:\s*:\s*(.+))?$/
83
+ const SHAPE_KEYWORDS = Object.keys(SHAPES)
84
+ const DECLARED = new RegExp('^(' + SHAPE_KEYWORDS.join('|') + ')\\s+(.+)$')
85
+ const QUOTED_ALIAS = /^["'](.+?)["']\s+as\s+([A-Za-z_][\w-]*)$/
86
+ const BRACKET_ALIAS = /^\[(.+?)\]\s+as\s+([A-Za-z_][\w-]*)$/
87
+ const PLAIN_ALIAS = /^([A-Za-z_][\w.-]*)\s+as\s+([A-Za-z_][\w-]*)$/
88
+ const BRACKET = /^\[(.+?)\]$/
89
+ const IGNORED = /^(skinparam|!theme|!define|!include|!pragma|hide|show|scale|left to right direction|top to bottom direction|together|allowmixing)\b/
90
+
91
+ interface Builder {
92
+ nodes: Map<string, DiagramNode>
93
+ edges: DiagramEdge[]
94
+ /** The source line of each edge, so a refusal decided after the whole file is read still names its line. */
95
+ edgeLines: number[]
96
+ /** The innermost boundary open when each edge was written, to catch an edge that leaves and re-enters it. */
97
+ edgeScopes: Array<string | undefined>
98
+ groups: DiagramGroup[]
99
+ open: DiagramGroup[]
100
+ /** Node ids an edge wrote first. A name that turns out to be a block is removed again, not drawn as a box. */
101
+ implicit: Set<string>
102
+ }
103
+
104
+ interface Reference {
105
+ id: string
106
+ label?: string
107
+ shape?: NodeShape
108
+ }
109
+
110
+ function ensure(builder: Builder, id: string, label?: string, shape?: NodeShape, implicit = false): void {
111
+ const existing = builder.nodes.get(id)
112
+ if (existing === undefined) {
113
+ builder.nodes.set(id, {
114
+ id,
115
+ role: 'normal',
116
+ ...(label === undefined ? {} : { label }),
117
+ ...(shape === undefined ? {} : { shape }),
118
+ })
119
+ if (implicit) builder.implicit.add(id)
120
+ // Membership is decided where the node is first written: the innermost open boundary.
121
+ const innermost = builder.open[builder.open.length - 1]
122
+ if (innermost !== undefined && !innermost.members.includes(id)) innermost.members.push(id)
123
+ return
124
+ }
125
+ // An explicit declaration settles what a name is: it stays a box even when a block has the same name.
126
+ if (!implicit) builder.implicit.delete(id)
127
+ if (label !== undefined && (existing.label === undefined || existing.label === '')) existing.label = label
128
+ // A bare reference may have been written first: the later declaration is what states the symbol.
129
+ if (shape !== undefined && existing.shape === undefined) existing.shape = shape
130
+ }
131
+
132
+ /** A component reference: `[text]`, `X`, or a quoted name. Returns the id, the label and any shape it stated. */
133
+ function reference(token: string): Reference {
134
+ const text = token.trim()
135
+ const bracket = BRACKET.exec(text)
136
+ if (bracket !== null) {
137
+ const label = bracket[1].trim()
138
+ return { id: label, label, shape: 'rect' }
139
+ }
140
+ const quoted = /^["'](.+?)["']$/.exec(text)
141
+ if (quoted !== null) return { id: quoted[1], label: quoted[1] }
142
+ return { id: text }
143
+ }
144
+
145
+ function keywordOf(line: string): string {
146
+ return line.split(/[\s{]/)[0].toLowerCase()
147
+ }
148
+
149
+ /** The id and label an opening boundary line states. An alias is the id, because edges will name it. */
150
+ function boundaryOf(declaration: string, keyword: string, ordinal: number): { id: string; label: string } {
151
+ const rest = declaration.slice(keyword.length).trim()
152
+ const alias = QUOTED_ALIAS.exec(rest) ?? BRACKET_ALIAS.exec(rest) ?? PLAIN_ALIAS.exec(rest)
153
+ if (alias !== null) return { id: alias[2], label: alias[1] }
154
+ const label = rest.replace(/^["']|["']$/g, '')
155
+ return { id: 'group-' + String(ordinal), label: label === '' ? keyword : label }
156
+ }
157
+
158
+ interface ArrowParts {
159
+ from: Reference
160
+ to: Reference
161
+ label: string
162
+ dashed: boolean
163
+ arrow: EdgeArrow
164
+ }
165
+
166
+ /**
167
+ * Read one arrow line.
168
+ *
169
+ * Returns the edge it states, `undefined` when the line is not an arrow at all, or the reason it is refused.
170
+ * The arrow the source writes decides which end carries the mark: `A <|-- B` is B generalising to A, so the
171
+ * edge runs from B to A with the triangle at A, and `A *-- B` puts its diamond at A, so A is the source.
172
+ */
173
+ function readArrow(line: string): ArrowParts | string | undefined {
174
+ const match = ARROW.exec(line)
175
+ if (match === null) return undefined
176
+ const left = reference(match[1])
177
+ const right = reference(match[5])
178
+ const leftMark = match[2]
179
+ const rightMark = match[4]
180
+ const dotted = match[3].startsWith('.')
181
+ const label = (match[6] ?? '').trim()
182
+
183
+ if (leftMark !== undefined && rightMark !== undefined) {
184
+ return 'an edge marked at both ends is not one this renderer draws'
185
+ }
186
+ if (leftMark === '*' || leftMark === 'o' || rightMark === '*' || rightMark === 'o') {
187
+ if (dotted) return 'a dotted composition or aggregation is not one this renderer draws'
188
+ const atRight = rightMark === '*' || rightMark === 'o'
189
+ const mark = atRight ? rightMark : leftMark
190
+ return {
191
+ from: atRight ? right : left,
192
+ to: atRight ? left : right,
193
+ label,
194
+ dashed: false,
195
+ arrow: mark === '*' ? 'composition' : 'aggregation',
196
+ }
197
+ }
198
+ if (leftMark !== undefined) {
199
+ return {
200
+ from: right,
201
+ to: left,
202
+ label,
203
+ dashed: dotted,
204
+ arrow: leftMark === '<|' ? (dotted ? 'realization' : 'generalization') : dotted ? 'dependency' : 'association',
205
+ }
206
+ }
207
+ if (rightMark !== undefined) {
208
+ return {
209
+ from: left,
210
+ to: right,
211
+ label,
212
+ dashed: dotted,
213
+ arrow: rightMark === '|>' ? (dotted ? 'realization' : 'generalization') : dotted ? 'dependency' : 'association',
214
+ }
215
+ }
216
+ return 'a line with no arrow end is not one this renderer draws; name the end it points at'
217
+ }
218
+
219
+ export function fromPlantUml(source: string, title = 'plantuml'): Parsed {
220
+ const lines = source.split(/\r?\n/)
221
+ const refused: string[] = []
222
+ const builder: Builder = { nodes: new Map(), edges: [], edgeLines: [], edgeScopes: [], groups: [], open: [], implicit: new Set() }
223
+ let startId: string | undefined
224
+ let seenAny = false
225
+ let groupCount = 0
226
+
227
+ lines.forEach((raw, index) => {
228
+ const line = raw.replace(/'.*$/, '').trim()
229
+ if (line === '') return
230
+ const where = 'line ' + String(index + 1) + ': '
231
+
232
+ if (/^@(start|end)uml\b/.test(line)) return
233
+ seenAny = true
234
+ if (/^title\b/.test(line)) return
235
+ if (IGNORED.test(line)) return
236
+
237
+ const keyword = keywordOf(line)
238
+ const notDrawn = NOT_DRAWN[keyword]
239
+ if (notDrawn !== undefined) {
240
+ refused.push(where + '`' + keyword + '`: ' + notDrawn)
241
+ return
242
+ }
243
+ if (FRAGMENT.has(keyword)) {
244
+ refused.push(where + '`' + keyword + '`: a sequence statement is not part of a drawing this renderer makes')
245
+ return
246
+ }
247
+
248
+ if (line === '}') {
249
+ const closing = builder.open.pop()
250
+ if (closing !== undefined) {
251
+ const container = builder.open[builder.open.length - 1]
252
+ if (container !== undefined) closing.parent = container.id
253
+ builder.groups.push(closing)
254
+ }
255
+ return
256
+ }
257
+
258
+ if (line.endsWith('{')) {
259
+ const declaration = line.slice(0, -1).trim()
260
+ const opener = keywordOf(declaration)
261
+ if (!BOUNDARY.has(opener)) {
262
+ const reason = SHAPES[opener] === undefined
263
+ ? '`' + declaration + '` is not a boundary this renderer draws'
264
+ : 'a `' + opener + '` block is not a boundary this renderer draws'
265
+ refused.push(where + reason)
266
+ return
267
+ }
268
+ groupCount += 1
269
+ const boundary = boundaryOf(declaration, opener, groupCount)
270
+ builder.open.push({ id: boundary.id, label: boundary.label, members: [] })
271
+ return
272
+ }
273
+
274
+ if (line === '[*] --> [*]') return
275
+
276
+ const startMarker = /^\[\*\]\s*-->\s*(.+)$/.exec(line)
277
+ if (startMarker !== null) {
278
+ const target = reference(startMarker[1])
279
+ ensure(builder, target.id, target.label, target.shape)
280
+ if (startId === undefined) startId = target.id
281
+ return
282
+ }
283
+
284
+ const declared = DECLARED.exec(line)
285
+ if (declared !== null) {
286
+ const shape = SHAPES[declared[1]] as NodeShape
287
+ const rest = declared[2].trim()
288
+ const quoted = QUOTED_ALIAS.exec(rest)
289
+ if (quoted !== null) {
290
+ ensure(builder, quoted[2], quoted[1], shape)
291
+ return
292
+ }
293
+ const bracketed = BRACKET_ALIAS.exec(rest)
294
+ if (bracketed !== null) {
295
+ ensure(builder, bracketed[2], bracketed[1], shape)
296
+ return
297
+ }
298
+ const plain = PLAIN_ALIAS.exec(rest)
299
+ if (plain !== null) {
300
+ ensure(builder, plain[2], plain[1], shape)
301
+ return
302
+ }
303
+ const written = reference(rest)
304
+ ensure(builder, written.id, written.label, shape)
305
+ return
306
+ }
307
+
308
+ const arrow = readArrow(line)
309
+ if (arrow !== undefined) {
310
+ if (typeof arrow === 'string') {
311
+ refused.push(where + arrow)
312
+ return
313
+ }
314
+ ensure(builder, arrow.from.id, arrow.from.label, arrow.from.shape, true)
315
+ ensure(builder, arrow.to.id, arrow.to.label, arrow.to.shape, true)
316
+ builder.edges.push({
317
+ from: arrow.from.id,
318
+ to: arrow.to.id,
319
+ label: arrow.label,
320
+ ...(arrow.dashed ? { dashed: true } : {}),
321
+ arrow: arrow.arrow,
322
+ })
323
+ builder.edgeLines.push(index + 1)
324
+ builder.edgeScopes.push(builder.open[builder.open.length - 1]?.id)
325
+ return
326
+ }
327
+
328
+ const single = BRACKET.exec(line)
329
+ if (single !== null) {
330
+ ensure(builder, single[1].trim(), single[1].trim(), 'rect')
331
+ return
332
+ }
333
+
334
+ refused.push(where + '`' + line + '` is not something this renderer draws')
335
+ })
336
+
337
+ // A block's name can be written as an edge endpoint: `Web <|-- Mobile` points at the `package Web`
338
+ // block. PlantUML allows it and so does UML; what it must not do is become a second box called `Web`.
339
+ // An endpoint an arrow created is re-read as the boundary it names, and the edge attaches to that
340
+ // boundary's rectangle. A name that is not a declared block stays an implicit element, which PlantUML
341
+ // also allows.
342
+ const boundaryNames = new Map<string, string>()
343
+ const disputedNames = new Set<string>()
344
+ for (const group of builder.groups) {
345
+ for (const name of [group.id, group.label]) {
346
+ const existing = boundaryNames.get(name)
347
+ if (existing !== undefined && existing !== group.id) disputedNames.add(name)
348
+ else boundaryNames.set(name, group.id)
349
+ }
350
+ }
351
+ for (const name of disputedNames) boundaryNames.delete(name)
352
+
353
+ // Only a node an arrow wrote can turn out to be a boundary. An explicitly declared element of the same
354
+ // name stays a box: the source said what it is.
355
+ const boundaryAt = new Map<string, string>()
356
+ const ambiguousAt = new Set<string>()
357
+ for (const id of builder.implicit) {
358
+ if (disputedNames.has(id)) {
359
+ ambiguousAt.add(id)
360
+ builder.nodes.delete(id)
361
+ } else {
362
+ const groupId = boundaryNames.get(id)
363
+ if (groupId === undefined) continue
364
+ boundaryAt.set(id, groupId)
365
+ builder.nodes.delete(id)
366
+ }
367
+ for (const group of builder.groups) {
368
+ const at = group.members.indexOf(id)
369
+ if (at >= 0) group.members.splice(at, 1)
370
+ }
371
+ }
372
+
373
+ const groupById = new Map(builder.groups.map((group) => [group.id, group] as const))
374
+ const childrenOf = new Map<string, string[]>()
375
+ for (const group of builder.groups) {
376
+ if (group.parent === undefined) continue
377
+ childrenOf.set(group.parent, [...(childrenOf.get(group.parent) ?? []), group.id])
378
+ }
379
+ // An empty boundary is not drawn, so an edge cannot attach to it. Look through nested boundaries, which
380
+ // are drawn only when something inside them is.
381
+ const drawable = new Map<string, boolean>()
382
+ const hasContents = (id: string): boolean => {
383
+ const known = drawable.get(id)
384
+ if (known !== undefined) return known
385
+ const group = groupById.get(id) as DiagramGroup
386
+ let found = false
387
+ for (const member of group.members) {
388
+ if (builder.nodes.has(member)) {
389
+ found = true
390
+ break
391
+ }
392
+ }
393
+ if (!found) {
394
+ for (const child of childrenOf.get(id) ?? []) {
395
+ if (hasContents(child)) {
396
+ found = true
397
+ break
398
+ }
399
+ }
400
+ }
401
+ drawable.set(id, found)
402
+ return found
403
+ }
404
+ const boundaryLabel = (id: string): string => (groupById.get(id) as DiagramGroup).label
405
+ const within = (scope: string | undefined, boundary: string): boolean => {
406
+ let cursor = scope
407
+ while (cursor !== undefined) {
408
+ if (cursor === boundary) return true
409
+ cursor = (groupById.get(cursor) as DiagramGroup).parent
410
+ }
411
+ return false
412
+ }
413
+
414
+ const keptEdges: DiagramEdge[] = []
415
+ builder.edges.forEach((edge, index) => {
416
+ const where = 'line ' + String(builder.edgeLines[index]) + ': '
417
+ const fromBoundary = boundaryAt.get(edge.from)
418
+ const toBoundary = boundaryAt.get(edge.to)
419
+ if (ambiguousAt.has(edge.from) || ambiguousAt.has(edge.to)) {
420
+ const name = ambiguousAt.has(edge.from) ? edge.from : edge.to
421
+ refused.push(where + '`' + name + '` is the name of more than one block; an edge cannot say which boundary it means')
422
+ return
423
+ }
424
+ if (fromBoundary !== undefined && toBoundary !== undefined) {
425
+ refused.push(where + '`' + boundaryLabel(fromBoundary) + '` and `' + boundaryLabel(toBoundary) + '` are boundaries, not elements; an edge between two boundaries is not one this renderer draws')
426
+ return
427
+ }
428
+ const boundary = fromBoundary ?? toBoundary
429
+ if (boundary !== undefined) {
430
+ const group = groupById.get(boundary) as DiagramGroup
431
+ if (group.parent !== undefined) {
432
+ refused.push(where + '`' + group.label + '` is a block inside `' + boundaryLabel(group.parent) + '`, not an element; an edge to a nested boundary would have to enter another boundary')
433
+ return
434
+ }
435
+ if (within(builder.edgeScopes[index], boundary)) {
436
+ refused.push(where + '`' + group.label + '` is the block this edge is written inside, not an element; an edge cannot leave a boundary and arrive at the same boundary')
437
+ return
438
+ }
439
+ if (!hasContents(boundary)) {
440
+ refused.push(where + '`' + group.label + '` is a block with nothing drawn inside it, so there is no rectangle to attach the edge to')
441
+ return
442
+ }
443
+ }
444
+ keptEdges.push({
445
+ ...edge,
446
+ ...(fromBoundary === undefined ? {} : { from: fromBoundary }),
447
+ ...(toBoundary === undefined ? {} : { to: toBoundary }),
448
+ })
449
+ })
450
+ builder.edges = keptEdges
451
+
452
+ if (!seenAny || builder.nodes.size === 0) {
453
+ return { diagram: emptyDiagram(title, 'no nodes found'), refused }
454
+ }
455
+
456
+ const diagram: Diagram = {
457
+ kind: startId === undefined ? 'graph' : 'machine',
458
+ title,
459
+ ...(startId === undefined ? {} : { init: startId }),
460
+ nodes: [...builder.nodes.values()],
461
+ edges: builder.edges,
462
+ ...(builder.groups.length === 0 ? {} : { groups: builder.groups }),
463
+ caption:
464
+ 'plantuml, ' + String(builder.nodes.size) + ' box' + (builder.nodes.size === 1 ? '' : 'es') + ', ' +
465
+ String(builder.edges.length) + ' arrow' + (builder.edges.length === 1 ? '' : 's') +
466
+ (refused.length === 0 ? '' : ', ' + String(refused.length) + ' line(s) refused'),
467
+ }
468
+ return { diagram, refused }
469
+ }