@formicoidea/labre-framework-c4 0.33.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 (61) hide show
  1. package/dist/actions.d.ts +179 -0
  2. package/dist/actions.js +375 -0
  3. package/dist/background.d.ts +77 -0
  4. package/dist/background.js +223 -0
  5. package/dist/commands.d.ts +4 -0
  6. package/dist/commands.js +221 -0
  7. package/dist/component.d.ts +192 -0
  8. package/dist/component.js +188 -0
  9. package/dist/consts.d.ts +331 -0
  10. package/dist/consts.js +384 -0
  11. package/dist/descriptor.d.ts +12 -0
  12. package/dist/descriptor.js +10 -0
  13. package/dist/effects.d.ts +9 -0
  14. package/dist/effects.js +6 -0
  15. package/dist/element-renderer.d.ts +18 -0
  16. package/dist/element-renderer.js +14 -0
  17. package/dist/element-view.d.ts +51 -0
  18. package/dist/element-view.js +146 -0
  19. package/dist/export.d.ts +184 -0
  20. package/dist/export.js +454 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +52 -0
  23. package/dist/interchange.d.ts +74 -0
  24. package/dist/interchange.js +143 -0
  25. package/dist/legend.d.ts +37 -0
  26. package/dist/legend.js +123 -0
  27. package/dist/levels.d.ts +70 -0
  28. package/dist/levels.js +46 -0
  29. package/dist/morph.d.ts +89 -0
  30. package/dist/morph.js +229 -0
  31. package/dist/node/node-renderer.d.ts +6 -0
  32. package/dist/node/node-renderer.js +304 -0
  33. package/dist/node/node-view.d.ts +45 -0
  34. package/dist/node/node-view.js +80 -0
  35. package/dist/node/type-line-watcher.d.ts +70 -0
  36. package/dist/node/type-line-watcher.js +142 -0
  37. package/dist/presets.d.ts +84 -0
  38. package/dist/presets.js +149 -0
  39. package/dist/profiles.d.ts +2 -0
  40. package/dist/profiles.js +177 -0
  41. package/dist/roles.d.ts +116 -0
  42. package/dist/roles.js +303 -0
  43. package/dist/rules.d.ts +95 -0
  44. package/dist/rules.js +1261 -0
  45. package/dist/toolbar/c4-menu.d.ts +11 -0
  46. package/dist/toolbar/c4-menu.js +14 -0
  47. package/dist/toolbar/c4-senior-button.d.ts +19 -0
  48. package/dist/toolbar/c4-senior-button.js +23 -0
  49. package/dist/toolbar/config.d.ts +150 -0
  50. package/dist/toolbar/config.js +436 -0
  51. package/dist/toolbar/icons.d.ts +90 -0
  52. package/dist/toolbar/icons.js +157 -0
  53. package/dist/toolbar/senior-tool.d.ts +1 -0
  54. package/dist/toolbar/senior-tool.js +11 -0
  55. package/dist/translations.d.ts +18 -0
  56. package/dist/translations.js +42 -0
  57. package/dist/type-line.d.ts +175 -0
  58. package/dist/type-line.js +244 -0
  59. package/dist/view.d.ts +33 -0
  60. package/dist/view.js +148 -0
  61. package/package.json +34 -0
