@herbertgao/pi-extensions 2026.9.7 → 2026.9.8

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.
Files changed (65) hide show
  1. package/node_modules/grok-mermaid/CHANGELOG.md +46 -0
  2. package/node_modules/grok-mermaid/LICENSE +205 -0
  3. package/node_modules/grok-mermaid/README.md +191 -0
  4. package/node_modules/grok-mermaid/dist/ansi.d.ts +16 -0
  5. package/node_modules/grok-mermaid/dist/ansi.d.ts.map +1 -0
  6. package/node_modules/grok-mermaid/dist/ansi.js +23 -0
  7. package/node_modules/grok-mermaid/dist/ansi.js.map +1 -0
  8. package/node_modules/grok-mermaid/dist/canvas.d.ts +87 -0
  9. package/node_modules/grok-mermaid/dist/canvas.d.ts.map +1 -0
  10. package/node_modules/grok-mermaid/dist/canvas.js +366 -0
  11. package/node_modules/grok-mermaid/dist/canvas.js.map +1 -0
  12. package/node_modules/grok-mermaid/dist/graph.d.ts +74 -0
  13. package/node_modules/grok-mermaid/dist/graph.d.ts.map +1 -0
  14. package/node_modules/grok-mermaid/dist/graph.js +91 -0
  15. package/node_modules/grok-mermaid/dist/graph.js.map +1 -0
  16. package/node_modules/grok-mermaid/dist/index.d.ts +32 -0
  17. package/node_modules/grok-mermaid/dist/index.d.ts.map +1 -0
  18. package/node_modules/grok-mermaid/dist/index.js +100 -0
  19. package/node_modules/grok-mermaid/dist/index.js.map +1 -0
  20. package/node_modules/grok-mermaid/dist/labels.d.ts +62 -0
  21. package/node_modules/grok-mermaid/dist/labels.d.ts.map +1 -0
  22. package/node_modules/grok-mermaid/dist/labels.js +324 -0
  23. package/node_modules/grok-mermaid/dist/labels.js.map +1 -0
  24. package/node_modules/grok-mermaid/dist/layout-seq.d.ts +12 -0
  25. package/node_modules/grok-mermaid/dist/layout-seq.d.ts.map +1 -0
  26. package/node_modules/grok-mermaid/dist/layout-seq.js +194 -0
  27. package/node_modules/grok-mermaid/dist/layout-seq.js.map +1 -0
  28. package/node_modules/grok-mermaid/dist/layout.d.ts +87 -0
  29. package/node_modules/grok-mermaid/dist/layout.d.ts.map +1 -0
  30. package/node_modules/grok-mermaid/dist/layout.js +881 -0
  31. package/node_modules/grok-mermaid/dist/layout.js.map +1 -0
  32. package/node_modules/grok-mermaid/dist/parse.d.ts +83 -0
  33. package/node_modules/grok-mermaid/dist/parse.d.ts.map +1 -0
  34. package/node_modules/grok-mermaid/dist/parse.js +1151 -0
  35. package/node_modules/grok-mermaid/dist/parse.js.map +1 -0
  36. package/node_modules/grok-mermaid/dist/source-box.d.ts +18 -0
  37. package/node_modules/grok-mermaid/dist/source-box.d.ts.map +1 -0
  38. package/node_modules/grok-mermaid/dist/source-box.js +78 -0
  39. package/node_modules/grok-mermaid/dist/source-box.js.map +1 -0
  40. package/node_modules/grok-mermaid/dist/types.d.ts +42 -0
  41. package/node_modules/grok-mermaid/dist/types.d.ts.map +1 -0
  42. package/node_modules/grok-mermaid/dist/types.js +1 -0
  43. package/node_modules/grok-mermaid/dist/types.js.map +1 -0
  44. package/node_modules/grok-mermaid/dist/width-data.d.ts +2 -0
  45. package/node_modules/grok-mermaid/dist/width-data.d.ts.map +1 -0
  46. package/node_modules/grok-mermaid/dist/width-data.js +994 -0
  47. package/node_modules/grok-mermaid/dist/width-data.js.map +1 -0
  48. package/node_modules/grok-mermaid/dist/width.d.ts +18 -0
  49. package/node_modules/grok-mermaid/dist/width.d.ts.map +1 -0
  50. package/node_modules/grok-mermaid/dist/width.js +76 -0
  51. package/node_modules/grok-mermaid/dist/width.js.map +1 -0
  52. package/node_modules/grok-mermaid/package.json +49 -0
  53. package/node_modules/grok-mermaid/src/ansi.ts +34 -0
  54. package/node_modules/grok-mermaid/src/canvas.ts +373 -0
  55. package/node_modules/grok-mermaid/src/graph.ts +142 -0
  56. package/node_modules/grok-mermaid/src/index.ts +104 -0
  57. package/node_modules/grok-mermaid/src/labels.ts +326 -0
  58. package/node_modules/grok-mermaid/src/layout-seq.ts +203 -0
  59. package/node_modules/grok-mermaid/src/layout.ts +1015 -0
  60. package/node_modules/grok-mermaid/src/parse.ts +1189 -0
  61. package/node_modules/grok-mermaid/src/source-box.ts +89 -0
  62. package/node_modules/grok-mermaid/src/types.ts +43 -0
  63. package/node_modules/grok-mermaid/src/width-data.ts +993 -0
  64. package/node_modules/grok-mermaid/src/width.ts +74 -0
  65. package/package.json +2 -1
