@formicoidea/labre-framework-uml 0.42.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/LICENSE +384 -0
- package/dist/actions.d.ts +430 -0
- package/dist/actions.js +724 -0
- package/dist/background.d.ts +153 -0
- package/dist/background.js +557 -0
- package/dist/board-hit.d.ts +137 -0
- package/dist/board-hit.js +191 -0
- package/dist/commands-manifest.d.ts +19 -0
- package/dist/commands-manifest.js +571 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +983 -0
- package/dist/component.d.ts +521 -0
- package/dist/component.js +708 -0
- package/dist/consts.d.ts +365 -0
- package/dist/consts.js +463 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/drawio-decode.d.ts +32 -0
- package/dist/drawio-decode.js +96 -0
- package/dist/drawio-import.d.ts +133 -0
- package/dist/drawio-import.js +885 -0
- package/dist/edge-morph.d.ts +154 -0
- package/dist/edge-morph.js +153 -0
- package/dist/edge-styles.d.ts +78 -0
- package/dist/edge-styles.js +210 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +64 -0
- package/dist/element-renderer.js +114 -0
- package/dist/element-view.d.ts +234 -0
- package/dist/element-view.js +430 -0
- package/dist/export.d.ts +61 -0
- package/dist/export.js +41 -0
- package/dist/filename.d.ts +17 -0
- package/dist/filename.js +26 -0
- package/dist/frame-tag.d.ts +70 -0
- package/dist/frame-tag.js +78 -0
- package/dist/grammar.d.ts +695 -0
- package/dist/grammar.js +1436 -0
- package/dist/import.d.ts +306 -0
- package/dist/import.js +1270 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +67 -0
- package/dist/interchange.d.ts +125 -0
- package/dist/interchange.js +372 -0
- package/dist/keywords.d.ts +204 -0
- package/dist/keywords.js +361 -0
- package/dist/kinds.d.ts +73 -0
- package/dist/kinds.js +91 -0
- package/dist/legend.d.ts +30 -0
- package/dist/legend.js +255 -0
- package/dist/model.d.ts +862 -0
- package/dist/model.js +1316 -0
- package/dist/morph.d.ts +178 -0
- package/dist/morph.js +496 -0
- package/dist/node/act-glyphs.d.ts +30 -0
- package/dist/node/act-glyphs.js +142 -0
- package/dist/node/compartment-watcher.d.ts +107 -0
- package/dist/node/compartment-watcher.js +163 -0
- package/dist/node/fit.d.ts +85 -0
- package/dist/node/fit.js +129 -0
- package/dist/node/node-renderer.d.ts +49 -0
- package/dist/node/node-renderer.js +611 -0
- package/dist/node/node-view.d.ts +51 -0
- package/dist/node/node-view.js +89 -0
- package/dist/node/paint.d.ts +84 -0
- package/dist/node/paint.js +143 -0
- package/dist/node/sd-glyphs.d.ts +36 -0
- package/dist/node/sd-glyphs.js +79 -0
- package/dist/node/stm-glyphs.d.ts +22 -0
- package/dist/node/stm-glyphs.js +111 -0
- package/dist/node/tier-metrics.d.ts +38 -0
- package/dist/node/tier-metrics.js +45 -0
- package/dist/operators.d.ts +45 -0
- package/dist/operators.js +75 -0
- package/dist/plantuml-import.d.ts +113 -0
- package/dist/plantuml-import.js +1723 -0
- package/dist/plantuml.d.ts +94 -0
- package/dist/plantuml.js +985 -0
- package/dist/presets.d.ts +149 -0
- package/dist/presets.js +302 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +358 -0
- package/dist/reading.d.ts +160 -0
- package/dist/reading.js +648 -0
- package/dist/roles.d.ts +257 -0
- package/dist/roles.js +997 -0
- package/dist/rules.d.ts +267 -0
- package/dist/rules.js +3090 -0
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +197 -0
- package/dist/toolbar/config.d.ts +228 -0
- package/dist/toolbar/config.js +833 -0
- package/dist/toolbar/icons.d.ts +440 -0
- package/dist/toolbar/icons.js +711 -0
- package/dist/toolbar/senior-tool.d.ts +11 -0
- package/dist/toolbar/senior-tool.js +21 -0
- package/dist/toolbar/uml-menu.d.ts +17 -0
- package/dist/toolbar/uml-menu.js +20 -0
- package/dist/toolbar/uml-senior-button.d.ts +18 -0
- package/dist/toolbar/uml-senior-button.js +22 -0
- package/dist/translations.d.ts +27 -0
- package/dist/translations.js +117 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +236 -0
- package/dist/xmi-import.d.ts +174 -0
- package/dist/xmi-import.js +2268 -0
- package/dist/xmi.d.ts +61 -0
- package/dist/xmi.js +1860 -0
- package/dist/xml-reader.d.ts +157 -0
- package/dist/xml-reader.js +430 -0
- package/dist/xml.d.ts +72 -0
- package/dist/xml.js +96 -0
- package/package.json +38 -0
package/dist/plantuml.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import { formatActivityEdgeLabel, formatEndLabel, formatTransitionLabel, parseCompartment, } from './grammar.js';
|
|
2
|
+
import { umlCentreInside, umlInteractionTimeline, } from './model.js';
|
|
3
|
+
/**
|
|
4
|
+
* One diagram as **PlantUML** source (https://plantuml.com/class-diagram).
|
|
5
|
+
*
|
|
6
|
+
* ## Why PlantUML beside XMI
|
|
7
|
+
*
|
|
8
|
+
* They answer different questions. XMI is what a MODELLING TOOL reads — it
|
|
9
|
+
* carries the metamodel, and no human opens it. PlantUML is what a repository
|
|
10
|
+
* reads: it goes in a `docs/` folder beside the code, it diffs line by line in a
|
|
11
|
+
* review, and the architect who wrote the diagram can still read it a year
|
|
12
|
+
* later. A framework that only exported XMI would be a framework whose diagrams
|
|
13
|
+
* cannot live in version control.
|
|
14
|
+
*
|
|
15
|
+
* ## What it writes, and what it declines to
|
|
16
|
+
*
|
|
17
|
+
* No `skinparam`, not one line. A `skinparam` is a THEME, and a theme baked into
|
|
18
|
+
* an exported file overrides whatever the repository it lands in has decided
|
|
19
|
+
* about how its diagrams look — silently, and in every diagram, forever. Labre's
|
|
20
|
+
* canvas colours are Labre's; the file carries the model.
|
|
21
|
+
*
|
|
22
|
+
* Member lines are written back VERBATIM, and that is a decision rather than
|
|
23
|
+
* laziness: PlantUML's member syntax IS UML's own §9.5.4 / §9.6.4 notation, the
|
|
24
|
+
* same grammar the author typed in the compartment. Re-spelling a parsed
|
|
25
|
+
* property could only lose what the parser did not model — a constraint, an
|
|
26
|
+
* unusual modifier, a qualified redefinition — so the parser is used where
|
|
27
|
+
* PlantUML needs STRUCTURE it cannot infer (an enumeration's literals, an
|
|
28
|
+
* object's slots) and nowhere else.
|
|
29
|
+
*
|
|
30
|
+
* ## The one thing it drops, said out loud
|
|
31
|
+
*
|
|
32
|
+
* A PORT. §11.3.4's small square has a place in the XMI file — it is an
|
|
33
|
+
* `ownedAttribute` of its component, with an id other elements can point at —
|
|
34
|
+
* and in a `.puml` it would have to be a `port` declaration inside a
|
|
35
|
+
* `component … { }` block, which is a recent addition to the language and not
|
|
36
|
+
* one every renderer in a repository's toolchain has. What the port CARRIES is
|
|
37
|
+
* not lost: the lollipops and sockets drawn on it are attributed to its
|
|
38
|
+
* component by `model.ts` and written below, so the picture still says which
|
|
39
|
+
* contracts the component offers and needs. Only the square itself goes, and
|
|
40
|
+
* the day the `port` syntax is safe to assume it is four lines here and a
|
|
41
|
+
* golden.
|
|
42
|
+
*
|
|
43
|
+
* ## Pure
|
|
44
|
+
*
|
|
45
|
+
* A model in, a string out. No `std`, no DOM, no clock — `docs/adr/0012` P3.
|
|
46
|
+
*/
|
|
47
|
+
/* ── Identifiers ──────────────────────────────────────────────────────── */
|
|
48
|
+
/**
|
|
49
|
+
* An alias — the identifier every declaration takes and every relation refers
|
|
50
|
+
* back to.
|
|
51
|
+
*
|
|
52
|
+
* Derived from the NAME rather than from the surface id, because an alias is
|
|
53
|
+
* READ: `customer --> order` says what it relates and `_x7fQa --> _9bTz1` says
|
|
54
|
+
* nothing at all to the person who opens the file. The folding is deliberately
|
|
55
|
+
* lossy — ASCII only, since PlantUML's bare identifier is a word and a unicode
|
|
56
|
+
* one needs quoting everywhere — and the NAME is preserved separately, in the
|
|
57
|
+
* quoted label every declaration carries.
|
|
58
|
+
*/
|
|
59
|
+
export function toPlantumlAlias(raw) {
|
|
60
|
+
let out = '';
|
|
61
|
+
for (const char of raw.toLowerCase()) {
|
|
62
|
+
out += /[a-z0-9_]/.test(char) ? char : '_';
|
|
63
|
+
}
|
|
64
|
+
out = out.replaceAll(/_{2,}/g, '_').replace(/^_+/, '').replace(/_+$/, '');
|
|
65
|
+
if (out.length === 0)
|
|
66
|
+
return 'e';
|
|
67
|
+
// An identifier may not open on a digit, and `e_` — for "element" — rather
|
|
68
|
+
// than a bare `_`, so two names one digit apart stay one character apart.
|
|
69
|
+
return /^\d/.test(out) ? `e_${out}` : out;
|
|
70
|
+
}
|
|
71
|
+
/** Mints document-unique aliases, and remembers what it minted. */
|
|
72
|
+
class AliasMinter {
|
|
73
|
+
#taken = new Set();
|
|
74
|
+
mint(name) {
|
|
75
|
+
const base = toPlantumlAlias(name);
|
|
76
|
+
if (!this.#taken.has(base)) {
|
|
77
|
+
this.#taken.add(base);
|
|
78
|
+
return base;
|
|
79
|
+
}
|
|
80
|
+
let n = 2;
|
|
81
|
+
while (this.#taken.has(`${base}_${n}`))
|
|
82
|
+
n++;
|
|
83
|
+
const unique = `${base}_${n}`;
|
|
84
|
+
this.#taken.add(unique);
|
|
85
|
+
return unique;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/* ── Text ─────────────────────────────────────────────────────────────── */
|
|
89
|
+
/**
|
|
90
|
+
* A label, as something PlantUML can carry inside `"…"`.
|
|
91
|
+
*
|
|
92
|
+
* Two transformations, and both are a thing the grammar cannot express:
|
|
93
|
+
*
|
|
94
|
+
* - **a line break becomes `\n`**, PlantUML's own escape. A declaration is one
|
|
95
|
+
* line and a canvas label routinely is not — it is how a class name fits in
|
|
96
|
+
* its box — so a raw newline would end the statement halfway through;
|
|
97
|
+
* - **`"` becomes `'`**, because PlantUML has no escape for a double quote
|
|
98
|
+
* inside a quoted label: the first one ENDS it, and the rest of the line is
|
|
99
|
+
* read as syntax.
|
|
100
|
+
*
|
|
101
|
+
* Nothing else is touched. Accents, CJK and emoji go through unharmed: they are
|
|
102
|
+
* text, PlantUML carries text, and folding them would be this exporter
|
|
103
|
+
* rewriting somebody's diagram.
|
|
104
|
+
*/
|
|
105
|
+
export function toPlantumlLabel(raw) {
|
|
106
|
+
return raw
|
|
107
|
+
.replaceAll('\r\n', '\n')
|
|
108
|
+
.replaceAll('"', "'")
|
|
109
|
+
.replaceAll('\n', '\\n')
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
/** What an unnamed box is called — see `c4/export.ts`'s `UNNAMED`. */
|
|
113
|
+
const UNNAMED = '?';
|
|
114
|
+
/** `<<entity>>` — the stereotype spelling PlantUML parses. */
|
|
115
|
+
function stereotypes(keywords) {
|
|
116
|
+
return keywords.map(keyword => ` <<${toPlantumlLabel(keyword)}>>`).join('');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The keywords PlantUML's own declaration word already states.
|
|
120
|
+
*
|
|
121
|
+
* `interface Payable` needs no `<<interface>>` after it: the word is the
|
|
122
|
+
* statement. Repeating it would draw the keyword twice on the rendered picture.
|
|
123
|
+
*/
|
|
124
|
+
const DECLARED_KEYWORDS = new Set([
|
|
125
|
+
'interface',
|
|
126
|
+
'enumeration',
|
|
127
|
+
'enum',
|
|
128
|
+
'class',
|
|
129
|
+
]);
|
|
130
|
+
/**
|
|
131
|
+
* The same, for the structural declarations — plus the two §19.4.4 writes as a
|
|
132
|
+
* stereotype rather than as a word of its own.
|
|
133
|
+
*
|
|
134
|
+
* `component Cart` needs no `<<component>>` and `artifact "cart.jar"` no
|
|
135
|
+
* `<<artifact>>`: the declaration word is the statement, and the stencil seeds
|
|
136
|
+
* the keyword into the name tier (`keywords.ts`) precisely so the CANVAS draws
|
|
137
|
+
* it. `device` and `executionEnvironment` are here because this writer emits
|
|
138
|
+
* them from the node's KIND — see {@link deploymentStereotype} — so leaving them
|
|
139
|
+
* in the keyword list would draw the word twice on the rendered picture.
|
|
140
|
+
*/
|
|
141
|
+
const STRUCTURAL_KEYWORDS = new Set([
|
|
142
|
+
'component',
|
|
143
|
+
'artifact',
|
|
144
|
+
'node',
|
|
145
|
+
'device',
|
|
146
|
+
'executionenvironment',
|
|
147
|
+
'execution environment',
|
|
148
|
+
]);
|
|
149
|
+
/**
|
|
150
|
+
* What a cube is declared as — always `node`, with the keyword as a stereotype.
|
|
151
|
+
*
|
|
152
|
+
* PlantUML's component-diagram vocabulary has `node` and has no `device`, so a
|
|
153
|
+
* writer that took §19.4.4's words literally would emit a line no renderer
|
|
154
|
+
* parses. It does not need to: §19.4.4 itself says a Device is drawn as "a Node
|
|
155
|
+
* graphic with the keyword «device»" and an ExecutionEnvironment as "a Node
|
|
156
|
+
* annotated with the keyword «executionEnvironment»", which is a `node`
|
|
157
|
+
* declaration carrying a stereotype — the same picture, in the format's own
|
|
158
|
+
* grammar, with nothing invented and nothing lost.
|
|
159
|
+
*/
|
|
160
|
+
function deploymentStereotype(kind) {
|
|
161
|
+
return kind === 'node'
|
|
162
|
+
? ''
|
|
163
|
+
: ` <<${kind === 'device' ? 'device' : 'executionEnvironment'}>>`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* A compartment line, guarded for the one character that could end a block.
|
|
167
|
+
*
|
|
168
|
+
* PlantUML closes a member block on a line that is exactly `}`. A compartment
|
|
169
|
+
* holding one is not a member anybody meant to write, and letting it through
|
|
170
|
+
* would unbalance the document from that point on.
|
|
171
|
+
*/
|
|
172
|
+
function memberLines(lines) {
|
|
173
|
+
return lines.filter(line => line !== '}' && line !== '{');
|
|
174
|
+
}
|
|
175
|
+
/* ── Declarations ─────────────────────────────────────────────────────── */
|
|
176
|
+
/** The PlantUML word each classifier kind is declared with. */
|
|
177
|
+
const DECLARATION = {
|
|
178
|
+
class: 'class',
|
|
179
|
+
interface: 'interface',
|
|
180
|
+
enumeration: 'enum',
|
|
181
|
+
object: 'object',
|
|
182
|
+
};
|
|
183
|
+
/* ── Relations ────────────────────────────────────────────────────────── */
|
|
184
|
+
/**
|
|
185
|
+
* One end of an arrow, with whatever §11.5.4 wrote beside it — `A "0..* items"`.
|
|
186
|
+
*
|
|
187
|
+
* PlantUML's slot for an end adornment is a QUOTED STRING between the artefact
|
|
188
|
+
* and the arrow, on the side the adornment belongs to: `A "1" -- "0..*" B`. The
|
|
189
|
+
* quotes are the format's own delimiter, which is why the text goes through
|
|
190
|
+
* {@link toPlantumlLabel} first — it turns a `"` an author typed into a `'` and
|
|
191
|
+
* flattens newlines, so an end label can never close the string early and
|
|
192
|
+
* unbalance the line.
|
|
193
|
+
*
|
|
194
|
+
* Only the four relationships §11.5.4 governs reach this function with an end
|
|
195
|
+
* filled in ({@link ADORNED_RELATION_KINDS}); everything else passes `undefined`
|
|
196
|
+
* and prints as the bare alias it always did.
|
|
197
|
+
*/
|
|
198
|
+
function adorned(alias, end, leading) {
|
|
199
|
+
const text = end ? toPlantumlLabel(formatEndLabel(end)) : '';
|
|
200
|
+
if (!text)
|
|
201
|
+
return alias;
|
|
202
|
+
return leading ? `${alias} "${text}"` : `"${text}" ${alias}`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* One relationship, as the arrow PlantUML draws it with.
|
|
206
|
+
*
|
|
207
|
+
* The two INHERITANCE arrows read right to left — `A <|-- B` is "B is an A" —
|
|
208
|
+
* so the specific classifier, which the role table makes the SOURCE, is written
|
|
209
|
+
* on the right. Getting this backwards would render every hierarchy upside down,
|
|
210
|
+
* which is why it is stated here once rather than inlined at two call sites.
|
|
211
|
+
*/
|
|
212
|
+
function relationLine(relation, source, target) {
|
|
213
|
+
const label = relation.label ? ` : ${toPlantumlLabel(relation.label)}` : '';
|
|
214
|
+
// §11.5.4's adornments, in PlantUML's own slot for them: a quoted string
|
|
215
|
+
// between an artefact and the arrow is the label of THAT end, which is the
|
|
216
|
+
// same identity `xmi.ts` writes with — what is drawn beside an end adorns the
|
|
217
|
+
// end at that classifier.
|
|
218
|
+
const from = adorned(source, relation.sourceEnd, true);
|
|
219
|
+
const to = adorned(target, relation.targetEnd, false);
|
|
220
|
+
switch (relation.kind) {
|
|
221
|
+
case 'association':
|
|
222
|
+
return `${from} -- ${to}${label}`;
|
|
223
|
+
case 'aggregation':
|
|
224
|
+
// The diamond is drawn at the LEFT end, and the source is the whole.
|
|
225
|
+
return `${from} o-- ${to}${label}`;
|
|
226
|
+
case 'composition':
|
|
227
|
+
return `${from} *-- ${to}${label}`;
|
|
228
|
+
case 'generalization':
|
|
229
|
+
return `${target} <|-- ${source}${label}`;
|
|
230
|
+
case 'realization':
|
|
231
|
+
return `${target} <|.. ${source}${label}`;
|
|
232
|
+
case 'dependency':
|
|
233
|
+
return `${source} ..> ${target}${label}`;
|
|
234
|
+
case 'anchor':
|
|
235
|
+
// A plain dashed line with no head: an anchor attaches a note, it does
|
|
236
|
+
// not point anywhere.
|
|
237
|
+
return `${source} .. ${target}`;
|
|
238
|
+
case 'include':
|
|
239
|
+
return `${source} ..> ${target} : <<include>>`;
|
|
240
|
+
case 'extend':
|
|
241
|
+
return `${source} ..> ${target} : <<extend>>`;
|
|
242
|
+
case 'deploy':
|
|
243
|
+
// §19.4.4's own alternative to nesting the artefact inside the cube: "a
|
|
244
|
+
// dashed arrow with the keyword «deploy»", from the artefact to the node.
|
|
245
|
+
return `${source} ..> ${target} : <<deploy>>`;
|
|
246
|
+
case 'manifest':
|
|
247
|
+
// §19.3.4: notated as an Abstraction — a dashed line with an open
|
|
248
|
+
// arrow-head — labelled with the keyword.
|
|
249
|
+
return `${source} ..> ${target} : <<manifest>>`;
|
|
250
|
+
case 'communication-path':
|
|
251
|
+
// §19.4.4: "depicted using the same as normal Association links", and an
|
|
252
|
+
// association is undirected, so the line carries no head either way.
|
|
253
|
+
return `${from} -- ${to}${label}`;
|
|
254
|
+
case 'message-sync':
|
|
255
|
+
case 'message-async':
|
|
256
|
+
case 'message-reply':
|
|
257
|
+
case 'message-create':
|
|
258
|
+
case 'message-delete':
|
|
259
|
+
// Written by the INTERACTION section, for the reason the behaviour edges
|
|
260
|
+
// below are written by theirs — and one more besides: a message's place
|
|
261
|
+
// in the document is its place in TIME (§17.4.4), so it cannot be emitted
|
|
262
|
+
// from a list of relations at all. See {@link sequenceDocument}.
|
|
263
|
+
return '';
|
|
264
|
+
case 'control-flow':
|
|
265
|
+
case 'object-flow':
|
|
266
|
+
case 'transition':
|
|
267
|
+
// Written by the ACTIVITY and the STATE MACHINE sections of the document
|
|
268
|
+
// rather than here: both are drawn in PlantUML's state-diagram syntax, and
|
|
269
|
+
// a behaviour arrow's label carries a guard, a weight or a trigger list
|
|
270
|
+
// that this function has no parsed form of. The empty string is filtered
|
|
271
|
+
// out by the caller, exactly as an unresolved end already is.
|
|
272
|
+
return '';
|
|
273
|
+
default: {
|
|
274
|
+
// Exhaustive: a relationship added to the union with no arrow to draw it
|
|
275
|
+
// fails the build here rather than vanishing from a file.
|
|
276
|
+
const never = relation.kind;
|
|
277
|
+
throw new Error(`unhandled UML relation: ${String(never)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/* ── Behaviour: the two flow sheets (§15.2.4, §14.2.4) ────────────────── */
|
|
282
|
+
/**
|
|
283
|
+
* PlantUML's own marker for a beginning and an end — never an alias.
|
|
284
|
+
*
|
|
285
|
+
* `[*]` is CONTEXTUAL in PlantUML: read as the source of an arrow it draws the
|
|
286
|
+
* filled disc, read as the target it draws the bullseye. That is exactly what
|
|
287
|
+
* §15.3.4 and §14.2.4 mean by the two glyphs, and it is why an initial node and
|
|
288
|
+
* a final node are never declared here — they have no alias to declare, and
|
|
289
|
+
* `[*] --> Draft` says the whole of it.
|
|
290
|
+
*/
|
|
291
|
+
const PLANTUML_TERMINAL = '[*]';
|
|
292
|
+
/**
|
|
293
|
+
* Which PlantUML state stereotype draws each behaviour glyph — and, where it
|
|
294
|
+
* draws none, which word is kept instead.
|
|
295
|
+
*
|
|
296
|
+
* ## Why BOTH sheets are written in the state-diagram syntax
|
|
297
|
+
*
|
|
298
|
+
* The decision this file makes for the behaviour half, stated once and cited
|
|
299
|
+
* wherever it shows:
|
|
300
|
+
*
|
|
301
|
+
* - PlantUML's **classic activity syntax** (`(*) --> "Action"`) is deprecated
|
|
302
|
+
* upstream and no longer rendered by current versions. A file nobody can
|
|
303
|
+
* render is worth less than one drawn slightly plainer — the same call the
|
|
304
|
+
* lollipop operators got, a hundred lines up.
|
|
305
|
+
* - PlantUML's **new activity syntax** (`start`, `:Action;`,
|
|
306
|
+
* `if (c) then … else … endif`, `fork` / `fork again` / `end fork`, `stop`)
|
|
307
|
+
* is a STRUCTURED BLOCK language. It can express a tree of nested blocks and
|
|
308
|
+
* nothing else, while a whiteboard draws arbitrary directed graphs: an arrow
|
|
309
|
+
* back three steps, two branches rejoining somewhere neither block contains,
|
|
310
|
+
* a decision whose arms end in different final nodes. A writer using it would
|
|
311
|
+
* have to reshape or refuse most real drawings, and reshaping a drawing is
|
|
312
|
+
* the one thing an exporter must never do.
|
|
313
|
+
* - The **state-diagram syntax** IS a general directed graph — `A --> B : …`,
|
|
314
|
+
* `[*] --> A`, `state X <<choice>>`, `state C { … }` — so every graph this
|
|
315
|
+
* canvas can draw comes out whole, and it is the NATIVE syntax for half the
|
|
316
|
+
* job anyway.
|
|
317
|
+
*
|
|
318
|
+
* So an activity sheet is written in a notation one step away from its own, and
|
|
319
|
+
* the document says so in a comment line rather than leaving a reader to work it
|
|
320
|
+
* out. What is lost is shape, never structure: every node, every arrow, every
|
|
321
|
+
* guard and every lane is in the file.
|
|
322
|
+
*
|
|
323
|
+
* ## What each glyph becomes
|
|
324
|
+
*
|
|
325
|
+
* The stereotypes PlantUML actually implements are used where one fits —
|
|
326
|
+
* `<<choice>>` for a diamond, `<<fork>>` for a bar, `<<history>>` and
|
|
327
|
+
* `<<deepHistory>>` for the two H circles, `<<entryPoint>>` / `<<exitPoint>>`
|
|
328
|
+
* for the connection points, `<<end>>` for a stop. Everything else takes a
|
|
329
|
+
* stereotype PlantUML does not know, which it renders as the word itself under
|
|
330
|
+
* the name: an object node reads «objectNode», a signal reads «signal». That is
|
|
331
|
+
* strictly better than borrowing a shape that means something else, because the
|
|
332
|
+
* reader is told what the glyph was instead of being shown the wrong one.
|
|
333
|
+
*
|
|
334
|
+
* Two conflations are worth naming because a reader will notice them: a JUNCTION
|
|
335
|
+
* borrows the choice diamond (§14.2.4.6 draws a small filled circle, PlantUML
|
|
336
|
+
* has no such glyph, and both are a branch) and a TERMINATE borrows `<<end>>`
|
|
337
|
+
* (§14.2.4.6 draws a cross, PlantUML has no cross, and both stop the machine).
|
|
338
|
+
*/
|
|
339
|
+
const PLANTUML_STATE_STEREOTYPE = {
|
|
340
|
+
// The activity glyphs.
|
|
341
|
+
'flow-final': 'end',
|
|
342
|
+
decision: 'choice',
|
|
343
|
+
fork: 'fork',
|
|
344
|
+
'object-node': 'objectNode',
|
|
345
|
+
'send-signal': 'signal',
|
|
346
|
+
'accept-event': 'accept',
|
|
347
|
+
'time-event': 'time',
|
|
348
|
+
// The pseudostates.
|
|
349
|
+
choice: 'choice',
|
|
350
|
+
junction: 'choice',
|
|
351
|
+
'shallow-history': 'history',
|
|
352
|
+
'deep-history': 'deepHistory',
|
|
353
|
+
'entry-point': 'entryPoint',
|
|
354
|
+
'exit-point': 'exitPoint',
|
|
355
|
+
terminate: 'end',
|
|
356
|
+
};
|
|
357
|
+
/** `A --> B : label`, with the label left off when there is nothing to say. */
|
|
358
|
+
function arrowLine(source, target, label) {
|
|
359
|
+
const written = label.trim();
|
|
360
|
+
return written
|
|
361
|
+
? `${source} --> ${target} : ${toPlantumlLabel(written)}`
|
|
362
|
+
: `${source} --> ${target}`;
|
|
363
|
+
}
|
|
364
|
+
/*
|
|
365
|
+
* §15.2.4's and §14.2.4.8's labels — `name [guard] {weight = w}` and
|
|
366
|
+
* `trigger [guard] / effect` — are re-spelled in the notation's own syntax
|
|
367
|
+
* rather than printed as records, because a PlantUML label is read by a HUMAN
|
|
368
|
+
* looking at the picture: the brackets, the braces and the slash are what tell
|
|
369
|
+
* them which part is which, and they are the same delimiters the author typed on
|
|
370
|
+
* the canvas.
|
|
371
|
+
*
|
|
372
|
+
* Both printers live in `grammar.ts`, beside the parsers they invert:
|
|
373
|
+
* `import.ts` writes the same words into an imported connector's centre label,
|
|
374
|
+
* and one grammar spelled in two files is how a guard comes back without its
|
|
375
|
+
* brackets.
|
|
376
|
+
*/
|
|
377
|
+
/* ── Sequence: the arrows, the words and the blocks (§17) ─────────────── */
|
|
378
|
+
/**
|
|
379
|
+
* §17.4.4's five arrows, in PlantUML's own spelling.
|
|
380
|
+
*
|
|
381
|
+
* The specification tells the first three apart BY THE DRAWING and PlantUML
|
|
382
|
+
* draws exactly those three: a filled head for a synchronous call, an open one
|
|
383
|
+
* for an asynchronous send, a dashed line for a reply. The other two share a
|
|
384
|
+
* drawing with the synchronous call and are told apart by what the line REACHES
|
|
385
|
+
* — a `create` lands on a participant's head, a `delete` on a cross — so each is
|
|
386
|
+
* written as the plain arrow plus the one keyword PlantUML has for that fact
|
|
387
|
+
* (`create` before it, `destroy` after it). See {@link sequenceLines}.
|
|
388
|
+
*/
|
|
389
|
+
const PLANTUML_MESSAGE_ARROW = {
|
|
390
|
+
'message-sync': '->',
|
|
391
|
+
'message-async': '->>',
|
|
392
|
+
'message-reply': '-->',
|
|
393
|
+
'message-create': '->',
|
|
394
|
+
'message-delete': '->',
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* §17.6.4's interaction operators, as the block PlantUML opens for each.
|
|
398
|
+
*
|
|
399
|
+
* Six of the twelve are keywords of the language; the rest are written as
|
|
400
|
+
* `group <operator>`, which is PlantUML's own generic block and renders the word
|
|
401
|
+
* in exactly the pentagon §17.6.4 asks for. The alternative — dropping them —
|
|
402
|
+
* would export a `neg` as a plain `alt` and say the opposite of what the author
|
|
403
|
+
* drew.
|
|
404
|
+
*
|
|
405
|
+
* `ref` is not a block at all (§17.7.4 is a one-line reference, not an operator)
|
|
406
|
+
* and is written by {@link sequenceLines} as `ref over …`.
|
|
407
|
+
*/
|
|
408
|
+
const PLANTUML_FRAGMENT_BLOCK = {
|
|
409
|
+
alt: 'alt',
|
|
410
|
+
opt: 'opt',
|
|
411
|
+
loop: 'loop',
|
|
412
|
+
par: 'par',
|
|
413
|
+
break: 'break',
|
|
414
|
+
critical: 'critical',
|
|
415
|
+
seq: 'group seq',
|
|
416
|
+
strict: 'group strict',
|
|
417
|
+
neg: 'group neg',
|
|
418
|
+
assert: 'group assert',
|
|
419
|
+
ignore: 'group ignore',
|
|
420
|
+
consider: 'group consider',
|
|
421
|
+
ref: 'ref',
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* Is this participant drawn as a STICK FIGURE?
|
|
425
|
+
*
|
|
426
|
+
* §17.3.4 draws every lifeline head as a rectangle and says nothing about
|
|
427
|
+
* actors; every sequence diagram in the wild draws the human at the left as one
|
|
428
|
+
* all the same, and PlantUML has a word for it. So the question is asked of the
|
|
429
|
+
* author's own statement and of nothing else: the classifier the lifeline is an
|
|
430
|
+
* instance of is called `Actor`, or the head carries the Annex C keyword. A
|
|
431
|
+
* participant is what falls through, which is what §17.3.4 draws.
|
|
432
|
+
*/
|
|
433
|
+
function isActorLifeline(lifeline) {
|
|
434
|
+
if ((lifeline.type ?? '').trim().toLowerCase() === 'actor')
|
|
435
|
+
return true;
|
|
436
|
+
return lifeline.keywords.some(keyword => keyword.trim().toLowerCase() === 'actor');
|
|
437
|
+
}
|
|
438
|
+
/** `[guard]` — §17.6.4's InteractionConstraint, with the brackets it is drawn in. */
|
|
439
|
+
function guardSuffix(guard) {
|
|
440
|
+
const written = toPlantumlLabel(guard ?? '');
|
|
441
|
+
return written ? ` [${written}]` : '';
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* One Interaction as the lines of a PlantUML sequence diagram — the
|
|
445
|
+
* participants, then the conversation.
|
|
446
|
+
*
|
|
447
|
+
* The conversation is walked off {@link umlInteractionTimeline}, which is what
|
|
448
|
+
* makes the nesting come out right: an `alt` is `alt … else … end` round exactly
|
|
449
|
+
* the entries drawn in its bands, and the tree is built once, in `model.ts`, so
|
|
450
|
+
* the XMI writer's `InteractionOperand` nesting and this one's keywords cannot
|
|
451
|
+
* disagree about which branch a message is in.
|
|
452
|
+
*/
|
|
453
|
+
function sequenceLines(interaction, minter, aliasOf) {
|
|
454
|
+
const declarations = [];
|
|
455
|
+
/** The lifeline an end of a message is on — a spine, or a bar's spine. */
|
|
456
|
+
const spineOf = new Map();
|
|
457
|
+
for (const lifeline of interaction.lifelines) {
|
|
458
|
+
spineOf.set(lifeline.id, lifeline.id);
|
|
459
|
+
}
|
|
460
|
+
for (const execution of interaction.executions) {
|
|
461
|
+
if (execution.lifelineId)
|
|
462
|
+
spineOf.set(execution.id, execution.lifelineId);
|
|
463
|
+
}
|
|
464
|
+
for (const destruction of interaction.destructions) {
|
|
465
|
+
if (destruction.lifelineId) {
|
|
466
|
+
spineOf.set(destruction.id, destruction.lifelineId);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const lifeline of interaction.lifelines) {
|
|
470
|
+
const alias = minter.mint(lifeline.name || lifeline.type || 'participant');
|
|
471
|
+
aliasOf.set(lifeline.id, alias);
|
|
472
|
+
// §17.3.4's `<name> : <Type>` written back whole: the head is one
|
|
473
|
+
// compartment, and splitting it out into two declarations is not something
|
|
474
|
+
// PlantUML has a slot for.
|
|
475
|
+
const stated = lifeline.type
|
|
476
|
+
? `${lifeline.name} : ${lifeline.type}`
|
|
477
|
+
: lifeline.name;
|
|
478
|
+
const word = isActorLifeline(lifeline) ? 'actor' : 'participant';
|
|
479
|
+
declarations.push(`${word} "${toPlantumlLabel(stated) || UNNAMED}" as ${alias}`);
|
|
480
|
+
}
|
|
481
|
+
/** The alias an end of a message resolves to, or nothing to point at. */
|
|
482
|
+
const endAlias = (id) => {
|
|
483
|
+
const spine = spineOf.get(id);
|
|
484
|
+
return spine ? aliasOf.get(spine) : undefined;
|
|
485
|
+
};
|
|
486
|
+
// A destruction a DELETE message already accounted for: §17.4.4 draws the
|
|
487
|
+
// cross as the end of that message, and PlantUML writes one `destroy` for the
|
|
488
|
+
// pair. Written twice, the picture would end the participant twice.
|
|
489
|
+
const crosses = new Set(interaction.destructions.map(each => each.id));
|
|
490
|
+
const consumed = new Set();
|
|
491
|
+
for (const message of interaction.messages) {
|
|
492
|
+
if (message.kind === 'message-delete' && crosses.has(message.targetId)) {
|
|
493
|
+
consumed.add(message.targetId);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const body = [];
|
|
497
|
+
const write = (entries, indent) => {
|
|
498
|
+
for (const entry of entries) {
|
|
499
|
+
switch (entry.at) {
|
|
500
|
+
case 'message': {
|
|
501
|
+
const { message } = entry;
|
|
502
|
+
const from = endAlias(message.sourceId);
|
|
503
|
+
const to = endAlias(message.targetId);
|
|
504
|
+
// An end this document has no participant for: there is nothing to
|
|
505
|
+
// draw between, exactly as an unresolved relation end is skipped.
|
|
506
|
+
if (!from || !to)
|
|
507
|
+
break;
|
|
508
|
+
const label = message.label
|
|
509
|
+
? ` : ${toPlantumlLabel(message.label)}`
|
|
510
|
+
: '';
|
|
511
|
+
// §17.4.4: a createMessage lands on the HEAD of a participant that
|
|
512
|
+
// does not exist yet, and `create` is PlantUML's word for that.
|
|
513
|
+
if (message.kind === 'message-create') {
|
|
514
|
+
body.push(`${indent}create ${to}`);
|
|
515
|
+
}
|
|
516
|
+
body.push(`${indent}${from} ${PLANTUML_MESSAGE_ARROW[message.kind]} ${to}${label}`);
|
|
517
|
+
if (message.kind === 'message-delete') {
|
|
518
|
+
body.push(`${indent}destroy ${to}`);
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'execution-start': {
|
|
523
|
+
const alias = endAlias(entry.execution.id);
|
|
524
|
+
if (alias)
|
|
525
|
+
body.push(`${indent}activate ${alias}`);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case 'execution-finish': {
|
|
529
|
+
const alias = endAlias(entry.execution.id);
|
|
530
|
+
if (alias)
|
|
531
|
+
body.push(`${indent}deactivate ${alias}`);
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case 'destruction': {
|
|
535
|
+
if (consumed.has(entry.destruction.id))
|
|
536
|
+
break;
|
|
537
|
+
const alias = endAlias(entry.destruction.id);
|
|
538
|
+
if (alias)
|
|
539
|
+
body.push(`${indent}destroy ${alias}`);
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
case 'fragment': {
|
|
543
|
+
const { fragment, operands } = entry;
|
|
544
|
+
if (fragment.operator === 'ref') {
|
|
545
|
+
// §17.7.4's InteractionUse: one line naming the interaction and the
|
|
546
|
+
// lifelines it runs over. It opens no block — an InteractionUse has
|
|
547
|
+
// no contents — so anything drawn over it is written after it, at
|
|
548
|
+
// the same level, rather than being lost.
|
|
549
|
+
const over = fragment.coveredLifelineIds
|
|
550
|
+
.map(id => aliasOf.get(id))
|
|
551
|
+
.filter((alias) => Boolean(alias));
|
|
552
|
+
const named = toPlantumlLabel(fragment.name);
|
|
553
|
+
if (over.length > 0) {
|
|
554
|
+
body.push(`${indent}ref over ${over.join(', ')}${named ? ` : ${named}` : ''}`);
|
|
555
|
+
}
|
|
556
|
+
for (const band of operands)
|
|
557
|
+
write(band.entries, indent);
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
const [first, ...rest] = operands;
|
|
561
|
+
body.push(`${indent}${PLANTUML_FRAGMENT_BLOCK[fragment.operator]}${guardSuffix(first?.operand.guard)}`);
|
|
562
|
+
write(first?.entries ?? [], `${indent} `);
|
|
563
|
+
for (const band of rest) {
|
|
564
|
+
body.push(`${indent}else${guardSuffix(band.operand.guard)}`);
|
|
565
|
+
write(band.entries, `${indent} `);
|
|
566
|
+
}
|
|
567
|
+
body.push(`${indent}end`);
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
write(umlInteractionTimeline(interaction), '');
|
|
574
|
+
return { declarations, body };
|
|
575
|
+
}
|
|
576
|
+
/* ── The document ─────────────────────────────────────────────────────── */
|
|
577
|
+
/**
|
|
578
|
+
* One diagram, as one complete PlantUML document.
|
|
579
|
+
*
|
|
580
|
+
* Elements first, nested in the packages and subjects they are drawn inside,
|
|
581
|
+
* then every relationship — PlantUML resolves an arrow by alias, and an element
|
|
582
|
+
* declared after the line referring to it renders as a bare box with none of its
|
|
583
|
+
* members.
|
|
584
|
+
*
|
|
585
|
+
* Containment is geometric, exactly as it is in `xmi.ts` and for the same
|
|
586
|
+
* reason: on a UML diagram, a class drawn inside a package IS in that package
|
|
587
|
+
* (§12.2.4), and a use case inside the subject rectangle IS the subject's
|
|
588
|
+
* (§18.1.4). Layout is the statement here, which is the one place this exporter
|
|
589
|
+
* reads geometry at all.
|
|
590
|
+
*
|
|
591
|
+
* The `title` is the diagram's own HEADING — `class Orders`, Annex A's frame tag
|
|
592
|
+
* — and there is no option to override it: the document's name is a different
|
|
593
|
+
* fact about a different thing, and substituting it would drop the kind tag,
|
|
594
|
+
* which is the half of the heading a `.puml` has nowhere else to put.
|
|
595
|
+
*/
|
|
596
|
+
export function exportPlantuml(model) {
|
|
597
|
+
const minter = new AliasMinter();
|
|
598
|
+
const aliasOf = new Map();
|
|
599
|
+
const declare = (node) => {
|
|
600
|
+
const alias = minter.mint(node.name || node.id);
|
|
601
|
+
aliasOf.set(node.id, alias);
|
|
602
|
+
return { alias, node };
|
|
603
|
+
};
|
|
604
|
+
// Aliases are minted in declaration order, so a collision suffix is stable.
|
|
605
|
+
const packages = model.packages.map(declare);
|
|
606
|
+
const classifiers = model.classifiers.map(declare);
|
|
607
|
+
const subjects = model.subjects.map(declare);
|
|
608
|
+
const actors = model.actors.map(declare);
|
|
609
|
+
const useCases = model.useCases.map(declare);
|
|
610
|
+
const components = model.components.map(declare);
|
|
611
|
+
const artifacts = model.artifacts.map(declare);
|
|
612
|
+
const cubes = model.nodes.map(declare);
|
|
613
|
+
const notes = model.notes.map((note, index) => {
|
|
614
|
+
const alias = minter.mint(note.name || `note${index + 1}`);
|
|
615
|
+
aliasOf.set(note.id, alias);
|
|
616
|
+
return { alias, node: note };
|
|
617
|
+
});
|
|
618
|
+
/** The smallest declared container whose box holds this centre. */
|
|
619
|
+
const containerOf = (element, containers, strictlyLarger = false) => {
|
|
620
|
+
const bounds = element.bounds;
|
|
621
|
+
if (!bounds)
|
|
622
|
+
return undefined;
|
|
623
|
+
const own = Math.max(0, bounds.w) * Math.max(0, bounds.h);
|
|
624
|
+
let best;
|
|
625
|
+
let bestArea = Number.POSITIVE_INFINITY;
|
|
626
|
+
for (const candidate of containers) {
|
|
627
|
+
const box = candidate.node.bounds;
|
|
628
|
+
if (candidate.node.id === element.id || !box)
|
|
629
|
+
continue;
|
|
630
|
+
if (!umlCentreInside(bounds, box))
|
|
631
|
+
continue;
|
|
632
|
+
const area = Math.max(0, box.w) * Math.max(0, box.h);
|
|
633
|
+
if (strictlyLarger && area <= own)
|
|
634
|
+
continue;
|
|
635
|
+
if (area < bestArea) {
|
|
636
|
+
best = candidate;
|
|
637
|
+
bestArea = area;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return best;
|
|
641
|
+
};
|
|
642
|
+
const packageOf = new Map();
|
|
643
|
+
for (const { node } of packages) {
|
|
644
|
+
packageOf.set(node.id, containerOf(node, packages, true)?.node.id);
|
|
645
|
+
}
|
|
646
|
+
for (const { node } of classifiers) {
|
|
647
|
+
packageOf.set(node.id, containerOf(node, packages)?.node.id);
|
|
648
|
+
}
|
|
649
|
+
const subjectOf = new Map();
|
|
650
|
+
for (const { node } of useCases) {
|
|
651
|
+
subjectOf.set(node.id, containerOf(node, subjects)?.node.id);
|
|
652
|
+
}
|
|
653
|
+
const lines = ['@startuml'];
|
|
654
|
+
const title = toPlantumlLabel(model.diagram.heading);
|
|
655
|
+
if (title)
|
|
656
|
+
lines.push(`title ${title}`);
|
|
657
|
+
lines.push('');
|
|
658
|
+
const classifierBlock = (declared, indent) => {
|
|
659
|
+
const classifier = declared.node;
|
|
660
|
+
const word = DECLARATION[classifier.kind];
|
|
661
|
+
// An instance is labelled `name : Type` (§11.6.4) — the underlined form the
|
|
662
|
+
// canvas draws, which PlantUML underlines for an `object` of its own accord.
|
|
663
|
+
const stated = classifier.kind === 'object' && classifier.instanceOf
|
|
664
|
+
? `${classifier.name} : ${classifier.instanceOf}`
|
|
665
|
+
: classifier.name;
|
|
666
|
+
const label = toPlantumlLabel(stated) || UNNAMED;
|
|
667
|
+
const extra = stereotypes(classifier.keywords.filter(keyword => !DECLARED_KEYWORDS.has(keyword.toLowerCase())));
|
|
668
|
+
// `abstract class` is PlantUML's own word for §9.2.4's italic name; the
|
|
669
|
+
// `object` declaration has no abstract form, and an instance has no use for
|
|
670
|
+
// one.
|
|
671
|
+
const head = classifier.isAbstract && classifier.kind === 'class'
|
|
672
|
+
? `abstract ${word}`
|
|
673
|
+
: word;
|
|
674
|
+
const body = classifier.kind === 'enumeration'
|
|
675
|
+
? // An enumeration's compartment holds LITERALS: PlantUML wants the bare
|
|
676
|
+
// name, so the line is parsed for it and the rest of §9.5.4's syntax —
|
|
677
|
+
// which a literal has no use for — is dropped.
|
|
678
|
+
classifier.attributes.map(literal => literal.name).filter(Boolean)
|
|
679
|
+
: classifier.kind === 'object'
|
|
680
|
+
? classifier.slots.map(slot => slot.value ? `${slot.name} = ${slot.value}` : slot.name)
|
|
681
|
+
: // Verbatim: PlantUML's member syntax is §9.5.4 / §9.6.4's own.
|
|
682
|
+
memberLines([
|
|
683
|
+
...classifier.lines.attributes,
|
|
684
|
+
...classifier.lines.operations,
|
|
685
|
+
]);
|
|
686
|
+
const declaration = `${indent}${head} "${label}" as ${declared.alias}${extra}`;
|
|
687
|
+
if (body.length === 0)
|
|
688
|
+
return [declaration];
|
|
689
|
+
return [
|
|
690
|
+
`${declaration} {`,
|
|
691
|
+
...body.map(line => `${indent} ${line}`),
|
|
692
|
+
`${indent}}`,
|
|
693
|
+
];
|
|
694
|
+
};
|
|
695
|
+
const packageBlock = (declared, indent) => {
|
|
696
|
+
const inner = [];
|
|
697
|
+
for (const nested of packages) {
|
|
698
|
+
if (packageOf.get(nested.node.id) === declared.node.id) {
|
|
699
|
+
inner.push(...packageBlock(nested, `${indent} `));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
for (const classifier of classifiers) {
|
|
703
|
+
if (packageOf.get(classifier.node.id) === declared.node.id) {
|
|
704
|
+
inner.push(...classifierBlock(classifier, `${indent} `));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const label = toPlantumlLabel(declared.node.name) || UNNAMED;
|
|
708
|
+
const head = `${indent}package "${label}" as ${declared.alias}${stereotypes(declared.node.keywords)}`;
|
|
709
|
+
return inner.length === 0 ? [head] : [`${head} {`, ...inner, `${indent}}`];
|
|
710
|
+
};
|
|
711
|
+
for (const pkg of packages) {
|
|
712
|
+
if (!packageOf.get(pkg.node.id))
|
|
713
|
+
lines.push(...packageBlock(pkg, ''));
|
|
714
|
+
}
|
|
715
|
+
for (const classifier of classifiers) {
|
|
716
|
+
if (!packageOf.get(classifier.node.id)) {
|
|
717
|
+
lines.push(...classifierBlock(classifier, ''));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
for (const actor of actors) {
|
|
721
|
+
lines.push(`actor "${toPlantumlLabel(actor.node.name) || UNNAMED}" as ${actor.alias}${stereotypes(actor.node.keywords)}`);
|
|
722
|
+
}
|
|
723
|
+
for (const subject of subjects) {
|
|
724
|
+
const inner = useCases
|
|
725
|
+
.filter(useCase => subjectOf.get(useCase.node.id) === subject.node.id)
|
|
726
|
+
.map(useCase => ` usecase "${toPlantumlLabel(useCase.node.name) || UNNAMED}" as ${useCase.alias}`);
|
|
727
|
+
// A subject is a `rectangle`, which is PlantUML's plain boundary box — the
|
|
728
|
+
// §18.1.4 rectangle the cases are drawn inside, and nothing more.
|
|
729
|
+
const head = `rectangle "${toPlantumlLabel(subject.node.name) || UNNAMED}" as ${subject.alias}`;
|
|
730
|
+
lines.push(...(inner.length === 0 ? [head] : [`${head} {`, ...inner, '}']));
|
|
731
|
+
}
|
|
732
|
+
for (const useCase of useCases) {
|
|
733
|
+
if (!subjectOf.get(useCase.node.id)) {
|
|
734
|
+
lines.push(`usecase "${toPlantumlLabel(useCase.node.name) || UNNAMED}" as ${useCase.alias}${stereotypes(useCase.node.keywords)}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// ── The structural sheets (§11.6.4, §19.3.4, §19.4.4) ────────────────
|
|
738
|
+
//
|
|
739
|
+
// Declared after the class-side artefacts and before the relations, for the
|
|
740
|
+
// reason the whole function is ordered this way: PlantUML resolves an arrow by
|
|
741
|
+
// alias, and an element declared after the line referring to it renders as a
|
|
742
|
+
// bare box.
|
|
743
|
+
for (const component of components) {
|
|
744
|
+
lines.push(`component "${toPlantumlLabel(component.node.name) || UNNAMED}" as ${component.alias}${stereotypes(component.node.keywords.filter(keyword => !STRUCTURAL_KEYWORDS.has(keyword.toLowerCase())))}`);
|
|
745
|
+
}
|
|
746
|
+
for (const artifact of artifacts) {
|
|
747
|
+
lines.push(`artifact "${toPlantumlLabel(artifact.node.name) || UNNAMED}" as ${artifact.alias}${stereotypes(artifact.node.keywords.filter(keyword => !STRUCTURAL_KEYWORDS.has(keyword.toLowerCase())))}`);
|
|
748
|
+
}
|
|
749
|
+
for (const cube of cubes) {
|
|
750
|
+
const node = cube.node;
|
|
751
|
+
lines.push(`node "${toPlantumlLabel(node.name) || UNNAMED}" as ${cube.alias}${deploymentStereotype(node.kind)}${stereotypes(node.keywords.filter(keyword => !STRUCTURAL_KEYWORDS.has(keyword.toLowerCase())))}`);
|
|
752
|
+
}
|
|
753
|
+
// The lollipops and the sockets, as the two dependencies §10.4.4 says they
|
|
754
|
+
// ARE.
|
|
755
|
+
//
|
|
756
|
+
// One `interface` declaration per NAME across the sheet, because that is what
|
|
757
|
+
// an alias is: two components providing `IOrder` are two arrows onto one
|
|
758
|
+
// declared identifier, and declaring it twice would be a duplicate-alias
|
|
759
|
+
// error. This is where PlantUML and XMI part company on purpose — the XMI
|
|
760
|
+
// writer mints an Interface per lollipop, since a file has to say which
|
|
761
|
+
// component declares which contract, while a `.puml` is a picture and the
|
|
762
|
+
// picture has one circle.
|
|
763
|
+
//
|
|
764
|
+
// The arrows are `i <|.. c` and `c ..> i` rather than the `-(` / `)-` lollipop
|
|
765
|
+
// operators: the realization and the usage arrows are the notation §10.4.4's
|
|
766
|
+
// own Figure 10.11 gives for the same two facts, they are the two this file
|
|
767
|
+
// already writes for a class diagram, and they parse in every version of the
|
|
768
|
+
// renderer. The ball-and-socket operators are newer and their spelling has
|
|
769
|
+
// changed; a file nobody can render is worth less than one drawn slightly
|
|
770
|
+
// plainer.
|
|
771
|
+
const interfaceAlias = new Map();
|
|
772
|
+
const interfaceLines = [];
|
|
773
|
+
const wiring = [];
|
|
774
|
+
const aliasForInterface = (name) => {
|
|
775
|
+
const known = interfaceAlias.get(name);
|
|
776
|
+
if (known)
|
|
777
|
+
return known;
|
|
778
|
+
const alias = minter.mint(name);
|
|
779
|
+
interfaceAlias.set(name, alias);
|
|
780
|
+
interfaceLines.push(`interface "${toPlantumlLabel(name)}" as ${alias}`);
|
|
781
|
+
return alias;
|
|
782
|
+
};
|
|
783
|
+
// `components` is `model.components.map(declare)`, so the two stay in step by
|
|
784
|
+
// index — no lookup, and no way for them to drift.
|
|
785
|
+
for (const [index, component] of components.entries()) {
|
|
786
|
+
const node = model.components[index];
|
|
787
|
+
for (const name of node.provided) {
|
|
788
|
+
// "This component realizes that contract" — the dashed hollow triangle,
|
|
789
|
+
// which is what a ball on a stub means (§10.4.4).
|
|
790
|
+
wiring.push(`${aliasForInterface(name)} <|.. ${component.alias}`);
|
|
791
|
+
}
|
|
792
|
+
for (const name of node.required) {
|
|
793
|
+
// "This component uses that contract" — the socket, which §10.4.4 draws
|
|
794
|
+
// as a Usage dependency and Figure 10.11 labels «use».
|
|
795
|
+
wiring.push(`${component.alias} ..> ${aliasForInterface(name)} : <<use>>`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
lines.push(...interfaceLines);
|
|
799
|
+
// ── The behaviour sheets (§15.2.4, §14.2.4) ──────────────────────────
|
|
800
|
+
//
|
|
801
|
+
// Written in PlantUML's STATE-DIAGRAM syntax, both of them — see
|
|
802
|
+
// {@link PLANTUML_STATE_STEREOTYPE} for the whole argument, and the comment
|
|
803
|
+
// line this emits for an activity so a reader of the file is told rather than
|
|
804
|
+
// left to work it out.
|
|
805
|
+
//
|
|
806
|
+
// Declared here, after everything else and before the arrows, for the reason
|
|
807
|
+
// the whole function is ordered this way: PlantUML resolves an arrow by alias.
|
|
808
|
+
const activity = model.activities[0];
|
|
809
|
+
const machine = model.stateMachines[0];
|
|
810
|
+
const behaviourLines = [];
|
|
811
|
+
const behaviourArrows = [];
|
|
812
|
+
/** A state declaration — the quoted form when there is a name to quote. */
|
|
813
|
+
const stateLine = (alias, label, stereotype, indent) => {
|
|
814
|
+
const tail = stereotype ? ` <<${stereotype}>>` : '';
|
|
815
|
+
const written = toPlantumlLabel(label);
|
|
816
|
+
return written
|
|
817
|
+
? `${indent}state "${written}" as ${alias}${tail}`
|
|
818
|
+
: // A control node carries no words (§15.3.4 draws none), and
|
|
819
|
+
// `state "" as D` is a box with an empty name rather than an unnamed
|
|
820
|
+
// one. The bare form is what PlantUML has for exactly this.
|
|
821
|
+
`${indent}state ${alias}${tail}`;
|
|
822
|
+
};
|
|
823
|
+
if (activity) {
|
|
824
|
+
behaviourLines.push("' An activity (§15.2.4), written in PlantUML's state-diagram syntax:", "' its structured activity syntax cannot express an arbitrary graph.");
|
|
825
|
+
const terminalKind = (kind) => kind === 'initial' || kind === 'activity-final';
|
|
826
|
+
for (const node of activity.nodes) {
|
|
827
|
+
// The disc and the bullseye ARE `[*]`, read from either end — see
|
|
828
|
+
// {@link PLANTUML_TERMINAL}. Nothing to mint and nothing to declare.
|
|
829
|
+
aliasOf.set(node.id, terminalKind(node.kind)
|
|
830
|
+
? PLANTUML_TERMINAL
|
|
831
|
+
: minter.mint(node.name || node.kind));
|
|
832
|
+
}
|
|
833
|
+
const activityNodeLine = (node, indent) => terminalKind(node.kind)
|
|
834
|
+
? []
|
|
835
|
+
: [
|
|
836
|
+
stateLine(aliasOf.get(node.id), node.name, PLANTUML_STATE_STEREOTYPE[node.kind], indent),
|
|
837
|
+
];
|
|
838
|
+
// The swimlanes, as composite boxes. An approximation and it says so: a
|
|
839
|
+
// state diagram has no lane, §15.6.4's band is the nearest thing PlantUML
|
|
840
|
+
// draws to one, and a box round the actions of one lane keeps the fact the
|
|
841
|
+
// lane states — who is responsible for what. The flows still cross freely,
|
|
842
|
+
// because every arrow is written at the TOP level below.
|
|
843
|
+
for (const partition of activity.partitions) {
|
|
844
|
+
const alias = minter.mint(partition.name || 'partition');
|
|
845
|
+
aliasOf.set(partition.id, alias);
|
|
846
|
+
const inside = activity.nodes.filter(node => node.partitionId === partition.id);
|
|
847
|
+
const head = stateLine(alias, partition.name, 'partition', '');
|
|
848
|
+
const inner = inside.flatMap(node => activityNodeLine(node, ' '));
|
|
849
|
+
behaviourLines.push(...(inner.length === 0 ? [head] : [`${head} {`, ...inner, '}']));
|
|
850
|
+
}
|
|
851
|
+
for (const node of activity.nodes) {
|
|
852
|
+
if (node.partitionId)
|
|
853
|
+
continue;
|
|
854
|
+
behaviourLines.push(...activityNodeLine(node, ''));
|
|
855
|
+
}
|
|
856
|
+
for (const edge of activity.edges) {
|
|
857
|
+
const source = aliasOf.get(edge.sourceId);
|
|
858
|
+
const target = aliasOf.get(edge.targetId);
|
|
859
|
+
if (!source || !target)
|
|
860
|
+
continue;
|
|
861
|
+
behaviourArrows.push(arrowLine(source, target, formatActivityEdgeLabel(edge)));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (machine) {
|
|
865
|
+
// A vertex at the TOP of the sheet gets PlantUML's own terminal marker; one
|
|
866
|
+
// drawn inside a composite state gets a declared box with `<<start>>` or
|
|
867
|
+
// `<<end>>` instead.
|
|
868
|
+
//
|
|
869
|
+
// The reason is `[*]`'s scoping: PlantUML reads it against the block the
|
|
870
|
+
// line is written in, and this writer emits every transition at the top
|
|
871
|
+
// level so that one crossing out of a composite state still resolves. A
|
|
872
|
+
// nested `[*]` would therefore render as the MACHINE's beginning rather than
|
|
873
|
+
// the composite state's, which is a different statement. A declared box is
|
|
874
|
+
// always right, at the cost of a circle drawn as a labelled node.
|
|
875
|
+
const terminalOf = (vertex, kind) => vertex.regionId === undefined && (kind === 'initial' || kind === 'final')
|
|
876
|
+
? PLANTUML_TERMINAL
|
|
877
|
+
: undefined;
|
|
878
|
+
for (const state of machine.states) {
|
|
879
|
+
aliasOf.set(state.id, minter.mint(state.name || 'state'));
|
|
880
|
+
}
|
|
881
|
+
for (const region of machine.regions) {
|
|
882
|
+
aliasOf.set(region.id, minter.mint(region.name || 'state'));
|
|
883
|
+
}
|
|
884
|
+
for (const final of machine.finalStates) {
|
|
885
|
+
aliasOf.set(final.id, terminalOf(final, 'final') ?? minter.mint(final.name || 'final'));
|
|
886
|
+
}
|
|
887
|
+
for (const pseudo of machine.pseudostates) {
|
|
888
|
+
aliasOf.set(pseudo.id, terminalOf(pseudo, pseudo.kind) ??
|
|
889
|
+
minter.mint(pseudo.name || pseudo.kind));
|
|
890
|
+
}
|
|
891
|
+
/** One region's contents — its vertices, and the composite states in it. */
|
|
892
|
+
const regionLines = (regionId, indent) => {
|
|
893
|
+
const out = [];
|
|
894
|
+
for (const state of machine.states) {
|
|
895
|
+
if (state.regionId !== regionId)
|
|
896
|
+
continue;
|
|
897
|
+
const alias = aliasOf.get(state.id);
|
|
898
|
+
out.push(stateLine(alias, state.name, undefined, indent));
|
|
899
|
+
// §14.2.4.4's internal activities, in PlantUML's own `S : …` form —
|
|
900
|
+
// which is the same line UML writes inside the second compartment.
|
|
901
|
+
for (const body of state.entry) {
|
|
902
|
+
out.push(`${indent}${alias} : entry / ${toPlantumlLabel(body)}`);
|
|
903
|
+
}
|
|
904
|
+
for (const body of state.doActivity) {
|
|
905
|
+
out.push(`${indent}${alias} : do / ${toPlantumlLabel(body)}`);
|
|
906
|
+
}
|
|
907
|
+
for (const body of state.exit) {
|
|
908
|
+
out.push(`${indent}${alias} : exit / ${toPlantumlLabel(body)}`);
|
|
909
|
+
}
|
|
910
|
+
// Everything else the author wrote in that compartment — an internal
|
|
911
|
+
// transition, a constraint — kept verbatim rather than dropped.
|
|
912
|
+
for (const line of state.lines) {
|
|
913
|
+
out.push(`${indent}${alias} : ${toPlantumlLabel(line)}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const final of machine.finalStates) {
|
|
917
|
+
if (final.regionId !== regionId)
|
|
918
|
+
continue;
|
|
919
|
+
const alias = aliasOf.get(final.id);
|
|
920
|
+
if (alias === PLANTUML_TERMINAL)
|
|
921
|
+
continue;
|
|
922
|
+
out.push(stateLine(alias, final.name, 'end', indent));
|
|
923
|
+
}
|
|
924
|
+
for (const pseudo of machine.pseudostates) {
|
|
925
|
+
if (pseudo.regionId !== regionId)
|
|
926
|
+
continue;
|
|
927
|
+
const alias = aliasOf.get(pseudo.id);
|
|
928
|
+
if (alias === PLANTUML_TERMINAL)
|
|
929
|
+
continue;
|
|
930
|
+
out.push(stateLine(alias, pseudo.name, pseudo.kind === 'initial'
|
|
931
|
+
? 'start'
|
|
932
|
+
: PLANTUML_STATE_STEREOTYPE[pseudo.kind], indent));
|
|
933
|
+
}
|
|
934
|
+
for (const region of machine.regions) {
|
|
935
|
+
if (region.parentId !== regionId)
|
|
936
|
+
continue;
|
|
937
|
+
const head = stateLine(aliasOf.get(region.id), region.name, undefined, indent);
|
|
938
|
+
const inner = regionLines(region.id, `${indent} `);
|
|
939
|
+
out.push(...(inner.length === 0
|
|
940
|
+
? [head]
|
|
941
|
+
: [`${head} {`, ...inner, `${indent}}`]));
|
|
942
|
+
}
|
|
943
|
+
return out;
|
|
944
|
+
};
|
|
945
|
+
behaviourLines.push(...regionLines(undefined, ''));
|
|
946
|
+
for (const transition of machine.transitions) {
|
|
947
|
+
const source = aliasOf.get(transition.sourceId);
|
|
948
|
+
const target = aliasOf.get(transition.targetId);
|
|
949
|
+
if (!source || !target)
|
|
950
|
+
continue;
|
|
951
|
+
behaviourArrows.push(arrowLine(source, target, formatTransitionLabel(transition)));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (behaviourLines.length > 0)
|
|
955
|
+
lines.push(...behaviourLines);
|
|
956
|
+
// ── The sequence sheet (§17.2.4) ─────────────────────────────────────
|
|
957
|
+
//
|
|
958
|
+
// Declared here with the other artefacts and written at the end with the
|
|
959
|
+
// other arrows, for the reason the whole function is ordered this way:
|
|
960
|
+
// PlantUML resolves a participant by alias.
|
|
961
|
+
const interaction = model.interactions[0];
|
|
962
|
+
const sequence = interaction
|
|
963
|
+
? sequenceLines(interaction, minter, aliasOf)
|
|
964
|
+
: { declarations: [], body: [] };
|
|
965
|
+
if (sequence.declarations.length > 0)
|
|
966
|
+
lines.push(...sequence.declarations);
|
|
967
|
+
for (const note of notes) {
|
|
968
|
+
// The block form rather than `note "…" as N`: a note is prose and routinely
|
|
969
|
+
// several lines, and the one-line form has nowhere to put the second one.
|
|
970
|
+
lines.push(`note as ${note.alias}`, ...parseCompartment(note.node.body).map(line => ` ${line}`), 'end note');
|
|
971
|
+
}
|
|
972
|
+
const relations = model.relations
|
|
973
|
+
.map(relation => {
|
|
974
|
+
const source = aliasOf.get(relation.sourceId);
|
|
975
|
+
const target = aliasOf.get(relation.targetId);
|
|
976
|
+
// An end this document has no alias for: there is nothing to point at.
|
|
977
|
+
return source && target ? relationLine(relation, source, target) : '';
|
|
978
|
+
})
|
|
979
|
+
.filter(Boolean);
|
|
980
|
+
const drawn = [...wiring, ...relations, ...behaviourArrows, ...sequence.body];
|
|
981
|
+
if (drawn.length > 0)
|
|
982
|
+
lines.push('', ...drawn);
|
|
983
|
+
lines.push('@enduml');
|
|
984
|
+
return `${lines.join('\n')}\n`;
|
|
985
|
+
}
|