@@ -0,0 +1,184 @@
1
+ import type { C4BoardElementModel, C4BoundaryElementModel, C4NodeElementModel, C4NodeKind, ConnectorElementModel } from '@formicoidea/labre-core/model';
2
+ import { type C4ComponentGroup, type C4TierElement } from './component.js';
3
+ /**
4
+ * The board, as a **mermaid C4 diagram** (https://mermaid.js.org/syntax/c4.html).
5
+ *
6
+ * ## Pure by construction
7
+ *
8
+ * Element models in, string out. No `BlockStdScope`, no surface, no DOM, no
9
+ * clock and no randomness — the same discipline `bpmn/export.ts` holds itself
10
+ * to, and for the same three reasons: a host can call it, a test can call it
11
+ * with plain stubs, and the same board always serializes to the same bytes. The
12
+ * command that downloads the file is the only thing that knows what a canvas is.
13
+ *
14
+ * ## What it says, and what it refuses to say
15
+ *
16
+ * The export speaks the author's STATEMENTS and nothing else. A connector
17
+ * carrying no C4 role relates nothing — `docs/adr/0010` is explicit that the
18
+ * role is the statement — so it is not a relationship that happens to be
19
+ * untyped, it is not a relationship at all, and it is absent. A plain rectangle
20
+ * drawn on a board is likewise not an unnamed system. Guessing would put words
21
+ * in an architect's mouth in a file they are about to paste into a renderer.
22
+ *
23
+ * ## Why the BOARD is the scope, where BPMN took the whole surface
24
+ *
25
+ * A BPMN document is a process, and half a process is not a smaller process —
26
+ * so `exportBpmnXml` serializes every pool on the surface whatever launched it.
27
+ * A C4 board is the opposite object: it is one LEVEL of one model (a context, a
28
+ * container or a component diagram), and the whole point of drawing three of
29
+ * them side by side on one canvas is that they are three separate diagrams.
30
+ * Merging them would produce a picture C4 explicitly tells you not to draw.
31
+ *
32
+ * So the scope is the SELECTED board, and an element belongs to it by the same
33
+ * arithmetic every framework in this library attributes with: its centre inside
34
+ * the frame's plot (`bpmn/facts.ts`, and the audit's own `attribute()`).
35
+ * Selecting several boards yields several documents, one per board, each
36
+ * complete and each announced by a `%%` comment — mermaid renders ONE diagram
37
+ * per document, so the alternative would be a file no renderer accepts.
38
+ */
39
+ /**
40
+ * Which of the three C4 diagram types a document is, and the ladder that
41
+ * decides it.
42
+ *
43
+ * mermaid asks for the type on the FIRST line, and it is not decoration: the
44
+ * macros it accepts are gated on it (`Component` is not a `C4Context` word).
45
+ * The type is therefore inferred from the deepest level actually drawn rather
46
+ * than asked of the author — a board holding a component is a component
47
+ * diagram, whatever its title says.
48
+ */
49
+ type C4Level = 'context' | 'container' | 'component';
50
+ export interface C4MermaidMapping {
51
+ /** The mermaid macro this kind is written as. */
52
+ macro: 'Person' | 'Person_Ext' | 'System' | 'System_Ext' | 'Container' | 'ContainerDb' | 'Component';
53
+ /**
54
+ * mermaid's third positional argument (`techn`), for the two kinds that are a
55
+ * container with a picture rather than a container flavour of their own.
56
+ */
57
+ techn?: string;
58
+ /** The lowest diagram type that admits this kind. */
59
+ level: C4Level;
60
+ }
61
+ /**
62
+ * The whole notation, kind by kind — the table this module is really about.
63
+ *
64
+ * `Record<C4NodeKind, …>` and therefore COMPILE-TOTAL: a kind added to the pack
65
+ * without a mermaid macro to serialize it as fails the build here, which is the
66
+ * only place that failure is cheap. A kind that reached a document and had no
67
+ * mapping would be an artefact the author drew, saved, and then silently lost on
68
+ * export.
69
+ *
70
+ * Three entries do not map one-for-one and the reasons are C4's own:
71
+ *
72
+ * - `mobile` and `browser` have no macro of their own, because they are not a
73
+ * level: they are a CONTAINER with a picture (`roles.ts` says the same thing
74
+ * about their role). What the picture meant is written into mermaid's `techn`
75
+ * slot, which is where a reader of the file looks for it;
76
+ * - `database` becomes `ContainerDb` rather than `Container`, which is the one
77
+ * specialisation the notation itself draws — the cylinder;
78
+ * - `person-ext` / `system-ext` take the `_Ext` macros, so mermaid paints them
79
+ * grey exactly as the canvas does. The external variants are the same LEVEL,
80
+ * which is why they never move the diagram type.
81
+ */
82
+ export declare const C4_MERMAID_OF_KIND: Record<C4NodeKind, C4MermaidMapping>;
83
+ /**
84
+ * A label, as something mermaid's C4 parser can carry inside `"…"`.
85
+ *
86
+ * Four transformations, and every one of them is a thing the grammar cannot
87
+ * express rather than a preference:
88
+ *
89
+ * - **line breaks and control characters become a space.** A macro call is one
90
+ * line, and a canvas label routinely is not — it is how a system name fits in
91
+ * its box. Written raw, the second line would be a statement mermaid cannot
92
+ * parse; joined with a space, the whole label survives.
93
+ * - **`"` becomes `'`.** The C4 grammar has no escape for a double quote inside
94
+ * a quoted argument: the first one ENDS the string, and everything after it is
95
+ * read as syntax. A straight apostrophe is the closest character that is not a
96
+ * parse error.
97
+ * - **a run of `%` collapses to one.** `%%` opens a comment wherever it appears
98
+ * on a line, quoted or not, so a label containing it would silently truncate
99
+ * the rest of the statement.
100
+ * - **whitespace runs collapse and the result is trimmed**, which is what makes
101
+ * a two-line label read as one sentence rather than as one with a gap in it.
102
+ *
103
+ * Nothing else is touched. Accents, CJK and emoji all go through unharmed —
104
+ * they are text, mermaid carries text, and folding them would be this exporter
105
+ * rewriting somebody's diagram.
106
+ */
107
+ export declare function toMermaidText(raw: string): string;
108
+ /**
109
+ * An alias — the identifier every macro takes first and every `Rel` refers back
110
+ * to.
111
+ *
112
+ * Derived from the NAME rather than from the surface id, because an alias is
113
+ * READ: a `Rel(customer, internet_banking, "uses")` says what it relates, and
114
+ * `Rel(_x7fQa, _9bTz1, "uses")` says nothing at all to the human who opens the
115
+ * file. The transformation is deliberately lossy — ASCII only, because the
116
+ * grammar's identifier is a bare word and a unicode one is a lexer error where
117
+ * a folded one is merely uglier — which is what {@link AliasMinter} is for.
118
+ */
119
+ export declare function toMermaidAlias(raw: string): string;
120
+ /**
121
+ * The surface, split by what each element is.
122
+ *
123
+ * `boards` is the SELECTION — the boards the author asked for — while the other
124
+ * three lists are everything on the surface, because what belongs to a board is
125
+ * decided here by geometry rather than by the caller. Document order throughout:
126
+ * it is the tie-break attribution breaks on, and sorting would make the export
127
+ * disagree with the badge the user can see.
128
+ */
129
+ export interface C4ExportBoard {
130
+ boards: readonly C4BoardElementModel[];
131
+ nodes: readonly C4NodeElementModel[];
132
+ boundaries: readonly C4BoundaryElementModel[];
133
+ connectors: readonly ConnectorElementModel[];
134
+ /**
135
+ * Every canvas TEXT element on the surface, and every GROUP.
136
+ *
137
+ * The two written tiers of a component — its type line and its description —
138
+ * are real text elements grouped with the shape since the PO's recette of
139
+ * 28/08/2026, so this is where the exporter reads the technology and the
140
+ * sentence it used to read off two model fields. `component.ts` does the
141
+ * resolving; both lists arrive unfiltered and in document order, exactly as
142
+ * the other three do.
143
+ *
144
+ * OPTIONAL, and the absence is not a degraded case: a host that hands over
145
+ * nodes alone gets an export of nodes alone, with every technology and every
146
+ * description empty. Which is also what a node whose group was released or
147
+ * whose words were deleted resolves to — see {@link c4ComponentTiers}.
148
+ */
149
+ texts?: readonly C4TierElement[];
150
+ groups?: readonly C4ComponentGroup[];
151
+ }
152
+ /**
153
+ * Serialize the selected C4 board(s) as mermaid C4 source.
154
+ *
155
+ * ## The shape of one document
156
+ *
157
+ * ```
158
+ * C4Container
159
+ * title Internet banking
160
+ * Person(customer, "Customer")
161
+ * System_Boundary(internet_banking, "Internet banking") {
162
+ * Container(spa, "Single-page app", "web browser")
163
+ * ContainerDb(database, "Database")
164
+ * }
165
+ * Rel(customer, spa, "uses")
166
+ * ```
167
+ *
168
+ * Elements first, nested in the boundaries they are drawn inside, then every
169
+ * relationship — mermaid resolves a `Rel` by alias, so an element declared after
170
+ * the line referring to it renders as an empty box on some versions and not at
171
+ * all on others. Boundaries are laid out by geometric containment, attributed to
172
+ * the SMALLEST frame whose plot holds the centre, so a container boundary drawn
173
+ * inside a system boundary nests inside it here too.
174
+ *
175
+ * ## What is dropped, and why it is not a bug
176
+ *
177
+ * A relationship with an end that is dangling, that lands on something with no
178
+ * C4 role, or that lands on an element of ANOTHER board. mermaid has no way to
179
+ * say "and this points off the page", and inventing an anchor would be the
180
+ * export asserting a link the author never drew. The picture keeps them; the
181
+ * file cannot carry them.
182
+ */
183
+ export declare function exportC4Mermaid(board: C4ExportBoard): string;
184
+ export {};
package/dist/export.js ADDED
@@ -0,0 +1,454 @@
1
+ import { backgroundPlot, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { roleIsA } from '@formicoidea/labre-core/std/gfx';
3
+ import { C4_BOARD_BACKGROUND, C4_BOUNDARY_BACKGROUND } from './background.js';
4
+ import { c4ComponentTiers, c4StatedDescription, c4StatedName, c4StatedTechnology, } from './component.js';
5
+ import { C4_ROLE, C4_ROLE_OF_KIND, C4_ROLES } from './roles.js';
6
+ const LEVEL_RANK = {
7
+ context: 0,
8
+ container: 1,
9
+ component: 2,
10
+ };
11
+ const DIAGRAM_OF_LEVEL = {
12
+ context: 'C4Context',
13
+ container: 'C4Container',
14
+ component: 'C4Component',
15
+ };
16
+ /**
17
+ * The whole notation, kind by kind — the table this module is really about.
18
+ *
19
+ * `Record<C4NodeKind, …>` and therefore COMPILE-TOTAL: a kind added to the pack
20
+ * without a mermaid macro to serialize it as fails the build here, which is the
21
+ * only place that failure is cheap. A kind that reached a document and had no
22
+ * mapping would be an artefact the author drew, saved, and then silently lost on
23
+ * export.
24
+ *
25
+ * Three entries do not map one-for-one and the reasons are C4's own:
26
+ *
27
+ * - `mobile` and `browser` have no macro of their own, because they are not a
28
+ * level: they are a CONTAINER with a picture (`roles.ts` says the same thing
29
+ * about their role). What the picture meant is written into mermaid's `techn`
30
+ * slot, which is where a reader of the file looks for it;
31
+ * - `database` becomes `ContainerDb` rather than `Container`, which is the one
32
+ * specialisation the notation itself draws — the cylinder;
33
+ * - `person-ext` / `system-ext` take the `_Ext` macros, so mermaid paints them
34
+ * grey exactly as the canvas does. The external variants are the same LEVEL,
35
+ * which is why they never move the diagram type.
36
+ */
37
+ export const C4_MERMAID_OF_KIND = {
38
+ person: { macro: 'Person', level: 'context' },
39
+ 'person-ext': { macro: 'Person_Ext', level: 'context' },
40
+ system: { macro: 'System', level: 'context' },
41
+ 'system-ext': { macro: 'System_Ext', level: 'context' },
42
+ container: { macro: 'Container', level: 'container' },
43
+ database: { macro: 'ContainerDb', level: 'container' },
44
+ mobile: { macro: 'Container', techn: 'mobile app', level: 'container' },
45
+ browser: { macro: 'Container', techn: 'web browser', level: 'container' },
46
+ component: { macro: 'Component', level: 'component' },
47
+ };
48
+ /**
49
+ * Which macros have a `techn` slot at all — mermaid's own grammar, and the
50
+ * reason the emission cannot be one `args.push` for everybody.
51
+ *
52
+ * The C4 macros take their optional arguments POSITIONALLY, and the position of
53
+ * `descr` is not the same in both families: `Person(alias, label, descr)` and
54
+ * `System(alias, label, descr)` have no technology (a person is not built with
55
+ * one, and a software system's is a level down), while
56
+ * `Container(alias, label, techn, descr)` and `Component(…)` do. Emitting a
57
+ * description in the third slot of a `Container` would render it as the
58
+ * technology — a wrong statement in a file somebody pastes into a renderer, and
59
+ * the exact reason this is a table rather than a conditional.
60
+ *
61
+ * The consequence for a `person` the author typed a technology on: it is drawn
62
+ * in the type line on the canvas (`c4TypeLine` writes whatever it is given) and
63
+ * it does not survive the export, because mermaid has nowhere to put it. Better
64
+ * than the alternative, which is inventing a slot or shifting the description.
65
+ */
66
+ const MACRO_TAKES_TECHN = {
67
+ Person: false,
68
+ Person_Ext: false,
69
+ System: false,
70
+ System_Ext: false,
71
+ Container: true,
72
+ ContainerDb: true,
73
+ Component: true,
74
+ };
75
+ /** The two boundary macros, keyed by the variant the element declares. */
76
+ const BOUNDARY_MACRO = {
77
+ system: 'System_Boundary',
78
+ container: 'Container_Boundary',
79
+ };
80
+ /**
81
+ * What an unnamed thing is called.
82
+ *
83
+ * mermaid's macros take the label positionally and an empty string renders as a
84
+ * shape with nothing written in it — which is exactly the box the author is
85
+ * looking at, and exactly the box nobody can read. `"?"` is the honest
86
+ * placeholder: it says "this one has no name" rather than pretending it does,
87
+ * and it survives the round trip to whoever has to fix it.
88
+ */
89
+ const UNNAMED = '?';
90
+ /* ── Text ─────────────────────────────────────────────────────────────── */
91
+ /**
92
+ * A label, as something mermaid's C4 parser can carry inside `"…"`.
93
+ *
94
+ * Four transformations, and every one of them is a thing the grammar cannot
95
+ * express rather than a preference:
96
+ *
97
+ * - **line breaks and control characters become a space.** A macro call is one
98
+ * line, and a canvas label routinely is not — it is how a system name fits in
99
+ * its box. Written raw, the second line would be a statement mermaid cannot
100
+ * parse; joined with a space, the whole label survives.
101
+ * - **`"` becomes `'`.** The C4 grammar has no escape for a double quote inside
102
+ * a quoted argument: the first one ENDS the string, and everything after it is
103
+ * read as syntax. A straight apostrophe is the closest character that is not a
104
+ * parse error.
105
+ * - **a run of `%` collapses to one.** `%%` opens a comment wherever it appears
106
+ * on a line, quoted or not, so a label containing it would silently truncate
107
+ * the rest of the statement.
108
+ * - **whitespace runs collapse and the result is trimmed**, which is what makes
109
+ * a two-line label read as one sentence rather than as one with a gap in it.
110
+ *
111
+ * Nothing else is touched. Accents, CJK and emoji all go through unharmed —
112
+ * they are text, mermaid carries text, and folding them would be this exporter
113
+ * rewriting somebody's diagram.
114
+ */
115
+ export function toMermaidText(raw) {
116
+ return raw
117
+ .replaceAll(/[\p{Cc}\p{Cf}]/gu, ' ')
118
+ .replaceAll('"', "'")
119
+ .replaceAll(/%{2,}/g, '%')
120
+ .replaceAll(/\s+/g, ' ')
121
+ .trim();
122
+ }
123
+ /**
124
+ * An alias — the identifier every macro takes first and every `Rel` refers back
125
+ * to.
126
+ *
127
+ * Derived from the NAME rather than from the surface id, because an alias is
128
+ * READ: a `Rel(customer, internet_banking, "uses")` says what it relates, and
129
+ * `Rel(_x7fQa, _9bTz1, "uses")` says nothing at all to the human who opens the
130
+ * file. The transformation is deliberately lossy — ASCII only, because the
131
+ * grammar's identifier is a bare word and a unicode one is a lexer error where
132
+ * a folded one is merely uglier — which is what {@link AliasMinter} is for.
133
+ */
134
+ export function toMermaidAlias(raw) {
135
+ let out = '';
136
+ for (const char of raw.toLowerCase()) {
137
+ out += /[a-z0-9_]/.test(char) ? char : '_';
138
+ }
139
+ out = out.replaceAll(/_{2,}/g, '_').replace(/^_+/, '').replace(/_+$/, '');
140
+ if (out.length === 0)
141
+ return 'e';
142
+ // An identifier may not open on a digit. `e_` — for "element" — rather than a
143
+ // bare `_`, so two names one digit apart stay one character apart.
144
+ return /^[0-9]/.test(out) ? `e_${out}` : out;
145
+ }
146
+ /**
147
+ * Mints document-unique aliases, and remembers what it minted.
148
+ *
149
+ * Uniqueness is settled by a counting suffix rather than by a hash, the same
150
+ * call `bpmn/export.ts`'s `IdMinter` makes: `customer` and `customer_2` are both
151
+ * readable in a diagram somebody is about to debug, which is where a human will
152
+ * actually meet them.
153
+ */
154
+ class AliasMinter {
155
+ #taken = new Set();
156
+ mint(name) {
157
+ const base = toMermaidAlias(name);
158
+ if (!this.#taken.has(base)) {
159
+ this.#taken.add(base);
160
+ return base;
161
+ }
162
+ let n = 2;
163
+ while (this.#taken.has(`${base}_${n}`))
164
+ n++;
165
+ const unique = `${base}_${n}`;
166
+ this.#taken.add(unique);
167
+ return unique;
168
+ }
169
+ }
170
+ /* ── Geometry ─────────────────────────────────────────────────────────── */
171
+ /**
172
+ * A bound's centre, as ratios of a frame's PLOT — `null` for a degenerate plot.
173
+ *
174
+ * Ratios of the plot and not of the element box, for the reason `bpmn/facts.ts`
175
+ * gives: the margin between the two is where the frame writes its own words, and
176
+ * an element laid over the board's title is not on the sheet's drawing area.
177
+ */
178
+ function plotRatios(def, frame, bound) {
179
+ const plot = backgroundPlot(def, frame.w, frame.h);
180
+ if (!(plot.width > 0) || !(plot.height > 0))
181
+ return null;
182
+ return [
183
+ (bound.x + bound.w / 2 - frame.x - plot.x0) / plot.width,
184
+ (bound.y + bound.h / 2 - frame.y - plot.y0) / plot.height,
185
+ ];
186
+ }
187
+ /** Inclusive containment of the centre in the plot, exactly as `zoneAt` tests it. */
188
+ function centreInPlot(def, frame, bound) {
189
+ const at = plotRatios(def, frame, bound);
190
+ return at !== null && at[0] >= 0 && at[0] <= 1 && at[1] >= 0 && at[1] <= 1;
191
+ }
192
+ /** The plot's area — what "the SMALLEST containing boundary" is measured on. */
193
+ function plotArea(def, frame) {
194
+ const plot = backgroundPlot(def, frame.w, frame.h);
195
+ return Math.max(0, plot.width) * Math.max(0, plot.height);
196
+ }
197
+ /** The text an element carries, as a plain string mermaid can hold. */
198
+ function labelOf(value) {
199
+ if (value === null || value === undefined)
200
+ return '';
201
+ return toMermaidText(String(value));
202
+ }
203
+ /**
204
+ * The role an element must carry to be exported at all.
205
+ *
206
+ * Read off the element rather than off its `kind`, and the difference is the
207
+ * whole of `docs/adr/0010`: the kind says which glyph to paint, the ROLE is the
208
+ * author's statement that this box is a system. A shape drawn with the C4
209
+ * palette by hand — copied, pasted, restyled — carries no role and is therefore
210
+ * not a C4 element, however much it looks like one.
211
+ */
212
+ function isC4Node(model) {
213
+ return model.role === C4_ROLE_OF_KIND[model.kind];
214
+ }
215
+ /* ── The serializer ───────────────────────────────────────────────────── */
216
+ /**
217
+ * Serialize the selected C4 board(s) as mermaid C4 source.
218
+ *
219
+ * ## The shape of one document
220
+ *
221
+ * ```
222
+ * C4Container
223
+ * title Internet banking
224
+ * Person(customer, "Customer")
225
+ * System_Boundary(internet_banking, "Internet banking") {
226
+ * Container(spa, "Single-page app", "web browser")
227
+ * ContainerDb(database, "Database")
228
+ * }
229
+ * Rel(customer, spa, "uses")
230
+ * ```
231
+ *
232
+ * Elements first, nested in the boundaries they are drawn inside, then every
233
+ * relationship — mermaid resolves a `Rel` by alias, so an element declared after
234
+ * the line referring to it renders as an empty box on some versions and not at
235
+ * all on others. Boundaries are laid out by geometric containment, attributed to
236
+ * the SMALLEST frame whose plot holds the centre, so a container boundary drawn
237
+ * inside a system boundary nests inside it here too.
238
+ *
239
+ * ## What is dropped, and why it is not a bug
240
+ *
241
+ * A relationship with an end that is dangling, that lands on something with no
242
+ * C4 role, or that lands on an element of ANOTHER board. mermaid has no way to
243
+ * say "and this points off the page", and inventing an anchor would be the
244
+ * export asserting a link the author never drew. The picture keeps them; the
245
+ * file cannot carry them.
246
+ */
247
+ export function exportC4Mermaid(board) {
248
+ // No board is not an error, it is a selection nobody made a statement with:
249
+ // the honest answer is the smallest valid document, which says nothing.
250
+ if (board.boards.length === 0)
251
+ return 'C4Context\n';
252
+ const many = board.boards.length > 1;
253
+ return board.boards.map(frame => oneBoard(frame, board, many)).join('\n');
254
+ }
255
+ /** One board, as one complete mermaid document (trailing newline included). */
256
+ function oneBoard(frame, board, announce) {
257
+ const bound = frame.elementBound;
258
+ const inScope = (element) => centreInPlot(C4_BOARD_BACKGROUND, bound, element.elementBound);
259
+ /* ── Who is on this sheet ────────────────────────────────────────── */
260
+ const nodes = board.nodes.filter(model => isC4Node(model) && inScope(model));
261
+ // `roleIsA` and not an equality: a boundary drawn today carries the CHILD role
262
+ // of its variant (`c4:system-boundary`, `c4:container-boundary`) and one drawn
263
+ // before the split carries the parent. Both are boundaries, both are exported,
264
+ // and the bracket line each one gets is decided by `variant` exactly as before
265
+ // — the role split changed which frames this filter has to recognise, not what
266
+ // the file says about any of them.
267
+ const boundaries = board.boundaries.filter(model => roleIsA(model.role, C4_ROLE.boundary, C4_ROLES) && inScope(model));
268
+ /* ── Aliases ─────────────────────────────────────────────────────── */
269
+ // Minted per DOCUMENT, so each one is self-contained: two boards may both
270
+ // hold a "Customer", and neither is the other's. Nodes before boundaries, in
271
+ // model order, which is what makes a collision suffix stable.
272
+ const minter = new AliasMinter();
273
+ const areaOf = (model) => plotArea(C4_BOUNDARY_BACKGROUND, model.elementBound);
274
+ /**
275
+ * The smallest boundary whose plot holds this centre — the most-nested one
276
+ * wins, which is the only reading that lets a container boundary drawn inside
277
+ * a system boundary mean what it draws.
278
+ *
279
+ * `skip` is the boundary asking about ITSELF: a frame contains its own centre,
280
+ * and a boundary is not its own parent. Equal areas are broken by document
281
+ * order so that two boundaries drawn on top of each other cannot each claim
282
+ * the other — the relation stays a strict order, and the walk below cannot
283
+ * loop.
284
+ */
285
+ const parentOf = (target, skip) => {
286
+ let best = -1;
287
+ let bestArea = Number.POSITIVE_INFINITY;
288
+ for (const [index, candidate] of boundaries.entries()) {
289
+ if (index === skip)
290
+ continue;
291
+ const area = areaOf(candidate);
292
+ if (skip >= 0) {
293
+ const own = areaOf(boundaries[skip]);
294
+ if (area < own || (area === own && index > skip))
295
+ continue;
296
+ }
297
+ if (!centreInPlot(C4_BOUNDARY_BACKGROUND, candidate.elementBound, target)) {
298
+ continue;
299
+ }
300
+ if (area < bestArea) {
301
+ best = index;
302
+ bestArea = area;
303
+ }
304
+ }
305
+ return best;
306
+ };
307
+ // The two written tiers are elements now, so what they say is read off the
308
+ // canvas rather than off the model. Unfiltered and in document order, because
309
+ // a tier belongs to its node through the GROUP and not through the geometry:
310
+ // an author who dragged a description half out of the board has still written
311
+ // it on that component.
312
+ const texts = board.texts ?? [];
313
+ const groups = board.groups ?? [];
314
+ const plannedNodes = nodes.map(model => {
315
+ const mapping = C4_MERMAID_OF_KIND[model.kind];
316
+ const tiers = c4ComponentTiers(model.id, groups, texts);
317
+ // The NAME comes off the `c4:title` tier, and off the shape's own inner text
318
+ // only for an element drawn before the title became a child. Verbatim, with
319
+ // no placeholder reading: an unnamed container really is a container, so
320
+ // `Container(x, "Container")` is true where `Container(x, "?")` throws away
321
+ // what little the author has said.
322
+ const name = labelOf(c4StatedName(tiers, model.text)) || UNNAMED;
323
+ return {
324
+ model,
325
+ mapping,
326
+ alias: minter.mint(name),
327
+ name,
328
+ // The author's own technology WINS over the kind's default: `mobile` and
329
+ // `browser` carry one because their picture means something the macro has
330
+ // no other way to say, and a container the author has typed "Flutter" on
331
+ // is not a "mobile app" that happens to be written in Flutter.
332
+ techn: labelOf(c4StatedTechnology(tiers.typeLine)) || mapping.techn || '',
333
+ descr: labelOf(c4StatedDescription(tiers.description)),
334
+ parent: parentOf(model.elementBound, -1),
335
+ };
336
+ });
337
+ const plannedBoundaries = boundaries.map((model, index) => {
338
+ const name = labelOf(model.name) || UNNAMED;
339
+ return {
340
+ model,
341
+ // An unstated variant reads as a SYSTEM boundary, which is the default
342
+ // the model itself documents and the one a reader draws first.
343
+ macro: BOUNDARY_MACRO[model.variant ?? 'system'],
344
+ alias: minter.mint(name),
345
+ name,
346
+ parent: parentOf(model.elementBound, index),
347
+ };
348
+ });
349
+ /* ── The relationships ───────────────────────────────────────────── */
350
+ const aliasOfModel = new Map(plannedNodes.map(node => [node.model.id, node.alias]));
351
+ /**
352
+ * …and so does every other part of the component it is drawn in.
353
+ *
354
+ * A C4 component is a group of four elements and every one of them is
355
+ * `connectable`: a native group is, and so is a canvas text. The connector
356
+ * tool's own search walks every connectable element whose bound holds the
357
+ * pointer and keeps the last one, so an arrow dragged onto a component records
358
+ * the id of the group, of the type line or of the description about as often
359
+ * as the shape's. All four look identical on the canvas — the words are drawn
360
+ * inside the box and the group's outline IS the box — and the difference would
361
+ * be silently fatal here, because a `Rel` is written by alias and only the
362
+ * shape has one: every relationship an author drew would be dropped from the
363
+ * file with no sign that it had been.
364
+ *
365
+ * So each part of a component answers for its shape. Only where the group
366
+ * holds exactly ONE C4 element, and not the first of several: a component
367
+ * grouped together with a second component is a lasso somebody drew round two
368
+ * boxes, and an arrow landing on it points at neither in particular. Guessing
369
+ * there would put a sentence in the file that nobody drew, which is the one
370
+ * thing this exporter refuses to do anywhere else.
371
+ */
372
+ for (const group of groups) {
373
+ const inside = plannedNodes.filter(node => group.childIds.includes(node.model.id));
374
+ if (inside.length !== 1)
375
+ continue;
376
+ const { alias } = inside[0];
377
+ aliasOfModel.set(group.id, alias);
378
+ for (const childId of group.childIds) {
379
+ // Never over an element that already speaks for itself: a second C4 node
380
+ // is excluded above, and a shape is its own answer.
381
+ if (!aliasOfModel.has(childId))
382
+ aliasOfModel.set(childId, alias);
383
+ }
384
+ }
385
+ const relations = [];
386
+ for (const connector of board.connectors) {
387
+ // A NEUTRAL connector states nothing (`docs/adr/0010`): not a relationship.
388
+ if (connector.role !== C4_ROLE.relationship)
389
+ continue;
390
+ const from = connector.source?.id;
391
+ const to = connector.target?.id;
392
+ if (!from || !to)
393
+ continue;
394
+ const source = aliasOfModel.get(from);
395
+ const target = aliasOfModel.get(to);
396
+ // An end on a boundary, on a sticky note, or on an element of another
397
+ // board: there is no alias in THIS document to point at.
398
+ if (!source || !target)
399
+ continue;
400
+ relations.push(`Rel(${source}, ${target}, "${labelOf(connector.text) || UNNAMED}")`);
401
+ }
402
+ /* ── The diagram type ────────────────────────────────────────────── */
403
+ let level = 'context';
404
+ for (const node of plannedNodes) {
405
+ if (LEVEL_RANK[node.mapping.level] > LEVEL_RANK[level]) {
406
+ level = node.mapping.level;
407
+ }
408
+ }
409
+ /* ── The document ────────────────────────────────────────────────── */
410
+ const lines = [];
411
+ const name = labelOf(frame.name);
412
+ // Only when there are several: a single document needs no signpost, and a
413
+ // comment nobody asked for is a line to scroll past.
414
+ if (announce)
415
+ lines.push(`%% ── ${name || UNNAMED}`);
416
+ lines.push(DIAGRAM_OF_LEVEL[level]);
417
+ // `title` is a C4 statement in mermaid, not a front-matter directive, and it
418
+ // takes the rest of the line unquoted — so the sanitizer's one-line guarantee
419
+ // is what keeps it from swallowing the next macro. An unnamed board simply has
420
+ // no title: an empty `title` line renders as a blank heading.
421
+ if (name)
422
+ lines.push(` title ${name}`);
423
+ const emit = (parent, indent) => {
424
+ for (const node of plannedNodes) {
425
+ if (node.parent !== parent)
426
+ continue;
427
+ const args = [node.alias, `"${node.name}"`];
428
+ // Positional, so a later argument is NEVER emitted without the earlier
429
+ // one: a container with a description and no technology writes an explicit
430
+ // empty `""` to hold the slot open, which is what mermaid's grammar
431
+ // requires and what keeps the description from being read as a technology.
432
+ if (MACRO_TAKES_TECHN[node.mapping.macro] && (node.techn || node.descr)) {
433
+ args.push(`"${node.techn}"`);
434
+ }
435
+ if (node.descr)
436
+ args.push(`"${node.descr}"`);
437
+ lines.push(`${indent}${node.mapping.macro}(${args.join(', ')})`);
438
+ }
439
+ for (const [index, boundary] of plannedBoundaries.entries()) {
440
+ if (boundary.parent !== parent)
441
+ continue;
442
+ lines.push(`${indent}${boundary.macro}(${boundary.alias}, "${boundary.name}") {`);
443
+ emit(index, `${indent} `);
444
+ lines.push(`${indent}}`);
445
+ }
446
+ };
447
+ emit(-1, ' ');
448
+ // Every relationship at the diagram level, after every element: mermaid
449
+ // resolves a `Rel` by alias, and one written inside a boundary block is not
450
+ // scoped to it anyway — putting them together is what makes the file readable.
451
+ for (const relation of relations)
452
+ lines.push(` ${relation}`);
453
+ return `${lines.join('\n')}\n`;
454
+ }
@@ -0,0 +1,16 @@
1
+ export { C4_BOARD_BACKGROUND, C4_BOUNDARY_BACKGROUND } from './background.js';
2
+ export { BOUNDARY_LABEL, type C4NodePaint, DESCRIPTION_PLACEHOLDER, NODE_LABEL, NODE_PALETTE, NODE_RADIUS, NODE_SIZE, NODE_STROKE_WIDTH, RELATIONSHIP_STROKE, RELATIONSHIP_WIDTH, } from './consts.js';
3
+ export { C4_TYPE_PLACEHOLDER, C4_TYPE_TAKES_TECHNOLOGY, C4_TYPE_WORD, c4MorphedTypeLine, c4TypeLine, normalizeC4TypeLine, technologyOfTypeLine, TYPE_TECHNOLOGY_PLACEHOLDER, } from './type-line.js';
4
+ export { c4MorphClears, c4MorphProps, c4NodeProps, GLYPH_BODY_KINDS, } from './presets.js';
5
+ export { C4_MORPH_FAMILIES, C4_MORPH_SPEC, c4MorphedTitle, c4NodeOfComponent, } from './morph.js';
6
+ export { type C4ComponentGroup, type C4ComponentTiers, c4ComponentSiblings, c4ComponentTiers, c4StatedDescription, c4StatedName, c4StatedTechnology, type C4TierBox, type C4TierBoxes, c4TierBoxes, type C4TierElement, c4TierText, } from './component.js';
7
+ export { C4_BOARD_LEVEL_MENU, type C4BoardLevelOption } from './levels.js';
8
+ export { C4_PROFILES } from './profiles.js';
9
+ export { C4_ROLE, C4_ROLE_OF_KIND, C4_ROLES, type C4Role, type C4RoleId, } from './roles.js';
10
+ export { c4BoardsForExport, c4ExportBoardOf } from './actions.js';
11
+ export { type C4ExportBoard, C4_MERMAID_OF_KIND, type C4MermaidMapping, exportC4Mermaid, toMermaidAlias, toMermaidText, } from './export.js';
12
+ export { C4_INTERCHANGE, C4_MERMAID_EXPORT, C4_MERMAID_EXTENSION, C4_MERMAID_FORMAT, C4_MERMAID_MIME, c4BoardFrom, c4SafeFilename, } from './interchange.js';
13
+ export { C4_ELEMENT_MATRIX, C4_RELATIONSHIP_MATRIX, C4_RULES, } from './rules.js';
14
+ export { c4CommandIcons, c4Commands } from './commands.js';
15
+ export { c4TranslationEntries } from './translations.js';
16
+ export { C4_AUTO_LEGEND } from './legend.js';