@@ -0,0 +1,1189 @@
1
+ /**
2
+ * Source text to diagram model.
3
+ *
4
+ * Every `parseX` returns `null` when the source is not that kind of diagram,
5
+ * or when it exceeds a cap — `render` tries each in turn and falls back to a
6
+ * framed copy of the source when they all decline.
7
+ */
8
+
9
+ import {
10
+ type ClassInfo,
11
+ emptyClassInfo,
12
+ Graph,
13
+ type Head,
14
+ type LineKind,
15
+ MAX_EDGES,
16
+ MAX_GROUP_DEPTH,
17
+ MAX_GROUPS,
18
+ MAX_MEMBERS,
19
+ MAX_NODES,
20
+ parseDir,
21
+ type Shape,
22
+ } from './graph.ts'
23
+ import { asciiLower, cleanLabel, decodeHtmlEntities, isIdChar, srcLines } from './labels.ts'
24
+
25
+ // ---------------------------------------------------------------- statements
26
+
27
+ function flushStatement(cur: string, out: string[]): string {
28
+ const trimmed = cur.trim()
29
+ if (trimmed !== '') out.push(trimmed)
30
+ return ''
31
+ }
32
+
33
+ /**
34
+ * Split one source line into statements on `;`, stopping at a `%%` comment.
35
+ *
36
+ * Quoted spans are opaque, so a label may contain `;` and `%%`.
37
+ */
38
+ export function splitStatements(line: string, out: string[]): void {
39
+ const chars = [...line]
40
+ let cur = ''
41
+ let inQuotes = false
42
+ for (let i = 0; i < chars.length; i++) {
43
+ const c = chars[i]
44
+ if (inQuotes) {
45
+ if (c === '"') inQuotes = false
46
+ cur += c
47
+ } else if (c === '"') {
48
+ inQuotes = true
49
+ cur += c
50
+ } else if (c === '%' && chars[i + 1] === '%') {
51
+ break
52
+ } else if (c === ';') {
53
+ cur = flushStatement(cur, out)
54
+ } else {
55
+ cur += c
56
+ }
57
+ }
58
+ flushStatement(cur, out)
59
+ }
60
+
61
+ /** All statements in a source block, in order. */
62
+ export function statementsOf(src: string): string[] {
63
+ const out: string[] = []
64
+ for (const line of srcLines(src)) splitStatements(line, out)
65
+ return out
66
+ }
67
+
68
+ const firstWord = (s: string): string => s.split(/\s+/).filter((w) => w !== '')[0] ?? ''
69
+ const words = (s: string): string[] => s.split(/\s+/).filter((w) => w !== '')
70
+
71
+ /** Split on the first occurrence of `sep`, Rust's `split_once`. */
72
+ function splitOnce(s: string, sep: string): [string, string] | null {
73
+ const i = s.indexOf(sep)
74
+ return i === -1 ? null : [s.slice(0, i), s.slice(i + sep.length)]
75
+ }
76
+
77
+ const nonEmpty = (s: string): string | null => (s === '' ? null : s)
78
+
79
+ /** Diagram kind from the header statement, lowercased. */
80
+ function headerKind(statements: string[]): string | null {
81
+ const header = statements[0]
82
+ if (header === undefined) return null
83
+ const kind = firstWord(header)
84
+ return kind === '' ? null : asciiLower(kind)
85
+ }
86
+
87
+ /** A diagram type this renderer draws. */
88
+ export type DiagramKind = 'flowchart' | 'state' | 'class' | 'er' | 'sequence'
89
+
90
+ /**
91
+ * The kind of diagram `src` declares, or `null` if its header names no type
92
+ * this renderer draws.
93
+ *
94
+ * Reads the header only — it says nothing about whether the body parses. Pair
95
+ * it with `render` to tell a source this renderer will never draw from one that
96
+ * is merely malformed:
97
+ *
98
+ * ```ts
99
+ * render(src) === null && diagramKind(src) !== null // syntax error
100
+ * ```
101
+ *
102
+ * Each branch mirrors the header test in the matching `parseX`, so the two
103
+ * always agree on what they recognise.
104
+ */
105
+ export function diagramKind(src: string): DiagramKind | null {
106
+ const kind = headerKind(statementsOf(src))
107
+ if (kind === null) return null
108
+ if (kind === 'graph' || kind === 'flowchart') return 'flowchart'
109
+ if (kind.startsWith('statediagram')) return 'state'
110
+ if (kind.startsWith('classdiagram')) return 'class'
111
+ if (kind === 'erdiagram') return 'er'
112
+ if (kind === 'sequencediagram') return 'sequence'
113
+ return null
114
+ }
115
+
116
+ // ----------------------------------------------------------------- flowchart
117
+
118
+ export function parseGraph(src: string): Graph | null {
119
+ const statements = statementsOf(src)
120
+ const kind = headerKind(statements)
121
+ if (kind !== 'graph' && kind !== 'flowchart') return null
122
+
123
+ const graph = new Graph(parseDir(words(statements[0])[1] ?? 'TB'))
124
+ const stack: number[] = []
125
+
126
+ for (const st of statements.slice(1)) {
127
+ switch (asciiLower(firstWord(st))) {
128
+ case 'subgraph': {
129
+ if (graph.groups.length >= MAX_GROUPS || stack.length >= MAX_GROUP_DEPTH) return null
130
+ const [id, label] = parseSubgraphDecl(st.slice('subgraph'.length).trim())
131
+ graph.groups.push({ id, label, parent: stack.at(-1) ?? null })
132
+ stack.push(graph.groups.length - 1)
133
+ graph.curGroup = stack.at(-1) ?? null
134
+ continue
135
+ }
136
+ case 'end':
137
+ stack.pop()
138
+ graph.curGroup = stack.at(-1) ?? null
139
+ continue
140
+ case 'classdef':
141
+ case 'class':
142
+ case 'style':
143
+ case 'linkstyle':
144
+ case 'click':
145
+ case 'direction':
146
+ continue
147
+ default:
148
+ break
149
+ }
150
+ parseStatement(st, graph)
151
+ if (graph.overCap) return null
152
+ }
153
+
154
+ return graph.nodes.length === 0 ? null : graph
155
+ }
156
+
157
+ /** `subgraph id[Title]`, `subgraph "Title"`, or a bare title. */
158
+ function parseSubgraphDecl(rest: string): [string, string] {
159
+ if (rest.startsWith('"')) {
160
+ const close = rest.indexOf('"', 1)
161
+ if (close !== -1) {
162
+ const label = rest.slice(1, close)
163
+ return [label, decodeHtmlEntities(label)]
164
+ }
165
+ }
166
+ const open = rest.indexOf('[')
167
+ if (open !== -1) {
168
+ const id = rest.slice(0, open).trim()
169
+ const label = cleanLabel(
170
+ rest
171
+ .slice(open + 1)
172
+ .replace(/\]+$/, '')
173
+ .trim(),
174
+ )
175
+ if (id !== '' && label !== '') return [id, label]
176
+ }
177
+ return [rest, rest]
178
+ }
179
+
180
+ /**
181
+ * A chain of `node link node link node ...`, each link fanning out over `&`.
182
+ *
183
+ * Parses as far as it can and keeps the prefix, matching upstream and
184
+ * mermaid.js. Whatever it could not read is recorded in `graph.warnings` rather
185
+ * than failing the diagram — see the note on that field.
186
+ */
187
+ function parseStatement(st: string, graph: Graph): void {
188
+ const chars = [...st]
189
+ let i = 0
190
+
191
+ const head = parseNodeGroup(chars, i, graph)
192
+ if (!head) {
193
+ graph.warnings.push(`dropped, does not start with a node: "${st}"`)
194
+ return
195
+ }
196
+ let prev = head.group
197
+ i = head.next
198
+
199
+ for (;;) {
200
+ i = skipSpaces(chars, i)
201
+ if (i >= chars.length) break
202
+ const link = parseLink(chars, i)
203
+ if (!link) {
204
+ graph.warnings.push(`dropped, expected a link: "${chars.slice(i).join('')}"`)
205
+ break
206
+ }
207
+ i = skipSpaces(chars, link.next)
208
+ const target = parseNodeGroup(chars, i, graph)
209
+ if (!target) {
210
+ graph.warnings.push(`dropped, link has no target: "${st}"`)
211
+ break
212
+ }
213
+ i = target.next
214
+ for (const f of prev) {
215
+ for (const t of target.group) {
216
+ // `A <-- B` reads right-to-left: swap the endpoints so the arrow that
217
+ // was written on the left becomes a normal forward head.
218
+ const reversed = link.left === 'arrow' && link.right !== 'arrow'
219
+ const pushed = graph.pushEdge({
220
+ from: reversed ? t : f,
221
+ to: reversed ? f : t,
222
+ label: link.label,
223
+ headTo: reversed ? 'arrow' : link.right,
224
+ headFrom: reversed ? link.right : link.left,
225
+ line: link.line,
226
+ })
227
+ if (!pushed) return
228
+ }
229
+ }
230
+ prev = target.group
231
+ }
232
+ }
233
+
234
+ /** One or more nodes joined by `&`, which fan out into a cross product. */
235
+ function parseNodeGroup(
236
+ chars: string[],
237
+ start: number,
238
+ graph: Graph,
239
+ ): { group: number[]; next: number } | null {
240
+ const first = parseNode(chars, start, graph)
241
+ if (!first) return null
242
+ const group = [first.index]
243
+ let i = first.next
244
+ for (;;) {
245
+ const j = skipSpaces(chars, i)
246
+ if (chars[j] !== '&') break
247
+ const next = parseNode(chars, j + 1, graph)
248
+ if (!next) return null
249
+ group.push(next.index)
250
+ i = next.next
251
+ }
252
+ return { group, next: i }
253
+ }
254
+
255
+ function skipSpaces(chars: string[], i: number): number {
256
+ while (i < chars.length && (chars[i] === ' ' || chars[i] === '\t')) i++
257
+ return i
258
+ }
259
+
260
+ function parseNode(
261
+ chars: string[],
262
+ start: number,
263
+ graph: Graph,
264
+ ): { index: number; next: number } | null {
265
+ let i = skipSpaces(chars, start)
266
+ const idStart = i
267
+ while (i < chars.length && isIdChar(chars[i])) i++
268
+ if (i === idStart) return null
269
+ const id = chars.slice(idStart, i).join('')
270
+
271
+ const shaped = readShapeAt(chars, i)
272
+ if (shaped.unclosed !== undefined) {
273
+ graph.warnings.push(`node "${id}": label is missing its closing \`${shaped.unclosed}\``)
274
+ }
275
+ const index = graph.nodeIndex(id, shaped.label, shaped.shape)
276
+ if (index === null) return null
277
+
278
+ // `id:::name` (after any shape) attaches a style class; swallow it so the
279
+ // statement keeps parsing — upstream drops the rest of the line here.
280
+ let next = shaped.after
281
+ if (chars[next] === ':' && chars[next + 1] === ':' && chars[next + 2] === ':') {
282
+ let k = next + 3
283
+ while (k < chars.length && (isIdChar(chars[k]) || chars[k] === '-')) k++
284
+ // A name never ends in `-`: back off so `A:::x-->B` keeps its link.
285
+ while (k > next + 3 && chars[k - 1] === '-') k--
286
+ if (k > next + 3) next = k
287
+ }
288
+ return { index, next }
289
+ }
290
+
291
+ /** What a shape bracket yielded. `closer` is set when the bracket never closed. */
292
+ interface Shaped {
293
+ shape: Shape
294
+ label: string | null
295
+ after: number
296
+ /** The closing token that was expected but never found. */
297
+ unclosed?: string
298
+ }
299
+
300
+ /** Dispatch on the bracket following an id to pick shape and closing token. */
301
+ function readShapeAt(chars: string[], i: number): Shaped {
302
+ const c = chars[i]
303
+ const n = chars[i + 1]
304
+ if (c === '[') {
305
+ if (n === '[') return readShape(chars, i + 2, ']]', 'rect')
306
+ if (n === '(') return readShape(chars, i + 2, ')]', 'round')
307
+ return readShape(chars, i + 1, ']', 'rect')
308
+ }
309
+ if (c === '(') {
310
+ if (n === '(') return readShape(chars, i + 2, '))', 'round')
311
+ if (n === '[') return readShape(chars, i + 2, '])', 'round')
312
+ return readShape(chars, i + 1, ')', 'round')
313
+ }
314
+ if (c === '{') {
315
+ if (n === '{') return readShape(chars, i + 2, '}}', 'diamond')
316
+ return readShape(chars, i + 1, '}', 'diamond')
317
+ }
318
+ if (c === '>') return readShape(chars, i + 1, ']', 'rect')
319
+ return { shape: 'rect', label: null, after: i }
320
+ }
321
+
322
+ /**
323
+ * Read label text up to `closer`.
324
+ *
325
+ * Quoting is decided by the first non-space character: inside a quoted label
326
+ * the closer is ignored until the quote closes, so `A["a] b"]` is one node.
327
+ * An unquoted label ends at the first closer, so `A[5" pipe]` keeps its quote.
328
+ */
329
+ function readShape(chars: string[], start: number, closer: string, shape: Shape): Shaped {
330
+ let j = start
331
+ while (chars[j] === ' ' || chars[j] === '\t') j++
332
+ const quoted = chars[j] === '"'
333
+
334
+ let i = start
335
+ let text = ''
336
+ let inQuotes = false
337
+ while (i < chars.length) {
338
+ const c = chars[i]
339
+ if (quoted && c === '"') {
340
+ inQuotes = !inQuotes
341
+ text += c
342
+ i++
343
+ continue
344
+ }
345
+ if (!inQuotes && chars.slice(i, i + closer.length).join('') === closer) {
346
+ return { shape, label: cleanLabel(text), after: i + closer.length }
347
+ }
348
+ text += c
349
+ i++
350
+ }
351
+ // Ran off the end still looking for the closer: everything after the opening
352
+ // bracket became label text, so any link operator in it was swallowed.
353
+ return { shape, label: cleanLabel(text), after: chars.length, unclosed: closer }
354
+ }
355
+
356
+ const isLinkChar = (c: string): boolean =>
357
+ c === '-' || c === '.' || c === '=' || c === '<' || c === '>'
358
+
359
+ interface Link {
360
+ left: Head
361
+ right: Head
362
+ line: LineKind
363
+ label: string | null
364
+ next: number
365
+ }
366
+
367
+ /**
368
+ * Read a link operator and its label.
369
+ *
370
+ * Labels come in two forms: `-->|text|` and the inline `-- text -->`, the
371
+ * latter only when the first operator carried no head.
372
+ */
373
+ function parseLink(chars: string[], start: number): Link | null {
374
+ let i = skipSpaces(chars, start)
375
+ let left: Head = 'none'
376
+ // A leading `o`/`x` decorates the tail, but only directly before an operator.
377
+ if (
378
+ (chars[i] === 'o' || chars[i] === 'x') &&
379
+ (chars[i + 1] === '-' || chars[i + 1] === '.' || chars[i + 1] === '=')
380
+ ) {
381
+ left = chars[i] === 'o' ? 'circle' : 'cross'
382
+ i++
383
+ }
384
+
385
+ const opStart = i
386
+ while (i < chars.length && isLinkChar(chars[i])) i++
387
+ if (i === opStart) return null
388
+ const op1 = chars.slice(opStart, i).join('')
389
+ if (left === 'none' && op1.startsWith('<')) left = 'arrow'
390
+
391
+ let line = lineKind(op1)
392
+ let right: Head = op1.includes('>') ? 'arrow' : 'none'
393
+ if (right === 'none') {
394
+ const trailing = trailingHead(chars, i)
395
+ if (trailing) {
396
+ right = trailing.head
397
+ i = trailing.next
398
+ }
399
+ }
400
+
401
+ if (chars[i] === '|') {
402
+ i++
403
+ const lStart = i
404
+ while (i < chars.length && chars[i] !== '|') i++
405
+ const label = cleanLabel(chars.slice(lStart, i).join(''))
406
+ if (chars[i] === '|') i++
407
+ return { left, right, line, label: nonEmpty(label), next: i }
408
+ }
409
+
410
+ if (right === 'none') {
411
+ const textStart = skipSpaces(chars, i)
412
+ let j = textStart
413
+ while (j < chars.length && !isLinkChar(chars[j])) j++
414
+ if (j < chars.length && j > textStart && chars[j] !== '<') {
415
+ const text = chars.slice(textStart, j).join('')
416
+ const op2Start = j
417
+ while (j < chars.length && isLinkChar(chars[j])) j++
418
+ const op2 = chars.slice(op2Start, j).join('')
419
+ if (op2.includes('>')) {
420
+ right = 'arrow'
421
+ } else {
422
+ const trailing = trailingHead(chars, j)
423
+ if (trailing) {
424
+ right = trailing.head
425
+ j = trailing.next
426
+ }
427
+ }
428
+ if (line === 'solid') line = lineKind(op2)
429
+ return { left, right, line, label: nonEmpty(cleanLabel(text)), next: j }
430
+ }
431
+ }
432
+
433
+ return { left, right, line, label: null, next: i }
434
+ }
435
+
436
+ function lineKind(op: string): LineKind {
437
+ if (op.includes('=')) return 'thick'
438
+ if (op.includes('.')) return 'dotted'
439
+ return 'solid'
440
+ }
441
+
442
+ /** A trailing `o`/`x` head, only when followed by a statement boundary. */
443
+ function trailingHead(chars: string[], i: number): { head: Head; next: number } | null {
444
+ const head: Head | null = chars[i] === 'o' ? 'circle' : chars[i] === 'x' ? 'cross' : null
445
+ if (head === null) return null
446
+ const after = chars[i + 1]
447
+ const boundary =
448
+ after === undefined ||
449
+ after === ' ' ||
450
+ after === '\t' ||
451
+ after === '|' ||
452
+ after === '&' ||
453
+ after === ';'
454
+ return boundary ? { head, next: i + 1 } : null
455
+ }
456
+
457
+ // --------------------------------------------------------------------- state
458
+
459
+ export function parseState(src: string): Graph | null {
460
+ const statements = statementsOf(src)
461
+ const kind = headerKind(statements)
462
+ if (kind === null || !kind.startsWith('statediagram')) return null
463
+
464
+ const graph = new Graph()
465
+ let inNote = false
466
+
467
+ for (let st of statements.slice(1)) {
468
+ if (inNote) {
469
+ if (asciiLower(st) === 'end note') inNote = false
470
+ continue
471
+ }
472
+ st = dropStyleTags(st)
473
+ const first = asciiLower(firstWord(st))
474
+ if (first === 'direction') {
475
+ graph.dir = parseDir(words(st)[1] ?? '')
476
+ } else if (first === 'note') {
477
+ // A single-line `note ... : text` needs no terminator.
478
+ if (!st.includes(':')) inNote = true
479
+ } else if (first === 'state') {
480
+ if (parseStateDecl(st, graph) === null) return null
481
+ } else if (['classdef', 'class', 'hide', 'scale', '}', '--'].includes(first)) {
482
+ // Styling and composite-state punctuation carry no layout meaning.
483
+ } else if (st.includes('-->')) {
484
+ if (parseTransition(st, graph) === null) return null
485
+ } else if (parseStateDesc(st, graph) === null) {
486
+ return null
487
+ }
488
+ if (graph.overCap) return null
489
+ }
490
+
491
+ return graph.nodes.length === 0 ? null : graph
492
+ }
493
+
494
+ /** `state "Label" as id`, `state id <<choice>>`, or `state id {`. */
495
+ function parseStateDecl(st: string, graph: Graph): true | null {
496
+ const rest = st.slice('state'.length).trim().replace(/\{$/, '').trim()
497
+ if (rest === '') return true
498
+
499
+ if (rest.startsWith('"')) {
500
+ const close = rest.indexOf('"', 1)
501
+ if (close === -1) return null
502
+ const label = rest.slice(1, close)
503
+ const after = rest.slice(close + 1).trim()
504
+ const id = after.startsWith('as') ? after.slice(2).trim() : label
505
+ return graph.nodeLabel(id, decodeHtmlEntities(label)) === null ? null : true
506
+ }
507
+
508
+ let shape: Shape = 'round'
509
+ let id = rest
510
+ let stereotyped = false
511
+ const pos = rest.indexOf('<<')
512
+ if (pos !== -1) {
513
+ const stereo = rest
514
+ .slice(pos + 2)
515
+ .replace(/>>$/, '')
516
+ .trim()
517
+ if (stereo === 'choice') shape = 'diamond'
518
+ id = rest.slice(0, pos).trim()
519
+ stereotyped = true
520
+ }
521
+ if (id === '' || /\s/.test(id)) return null
522
+ return graph.nodeIndex(id, stereotyped ? id : null, shape) === null ? null : true
523
+ }
524
+
525
+ /** `A --> B: label`, including chains `A --> B --> C`. */
526
+ function parseTransition(st: string, graph: Graph): true | null {
527
+ let rest = st
528
+ let prev: number | null = null
529
+
530
+ for (;;) {
531
+ const split = splitOnce(rest, '-->')
532
+ if (!split) break
533
+ const [lhs, rhs] = split
534
+
535
+ const fromId = lhs.trimEnd().replace(/-+$/, '').trim()
536
+ let from: number
537
+ if (prev !== null) {
538
+ // Mid-chain: the source is the previous target, so nothing may precede.
539
+ if (fromId !== '') return null
540
+ from = prev
541
+ } else {
542
+ if (fromId === '') return null
543
+ const f = stateEndpoint(graph, fromId, true)
544
+ if (f === null) return null
545
+ from = f
546
+ }
547
+
548
+ const nextArrow = rhs.indexOf('-->')
549
+ const toPartRaw = nextArrow === -1 ? rhs : rhs.slice(0, nextArrow)
550
+ const tail = nextArrow === -1 ? '' : rhs.slice(nextArrow)
551
+
552
+ const colon = splitOnce(toPartRaw, ':')
553
+ const toPart = colon ? colon[0] : toPartRaw
554
+ const label = colon ? nonEmpty(decodeHtmlEntities(colon[1].trim())) : null
555
+
556
+ const toId = toPart.trimStart().replace(/^>+/, '').trimEnd().replace(/-+$/, '').trim()
557
+ if (toId === '') return null
558
+ const to = stateEndpoint(graph, toId, false)
559
+ if (to === null) return null
560
+
561
+ if (!graph.pushEdge({ from, to, label, headTo: 'arrow', headFrom: 'none', line: 'solid' })) {
562
+ return true
563
+ }
564
+ prev = to
565
+ rest = tail
566
+ }
567
+ return true
568
+ }
569
+
570
+ /**
571
+ * Remove every `:::name` style tag from a statement, parsed with the same
572
+ * name scan as the flowchart shorthand. State and class statements are read
573
+ * by string splits, and the `:` label split would cut inside a `:::`, so tags
574
+ * are dropped before dispatch rather than at each id.
575
+ */
576
+ function dropStyleTags(st: string): string {
577
+ const chars = [...st]
578
+ const out: string[] = []
579
+ for (let i = 0; i < chars.length; ) {
580
+ if (chars[i] === ':' && chars[i + 1] === ':' && chars[i + 2] === ':') {
581
+ let k = i + 3
582
+ while (k < chars.length && (isIdChar(chars[k]) || chars[k] === '-')) k++
583
+ while (k > i + 3 && chars[k - 1] === '-') k--
584
+ if (k > i + 3) {
585
+ i = k
586
+ continue
587
+ }
588
+ }
589
+ out.push(chars[i])
590
+ i++
591
+ }
592
+ return out.join('')
593
+ }
594
+
595
+ /** `[*]` is start or end depending on which side of the arrow it sits. */
596
+ function stateEndpoint(graph: Graph, id: string, isSource: boolean): number | null {
597
+ if (id === '[*]') return graph.nodeIndex(isSource ? '[*]start' : '[*]end', '●', 'round')
598
+ return graph.nodeIndex(id, null, 'round')
599
+ }
600
+
601
+ /** `id: description`, or a bare state name. */
602
+ function parseStateDesc(st: string, graph: Graph): true | null {
603
+ const split = splitOnce(st, ':')
604
+ if (split) {
605
+ const id = split[0].trim()
606
+ const desc = split[1].trim()
607
+ if (id === '' || /\s/.test(id) || desc === '') return null
608
+ return graph.nodeLabel(id, decodeHtmlEntities(desc)) === null ? null : true
609
+ }
610
+ if (/\s/.test(st)) return null
611
+ return graph.nodeIndex(st, null, 'round') === null ? null : true
612
+ }
613
+
614
+ // --------------------------------------------------------------------- class
615
+
616
+ /** Relation operators, longest-first so `--|>` wins over `--`. */
617
+ const CLASS_OPS: [string, Head, Head, LineKind][] = [
618
+ ['<|--', 'triangle', 'none', 'solid'],
619
+ ['--|>', 'none', 'triangle', 'solid'],
620
+ ['<|..', 'triangle', 'none', 'dotted'],
621
+ ['..|>', 'none', 'triangle', 'dotted'],
622
+ ['*--', 'diamondFill', 'none', 'solid'],
623
+ ['--*', 'none', 'diamondFill', 'solid'],
624
+ ['o--', 'diamondOpen', 'none', 'solid'],
625
+ ['--o', 'none', 'diamondOpen', 'solid'],
626
+ ['<--', 'arrow', 'none', 'solid'],
627
+ ['-->', 'none', 'arrow', 'solid'],
628
+ ['<..', 'arrow', 'none', 'dotted'],
629
+ ['..>', 'none', 'arrow', 'dotted'],
630
+ ['--', 'none', 'none', 'solid'],
631
+ ['..', 'none', 'none', 'dotted'],
632
+ ]
633
+
634
+ const MAX_CLASS_OP = 4
635
+
636
+ export function parseClass(src: string): { graph: Graph; infos: ClassInfo[] } | null {
637
+ const statements = statementsOf(src)
638
+ const kind = headerKind(statements)
639
+ if (kind === null || !kind.startsWith('classdiagram')) return null
640
+
641
+ const graph = new Graph()
642
+ const infos: ClassInfo[] = []
643
+ const sync = () => {
644
+ while (infos.length < graph.nodes.length) infos.push(emptyClassInfo())
645
+ }
646
+ /** Declare a class, keeping `infos` aligned with `graph.nodes`. */
647
+ const declare = (name: string): number | null => {
648
+ const idx = graph.nodeIndex(name, null, 'rect')
649
+ sync()
650
+ return idx
651
+ }
652
+ let curClass: number | null = null
653
+
654
+ for (let st of statements.slice(1)) {
655
+ if (curClass !== null) {
656
+ if (st === '}') curClass = null
657
+ else pushMember(infos[curClass], st)
658
+ continue
659
+ }
660
+ st = dropStyleTags(st)
661
+
662
+ const first = asciiLower(firstWord(st))
663
+ if (first === 'direction') {
664
+ graph.dir = parseDir(words(st)[1] ?? '')
665
+ continue
666
+ }
667
+ if (
668
+ [
669
+ 'note',
670
+ 'callback',
671
+ 'click',
672
+ 'link',
673
+ 'style',
674
+ 'cssclass',
675
+ 'classdef',
676
+ 'namespace',
677
+ '}',
678
+ ].includes(first)
679
+ ) {
680
+ continue
681
+ }
682
+ if (first === 'class') {
683
+ const rest = st.slice('class'.length).trim()
684
+ const open = rest.endsWith('{')
685
+ const name = open ? rest.slice(0, -1).trim() : rest
686
+ if (name === '' || /\s/.test(name)) return null
687
+ const idx = declare(name)
688
+ if (idx === null) return null
689
+ if (open) curClass = idx
690
+ continue
691
+ }
692
+
693
+ if (st.startsWith('<<')) {
694
+ const split = splitOnce(st.slice(2), '>>')
695
+ if (!split) return null
696
+ const name = split[1].trim()
697
+ if (name === '' || /\s/.test(name)) return null
698
+ const idx = declare(name)
699
+ if (idx === null) return null
700
+ infos[idx].annotation = split[0].trim()
701
+ continue
702
+ }
703
+
704
+ const rel = parseClassRelation(st)
705
+ if (rel) {
706
+ const f = declare(rel.from)
707
+ if (f === null) return null
708
+ const t = declare(rel.to)
709
+ if (t === null) return null
710
+ if (graph.edges.length >= MAX_EDGES) return null
711
+ graph.edges.push({
712
+ from: f,
713
+ to: t,
714
+ label: rel.label,
715
+ headTo: rel.headTo,
716
+ headFrom: rel.headFrom,
717
+ line: rel.line,
718
+ })
719
+ continue
720
+ }
721
+
722
+ const member = splitOnce(st, ':')
723
+ if (member) {
724
+ const id = member[0].trim()
725
+ const text = member[1].trim()
726
+ if (id === '' || /\s/.test(id) || text === '') return null
727
+ const idx = declare(id)
728
+ if (idx === null) return null
729
+ pushMember(infos[idx], text)
730
+ continue
731
+ }
732
+ return null
733
+ }
734
+
735
+ if (graph.nodes.length === 0) return null
736
+ sync()
737
+ return { graph, infos }
738
+ }
739
+
740
+ /** Add a member to the attribute or method compartment, eliding past the cap. */
741
+ export function pushMember(info: ClassInfo, raw: string): void {
742
+ if (raw.startsWith('<<')) {
743
+ const split = splitOnce(raw.slice(2), '>>')
744
+ if (split) info.annotation = split[0].trim()
745
+ return
746
+ }
747
+ const member = decodeHtmlEntities(displayGenerics(raw.trim()))
748
+ const list = member.includes('(') ? info.methods : info.attrs
749
+ if (list.length < MAX_MEMBERS) list.push(member)
750
+ else if (list.length === MAX_MEMBERS) list.push('…')
751
+ }
752
+
753
+ interface ClassRelation {
754
+ from: string
755
+ to: string
756
+ headFrom: Head
757
+ headTo: Head
758
+ line: LineKind
759
+ label: string | null
760
+ }
761
+
762
+ function parseClassRelation(st: string): ClassRelation | null {
763
+ const chars = [...st]
764
+ let found: { pos: number; op: string; headFrom: Head; headTo: Head; line: LineKind } | null = null
765
+
766
+ outer: for (let pos = 0; pos < chars.length; pos++) {
767
+ const tail = chars.slice(pos, pos + MAX_CLASS_OP).join('')
768
+ for (const [op, headFrom, headTo, line] of CLASS_OPS) {
769
+ if (!tail.startsWith(op)) continue
770
+ // `o` is also an identifier character: skip a match glued to a name.
771
+ if (op.startsWith('o') && pos > 0 && isIdChar(chars[pos - 1])) continue
772
+ const after = chars[pos + [...op].length]
773
+ if (op.endsWith('o') && after !== undefined && isIdChar(after)) continue
774
+ found = { pos, op, headFrom, headTo, line }
775
+ break outer
776
+ }
777
+ }
778
+ if (!found) return null
779
+
780
+ const lhsRaw = chars.slice(0, found.pos).join('').trim()
781
+ const rhsRaw = chars
782
+ .slice(found.pos + [...found.op].length)
783
+ .join('')
784
+ .trim()
785
+
786
+ const [lhs, cardFrom] = stripCardinalitySuffix(lhsRaw)
787
+ const [rhs, cardTo] = stripCardinalityPrefix(rhsRaw)
788
+
789
+ const split = splitOnce(rhs, ':')
790
+ const toId = (split ? split[0] : rhs).trim()
791
+ const relLabel = split ? nonEmpty(decodeHtmlEntities(split[1].trim())) : null
792
+
793
+ if (lhs === '' || toId === '' || /\s/.test(lhs) || /\s/.test(toId)) return null
794
+
795
+ const label = nonEmpty([cardFrom, relLabel ?? '', cardTo].filter((s) => s !== '').join(' '))
796
+ return {
797
+ from: lhs,
798
+ to: toId,
799
+ headFrom: found.headFrom,
800
+ headTo: found.headTo,
801
+ line: found.line,
802
+ label,
803
+ }
804
+ }
805
+
806
+ /** `Class "1"` — a quoted cardinality trailing the left-hand name. */
807
+ function stripCardinalitySuffix(s: string): [string, string] {
808
+ const t = s.trimEnd()
809
+ if (t.endsWith('"')) {
810
+ const rest = t.slice(0, -1)
811
+ const q = rest.lastIndexOf('"')
812
+ if (q !== -1) return [rest.slice(0, q).trimEnd(), rest.slice(q + 1)]
813
+ }
814
+ return [t, '']
815
+ }
816
+
817
+ /** `"0..*" Class` — a quoted cardinality leading the right-hand name. */
818
+ function stripCardinalityPrefix(s: string): [string, string] {
819
+ const t = s.trimStart()
820
+ if (t.startsWith('"')) {
821
+ const rest = t.slice(1)
822
+ const q = rest.indexOf('"')
823
+ if (q !== -1) return [rest.slice(q + 1).trimStart(), rest.slice(0, q)]
824
+ }
825
+ return [t, '']
826
+ }
827
+
828
+ /** Mermaid writes generics as `List~T~`; show them as `List<T>`. */
829
+ function displayGenerics(s: string): string {
830
+ let out = ''
831
+ let open = false
832
+ for (const c of s) {
833
+ if (c === '~') {
834
+ out += open ? '>' : '<'
835
+ open = !open
836
+ } else {
837
+ out += c
838
+ }
839
+ }
840
+ return out
841
+ }
842
+
843
+ // ------------------------------------------------------------------------ ER
844
+
845
+ export function parseEr(src: string): { graph: Graph; infos: ClassInfo[] } | null {
846
+ const statements = statementsOf(src)
847
+ if (headerKind(statements) !== 'erdiagram') return null
848
+
849
+ const graph = new Graph()
850
+ const infos: ClassInfo[] = []
851
+ let curEntity: number | null = null
852
+
853
+ for (const st of statements.slice(1)) {
854
+ if (curEntity !== null) {
855
+ if (st === '}') curEntity = null
856
+ else pushErAttribute(infos[curEntity], st)
857
+ continue
858
+ }
859
+
860
+ const rel = splitErRelationship(st)
861
+ if (rel) {
862
+ const tokens = words(rel.rel)
863
+ if (tokens.length !== 3) return null
864
+ const op = parseErOp(tokens[1])
865
+ if (!op) return null
866
+ const f = erEntity(graph, infos, tokens[0])
867
+ if (f === null) return null
868
+ const t = erEntity(graph, infos, tokens[2])
869
+ if (t === null) return null
870
+ if (graph.edges.length >= MAX_EDGES) return null
871
+ const relLabel = rel.label === null ? '' : cleanLabel(rel.label)
872
+ graph.edges.push({
873
+ from: f,
874
+ to: t,
875
+ label: nonEmpty([op.cardL, relLabel, op.cardR].filter((s) => s !== '').join(' ')),
876
+ headTo: 'none',
877
+ headFrom: 'none',
878
+ line: op.line,
879
+ })
880
+ continue
881
+ }
882
+
883
+ const open = st.endsWith('{')
884
+ const decl = open ? st.slice(0, -1).trim() : st
885
+ if (decl === '' || words(decl).length !== 1) return null
886
+ const idx = erEntity(graph, infos, decl)
887
+ if (idx === null) return null
888
+ if (open) curEntity = idx
889
+ }
890
+
891
+ if (graph.nodes.length === 0) return null
892
+ while (infos.length < graph.nodes.length) infos.push(emptyClassInfo())
893
+ return { graph, infos }
894
+ }
895
+
896
+ function erEntity(graph: Graph, infos: ClassInfo[], token: string): number | null {
897
+ const open = token.indexOf('[')
898
+ let idx: number | null
899
+ if (open !== -1) {
900
+ const id = token.slice(0, open)
901
+ const label = cleanLabel(token.slice(open + 1).replace(/\]+$/, ''))
902
+ if (id === '' || label === '') return null
903
+ idx = graph.nodeLabel(id, label)
904
+ } else {
905
+ idx = graph.nodeIndex(token, null, 'rect')
906
+ }
907
+ if (idx === null) return null
908
+ while (infos.length < graph.nodes.length) infos.push(emptyClassInfo())
909
+ return idx
910
+ }
911
+
912
+ function splitErRelationship(st: string): { rel: string; label: string | null } | null {
913
+ const split = splitOnce(st, ':')
914
+ const rel = split ? split[0] : st
915
+ const label = split ? split[1].trim() : null
916
+ return words(rel).some((t) => parseErOp(t) !== null) ? { rel, label } : null
917
+ }
918
+
919
+ const isAscii = (s: string): boolean => {
920
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false
921
+ return true
922
+ }
923
+
924
+ /** A crow's-foot operator: two cardinality glyphs around `--` or `..`. */
925
+ function parseErOp(tok: string): { cardL: string; cardR: string; line: LineKind } | null {
926
+ if (tok.length !== 6 || !isAscii(tok)) return null
927
+ const mid = tok.slice(2, 4)
928
+ const line: LineKind | null = mid === '--' ? 'solid' : mid === '..' ? 'dotted' : null
929
+ if (line === null) return null
930
+ const cardL = erCard(tok.slice(0, 2))
931
+ const cardR = erCard(tok.slice(4, 6))
932
+ return cardL === null || cardR === null ? null : { cardL, cardR, line }
933
+ }
934
+
935
+ function erCard(tok: string): string | null {
936
+ switch (tok) {
937
+ case '|o':
938
+ case 'o|':
939
+ return '0..1'
940
+ case '||':
941
+ return '1'
942
+ case '}o':
943
+ case 'o{':
944
+ return '0..*'
945
+ case '}|':
946
+ case '|{':
947
+ return '1..*'
948
+ default:
949
+ return null
950
+ }
951
+ }
952
+
953
+ /** ER attributes are `type name`; a trailing quoted comment is dropped. */
954
+ export function pushErAttribute(info: ClassInfo, raw: string): void {
955
+ const parts: string[] = []
956
+ for (const tok of words(raw)) {
957
+ if (tok.startsWith('"')) break
958
+ parts.push(tok)
959
+ }
960
+ if (parts.length === 0) return
961
+ const line = decodeHtmlEntities(parts.join(' '))
962
+ if (info.attrs.length < MAX_MEMBERS) info.attrs.push(line)
963
+ else if (info.attrs.length === MAX_MEMBERS) info.attrs.push('…')
964
+ }
965
+
966
+ // ------------------------------------------------------------------ sequence
967
+
968
+ export type SeqHead = 'arrow' | 'cross'
969
+
970
+ /** Message operators, longest-first so `-->>` wins over `-->`. */
971
+ const SEQ_OPS: [string, boolean, SeqHead][] = [
972
+ ['-->>', true, 'arrow'],
973
+ ['->>', false, 'arrow'],
974
+ ['--x', true, 'cross'],
975
+ ['-x', false, 'cross'],
976
+ ['--)', true, 'arrow'],
977
+ ['-)', false, 'arrow'],
978
+ ['-->', true, 'arrow'],
979
+ ['->', false, 'arrow'],
980
+ ]
981
+
982
+ const MAX_SEQ_OP = 4
983
+
984
+ export type NoteAnchor =
985
+ | { kind: 'over'; from: number; to: number }
986
+ | { kind: 'left'; at: number }
987
+ | { kind: 'right'; at: number }
988
+
989
+ export type SeqItem =
990
+ | {
991
+ kind: 'message'
992
+ from: number
993
+ to: number
994
+ text: string | null
995
+ dashed: boolean
996
+ head: SeqHead
997
+ }
998
+ | { kind: 'note'; anchor: NoteAnchor; text: string }
999
+ | { kind: 'divider'; text: string }
1000
+
1001
+ export class Sequence {
1002
+ labels: string[] = []
1003
+ index = new Map<string, number>()
1004
+ items: SeqItem[] = []
1005
+
1006
+ participant(id: string, label: string | null): number | null {
1007
+ const existing = this.index.get(id)
1008
+ if (existing !== undefined) {
1009
+ if (label !== null) this.labels[existing] = label
1010
+ return existing
1011
+ }
1012
+ if (this.labels.length >= MAX_NODES) return null
1013
+ this.index.set(id, this.labels.length)
1014
+ this.labels.push(label ?? id)
1015
+ return this.labels.length - 1
1016
+ }
1017
+ }
1018
+
1019
+ export function parseSequence(src: string): Sequence | null {
1020
+ const statements = statementsOf(src)
1021
+ if (headerKind(statements) !== 'sequencediagram') return null
1022
+
1023
+ const seq = new Sequence()
1024
+ let autonumber = false
1025
+ let msgCount = 0
1026
+ /** One entry per open block; `true` when it draws a divider on `end`. */
1027
+ const blocks: boolean[] = []
1028
+
1029
+ for (const st of statements.slice(1)) {
1030
+ const first = firstWord(st)
1031
+ const lower = asciiLower(first)
1032
+
1033
+ if (lower === 'participant' || lower === 'actor') {
1034
+ const rest = st.slice(first.length).trim()
1035
+ if (rest === '') return null
1036
+ const as = splitOnce(rest, ' as ')
1037
+ if (seq.participant(as ? as[0].trim() : rest, as ? cleanLabel(as[1]) : null) === null) {
1038
+ return null
1039
+ }
1040
+ continue
1041
+ }
1042
+ if (lower === 'autonumber') {
1043
+ autonumber = true
1044
+ continue
1045
+ }
1046
+ if (
1047
+ [
1048
+ 'activate',
1049
+ 'deactivate',
1050
+ 'create',
1051
+ 'destroy',
1052
+ 'title',
1053
+ 'acctitle',
1054
+ 'accdescr',
1055
+ 'links',
1056
+ 'link',
1057
+ 'properties',
1058
+ ].includes(lower)
1059
+ ) {
1060
+ continue
1061
+ }
1062
+ if (lower === 'note') {
1063
+ const note = parseNoteAnchor(st.slice(first.length).trim(), seq)
1064
+ if (!note) return null
1065
+ if (seq.items.length >= MAX_EDGES) return null
1066
+ seq.items.push({ kind: 'note', anchor: note.anchor, text: note.text })
1067
+ continue
1068
+ }
1069
+ if (
1070
+ ['loop', 'alt', 'opt', 'par', 'critical', 'break', 'else', 'and', 'option'].includes(lower)
1071
+ ) {
1072
+ if (['else', 'and', 'option'].includes(lower)) {
1073
+ // A continuation only divides a block that opened one.
1074
+ if (blocks.at(-1) !== true) continue
1075
+ } else {
1076
+ blocks.push(true)
1077
+ }
1078
+ if (seq.items.length >= MAX_EDGES) return null
1079
+ seq.items.push({ kind: 'divider', text: decodeHtmlEntities(st) })
1080
+ continue
1081
+ }
1082
+ if (lower === 'rect' || lower === 'box') {
1083
+ blocks.push(false)
1084
+ continue
1085
+ }
1086
+ if (lower === 'end') {
1087
+ if (blocks.pop() === true) {
1088
+ if (seq.items.length >= MAX_EDGES) return null
1089
+ seq.items.push({ kind: 'divider', text: 'end' })
1090
+ }
1091
+ continue
1092
+ }
1093
+
1094
+ const msg = parseSeqMessage(st, seq)
1095
+ if (!msg) return null
1096
+ let text = msg.text
1097
+ if (autonumber) {
1098
+ msgCount++
1099
+ text = text === null ? `${msgCount}.` : `${msgCount}. ${text}`
1100
+ }
1101
+ if (seq.items.length >= MAX_EDGES) return null
1102
+ seq.items.push({
1103
+ kind: 'message',
1104
+ from: msg.from,
1105
+ to: msg.to,
1106
+ text,
1107
+ dashed: msg.dashed,
1108
+ head: msg.head,
1109
+ })
1110
+ }
1111
+
1112
+ return seq.labels.length === 0 ? null : seq
1113
+ }
1114
+
1115
+ function parseNoteAnchor(rest: string, seq: Sequence): { text: string; anchor: NoteAnchor } | null {
1116
+ const lower = asciiLower(rest)
1117
+ let kind: NoteAnchor['kind']
1118
+ let idsAndText: string
1119
+ if (lower.startsWith('over ')) {
1120
+ kind = 'over'
1121
+ idsAndText = rest.slice('over '.length)
1122
+ } else if (lower.startsWith('left of ')) {
1123
+ kind = 'left'
1124
+ idsAndText = rest.slice('left of '.length)
1125
+ } else if (lower.startsWith('right of ')) {
1126
+ kind = 'right'
1127
+ idsAndText = rest.slice('right of '.length)
1128
+ } else {
1129
+ return null
1130
+ }
1131
+
1132
+ const split = splitOnce(idsAndText, ':')
1133
+ if (!split) return null
1134
+ const text = decodeHtmlEntities(split[1].trim())
1135
+ const parts = split[0]
1136
+ .split(',')
1137
+ .map((s) => s.trim())
1138
+ .filter((s) => s !== '')
1139
+ if (parts.length === 0) return null
1140
+ const a = seq.participant(parts[0], null)
1141
+ if (a === null) return null
1142
+
1143
+ if (kind !== 'over') return { text, anchor: { kind, at: a } }
1144
+ let b = a
1145
+ if (parts[1] !== undefined) {
1146
+ const second = seq.participant(parts[1], null)
1147
+ if (second === null) return null
1148
+ b = second
1149
+ }
1150
+ return { text, anchor: { kind: 'over', from: Math.min(a, b), to: Math.max(a, b) } }
1151
+ }
1152
+
1153
+ function parseSeqMessage(
1154
+ st: string,
1155
+ seq: Sequence,
1156
+ ): { from: number; to: number; text: string | null; dashed: boolean; head: SeqHead } | null {
1157
+ const chars = [...st]
1158
+ let found: { pos: number; op: string; dashed: boolean; head: SeqHead } | null = null
1159
+ outer: for (let pos = 0; pos < chars.length; pos++) {
1160
+ const tail = chars.slice(pos, pos + MAX_SEQ_OP).join('')
1161
+ for (const [op, dashed, head] of SEQ_OPS) {
1162
+ if (tail.startsWith(op)) {
1163
+ found = { pos, op, dashed, head }
1164
+ break outer
1165
+ }
1166
+ }
1167
+ }
1168
+ if (!found) return null
1169
+
1170
+ const fromId = chars.slice(0, found.pos).join('').trim()
1171
+ if (fromId === '') return null
1172
+ // `+`/`-` activate and deactivate the target; they carry no layout meaning.
1173
+ const rest = chars
1174
+ .slice(found.pos + [...found.op].length)
1175
+ .join('')
1176
+ .trimStart()
1177
+ .replace(/^[+-]+/, '')
1178
+
1179
+ const split = splitOnce(rest, ':')
1180
+ const toId = (split ? split[0] : rest).trim()
1181
+ const text = split ? nonEmpty(decodeHtmlEntities(split[1].trim())) : null
1182
+ if (toId === '') return null
1183
+
1184
+ const from = seq.participant(fromId, null)
1185
+ if (from === null) return null
1186
+ const to = seq.participant(toId, null)
1187
+ if (to === null) return null
1188
+ return { from, to, text, dashed: found.dashed, head: found.head }
1189
+ }