@jarenjs/mermaid 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +282 -0
  2. package/dist/types/ast.d.ts +210 -0
  3. package/dist/types/component/index.d.ts +81 -0
  4. package/dist/types/errors.d.ts +27 -0
  5. package/dist/types/index.d.ts +97 -0
  6. package/dist/types/interactive.d.ts +36 -0
  7. package/dist/types/layout/flowchart.d.ts +12 -0
  8. package/dist/types/layout/sequence.d.ts +12 -0
  9. package/dist/types/layout/state.d.ts +19 -0
  10. package/dist/types/parser/class.d.ts +11 -0
  11. package/dist/types/parser/config.d.ts +61 -0
  12. package/dist/types/parser/er.d.ts +11 -0
  13. package/dist/types/parser/flowchart.d.ts +26 -0
  14. package/dist/types/parser/gantt.d.ts +11 -0
  15. package/dist/types/parser/index.d.ts +22 -0
  16. package/dist/types/parser/pie.d.ts +11 -0
  17. package/dist/types/parser/sequence.d.ts +16 -0
  18. package/dist/types/parser/state.d.ts +22 -0
  19. package/dist/types/plugin.d.ts +50 -0
  20. package/dist/types/render/error.d.ts +23 -0
  21. package/dist/types/render/flowchart.d.ts +18 -0
  22. package/dist/types/render/index.d.ts +23 -0
  23. package/dist/types/render/misc.d.ts +54 -0
  24. package/dist/types/render/sequence.d.ts +16 -0
  25. package/dist/types/styles.d.ts +81 -0
  26. package/dist/types/theme.d.ts +47 -0
  27. package/dist/types/to-mermaid.d.ts +20 -0
  28. package/dist/types/utils.d.ts +47 -0
  29. package/docs/MERMAID-FORMAT.md +242 -0
  30. package/package.json +84 -0
  31. package/schemas/jaren-mermaid-ast.schema.json +78 -0
  32. package/schemas/jaren-workflow.schema.json +28 -0
  33. package/src/ast.js +252 -0
  34. package/src/component/index.js +109 -0
  35. package/src/errors.js +35 -0
  36. package/src/index.js +155 -0
  37. package/src/interactive.js +244 -0
  38. package/src/layout/flowchart.js +352 -0
  39. package/src/layout/sequence.js +178 -0
  40. package/src/layout/state.js +65 -0
  41. package/src/parser/class.js +90 -0
  42. package/src/parser/config.js +215 -0
  43. package/src/parser/er.js +86 -0
  44. package/src/parser/flowchart.js +413 -0
  45. package/src/parser/gantt.js +49 -0
  46. package/src/parser/index.js +122 -0
  47. package/src/parser/pie.js +32 -0
  48. package/src/parser/sequence.js +156 -0
  49. package/src/parser/state.js +137 -0
  50. package/src/plugin.js +76 -0
  51. package/src/render/error.js +55 -0
  52. package/src/render/flowchart.js +249 -0
  53. package/src/render/index.js +93 -0
  54. package/src/render/misc.js +135 -0
  55. package/src/render/sequence.js +152 -0
  56. package/src/styles.js +181 -0
  57. package/src/theme.js +180 -0
  58. package/src/to-mermaid.js +317 -0
  59. package/src/utils.js +64 -0
  60. package/styles/mermaid.css +115 -0
  61. package/stylesheets/dag-to-flowchart.jslt.json +62 -0
  62. package/stylesheets/flowchart-to-dag.jslt.json +29 -0
  63. package/stylesheets/state-to-workflow.jslt.json +26 -0
  64. package/stylesheets/workflow-to-state.jslt.json +41 -0
