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 +21 -0
- package/README.md +235 -0
- package/SPEC.md +252 -0
- package/package.json +66 -0
- package/src/index.d.ts +98 -0
- package/src/index.js +310 -0
- package/src/mermaid-plugin.d.ts +64 -0
- package/src/mermaid-plugin.js +321 -0
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
|
+
}
|