@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,413 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Flowchart grammar → flowchart AST. Flowchart and sequence are
|
|
4
|
+
* the two fully-modeled diagram types; this is the first.
|
|
5
|
+
*
|
|
6
|
+
* Char-code recursive descent over one statement at a time. Every
|
|
7
|
+
* pattern regex is a module constant used with the sticky (`y`) flag so
|
|
8
|
+
* there is no per-call `RegExp` allocation; the vertex/edge chain is a
|
|
9
|
+
* hand-written scan. Produces the geometry-free flowchart AST from
|
|
10
|
+
* `ast.js` — nodes, edges, subgraphs, classDef/class/style — preserving
|
|
11
|
+
* shape, label and declaration order so `to-mermaid.js` is a fixed
|
|
12
|
+
* point.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
flowNode, flowEdge, flowSubgraph, flowClassDef, flowClass, flowStyle, flowchartAst,
|
|
17
|
+
} from '../ast.js';
|
|
18
|
+
import { fail } from '../errors.js';
|
|
19
|
+
import { countCharCode } from '@jarenjs/core/string';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shape opener sequences, longest first, each with the closer sequences
|
|
23
|
+
* that terminate it and the resulting shape name.
|
|
24
|
+
* @type {[string, [string, string][]][]}
|
|
25
|
+
*/
|
|
26
|
+
const SHAPE_OPENERS = [
|
|
27
|
+
['(((', [[')))', 'doublecircle']]],
|
|
28
|
+
['[[', [[']]', 'subroutine']]],
|
|
29
|
+
['[(', [[')]', 'cylinder']]],
|
|
30
|
+
['[/', [['/]', 'parallelogram'], ['\\]', 'trapezoid']]],
|
|
31
|
+
['[\\', [['\\]', 'parallelogram_alt'], ['/]', 'trapezoid_alt']]],
|
|
32
|
+
['([', [['])', 'stadium']]],
|
|
33
|
+
['((', [['))', 'circle']]],
|
|
34
|
+
['{{', [['}}', 'hexagon']]],
|
|
35
|
+
['[', [[']', 'rect']]],
|
|
36
|
+
['(', [[')', 'round']]],
|
|
37
|
+
['{', [['}', 'diamond']]],
|
|
38
|
+
['>', [[']', 'asymmetric']]],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Sticky: an edge with an inline ` text ` label between two link runs. */
|
|
42
|
+
const RE_EDGE_LABELED = /(<?)([-.=]{2,})\s+(.+?)\s+([-.=]{2,})([>ox]?)/y;
|
|
43
|
+
/** Sticky: a plain link operator. */
|
|
44
|
+
const RE_EDGE_PLAIN = /(<?)([-.=]{2,})([>ox]?)/y;
|
|
45
|
+
/** Sticky: an identifier (vertex id, class name, …). Ids are
|
|
46
|
+
* alphanumeric/underscore (plus Unicode letters); `-`/`.` are excluded
|
|
47
|
+
* so they cannot swallow a following link operator. */
|
|
48
|
+
const RE_ID = /[A-Za-z0-9_À-][\wÀ-]*/y;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a flowchart body (config already stripped) into the flowchart
|
|
52
|
+
* AST. `keyword`/`firstLine` give the header; `direction` overrides.
|
|
53
|
+
* @param {string[]} lines body lines
|
|
54
|
+
* @param {number} lineOffset absolute line number of `lines[0]`
|
|
55
|
+
* @param {string} direction detected direction
|
|
56
|
+
* @returns {object}
|
|
57
|
+
*/
|
|
58
|
+
export function parseFlowchart(lines, lineOffset, direction) {
|
|
59
|
+
/** @type {Map<string, object>} */
|
|
60
|
+
const nodeMap = new Map();
|
|
61
|
+
const nodeOrder = [];
|
|
62
|
+
const edges = [];
|
|
63
|
+
const subgraphs = [];
|
|
64
|
+
const classDefs = [];
|
|
65
|
+
const classes = [];
|
|
66
|
+
const styles = [];
|
|
67
|
+
/** @type {{ id: string, label: string, direction: string|null, nodes: string[] }[]} */
|
|
68
|
+
const subgraphStack = [];
|
|
69
|
+
|
|
70
|
+
const ensureNode = (id, label, shape) => {
|
|
71
|
+
let node = nodeMap.get(id);
|
|
72
|
+
if (node === undefined) {
|
|
73
|
+
node = flowNode(id, label ?? id, shape ?? 'rect');
|
|
74
|
+
nodeMap.set(id, node);
|
|
75
|
+
nodeOrder.push(id);
|
|
76
|
+
}
|
|
77
|
+
else if (label !== null && label !== undefined) {
|
|
78
|
+
node.label = label;
|
|
79
|
+
node.shape = shape ?? node.shape;
|
|
80
|
+
}
|
|
81
|
+
if (subgraphStack.length > 0) {
|
|
82
|
+
const group = subgraphStack[subgraphStack.length - 1];
|
|
83
|
+
if (!group.nodes.includes(id)) group.nodes.push(id);
|
|
84
|
+
}
|
|
85
|
+
return node;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
for (let li = 0; li < lines.length; li++) {
|
|
89
|
+
const line = lines[li].trim();
|
|
90
|
+
if (line === '') continue;
|
|
91
|
+
const lineNo = lineOffset + li + 1;
|
|
92
|
+
|
|
93
|
+
// subgraph … end
|
|
94
|
+
if (line === 'end') {
|
|
95
|
+
const group = subgraphStack.pop();
|
|
96
|
+
if (group === undefined) fail("unexpected 'end' with no open subgraph", lineNo);
|
|
97
|
+
// A finished subgraph goes to the list only when it is top-level;
|
|
98
|
+
// nested subgraphs still record membership via their id.
|
|
99
|
+
subgraphs.push(flowSubgraph(group.id, group.label, group.direction, group.nodes));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (line.startsWith('subgraph')) {
|
|
103
|
+
const rest = line.slice('subgraph'.length).trim();
|
|
104
|
+
const sg = parseSubgraphHeader(rest);
|
|
105
|
+
subgraphStack.push({ id: sg.id, label: sg.label, direction: null, nodes: [] });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (line.startsWith('direction ')) {
|
|
109
|
+
const dir = line.slice('direction '.length).trim();
|
|
110
|
+
if (subgraphStack.length > 0) subgraphStack[subgraphStack.length - 1].direction = dir;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (line.startsWith('classDef ')) {
|
|
114
|
+
const rest = line.slice('classDef '.length).trim();
|
|
115
|
+
const sp = rest.indexOf(' ');
|
|
116
|
+
if (sp === -1) { classDefs.push(flowClassDef(rest, '')); continue; }
|
|
117
|
+
classDefs.push(flowClassDef(rest.slice(0, sp).trim(), rest.slice(sp + 1).trim()));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (line.startsWith('class ')) {
|
|
121
|
+
const rest = line.slice('class '.length).trim();
|
|
122
|
+
const sp = rest.lastIndexOf(' ');
|
|
123
|
+
if (sp === -1) continue;
|
|
124
|
+
const nodeList = rest.slice(0, sp).split(',').map((s) => s.trim()).filter(Boolean);
|
|
125
|
+
const name = rest.slice(sp + 1).trim();
|
|
126
|
+
for (const n of nodeList) classes.push(flowClass(n, name));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (line.startsWith('style ')) {
|
|
130
|
+
const rest = line.slice('style '.length).trim();
|
|
131
|
+
const sp = rest.indexOf(' ');
|
|
132
|
+
if (sp === -1) continue;
|
|
133
|
+
styles.push(flowStyle(rest.slice(0, sp).trim(), rest.slice(sp + 1).trim()));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (line.startsWith('click ') || line.startsWith('linkStyle ')) {
|
|
137
|
+
continue; // parse-accept, not modeled in v1
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A node/edge chain: split on ';' first (multiple statements per line).
|
|
141
|
+
for (const stmt of splitStatements(line)) {
|
|
142
|
+
parseChain(stmt, lineNo, ensureNode, edges, classes);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Close any dangling subgraphs (lenient).
|
|
147
|
+
while (subgraphStack.length > 0) {
|
|
148
|
+
const group = subgraphStack.pop();
|
|
149
|
+
subgraphs.push(flowSubgraph(group.id, group.label, group.direction, group.nodes));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nodes = nodeOrder.map((id) => nodeMap.get(id));
|
|
153
|
+
return flowchartAst(direction, nodes, edges, subgraphs, classDefs, classes, styles);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Split a line on top-level `;` (rarely used, but valid).
|
|
158
|
+
*
|
|
159
|
+
* Only a `;` OUTSIDE a quoted label separates statements. A label is text —
|
|
160
|
+
* `"fetch is injected; streaming via SSE"` is one node, not two statements —
|
|
161
|
+
* and splitting inside it produced an unterminated-shape error on a document
|
|
162
|
+
* that is perfectly good Mermaid.
|
|
163
|
+
* @param {string} line
|
|
164
|
+
* @returns {string[]}
|
|
165
|
+
*/
|
|
166
|
+
function splitStatements(line) {
|
|
167
|
+
if (line.indexOf(';') === -1) return [line];
|
|
168
|
+
const out = [];
|
|
169
|
+
let start = 0;
|
|
170
|
+
let quote = '';
|
|
171
|
+
for (let i = 0; i < line.length; i++) {
|
|
172
|
+
const ch = line[i];
|
|
173
|
+
if (quote !== '') {
|
|
174
|
+
if (ch === quote) quote = '';
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ch === '"' || ch === '\'') { quote = ch; continue; }
|
|
178
|
+
if (ch !== ';') continue;
|
|
179
|
+
const part = line.slice(start, i).trim();
|
|
180
|
+
if (part !== '') out.push(part);
|
|
181
|
+
start = i + 1;
|
|
182
|
+
}
|
|
183
|
+
const tail = line.slice(start).trim();
|
|
184
|
+
if (tail !== '') out.push(tail);
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parse `subgraph` header: `id`, `id[title]`, `"title"`, or `id [title]`.
|
|
190
|
+
* @param {string} rest
|
|
191
|
+
* @returns {{ id: string, label: string }}
|
|
192
|
+
*/
|
|
193
|
+
function parseSubgraphHeader(rest) {
|
|
194
|
+
if (rest === '') return { id: 'sub', label: '' };
|
|
195
|
+
// id[title] form
|
|
196
|
+
const br = rest.indexOf('[');
|
|
197
|
+
if (br !== -1 && rest.endsWith(']')) {
|
|
198
|
+
const id = rest.slice(0, br).trim();
|
|
199
|
+
const label = stripQuotes(rest.slice(br + 1, -1).trim());
|
|
200
|
+
return { id: id || label, label };
|
|
201
|
+
}
|
|
202
|
+
if (rest.startsWith('"') && rest.endsWith('"')) {
|
|
203
|
+
const label = stripQuotes(rest.slice(1, -1));
|
|
204
|
+
return { id: label, label };
|
|
205
|
+
}
|
|
206
|
+
return { id: rest, label: rest };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse one vertex/edge chain: `A[x] --> B & C -->|l| D`.
|
|
211
|
+
* @param {string} stmt
|
|
212
|
+
* @param {number} lineNo
|
|
213
|
+
* @param {(id: string, label: string|null, shape: string|null) => object} ensureNode
|
|
214
|
+
* @param {object[]} edges
|
|
215
|
+
* @param {object[]} classes
|
|
216
|
+
*/
|
|
217
|
+
function parseChain(stmt, lineNo, ensureNode, edges, classes) {
|
|
218
|
+
const s = stmt;
|
|
219
|
+
let pos = skipSpace(s, 0);
|
|
220
|
+
let prevGroup = readVertexGroup(s, pos, lineNo, ensureNode, classes);
|
|
221
|
+
if (prevGroup === null) return; // empty
|
|
222
|
+
pos = prevGroup.pos;
|
|
223
|
+
|
|
224
|
+
// Standalone vertex (no edge): already ensured; done.
|
|
225
|
+
for (;;) {
|
|
226
|
+
pos = skipSpace(s, pos);
|
|
227
|
+
if (pos >= s.length) break;
|
|
228
|
+
const edge = readEdge(s, pos);
|
|
229
|
+
if (edge === null) {
|
|
230
|
+
// Unrecognized trailing content — be lenient, stop.
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
pos = edge.pos;
|
|
234
|
+
pos = skipSpace(s, pos);
|
|
235
|
+
const nextGroup = readVertexGroup(s, pos, lineNo, ensureNode, classes);
|
|
236
|
+
if (nextGroup === null) fail('expected a node after a link', lineNo);
|
|
237
|
+
pos = nextGroup.pos;
|
|
238
|
+
for (const from of prevGroup.ids) {
|
|
239
|
+
for (const to of nextGroup.ids) {
|
|
240
|
+
edges.push(flowEdge(from, to, edge.stroke, edge.head, edge.tail, edge.length, edge.label));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
prevGroup = nextGroup;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Read a `&`-separated vertex group, ensuring each node.
|
|
249
|
+
* @returns {{ ids: string[], pos: number } | null}
|
|
250
|
+
*/
|
|
251
|
+
function readVertexGroup(s, pos, lineNo, ensureNode, classes) {
|
|
252
|
+
const ids = [];
|
|
253
|
+
for (;;) {
|
|
254
|
+
pos = skipSpace(s, pos);
|
|
255
|
+
const v = readVertex(s, pos, lineNo);
|
|
256
|
+
if (v === null) return ids.length === 0 ? null : { ids, pos };
|
|
257
|
+
ensureNode(v.id, v.label, v.shape);
|
|
258
|
+
if (v.className !== null) classes.push(flowClass(v.id, v.className));
|
|
259
|
+
ids.push(v.id);
|
|
260
|
+
pos = skipSpace(s, v.pos);
|
|
261
|
+
if (s.charCodeAt(pos) === 0x26 /* & */) { pos++; continue; }
|
|
262
|
+
return { ids, pos };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Read one vertex: id + optional shape/label + optional `:::class`.
|
|
268
|
+
* @returns {{ id: string, label: string|null, shape: string|null, className: string|null, pos: number } | null}
|
|
269
|
+
*/
|
|
270
|
+
function readVertex(s, pos, lineNo) {
|
|
271
|
+
RE_ID.lastIndex = pos;
|
|
272
|
+
const m = RE_ID.exec(s);
|
|
273
|
+
if (m === null || m.index !== pos) return null;
|
|
274
|
+
const id = m[0];
|
|
275
|
+
let p = RE_ID.lastIndex;
|
|
276
|
+
|
|
277
|
+
let label = null;
|
|
278
|
+
let shape = null;
|
|
279
|
+
// Shape wrapper directly after the id (no space).
|
|
280
|
+
const opened = matchOpener(s, p);
|
|
281
|
+
if (opened !== null) {
|
|
282
|
+
const closed = scanToCloser(s, opened.contentStart, opened.closers);
|
|
283
|
+
if (closed === null) fail(`unterminated '${opened.opener}' shape for node '${id}'`, lineNo);
|
|
284
|
+
label = stripQuotes(s.slice(opened.contentStart, closed.at).trim());
|
|
285
|
+
shape = closed.shape;
|
|
286
|
+
p = closed.end;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Inline class: A:::name
|
|
290
|
+
let className = null;
|
|
291
|
+
if (s.charCodeAt(p) === 0x3a && s.charCodeAt(p + 1) === 0x3a && s.charCodeAt(p + 2) === 0x3a) {
|
|
292
|
+
RE_ID.lastIndex = p + 3;
|
|
293
|
+
const cm = RE_ID.exec(s);
|
|
294
|
+
if (cm !== null && cm.index === p + 3) {
|
|
295
|
+
className = cm[0];
|
|
296
|
+
p = RE_ID.lastIndex;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { id, label, shape, className, pos: p };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Match a shape opener at `pos` (longest first).
|
|
304
|
+
* @returns {{ opener: string, contentStart: number, closers: [string, string][] } | null}
|
|
305
|
+
*/
|
|
306
|
+
function matchOpener(s, pos) {
|
|
307
|
+
for (let i = 0; i < SHAPE_OPENERS.length; i++) {
|
|
308
|
+
const [opener, closers] = SHAPE_OPENERS[i];
|
|
309
|
+
if (s.startsWith(opener, pos)) {
|
|
310
|
+
return { opener, contentStart: pos + opener.length, closers };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Scan for the earliest closer sequence from `from`.
|
|
318
|
+
* @param {string} s
|
|
319
|
+
* @param {number} from
|
|
320
|
+
* @param {[string, string][]} closers
|
|
321
|
+
* @returns {{ at: number, end: number, shape: string } | null}
|
|
322
|
+
*/
|
|
323
|
+
function scanToCloser(s, from, closers) {
|
|
324
|
+
for (let i = from; i < s.length; i++) {
|
|
325
|
+
for (let c = 0; c < closers.length; c++) {
|
|
326
|
+
const [seq, shape] = closers[c];
|
|
327
|
+
if (s.startsWith(seq, i)) return { at: i, end: i + seq.length, shape };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Read a link operator, plain or labeled.
|
|
335
|
+
* @returns {{ stroke: string, head: string, tail: string, length: number, label: string|null, pos: number } | null}
|
|
336
|
+
*/
|
|
337
|
+
function readEdge(s, pos) {
|
|
338
|
+
RE_EDGE_LABELED.lastIndex = pos;
|
|
339
|
+
let m = RE_EDGE_LABELED.exec(s);
|
|
340
|
+
let label = null;
|
|
341
|
+
if (m !== null && m.index === pos) {
|
|
342
|
+
label = m[3].trim();
|
|
343
|
+
const info = classifyLink(m[1], m[4], m[5]);
|
|
344
|
+
return { ...info, label, pos: RE_EDGE_LABELED.lastIndex };
|
|
345
|
+
}
|
|
346
|
+
RE_EDGE_PLAIN.lastIndex = pos;
|
|
347
|
+
m = RE_EDGE_PLAIN.exec(s);
|
|
348
|
+
if (m === null || m.index !== pos) return null;
|
|
349
|
+
const info = classifyLink(m[1], m[2], m[3]);
|
|
350
|
+
let p = RE_EDGE_PLAIN.lastIndex;
|
|
351
|
+
// Optional |label|
|
|
352
|
+
if (s.charCodeAt(p) === 0x7c /* | */) {
|
|
353
|
+
const close = s.indexOf('|', p + 1);
|
|
354
|
+
if (close !== -1) {
|
|
355
|
+
label = stripQuotes(s.slice(p + 1, close).trim());
|
|
356
|
+
p = close + 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { ...info, label, pos: p };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Classify a link body into stroke/head/tail/length.
|
|
364
|
+
* @param {string} tailMark leading `<` or ''
|
|
365
|
+
* @param {string} body run of `-`/`.`/`=`
|
|
366
|
+
* @param {string} headMark trailing `>`/`o`/`x` or ''
|
|
367
|
+
*/
|
|
368
|
+
function classifyLink(tailMark, body, headMark) {
|
|
369
|
+
const stroke = body.indexOf('=') !== -1 ? 'thick'
|
|
370
|
+
: body.indexOf('.') !== -1 ? 'dotted' : 'solid';
|
|
371
|
+
const head = headMark === '>' ? 'arrow' : headMark === 'o' ? 'circle'
|
|
372
|
+
: headMark === 'x' ? 'cross' : 'none';
|
|
373
|
+
const tail = tailMark === '<' ? 'arrow' : 'none';
|
|
374
|
+
let length;
|
|
375
|
+
if (stroke === 'thick') length = countCharCode(body, 0x3d);
|
|
376
|
+
else if (stroke === 'dotted') length = countCharCode(body, 0x2e);
|
|
377
|
+
else length = countCharCode(body, 0x2d);
|
|
378
|
+
if (length < 1) length = 1;
|
|
379
|
+
return { stroke, head, tail, length };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} s @param {number} pos @returns {number}
|
|
384
|
+
*/
|
|
385
|
+
function skipSpace(s, pos) {
|
|
386
|
+
while (pos < s.length) {
|
|
387
|
+
const c = s.charCodeAt(pos);
|
|
388
|
+
if (c !== 0x20 && c !== 0x09) break;
|
|
389
|
+
pos++;
|
|
390
|
+
}
|
|
391
|
+
return pos;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Strip surrounding matching quotes from a label.
|
|
396
|
+
* @param {string} s @returns {string}
|
|
397
|
+
*/
|
|
398
|
+
export function stripQuotes(s) {
|
|
399
|
+
let out = s;
|
|
400
|
+
if (out.length >= 2) {
|
|
401
|
+
const a = out.charCodeAt(0);
|
|
402
|
+
const b = out.charCodeAt(out.length - 1);
|
|
403
|
+
if ((a === 0x22 && b === 0x22) || (a === 0x27 && b === 0x27)) out = out.slice(1, -1);
|
|
404
|
+
}
|
|
405
|
+
// `<br/>` is Mermaid's line break inside a label. Normalizing it to a real
|
|
406
|
+
// newline here — the one place every label passes through — is what lets
|
|
407
|
+
// both the text measurement and the renderer treat it as one, instead of
|
|
408
|
+
// printing the tag and sizing the box for a single long line.
|
|
409
|
+
return RE_BR.test(out) ? out.replace(RE_BR, '\n') : out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** `<br>`, `<br/>`, `<br />` — the spellings Mermaid accepts. */
|
|
413
|
+
const RE_BR = /<br\s*\/?>/gi;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Gantt grammar → gantt AST: a schedule DAG. Header
|
|
4
|
+
* directives (`title`, `dateFormat`, `axisFormat`, `excludes`) go to
|
|
5
|
+
* `meta`; `section` groups tasks; task rows keep their raw metadata
|
|
6
|
+
* string (`:done, id, 2014-01-06, 3d`) verbatim, geometry-free.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HEADER_KEYS = new Set(['title', 'dateFormat', 'axisFormat', 'excludes', 'todayMarker', 'tickInterval', 'weekday']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string[]} lines
|
|
13
|
+
* @returns {object}
|
|
14
|
+
*/
|
|
15
|
+
export function parseGantt(lines) {
|
|
16
|
+
const meta = {};
|
|
17
|
+
const sections = [];
|
|
18
|
+
let current = { name: null, tasks: [] };
|
|
19
|
+
sections.push(current);
|
|
20
|
+
|
|
21
|
+
for (let li = 0; li < lines.length; li++) {
|
|
22
|
+
const line = lines[li].trim();
|
|
23
|
+
if (line === '' || line.startsWith('%%')) continue;
|
|
24
|
+
const sp = line.indexOf(' ');
|
|
25
|
+
const key = sp === -1 ? line : line.slice(0, sp);
|
|
26
|
+
|
|
27
|
+
if (HEADER_KEYS.has(key)) {
|
|
28
|
+
meta[key] = sp === -1 ? '' : line.slice(sp + 1).trim();
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (key === 'section') {
|
|
32
|
+
current = { name: line.slice('section'.length).trim(), tasks: [] };
|
|
33
|
+
sections.push(current);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Task row: `Name : meta`
|
|
37
|
+
const colon = line.indexOf(':');
|
|
38
|
+
if (colon !== -1) {
|
|
39
|
+
const name = line.slice(0, colon).trim();
|
|
40
|
+
const info = line.slice(colon + 1).trim();
|
|
41
|
+
current.tasks.push({ name, info });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Drop a leading empty default section if unused.
|
|
46
|
+
const trimmed = sections[0].name === null && sections[0].tasks.length === 0
|
|
47
|
+
? sections.slice(1) : sections;
|
|
48
|
+
return { meta, sections: trimmed };
|
|
49
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `parseMermaid`: detect the diagram type + config on the first
|
|
4
|
+
* non-config line, dispatch to the type parser, and assemble the shared
|
|
5
|
+
* `DiagramDocument` envelope.
|
|
6
|
+
*
|
|
7
|
+
* Flowchart and sequence are fully modeled. The remaining
|
|
8
|
+
* first-class types (class, ER, state, gantt, pie) plug in through
|
|
9
|
+
* `TYPE_PARSERS`; secondary types (mindmap, gitGraph, journey, timeline,
|
|
10
|
+
* quadrantChart) parse-accept into a geometry-free `rawAst` and are
|
|
11
|
+
* counted honestly in the coverage scorecard.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { diagramDocument, rawAst } from '../ast.js';
|
|
15
|
+
import { hashContent, toLines, firstToken } from '../utils.js';
|
|
16
|
+
import { parseMermaidConfig } from './config.js';
|
|
17
|
+
import { parseFlowchart } from './flowchart.js';
|
|
18
|
+
import { parseSequence } from './sequence.js';
|
|
19
|
+
import { parseClass } from './class.js';
|
|
20
|
+
import { parseState } from './state.js';
|
|
21
|
+
import { parseEr } from './er.js';
|
|
22
|
+
import { parseGantt } from './gantt.js';
|
|
23
|
+
import { parsePie } from './pie.js';
|
|
24
|
+
import { fail } from '../errors.js';
|
|
25
|
+
|
|
26
|
+
/** Keyword → canonical diagram type. */
|
|
27
|
+
const KEYWORDS = {
|
|
28
|
+
flowchart: 'flowchart',
|
|
29
|
+
graph: 'flowchart',
|
|
30
|
+
sequenceDiagram: 'sequence',
|
|
31
|
+
classDiagram: 'class',
|
|
32
|
+
'classDiagram-v2': 'class',
|
|
33
|
+
stateDiagram: 'state',
|
|
34
|
+
'stateDiagram-v2': 'state',
|
|
35
|
+
erDiagram: 'er',
|
|
36
|
+
gantt: 'gantt',
|
|
37
|
+
pie: 'pie',
|
|
38
|
+
mindmap: 'mindmap',
|
|
39
|
+
gitGraph: 'gitGraph',
|
|
40
|
+
journey: 'journey',
|
|
41
|
+
timeline: 'timeline',
|
|
42
|
+
quadrantChart: 'quadrantChart',
|
|
43
|
+
requirementDiagram: 'requirement',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Fully/partly modeled type parsers. */
|
|
47
|
+
const TYPE_PARSERS = {
|
|
48
|
+
class: parseClass,
|
|
49
|
+
state: parseState,
|
|
50
|
+
er: parseEr,
|
|
51
|
+
gantt: parseGantt,
|
|
52
|
+
pie: parsePie,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Types that parse-accept into a placeholder in the coverage scorecard. */
|
|
56
|
+
export const SECONDARY_TYPES = new Set([
|
|
57
|
+
'mindmap', 'gitGraph', 'journey', 'timeline', 'quadrantChart', 'requirement',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse Mermaid source into a `DiagramDocument`.
|
|
62
|
+
* @param {string} source
|
|
63
|
+
* @param {{ parseFrontmatter?: (text: string) => any }} [options]
|
|
64
|
+
* @returns {import('../ast.js').DiagramDocument}
|
|
65
|
+
*/
|
|
66
|
+
export function parseMermaid(source, options = {}) {
|
|
67
|
+
const { config, title, body } = parseMermaidConfig(source, options);
|
|
68
|
+
const lines = toLines(body);
|
|
69
|
+
|
|
70
|
+
// First non-blank line carries the diagram keyword.
|
|
71
|
+
let start = 0;
|
|
72
|
+
while (start < lines.length && lines[start].trim() === '') start++;
|
|
73
|
+
if (start >= lines.length) fail('empty diagram', 1);
|
|
74
|
+
|
|
75
|
+
const header = lines[start].trim();
|
|
76
|
+
const keyword = firstToken(header);
|
|
77
|
+
const type = KEYWORDS[keyword];
|
|
78
|
+
if (type === undefined) {
|
|
79
|
+
fail(`unknown diagram type '${keyword}'`, start + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let direction = null;
|
|
83
|
+
let ast;
|
|
84
|
+
const bodyLines = lines.slice(start + 1);
|
|
85
|
+
const bodyOffset = start + 1;
|
|
86
|
+
|
|
87
|
+
if (type === 'flowchart') {
|
|
88
|
+
direction = detectDirection(header, keyword);
|
|
89
|
+
ast = parseFlowchart(bodyLines, bodyOffset, direction);
|
|
90
|
+
}
|
|
91
|
+
else if (type === 'sequence') {
|
|
92
|
+
ast = parseSequence(bodyLines, bodyOffset);
|
|
93
|
+
}
|
|
94
|
+
else if (TYPE_PARSERS[type] !== undefined) {
|
|
95
|
+
ast = TYPE_PARSERS[type](bodyLines, bodyOffset, header);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// Secondary: preserve the raw body so `toMermaid` round-trips and
|
|
99
|
+
// the scorecard is honest.
|
|
100
|
+
ast = rawAst(type, bodyLines.filter((l) => l.trim() !== ''));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return diagramDocument(type, config, ast, {
|
|
104
|
+
hash: hashContent(source),
|
|
105
|
+
direction,
|
|
106
|
+
title,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Detect a flowchart direction from the header (`flowchart TD`).
|
|
112
|
+
* @param {string} header
|
|
113
|
+
* @param {string} keyword
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function detectDirection(header, keyword) {
|
|
117
|
+
const rest = header.slice(keyword.length).trim();
|
|
118
|
+
// `graph LR` or `flowchart TD`; a trailing `:` occasionally appears.
|
|
119
|
+
const dir = rest.replace(/:$/, '').trim().toUpperCase();
|
|
120
|
+
const known = new Set(['TB', 'TD', 'BT', 'LR', 'RL']);
|
|
121
|
+
return known.has(dir) ? (dir === 'TD' ? 'TD' : dir) : 'TB';
|
|
122
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Pie grammar → pie AST. `pie [showData]` then `"label" : value`
|
|
4
|
+
* rows, with an optional `title …`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { stripQuotes } from './flowchart.js';
|
|
8
|
+
|
|
9
|
+
/** `"label" : 42` (label may be unquoted). */
|
|
10
|
+
const RE_SLICE = /^(".*?"|[^:]+?)\s*:\s*([0-9]*\.?[0-9]+)\s*$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string[]} lines
|
|
14
|
+
* @param {number} lineOffset
|
|
15
|
+
* @param {string} header
|
|
16
|
+
* @returns {object}
|
|
17
|
+
*/
|
|
18
|
+
export function parsePie(lines, lineOffset, header) {
|
|
19
|
+
const showData = /\bshowData\b/.test(header);
|
|
20
|
+
let title = null;
|
|
21
|
+
const slices = [];
|
|
22
|
+
for (let li = 0; li < lines.length; li++) {
|
|
23
|
+
const line = lines[li].trim();
|
|
24
|
+
if (line === '') continue;
|
|
25
|
+
if (line.startsWith('title ')) { title = line.slice('title '.length).trim(); continue; }
|
|
26
|
+
const m = RE_SLICE.exec(line);
|
|
27
|
+
if (m !== null) {
|
|
28
|
+
slices.push({ label: stripQuotes(m[1].trim()), value: Number(m[2]) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { title, showData, slices };
|
|
32
|
+
}
|