@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.
- package/README.md +282 -0
- package/dist/types/ast.d.ts +210 -0
- package/dist/types/component/index.d.ts +81 -0
- package/dist/types/errors.d.ts +27 -0
- package/dist/types/index.d.ts +97 -0
- package/dist/types/interactive.d.ts +36 -0
- package/dist/types/layout/flowchart.d.ts +12 -0
- package/dist/types/layout/sequence.d.ts +12 -0
- package/dist/types/layout/state.d.ts +19 -0
- package/dist/types/parser/class.d.ts +11 -0
- package/dist/types/parser/config.d.ts +61 -0
- package/dist/types/parser/er.d.ts +11 -0
- package/dist/types/parser/flowchart.d.ts +26 -0
- package/dist/types/parser/gantt.d.ts +11 -0
- package/dist/types/parser/index.d.ts +22 -0
- package/dist/types/parser/pie.d.ts +11 -0
- package/dist/types/parser/sequence.d.ts +16 -0
- package/dist/types/parser/state.d.ts +22 -0
- package/dist/types/plugin.d.ts +50 -0
- package/dist/types/render/error.d.ts +23 -0
- package/dist/types/render/flowchart.d.ts +18 -0
- package/dist/types/render/index.d.ts +23 -0
- package/dist/types/render/misc.d.ts +54 -0
- package/dist/types/render/sequence.d.ts +16 -0
- package/dist/types/styles.d.ts +81 -0
- package/dist/types/theme.d.ts +47 -0
- package/dist/types/to-mermaid.d.ts +20 -0
- package/dist/types/utils.d.ts +47 -0
- package/docs/MERMAID-FORMAT.md +242 -0
- package/package.json +84 -0
- package/schemas/jaren-mermaid-ast.schema.json +78 -0
- package/schemas/jaren-workflow.schema.json +28 -0
- package/src/ast.js +252 -0
- package/src/component/index.js +109 -0
- package/src/errors.js +35 -0
- package/src/index.js +155 -0
- package/src/interactive.js +244 -0
- package/src/layout/flowchart.js +352 -0
- package/src/layout/sequence.js +178 -0
- package/src/layout/state.js +65 -0
- package/src/parser/class.js +90 -0
- package/src/parser/config.js +215 -0
- package/src/parser/er.js +86 -0
- package/src/parser/flowchart.js +413 -0
- package/src/parser/gantt.js +49 -0
- package/src/parser/index.js +122 -0
- package/src/parser/pie.js +32 -0
- package/src/parser/sequence.js +156 -0
- package/src/parser/state.js +137 -0
- package/src/plugin.js +76 -0
- package/src/render/error.js +55 -0
- package/src/render/flowchart.js +249 -0
- package/src/render/index.js +93 -0
- package/src/render/misc.js +135 -0
- package/src/render/sequence.js +152 -0
- package/src/styles.js +181 -0
- package/src/theme.js +180 -0
- package/src/to-mermaid.js +317 -0
- package/src/utils.js +64 -0
- package/styles/mermaid.css +115 -0
- package/stylesheets/dag-to-flowchart.jslt.json +62 -0
- package/stylesheets/flowchart-to-dag.jslt.json +29 -0
- package/stylesheets/state-to-workflow.jslt.json +26 -0
- 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
|
+
}
|