@yassimba/pi-loom-mermaid 0.2.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.
@@ -0,0 +1,519 @@
1
+ /**
2
+ * `graph` / `flowchart`: node chains with inline shapes and labelled links,
3
+ * plus `subgraph` grouping.
4
+ *
5
+ * The grammar is lenient, inherited from upstream and mermaid.js itself:
6
+ * a statement contributes whatever prefix parsed and the rest is dropped,
7
+ * recorded in `graph.warnings`.
8
+ */
9
+
10
+ import {
11
+ Graph,
12
+ type Head,
13
+ type LineKind,
14
+ MAX_GROUP_DEPTH,
15
+ MAX_GROUPS,
16
+ parseDir,
17
+ type Shape,
18
+ } from '../graph.ts'
19
+ import { asciiLower, cleanLabel, decodeHtmlEntities, isIdChar } from '../labels.ts'
20
+ import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
21
+ import type { Diagram } from '../registry.ts'
22
+ import {
23
+ firstWord,
24
+ headerKind,
25
+ nonEmpty,
26
+ parseClassAssign,
27
+ parseClassDef,
28
+ parseHref,
29
+ splitOnce,
30
+ splitTop,
31
+ statementsOf,
32
+ words,
33
+ } from '../statements.ts'
34
+
35
+ export const flowchart: Diagram = {
36
+ kind: 'flowchart',
37
+ headers: ['graph', 'flowchart'],
38
+ render(src, limits) {
39
+ const graph = parseGraph(src)
40
+ if (graph === null) return null
41
+ const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
42
+ if (canvas === null) return null
43
+ return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
44
+ },
45
+ }
46
+
47
+ function parseGraph(src: string): Graph | null {
48
+ const statements = statementsOf(src)
49
+ const kind = headerKind(statements)
50
+ if (kind === null || !flowchart.headers.includes(kind)) return null
51
+
52
+ const graph = new Graph(parseDir(words(statements[0])[1] ?? 'TB'))
53
+ const stack: number[] = []
54
+ /** `class A,B name` assignments, applied after the walk so a statement may
55
+ * precede the nodes it names. Unknown ids are ignored. */
56
+ const classAssignments: [string[], string[]][] = []
57
+ /** `click A "url"` link targets, applied after the walk like classes. */
58
+ const hrefs: [string, string][] = []
59
+
60
+ for (const st of statements.slice(1)) {
61
+ switch (asciiLower(firstWord(st))) {
62
+ case 'subgraph': {
63
+ if (graph.groups.length >= MAX_GROUPS || stack.length >= MAX_GROUP_DEPTH) {
64
+ graph.truncated ??= `subgraph cap (${MAX_GROUPS} groups, depth ${MAX_GROUP_DEPTH}) reached`
65
+ break
66
+ }
67
+ const [id, label] = parseSubgraphDecl(st.slice('subgraph'.length).trim())
68
+ graph.groups.push({ id, label, parent: stack.at(-1) ?? null })
69
+ stack.push(graph.groups.length - 1)
70
+ graph.curGroup = stack.at(-1) ?? null
71
+ continue
72
+ }
73
+ case 'end':
74
+ stack.pop()
75
+ graph.curGroup = stack.at(-1) ?? null
76
+ continue
77
+ case 'classdef': {
78
+ const def = parseClassDef(st.slice(firstWord(st).length))
79
+ if (def) for (const name of def.names) graph.classDefs[name] = def.props
80
+ continue
81
+ }
82
+ case 'class': {
83
+ const assign = parseClassAssign(st.slice(firstWord(st).length))
84
+ if (assign) classAssignments.push(assign)
85
+ continue
86
+ }
87
+ case 'click': {
88
+ // `click A "url" [tooltip]` / `click A href "url" …`; the callback
89
+ // forms carry nothing a terminal can invoke.
90
+ const target = parseHref(st.slice(firstWord(st).length))
91
+ if (target) hrefs.push(target)
92
+ continue
93
+ }
94
+ case 'style':
95
+ case 'linkstyle':
96
+ case 'direction':
97
+ continue
98
+ default:
99
+ break
100
+ }
101
+ if (graph.truncated === null) parseStatement(st, graph)
102
+ if (graph.truncated !== null) break
103
+ }
104
+
105
+ graph.applyClasses(classAssignments)
106
+ graph.applyHrefs(hrefs)
107
+
108
+ if (graph.truncated !== null) graph.warnings.push(`diagram truncated: ${graph.truncated}`)
109
+ return graph.nodes.length === 0 ? null : graph
110
+ }
111
+
112
+ /** `subgraph id[Title]`, `subgraph "Title"`, or a bare title. */
113
+ function parseSubgraphDecl(rest: string): [string, string] {
114
+ if (rest.startsWith('"')) {
115
+ const close = rest.indexOf('"', 1)
116
+ if (close !== -1) {
117
+ const label = rest.slice(1, close)
118
+ return [label, decodeHtmlEntities(label)]
119
+ }
120
+ }
121
+ const open = rest.indexOf('[')
122
+ if (open !== -1) {
123
+ const id = rest.slice(0, open).trim()
124
+ const label = cleanLabel(
125
+ rest
126
+ .slice(open + 1)
127
+ .replace(/\]+$/, '')
128
+ .trim(),
129
+ )
130
+ if (id !== '' && label !== '') return [id, label]
131
+ }
132
+ return [rest, rest]
133
+ }
134
+
135
+ /**
136
+ * A chain of `node link node link node ...`, each link fanning out over `&`.
137
+ *
138
+ * Parses as far as it can and keeps the prefix, matching upstream and
139
+ * mermaid.js. Whatever it could not read is recorded in `graph.warnings` rather
140
+ * than failing the diagram — see the note on that field.
141
+ */
142
+ function parseStatement(st: string, graph: Graph): void {
143
+ const chars = [...st]
144
+ let i = 0
145
+
146
+ // A parse failure after the cap hit is the cap's fault, not the
147
+ // statement's: the truncation warning already covers it (Graph.drop has
148
+ // the same rule).
149
+ const head = parseNodeGroup(chars, i, graph)
150
+ if (!head) {
151
+ if (graph.truncated === null)
152
+ graph.warnings.push(`dropped, does not start with a node: "${st}"`)
153
+ return
154
+ }
155
+ let prev = head.group
156
+ i = head.next
157
+
158
+ for (;;) {
159
+ i = skipSpaces(chars, i)
160
+ if (i >= chars.length) break
161
+ const link = parseLink(chars, i)
162
+ if (!link) {
163
+ if (graph.truncated === null)
164
+ graph.warnings.push(`dropped, expected a link: "${chars.slice(i).join('')}"`)
165
+ break
166
+ }
167
+ i = skipSpaces(chars, link.next)
168
+ const target = parseNodeGroup(chars, i, graph)
169
+ if (!target) {
170
+ if (graph.truncated === null) graph.warnings.push(`dropped, link has no target: "${st}"`)
171
+ break
172
+ }
173
+ i = target.next
174
+ for (const f of prev) {
175
+ for (const t of target.group) {
176
+ // `A <-- B` reads right-to-left: swap the endpoints so the arrow that
177
+ // was written on the left becomes a normal forward head.
178
+ const reversed = link.left === 'arrow' && link.right !== 'arrow'
179
+ const pushed = graph.pushEdge({
180
+ from: reversed ? t : f,
181
+ to: reversed ? f : t,
182
+ label: link.label,
183
+ headTo: reversed ? 'arrow' : link.right,
184
+ headFrom: reversed ? link.right : link.left,
185
+ line: link.line,
186
+ })
187
+ if (!pushed) return
188
+ }
189
+ }
190
+ prev = target.group
191
+ }
192
+ }
193
+
194
+ /** One or more nodes joined by `&`, which fan out into a cross product. */
195
+ function parseNodeGroup(
196
+ chars: string[],
197
+ start: number,
198
+ graph: Graph,
199
+ ): { group: number[]; next: number } | null {
200
+ const first = parseNode(chars, start, graph)
201
+ if (!first) return null
202
+ const group = [first.index]
203
+ let i = first.next
204
+ for (;;) {
205
+ const j = skipSpaces(chars, i)
206
+ if (chars[j] !== '&') break
207
+ const next = parseNode(chars, j + 1, graph)
208
+ if (!next) return null
209
+ group.push(next.index)
210
+ i = next.next
211
+ }
212
+ return { group, next: i }
213
+ }
214
+
215
+ function skipSpaces(chars: string[], i: number): number {
216
+ while (i < chars.length && (chars[i] === ' ' || chars[i] === '\t')) i++
217
+ return i
218
+ }
219
+
220
+ function parseNode(
221
+ chars: string[],
222
+ start: number,
223
+ graph: Graph,
224
+ ): { index: number; next: number } | null {
225
+ let i = skipSpaces(chars, start)
226
+ const idStart = i
227
+ // `-` joins the id only when an id char follows, so kebab-case ids parse
228
+ // while `-->` / `-.` / `--` still terminate (mermaid lexes ids greedily).
229
+ while (
230
+ i < chars.length &&
231
+ (isIdChar(chars[i]) || (chars[i] === '-' && i + 1 < chars.length && isIdChar(chars[i + 1])))
232
+ )
233
+ i++
234
+ if (i === idStart) return null
235
+ const id = chars.slice(idStart, i).join('')
236
+
237
+ const shaped =
238
+ chars[i] === '@' && chars[i + 1] === '{' ? readAtShape(chars, i + 2) : readShapeAt(chars, i)
239
+ if (shaped.unclosed !== undefined) {
240
+ graph.warnings.push(`node "${id}": label is missing its closing \`${shaped.unclosed}\``)
241
+ }
242
+ const index = graph.nodeIndex(id, shaped.label, shaped.shape)
243
+ if (index === null) return null
244
+
245
+ // `id:::name` (after any shape) attaches an author class to the node —
246
+ // upstream drops the rest of the line here.
247
+ let next = shaped.after
248
+ if (chars[next] === ':' && chars[next + 1] === ':' && chars[next + 2] === ':') {
249
+ let k = next + 3
250
+ while (k < chars.length && (isIdChar(chars[k]) || chars[k] === '-')) k++
251
+ // A name never ends in `-`: back off so `A:::x-->B` keeps its link.
252
+ while (k > next + 3 && chars[k - 1] === '-') k--
253
+ if (k > next + 3) {
254
+ graph.addClass(index, chars.slice(next + 3, k).join(''))
255
+ next = k
256
+ }
257
+ }
258
+ return { index, next }
259
+ }
260
+
261
+ /** What a shape bracket yielded. `closer` is set when the bracket never closed. */
262
+ interface Shaped {
263
+ shape: Shape
264
+ label: string | null
265
+ after: number
266
+ /** The closing token that was expected but never found. */
267
+ unclosed?: string
268
+ }
269
+
270
+ /** Dispatch on the bracket following an id to pick shape and closing token. */
271
+ function readShapeAt(chars: string[], i: number): Shaped {
272
+ const c = chars[i]
273
+ const n = chars[i + 1]
274
+ if (c === '[') {
275
+ if (n === '[') return readShape(chars, i + 2, ']]', 'rect')
276
+ if (n === '(') return readShape(chars, i + 2, ')]', 'round')
277
+ return readShape(chars, i + 1, ']', 'rect')
278
+ }
279
+ if (c === '(') {
280
+ if (n === '(') return readShape(chars, i + 2, '))', 'round')
281
+ if (n === '[') return readShape(chars, i + 2, '])', 'round')
282
+ return readShape(chars, i + 1, ')', 'round')
283
+ }
284
+ if (c === '{') {
285
+ if (n === '{') return readShape(chars, i + 2, '}}', 'diamond')
286
+ return readShape(chars, i + 1, '}', 'diamond')
287
+ }
288
+ if (c === '>') return readShape(chars, i + 1, ']', 'rect')
289
+ return { shape: 'rect', label: null, after: i }
290
+ }
291
+
292
+ /**
293
+ * Read label text up to `closer`.
294
+ *
295
+ * Quoting is decided by the first non-space character: inside a quoted label
296
+ * the closer is ignored until the quote closes, so `A["a] b"]` is one node.
297
+ * An unquoted label ends at the first closer, so `A[5" pipe]` keeps its quote.
298
+ */
299
+ function readShape(chars: string[], start: number, closer: string, shape: Shape): Shaped {
300
+ let j = start
301
+ while (chars[j] === ' ' || chars[j] === '\t') j++
302
+ const quoted = chars[j] === '"'
303
+
304
+ let i = start
305
+ let text = ''
306
+ let inQuotes = false
307
+ while (i < chars.length) {
308
+ const c = chars[i]
309
+ if (quoted && c === '"') {
310
+ inQuotes = !inQuotes
311
+ text += c
312
+ i++
313
+ continue
314
+ }
315
+ if (!inQuotes && chars.slice(i, i + closer.length).join('') === closer) {
316
+ return { shape, label: cleanLabel(text), after: i + closer.length }
317
+ }
318
+ text += c
319
+ i++
320
+ }
321
+ // Ran off the end still looking for the closer: everything after the opening
322
+ // bracket became label text, so any link operator in it was swallowed.
323
+ return { shape, label: cleanLabel(text), after: chars.length, unclosed: closer }
324
+ }
325
+
326
+ /**
327
+ * Flowchart v2 shape names that read as something other than a plain box.
328
+ * The terminal has three silhouettes; every name not listed here means
329
+ * "some kind of box" and maps to `rect`.
330
+ */
331
+ const AT_SHAPES: Record<string, Shape> = {
332
+ rounded: 'round',
333
+ stadium: 'round',
334
+ pill: 'round',
335
+ terminal: 'round',
336
+ cyl: 'round',
337
+ cylinder: 'round',
338
+ database: 'round',
339
+ db: 'round',
340
+ circle: 'round',
341
+ circ: 'round',
342
+ 'sm-circ': 'round',
343
+ 'small-circle': 'round',
344
+ 'dbl-circ': 'round',
345
+ 'double-circle': 'round',
346
+ 'fr-circ': 'round',
347
+ 'framed-circle': 'round',
348
+ start: 'round',
349
+ stop: 'round',
350
+ event: 'round',
351
+ delay: 'round',
352
+ cloud: 'round',
353
+ bang: 'round',
354
+ diam: 'diamond',
355
+ diamond: 'diamond',
356
+ decision: 'diamond',
357
+ question: 'diamond',
358
+ hex: 'diamond',
359
+ hexagon: 'diamond',
360
+ prepare: 'diamond',
361
+ }
362
+
363
+ /**
364
+ * The v2 node syntax `id@{shape: cyl, label: "..."}`, cursor past the `@{`.
365
+ *
366
+ * The body is `key: value` pairs split on top-level commas; quoted values may
367
+ * contain commas and `}`. Unknown keys are ignored, unknown shapes draw as a
368
+ * plain box. A body that never closes reports itself like any unterminated
369
+ * label bracket.
370
+ */
371
+ function readAtShape(chars: string[], start: number): Shaped {
372
+ let i = start
373
+ let depth = 0
374
+ let inQuotes = false
375
+ for (; i < chars.length; i++) {
376
+ const c = chars[i]
377
+ if (inQuotes) {
378
+ if (c === '"') inQuotes = false
379
+ } else if (c === '"') {
380
+ inQuotes = true
381
+ } else if (c === '{') {
382
+ depth++
383
+ } else if (c === '}') {
384
+ if (depth === 0) break
385
+ depth--
386
+ }
387
+ }
388
+ const body = chars.slice(start, i).join('')
389
+ const closed = chars[i] === '}'
390
+
391
+ let shape: Shape = 'rect'
392
+ let label: string | null = null
393
+ for (const pair of splitTop(body, (c) => c === ',')) {
394
+ const kv = splitOnce(pair, ':')
395
+ if (kv === null) continue
396
+ const key = asciiLower(kv[0].trim())
397
+ if (key === 'shape') shape = AT_SHAPES[asciiLower(kv[1].trim())] ?? 'rect'
398
+ else if (key === 'label') label = nonEmpty(cleanLabel(kv[1]))
399
+ }
400
+ return closed
401
+ ? { shape, label, after: i + 1 }
402
+ : { shape, label, after: chars.length, unclosed: '}' }
403
+ }
404
+
405
+ const isLinkChar = (c: string): boolean =>
406
+ c === '-' || c === '.' || c === '=' || c === '<' || c === '>'
407
+
408
+ interface Link {
409
+ left: Head
410
+ right: Head
411
+ line: LineKind
412
+ label: string | null
413
+ next: number
414
+ }
415
+
416
+ /**
417
+ * Read a link operator and its label.
418
+ *
419
+ * Labels come in two forms: `-->|text|` and the inline `-- text -->`, the
420
+ * latter only when the first operator carried no head.
421
+ */
422
+ function parseLink(chars: string[], start: number): Link | null {
423
+ let i = skipSpaces(chars, start)
424
+ let left: Head = 'none'
425
+ // A leading `o`/`x` decorates the tail, but only directly before an operator.
426
+ if (
427
+ (chars[i] === 'o' || chars[i] === 'x') &&
428
+ (chars[i + 1] === '-' || chars[i + 1] === '.' || chars[i + 1] === '=')
429
+ ) {
430
+ left = chars[i] === 'o' ? 'circle' : 'cross'
431
+ i++
432
+ }
433
+
434
+ const opStart = i
435
+ while (i < chars.length && isLinkChar(chars[i])) i++
436
+ if (i === opStart) return null
437
+ const op1 = chars.slice(opStart, i).join('')
438
+ if (left === 'none' && op1.startsWith('<')) left = 'arrow'
439
+
440
+ let line = lineKind(op1)
441
+ let right: Head = op1.includes('>') ? 'arrow' : 'none'
442
+ if (right === 'none') {
443
+ const trailing = trailingHead(chars, i)
444
+ if (trailing) {
445
+ right = trailing.head
446
+ i = trailing.next
447
+ }
448
+ }
449
+
450
+ if (chars[i] === '|') {
451
+ i++
452
+ const lStart = i
453
+ // A quoted stretch keeps its `|`s as text: `-->|"a|b"|`.
454
+ let inQuotes = false
455
+ while (i < chars.length && (inQuotes || chars[i] !== '|')) {
456
+ if (chars[i] === '"') inQuotes = !inQuotes
457
+ i++
458
+ }
459
+ const label = cleanLabel(chars.slice(lStart, i).join(''))
460
+ if (chars[i] === '|') i++
461
+ return { left, right, line, label: nonEmpty(label), next: i }
462
+ }
463
+
464
+ if (right === 'none') {
465
+ const textStart = skipSpaces(chars, i)
466
+ let j = textStart
467
+ // The label runs to the closing operator, which always starts with two
468
+ // link chars (`--`, `-.`, `==`): a lone `=`/`.`/`-` is label text, and a
469
+ // quoted stretch is label text throughout (`A --"a=b"--> B`).
470
+ while (j < chars.length) {
471
+ if (chars[j] === '"') {
472
+ j++
473
+ while (j < chars.length && chars[j] !== '"') j++
474
+ if (j < chars.length) j++
475
+ } else if (isLinkChar(chars[j]) && isLinkChar(chars[j + 1])) break
476
+ else j++
477
+ }
478
+ if (j < chars.length && j > textStart && chars[j] !== '<') {
479
+ const text = chars.slice(textStart, j).join('')
480
+ const op2Start = j
481
+ while (j < chars.length && isLinkChar(chars[j])) j++
482
+ const op2 = chars.slice(op2Start, j).join('')
483
+ if (op2.includes('>')) {
484
+ right = 'arrow'
485
+ } else {
486
+ const trailing = trailingHead(chars, j)
487
+ if (trailing) {
488
+ right = trailing.head
489
+ j = trailing.next
490
+ }
491
+ }
492
+ if (line === 'solid') line = lineKind(op2)
493
+ return { left, right, line, label: nonEmpty(cleanLabel(text)), next: j }
494
+ }
495
+ }
496
+
497
+ return { left, right, line, label: null, next: i }
498
+ }
499
+
500
+ function lineKind(op: string): LineKind {
501
+ if (op.includes('=')) return 'thick'
502
+ if (op.includes('.')) return 'dotted'
503
+ return 'solid'
504
+ }
505
+
506
+ /** A trailing `o`/`x` head, only when followed by a statement boundary. */
507
+ function trailingHead(chars: string[], i: number): { head: Head; next: number } | null {
508
+ const head: Head | null = chars[i] === 'o' ? 'circle' : chars[i] === 'x' ? 'cross' : null
509
+ if (head === null) return null
510
+ const after = chars[i + 1]
511
+ const boundary =
512
+ after === undefined ||
513
+ after === ' ' ||
514
+ after === '\t' ||
515
+ after === '|' ||
516
+ after === '&' ||
517
+ after === ';'
518
+ return boundary ? { head, next: i + 1 } : null
519
+ }