@@ -0,0 +1,156 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Sequence grammar → sequence AST. Flowchart and sequence are
4
+ * the two fully-modeled diagram types; this is the second.
5
+ *
6
+ * Line-oriented, with a block stack for loop/alt/opt/par/critical/break.
7
+ * Explicitly declared participants keep their declaration order; a
8
+ * message to an undeclared actor is legal (the layout pass creates the
9
+ * lifeline), so the AST records only what the source states — faithful
10
+ * and geometry-free. Module-const regexes only.
11
+ */
12
+
13
+ import {
14
+ seqParticipant, seqMessage, seqNote, seqActivation, seqBlock, sequenceAst,
15
+ } from '../ast.js';
16
+ import { fail } from '../errors.js';
17
+ import { firstToken } from '../utils.js';
18
+
19
+ /** A message: `A->>+B: text`. */
20
+ const RE_MSG = /^([^-<>:]+?)\s*((?:-{1,2})(?:>>|>|x|\)))\s*([+-]?)\s*([^:]+?)\s*:\s*(.*)$/;
21
+ /** `participant A as Alice` / `actor A`. */
22
+ const RE_PARTICIPANT = /^(participant|actor)\s+(.+)$/;
23
+ /** `note left of A: t` / `note right of A: t` / `note over A,B: t`. */
24
+ const RE_NOTE = /^[Nn]ote\s+(left of|right of|over)\s+([^:]+):\s*(.*)$/;
25
+
26
+ /** Block openers whose first word starts a frame. */
27
+ const OPEN_BLOCKS = new Set(['loop', 'opt', 'alt', 'par', 'critical', 'break', 'rect']);
28
+
29
+ /**
30
+ * @param {string[]} lines body lines (config stripped)
31
+ * @param {number} lineOffset absolute line number of `lines[0]`
32
+ * @returns {object}
33
+ */
34
+ export function parseSequence(lines, lineOffset) {
35
+ const participants = [];
36
+ const seen = new Set();
37
+ const rootStatements = [];
38
+ let current = rootStatements;
39
+ /** @type {{ block: any, parent: any[], skip: boolean }[]} */
40
+ const stack = [];
41
+ let autonumber = false;
42
+
43
+ const declare = (id, label, kind) => {
44
+ if (seen.has(id)) return;
45
+ seen.add(id);
46
+ participants.push(seqParticipant(id, label ?? id, kind));
47
+ };
48
+
49
+ for (let li = 0; li < lines.length; li++) {
50
+ const line = lines[li].trim();
51
+ if (line === '') continue;
52
+ const lineNo = lineOffset + li + 1;
53
+ const firstWord = firstToken(line);
54
+
55
+ if (line === 'autonumber') { autonumber = true; continue; }
56
+
57
+ if (firstWord === 'participant' || firstWord === 'actor') {
58
+ const m = RE_PARTICIPANT.exec(line);
59
+ if (m !== null) {
60
+ const { id, label } = splitAlias(m[2].trim());
61
+ declare(id, label, /** @type {any} */ (m[1]));
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (firstWord === 'activate' || firstWord === 'deactivate') {
67
+ const actor = line.slice(firstWord.length).trim();
68
+ current.push(seqActivation(/** @type {any} */ (firstWord), actor));
69
+ continue;
70
+ }
71
+
72
+ if (/^[Nn]ote\s/.test(line)) {
73
+ const m = RE_NOTE.exec(line);
74
+ if (m !== null) {
75
+ const actors = m[2].split(',').map((s) => s.trim()).filter(Boolean);
76
+ current.push(seqNote(/** @type {any} */ (m[1]), actors, m[3].trim()));
77
+ }
78
+ continue;
79
+ }
80
+
81
+ // Block control.
82
+ if (OPEN_BLOCKS.has(firstWord)) {
83
+ const label = line.slice(firstWord.length).trim();
84
+ if (firstWord === 'rect') {
85
+ // Background rectangle: parse-accept, statements flow to parent.
86
+ stack.push({ block: null, parent: current, skip: true });
87
+ continue;
88
+ }
89
+ const block = seqBlock(/** @type {any} */ (firstWord), [{ label, statements: [] }]);
90
+ current.push(block);
91
+ stack.push({ block, parent: current, skip: false });
92
+ current = block.branches[0].statements;
93
+ continue;
94
+ }
95
+ if (firstWord === 'else' || firstWord === 'and' || firstWord === 'option') {
96
+ const top = stack[stack.length - 1];
97
+ if (top === undefined || top.block === null) {
98
+ fail(`'${firstWord}' outside a block`, lineNo);
99
+ }
100
+ const branch = { label: line.slice(firstWord.length).trim(), statements: [] };
101
+ top.block.branches.push(branch);
102
+ current = branch.statements;
103
+ continue;
104
+ }
105
+ if (line === 'end') {
106
+ const entry = stack.pop();
107
+ if (entry === undefined) fail("unexpected 'end'", lineNo);
108
+ current = entry.parent;
109
+ continue;
110
+ }
111
+
112
+ // A message.
113
+ const mm = RE_MSG.exec(line);
114
+ if (mm !== null) {
115
+ const from = mm[1].trim();
116
+ const arrow = mm[2];
117
+ const activation = mm[3] === '+' ? 'activate' : mm[3] === '-' ? 'deactivate' : null;
118
+ const to = mm[4].trim();
119
+ const text = mm[5];
120
+ declare(from, from, 'participant');
121
+ declare(to, to, 'participant');
122
+ const { line: lineStyle, head } = classifyArrow(arrow);
123
+ current.push(seqMessage(from, to, text, lineStyle, head, /** @type {any} */ (activation)));
124
+ continue;
125
+ }
126
+
127
+ // Unknown directive — lenient parse-accept (title:, links, etc.).
128
+ }
129
+
130
+ return sequenceAst(participants, rootStatements, autonumber);
131
+ }
132
+
133
+ /**
134
+ * Classify a sequence arrow token into line style + head.
135
+ * @param {string} arrow
136
+ * @returns {{ line: 'solid'|'dotted', head: 'arrow'|'open'|'cross'|'point' }}
137
+ */
138
+ function classifyArrow(arrow) {
139
+ const line = arrow.startsWith('--') ? 'dotted' : 'solid';
140
+ const head = arrow.endsWith('>>') ? 'arrow'
141
+ : arrow.endsWith('x') ? 'cross'
142
+ : arrow.endsWith(')') ? 'point'
143
+ : 'open';
144
+ return { line, head };
145
+ }
146
+
147
+ /**
148
+ * `A as Alice` → { id, label }; `A` → { id, label: id }.
149
+ * @param {string} text
150
+ * @returns {{ id: string, label: string }}
151
+ */
152
+ function splitAlias(text) {
153
+ const idx = text.indexOf(' as ');
154
+ if (idx === -1) return { id: text.trim(), label: text.trim() };
155
+ return { id: text.slice(0, idx).trim(), label: text.slice(idx + 4).trim() };
156
+ }
@@ -0,0 +1,137 @@
1
+ //@ts-check
2
+ /**
3
+ * @file State-diagram grammar (`stateDiagram-v2`) → state AST. Models
4
+ * the finite state machine faithfully: states, transitions
5
+ * (`A --> B : label`), the `[*]` start/end pseudo-states, `state "x" as
6
+ * s` descriptions and `s : desc` labels. This is the AST the flagship
7
+ * `state ⇄ workflow` JSLT projection consumes.
8
+ *
9
+ * A transition carries its verbatim `label` — what renderers draw and
10
+ * `toMermaid` prints — plus the label's UML reading, parsed into
11
+ * `event [guard] / effect` parts (each null when absent). A label that
12
+ * fits no UML pattern reads whole as the event, so plain labels keep
13
+ * their historical meaning byte for byte.
14
+ *
15
+ * Composite states (`state Foo { … }`) are flattened one level: the
16
+ * inner transitions are captured with their parent recorded, keeping the
17
+ * AST geometry-free.
18
+ */
19
+
20
+ /** `A --> B` / `A --> B : label`. */
21
+ const RE_TRANSITION = /^(\S+)\s*-->\s*(\S+)(?:\s*:\s*(.*))?$/;
22
+
23
+ /**
24
+ * Read a transition label's UML parts: `event [guard] / effect`, every
25
+ * part optional. The first `[` opens the guard (nesting counted), and
26
+ * the effect starts at the first `/` after the guard (or the first `/`
27
+ * at all when there is none). Anything that breaks the pattern — an
28
+ * unmatched `[`, or text between `]` and `/` — reads whole as the
29
+ * event, which is exactly the historical meaning of a plain label.
30
+ * @param {string|null} label
31
+ * @returns {{ event: string|null, guard: string|null, effect: string|null }}
32
+ */
33
+ function parseTransitionLabel(label) {
34
+ const part = (s) => {
35
+ const t = s.trim();
36
+ return t === '' ? null : t;
37
+ };
38
+ if (label === null) return { event: null, guard: null, effect: null };
39
+ const open = label.indexOf('[');
40
+ if (open === -1) {
41
+ const slash = label.indexOf('/');
42
+ if (slash === -1) return { event: part(label), guard: null, effect: null };
43
+ return { event: part(label.slice(0, slash)), guard: null, effect: part(label.slice(slash + 1)) };
44
+ }
45
+ let depth = 0;
46
+ let close = -1;
47
+ for (let i = open; i < label.length; i++) {
48
+ if (label[i] === '[') depth++;
49
+ else if (label[i] === ']') {
50
+ depth--;
51
+ if (depth === 0) { close = i; break; }
52
+ }
53
+ }
54
+ if (close === -1) return { event: part(label), guard: null, effect: null };
55
+ const after = label.slice(close + 1);
56
+ const slash = after.indexOf('/');
57
+ const between = slash === -1 ? after : after.slice(0, slash);
58
+ if (between.trim() !== '') return { event: part(label), guard: null, effect: null };
59
+ return {
60
+ event: part(label.slice(0, open)),
61
+ guard: part(label.slice(open + 1, close)),
62
+ effect: slash === -1 ? null : part(after.slice(slash + 1)),
63
+ };
64
+ }
65
+ /** `state "long description" as id`. */
66
+ const RE_STATE_AS = /^state\s+"([^"]*)"\s+as\s+(\S+)\s*$/;
67
+ /** `id : description`. */
68
+ const RE_STATE_DESC = /^(\S+)\s*:\s*(.*)$/;
69
+
70
+ /**
71
+ * @param {string[]} lines
72
+ * @returns {object}
73
+ */
74
+ export function parseState(lines) {
75
+ /** @type {Map<string, { id: string, label: string }>} */
76
+ const stateMap = new Map();
77
+ const order = [];
78
+ const transitions = [];
79
+ /** @type {string[]} */
80
+ const parentStack = [];
81
+
82
+ const ensure = (id, label) => {
83
+ if (id === '[*]') return;
84
+ let st = stateMap.get(id);
85
+ if (st === undefined) {
86
+ st = { id, label: label ?? id };
87
+ stateMap.set(id, st);
88
+ order.push(id);
89
+ }
90
+ else if (label != null) {
91
+ st.label = label;
92
+ }
93
+ };
94
+
95
+ for (let li = 0; li < lines.length; li++) {
96
+ let line = lines[li].trim();
97
+ if (line === '' || line.startsWith('%%')) continue;
98
+ if (line === 'end' || line === '}') { parentStack.pop(); continue; }
99
+
100
+ // Composite state opener: `state Foo {`
101
+ if (line.startsWith('state ') && line.endsWith('{')) {
102
+ const inner = line.slice('state '.length, -1).trim();
103
+ const id = inner.split(/\s+/)[0];
104
+ ensure(id, id);
105
+ parentStack.push(id);
106
+ continue;
107
+ }
108
+
109
+ const sa = RE_STATE_AS.exec(line);
110
+ if (sa !== null) { ensure(sa[2], sa[1]); continue; }
111
+
112
+ const tr = RE_TRANSITION.exec(line);
113
+ if (tr !== null) {
114
+ if (tr[1] !== '[*]') ensure(tr[1], null);
115
+ if (tr[2] !== '[*]') ensure(tr[2], null);
116
+ const label = tr[3] ? tr[3].trim() : null;
117
+ const parts = parseTransitionLabel(label === '' ? null : label);
118
+ transitions.push({
119
+ from: tr[1],
120
+ to: tr[2],
121
+ label: label === '' ? null : label,
122
+ event: parts.event,
123
+ guard: parts.guard,
124
+ effect: parts.effect,
125
+ parent: parentStack.length ? parentStack[parentStack.length - 1] : null,
126
+ });
127
+ continue;
128
+ }
129
+
130
+ if (line.startsWith('state ')) { ensure(line.slice('state '.length).trim(), null); continue; }
131
+
132
+ const sd = RE_STATE_DESC.exec(line);
133
+ if (sd !== null && sd[1] !== '[*]') { ensure(sd[1], sd[2].trim()); continue; }
134
+ }
135
+
136
+ return { states: order.map((id) => stateMap.get(id)), transitions };
137
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,76 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The Markdown plugin — the md→mermaid dependency arrow.
4
+ * `mermaidPlugin()` returns a **plain, self-frozen object**
5
+ * shaped exactly like `@jarenjs/md`'s `MdPlugin` typedef, but it does
6
+ * **not** import `definePlugin` from `@jarenjs/md` — so there is no
7
+ * import cycle. `@jarenjs/md` re-exports this and adds `@jarenjs/mermaid`
8
+ * to its dependencies; consumers who never use it tree-shake it away
9
+ * (`sideEffects:false`).
10
+ *
11
+ * `render` is pure, synchronous and error-safe: a `mermaid`
12
+ * fence becomes inline SVG with no injected instance and no `innerHTML`,
13
+ * so a Markdown document renders to a full SVG string through SSR with
14
+ * no browser — a capability the old injection wrapper lacked.
15
+ *
16
+ * `hydrate` exists only when a consumer asks for `interactive: true`. The
17
+ * render is complete without it: hydration adds pan/zoom/touch to an already
18
+ * finished SVG, so server output is byte-identical either way and a page that
19
+ * does not opt in never loads the module.
20
+ */
21
+
22
+ import { diagramToVnode } from './render/index.js';
23
+ import { toMermaid } from './to-mermaid.js';
24
+ import { hashContent } from './utils.js';
25
+
26
+ /**
27
+ * @param {{ theme?: any, [k: string]: any }} [options]
28
+ * @returns {Readonly<{ name: string, fences: string[], node: string, render: (node: any, h: any, ctx: any) => any }>}
29
+ */
30
+ export function mermaidPlugin(options = {}) {
31
+ return Object.freeze({
32
+ name: 'mermaid',
33
+ fences: Object.freeze(['mermaid', 'mmd']),
34
+ node: 'mermaid',
35
+ /**
36
+ * @param {{ value: string, meta?: any }} node
37
+ */
38
+ render: (node) => {
39
+ const hash = hashContent(node.value);
40
+ const svg = diagramToVnode(node.value, options);
41
+ const props = { class: 'md-mermaid mermaid-block', key: hash };
42
+ // The hydrate marker is what the md component looks for; emitting it
43
+ // only when interactive keeps non-interactive output unchanged.
44
+ if (options.interactive === true) {
45
+ props['data-md-hydrate'] = 'mermaid';
46
+ props['data-md-hash'] = hash;
47
+ }
48
+ return ['div', props, svg];
49
+ },
50
+ ...(options.interactive === true
51
+ ? {
52
+ /**
53
+ * @param {any} el the rendered block element
54
+ */
55
+ hydrate: async (el) => {
56
+ const { attachInteractiveDiagram } = await import('./interactive.js');
57
+ attachInteractiveDiagram(el);
58
+ },
59
+ }
60
+ : {}),
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Refresh a `mermaid` fence node's source after a JSLT transform so the
66
+ * generic Markdown fence printer re-emits the new diagram. Because
67
+ * there is no per-plugin `toMarkdown` hook, this is the primitive that
68
+ * makes a transformed diagram round-trip through `toMarkdown`.
69
+ *
70
+ * @param {{ value: string, [k: string]: any }} node the fence node (mutated copy is caller's job)
71
+ * @param {import('./ast.js').DiagramDocument} newDoc the transformed document
72
+ * @returns {{ type: string, value: string, meta: any }} a fresh fence node
73
+ */
74
+ export function refreshMermaidFence(node, newDoc) {
75
+ return { type: 'mermaid', value: toMermaid(newDoc), meta: node.meta ?? null };
76
+ }
@@ -0,0 +1,55 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The error vnode. The render path never
4
+ * throws: a parse/layout failure becomes a clear error box — message +
5
+ * offending line — mirroring Mermaid's own error box, keyed so the
6
+ * patcher swaps it cleanly.
7
+ *
8
+ * It themes like every other diagram (DESIGN.md §7): concrete colors ride
9
+ * as presentation attributes so `toSvgString()` stays standalone-valid,
10
+ * and the root carries the inline `--mm-*` stamp plus a class per shape,
11
+ * so a host theme re-colors the error box along with the diagrams it
12
+ * replaces. The font is pinned to monospace because the box quotes source.
13
+ */
14
+
15
+ import { rect, svgRoot, textAt } from '@jarenjs/view/helpers';
16
+ import { hashContent } from '../utils.js';
17
+ import { createTheme } from '../theme.js';
18
+
19
+ /**
20
+ * @param {string} message
21
+ * @param {number} [line]
22
+ * @param {string} [sourceLine] the offending source line, if known
23
+ * @param {ReturnType<typeof createTheme>} [theme] the resolved theme; the
24
+ * default keeps the vnode renderable on its own (a parse failure has no
25
+ * document to read a theme from)
26
+ * @returns {any} an SVG error vnode
27
+ */
28
+ export function errorVnode(message, line = 0, sourceLine = '', theme = createTheme('default')) {
29
+ const width = 520;
30
+ const height = sourceLine ? 96 : 72;
31
+ const key = 'mmerr-' + hashContent(message + ':' + line);
32
+ const lineLabel = line > 0 ? `Line ${line}: ` : '';
33
+ const t = theme.tokens;
34
+ return svgRoot('mermaid mm-error', width, height, theme, [
35
+ rect(1, 1, width - 2, height - 2, {
36
+ rx: 6, class: 'mm-error-box', fill: t.errFill, stroke: t.errStroke, 'stroke-width': 1.5,
37
+ }),
38
+ textAt(14, 26, 'Mermaid parse error', 14, {
39
+ class: 'mm-error-title', 'font-weight': 'bold', fill: t.errTitle,
40
+ }),
41
+ textAt(14, 48, lineLabel + message, 12, { class: 'mm-error-msg', fill: t.errText }),
42
+ ...(sourceLine
43
+ ? [textAt(14, 72, truncate(sourceLine, 72), 12, {
44
+ class: 'mm-error-source', fill: t.errSource, style: { 'white-space': 'pre' },
45
+ })]
46
+ : []),
47
+ ], key, { fontFamily: 'monospace' });
48
+ }
49
+
50
+ /**
51
+ * @param {string} s @param {number} max @returns {string}
52
+ */
53
+ function truncate(s, max) {
54
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
55
+ }
@@ -0,0 +1,249 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Flowchart renderer: `PositionedDiagram` → pure-vnode SVG. One
4
+ * specialized closure over the scene graph; arrowheads are inline
5
+ * polygons (no `<marker>` id collisions across diagrams on one page),
6
+ * every shape carries a `mm-*` class for CSS re-theming and concrete
7
+ * theme colors for standalone SSR.
8
+ */
9
+
10
+ import {
11
+ svgRoot, group, rect, path, circle, polygon, textAt, textLines, num,
12
+ } from '@jarenjs/view/helpers';
13
+
14
+ import { shapeAttributes, shapeStyle, textColor } from '../styles.js';
15
+
16
+ /**
17
+ * @param {any} scene PositionedDiagram (flowchart or state, via its adapter)
18
+ * @param {{ tokens: Record<string,string>, cssVars: Record<string,string> }} theme
19
+ * @param {string} hash content hash for the root key
20
+ * @param {string} [rootClass] root `class` — the state renderer adds `mm-state`
21
+ * @returns {any}
22
+ */
23
+ export function renderFlowchart(scene, theme, hash, rootClass = 'mermaid mm-svg') {
24
+ const t = theme.tokens;
25
+ const children = [];
26
+
27
+ // Subgraph frames first (behind nodes).
28
+ for (const sg of scene.subgraphs) {
29
+ if (sg.w === 0) continue;
30
+ children.push(group({ class: 'mm-cluster', key: 'sg-' + sg.id }, [
31
+ rect(sg.x, sg.y, sg.w, sg.h, {
32
+ rx: 6, class: 'mm-cluster-rect',
33
+ fill: t.clusterFill, stroke: t.clusterStroke, 'stroke-width': 1,
34
+ }),
35
+ sg.label
36
+ ? textAt(sg.x + sg.w / 2, sg.y + 15, sg.label, scene.fontSize, {
37
+ 'text-anchor': 'middle', class: 'mm-cluster-label', fill: t.nodeText,
38
+ })
39
+ : null,
40
+ ]));
41
+ }
42
+
43
+ // Edges (behind nodes so the node fill covers endpoints).
44
+ for (let i = 0; i < scene.edges.length; i++) {
45
+ const e = scene.edges[i];
46
+ if (e.points.length < 2) continue;
47
+ children.push(renderEdge(e, t, scene.fontSize, i));
48
+ }
49
+
50
+ // Nodes.
51
+ for (const node of scene.nodes) {
52
+ children.push(renderNode(node, t, scene.fontSize));
53
+ }
54
+
55
+ return svgRoot(rootClass, scene.width, scene.height, theme, children,
56
+ 'mmfc-' + hash, { fit: false });
57
+ }
58
+
59
+ /**
60
+ * @param {any} node @param {Record<string,string>} t @param {number} fontSize
61
+ * @returns {any}
62
+ */
63
+ function renderNode(node, t, fontSize) {
64
+ const shapeEl = shapeVnode(node, t);
65
+ const cx = node.x + node.w / 2;
66
+ const cy = node.y + node.h / 2;
67
+ const color = textColor(node.styles, t);
68
+ const label = textLines(cx, cy, node.label.split('\n'), fontSize,
69
+ color === null
70
+ ? { class: 'mm-label', fill: t.nodeText }
71
+ : { class: 'mm-label', fill: color, style: `fill:${color}` });
72
+ // `data-id` is the stable identity an editor's event delegation and
73
+ // hit-testing key on (MERMAID-FORMAT §7): the AST node id, verbatim.
74
+ return group({ class: 'mm-node', key: 'n-' + node.id, 'data-id': node.id }, [shapeEl, label]);
75
+ }
76
+
77
+ /**
78
+ * @param {any} node @param {Record<string,string>} t
79
+ * @returns {any}
80
+ */
81
+ function shapeVnode(node, t) {
82
+ const { x, y, w, h } = node;
83
+ const fill = t.nodeFill;
84
+ const stroke = t.nodeStroke;
85
+ // Author styles (classDef / class / style) win over the theme defaults;
86
+ // spreading them last is what makes `class X note` repaint the box.
87
+ // Presentation attributes carry the theme (so standalone SSR looks right
88
+ // with no stylesheet); the author's own styles go inline, which is what
89
+ // outranks the stylesheet's `.mm-node-shape` rule.
90
+ const authored = shapeStyle(node.styles, t);
91
+ const common = {
92
+ class: 'mm-node-shape', fill, stroke, 'stroke-width': 1,
93
+ ...shapeAttributes(node.styles, t),
94
+ ...(authored === null ? {} : { style: authored }),
95
+ };
96
+ switch (node.shape) {
97
+ case 'round':
98
+ return rect(x, y, w, h, { rx: 8, ...common });
99
+ case 'stadium':
100
+ return rect(x, y, w, h, { rx: h / 2, ...common });
101
+ case 'subroutine':
102
+ return group({}, [
103
+ rect(x, y, w, h, common),
104
+ path(`M${x + 6},${y} L${x + 6},${y + h} M${x + w - 6},${y} L${x + w - 6},${y + h}`, { fill: 'none', stroke, 'stroke-width': 1 }),
105
+ ]);
106
+ case 'cylinder': {
107
+ const ry = Math.min(8, h / 6);
108
+ const d = `M${x},${y + ry} C${x},${y - ry / 2} ${x + w},${y - ry / 2} ${x + w},${y + ry}`
109
+ + ` L${x + w},${y + h - ry} C${x + w},${y + h + ry / 2} ${x},${y + h + ry / 2} ${x},${y + h - ry} Z`;
110
+ return path(d, common);
111
+ }
112
+ case 'circle':
113
+ return circle(x + w / 2, y + h / 2, Math.min(w, h) / 2, common);
114
+ case 'statedot':
115
+ // the state diagram's start marker: a small filled dot (the state
116
+ // layout is the only producer; flowchart source cannot spell it)
117
+ return circle(x + w / 2, y + h / 2, Math.min(w, h) / 2,
118
+ { ...common, fill: stroke });
119
+ case 'doublecircle': {
120
+ const r = Math.min(w, h) / 2;
121
+ return group({}, [
122
+ circle(x + w / 2, y + h / 2, r, common),
123
+ circle(x + w / 2, y + h / 2, r - 4, { ...common, fill: 'none' }),
124
+ ]);
125
+ }
126
+ case 'diamond':
127
+ return polygon([
128
+ { x: x + w / 2, y }, { x: x + w, y: y + h / 2 },
129
+ { x: x + w / 2, y: y + h }, { x, y: y + h / 2 },
130
+ ], common);
131
+ case 'hexagon': {
132
+ const o = Math.min(14, w / 4);
133
+ return polygon([
134
+ { x: x + o, y }, { x: x + w - o, y }, { x: x + w, y: y + h / 2 },
135
+ { x: x + w - o, y: y + h }, { x: x + o, y: y + h }, { x, y: y + h / 2 },
136
+ ], common);
137
+ }
138
+ case 'parallelogram': {
139
+ const o = Math.min(16, w / 4);
140
+ return polygon([
141
+ { x: x + o, y }, { x: x + w, y }, { x: x + w - o, y: y + h }, { x, y: y + h },
142
+ ], common);
143
+ }
144
+ case 'parallelogram_alt': {
145
+ const o = Math.min(16, w / 4);
146
+ return polygon([
147
+ { x, y }, { x: x + w - o, y }, { x: x + w, y: y + h }, { x: x + o, y: y + h },
148
+ ], common);
149
+ }
150
+ case 'trapezoid': {
151
+ const o = Math.min(16, w / 4);
152
+ return polygon([
153
+ { x: x + o, y }, { x: x + w - o, y }, { x: x + w, y: y + h }, { x, y: y + h },
154
+ ], common);
155
+ }
156
+ case 'trapezoid_alt': {
157
+ const o = Math.min(16, w / 4);
158
+ return polygon([
159
+ { x, y }, { x: x + w, y }, { x: x + w - o, y: y + h }, { x: x + o, y: y + h },
160
+ ], common);
161
+ }
162
+ case 'asymmetric':
163
+ return polygon([
164
+ { x, y }, { x: x + w - 8, y }, { x: x + w, y: y + h / 2 }, { x: x + w - 8, y: y + h }, { x, y: y + h },
165
+ ], common);
166
+ default:
167
+ return rect(x, y, w, h, common);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {any} e @param {Record<string,string>} t @param {number} fontSize @param {number} i
173
+ * @returns {any}
174
+ */
175
+ function renderEdge(e, t, fontSize, i) {
176
+ const p1 = e.points[0];
177
+ const p2 = e.points[e.points.length - 1];
178
+ const stroke = t.lineColor;
179
+ const strokeWidth = e.stroke === 'thick' ? 3 : 1.5;
180
+ const dash = e.stroke === 'dotted' ? '3 3' : null;
181
+ const lineProps = {
182
+ class: 'mm-edge-line', fill: 'none', stroke, 'stroke-width': strokeWidth,
183
+ };
184
+ if (dash) lineProps['stroke-dasharray'] = dash;
185
+
186
+ const parts = [path(`M${num(p1.x)},${num(p1.y)} L${num(p2.x)},${num(p2.y)}`, lineProps)];
187
+
188
+ if (e.head !== 'none') parts.push(marker(p2, p1, e.head, stroke));
189
+ if (e.tail !== 'none') parts.push(marker(p1, p2, e.tail, stroke));
190
+
191
+ if (e.label != null && e.labelPos) {
192
+ // The layout measured the label and resolved its position; re-estimating
193
+ // the width here is what let a long label overflow its own background.
194
+ const lw = e.labelW ?? (e.label.length * fontSize * 0.55 + 8);
195
+ const lh = e.labelH ?? fontSize * 1.4;
196
+ parts.push(rect(e.labelPos.x - lw / 2, e.labelPos.y - lh / 2, lw, lh, {
197
+ class: 'mm-edge-label-bg', fill: t.edgeLabelBg, stroke: 'none',
198
+ }));
199
+ parts.push(textAt(e.labelPos.x, e.labelPos.y, e.label, fontSize, {
200
+ 'text-anchor': 'middle', 'dominant-baseline': 'central',
201
+ class: 'mm-edge-label', fill: t.edgeLabelText,
202
+ }));
203
+ }
204
+
205
+ // Edge identity for editors: endpoints plus `data-edge`, the edge's
206
+ // AST position — which is also its docPath tail (MERMAID-FORMAT §7).
207
+ return group({
208
+ class: 'mm-edge', key: 'e-' + i,
209
+ 'data-edge': i, 'data-from': e.from, 'data-to': e.to,
210
+ }, parts);
211
+ }
212
+
213
+ /**
214
+ * An endpoint marker (arrow/circle/cross) at `tip`, oriented away from
215
+ * `from`.
216
+ * @param {{x:number,y:number}} tip
217
+ * @param {{x:number,y:number}} from
218
+ * @param {string} kind
219
+ * @param {string} color
220
+ * @returns {any}
221
+ */
222
+ function marker(tip, from, kind, color) {
223
+ const dx = tip.x - from.x;
224
+ const dy = tip.y - from.y;
225
+ const len = Math.hypot(dx, dy) || 1;
226
+ const ux = dx / len;
227
+ const uy = dy / len;
228
+ if (kind === 'circle') {
229
+ return circle(tip.x - ux * 4, tip.y - uy * 4, 4, { fill: color, stroke: color });
230
+ }
231
+ if (kind === 'cross') {
232
+ const s = 5;
233
+ const px = -uy, py = ux;
234
+ return path(
235
+ `M${num(tip.x - ux * 8 + px * s)},${num(tip.y - uy * 8 + py * s)} L${num(tip.x - px * s)},${num(tip.y - py * s)}`
236
+ + ` M${num(tip.x - ux * 8 - px * s)},${num(tip.y - uy * 8 - py * s)} L${num(tip.x + px * s)},${num(tip.y + py * s)}`,
237
+ { stroke: color, 'stroke-width': 1.5, fill: 'none' });
238
+ }
239
+ // arrow (default)
240
+ const size = 9;
241
+ const px = -uy, py = ux;
242
+ const baseX = tip.x - ux * size;
243
+ const baseY = tip.y - uy * size;
244
+ return polygon([
245
+ { x: tip.x, y: tip.y },
246
+ { x: baseX + px * (size / 2.4), y: baseY + py * (size / 2.4) },
247
+ { x: baseX - px * (size / 2.4), y: baseY - py * (size / 2.4) },
248
+ ], { class: 'mm-arrowhead', fill: color, stroke: color });
249
+ }