@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.
- package/dist/actions.d.ts +179 -0
- package/dist/actions.js +375 -0
- package/dist/background.d.ts +77 -0
- package/dist/background.js +223 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +221 -0
- package/dist/component.d.ts +192 -0
- package/dist/component.js +188 -0
- package/dist/consts.d.ts +331 -0
- package/dist/consts.js +384 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +18 -0
- package/dist/element-renderer.js +14 -0
- package/dist/element-view.d.ts +51 -0
- package/dist/element-view.js +146 -0
- package/dist/export.d.ts +184 -0
- package/dist/export.js +454 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +52 -0
- package/dist/interchange.d.ts +74 -0
- package/dist/interchange.js +143 -0
- package/dist/legend.d.ts +37 -0
- package/dist/legend.js +123 -0
- package/dist/levels.d.ts +70 -0
- package/dist/levels.js +46 -0
- package/dist/morph.d.ts +89 -0
- package/dist/morph.js +229 -0
- package/dist/node/node-renderer.d.ts +6 -0
- package/dist/node/node-renderer.js +304 -0
- package/dist/node/node-view.d.ts +45 -0
- package/dist/node/node-view.js +80 -0
- package/dist/node/type-line-watcher.d.ts +70 -0
- package/dist/node/type-line-watcher.js +142 -0
- package/dist/presets.d.ts +84 -0
- package/dist/presets.js +149 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +177 -0
- package/dist/roles.d.ts +116 -0
- package/dist/roles.js +303 -0
- package/dist/rules.d.ts +95 -0
- package/dist/rules.js +1261 -0
- package/dist/toolbar/c4-menu.d.ts +11 -0
- package/dist/toolbar/c4-menu.js +14 -0
- package/dist/toolbar/c4-senior-button.d.ts +19 -0
- package/dist/toolbar/c4-senior-button.js +23 -0
- package/dist/toolbar/config.d.ts +150 -0
- package/dist/toolbar/config.js +436 -0
- package/dist/toolbar/icons.d.ts +90 -0
- package/dist/toolbar/icons.js +157 -0
- package/dist/toolbar/senior-tool.d.ts +1 -0
- package/dist/toolbar/senior-tool.js +11 -0
- package/dist/translations.d.ts +18 -0
- package/dist/translations.js +42 -0
- package/dist/type-line.d.ts +175 -0
- package/dist/type-line.js +244 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +148 -0
- package/package.json +34 -0
package/dist/export.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|