@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.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/index.ts +1 -0
- package/package.json +57 -0
- package/src/index.ts +103 -0
- package/src/loom-mermaid/ansi.ts +65 -0
- package/src/loom-mermaid/canvas.ts +466 -0
- package/src/loom-mermaid/class-style.ts +65 -0
- package/src/loom-mermaid/css-colors.ts +153 -0
- package/src/loom-mermaid/diagrams/class.ts +297 -0
- package/src/loom-mermaid/diagrams/er.ts +189 -0
- package/src/loom-mermaid/diagrams/flowchart.ts +519 -0
- package/src/loom-mermaid/diagrams/gitgraph.ts +230 -0
- package/src/loom-mermaid/diagrams/mindmap.ts +107 -0
- package/src/loom-mermaid/diagrams/pie.ts +121 -0
- package/src/loom-mermaid/diagrams/sequence.ts +305 -0
- package/src/loom-mermaid/diagrams/state.ts +279 -0
- package/src/loom-mermaid/diagrams/timeline.ts +117 -0
- package/src/loom-mermaid/graph-render.ts +195 -0
- package/src/loom-mermaid/graph.ts +205 -0
- package/src/loom-mermaid/index.ts +78 -0
- package/src/loom-mermaid/labels.ts +353 -0
- package/src/loom-mermaid/layout-seq.ts +234 -0
- package/src/loom-mermaid/layout.ts +1749 -0
- package/src/loom-mermaid/paint.ts +325 -0
- package/src/loom-mermaid/placement.ts +255 -0
- package/src/loom-mermaid/registry.ts +88 -0
- package/src/loom-mermaid/source-box.ts +111 -0
- package/src/loom-mermaid/statements.ts +228 -0
- package/src/loom-mermaid/types.ts +64 -0
- package/src/loom-mermaid/width-data.ts +993 -0
- package/src/loom-mermaid/width.ts +69 -0
|
@@ -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
|
+
}
|