@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,178 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Sequence layout: actor lifeline x-positions, message y-advance,
|
|
4
|
+
* activation bars, note boxes and nested block frames
|
|
5
|
+
* (loop/alt/opt/par). Pure and deterministic; emits a host-free
|
|
6
|
+
* `PositionedDiagram`. Geometry is an approximation over the headless
|
|
7
|
+
* text metrics (no DOM) — pixel parity is a non-goal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { textWidth } from '@jarenjs/view/helpers';
|
|
11
|
+
import { coord as round } from '../utils.js';
|
|
12
|
+
|
|
13
|
+
const FONT_SIZE = 14;
|
|
14
|
+
const ACTOR_H = 34;
|
|
15
|
+
const ACTOR_MIN_W = 80;
|
|
16
|
+
const ACTOR_PAD = 20;
|
|
17
|
+
const ACTOR_GAP = 60;
|
|
18
|
+
const TOP = 12;
|
|
19
|
+
const MARGIN = 12;
|
|
20
|
+
const MSG_GAP = 40;
|
|
21
|
+
const SELF_GAP = 52;
|
|
22
|
+
const NOTE_H = 34;
|
|
23
|
+
const NOTE_PAD = 10;
|
|
24
|
+
const ACT_W = 10;
|
|
25
|
+
const BLOCK_LABEL_H = 24;
|
|
26
|
+
const BLOCK_PAD = 12;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {any} ast sequence AST
|
|
30
|
+
* @returns {any} PositionedDiagram
|
|
31
|
+
*/
|
|
32
|
+
export function layoutSequence(ast) {
|
|
33
|
+
const actors = ast.participants.map((p) => ({
|
|
34
|
+
id: p.id,
|
|
35
|
+
label: p.label,
|
|
36
|
+
kind: p.kind,
|
|
37
|
+
w: Math.max(ACTOR_MIN_W, textWidth(p.label, FONT_SIZE, 700) + 2 * ACTOR_PAD),
|
|
38
|
+
}));
|
|
39
|
+
/** @type {Map<string, any>} */
|
|
40
|
+
const byId = new Map();
|
|
41
|
+
|
|
42
|
+
// Actor centers, left to right.
|
|
43
|
+
let cursor = MARGIN;
|
|
44
|
+
for (let i = 0; i < actors.length; i++) {
|
|
45
|
+
const a = actors[i];
|
|
46
|
+
a.x = cursor + a.w / 2;
|
|
47
|
+
a.boxX = cursor;
|
|
48
|
+
a.boxY = TOP;
|
|
49
|
+
a.h = ACTOR_H;
|
|
50
|
+
byId.set(a.id, a);
|
|
51
|
+
cursor += a.w + ACTOR_GAP;
|
|
52
|
+
}
|
|
53
|
+
const width = Math.max(cursor - ACTOR_GAP + MARGIN, ACTOR_MIN_W + 2 * MARGIN);
|
|
54
|
+
|
|
55
|
+
const centerOf = (id) => (byId.get(id)?.x ?? MARGIN);
|
|
56
|
+
|
|
57
|
+
const messages = [];
|
|
58
|
+
const notes = [];
|
|
59
|
+
const activations = [];
|
|
60
|
+
const blocks = [];
|
|
61
|
+
/** @type {Map<string, number[]>} activation start-y stack per actor */
|
|
62
|
+
const actStacks = new Map();
|
|
63
|
+
|
|
64
|
+
const state = { y: TOP + ACTOR_H + MSG_GAP };
|
|
65
|
+
|
|
66
|
+
const activate = (id) => {
|
|
67
|
+
if (!actStacks.has(id)) actStacks.set(id, []);
|
|
68
|
+
actStacks.get(id).push(state.y);
|
|
69
|
+
};
|
|
70
|
+
const deactivate = (id) => {
|
|
71
|
+
const stack = actStacks.get(id);
|
|
72
|
+
if (stack && stack.length) {
|
|
73
|
+
const startY = stack.pop();
|
|
74
|
+
const a = byId.get(id);
|
|
75
|
+
if (a) activations.push({ x: a.x - ACT_W / 2, y: startY, w: ACT_W, h: Math.max(state.y - startY, MSG_GAP / 2) });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {any[]} statements
|
|
81
|
+
*/
|
|
82
|
+
const walk = (statements) => {
|
|
83
|
+
for (const stmt of statements) {
|
|
84
|
+
if (stmt.kind === 'message') {
|
|
85
|
+
const x1 = centerOf(stmt.from);
|
|
86
|
+
const x2 = centerOf(stmt.to);
|
|
87
|
+
if (stmt.activation === 'activate') activate(stmt.to);
|
|
88
|
+
if (stmt.from === stmt.to) {
|
|
89
|
+
messages.push({ x1, y: state.y, x2, label: stmt.text, line: stmt.line, head: stmt.head, self: true });
|
|
90
|
+
state.y += SELF_GAP;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
messages.push({ x1, y: state.y, x2, label: stmt.text, line: stmt.line, head: stmt.head, self: false });
|
|
94
|
+
state.y += MSG_GAP;
|
|
95
|
+
}
|
|
96
|
+
if (stmt.activation === 'deactivate') deactivate(stmt.from);
|
|
97
|
+
}
|
|
98
|
+
else if (stmt.kind === 'note') {
|
|
99
|
+
const xs = stmt.actors.map(centerOf);
|
|
100
|
+
const minX = Math.min(...xs);
|
|
101
|
+
const maxX = Math.max(...xs);
|
|
102
|
+
const textW = textWidth(stmt.text, FONT_SIZE) + 2 * NOTE_PAD;
|
|
103
|
+
let x, w;
|
|
104
|
+
if (stmt.placement === 'over') {
|
|
105
|
+
const span = maxX - minX;
|
|
106
|
+
w = Math.max(textW, span + ACTOR_MIN_W);
|
|
107
|
+
x = (minX + maxX) / 2 - w / 2;
|
|
108
|
+
}
|
|
109
|
+
else if (stmt.placement === 'left of') {
|
|
110
|
+
w = textW; x = minX - w - 10;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
w = textW; x = maxX + 10;
|
|
114
|
+
}
|
|
115
|
+
notes.push({ x, y: state.y, w, h: NOTE_H, text: stmt.text });
|
|
116
|
+
state.y += NOTE_H + 10;
|
|
117
|
+
}
|
|
118
|
+
else if (stmt.kind === 'activate') activate(stmt.actor);
|
|
119
|
+
else if (stmt.kind === 'deactivate') deactivate(stmt.actor);
|
|
120
|
+
else if (stmt.kind === 'block') {
|
|
121
|
+
const startY = state.y;
|
|
122
|
+
state.y += BLOCK_LABEL_H + BLOCK_PAD;
|
|
123
|
+
const dividers = [];
|
|
124
|
+
for (let bi = 0; bi < stmt.branches.length; bi++) {
|
|
125
|
+
if (bi > 0) {
|
|
126
|
+
dividers.push({ y: state.y, label: stmt.branches[bi].label });
|
|
127
|
+
state.y += BLOCK_LABEL_H;
|
|
128
|
+
}
|
|
129
|
+
walk(stmt.branches[bi].statements);
|
|
130
|
+
}
|
|
131
|
+
state.y += BLOCK_PAD;
|
|
132
|
+
const bx = MARGIN + 4;
|
|
133
|
+
blocks.push({
|
|
134
|
+
x: bx,
|
|
135
|
+
y: startY,
|
|
136
|
+
w: width - 2 * bx,
|
|
137
|
+
h: state.y - startY,
|
|
138
|
+
label: stmt.branches[0].label,
|
|
139
|
+
blockType: stmt.blockType,
|
|
140
|
+
dividers,
|
|
141
|
+
});
|
|
142
|
+
state.y += MSG_GAP / 2;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
walk(ast.statements);
|
|
148
|
+
|
|
149
|
+
// Close any dangling activations at the bottom.
|
|
150
|
+
for (const [id, stack] of actStacks) {
|
|
151
|
+
while (stack.length) {
|
|
152
|
+
const startY = stack.pop();
|
|
153
|
+
const a = byId.get(id);
|
|
154
|
+
if (a) activations.push({ x: a.x - ACT_W / 2, y: startY, w: ACT_W, h: Math.max(state.y - startY, MSG_GAP / 2) });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const lineBottom = state.y + 6;
|
|
159
|
+
const height = lineBottom + ACTOR_H + MARGIN;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
type: 'sequence',
|
|
163
|
+
width: round(width),
|
|
164
|
+
height: round(height),
|
|
165
|
+
fontSize: FONT_SIZE,
|
|
166
|
+
lineTop: TOP + ACTOR_H,
|
|
167
|
+
lineBottom: round(lineBottom),
|
|
168
|
+
actors: actors.map((a) => ({
|
|
169
|
+
id: a.id, label: a.label, kind: a.kind,
|
|
170
|
+
x: round(a.x), boxX: round(a.boxX), boxY: a.boxY, w: round(a.w), h: a.h,
|
|
171
|
+
bottomY: round(lineBottom),
|
|
172
|
+
})),
|
|
173
|
+
messages: messages.map((m) => ({ ...m, x1: round(m.x1), x2: round(m.x2), y: round(m.y) })),
|
|
174
|
+
notes: notes.map((n) => ({ ...n, x: round(n.x), y: round(n.y), w: round(n.w) })),
|
|
175
|
+
activations: activations.map((a) => ({ x: round(a.x), y: round(a.y), w: a.w, h: round(a.h) })),
|
|
176
|
+
blocks: blocks.map((b) => ({ ...b, x: round(b.x), y: round(b.y), w: round(b.w), h: round(b.h), dividers: b.dividers.map((d) => ({ y: round(d.y), label: d.label })) })),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file State-diagram layout: an ADAPTER, not a second algorithm. The
|
|
4
|
+
* state AST (states + labeled transitions + `[*]` pseudo-states) maps
|
|
5
|
+
* onto the flowchart layout's input vocabulary — states as rounded
|
|
6
|
+
* nodes, transition labels as edge labels, the start/end pseudo-states
|
|
7
|
+
* as synthetic `statedot`/`doublecircle` nodes — and `layoutFlowchart`
|
|
8
|
+
* does the ranking, ordering and routing. Same AST → identical
|
|
9
|
+
* geometry, like every layout in this component.
|
|
10
|
+
*
|
|
11
|
+
* Composite states arrive flattened from the parser (the `parent`
|
|
12
|
+
* field is recorded but not drawn as a cluster in v1 — the honest
|
|
13
|
+
* limitation lives in MERMAID-FORMAT §6).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { layoutFlowchart } from './flowchart.js';
|
|
17
|
+
|
|
18
|
+
/** Synthetic ids for the `[*]` pseudo-states (never valid state ids
|
|
19
|
+
* in source, since `[*]` is the only spelling the parser reserves). */
|
|
20
|
+
const START_ID = '__start';
|
|
21
|
+
const END_ID = '__end';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lay out a state AST through the flowchart engine.
|
|
25
|
+
* @param {any} ast state AST ({ states, transitions })
|
|
26
|
+
* @returns {any} PositionedDiagram
|
|
27
|
+
*/
|
|
28
|
+
export function layoutState(ast) {
|
|
29
|
+
const nodes = [];
|
|
30
|
+
const edges = [];
|
|
31
|
+
let hasStart = false;
|
|
32
|
+
let hasEnd = false;
|
|
33
|
+
|
|
34
|
+
for (const t of ast.transitions) {
|
|
35
|
+
if (t.from === '[*]') hasStart = true;
|
|
36
|
+
if (t.to === '[*]') hasEnd = true;
|
|
37
|
+
}
|
|
38
|
+
if (hasStart) nodes.push({ id: START_ID, label: '', shape: 'statedot' });
|
|
39
|
+
for (const s of ast.states) {
|
|
40
|
+
nodes.push({ id: s.id, label: s.label ?? s.id, shape: 'round' });
|
|
41
|
+
}
|
|
42
|
+
if (hasEnd) nodes.push({ id: END_ID, label: '', shape: 'doublecircle' });
|
|
43
|
+
|
|
44
|
+
for (const t of ast.transitions) {
|
|
45
|
+
edges.push({
|
|
46
|
+
from: t.from === '[*]' ? START_ID : t.from,
|
|
47
|
+
to: t.to === '[*]' ? END_ID : t.to,
|
|
48
|
+
label: t.label ?? null,
|
|
49
|
+
stroke: 'solid',
|
|
50
|
+
head: 'arrow',
|
|
51
|
+
tail: 'none',
|
|
52
|
+
length: 2,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return layoutFlowchart({
|
|
57
|
+
direction: 'TB',
|
|
58
|
+
nodes,
|
|
59
|
+
edges,
|
|
60
|
+
subgraphs: [],
|
|
61
|
+
classDefs: [],
|
|
62
|
+
classes: [],
|
|
63
|
+
styles: [],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Class-diagram grammar → class AST. Supports `class Foo { … }`
|
|
4
|
+
* bodies, `Foo : +member` line form, and relations
|
|
5
|
+
* (`<|--`, `*--`, `o--`, `-->`, `..>`, `..|>`) with optional `: label`.
|
|
6
|
+
* Geometry-free: members and relations preserve declaration order.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { collectBraceBody } from '../utils.js';
|
|
10
|
+
|
|
11
|
+
/** Relation operator between two class names, optional `: label`. */
|
|
12
|
+
const RE_RELATION = /^(\S+)\s+([<>|*o.]{0,2}(?:--|\.\.)[<>|*o.]{0,2})\s+(\S+)(?:\s*:\s*(.*))?$/;
|
|
13
|
+
/** `ClassName : member` line form. */
|
|
14
|
+
const RE_MEMBER_LINE = /^(\S+)\s*:\s*(.+)$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string[]} lines
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
export function parseClass(lines) {
|
|
21
|
+
/** @type {Map<string, { name: string, label: string, members: object[] }>} */
|
|
22
|
+
const classMap = new Map();
|
|
23
|
+
const order = [];
|
|
24
|
+
const relations = [];
|
|
25
|
+
|
|
26
|
+
const ensure = (name) => {
|
|
27
|
+
let cls = classMap.get(name);
|
|
28
|
+
if (cls === undefined) {
|
|
29
|
+
cls = { name, label: name, members: [] };
|
|
30
|
+
classMap.set(name, cls);
|
|
31
|
+
order.push(name);
|
|
32
|
+
}
|
|
33
|
+
return cls;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (let li = 0; li < lines.length; li++) {
|
|
37
|
+
let line = lines[li].trim();
|
|
38
|
+
if (line === '' || line.startsWith('%%')) continue;
|
|
39
|
+
|
|
40
|
+
// class Foo { ... } (single or multi-line body)
|
|
41
|
+
if (line.startsWith('class ')) {
|
|
42
|
+
const rest = line.slice('class '.length).trim();
|
|
43
|
+
const brace = rest.indexOf('{');
|
|
44
|
+
if (brace === -1) { ensure(rest.replace(/[~<].*$/, '').trim()); continue; }
|
|
45
|
+
const name = rest.slice(0, brace).trim();
|
|
46
|
+
const cls = ensure(name);
|
|
47
|
+
// Consume until closing brace across lines.
|
|
48
|
+
let body;
|
|
49
|
+
({ body, li } = collectBraceBody(lines, li, rest.slice(brace + 1)));
|
|
50
|
+
for (const raw of body.split('\n')) {
|
|
51
|
+
const mem = raw.trim();
|
|
52
|
+
if (mem !== '') cls.members.push(member(mem));
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rel = RE_RELATION.exec(line);
|
|
58
|
+
if (rel !== null) {
|
|
59
|
+
ensure(rel[1]);
|
|
60
|
+
ensure(rel[3]);
|
|
61
|
+
relations.push({ from: rel[1], to: rel[3], type: rel[2], label: rel[4] ? rel[4].trim() : null });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ml = RE_MEMBER_LINE.exec(line);
|
|
66
|
+
if (ml !== null && !line.startsWith('class')) {
|
|
67
|
+
ensure(ml[1]).members.push(member(ml[2].trim()));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { classes: order.map((n) => classMap.get(n)), relations };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classify a member as a method (has `()`) or attribute, capturing a
|
|
77
|
+
* leading visibility marker.
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @returns {object}
|
|
80
|
+
*/
|
|
81
|
+
function member(text) {
|
|
82
|
+
let visibility = null;
|
|
83
|
+
const first = text.charCodeAt(0);
|
|
84
|
+
if (first === 0x2b || first === 0x2d || first === 0x23 || first === 0x7e) {
|
|
85
|
+
visibility = text[0];
|
|
86
|
+
text = text.slice(1);
|
|
87
|
+
}
|
|
88
|
+
const kind = text.indexOf('(') !== -1 ? 'method' : 'attribute';
|
|
89
|
+
return { kind, visibility, text: text.trim() };
|
|
90
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Config extraction: the leading `---` front-matter block and any
|
|
4
|
+
* `%%{init: {...}}%%` directives → a plain-JSON `config` (MERMAID-FORMAT
|
|
5
|
+
* §3).
|
|
6
|
+
*
|
|
7
|
+
* The raw source is never consumed here — this returns the cleaned body
|
|
8
|
+
* (front-matter, init directives and `%%` comments removed) plus the
|
|
9
|
+
* merged config and title, and the original source stays in the
|
|
10
|
+
* Markdown fence `value` so `toMarkdown` round-trips verbatim.
|
|
11
|
+
*
|
|
12
|
+
* Front-matter is a YAML subset. The engine may NOT statically import
|
|
13
|
+
* `@jarenjs/md` (two-layer rule), so a small built-in subset parser is
|
|
14
|
+
* the default; a host that already has `@jarenjs/md` can inject its
|
|
15
|
+
* richer `parseFrontmatter` through `options.parseFrontmatter`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A leading front-matter fence: `---` on its own line at the very top. */
|
|
19
|
+
const FM_OPEN = /^---[ \t]*$/;
|
|
20
|
+
/** An `%%{init: {...}}%%` (or `%%{ init: ... }%%`) directive line. */
|
|
21
|
+
const RE_INIT = /^\s*%%\{\s*(?:init|initialize)\s*:\s*([\s\S]*?)\}%%\s*$/;
|
|
22
|
+
/** A whole-line `%%` comment (but not an `%%{...}%%` directive). */
|
|
23
|
+
const RE_COMMENT = /^\s*%%(?!\{)/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} MermaidConfigResult
|
|
27
|
+
* @property {Record<string, any>} config merged config object
|
|
28
|
+
* @property {string|null} title front-matter `title`, if any
|
|
29
|
+
* @property {string} body source with front-matter/init/comments stripped
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract config + title and return the cleaned diagram body.
|
|
34
|
+
* @param {string} source
|
|
35
|
+
* @param {{ parseFrontmatter?: (text: string) => any }} [options]
|
|
36
|
+
* @returns {MermaidConfigResult}
|
|
37
|
+
*/
|
|
38
|
+
export function parseMermaidConfig(source, options = {}) {
|
|
39
|
+
const normalized = source.replace(/\r\n?/g, '\n');
|
|
40
|
+
/** @type {Record<string, any>} */
|
|
41
|
+
let config = {};
|
|
42
|
+
let title = null;
|
|
43
|
+
let rest = normalized;
|
|
44
|
+
|
|
45
|
+
// 1. Front-matter: `---` … `---` only when it is the very first line.
|
|
46
|
+
const firstNl = rest.indexOf('\n');
|
|
47
|
+
if (firstNl !== -1 && FM_OPEN.test(rest.slice(0, firstNl))) {
|
|
48
|
+
const closeAt = findFrontmatterClose(rest, firstNl + 1);
|
|
49
|
+
if (closeAt !== -1) {
|
|
50
|
+
const fmText = rest.slice(firstNl + 1, closeAt.start);
|
|
51
|
+
const parsed = options.parseFrontmatter
|
|
52
|
+
? safeCall(options.parseFrontmatter, fmText)
|
|
53
|
+
: parseYamlSubset(fmText);
|
|
54
|
+
if (parsed && typeof parsed === 'object') {
|
|
55
|
+
if (typeof parsed.title === 'string') title = parsed.title;
|
|
56
|
+
if (parsed.config && typeof parsed.config === 'object') {
|
|
57
|
+
config = mergeConfig(config, parsed.config);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
rest = rest.slice(closeAt.end);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Init directives + comment stripping, line by line.
|
|
65
|
+
const lines = rest.split('\n');
|
|
66
|
+
const out = [];
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i];
|
|
69
|
+
const initMatch = RE_INIT.exec(line);
|
|
70
|
+
if (initMatch) {
|
|
71
|
+
const obj = parseLooseObject(initMatch[1]);
|
|
72
|
+
if (obj && typeof obj === 'object') config = mergeConfig(config, obj);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (RE_COMMENT.test(line)) continue;
|
|
76
|
+
out.push(line);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { config, title, body: out.join('\n') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find the closing `---` fence starting at `from`.
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @param {number} from
|
|
86
|
+
* @returns {{ start: number, end: number } | -1}
|
|
87
|
+
*/
|
|
88
|
+
function findFrontmatterClose(text, from) {
|
|
89
|
+
let pos = from;
|
|
90
|
+
while (pos < text.length) {
|
|
91
|
+
let nl = text.indexOf('\n', pos);
|
|
92
|
+
if (nl === -1) nl = text.length;
|
|
93
|
+
const line = text.slice(pos, nl);
|
|
94
|
+
if (FM_OPEN.test(line)) {
|
|
95
|
+
return { start: pos, end: nl === text.length ? nl : nl + 1 };
|
|
96
|
+
}
|
|
97
|
+
pos = nl + 1;
|
|
98
|
+
if (nl === text.length) break;
|
|
99
|
+
}
|
|
100
|
+
return -1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a small YAML subset: `key: value` and one level of nesting via
|
|
105
|
+
* two-space indentation. Values are scalars (string/number/bool/null).
|
|
106
|
+
* Deliberately minimal — the documented default when `@jarenjs/md`'s
|
|
107
|
+
* parser is not injected.
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {Record<string, any>}
|
|
110
|
+
*/
|
|
111
|
+
export function parseYamlSubset(text) {
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const root = {};
|
|
114
|
+
/** @type {{ indent: number, obj: Record<string, any> }[]} */
|
|
115
|
+
const stack = [{ indent: -1, obj: root }];
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
const raw = lines[i];
|
|
118
|
+
if (raw.trim() === '' || raw.trim().startsWith('#')) continue;
|
|
119
|
+
let indent = 0;
|
|
120
|
+
while (indent < raw.length && raw.charCodeAt(indent) === 0x20) indent++;
|
|
121
|
+
const colon = raw.indexOf(':', indent);
|
|
122
|
+
if (colon === -1) continue;
|
|
123
|
+
const key = raw.slice(indent, colon).trim();
|
|
124
|
+
const valueText = raw.slice(colon + 1).trim();
|
|
125
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
|
|
126
|
+
const parent = stack[stack.length - 1].obj;
|
|
127
|
+
if (valueText === '') {
|
|
128
|
+
const child = {};
|
|
129
|
+
parent[key] = child;
|
|
130
|
+
stack.push({ indent, obj: child });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
parent[key] = parseScalar(valueText);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return root;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} v
|
|
141
|
+
* @returns {any}
|
|
142
|
+
*/
|
|
143
|
+
function parseScalar(v) {
|
|
144
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
145
|
+
return v.slice(1, -1);
|
|
146
|
+
}
|
|
147
|
+
if (v === 'true') return true;
|
|
148
|
+
if (v === 'false') return false;
|
|
149
|
+
if (v === 'null' || v === '~') return null;
|
|
150
|
+
if (v !== '' && !Number.isNaN(Number(v))) return Number(v);
|
|
151
|
+
return v;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse the relaxed object inside `%%{init: … }%%`. Tries strict JSON
|
|
156
|
+
* first, then a light normalization (quote bare keys, single→double
|
|
157
|
+
* quotes). Returns `null` on failure (config parsing never throws).
|
|
158
|
+
* @param {string} text
|
|
159
|
+
* @returns {Record<string, any> | null}
|
|
160
|
+
*/
|
|
161
|
+
export function parseLooseObject(text) {
|
|
162
|
+
const trimmed = text.trim();
|
|
163
|
+
const src = trimmed.startsWith('{') ? trimmed : '{' + trimmed + '}';
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(src);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// fall through to lenient
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const lenient = src
|
|
172
|
+
.replace(/'/g, '"')
|
|
173
|
+
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
|
|
174
|
+
return JSON.parse(lenient);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Shallow-merge nested config objects (one level deep for per-type
|
|
183
|
+
* keys like `flowchart`), returning a fresh object.
|
|
184
|
+
* @param {Record<string, any>} base
|
|
185
|
+
* @param {Record<string, any>} extra
|
|
186
|
+
* @returns {Record<string, any>}
|
|
187
|
+
*/
|
|
188
|
+
function mergeConfig(base, extra) {
|
|
189
|
+
const out = { ...base };
|
|
190
|
+
for (const key of Object.keys(extra)) {
|
|
191
|
+
const value = extra[key];
|
|
192
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
193
|
+
&& out[key] && typeof out[key] === 'object' && !Array.isArray(out[key])) {
|
|
194
|
+
out[key] = { ...out[key], ...value };
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
out[key] = value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {(text: string) => any} fn
|
|
205
|
+
* @param {string} text
|
|
206
|
+
* @returns {any}
|
|
207
|
+
*/
|
|
208
|
+
function safeCall(fn, text) {
|
|
209
|
+
try {
|
|
210
|
+
return fn(text);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/parser/er.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file ER-diagram grammar → ER AST. Entity blocks
|
|
4
|
+
* (`CUSTOMER { string name PK }`) and relationships
|
|
5
|
+
* (`CUSTOMER ||--o{ ORDER : places`). Cardinality tokens are preserved
|
|
6
|
+
* verbatim for a faithful, geometry-free model.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { collectBraceBody } from '../utils.js';
|
|
10
|
+
|
|
11
|
+
/** `LEFT <cardl>--<cardr> RIGHT : label` (relationship). */
|
|
12
|
+
const RE_REL = /^(\S+)\s+([|}{o]{1,2})(--|\.\.)([|}{o]{1,2})\s+(\S+)\s*:\s*(.*)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string[]} lines
|
|
16
|
+
* @returns {object}
|
|
17
|
+
*/
|
|
18
|
+
export function parseEr(lines) {
|
|
19
|
+
/** @type {Map<string, { name: string, attributes: object[] }>} */
|
|
20
|
+
const entityMap = new Map();
|
|
21
|
+
const order = [];
|
|
22
|
+
const relationships = [];
|
|
23
|
+
|
|
24
|
+
const ensure = (name) => {
|
|
25
|
+
let ent = entityMap.get(name);
|
|
26
|
+
if (ent === undefined) {
|
|
27
|
+
ent = { name, attributes: [] };
|
|
28
|
+
entityMap.set(name, ent);
|
|
29
|
+
order.push(name);
|
|
30
|
+
}
|
|
31
|
+
return ent;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (let li = 0; li < lines.length; li++) {
|
|
35
|
+
let line = lines[li].trim();
|
|
36
|
+
if (line === '' || line.startsWith('%%')) continue;
|
|
37
|
+
|
|
38
|
+
// Relationships are matched BEFORE the '{' entity-block check: a
|
|
39
|
+
// cardinality token can itself contain a brace (`CUSTOMER ||--o{ ORDER`),
|
|
40
|
+
// so `indexOf('{')` alone would misread the relationship as an entity
|
|
41
|
+
// block. An entity opener (`CUSTOMER {`) has no `--`/`..` connector, so
|
|
42
|
+
// it can never match RE_REL.
|
|
43
|
+
const rel = RE_REL.exec(line);
|
|
44
|
+
if (rel !== null) {
|
|
45
|
+
ensure(rel[1]);
|
|
46
|
+
ensure(rel[5]);
|
|
47
|
+
relationships.push({
|
|
48
|
+
left: rel[1],
|
|
49
|
+
right: rel[5],
|
|
50
|
+
leftCard: rel[2],
|
|
51
|
+
rightCard: rel[4],
|
|
52
|
+
identifying: rel[3] === '--',
|
|
53
|
+
label: rel[6].trim(),
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const brace = line.indexOf('{');
|
|
59
|
+
if (brace !== -1) {
|
|
60
|
+
const name = line.slice(0, brace).trim();
|
|
61
|
+
const ent = ensure(name);
|
|
62
|
+
let body;
|
|
63
|
+
({ body, li } = collectBraceBody(lines, li, line.slice(brace + 1)));
|
|
64
|
+
for (const raw of body.split('\n')) {
|
|
65
|
+
const attr = raw.trim();
|
|
66
|
+
if (attr === '') continue;
|
|
67
|
+
const parts = attr.split(/\s+/);
|
|
68
|
+
entityAttr(ent, parts);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { entities: order.map((n) => entityMap.get(n)), relationships };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {{ attributes: object[] }} ent
|
|
79
|
+
* @param {string[]} parts `[type, name, key?, "comment"?]`
|
|
80
|
+
*/
|
|
81
|
+
function entityAttr(ent, parts) {
|
|
82
|
+
const type = parts[0] ?? '';
|
|
83
|
+
const name = parts[1] ?? '';
|
|
84
|
+
const keys = parts.slice(2).filter((p) => p === 'PK' || p === 'FK' || p === 'UK');
|
|
85
|
+
ent.attributes.push({ type, name, keys });
|
|
86
|
+
}
|