flowfig 0.9.0 → 0.10.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 +305 -59
- package/dist/atlas.d.ts +6 -0
- package/dist/atlas.js +122 -0
- package/dist/auto.d.ts +3 -0
- package/dist/auto.js +302 -0
- package/dist/browser.js +3 -1
- package/dist/check.js +54 -17
- package/dist/cli.js +22 -5
- package/dist/coverage.js +10 -7
- package/dist/diff.d.ts +10 -0
- package/dist/diff.js +137 -7
- package/dist/geometry.d.ts +4 -0
- package/dist/geometry.js +45 -17
- package/dist/index.d.ts +1 -1
- package/dist/index.js +149 -79
- package/dist/load.js +4 -3
- package/dist/mermaid.d.ts +19 -0
- package/dist/mermaid.js +617 -0
- package/dist/model.d.ts +11 -3
- package/dist/model.js +25 -1
- package/dist/open.d.ts +1 -0
- package/dist/open.js +11 -8
- package/dist/page.d.ts +2 -1
- package/dist/page.js +5 -4
- package/dist/svg.d.ts +7 -2
- package/dist/svg.js +66 -20
- package/dist/trace.js +10 -3
- package/dist/verify.d.ts +2 -0
- package/dist/verify.js +36 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/mermaid.js
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { dayOf } from './model.js';
|
|
4
|
+
const IGNORED = /^(%%|classDef\s|class\s|style\s|linkStyle\s|click\s|direction\s)/;
|
|
5
|
+
const ID = /[\p{L}\p{N}_]+/uy;
|
|
6
|
+
const SHAPES = [
|
|
7
|
+
['([', '])', 'box'],
|
|
8
|
+
['[(', ')]', 'store'],
|
|
9
|
+
['((', '))', 'box'],
|
|
10
|
+
['[', ']', 'box'],
|
|
11
|
+
['(', ')', 'box'],
|
|
12
|
+
['{', '}', 'decision'],
|
|
13
|
+
];
|
|
14
|
+
const ARROW = /(?:--+|-\.+-|==+)(>?)/y;
|
|
15
|
+
const LABELED_ARROW = /(?:--|==|-\.)\s+(\S.*?)\s+(?:--+|==+|\.-+)(>?)/y;
|
|
16
|
+
const NO_ARROW = 'a link with no arrow; use --> to give it a direction';
|
|
17
|
+
const ENTITIES = { quot: '"', amp: '&', lt: '<', gt: '>', apos: "'", nbsp: ' ' };
|
|
18
|
+
const PIPE_LABEL = /\s*\|"?([^|"]*)"?\|/y;
|
|
19
|
+
const CLASS = /:::[\w-]+/y;
|
|
20
|
+
const clean = (text) => text
|
|
21
|
+
.replace(/#(\w+);/g, (all, code) => {
|
|
22
|
+
const n = /^\d+$/.test(code) ? Number(code) : NaN;
|
|
23
|
+
if ([9, 10, 13].includes(n) || (n >= 0x20 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) && n !== 0xfffe && n !== 0xffff))
|
|
24
|
+
return String.fromCodePoint(n);
|
|
25
|
+
if (!Number.isNaN(n))
|
|
26
|
+
throw new Error(`the entity code "${all}" is not a valid XML character`);
|
|
27
|
+
if (Object.hasOwn(ENTITIES, code))
|
|
28
|
+
return ENTITIES[code];
|
|
29
|
+
throw new Error(`the entity code "${all}" is not read`);
|
|
30
|
+
})
|
|
31
|
+
.replace(/<br\s*\/?>/gi, ' ')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
function sticky(re, s, at) {
|
|
35
|
+
re.lastIndex = at;
|
|
36
|
+
return re.exec(s);
|
|
37
|
+
}
|
|
38
|
+
export function fromMermaid(text, first = 1) {
|
|
39
|
+
let acc = false;
|
|
40
|
+
const lines = text
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((raw, i) => ({ s: raw.trim(), n: first + i }))
|
|
43
|
+
.filter(({ s }) => {
|
|
44
|
+
if (acc || /^accDescr\s*\{/.test(s))
|
|
45
|
+
return ((acc = !s.endsWith('}')), false);
|
|
46
|
+
return !/^acc(Title|Descr)\s*:/.test(s);
|
|
47
|
+
});
|
|
48
|
+
const body = lines.filter(({ s }) => s && !IGNORED.test(s));
|
|
49
|
+
const head = body.shift();
|
|
50
|
+
if (!head)
|
|
51
|
+
return { faults: [{ line: first, reason: 'the diagram is empty' }] };
|
|
52
|
+
const flow = /^(?:flowchart|graph)(?:\s+(TD|TB|LR|RL|BT))?\s*;?$/.exec(head.s);
|
|
53
|
+
if (flow)
|
|
54
|
+
return flowchart(body, flow[1] === 'LR' || flow[1] === 'RL' ? 'row' : 'column');
|
|
55
|
+
if (head.s === 'sequenceDiagram')
|
|
56
|
+
return sequence(body);
|
|
57
|
+
if (/^stateDiagram(?:-v2)?$/.test(head.s))
|
|
58
|
+
return state(body);
|
|
59
|
+
if (head.s === 'gantt')
|
|
60
|
+
return gantt(lines.filter(({ s, n }) => s && n > head.n && !s.startsWith('%%')));
|
|
61
|
+
return { faults: [{ line: head.n, reason: `"${head.s}" is not a flowchart, graph, sequenceDiagram, stateDiagram or gantt` }] };
|
|
62
|
+
}
|
|
63
|
+
function flowchart(body, direction) {
|
|
64
|
+
const faults = [];
|
|
65
|
+
const nodes = new Map();
|
|
66
|
+
const groups = [];
|
|
67
|
+
const open = [];
|
|
68
|
+
const edges = [];
|
|
69
|
+
const seen = new Map();
|
|
70
|
+
const edgeLines = [];
|
|
71
|
+
let order = 0;
|
|
72
|
+
const text = (raw) => {
|
|
73
|
+
if (raw.startsWith('`'))
|
|
74
|
+
throw new Error('a Markdown string is not read');
|
|
75
|
+
return clean(raw);
|
|
76
|
+
};
|
|
77
|
+
const node = (s, at) => {
|
|
78
|
+
const id = sticky(ID, s, at)?.[0];
|
|
79
|
+
if (!id)
|
|
80
|
+
throw new Error(at < s.length ? `expected a node id at "${s.slice(at)}"` : 'the link has no target');
|
|
81
|
+
at += id.length;
|
|
82
|
+
const found = nodes.get(id) ?? { id, label: id, order: order++ };
|
|
83
|
+
nodes.set(id, found);
|
|
84
|
+
for (const g of open)
|
|
85
|
+
g.mentions.push(id);
|
|
86
|
+
for (const [start, end, shape] of SHAPES) {
|
|
87
|
+
if (!s.startsWith(start, at))
|
|
88
|
+
continue;
|
|
89
|
+
let from = at + start.length;
|
|
90
|
+
let label;
|
|
91
|
+
if (s[from] === '"') {
|
|
92
|
+
const close = s.indexOf('"', from + 1);
|
|
93
|
+
if (close !== -1 && s.startsWith(end, close + 1))
|
|
94
|
+
[label, from] = [s.slice(from + 1, close), close + 1];
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const close = s.indexOf(end, from);
|
|
98
|
+
const inner = close === -1 ? '' : s.slice(from, close);
|
|
99
|
+
if (close !== -1 && !/[[\](){}"]|^[/\\]/.test(inner))
|
|
100
|
+
[label, from] = [inner, close];
|
|
101
|
+
}
|
|
102
|
+
if (label === undefined || !s.startsWith(end, from))
|
|
103
|
+
continue;
|
|
104
|
+
Object.assign(found, { label: text(label) || id }, shape === 'box' ? {} : { shape });
|
|
105
|
+
at = from + end.length;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if ('[({>'.includes(s[at] ?? ' '))
|
|
109
|
+
throw new Error(`unsupported node shape at "${s.slice(at)}"`);
|
|
110
|
+
if (sticky(CLASS, s, at))
|
|
111
|
+
at = CLASS.lastIndex;
|
|
112
|
+
return [id, at];
|
|
113
|
+
};
|
|
114
|
+
const line = (raw, n) => {
|
|
115
|
+
const s = raw.replace(/;$/, '');
|
|
116
|
+
if (/^subgraph\b/.test(s)) {
|
|
117
|
+
const sub = /^subgraph\s+([\p{L}\p{N}_]+)(?:\s*\[\s*(?:"([^"]*)"|([^\]"]*))\s*\])?$/u.exec(s);
|
|
118
|
+
const title = sub ? undefined : /^subgraph\s+(?:"([^"]*)"|([^[\]"]+))$/.exec(s);
|
|
119
|
+
if (!sub && !title) {
|
|
120
|
+
open.push({ label: '', children: [], order: order++, mentions: [] });
|
|
121
|
+
throw new Error('a subgraph needs the form "subgraph id", "subgraph id [title]" or "subgraph title"');
|
|
122
|
+
}
|
|
123
|
+
const g = {
|
|
124
|
+
id: sub ? sub[1] : `subgraph-${groups.length + 1}`,
|
|
125
|
+
label: text(sub ? (sub[2] ?? sub[3] ?? sub[1]) : (title[1] ?? title[2])),
|
|
126
|
+
children: [],
|
|
127
|
+
order: order++,
|
|
128
|
+
parent: open.at(-1),
|
|
129
|
+
mentions: [],
|
|
130
|
+
};
|
|
131
|
+
groups.push(g);
|
|
132
|
+
open.push(g);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (s === 'end') {
|
|
136
|
+
const g = open.pop();
|
|
137
|
+
if (!g)
|
|
138
|
+
throw new Error('"end" has no subgraph');
|
|
139
|
+
for (const id of g.mentions)
|
|
140
|
+
if (!nodes.get(id).parent)
|
|
141
|
+
nodes.get(id).parent = g;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const chain = [];
|
|
145
|
+
const labels = [];
|
|
146
|
+
let at = 0;
|
|
147
|
+
for (;;) {
|
|
148
|
+
const side = [];
|
|
149
|
+
for (;;) {
|
|
150
|
+
const [id, next] = node(s, at);
|
|
151
|
+
side.push(id);
|
|
152
|
+
at = next;
|
|
153
|
+
const amp = /\s*&\s*/y;
|
|
154
|
+
if (!sticky(amp, s, at))
|
|
155
|
+
break;
|
|
156
|
+
at = amp.lastIndex;
|
|
157
|
+
}
|
|
158
|
+
chain.push(side);
|
|
159
|
+
while (s[at] === ' ')
|
|
160
|
+
at++;
|
|
161
|
+
if (at >= s.length)
|
|
162
|
+
break;
|
|
163
|
+
const labeled = sticky(LABELED_ARROW, s, at);
|
|
164
|
+
const plain = labeled ? null : sticky(ARROW, s, at);
|
|
165
|
+
if (!labeled && !plain)
|
|
166
|
+
throw new Error(`unsupported text at "${s.slice(at)}"`);
|
|
167
|
+
if (!(labeled ?? plain)[labeled ? 2 : 1])
|
|
168
|
+
throw new Error(NO_ARROW);
|
|
169
|
+
if (labeled) {
|
|
170
|
+
labels.push(text(labeled[1].replace(/^"|"$/g, '')));
|
|
171
|
+
at = LABELED_ARROW.lastIndex;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
at = ARROW.lastIndex;
|
|
175
|
+
const pipe = sticky(PIPE_LABEL, s, at);
|
|
176
|
+
labels.push(pipe ? text(pipe[1]) : undefined);
|
|
177
|
+
if (pipe)
|
|
178
|
+
at = PIPE_LABEL.lastIndex;
|
|
179
|
+
}
|
|
180
|
+
while (s[at] === ' ')
|
|
181
|
+
at++;
|
|
182
|
+
}
|
|
183
|
+
labels.forEach((label, i) => {
|
|
184
|
+
for (const from of chain[i])
|
|
185
|
+
for (const to of chain[i + 1]) {
|
|
186
|
+
const id = `${from}->${to}`;
|
|
187
|
+
const count = (seen.get(id) ?? 0) + 1;
|
|
188
|
+
seen.set(id, count);
|
|
189
|
+
edges.push({ from, to, ...(label ? { label } : {}), ...(count > 1 ? { id: `${id}#${count}` } : {}) });
|
|
190
|
+
edgeLines.push(n);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
for (const { s, n } of body)
|
|
195
|
+
try {
|
|
196
|
+
line(s, n);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
faults.push({ line: n, reason: e.message });
|
|
200
|
+
}
|
|
201
|
+
if (open.length)
|
|
202
|
+
faults.push({ line: body.at(-1)?.n ?? 1, reason: `subgraph "${open.at(-1).label}" has no "end"` });
|
|
203
|
+
if (faults.length)
|
|
204
|
+
return { faults };
|
|
205
|
+
const top = tree(groups, nodes);
|
|
206
|
+
const dropped = new Set(groups.filter((g) => !g.children.length).map((g) => g.id));
|
|
207
|
+
edges.forEach((e, i) => {
|
|
208
|
+
for (const end of [e.from, e.to])
|
|
209
|
+
if (dropped.has(end))
|
|
210
|
+
faults.push({ line: edgeLines[i], reason: `the link names subgraph "${end}", which has no node` });
|
|
211
|
+
});
|
|
212
|
+
if (faults.length)
|
|
213
|
+
return { faults };
|
|
214
|
+
return { spec: { layout: { auto: true, direction, children: top }, edges }, faults: [] };
|
|
215
|
+
}
|
|
216
|
+
function tree(groups, nodes) {
|
|
217
|
+
const groupIds = new Set(groups.map((g) => g.id));
|
|
218
|
+
const items = [...groups, ...[...nodes.values()].filter((v) => !groupIds.has(v.id))].sort((a, b) => a.order - b.order);
|
|
219
|
+
const children = (parent) => items
|
|
220
|
+
.filter((i) => i.parent === parent)
|
|
221
|
+
.map(({ order: _o, parent: _p, ...rest }) => rest)
|
|
222
|
+
.map((i) => ('mentions' in i ? (({ mentions: _m, ...g }) => g)(i) : i))
|
|
223
|
+
.filter((i) => !('children' in i) || i.children.length);
|
|
224
|
+
for (const g of [...groups].reverse())
|
|
225
|
+
g.children = children(g);
|
|
226
|
+
return children();
|
|
227
|
+
}
|
|
228
|
+
function state(body) {
|
|
229
|
+
const faults = [];
|
|
230
|
+
const nodes = new Map();
|
|
231
|
+
const groups = [];
|
|
232
|
+
const open = [];
|
|
233
|
+
const edges = [];
|
|
234
|
+
const seen = new Map();
|
|
235
|
+
const marks = new Map();
|
|
236
|
+
const subs = new Map();
|
|
237
|
+
const opened = new Map();
|
|
238
|
+
let order = 0;
|
|
239
|
+
let note = false;
|
|
240
|
+
const meet = (id) => {
|
|
241
|
+
const found = nodes.get(id) ?? { id, label: id, order: order++ };
|
|
242
|
+
nodes.set(id, found);
|
|
243
|
+
for (const g of open)
|
|
244
|
+
g.mentions.push(id);
|
|
245
|
+
return found;
|
|
246
|
+
};
|
|
247
|
+
const mark = (id, value, n) => {
|
|
248
|
+
const had = marks.get(id);
|
|
249
|
+
if (had && had.mark !== value)
|
|
250
|
+
throw new Error(`state "${id}" is a start and an end state; a box has one mark`);
|
|
251
|
+
marks.set(id, { mark: value, line: n });
|
|
252
|
+
meet(id).mark = value;
|
|
253
|
+
};
|
|
254
|
+
const line = (s, n) => {
|
|
255
|
+
if (note) {
|
|
256
|
+
note = s !== 'end note';
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (/^note\b/.test(s)) {
|
|
260
|
+
note = !s.includes(':');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (s === 'hide empty description')
|
|
264
|
+
return;
|
|
265
|
+
if (s === '--')
|
|
266
|
+
throw new Error('a concurrent region (--) is not read');
|
|
267
|
+
if (s === '}') {
|
|
268
|
+
const g = open.pop();
|
|
269
|
+
if (!g)
|
|
270
|
+
throw new Error('"}" has no composite state');
|
|
271
|
+
for (const id of g.mentions)
|
|
272
|
+
if (!nodes.get(id).parent)
|
|
273
|
+
nodes.get(id).parent = g;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const decl = /^state\s+(?:"([^"]*)"\s+as\s+)?([\p{L}\p{N}_]+)\s*(?:<<(\w+)>>)?\s*(\{)?$/u.exec(s);
|
|
277
|
+
if (decl) {
|
|
278
|
+
const [, name, id, kind, block] = decl;
|
|
279
|
+
if (kind && kind !== 'choice')
|
|
280
|
+
throw new Error(`a <<${kind}>> state is not read`);
|
|
281
|
+
const found = meet(id);
|
|
282
|
+
if (name !== undefined)
|
|
283
|
+
found.label = clean(name) || id;
|
|
284
|
+
if (kind)
|
|
285
|
+
found.shape = 'decision';
|
|
286
|
+
if (block) {
|
|
287
|
+
const g = { id, label: found.label, children: [], order: found.order, parent: open.at(-1), mentions: [] };
|
|
288
|
+
groups.push(g);
|
|
289
|
+
open.push(g);
|
|
290
|
+
opened.set(id, n);
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const move = /^([\p{L}\p{N}_]+|\[\*\])\s*-->\s*([\p{L}\p{N}_]+|\[\*\])\s*(?::(?!::)\s*(.*))?$/u.exec(s);
|
|
295
|
+
if (move) {
|
|
296
|
+
const [, from, to, raw] = move;
|
|
297
|
+
if (from === '[*]' && to === '[*]')
|
|
298
|
+
throw new Error('a transition from [*] to [*] is not read');
|
|
299
|
+
if (from === '[*]' || to === '[*]') {
|
|
300
|
+
if (raw?.trim())
|
|
301
|
+
throw new Error('a label on a [*] transition is not read');
|
|
302
|
+
const id = from === '[*]' ? to : from;
|
|
303
|
+
if (open.length)
|
|
304
|
+
meet(id);
|
|
305
|
+
else
|
|
306
|
+
mark(id, from === '[*]' ? 'start' : 'end', n);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (from === to)
|
|
310
|
+
throw new Error(`a transition from "${from}" to itself is not read`);
|
|
311
|
+
meet(from);
|
|
312
|
+
meet(to);
|
|
313
|
+
const label = clean(raw ?? '');
|
|
314
|
+
const id = `${from}->${to}`;
|
|
315
|
+
const count = (seen.get(id) ?? 0) + 1;
|
|
316
|
+
seen.set(id, count);
|
|
317
|
+
edges.push({ from, to, ...(label ? { label } : {}), ...(count > 1 ? { id: `${id}#${count}` } : {}) });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const described = /^([\p{L}\p{N}_]+)\s*:(?!::)\s*(.*)$/u.exec(s);
|
|
321
|
+
if (described) {
|
|
322
|
+
const found = meet(described[1]);
|
|
323
|
+
const text = clean(described[2]);
|
|
324
|
+
if (text)
|
|
325
|
+
found.sub = found.sub ? `${found.sub} ${text}` : text;
|
|
326
|
+
subs.set(described[1], n);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (/^[\p{L}\p{N}_]+$/u.test(s))
|
|
330
|
+
return void meet(s);
|
|
331
|
+
throw new Error(`unsupported line "${s}"`);
|
|
332
|
+
};
|
|
333
|
+
for (const { s, n } of body)
|
|
334
|
+
try {
|
|
335
|
+
line(s, n);
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
faults.push({ line: n, reason: e.message });
|
|
339
|
+
}
|
|
340
|
+
if (note)
|
|
341
|
+
faults.push({ line: body.at(-1).n, reason: 'the note has no "end note"' });
|
|
342
|
+
if (open.length)
|
|
343
|
+
faults.push({ line: body.at(-1).n, reason: `composite state "${open.at(-1).id}" has no "}"` });
|
|
344
|
+
for (const g of groups) {
|
|
345
|
+
const id = g.id;
|
|
346
|
+
if (marks.has(id))
|
|
347
|
+
faults.push({ line: marks.get(id).line, reason: `a [*] on composite state "${id}" is not read` });
|
|
348
|
+
if (subs.has(id))
|
|
349
|
+
faults.push({ line: subs.get(id), reason: `a description on composite state "${id}" is not read` });
|
|
350
|
+
}
|
|
351
|
+
if (faults.length)
|
|
352
|
+
return { faults };
|
|
353
|
+
if (!nodes.size)
|
|
354
|
+
return { faults: [{ line: body[0]?.n ?? 1, reason: 'the diagram has no state' }] };
|
|
355
|
+
const top = tree(groups, nodes);
|
|
356
|
+
const empty = groups.find((g) => !g.children.length);
|
|
357
|
+
if (empty)
|
|
358
|
+
return { faults: [{ line: opened.get(empty.id), reason: `composite state "${empty.id}" has no state` }] };
|
|
359
|
+
return { spec: { layout: { auto: true, direction: 'column', children: top }, edges }, faults: [] };
|
|
360
|
+
}
|
|
361
|
+
function gantt(body) {
|
|
362
|
+
const faults = [];
|
|
363
|
+
const tracks = [];
|
|
364
|
+
const tasks = new Map();
|
|
365
|
+
const edges = [];
|
|
366
|
+
let prev;
|
|
367
|
+
const line = (s, n) => {
|
|
368
|
+
if (/^(title|axisFormat|tickInterval|todayMarker|weekday)\b|^click\s+\S+\s+(href|call)\b/.test(s))
|
|
369
|
+
return;
|
|
370
|
+
const format = /^dateFormat\s+(.*)$/.exec(s);
|
|
371
|
+
if (format) {
|
|
372
|
+
if (format[1] !== 'YYYY-MM-DD')
|
|
373
|
+
throw new Error(`dateFormat "${format[1]}" is not read; use YYYY-MM-DD`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (/^(excludes|weekend)\b/.test(s))
|
|
377
|
+
throw new Error(`"${s}" changes the task dates and is not read`);
|
|
378
|
+
const section = /^section\s+(.*)$/.exec(s);
|
|
379
|
+
if (section)
|
|
380
|
+
return void tracks.push({ id: `section-${tracks.length + 1}`, label: clean(section[1]), children: [] });
|
|
381
|
+
const task = /^([^:]+):(.*)$/.exec(s);
|
|
382
|
+
if (!task)
|
|
383
|
+
throw new Error(`unsupported line "${s}"`);
|
|
384
|
+
const parts = task[2].split(',').map((p) => p.trim());
|
|
385
|
+
const tags = [];
|
|
386
|
+
while (['done', 'active', 'crit', 'milestone'].includes(parts[0]))
|
|
387
|
+
tags.push(parts.shift());
|
|
388
|
+
if (!parts.length || parts.length > 3 || parts.includes(''))
|
|
389
|
+
throw new Error(`the task "${task[2].trim()}" is not read`);
|
|
390
|
+
const id = parts.length === 3 ? parts.shift() : `task-${tasks.size + 1}`;
|
|
391
|
+
if (!/^[\p{L}\p{N}_-]+$/u.test(id))
|
|
392
|
+
throw new Error(`the task id "${id}" is not read`);
|
|
393
|
+
if (tasks.has(id))
|
|
394
|
+
throw new Error(`the task id "${id}" is used twice`);
|
|
395
|
+
const node = { id, label: clean(task[1]), ...(tags.includes('crit') ? { tone: 'red' } : {}) };
|
|
396
|
+
if (!tracks.length)
|
|
397
|
+
tracks.push({ id: 'section-1', label: '', children: [] });
|
|
398
|
+
tracks.at(-1).children.push(node);
|
|
399
|
+
const t = { node, n, prev, tags, start: parts.length === 2 ? parts[0] : '', end: parts.at(-1) };
|
|
400
|
+
tasks.set(id, t);
|
|
401
|
+
prev = t;
|
|
402
|
+
};
|
|
403
|
+
for (const { s, n } of body)
|
|
404
|
+
try {
|
|
405
|
+
line(s, n);
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
faults.push({ line: n, reason: e.message });
|
|
409
|
+
}
|
|
410
|
+
if (faults.length)
|
|
411
|
+
return { faults };
|
|
412
|
+
if (!tasks.size)
|
|
413
|
+
return { faults: [{ line: body[0]?.n ?? 1, reason: 'the gantt has no task' }] };
|
|
414
|
+
const busy = new Set();
|
|
415
|
+
const fail = (t, reason) => {
|
|
416
|
+
throw Object.assign(new Error(reason), { line: t.n });
|
|
417
|
+
};
|
|
418
|
+
const span = (t) => {
|
|
419
|
+
if (t.span)
|
|
420
|
+
return t.span;
|
|
421
|
+
if (busy.has(t))
|
|
422
|
+
fail(t, `the task "${t.node.id}" depends on itself`);
|
|
423
|
+
busy.add(t);
|
|
424
|
+
let from;
|
|
425
|
+
const after = /^after\s+(.+)$/.exec(t.start);
|
|
426
|
+
if (!t.start) {
|
|
427
|
+
if (!t.prev)
|
|
428
|
+
fail(t, 'the first task needs a start date');
|
|
429
|
+
from = span(t.prev)[1];
|
|
430
|
+
}
|
|
431
|
+
else if (after) {
|
|
432
|
+
from = Math.max(...[...new Set(after[1].split(/\s+/))].map((ref) => {
|
|
433
|
+
const source = tasks.get(ref);
|
|
434
|
+
if (!source)
|
|
435
|
+
fail(t, `"after ${ref}" names no task`);
|
|
436
|
+
edges.push({ from: ref, to: t.node.id });
|
|
437
|
+
return span(source)[1];
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
else if ((from = dayOf(t.start)) == null)
|
|
441
|
+
fail(t, `the start "${t.start}" is not a YYYY-MM-DD date or "after <id>"`);
|
|
442
|
+
const length = /^(\d+)([dw])$/.exec(t.end);
|
|
443
|
+
const end = length ? from + Number(length[1]) * (length[2] === 'w' ? 7 : 1) : dayOf(t.end);
|
|
444
|
+
if (end == null)
|
|
445
|
+
fail(t, `the end "${t.end}" is not a YYYY-MM-DD date or a count of days (d) or weeks (w)`);
|
|
446
|
+
const milestone = t.tags.includes('milestone');
|
|
447
|
+
if (milestone && end !== from)
|
|
448
|
+
fail(t, 'a milestone with a duration is not read; use 0d');
|
|
449
|
+
if (!milestone && end <= from)
|
|
450
|
+
fail(t, 'the task ends before it starts');
|
|
451
|
+
const iso = (day) => new Date(day * 86400000).toISOString().slice(0, 10);
|
|
452
|
+
Object.assign(t.node, { from: iso(from) }, milestone ? {} : { to: iso(end - 1) });
|
|
453
|
+
busy.delete(t);
|
|
454
|
+
return (t.span = [from, end]);
|
|
455
|
+
};
|
|
456
|
+
try {
|
|
457
|
+
for (const t of tasks.values())
|
|
458
|
+
span(t);
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
return { faults: [{ line: e.line, reason: e.message }] };
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
spec: { timeline: true, layout: { direction: 'column', children: tracks.filter((g) => g.children.length) }, edges },
|
|
465
|
+
faults: [],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function sequence(body) {
|
|
469
|
+
const faults = [];
|
|
470
|
+
const people = new Map();
|
|
471
|
+
const edges = [];
|
|
472
|
+
const flow = [];
|
|
473
|
+
const branches = [];
|
|
474
|
+
const blocks = [];
|
|
475
|
+
const meet = (id, label = id) => {
|
|
476
|
+
if (!people.has(id) || label !== id)
|
|
477
|
+
people.set(id, { id, label: clean(label).replace(/^"|"$/g, '') });
|
|
478
|
+
};
|
|
479
|
+
const line = (s) => {
|
|
480
|
+
if (/^(activate|deactivate|note|autonumber|and)\b/i.test(s))
|
|
481
|
+
return;
|
|
482
|
+
const block = /^(alt|else|opt|loop|par|rect)\b\s*(.*)$/.exec(s);
|
|
483
|
+
if (block) {
|
|
484
|
+
const [, kind, condition] = block;
|
|
485
|
+
if (kind === 'else' && blocks.at(-1) !== 'alt')
|
|
486
|
+
throw new Error('"else" has no alt');
|
|
487
|
+
const nested = kind === 'alt' && blocks.includes('alt');
|
|
488
|
+
if (kind !== 'else')
|
|
489
|
+
blocks.push(kind);
|
|
490
|
+
if (nested)
|
|
491
|
+
throw new Error('an alt inside an alt is not read');
|
|
492
|
+
if (kind === 'alt' && branches.length)
|
|
493
|
+
throw new Error('a second alt is not read; split the diagram');
|
|
494
|
+
if (kind === 'alt' || kind === 'else')
|
|
495
|
+
branches.push(clean(condition) || `branch ${branches.length + 1}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (s === 'end') {
|
|
499
|
+
if (!blocks.pop())
|
|
500
|
+
throw new Error('"end" has no block');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const who = /^(?:participant|actor)\s+([\w.]+)(?:\s+as\s+(.+))?$/.exec(s);
|
|
504
|
+
if (who)
|
|
505
|
+
return meet(who[1], who[2]);
|
|
506
|
+
const msg = /^([\w.]+)\s*(-->>|->>|-->|->|--\)|-\))\s*[+-]?([\w.]+)\s*(?::\s*(.*))?$/.exec(s);
|
|
507
|
+
if (!msg)
|
|
508
|
+
throw new Error(`unsupported line "${s}"`);
|
|
509
|
+
const [, from, arrow, to, text] = msg;
|
|
510
|
+
if (from === to)
|
|
511
|
+
throw new Error(`a message from "${from}" to itself has no edge`);
|
|
512
|
+
const data = clean(text ?? '');
|
|
513
|
+
if (!people.has(from))
|
|
514
|
+
meet(from);
|
|
515
|
+
if (!people.has(to))
|
|
516
|
+
meet(to);
|
|
517
|
+
let edge = edges.find((e) => (e.from === from && e.to === to) || (e.from === to && e.to === from));
|
|
518
|
+
if (!edge)
|
|
519
|
+
edges.push((edge = { from, to }));
|
|
520
|
+
const hop = {
|
|
521
|
+
edge: `${edge.from}->${edge.to}`,
|
|
522
|
+
...(edge.from === to ? { back: true } : {}),
|
|
523
|
+
...(arrow.endsWith(')') ? { async: true } : {}),
|
|
524
|
+
...(data ? { data } : {}),
|
|
525
|
+
};
|
|
526
|
+
flow.push(blocks.includes('alt') ? { hop, branch: branches.length - 1 } : { hop });
|
|
527
|
+
};
|
|
528
|
+
for (const { s, n } of body)
|
|
529
|
+
try {
|
|
530
|
+
line(s);
|
|
531
|
+
}
|
|
532
|
+
catch (e) {
|
|
533
|
+
faults.push({ line: n, reason: e.message });
|
|
534
|
+
}
|
|
535
|
+
if (blocks.length)
|
|
536
|
+
faults.push({ line: body.at(-1).n, reason: `the ${blocks.at(-1)} block has no "end"` });
|
|
537
|
+
if (faults.length)
|
|
538
|
+
return { faults };
|
|
539
|
+
if (!flow.length)
|
|
540
|
+
return { faults: [{ line: body[0]?.n ?? 1, reason: 'the sequence has no message' }] };
|
|
541
|
+
const steps = (branches.length ? branches : ['Messages']).map((label, i) => ({
|
|
542
|
+
label,
|
|
543
|
+
flow: flow.filter((f) => f.branch === undefined || f.branch === i).map((f) => f.hop),
|
|
544
|
+
}));
|
|
545
|
+
return { spec: { layout: { auto: true, children: [...people.values()] }, edges, steps, rail: 'only' }, faults: [] };
|
|
546
|
+
}
|
|
547
|
+
export function markdownBlocks(text) {
|
|
548
|
+
const out = [];
|
|
549
|
+
const lines = text.split(/\r?\n/);
|
|
550
|
+
for (let i = 0; i < lines.length; i++) {
|
|
551
|
+
const fence = /^\s*(`{3,}|~{3,})\s*mermaid\s*$/.exec(lines[i]);
|
|
552
|
+
if (!fence)
|
|
553
|
+
continue;
|
|
554
|
+
const end = lines.findIndex((l, j) => j > i && l.trim().startsWith(fence[1]));
|
|
555
|
+
const stop = end === -1 ? lines.length : end;
|
|
556
|
+
out.push({ text: lines.slice(i + 1, stop).join('\n'), line: i + 2 });
|
|
557
|
+
i = stop;
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
const USAGE = 'usage: flowfig from-mermaid <file.mmd|file.md|-> [--out <dir>]';
|
|
562
|
+
export function runFromMermaid(argv) {
|
|
563
|
+
const fail = (message, code) => (console.error(message), code);
|
|
564
|
+
const args = [...argv];
|
|
565
|
+
let out;
|
|
566
|
+
const at = args.indexOf('--out');
|
|
567
|
+
if (at !== -1) {
|
|
568
|
+
out = args.splice(at, 2)[1];
|
|
569
|
+
if (!out || out.startsWith('-'))
|
|
570
|
+
return fail('--out needs a folder', 2);
|
|
571
|
+
}
|
|
572
|
+
const bad = args.find((a) => a.startsWith('-') && a !== '-');
|
|
573
|
+
if (bad)
|
|
574
|
+
return fail(`unknown flag ${bad}`, 2);
|
|
575
|
+
if (args.length !== 1)
|
|
576
|
+
return fail(USAGE, 2);
|
|
577
|
+
const [file] = args;
|
|
578
|
+
let text;
|
|
579
|
+
try {
|
|
580
|
+
text = readFileSync(file === '-' ? 0 : file, 'utf8');
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
return fail(`${file}: ${e.message}`, 2);
|
|
584
|
+
}
|
|
585
|
+
const markdown = /\.(md|markdown|mdx)$/i.test(file);
|
|
586
|
+
const blocks = markdown ? markdownBlocks(text) : [{ text, line: 1 }];
|
|
587
|
+
if (!blocks.length)
|
|
588
|
+
return fail(`${file}: no mermaid block`, 1);
|
|
589
|
+
if (blocks.length > 1 && !out)
|
|
590
|
+
return fail(`${file} has ${blocks.length} diagrams; use --out <dir>`, 2);
|
|
591
|
+
const name = file === '-' ? 'stdin' : basename(file).replace(/\.[^.]+$/, '');
|
|
592
|
+
let code = 0;
|
|
593
|
+
for (const [i, b] of blocks.entries()) {
|
|
594
|
+
const r = fromMermaid(b.text, b.line);
|
|
595
|
+
if (!r.spec) {
|
|
596
|
+
code = 1;
|
|
597
|
+
for (const f of r.faults)
|
|
598
|
+
console.error(`${file === '-' ? '<stdin>' : file}:${f.line}: ${f.reason}`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const json = JSON.stringify(r.spec, null, 2) + '\n';
|
|
602
|
+
if (!out) {
|
|
603
|
+
process.stdout.write(json);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const path = join(out, markdown ? `${name}-${i + 1}.json` : `${name}.json`);
|
|
607
|
+
try {
|
|
608
|
+
mkdirSync(out, { recursive: true });
|
|
609
|
+
writeFileSync(path, json);
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
return fail(`--out ${out}: ${e.message}`, 2);
|
|
613
|
+
}
|
|
614
|
+
console.log(path);
|
|
615
|
+
}
|
|
616
|
+
return code;
|
|
617
|
+
}
|
package/dist/model.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export type FigNode = {
|
|
|
26
26
|
mark?: 'start' | 'end';
|
|
27
27
|
/** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
|
|
28
28
|
source?: string;
|
|
29
|
+
/** A figure with more detail: an SVG path from the repo root. */
|
|
30
|
+
detail?: string;
|
|
29
31
|
};
|
|
30
32
|
/** A frame that lays out its children in a row or a column. */
|
|
31
33
|
export type FigGroup = {
|
|
@@ -37,10 +39,12 @@ export type FigGroup = {
|
|
|
37
39
|
direction?: 'row' | 'column';
|
|
38
40
|
/** Space between children in px. Default: 28 for a column, fitted for a row. */
|
|
39
41
|
gap?: number;
|
|
40
|
-
/** Where children line up across the group's direction. Default: center
|
|
42
|
+
/** Where children line up across the group's direction. Default: `center`, also for a column. */
|
|
41
43
|
align?: 'start' | 'center' | 'end';
|
|
42
44
|
/** The boxes and groups inside, in order. */
|
|
43
45
|
children: (FigNode | FigGroup)[];
|
|
46
|
+
/** On the root layout: flowfig places the boxes. A labeled group is a frame. */
|
|
47
|
+
auto?: true;
|
|
44
48
|
};
|
|
45
49
|
/** An arrow from one box to another. */
|
|
46
50
|
export type FigEdge = {
|
|
@@ -52,8 +56,8 @@ export type FigEdge = {
|
|
|
52
56
|
to: string;
|
|
53
57
|
/** Text on the edge, drawn as a small pill. */
|
|
54
58
|
label?: ReactNode;
|
|
55
|
-
/** Routes the edge
|
|
56
|
-
around?: 'above' | 'below';
|
|
59
|
+
/** Routes the edge around the boxes in between (loops, skip-ahead edges). */
|
|
60
|
+
around?: 'above' | 'below' | 'left' | 'right';
|
|
57
61
|
/** Draws the edge only while a step uses it. */
|
|
58
62
|
quiet?: boolean;
|
|
59
63
|
/** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
|
|
@@ -350,8 +354,12 @@ export declare const counts: (p: FlowProps) => {
|
|
|
350
354
|
steps: number;
|
|
351
355
|
messages: number;
|
|
352
356
|
};
|
|
357
|
+
export declare const idsIn: (c: FigNode | FigGroup) => string[];
|
|
353
358
|
export declare function groupGap(g: FigGroup, edges: FigEdge[]): number;
|
|
354
359
|
export declare const altText: (fig: FlowProps) => {
|
|
355
360
|
title: string;
|
|
356
361
|
desc: string;
|
|
357
362
|
};
|
|
363
|
+
export declare const specKey: (...parts: unknown[]) => string;
|
|
364
|
+
export declare const fitScale: (wide: number, box: number, map: number) => number;
|
|
365
|
+
export declare const nextWide: (wide: number, box: number, map: number) => number;
|