@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,305 @@
1
+ /**
2
+ * `sequenceDiagram`: participants, messages, notes and block dividers.
3
+ * Lenient: an unreadable statement is dropped and recorded in `warnings`.
4
+ *
5
+ * Sequence diagrams have their own model — participants in declaration order
6
+ * plus a flat list of items — and their own geometry in `layout-seq.ts`.
7
+ */
8
+
9
+ import { MAX_EDGES, MAX_NODES } from '../graph.ts'
10
+ import { asciiLower, cleanLabel, decodeHtmlEntities } from '../labels.ts'
11
+ import { layoutSequence } from '../layout-seq.ts'
12
+ import type { Diagram } from '../registry.ts'
13
+ import { firstWord, headerKind, nonEmpty, splitOnce, statementsOf } from '../statements.ts'
14
+
15
+ export const sequence: Diagram = {
16
+ kind: 'sequence',
17
+ headers: ['sequencediagram'],
18
+ render(src, limits) {
19
+ const seq = parseSequence(src)
20
+ if (seq === null) return null
21
+ const canvas = layoutSequence(seq, limits)
22
+ if (canvas === null) return null
23
+ return { canvas, warnings: seq.warnings, classDefs: {} }
24
+ },
25
+ }
26
+
27
+ type SeqHead = 'arrow' | 'cross'
28
+
29
+ /** Message operators, longest-first so `-->>` wins over `-->`. */
30
+ const SEQ_OPS: [string, boolean, SeqHead][] = [
31
+ ['-->>', true, 'arrow'],
32
+ ['->>', false, 'arrow'],
33
+ ['--x', true, 'cross'],
34
+ ['-x', false, 'cross'],
35
+ ['--)', true, 'arrow'],
36
+ ['-)', false, 'arrow'],
37
+ ['-->', true, 'arrow'],
38
+ ['->', false, 'arrow'],
39
+ ]
40
+
41
+ const MAX_SEQ_OP = 4
42
+
43
+ export type NoteAnchor =
44
+ | { kind: 'over'; from: number; to: number }
45
+ | { kind: 'left'; at: number }
46
+ | { kind: 'right'; at: number }
47
+
48
+ export type SeqItem =
49
+ | {
50
+ kind: 'message'
51
+ from: number
52
+ to: number
53
+ text: string | null
54
+ dashed: boolean
55
+ head: SeqHead
56
+ }
57
+ | { kind: 'note'; anchor: NoteAnchor; text: string }
58
+ | { kind: 'divider'; text: string }
59
+
60
+ /** A hot span of one lifeline: item indices, `to === null` while still open. */
61
+ interface Activation {
62
+ at: number
63
+ from: number
64
+ to: number | null
65
+ }
66
+
67
+ export class Sequence {
68
+ labels: string[] = []
69
+ index = new Map<string, number>()
70
+ items: SeqItem[] = []
71
+ activations: Activation[] = []
72
+ /** Set when a size cap was hit; the parser stops and renders the prefix. */
73
+ truncated: string | null = null
74
+ /** Statements the grammar could not read and dropped. */
75
+ warnings: string[] = []
76
+
77
+ participant(id: string, label: string | null): number | null {
78
+ const existing = this.index.get(id)
79
+ if (existing !== undefined) {
80
+ if (label !== null) this.labels[existing] = label
81
+ return existing
82
+ }
83
+ if (this.labels.length >= MAX_NODES) {
84
+ this.truncated ??= `participant cap (${MAX_NODES}) reached`
85
+ return null
86
+ }
87
+ this.index.set(id, this.labels.length)
88
+ this.labels.push(label ?? id)
89
+ return this.labels.length - 1
90
+ }
91
+
92
+ /** Append an item, or flag `truncated` when the item cap is reached. */
93
+ pushItem(item: SeqItem): void {
94
+ if (this.items.length >= MAX_EDGES) this.truncated ??= `item cap (${MAX_EDGES}) reached`
95
+ else this.items.push(item)
96
+ }
97
+
98
+ /** Record an unreadable statement; skipped once truncated. */
99
+ drop(st: string): void {
100
+ if (this.truncated === null) this.warnings.push(`dropped, unreadable statement: "${st}"`)
101
+ }
102
+
103
+ /** Open an activation at item `from`. */
104
+ activate(at: number, from: number): void {
105
+ this.activations.push({ at, from, to: null })
106
+ }
107
+
108
+ /** Close the innermost open activation of `at` at item `to`. */
109
+ deactivate(at: number, to: number): void {
110
+ for (let i = this.activations.length - 1; i >= 0; i--) {
111
+ const a = this.activations[i]
112
+ if (a.at === at && a.to === null) {
113
+ a.to = to
114
+ return
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ function parseSequence(src: string): Sequence | null {
121
+ const statements = statementsOf(src)
122
+ const kind = headerKind(statements)
123
+ if (kind === null || !sequence.headers.includes(kind)) return null
124
+
125
+ const seq = new Sequence()
126
+ let autonumber = false
127
+ let msgCount = 0
128
+ /** One entry per open block; `true` when it draws a divider on `end`. */
129
+ const blocks: boolean[] = []
130
+
131
+ for (const st of statements.slice(1)) {
132
+ const first = firstWord(st)
133
+ const lower = asciiLower(first)
134
+
135
+ if (lower === 'participant' || lower === 'actor') {
136
+ const rest = st.slice(first.length).trim()
137
+ if (rest === '') {
138
+ seq.drop(st)
139
+ } else {
140
+ const as = splitOnce(rest, ' as ')
141
+ seq.participant(as ? as[0].trim() : rest, as ? cleanLabel(as[1]) : null)
142
+ }
143
+ } else if (lower === 'autonumber') {
144
+ autonumber = true
145
+ } else if (lower === 'activate' || lower === 'deactivate') {
146
+ // Applies to the preceding message's row.
147
+ const who = seq.index.get(st.slice(first.length).trim())
148
+ if (who !== undefined && seq.items.length > 0) {
149
+ if (lower === 'activate') seq.activate(who, seq.items.length - 1)
150
+ else seq.deactivate(who, seq.items.length - 1)
151
+ }
152
+ } else if (
153
+ [
154
+ 'create',
155
+ 'destroy',
156
+ 'title',
157
+ 'acctitle',
158
+ 'accdescr',
159
+ 'links',
160
+ 'link',
161
+ 'properties',
162
+ ].includes(lower)
163
+ ) {
164
+ // No layout meaning.
165
+ } else if (lower === 'note') {
166
+ const note = parseNoteAnchor(st.slice(first.length).trim(), seq)
167
+ if (!note) seq.drop(st)
168
+ else seq.pushItem({ kind: 'note', anchor: note.anchor, text: note.text })
169
+ } else if (
170
+ ['loop', 'alt', 'opt', 'par', 'critical', 'break', 'else', 'and', 'option'].includes(lower)
171
+ ) {
172
+ // A continuation only divides a block that opened one.
173
+ const continues = ['else', 'and', 'option'].includes(lower)
174
+ if (!continues) blocks.push(true)
175
+ if (!continues || blocks.at(-1) === true) {
176
+ seq.pushItem({ kind: 'divider', text: decodeHtmlEntities(st) })
177
+ }
178
+ } else if (lower === 'rect' || lower === 'box') {
179
+ blocks.push(false)
180
+ } else if (lower === 'end') {
181
+ if (blocks.pop() === true) seq.pushItem({ kind: 'divider', text: 'end' })
182
+ } else {
183
+ const msg = parseSeqMessage(st, seq)
184
+ if (!msg) {
185
+ seq.drop(st)
186
+ } else {
187
+ let text = msg.text
188
+ if (autonumber) {
189
+ msgCount++
190
+ text = text === null ? `${msgCount}.` : `${msgCount}. ${text}`
191
+ }
192
+ const item = seq.items.length
193
+ seq.pushItem({
194
+ kind: 'message',
195
+ from: msg.from,
196
+ to: msg.to,
197
+ text,
198
+ dashed: msg.dashed,
199
+ head: msg.head,
200
+ })
201
+ // `+` activates the receiver on this row; `-` deactivates the sender.
202
+ // Only once the item was accepted — an activation pointing past the
203
+ // item cap would dereference a message that does not exist.
204
+ if (seq.items.length === item + 1) {
205
+ if (msg.marks.includes('+')) seq.activate(msg.to, item)
206
+ if (msg.marks.includes('-')) seq.deactivate(msg.from, item)
207
+ }
208
+ }
209
+ }
210
+ if (seq.truncated !== null) {
211
+ seq.warnings.push(`diagram truncated: ${seq.truncated}`)
212
+ break
213
+ }
214
+ }
215
+
216
+ return seq.labels.length === 0 ? null : seq
217
+ }
218
+
219
+ function parseNoteAnchor(rest: string, seq: Sequence): { text: string; anchor: NoteAnchor } | null {
220
+ const lower = asciiLower(rest)
221
+ let kind: NoteAnchor['kind']
222
+ let idsAndText: string
223
+ if (lower.startsWith('over ')) {
224
+ kind = 'over'
225
+ idsAndText = rest.slice('over '.length)
226
+ } else if (lower.startsWith('left of ')) {
227
+ kind = 'left'
228
+ idsAndText = rest.slice('left of '.length)
229
+ } else if (lower.startsWith('right of ')) {
230
+ kind = 'right'
231
+ idsAndText = rest.slice('right of '.length)
232
+ } else {
233
+ return null
234
+ }
235
+
236
+ const split = splitOnce(idsAndText, ':')
237
+ if (!split) return null
238
+ const text = decodeHtmlEntities(split[1].trim())
239
+ const parts = split[0]
240
+ .split(',')
241
+ .map((s) => s.trim())
242
+ .filter((s) => s !== '')
243
+ if (parts.length === 0) return null
244
+ const a = seq.participant(parts[0], null)
245
+ if (a === null) return null
246
+
247
+ if (kind !== 'over') return { text, anchor: { kind, at: a } }
248
+ let b = a
249
+ if (parts[1] !== undefined) {
250
+ const second = seq.participant(parts[1], null)
251
+ if (second === null) return null
252
+ b = second
253
+ }
254
+ return { text, anchor: { kind: 'over', from: Math.min(a, b), to: Math.max(a, b) } }
255
+ }
256
+
257
+ function parseSeqMessage(
258
+ st: string,
259
+ seq: Sequence,
260
+ ): {
261
+ from: number
262
+ to: number
263
+ text: string | null
264
+ dashed: boolean
265
+ head: SeqHead
266
+ marks: string
267
+ } | null {
268
+ const chars = [...st]
269
+ let found: { pos: number; op: string; dashed: boolean; head: SeqHead } | null = null
270
+ outer: for (let pos = 0; pos < chars.length; pos++) {
271
+ const tail = chars.slice(pos, pos + MAX_SEQ_OP).join('')
272
+ for (const [op, dashed, head] of SEQ_OPS) {
273
+ if (tail.startsWith(op)) {
274
+ // `-x` / `-)` embedded in a hyphenated token (`pre-x->>B`) is not an
275
+ // operator — the real one follows. Require a non-link char after.
276
+ const after = chars[pos + op.length]
277
+ if ((op.endsWith('x') || op.endsWith(')')) && (after === '-' || after === '>')) continue
278
+ found = { pos, op, dashed, head }
279
+ break outer
280
+ }
281
+ }
282
+ }
283
+ if (!found) return null
284
+
285
+ const fromId = chars.slice(0, found.pos).join('').trim()
286
+ if (fromId === '') return null
287
+ // `+` activates the receiver, `-` deactivates the sender.
288
+ const afterOp = chars
289
+ .slice(found.pos + [...found.op].length)
290
+ .join('')
291
+ .trimStart()
292
+ const marks = afterOp.match(/^[+-]+/)?.[0] ?? ''
293
+ const rest = afterOp.slice(marks.length)
294
+
295
+ const split = splitOnce(rest, ':')
296
+ const toId = (split ? split[0] : rest).trim()
297
+ const text = split ? nonEmpty(decodeHtmlEntities(split[1].trim())) : null
298
+ if (toId === '') return null
299
+
300
+ const from = seq.participant(fromId, null)
301
+ if (from === null) return null
302
+ const to = seq.participant(toId, null)
303
+ if (to === null) return null
304
+ return { from, to, text, dashed: found.dashed, head: found.head, marks }
305
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `stateDiagram` / `stateDiagram-v2`: states, transitions, descriptions and
3
+ * composite states. Lenient: an unreadable statement is dropped and recorded
4
+ * in `warnings`.
5
+ *
6
+ * A composite (`state X { ... }`) becomes a `Group`, drawn as a titled frame
7
+ * by the same machinery flowchart subgraphs use; `--` splits a composite into
8
+ * unlabelled sibling region groups. `[*]` is scoped per group, so an inner
9
+ * start dot is a different node from the outer one.
10
+ */
11
+
12
+ import { Graph, MAX_GROUP_DEPTH, MAX_GROUPS, parseDir, type Shape } from '../graph.ts'
13
+ import { asciiLower, decodeHtmlEntities } from '../labels.ts'
14
+ import { layoutFlowchart, layoutGrouped } from '../graph-render.ts'
15
+ import type { Diagram } from '../registry.ts'
16
+ import {
17
+ firstWord,
18
+ headerKind,
19
+ nonEmpty,
20
+ parseClassAssign,
21
+ parseClassDef,
22
+ splitColon,
23
+ splitOnce,
24
+ statementsOf,
25
+ takeTags,
26
+ words,
27
+ } from '../statements.ts'
28
+
29
+ export const state: Diagram = {
30
+ kind: 'state',
31
+ headers: ['statediagram', 'statediagram-v2'],
32
+ render(src, limits) {
33
+ const graph = parseState(src)
34
+ if (graph === null) return null
35
+ const canvas = graph.groups.length === 0 ? layoutFlowchart(graph, limits) : layoutGrouped(graph, limits)
36
+ if (canvas === null) return null
37
+ return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
38
+ },
39
+ }
40
+
41
+ function parseState(src: string): Graph | null {
42
+ const statements = statementsOf(src)
43
+ const kind = headerKind(statements)
44
+ if (kind === null || !state.headers.includes(kind)) return null
45
+
46
+ const graph = new Graph()
47
+ let inNote = false
48
+ /** `class A,B name` assignments, applied after the walk. */
49
+ const classAssignments: [string[], string[]][] = []
50
+ /** Open composites: the group itself and its current `--` region, if any. */
51
+ const stack: { base: number; region: number | null }[] = []
52
+
53
+ /** New group under the current scope, or `null` once a cap is hit. */
54
+ const newGroup = (id: string, label: string): number | null => {
55
+ if (graph.groups.length >= MAX_GROUPS || stack.length >= MAX_GROUP_DEPTH) {
56
+ graph.truncated ??= `subgraph cap (${MAX_GROUPS} groups, depth ${MAX_GROUP_DEPTH}) reached`
57
+ return null
58
+ }
59
+ graph.groups.push({ id, label, parent: graph.curGroup })
60
+ return graph.groups.length - 1
61
+ }
62
+ const scopeOf = (): number | null => {
63
+ const top = stack.at(-1)
64
+ return top === undefined ? null : (top.region ?? top.base)
65
+ }
66
+
67
+ for (const st of statements.slice(1)) {
68
+ if (inNote) {
69
+ if (asciiLower(st) === 'end note') inNote = false
70
+ continue
71
+ }
72
+ const first = asciiLower(firstWord(st))
73
+ if (first === 'direction') {
74
+ graph.dir = parseDir(words(st)[1] ?? '')
75
+ } else if (first === 'note') {
76
+ // A single-line `note ... : text` needs no terminator.
77
+ if (!st.includes(':')) inNote = true
78
+ } else if (first === 'state') {
79
+ const rest = st.slice(firstWord(st).length).trim()
80
+ const open = rest.endsWith('{')
81
+ const body = open ? rest.slice(0, -1).trim() : rest
82
+ if (!open) {
83
+ if (parseStateDecl(body, graph) === null) graph.drop(st)
84
+ } else {
85
+ // A composite. An unreadable declaration still opens an anonymous
86
+ // frame: the `{` was consumed, so the `}` balance must hold.
87
+ const named = compositeName(body)
88
+ if (named === null) graph.drop(st)
89
+ const gi = newGroup(named?.id ?? `anon ${graph.groups.length}`, named?.label ?? '')
90
+ if (gi !== null) {
91
+ stack.push({ base: gi, region: null })
92
+ graph.curGroup = gi
93
+ }
94
+ }
95
+ } else if (first === '}') {
96
+ stack.pop()
97
+ graph.curGroup = scopeOf()
98
+ } else if (first === '--') {
99
+ // Region divider: members so far move into region 1 on the first `--`;
100
+ // each divider opens the next unlabelled sibling region.
101
+ const top = stack.at(-1)
102
+ if (top !== undefined) {
103
+ if (top.region === null) {
104
+ const r1 = newGroup(`region ${graph.groups.length}`, '')
105
+ if (r1 !== null) {
106
+ graph.nodeGroup.forEach((g, i) => {
107
+ if (g === top.base) graph.nodeGroup[i] = r1
108
+ })
109
+ graph.groups.forEach((g, i) => {
110
+ if (i !== r1 && g.parent === top.base) g.parent = r1
111
+ })
112
+ }
113
+ }
114
+ graph.curGroup = top.base
115
+ const next = newGroup(`region ${graph.groups.length}`, '')
116
+ top.region = next
117
+ graph.curGroup = next ?? top.base
118
+ }
119
+ } else if (first === 'classdef') {
120
+ const def = parseClassDef(st.slice(firstWord(st).length))
121
+ if (def) for (const name of def.names) graph.classDefs[name] = def.props
122
+ } else if (first === 'class') {
123
+ const assign = parseClassAssign(st.slice(firstWord(st).length))
124
+ if (assign) classAssignments.push(assign)
125
+ } else if (['hide', 'scale'].includes(first)) {
126
+ // Styling directives carry no layout meaning.
127
+ } else if (st.includes('-->')) {
128
+ if (parseTransition(st, graph) === null) graph.drop(st)
129
+ } else if (parseStateDesc(st, graph) === null) {
130
+ graph.drop(st)
131
+ }
132
+ if (graph.truncated !== null) {
133
+ graph.warnings.push(`diagram truncated: ${graph.truncated}`)
134
+ break
135
+ }
136
+ }
137
+
138
+ graph.applyClasses(classAssignments)
139
+
140
+ return graph.nodes.length === 0 ? null : graph
141
+ }
142
+
143
+ /** The id and label of a composite declaration body, or `null`. */
144
+ function compositeName(body: string): { id: string; label: string } | null {
145
+ if (body.startsWith('"')) {
146
+ const close = body.indexOf('"', 1)
147
+ if (close === -1) return null
148
+ const label = decodeHtmlEntities(body.slice(1, close))
149
+ const after = body.slice(close + 1).trim()
150
+ // A `:::` tag on a composite is dropped: groups paint no classed cells.
151
+ const id = takeTags(after.startsWith('as') ? after.slice(2).trim() : label).id
152
+ return id === '' ? null : { id, label }
153
+ }
154
+ // A stereotype on a composite carries no drawing of its own; keep the name.
155
+ const id = takeTags(body.split('<<')[0].trim()).id
156
+ return id === '' || /\s/.test(id) ? null : { id, label: id }
157
+ }
158
+
159
+ /** `state "Label" as id` or `state id <<choice>>` — the non-composite forms. */
160
+ function parseStateDecl(rest: string, graph: Graph): true | null {
161
+ if (rest === '') return true
162
+
163
+ if (rest.startsWith('"')) {
164
+ const close = rest.indexOf('"', 1)
165
+ if (close === -1) return null
166
+ const label = rest.slice(1, close)
167
+ const after = rest.slice(close + 1).trim()
168
+ const { id, classes } = takeTags(after.startsWith('as') ? after.slice(2).trim() : label)
169
+ const idx = graph.nodeLabel(id, decodeHtmlEntities(label))
170
+ if (idx === null) return null
171
+ for (const cls of classes) graph.addClass(idx, cls)
172
+ return true
173
+ }
174
+
175
+ let shape: Shape = 'round'
176
+ let token = rest
177
+ let stereotyped = false
178
+ const pos = rest.indexOf('<<')
179
+ if (pos !== -1) {
180
+ const stereo = rest
181
+ .slice(pos + 2)
182
+ .replace(/>>$/, '')
183
+ .trim()
184
+ if (stereo === 'choice') shape = 'diamond'
185
+ token = rest.slice(0, pos).trim()
186
+ stereotyped = true
187
+ }
188
+ const { id, classes } = takeTags(token)
189
+ if (id === '' || /\s/.test(id)) return null
190
+ const idx = graph.nodeIndex(id, stereotyped ? id : null, shape)
191
+ if (idx === null) return null
192
+ for (const cls of classes) graph.addClass(idx, cls)
193
+ return true
194
+ }
195
+
196
+ /** `A --> B: label`, including chains `A --> B --> C`. */
197
+ function parseTransition(st: string, graph: Graph): true | null {
198
+ let rest = st
199
+ let prev: number | null = null
200
+
201
+ for (;;) {
202
+ const split = splitOnce(rest, '-->')
203
+ if (!split) break
204
+ const [lhs, rhs] = split
205
+
206
+ const fromTok = takeTags(lhs.trimEnd().replace(/-+$/, '').trim())
207
+ let from: number
208
+ if (prev !== null) {
209
+ // Mid-chain: the source is the previous target, so nothing may precede.
210
+ if (fromTok.id !== '') return null
211
+ from = prev
212
+ } else {
213
+ if (fromTok.id === '') return null
214
+ const f = stateEndpoint(graph, fromTok.id, true)
215
+ if (f === null) return null
216
+ for (const cls of fromTok.classes) graph.addClass(f, cls)
217
+ from = f
218
+ }
219
+
220
+ // After the label colon it is all label — mermaid never chains past a
221
+ // label, so an arrow inside one (`: go "x --> y"`) is text, not a link.
222
+ const nextArrow = rhs.indexOf('-->')
223
+ const colon = splitColon(rhs)
224
+ const labelFirst = colon !== null && (nextArrow === -1 || colon[0].length < nextArrow)
225
+ const toPart = labelFirst ? colon[0] : nextArrow === -1 ? rhs : rhs.slice(0, nextArrow)
226
+ const label = labelFirst ? nonEmpty(decodeHtmlEntities(colon[1].trim())) : null
227
+ const tail = labelFirst || nextArrow === -1 ? '' : rhs.slice(nextArrow)
228
+
229
+ const toTok = takeTags(
230
+ toPart.trimStart().replace(/^>+/, '').trimEnd().replace(/-+$/, '').trim(),
231
+ )
232
+ if (toTok.id === '') return null
233
+ const to = stateEndpoint(graph, toTok.id, false)
234
+ if (to === null) return null
235
+ for (const cls of toTok.classes) graph.addClass(to, cls)
236
+
237
+ if (!graph.pushEdge({ from, to, label, headTo: 'arrow', headFrom: 'none', line: 'solid' })) {
238
+ return true
239
+ }
240
+ prev = to
241
+ rest = tail
242
+ }
243
+ return true
244
+ }
245
+
246
+ /**
247
+ * `[*]` is start or end depending on which side of the arrow it sits, and is
248
+ * scoped to the enclosing composite — each frame has its own start and end.
249
+ */
250
+ function stateEndpoint(graph: Graph, id: string, isSource: boolean): number | null {
251
+ if (id === '[*]') {
252
+ const scope = graph.curGroup === null ? '' : ` g${graph.curGroup}`
253
+ return graph.nodeIndex(`[*]${isSource ? 'start' : 'end'}${scope}`, '●', 'round')
254
+ }
255
+ return graph.nodeIndex(id, null, 'round')
256
+ }
257
+
258
+ /** `id: description`, or a bare state name; either may carry `:::` tags. */
259
+ function parseStateDesc(st: string, graph: Graph): true | null {
260
+ const split = splitColon(st)
261
+ const { id, classes } = takeTags((split ? split[0] : st).trim())
262
+ let idx: number | null
263
+ if (split) {
264
+ const desc = split[1].trim()
265
+ if (id === '' || /\s/.test(id) || desc === '') return null
266
+ // Repeated descriptions accumulate (mermaid stacks them as lines; here
267
+ // they join and wrap). A bare node's label is its id — that one replaces.
268
+ const prev = graph.index.get(id)
269
+ const before = prev === undefined ? null : graph.nodes[prev].label
270
+ const text = decodeHtmlEntities(desc)
271
+ idx = graph.nodeLabel(id, before !== null && before !== id ? `${before} ${text}` : text)
272
+ } else {
273
+ if (id === '' || /\s/.test(id)) return null
274
+ idx = graph.nodeIndex(id, null, 'round')
275
+ }
276
+ if (idx === null) return null
277
+ for (const cls of classes) graph.addClass(idx, cls)
278
+ return true
279
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `timeline`: periods and their events as a vertical list — one row per
3
+ * event, the period named on its first row. Lenient: an unreadable statement
4
+ * is dropped and recorded in `warnings`.
5
+ */
6
+
7
+ import { Canvas, drawText } from '../canvas.ts'
8
+ import { MAX_EDGES } from '../graph.ts'
9
+ import { decodeHtmlEntities, fitLabel, type Limits } from '../labels.ts'
10
+ import type { Diagram } from '../registry.ts'
11
+ import { headerKind, nonEmpty, statementsOf, words } from '../statements.ts'
12
+ import { stringWidth } from '../width.ts'
13
+
14
+ /** One output row: a period cell (blank on continuations) and an event. */
15
+ interface Row {
16
+ period: string
17
+ event: string
18
+ /** Section headers occupy a row of their own. */
19
+ section?: boolean
20
+ }
21
+
22
+ export const timeline: Diagram = {
23
+ kind: 'timeline',
24
+ headers: ['timeline'],
25
+ render(src, limits) {
26
+ const parsed = parseTimeline(src, limits)
27
+ if (parsed === null) return null
28
+ const { title, rows, warnings } = parsed
29
+
30
+ const periodW = Math.max(...rows.map((r) => (r.section ? 0 : stringWidth(r.period))))
31
+ const top = title === null ? 0 : 1
32
+ const width = Math.max(
33
+ title === null ? 0 : stringWidth(title) + 6,
34
+ ...rows.map((r) => (r.section ? stringWidth(r.event) : periodW + 3 + stringWidth(r.event))),
35
+ )
36
+ const canvas = new Canvas(width, top + rows.length)
37
+
38
+ if (title !== null) {
39
+ const t = ` ${title} `
40
+ const x = Math.max(0, Math.floor((width - stringWidth(t) - 4) / 2))
41
+ drawText(canvas, '──', x, 0, 'edge')
42
+ drawText(canvas, t, x + 2, 0, 'title')
43
+ drawText(canvas, '──', x + 2 + stringWidth(t), 0, 'edge')
44
+ }
45
+ rows.forEach((row, i) => {
46
+ const y = top + i
47
+ if (row.section) {
48
+ drawText(canvas, row.event, 0, y, 'title')
49
+ return
50
+ }
51
+ drawText(canvas, row.period, 0, y, 'text')
52
+ if (row.event === '') return
53
+ drawText(canvas, '─', periodW + 1, y, 'edge')
54
+ drawText(canvas, row.event, periodW + 3, y, 'edgeLabel')
55
+ })
56
+ return { canvas, warnings, classDefs: {} }
57
+ },
58
+ }
59
+
60
+ function parseTimeline(
61
+ src: string,
62
+ limits: Limits,
63
+ ): { title: string | null; rows: Row[]; warnings: string[] } | null {
64
+ const statements = statementsOf(src)
65
+ if (headerKind(statements) !== 'timeline') return null
66
+
67
+ let title: string | null = null
68
+ const rows: Row[] = []
69
+ const warnings: string[] = []
70
+ let truncated = false
71
+ let lastPeriod = false
72
+
73
+ for (const st of statements.slice(1)) {
74
+ if (rows.length >= MAX_EDGES) {
75
+ truncated = true
76
+ break
77
+ }
78
+ const first = words(st)[0]?.toLowerCase()
79
+ if (first === 'title') {
80
+ title = nonEmpty(st.slice(st.toLowerCase().indexOf('title') + 5).trim())
81
+ continue
82
+ }
83
+ if (first === 'section') {
84
+ const name = st.slice(st.toLowerCase().indexOf('section') + 7).trim()
85
+ rows.push({ period: '', event: clean(name, limits.label), section: true })
86
+ lastPeriod = false
87
+ continue
88
+ }
89
+ // `period : event : event`; a statement of only `: event`s continues the
90
+ // previous period (the `;`-split form of mermaid's multi-line events).
91
+ const parts = st.split(':').map((p) => clean(p, limits.label))
92
+ const period = parts.shift() ?? ''
93
+ if (period === '' && parts.length > 0 && lastPeriod) {
94
+ for (const event of parts) rows.push({ period: '', event })
95
+ continue
96
+ }
97
+ // A bare period renders event-less, as mermaid draws it.
98
+ if (period !== '' && parts.length === 0) {
99
+ rows.push({ period, event: '' })
100
+ lastPeriod = true
101
+ continue
102
+ }
103
+ if (period === '' || parts.some((p) => p === '')) {
104
+ warnings.push(`dropped, unreadable statement: "${st}"`)
105
+ continue
106
+ }
107
+ parts.forEach((event, i) => {
108
+ rows.push({ period: i === 0 ? period : '', event })
109
+ })
110
+ lastPeriod = true
111
+ }
112
+ if (truncated) warnings.push(`diagram truncated: event cap (${MAX_EDGES}) reached`)
113
+
114
+ return rows.length === 0 ? null : { title, rows, warnings }
115
+ }
116
+
117
+ const clean = (s: string, max: number): string => fitLabel(decodeHtmlEntities(s.trim()), max)