merpeeps-mermaid-layout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Merpeeps contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # merpeeps-mermaid-layout
2
+
3
+ **Save node positions and styling for Mermaid diagrams inside the diagram file
4
+ — and render them.**
5
+
6
+ A Mermaid layout plugin, plus the metadata format behind it. Part of the
7
+ [Merpeeps project](https://github.com/sargeMonkey/merpeeps), which also provides a
8
+ visual editor for placing the boxes in the first place.
9
+
10
+ ```bash
11
+ npm install merpeeps-mermaid-layout
12
+ ```
13
+
14
+ ## The problem
15
+
16
+ Mermaid has no coordinate system. Positions are derived by the layout engine at
17
+ render time, so there is nowhere in the syntax to say *"this node goes here"*.
18
+ Every visual editor built on Mermaid runs into the same wall, and each one
19
+ invents its own private way to remember an arrangement — a sidecar file, a
20
+ database row, a proprietary format. None of them interoperate, and the moment
21
+ you leave that editor, the arrangement is gone.
22
+
23
+ ## The approach
24
+
25
+ Put the metadata in the file, under an unknown YAML frontmatter key.
26
+
27
+ ```mermaid
28
+ ---
29
+ title: Order fulfilment
30
+ x-merpeeps:
31
+ spec: 1
32
+ layout:
33
+ engine: manual
34
+ nodes:
35
+ gw: { x: 60, y: 130 }
36
+ db: { x: 300, y: 130 }
37
+ ---
38
+ flowchart LR
39
+ gw[Gateway] --> db[(Database)]
40
+ ```
41
+
42
+ Mermaid ignores frontmatter keys it does not recognise, so this file renders
43
+ **byte-for-byte identically** to the same file without the `x-merpeeps` block. Your
44
+ editor reads the positions; every other tool sees a perfectly ordinary Mermaid
45
+ diagram; git sees a readable text diff.
46
+
47
+ This is verified, not assumed — see [Verification](#verification).
48
+
49
+ ## Usage
50
+
51
+ ```js
52
+ import { getPositions, setPositions, validate, strip } from 'merpeeps-mermaid-layout';
53
+
54
+ const src = `flowchart LR\n a[Start] --> b[End]\n`;
55
+
56
+ // Remember an arrangement.
57
+ const saved = setPositions(src, {
58
+ a: { x: 0, y: 0 },
59
+ b: { x: 220, y: 0 },
60
+ });
61
+
62
+ // Read it back.
63
+ getPositions(saved); // → { a: { x: 0, y: 0 }, b: { x: 220, y: 0 } }
64
+
65
+ // The diagram itself is untouched.
66
+ strip(saved) === src; // → true
67
+
68
+ // Check the invariants without a renderer.
69
+ validate(saved).ok; // → true
70
+ ```
71
+
72
+ ### API
73
+
74
+ | Function | Purpose |
75
+ |---|---|
76
+ | `getPositions(src)` | Node positions keyed by node id |
77
+ | `setPositions(src, positions, opts?)` | Write positions, replacing any existing layout |
78
+ | `getStyles(src)` / `setStyles(src, styles)` | Per-node fill, stroke, icon and so on |
79
+ | `parse(src)` / `serialise(src, merpeeps, opts?)` | The raw metadata block |
80
+ | `inject(src, patch)` | Deep-merge a partial update |
81
+ | `strip(src)` | Remove all metadata — plain Mermaid again |
82
+ | `bodyOf(src)` | Diagram body only, metadata removed |
83
+ | `staleIds(src, knownIds)` | Positions whose node no longer exists |
84
+ | `validate(src)` | Check the offline invariants |
85
+
86
+ From `merpeeps-mermaid-layout/mermaid`:
87
+
88
+ | Function | Purpose |
89
+ |---|---|
90
+ | `registerMerpeepsLayout(mermaid)` | Register the layout algorithm once |
91
+ | `renderWithLayout(mermaid, id, src, el?)` | Render honouring stored positions and styling |
92
+ | `applyNodeStyle(nodeEl, style)` | Paint one already-rendered node |
93
+ | `styleSvg(svg, styles)` | Apply stored styling to a rendered SVG string |
94
+
95
+ Full type definitions ship with the package.
96
+
97
+ ## Rendering at the stored positions
98
+
99
+ Storing an arrangement is only half of it — something has to *draw* it. The
100
+ package ships a mermaid layout plugin, so any mermaid-based viewer can honour a
101
+ saved layout **and its styling** instead of re-deriving one:
102
+
103
+ ```js
104
+ import mermaid from 'mermaid';
105
+ import { registerMerpeepsLayout, renderWithLayout } from 'merpeeps-mermaid-layout/mermaid';
106
+
107
+ registerMerpeepsLayout(mermaid);
108
+
109
+ const { svg } = await renderWithLayout(mermaid, 'diagram-1', source);
110
+ ```
111
+
112
+ `renderWithLayout` falls straight through to `mermaid.render` when a document
113
+ stores no positions, or when it uses subgraphs (cluster bounds are not
114
+ determined by node positions alone). Stored styling is applied on either path,
115
+ so the result is never worse than stock mermaid.
116
+
117
+ The layout selection is injected **in memory only** and never written to your
118
+ file. A document that depends on a plugin being installed is exactly the
119
+ lock-in this format exists to avoid.
120
+
121
+ ### Why it needs the `renderWithLayout` wrapper
122
+
123
+ A registered mermaid layout algorithm receives a `LayoutData`, not the source
124
+ text, and mermaid drops unknown frontmatter keys before that point — so the
125
+ algorithm cannot read the Merpeeps block itself. It does receive `diagramId`, and the
126
+ caller has both the id and the text, so the wrapper bridges the two. This was
127
+ established by probing a live mermaid build, not by reading its types.
128
+
129
+ ### Styling
130
+
131
+ Stored `fill`, `stroke`, `strokeWidth`, `dashed` and `text` are painted onto the
132
+ rendered SVG rather than pushed through mermaid's `style`/`classDef` syntax.
133
+ Writing style into the diagram body would change the file, and the whole point
134
+ of Merpeeps is that presentation can be stored *without* touching what the diagram
135
+ says.
136
+
137
+ ### Two carriers
138
+
139
+ Frontmatter is the default and works for every diagram type. Some hosts strip
140
+ frontmatter, so a comment carrier is available as a fallback:
141
+
142
+ ```js
143
+ serialise(src, merpeeps, { carrier: 'comment' });
144
+ // → …\n%% merpeeps1 {"spec":1,"layout":{…}}
145
+ ```
146
+
147
+ Mermaid's `@{ ... }` node metadata was considered and rejected as the primary
148
+ carrier: it is **flowchart-only**, unavailable in C4, architecture and the other
149
+ graph types.
150
+
151
+ ## Guarantees
152
+
153
+ 1. **The body is never modified.** Writing metadata is byte-preserving; the
154
+ diagram text you wrote is the diagram text that stays on disk.
155
+ 2. **Writing twice converges.** No churn in git from repeated saves.
156
+ 3. **Nothing leaks into the rendered output.** No metadata token appears in the
157
+ SVG a stock renderer produces.
158
+ 4. **Stale entries are reported, never deleted.** Renaming a node in text does
159
+ not silently destroy the layout you arranged — `staleIds` tells you, and you
160
+ decide.
161
+
162
+ ## Verification
163
+
164
+ The degradation claim is the one everything else depends on, so it is measured
165
+ rather than asserted. The reference suite renders every Mermaid diagram type
166
+ twice — once plain, once with metadata attached — in a real browser, and
167
+ compares the results.
168
+
169
+ Against **Mermaid 11.16.1**:
170
+
171
+ | Claim | Result |
172
+ |---|---|
173
+ | Unknown frontmatter keys are ignored | ✅ every diagram type |
174
+ | `%%` comments are ignored | ✅ every diagram type |
175
+ | Extended file renders identically to the plain file | ✅ 30 / 30 renderable types |
176
+ | No metadata reaches the rendered surface | ✅ zero leaks |
177
+
178
+ Two details worth knowing if you build your own checker: leak detection must
179
+ scan the *user-visible* text rather than the raw SVG, because short numeric
180
+ tokens collide with floating-point path data; and a few diagram types are
181
+ non-deterministic between renders, so a baseline has to be rendered twice before
182
+ a difference can be blamed on your change.
183
+
184
+ ## Scope
185
+
186
+ This package is the **storage contract** plus a renderer that honours it. It
187
+ stores positions and can draw them; it does not *compute* them. That is
188
+ deliberate — the value is in every tool agreeing on where an arrangement lives,
189
+ and shipping a layout algorithm would only invite disagreement about which one
190
+ is best. Bring your own layout, or a user's mouse.
191
+
192
+ ### Not an alternative to `@mermaid-js/layout-elk`
193
+
194
+ Merpeeps registers through the same plugin interface as the official
195
+ [`@mermaid-js/layout-elk`](https://www.npmjs.com/package/@mermaid-js/layout-elk)
196
+ and [`@mermaid-js/layout-tidy-tree`](https://www.npmjs.com/package/@mermaid-js/layout-tidy-tree),
197
+ which makes them look like competitors. They solve opposite halves of the
198
+ problem:
199
+
200
+ | | Official layout plugins | Merpeeps |
201
+ |---|---|---|
202
+ | Input | Graph structure | Stored coordinates |
203
+ | Output | A computed arrangement | The arrangement that was saved |
204
+ | Author intent | Cannot be expressed | Is the whole input |
205
+ | Reads externally supplied positions | No | Yes, that is the point |
206
+ | Size | 15 MB (elk) · 242 KB (tidy-tree) | 17 KB |
207
+
208
+ ELK and tidy-tree are *better algorithms*. Merpeeps is *no algorithm* — it
209
+ reproduces a decision a person already made. Use them together: let ELK do the
210
+ first pass, move the few nodes it got wrong, and save the result.
211
+
212
+ The format is specified in [`SPEC.md`](./SPEC.md). Implementations exist in
213
+ JavaScript (this package) and C#, checked against a shared conformance suite so
214
+ the two cannot drift apart.
215
+
216
+ ## Licence and names
217
+
218
+ MIT — see [`LICENSE`](./LICENSE).
219
+
220
+ **Not affiliated with, endorsed by, or sponsored by Mermaid Chart Inc. or the
221
+ Mermaid open-source project.** "Mermaid" is used here only to describe the file
222
+ format this tool reads and writes.
223
+
224
+ ---
225
+
226
+ ## Part of Merpeeps
227
+
228
+ | | |
229
+ |---|---|
230
+ | [Repository](https://github.com/sargeMonkey/merpeeps) | Source, issues, and the full story |
231
+ | [Visual editor](https://github.com/sargeMonkey/merpeeps/blob/main/docs/GUIDE.md) | A desktop app for placing the boxes — this package renders what it saves |
232
+ | [SPEC.md](SPEC.md) | The stored format, and what a reader must do with it |
233
+
234
+ MIT licensed. Not affiliated with, endorsed by, or sponsored by Mermaid Chart
235
+ Inc. or the Mermaid open-source project.
package/SPEC.md ADDED
@@ -0,0 +1,252 @@
1
+ # Merpeeps — format specification, v0.1 (draft)
2
+
3
+ A **type-agnostic** sidecar layer for layout, style and semantics that rides
4
+ inside ordinary Mermaid files and degrades gracefully in stock renderers.
5
+
6
+ ## Design constraints (empirically established)
7
+
8
+ | Constraint | Evidence |
9
+ |---|---|
10
+ | `@{ }` per-node metadata is **flowchart-only** | mermaid `flowDb.ts` dispatch; not present in architecture/C4 grammars |
11
+ | Unknown YAML frontmatter keys are silently ignored | verified: parse + render clean, mermaid 11.16.1 |
12
+ | `%%` comments are always ignored | verified: parse + render clean |
13
+ | Extension must not alter rendered output | verified: zero leak, identical node/edge counts |
14
+
15
+ **Chosen carrier: YAML frontmatter under a single namespaced key.**
16
+ Rationale: structured (mermaid already runs `js-yaml` over it), one block rather
17
+ than scattered comments, trivially diffable, and type-agnostic — frontmatter is
18
+ handled by mermaid's common preprocessor, not per-diagram grammars.
19
+
20
+ `%%` comments are the **fallback carrier** for hosts that strip frontmatter
21
+ (see §6).
22
+
23
+ ## 1. Shape
24
+
25
+ ```yaml
26
+ ---
27
+ title: Payment flow # untouched official key
28
+ x-merpeeps:
29
+ spec: 1
30
+ layout:
31
+ nodes:
32
+ A: { x: 120, y: 80, w: 160, h: 60 }
33
+ B: { x: 300, y: 240 }
34
+ edges:
35
+ "A->B#0": { waypoints: [[200,150],[260,200]], labelT: 0.5 }
36
+ viewport: { x: 0, y: 0, zoom: 1 }
37
+ engine: manual # manual | dagre | elk | fcose
38
+ style:
39
+ nodes:
40
+ A:
41
+ fill: "#dae8fc" # any CSS colour
42
+ stroke: "#6c8ebf"
43
+ text: "#1a1a1a"
44
+ strokeWidth: 2
45
+ dashed: false
46
+ icon: gateway # see §6b
47
+ edges:
48
+ "A->B#0": { stroke: "#6c8ebf", width: 2, dash: false }
49
+ semantics:
50
+ A: { kind: container, tech: "Node.js", team: platform, url: "…" }
51
+ extras: {} # reserved; preserved verbatim
52
+ ---
53
+ flowchart TD
54
+ A[Start] --> B[End]
55
+ ```
56
+
57
+ Every key is **optional**. A file carrying only `layout.nodes` is valid.
58
+
59
+ ## 2. Identity (the hard part)
60
+
61
+ The spec joins to the diagram by **entity id**. Mermaid has no universal id
62
+ concept, so each diagram type needs a small *identity adapter* — far smaller
63
+ than a per-type editor.
64
+
65
+ | Type family | Node identity | Edge identity |
66
+ |---|---|---|
67
+ | flowchart, class, state, ER | declared id | `"<src>-><dst>#<ordinal>"` |
68
+ | sequence | participant id/alias | message ordinal `"#<n>"` |
69
+ | C4, architecture | declared alias | `"<src>-><dst>#<ordinal>"` |
70
+ | pie, venn, radar, xychart | label text (normalised) | n/a |
71
+ | gantt, kanban, timeline | task/item id, else ordinal | n/a |
72
+ | mindmap, treemap, treeView | path from root `"root/a/b"` | n/a |
73
+
74
+ **Ordinal suffix** (`#0`) disambiguates parallel edges between the same pair.
75
+ Ordinals are assigned in **source order** and are stable under edits that do not
76
+ reorder statements.
77
+
78
+ **Anonymous entities** get a synthetic, reserved id. A state diagram's `[*]`
79
+ carries no id yet needs a position, and it means *start* in the source position
80
+ but *end* in the target position — so it maps to two distinct ids, `__start` and
81
+ `__end`, rather than one. Reserved ids use a `__` prefix, which mermaid's own
82
+ grammars do not produce.
83
+
84
+ **Rule:** an identity that no longer resolves is *retained, not deleted*, and
85
+ flagged `stale` — so renaming a node in text never silently destroys its layout.
86
+
87
+ ## 3. Invariants (all machine-checkable)
88
+
89
+ 1. **Degradation** — stock Mermaid renders base and Merpeeps-extended source to the
90
+ same visible text and the same node/edge counts.
91
+ 2. **No leak** — no Merpeeps token appears in rendered output.
92
+ 3. **Round-trip** — `serialise(parse(x))` preserves every byte outside the
93
+ `x-merpeeps` block.
94
+ 4. **Idempotence** — applying `serialise(parse(·))` twice equals once.
95
+ 5. **Merge safety** — injecting into a file that already has frontmatter must
96
+ merge, never emit a second `---` block.
97
+ 6. **Unknown preservation** — unrecognised keys under `x-merpeeps` survive a
98
+ round-trip verbatim (forward compatibility).
99
+ 7. **Type agnosticism** — 1–6 hold for every Mermaid diagram type that renders.
100
+
101
+ ## 4. Versioning
102
+
103
+ `spec` is an integer. A reader encountering `spec` greater than it understands
104
+ MUST still render, MUST preserve the block verbatim, and SHOULD warn.
105
+
106
+ ## 5. Why `x-` prefix
107
+
108
+ Reserves the namespace against future official mermaid frontmatter keys, mirroring
109
+ the `x-` convention in OpenAPI/HTTP. If mermaid later blesses a layout key, Merpeeps
110
+ migrates rather than collides.
111
+
112
+ ## 6. Fallback carrier
113
+
114
+ Where frontmatter is stripped (some wikis, some markdown pipelines), the same
115
+ document is emitted as a single comment line:
116
+
117
+ ```
118
+ %% merpeeps1 {"layout":{"nodes":{"A":{"x":120,"y":80}}}}
119
+ ```
120
+
121
+ Readers MUST accept either. Writers SHOULD prefer frontmatter.
122
+
123
+ ## 6b. Icon vocabulary
124
+ `style.nodes.<id>.icon` names a glyph drawn in place of a plain box. This is among
125
+ the most-requested node features in mermaid (#1723, 122 👍, open since 2020) and
126
+ is not expressible in mermaid syntax, which is why it belongs here.
127
+
128
+ ### Core names
129
+
130
+ Names with **no prefix** are the core vocabulary. They are stable, always
131
+ available, and no pack may claim one:
132
+
133
+ `server`, `database`, `storage`, `queue`, `cache`, `function`, `gateway`,
134
+ `user`, `browser`, `mobile`, `shield`, `cloud`, `cog`
135
+
136
+ The empty string means "no icon".
137
+
138
+ ### Pack names
139
+
140
+ Everything else is `pack:name` — `aws:lambda`, `azure:sql`, `bpmn:gateway`. The
141
+ prefix is what stops two independent packs colliding on `gateway`.
142
+
143
+ ```
144
+ icon-name = core-name / pack-id ":" local-name
145
+ pack-id = 1*( ALPHA / DIGIT / "-" ) ; lowercase
146
+ local-name = 1*( ALPHA / DIGIT / "-" / "_" ) ; lowercase
147
+ ```
148
+
149
+ A name with more than one `:` is invalid; readers MUST treat it as unknown
150
+ rather than guessing at the intent.
151
+
152
+ ### What an unrecognised name means
153
+
154
+ **A reader that does not recognise a name MUST draw the plain shape, MUST retain
155
+ the name unchanged, and MUST NOT fail.**
156
+
157
+ All three clauses carry weight:
158
+
159
+ - **Draw the plain shape** — a missing pack degrades the picture, never the
160
+ document. This is also exactly what mermaid does with an unregistered icon
161
+ pack: the node renders as an ordinary box.
162
+ - **Retain the name** — the same rule as unresolvable node ids in §2. Opening a
163
+ diagram on a machine without the AWS pack and saving it must not silently
164
+ strip every AWS icon from a colleague's work. Deletion is never the safe
165
+ default when the tool is the thing that is missing, not the data.
166
+ - **Do not fail** — a document that will not open because of a decorative
167
+ reference is a worse outcome than one that opens plainer than intended.
168
+
169
+ A reader SHOULD surface which packs are referenced but unavailable, so the
170
+ degradation is visible rather than mysterious.
171
+
172
+ ### What packs may not do
173
+
174
+ A pack MUST NOT define a core name, and MUST NOT redefine another pack's names.
175
+ Where a pack is loaded that violates this, the core or incumbent definition wins
176
+ and the conflict SHOULD be reported. Otherwise installing a pack could silently
177
+ change how an existing diagram looks, which would make icons untrustworthy.
178
+
179
+ Pack distribution is deliberately out of scope here. This section fixes only
180
+ what a name *means*; how a pack reaches a reader is a tooling concern.
181
+
182
+ ## 6c. Layout constraints
183
+
184
+ `layout.constraints` expresses arrangement as **relationships** rather than
185
+ coordinates:
186
+
187
+ ```yaml
188
+ layout:
189
+ constraints:
190
+ - same-rank: [api, cache] # keep these on one layer
191
+ - order: [web, api, db] # sequence them within their layer
192
+ ```
193
+
194
+ ### Why this and not coordinates
195
+
196
+ Both express layout intent. They differ on what happens when they go stale, and
197
+ that difference is the whole argument:
198
+
199
+ | | When the diagram changes underneath it |
200
+ |---|---|
201
+ | `x: 10, y: 20` | describes nothing; the node lands somewhere wrong while the picture still looks plausible — **silently wrong** |
202
+ | `same-rank: [a, b]` | stops applying; the engine lays out normally — **correct, just not hand-tuned** |
203
+
204
+ Wrong-but-plausible is a worse failure than correct-but-ordinary, and it is a
205
+ difference in kind rather than degree. A coordinate is presentational data that
206
+ can *disagree with the graph*; a constraint is semantic and can only become
207
+ irrelevant.
208
+
209
+ Constraints are also sparse by nature — you state something about the two or
210
+ three nodes you care about — where coordinates pull towards an entry per node,
211
+ so there is far less to keep in step in the first place.
212
+
213
+ This is what every durable text diagram language converged on independently:
214
+ Graphviz, D2, TikZ and PlantUML all make relationship placement first-class and
215
+ quarantine absolute coordinates into a separate opt-in mode. Mermaid's own
216
+ most-upvoted layout request (#3723, "specify that two nodes should be at the same
217
+ level", 82 👍, open since 2022) is asking for a rank constraint, not a coordinate
218
+ map.
219
+
220
+ ### Tiers
221
+
222
+ | Tier | Mechanism | When |
223
+ |---|---|---|
224
+ | 0 | nothing | the default; the engine arranges everything |
225
+ | 1 | `layout.constraints` | **the recommended way to influence a layout** |
226
+ | 2 | `layout.nodes` coordinates | pixel-perfect escape hatch, written only on an explicit request |
227
+
228
+ Tier 2 is not deprecated — constraints cannot reproduce an arbitrary hand-dragged
229
+ arrangement, and nothing else can. It is demoted, not removed.
230
+
231
+ ### Rules
232
+
233
+ - **Stale ids are retained and reported, never removed.** A constraint naming a
234
+ node that no longer exists is inert. Deleting it would destroy intent the
235
+ moment someone renamed a node on a branch.
236
+ - **A group with fewer than two surviving members does nothing.**
237
+ - **`same-rank` moves the group to the deepest member's layer**, never the
238
+ shallowest. Pulling nodes upwards would place them above their own
239
+ predecessors and turn forward edges into back edges, which reads as a broken
240
+ diagram rather than an honoured constraint.
241
+ - **`order` only sequences members that share a layer.** It expresses
242
+ left-to-right intent, not a demand to move nodes between layers.
243
+ - **Constraint kinds a reader does not model MUST be retained verbatim.**
244
+ Partial knowledge is worse than none: a reader that parses the list and
245
+ re-emits only what it understands destroys a newer tool's work merely by
246
+ opening the file.
247
+
248
+ ## 7. Open questions (need human decision)
249
+
250
+ - Should `semantics.kind` use a closed C4 vocabulary or be free-form?
251
+ - Should edge waypoints be absolute or relative to endpoints?
252
+ - Do we normalise colour to hex, or preserve author input?
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "merpeeps-mermaid-layout",
3
+ "version": "0.1.0",
4
+ "description": "Save node positions and styling for Mermaid diagrams inside the diagram file itself, and render them. A mermaid layout plugin plus the metadata format behind it.",
5
+ "keywords": [
6
+ "mermaid",
7
+ "mermaid-layout",
8
+ "mermaid-plugin",
9
+ "mermaid-diagram",
10
+ "layout",
11
+ "positions",
12
+ "diagram",
13
+ "flowchart",
14
+ "graph",
15
+ "frontmatter",
16
+ "visual-editor",
17
+ "merpeeps"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "Merpeeps contributors",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/sargeMonkey/merpeeps.git",
24
+ "directory": "packages/merpeeps-mermaid-layout"
25
+ },
26
+ "homepage": "https://github.com/sargeMonkey/merpeeps#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/sargeMonkey/merpeeps/issues"
29
+ },
30
+ "type": "module",
31
+ "main": "./src/index.js",
32
+ "types": "./src/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./src/index.d.ts",
36
+ "default": "./src/index.js"
37
+ },
38
+ "./mermaid": {
39
+ "types": "./src/mermaid-plugin.d.ts",
40
+ "default": "./src/mermaid-plugin.js"
41
+ }
42
+ },
43
+ "files": [
44
+ "src/",
45
+ "SPEC.md",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "dependencies": {
53
+ "js-yaml": "^4.1.0"
54
+ },
55
+ "scripts": {
56
+ "test": "node --test",
57
+ "test:package": "node ../../oracle/package-consumer-test.mjs merpeeps-mermaid-layout",
58
+ "release:check": "npm test && npm run test:package",
59
+ "prepublishOnly": "npm run release:check"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public",
63
+ "registry": "https://registry.npmjs.org/"
64
+ },
65
+ "sideEffects": false
66
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ /** A node's stored position, in diagram coordinates. */
2
+ export interface Position {
3
+ x: number;
4
+ y: number;
5
+ /** Width. Omitted when the renderer's default is fine. */
6
+ w?: number;
7
+ /** Height. Omitted when the renderer's default is fine. */
8
+ h?: number;
9
+ }
10
+
11
+ /** Per-node presentation. All fields optional; unknown fields are preserved. */
12
+ export interface NodeStyle {
13
+ fill?: string;
14
+ stroke?: string;
15
+ text?: string;
16
+ strokeWidth?: number;
17
+ dashed?: boolean;
18
+ icon?: string;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ /** The parsed `x-merpeeps` block. Unknown keys are preserved on write. */
23
+ export interface MerpeepsBlock {
24
+ spec?: number;
25
+ layout?: {
26
+ nodes?: Record<string, Position>;
27
+ engine?: string;
28
+ [key: string]: unknown;
29
+ };
30
+ style?: {
31
+ nodes?: Record<string, NodeStyle>;
32
+ [key: string]: unknown;
33
+ };
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ export interface SplitResult {
38
+ /** Raw frontmatter text, or null when the document has none. */
39
+ fmText: string | null;
40
+ /** Parsed frontmatter mapping. Empty when absent or malformed. */
41
+ fm: Record<string, unknown>;
42
+ /** Everything after the frontmatter. */
43
+ body: string;
44
+ }
45
+
46
+ export interface SerialiseOptions {
47
+ /**
48
+ * `'frontmatter'` (default) writes under the `x-merpeeps` key.
49
+ * `'comment'` writes a `%% merpeeps1 {...}` line instead, for hosts that strip
50
+ * frontmatter.
51
+ */
52
+ carrier?: 'frontmatter' | 'comment';
53
+ }
54
+
55
+ export interface SetPositionsOptions {
56
+ /** Free-form note about what produced the layout. Defaults to `'manual'`. */
57
+ engine?: string;
58
+ }
59
+
60
+ export interface ValidationIssue {
61
+ rule: 'parse' | 'shape' | 'spec' | 'layout' | 'body' | 'idempotent';
62
+ message: string;
63
+ }
64
+
65
+ export interface ValidationResult {
66
+ ok: boolean;
67
+ issues: ValidationIssue[];
68
+ }
69
+
70
+ export declare const KEY: 'x-merpeeps';
71
+ export declare const SPEC_VERSION: number;
72
+
73
+ export declare function split(src: string): SplitResult;
74
+ export declare function parse(src: string): MerpeepsBlock | null;
75
+ export declare function serialise(
76
+ src: string,
77
+ merpeeps: MerpeepsBlock | null,
78
+ options?: SerialiseOptions,
79
+ ): string;
80
+ export declare function inject(src: string, patch: MerpeepsBlock): string;
81
+ export declare function strip(src: string): string;
82
+ export declare function bodyOf(src: string): string;
83
+
84
+ export declare function getPositions(src: string): Record<string, Position>;
85
+ export declare function setPositions(
86
+ src: string,
87
+ positions: Record<string, Position>,
88
+ options?: SetPositionsOptions,
89
+ ): string;
90
+
91
+ export declare function getStyles(src: string): Record<string, NodeStyle>;
92
+ export declare function setStyles(
93
+ src: string,
94
+ styles: Record<string, NodeStyle>,
95
+ ): string;
96
+
97
+ export declare function staleIds(src: string, knownIds: Iterable<string>): string[];
98
+ export declare function validate(src: string): ValidationResult;
package/src/index.js ADDED
@@ -0,0 +1,310 @@
1
+ // Merpeeps v0.1 — store layout, styling and semantics inside ordinary Mermaid files.
2
+ //
3
+ // The whole design rests on one property, verified against Mermaid 11.16.1:
4
+ // an unknown key in YAML frontmatter is ignored by every diagram type, so the
5
+ // metadata rides along invisibly and a stock renderer draws exactly the same
6
+ // picture it would have drawn without it.
7
+ //
8
+ // Pure functions, no DOM, no renderer. Safe in Node and the browser.
9
+
10
+ import * as yaml from 'js-yaml';
11
+
12
+ /** The frontmatter key everything lives under. */
13
+ export const KEY = 'x-merpeeps';
14
+
15
+ /** Spec version written into new blocks. */
16
+ export const SPEC_VERSION = 1;
17
+
18
+ const FM = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
19
+ const COMMENT = /^%%[ \t]*merpeeps1[ \t]+(\{[\s\S]*?\})[ \t]*$/m;
20
+
21
+ // Removal must consume the comment's own line terminator. Matching only the
22
+ // line content leaves a blank line behind, so stripping metadata would not
23
+ // restore the original body byte-for-byte — which is the one promise the
24
+ // format cannot break.
25
+ const COMMENT_LINE = /^%%[ \t]*merpeeps1[ \t]+\{[\s\S]*?\}[ \t]*(?:\r?\n|$)/m;
26
+
27
+ // ---------------------------------------------------------------- carrier
28
+
29
+ /**
30
+ * Split a Mermaid document into its frontmatter and body.
31
+ * A document with no frontmatter yields `{ fmText: null, fm: {}, body: src }`.
32
+ */
33
+ export function split(src) {
34
+ assertString(src, 'src');
35
+ const m = src.match(FM);
36
+ if (!m) return { fmText: null, fm: {}, body: src };
37
+ let fm = {};
38
+ try {
39
+ const loaded = yaml.load(m[1]);
40
+ fm = loaded && typeof loaded === 'object' && !Array.isArray(loaded) ? loaded : {};
41
+ } catch {
42
+ fm = {};
43
+ }
44
+ return { fmText: m[1], fm, body: src.slice(m[0].length) };
45
+ }
46
+
47
+ /**
48
+ * Read the Merpeeps block: frontmatter first, then the `%% merpeeps1 {...}` fallback.
49
+ * Returns null when the document carries no metadata.
50
+ */
51
+ export function parse(src) {
52
+ const { fm, body } = split(src);
53
+ if (fm && typeof fm === 'object' && fm[KEY] != null) return fm[KEY];
54
+
55
+ const c = body.match(COMMENT) ?? src.match(COMMENT);
56
+ if (c) {
57
+ try {
58
+ return JSON.parse(c[1]);
59
+ } catch {
60
+ /* malformed fallback: treat as absent rather than throwing */
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Write an Merpeeps block into `src`, merging into any existing frontmatter.
68
+ *
69
+ * Never emits a second `---` block, and never alters the diagram body.
70
+ * Pass `merpeeps = null` to remove the block entirely.
71
+ */
72
+ export function serialise(src, merpeeps, { carrier = 'frontmatter' } = {}) {
73
+ assertString(src, 'src');
74
+ const { fm, body } = split(src);
75
+
76
+ if (carrier === 'comment') {
77
+ // The comment carrier is self-contained, so a frontmatter copy of the
78
+ // block would be a second source of truth and is dropped — but the
79
+ // author's other frontmatter keys are not ours to discard.
80
+ const rest = { ...fm };
81
+ delete rest[KEY];
82
+ const head = withFrontmatter(rest, '');
83
+ const base = stripComment(body);
84
+ if (merpeeps == null) return `${head}${base}`;
85
+
86
+ // Separate only when the body does not already end with a newline.
87
+ // Trimming instead would delete trailing blank lines the author wrote.
88
+ const sep = base.length === 0 || base.endsWith('\n') ? '' : '\n';
89
+ return `${head}${base}${sep}%% merpeeps1 ${JSON.stringify(merpeeps)}\n`;
90
+ }
91
+
92
+ const next = { ...fm };
93
+ if (merpeeps == null) delete next[KEY];
94
+ else next[KEY] = merpeeps;
95
+ return withFrontmatter(next, stripComment(body));
96
+ }
97
+
98
+ /** Deep-merge `patch` into the existing Merpeeps block and write it back. */
99
+ export function inject(src, patch) {
100
+ const cur = parse(src) ?? { spec: SPEC_VERSION };
101
+ return serialise(src, deepMerge(cur, patch));
102
+ }
103
+
104
+ /** The document with every Merpeeps carrier removed — plain Mermaid again. */
105
+ export function strip(src) {
106
+ return serialise(src, null);
107
+ }
108
+
109
+ /** Body only, with any Merpeeps carrier removed. Used to prove byte-preservation. */
110
+ export function bodyOf(src) {
111
+ return stripComment(split(src).body);
112
+ }
113
+
114
+ // ---------------------------------------------------------------- layout
115
+
116
+ /**
117
+ * Node positions keyed by diagram node id, e.g. `{ a: { x: 0, y: 0 } }`.
118
+ * Returns an empty object when the document carries none.
119
+ */
120
+ export function getPositions(src) {
121
+ const nodes = parse(src)?.layout?.nodes;
122
+ if (!nodes || typeof nodes !== 'object') return {};
123
+
124
+ const out = {};
125
+ for (const [id, raw] of Object.entries(nodes)) {
126
+ if (!raw || typeof raw !== 'object') continue;
127
+ const x = num(raw.x);
128
+ const y = num(raw.y);
129
+ if (x === null || y === null) continue;
130
+ const entry = { x, y };
131
+ if (num(raw.w) !== null) entry.w = num(raw.w);
132
+ if (num(raw.h) !== null) entry.h = num(raw.h);
133
+ out[id] = entry;
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * Write node positions, replacing any existing layout.
140
+ *
141
+ * Positions are *replaced* rather than merged, because merging would silently
142
+ * resurrect nodes the caller had deleted.
143
+ */
144
+ export function setPositions(src, positions, { engine = 'manual' } = {}) {
145
+ assertPlainObject(positions, 'positions');
146
+
147
+ const nodes = {};
148
+ for (const [id, p] of Object.entries(positions)) {
149
+ if (!p || typeof p !== 'object') {
150
+ throw new TypeError(`position for "${id}" must be an object with x and y`);
151
+ }
152
+ const x = num(p.x);
153
+ const y = num(p.y);
154
+ if (x === null || y === null) {
155
+ throw new TypeError(`position for "${id}" needs finite numeric x and y`);
156
+ }
157
+ const entry = { x, y };
158
+ if (num(p.w) !== null) entry.w = num(p.w);
159
+ if (num(p.h) !== null) entry.h = num(p.h);
160
+ nodes[id] = entry;
161
+ }
162
+
163
+ const cur = parse(src) ?? {};
164
+ return serialise(src, {
165
+ ...cur,
166
+ spec: cur.spec ?? SPEC_VERSION,
167
+ layout: { ...cur.layout, nodes, engine },
168
+ });
169
+ }
170
+
171
+ /** Per-node presentation (fill, stroke, icon…), keyed by node id. */
172
+ export function getStyles(src) {
173
+ const nodes = parse(src)?.style?.nodes;
174
+ return nodes && typeof nodes === 'object' ? { ...nodes } : {};
175
+ }
176
+
177
+ /** Write per-node presentation, replacing any existing style block. */
178
+ export function setStyles(src, styles) {
179
+ assertPlainObject(styles, 'styles');
180
+ const cur = parse(src) ?? {};
181
+ return serialise(src, {
182
+ ...cur,
183
+ spec: cur.spec ?? SPEC_VERSION,
184
+ style: { ...cur.style, nodes: { ...styles } },
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Positions whose node id no longer appears in `knownIds`.
190
+ *
191
+ * Stale entries are reported, never auto-deleted: renaming a node in text
192
+ * should not silently destroy the layout you spent time arranging.
193
+ */
194
+ export function staleIds(src, knownIds) {
195
+ const known = new Set(knownIds ?? []);
196
+ return Object.keys(getPositions(src)).filter((id) => !known.has(id));
197
+ }
198
+
199
+ // ------------------------------------------------------------- validation
200
+
201
+ /**
202
+ * Check the invariants that do not need a renderer.
203
+ *
204
+ * Returns `{ ok, issues }`. Rendering equivalence is the one invariant this
205
+ * cannot check offline — for that, render both documents and compare.
206
+ */
207
+ export function validate(src) {
208
+ const issues = [];
209
+ const push = (rule, message) => issues.push({ rule, message });
210
+
211
+ let merpeeps = null;
212
+ try {
213
+ merpeeps = parse(src);
214
+ } catch (e) {
215
+ push('parse', `metadata could not be read: ${e.message}`);
216
+ return { ok: false, issues };
217
+ }
218
+
219
+ if (merpeeps == null) return { ok: true, issues };
220
+
221
+ if (typeof merpeeps !== 'object' || Array.isArray(merpeeps)) {
222
+ push('shape', 'the Merpeeps block must be a mapping');
223
+ return { ok: false, issues };
224
+ }
225
+ if (merpeeps.spec !== undefined && !Number.isInteger(merpeeps.spec)) {
226
+ push('spec', '"spec" must be an integer when present');
227
+ }
228
+
229
+ const nodes = merpeeps.layout?.nodes;
230
+ if (nodes !== undefined) {
231
+ if (!nodes || typeof nodes !== 'object' || Array.isArray(nodes)) {
232
+ push('layout', 'layout.nodes must be a mapping of id to position');
233
+ } else {
234
+ for (const [id, p] of Object.entries(nodes)) {
235
+ if (!p || typeof p !== 'object') {
236
+ push('layout', `layout.nodes["${id}"] must be an object`);
237
+ continue;
238
+ }
239
+ if (num(p.x) === null || num(p.y) === null) {
240
+ push('layout', `layout.nodes["${id}"] needs finite numeric x and y`);
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ // Body preservation is the promise the whole format rests on.
247
+ try {
248
+ if (bodyOf(serialise(src, merpeeps)) !== bodyOf(src)) {
249
+ push('body', 'rewriting the metadata would change the diagram body');
250
+ }
251
+ } catch (e) {
252
+ push('body', `round-trip failed: ${e.message}`);
253
+ }
254
+
255
+ // Writing twice must converge, or files churn on every save.
256
+ try {
257
+ const once = serialise(src, merpeeps);
258
+ if (serialise(once, parse(once)) !== once) {
259
+ push('idempotent', 'writing the metadata twice does not converge');
260
+ }
261
+ } catch (e) {
262
+ push('idempotent', `idempotence check failed: ${e.message}`);
263
+ }
264
+
265
+ return { ok: issues.length === 0, issues };
266
+ }
267
+
268
+ // ---------------------------------------------------------------- helpers
269
+
270
+ function stripComment(body) {
271
+ // No blank-line collapsing here: rewriting the author's whitespace would be
272
+ // a body modification, and COMMENT_LINE already removes cleanly.
273
+ return body.replace(COMMENT_LINE, '');
274
+ }
275
+
276
+ function withFrontmatter(fm, body) {
277
+ const keys = Object.keys(fm ?? {});
278
+ if (keys.length === 0) return body;
279
+ const text = yaml.dump(fm, { lineWidth: -1, noRefs: true }).replace(/\s*$/, '');
280
+ return `---\n${text}\n---\n${body}`;
281
+ }
282
+
283
+ function deepMerge(a, b) {
284
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) return b;
285
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) return b;
286
+ const out = { ...a };
287
+ for (const [k, v] of Object.entries(b)) {
288
+ out[k] = k in a ? deepMerge(a[k], v) : v;
289
+ }
290
+ return out;
291
+ }
292
+
293
+ function num(v) {
294
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
295
+ if (typeof v === 'string' && v.trim() !== '') {
296
+ const n = Number(v);
297
+ return Number.isFinite(n) ? n : null;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ function assertString(v, name) {
303
+ if (typeof v !== 'string') throw new TypeError(`${name} must be a string`);
304
+ }
305
+
306
+ function assertPlainObject(v, name) {
307
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
308
+ throw new TypeError(`${name} must be an object`);
309
+ }
310
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Minimal shape of the mermaid API this plugin uses. Declared structurally so
3
+ * the package does not need a dependency on mermaid's own types.
4
+ */
5
+ export interface MermaidLike {
6
+ registerLayoutLoaders(loaders: Array<{
7
+ name: string;
8
+ loader: () => Promise<unknown>;
9
+ algorithm?: string;
10
+ }>): void;
11
+ render(
12
+ id: string,
13
+ text: string,
14
+ container?: Element,
15
+ ): Promise<{ svg: string; bindFunctions?: (element: Element) => void }>;
16
+ }
17
+
18
+ export interface RenderResult {
19
+ svg: string;
20
+ bindFunctions?: (element: Element) => void;
21
+ }
22
+
23
+ /** The name this algorithm registers under (`config.layout`). */
24
+ export declare const LAYOUT_NAME: 'merpeeps';
25
+
26
+ /** Register the Merpeeps layout algorithm with a mermaid instance. */
27
+ export declare function registerMerpeepsLayout<T extends MermaidLike>(mermaid: T): T;
28
+
29
+ /**
30
+ * Render `text`, honouring any positions and styling stored in its Merpeeps block.
31
+ *
32
+ * Falls through to `mermaid.render` when the document stores no positions, or
33
+ * when it uses subgraphs, so the result is never worse than stock mermaid.
34
+ * Stored styling is applied on either path.
35
+ */
36
+ export declare function renderWithLayout(
37
+ mermaid: MermaidLike,
38
+ id: string,
39
+ text: string,
40
+ container?: Element,
41
+ ): Promise<RenderResult>;
42
+
43
+ /** Per-node presentation, as stored under `style.nodes`. */
44
+ export interface NodeStyle {
45
+ fill?: string;
46
+ stroke?: string;
47
+ text?: string;
48
+ strokeWidth?: number;
49
+ dashed?: boolean;
50
+ icon?: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ /** Paint one already-rendered node element from a stored style. */
55
+ export declare function applyNodeStyle(nodeEl: Element, style: NodeStyle): void;
56
+
57
+ /**
58
+ * Apply stored styling to a rendered SVG string.
59
+ * Requires a DOM; returns the input unchanged when there is none.
60
+ */
61
+ export declare function styleSvg(
62
+ svg: string,
63
+ styles: Record<string, NodeStyle>,
64
+ ): string;
@@ -0,0 +1,321 @@
1
+ // A mermaid layout plugin that draws diagrams at their stored Merpeeps positions.
2
+ //
3
+ // This is the piece that makes stored layout matter outside our own editor.
4
+ // Register it once, and any mermaid-based viewer honours the arrangement a
5
+ // human made, instead of re-deriving one at render time.
6
+ //
7
+ // How it has to work, and why:
8
+ //
9
+ // A registered layout algorithm receives a `LayoutData`, not the source text,
10
+ // and mermaid deliberately drops unknown frontmatter keys before that point —
11
+ // verified, not assumed (see oracle/layout-plugin-probe.mjs: `configHasMerpeeps`
12
+ // and `layoutDataHasMerpeeps` are both false). So the algorithm cannot read the Merpeeps
13
+ // block itself. What it *does* get is `diagramId`, and the caller knows both
14
+ // the id and the text. `renderWithLayout` bridges that gap: it reads the
15
+ // positions, parks them under the id, and hands the id to mermaid.
16
+ //
17
+ // Selecting this algorithm is done in memory, never written to disk, so the
18
+ // file on disk stays an ordinary mermaid document that any stock viewer draws
19
+ // normally.
20
+
21
+ import * as yaml from 'js-yaml';
22
+ import { getPositions, getStyles, split } from './index.js';
23
+
24
+ /** The name this algorithm registers under (`config.layout`). */
25
+ export const LAYOUT_NAME = 'merpeeps';
26
+
27
+ /** Layout and styling parked for an in-flight render, keyed by diagram id. */
28
+ const pending = new Map();
29
+
30
+ /**
31
+ * Register the Merpeeps layout algorithm with a mermaid instance.
32
+ *
33
+ * ```js
34
+ * import mermaid from 'mermaid';
35
+ * import { registerMerpeepsLayout, renderWithLayout } from 'merpeeps-mermaid-layout/mermaid';
36
+ *
37
+ * registerMerpeepsLayout(mermaid);
38
+ * const { svg } = await renderWithLayout(mermaid, 'g1', source);
39
+ * ```
40
+ */
41
+ export function registerMerpeepsLayout(mermaid) {
42
+ mermaid.registerLayoutLoaders([
43
+ { name: LAYOUT_NAME, loader: async () => ({ render }) },
44
+ ]);
45
+ return mermaid;
46
+ }
47
+
48
+ /**
49
+ * Render `text`, honouring any positions stored in its Merpeeps block.
50
+ *
51
+ * Falls straight through to `mermaid.render` when the document stores no
52
+ * positions, or when it uses subgraphs — see the note on groups below. In both
53
+ * cases you get mermaid's normal output, never a broken diagram.
54
+ */
55
+ export async function renderWithLayout(mermaid, id, text, container) {
56
+ const positions = getPositions(text);
57
+ const styles = getStyles(text);
58
+
59
+ const hasPositions = Object.keys(positions).length > 0;
60
+ const hasStyles = Object.keys(styles).length > 0;
61
+
62
+ // Nothing stored, or a shape this algorithm does not handle: let mermaid do
63
+ // what it always does.
64
+ if (!hasPositions || hasGroups(text)) {
65
+ // Styling alone is still worth applying, and needs no custom layout.
66
+ const result = await mermaid.render(id, text, container);
67
+ return hasStyles ? { ...result, svg: styleSvg(result.svg, styles) } : result;
68
+ }
69
+
70
+ pending.set(id, { positions, styles });
71
+ try {
72
+ return await mermaid.render(id, selectLayout(text), container);
73
+ } finally {
74
+ pending.delete(id);
75
+ }
76
+ }
77
+
78
+ // ------------------------------------------------------------- the algorithm
79
+
80
+ async function render(data4Layout, svg, helpers) {
81
+ const {
82
+ insertCluster, insertEdge, insertEdgeLabel, insertMarkers, insertNode,
83
+ positionEdgeLabel,
84
+ } = helpers;
85
+
86
+ const { positions: stored = {}, styles = {} } =
87
+ pending.get(data4Layout.diagramId) ?? {};
88
+ const nodeDb = {};
89
+ const clusterDb = {};
90
+
91
+ const element = svg.select('g');
92
+ insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId);
93
+ const subGraphsEl = element.insert('g').attr('class', 'subgraphs');
94
+ const edgePaths = element.insert('g').attr('class', 'edgePaths');
95
+ const edgeLabels = element.insert('g').attr('class', 'edgeLabels');
96
+ const nodesEl = element.insert('g').attr('class', 'nodes');
97
+
98
+ // Insert first, then measure. A node's real size depends on its rendered
99
+ // label, so a stored width is a hint rather than the truth.
100
+ //
101
+ // The node objects are used as-is rather than copied: insertNode attaches an
102
+ // `intersect` function to the object it is handed, and insertEdge only clips
103
+ // an edge to the node outline when *both* endpoints expose one. Copying first
104
+ // loses it, and the arrowhead then lands on the node's centre — hidden
105
+ // underneath it, since nodes are drawn after edges.
106
+ await Promise.all(data4Layout.nodes.map(async (node) => {
107
+ if (node.isGroup) {
108
+ clusterDb[node.id] = node;
109
+ nodeDb[node.id] = node;
110
+ await insertCluster(subGraphsEl, node);
111
+ return;
112
+ }
113
+ nodeDb[node.id] = node;
114
+ const el = await insertNode(nodesEl, node, {
115
+ config: data4Layout.config,
116
+ dir: data4Layout.direction || 'TB',
117
+ });
118
+ const box = el.node().getBBox();
119
+ node.width = box.width;
120
+ node.height = box.height;
121
+ node.domId = el;
122
+
123
+ // Shapes that do not supply their own still need to clip somehow.
124
+ if (typeof node.intersect !== 'function') {
125
+ node.intersect = (point) => intersectRect(node, point);
126
+ }
127
+
128
+ if (styles[node.id]) applyNodeStyle(el.node(), styles[node.id]);
129
+ }));
130
+
131
+ place(data4Layout.nodes, nodeDb, stored);
132
+
133
+ // Nodes are drawn centred on the origin, so positioning is a translate.
134
+ for (const node of Object.values(nodeDb)) {
135
+ if (!node.domId) continue;
136
+ node.domId.attr('transform', `translate(${node.x}, ${node.y})`);
137
+ }
138
+
139
+ await Promise.all(data4Layout.edges.map(async (edge) => {
140
+ await insertEdgeLabel(edgeLabels, edge);
141
+
142
+ const startNode = nodeDb[edge.start ?? ''];
143
+ const endNode = nodeDb[edge.end ?? ''];
144
+ if (!startNode || !endNode) return;
145
+
146
+ // insertEdge discards the first and last point and re-derives them by
147
+ // intersecting against each node, so a two-point line leaves it nothing to
148
+ // work from and it throws. A midpoint is the minimum it can use.
149
+ const from = { x: startNode.x ?? 0, y: startNode.y ?? 0 };
150
+ const to = { x: endNode.x ?? 0, y: endNode.y ?? 0 };
151
+ const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
152
+
153
+ const withPath = { ...edge, points: [from, mid, to] };
154
+ const paths = insertEdge(
155
+ edgePaths, withPath, clusterDb, data4Layout.type,
156
+ startNode, endNode, data4Layout.diagramId,
157
+ );
158
+ positionEdgeLabel(withPath, paths);
159
+ }));
160
+ }
161
+
162
+ /**
163
+ * Apply stored coordinates, and give anything unpositioned a predictable spot
164
+ * below the rest rather than stacking it all on the origin.
165
+ *
166
+ * Merpeeps stores the top-left corner; mermaid positions by centre.
167
+ */
168
+ function place(nodes, nodeDb, stored) {
169
+ let cursorX = 0;
170
+ let maxY = 0;
171
+ let anyStored = false;
172
+
173
+ for (const raw of nodes) {
174
+ const node = nodeDb[raw.id];
175
+ if (!node) continue;
176
+ const p = stored[raw.id];
177
+ if (!p) continue;
178
+
179
+ node.x = p.x + (node.width ?? 0) / 2;
180
+ node.y = p.y + (node.height ?? 0) / 2;
181
+ maxY = Math.max(maxY, p.y + (node.height ?? 0));
182
+ cursorX = Math.min(cursorX, p.x);
183
+ anyStored = true;
184
+ }
185
+
186
+ const fallbackY = (anyStored ? maxY : 0) + 60;
187
+ let fallbackX = anyStored ? cursorX : 0;
188
+
189
+ for (const raw of nodes) {
190
+ const node = nodeDb[raw.id];
191
+ if (!node || stored[raw.id]) continue;
192
+
193
+ node.x = fallbackX + (node.width ?? 0) / 2;
194
+ node.y = fallbackY + (node.height ?? 0) / 2;
195
+ fallbackX += (node.width ?? 0) + 40;
196
+ }
197
+ }
198
+
199
+ // ------------------------------------------------------------------ helpers
200
+
201
+ // ------------------------------------------------------------------ styling
202
+
203
+ /**
204
+ * Paint one node's outline and label from its stored style.
205
+ *
206
+ * Applied to the rendered SVG rather than pushed through mermaid's own
207
+ * `style`/`classDef` syntax, because writing style into the diagram body would
208
+ * change the file — and Merpeeps exists precisely so presentation can be stored
209
+ * *without* touching what the diagram says.
210
+ *
211
+ * Written as inline style properties with `important`, since mermaid's
212
+ * stylesheet targets the same elements by class and would otherwise win.
213
+ */
214
+ export function applyNodeStyle(nodeEl, style) {
215
+ if (!nodeEl || !style) return;
216
+
217
+ const shapes = nodeEl.querySelectorAll(
218
+ 'rect, circle, ellipse, polygon, path.basic, path.outer-path, path.node-bkg, path');
219
+
220
+ for (const shape of shapes) {
221
+ // Arrow markers and label glyphs live inside the node group too; painting
222
+ // them would smear the fill across text.
223
+ if (shape.closest('.label') || shape.closest('foreignObject')) continue;
224
+
225
+ if (style.fill) shape.style.setProperty('fill', style.fill, 'important');
226
+ if (style.stroke) shape.style.setProperty('stroke', style.stroke, 'important');
227
+ if (style.strokeWidth != null) {
228
+ shape.style.setProperty('stroke-width', `${style.strokeWidth}px`, 'important');
229
+ }
230
+ if (style.dashed) {
231
+ shape.style.setProperty('stroke-dasharray', '6 4', 'important');
232
+ }
233
+ }
234
+
235
+ if (style.text) {
236
+ for (const t of nodeEl.querySelectorAll('text, tspan, .nodeLabel, span, p')) {
237
+ t.style.setProperty('color', style.text, 'important');
238
+ t.style.setProperty('fill', style.text, 'important');
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Apply stored styling to an already-rendered SVG string.
245
+ *
246
+ * Used on the fall-through paths, where mermaid did the layout and there is no
247
+ * plugin hook to paint through. Needs a DOM, so on a server it is a no-op
248
+ * rather than an error — the diagram is still correct, just unstyled.
249
+ */
250
+ export function styleSvg(svg, styles) {
251
+ if (typeof document === 'undefined') return svg;
252
+
253
+ const host = document.createElement('div');
254
+ host.innerHTML = svg;
255
+
256
+ for (const [id, style] of Object.entries(styles)) {
257
+ for (const el of host.querySelectorAll('g.node')) {
258
+ // Mermaid prefixes and suffixes node ids (`flowchart-gw-3`), so match on
259
+ // the id as a whole segment rather than by substring.
260
+ if (!new RegExp(`(^|[-_])${escapeRe(id)}([-_]|$)`).test(el.id)) continue;
261
+ applyNodeStyle(el, style);
262
+ }
263
+ }
264
+ return host.innerHTML;
265
+ }
266
+
267
+ function escapeRe(s) {
268
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
269
+ }
270
+
271
+ /**
272
+ * Where the line from `point` to the node's centre crosses the node's box.
273
+ *
274
+ * Used only for shapes that do not attach their own `intersect`. This is the
275
+ * classic dagre-d3 rectangle intersection.
276
+ */
277
+ function intersectRect(node, point) {
278
+ const dx = point.x - node.x;
279
+ const dy = point.y - node.y;
280
+ let w = (node.width ?? 0) / 2;
281
+ let h = (node.height ?? 0) / 2;
282
+
283
+ let sx;
284
+ let sy;
285
+ if (Math.abs(dy) * w > Math.abs(dx) * h) {
286
+ if (dy < 0) h = -h;
287
+ sx = dy === 0 ? 0 : (h * dx) / dy;
288
+ sy = h;
289
+ } else {
290
+ if (dx < 0) w = -w;
291
+ sx = w;
292
+ sy = dx === 0 ? 0 : (w * dy) / dx;
293
+ }
294
+ return { x: node.x + sx, y: node.y + sy };
295
+ }
296
+
297
+ /**
298
+ * Add `config.layout: merpeeps` to a copy of the source.
299
+ *
300
+ * Deliberately in memory only. Writing it to the file would make the document
301
+ * depend on this plugin being installed, which is exactly the lock-in Merpeeps
302
+ * exists to avoid.
303
+ */
304
+ function selectLayout(text) {
305
+ const { fm, body } = split(text);
306
+ const next = {
307
+ ...fm,
308
+ config: { ...(fm.config ?? {}), layout: LAYOUT_NAME },
309
+ };
310
+ const head = yaml.dump(next, { lineWidth: -1, noRefs: true }).replace(/\s*$/, '');
311
+ return `---\n${head}\n---\n${body}`;
312
+ }
313
+
314
+ /**
315
+ * Subgraphs need cluster bounds derived from their children, which stored
316
+ * node positions alone do not determine. Rather than draw them wrongly, hand
317
+ * those diagrams back to mermaid's own layout.
318
+ */
319
+ function hasGroups(text) {
320
+ return /^\s*subgraph\s/m.test(split(text).body);
321
+ }