@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.
Files changed (114) hide show
  1. package/LICENSE +384 -0
  2. package/dist/actions.d.ts +430 -0
  3. package/dist/actions.js +724 -0
  4. package/dist/background.d.ts +153 -0
  5. package/dist/background.js +557 -0
  6. package/dist/board-hit.d.ts +137 -0
  7. package/dist/board-hit.js +191 -0
  8. package/dist/commands-manifest.d.ts +19 -0
  9. package/dist/commands-manifest.js +571 -0
  10. package/dist/commands.d.ts +4 -0
  11. package/dist/commands.js +983 -0
  12. package/dist/component.d.ts +521 -0
  13. package/dist/component.js +708 -0
  14. package/dist/consts.d.ts +365 -0
  15. package/dist/consts.js +463 -0
  16. package/dist/descriptor.d.ts +12 -0
  17. package/dist/descriptor.js +10 -0
  18. package/dist/drawio-decode.d.ts +32 -0
  19. package/dist/drawio-decode.js +96 -0
  20. package/dist/drawio-import.d.ts +133 -0
  21. package/dist/drawio-import.js +885 -0
  22. package/dist/edge-morph.d.ts +154 -0
  23. package/dist/edge-morph.js +153 -0
  24. package/dist/edge-styles.d.ts +78 -0
  25. package/dist/edge-styles.js +210 -0
  26. package/dist/effects.d.ts +9 -0
  27. package/dist/effects.js +6 -0
  28. package/dist/element-renderer.d.ts +64 -0
  29. package/dist/element-renderer.js +114 -0
  30. package/dist/element-view.d.ts +234 -0
  31. package/dist/element-view.js +430 -0
  32. package/dist/export.d.ts +61 -0
  33. package/dist/export.js +41 -0
  34. package/dist/filename.d.ts +17 -0
  35. package/dist/filename.js +26 -0
  36. package/dist/frame-tag.d.ts +70 -0
  37. package/dist/frame-tag.js +78 -0
  38. package/dist/grammar.d.ts +695 -0
  39. package/dist/grammar.js +1436 -0
  40. package/dist/import.d.ts +306 -0
  41. package/dist/import.js +1270 -0
  42. package/dist/index.d.ts +47 -0
  43. package/dist/index.js +67 -0
  44. package/dist/interchange.d.ts +125 -0
  45. package/dist/interchange.js +372 -0
  46. package/dist/keywords.d.ts +204 -0
  47. package/dist/keywords.js +361 -0
  48. package/dist/kinds.d.ts +73 -0
  49. package/dist/kinds.js +91 -0
  50. package/dist/legend.d.ts +30 -0
  51. package/dist/legend.js +255 -0
  52. package/dist/model.d.ts +862 -0
  53. package/dist/model.js +1316 -0
  54. package/dist/morph.d.ts +178 -0
  55. package/dist/morph.js +496 -0
  56. package/dist/node/act-glyphs.d.ts +30 -0
  57. package/dist/node/act-glyphs.js +142 -0
  58. package/dist/node/compartment-watcher.d.ts +107 -0
  59. package/dist/node/compartment-watcher.js +163 -0
  60. package/dist/node/fit.d.ts +85 -0
  61. package/dist/node/fit.js +129 -0
  62. package/dist/node/node-renderer.d.ts +49 -0
  63. package/dist/node/node-renderer.js +611 -0
  64. package/dist/node/node-view.d.ts +51 -0
  65. package/dist/node/node-view.js +89 -0
  66. package/dist/node/paint.d.ts +84 -0
  67. package/dist/node/paint.js +143 -0
  68. package/dist/node/sd-glyphs.d.ts +36 -0
  69. package/dist/node/sd-glyphs.js +79 -0
  70. package/dist/node/stm-glyphs.d.ts +22 -0
  71. package/dist/node/stm-glyphs.js +111 -0
  72. package/dist/node/tier-metrics.d.ts +38 -0
  73. package/dist/node/tier-metrics.js +45 -0
  74. package/dist/operators.d.ts +45 -0
  75. package/dist/operators.js +75 -0
  76. package/dist/plantuml-import.d.ts +113 -0
  77. package/dist/plantuml-import.js +1723 -0
  78. package/dist/plantuml.d.ts +94 -0
  79. package/dist/plantuml.js +985 -0
  80. package/dist/presets.d.ts +149 -0
  81. package/dist/presets.js +302 -0
  82. package/dist/profiles.d.ts +2 -0
  83. package/dist/profiles.js +358 -0
  84. package/dist/reading.d.ts +160 -0
  85. package/dist/reading.js +648 -0
  86. package/dist/roles.d.ts +257 -0
  87. package/dist/roles.js +997 -0
  88. package/dist/rules.d.ts +267 -0
  89. package/dist/rules.js +3090 -0
  90. package/dist/templates.d.ts +2 -0
  91. package/dist/templates.js +197 -0
  92. package/dist/toolbar/config.d.ts +228 -0
  93. package/dist/toolbar/config.js +833 -0
  94. package/dist/toolbar/icons.d.ts +440 -0
  95. package/dist/toolbar/icons.js +711 -0
  96. package/dist/toolbar/senior-tool.d.ts +11 -0
  97. package/dist/toolbar/senior-tool.js +21 -0
  98. package/dist/toolbar/uml-menu.d.ts +17 -0
  99. package/dist/toolbar/uml-menu.js +20 -0
  100. package/dist/toolbar/uml-senior-button.d.ts +18 -0
  101. package/dist/toolbar/uml-senior-button.js +22 -0
  102. package/dist/translations.d.ts +27 -0
  103. package/dist/translations.js +117 -0
  104. package/dist/view.d.ts +33 -0
  105. package/dist/view.js +236 -0
  106. package/dist/xmi-import.d.ts +174 -0
  107. package/dist/xmi-import.js +2268 -0
  108. package/dist/xmi.d.ts +61 -0
  109. package/dist/xmi.js +1860 -0
  110. package/dist/xml-reader.d.ts +157 -0
  111. package/dist/xml-reader.js +430 -0
  112. package/dist/xml.d.ts +72 -0
  113. package/dist/xml.js +96 -0
  114. package/package.json +38 -0
@@ -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
+ }