@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,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `classDiagram`: classes with member compartments and UML relations.
|
|
3
|
+
* Lenient: an unreadable statement is dropped and recorded in `warnings`.
|
|
4
|
+
*
|
|
5
|
+
* Compartments live on `Node.sections`: `[title, attrs, methods]`, where the
|
|
6
|
+
* title is the optional `«annotation»` line followed by the class name.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Graph, type Head, type LineKind, MAX_MEMBERS, type Node, parseDir } from '../graph.ts'
|
|
10
|
+
import { asciiLower, cleanLabel, decodeHtmlEntities, displayGenerics, isIdChar } from '../labels.ts'
|
|
11
|
+
import { layoutClass } from '../graph-render.ts'
|
|
12
|
+
import type { Diagram } from '../registry.ts'
|
|
13
|
+
import {
|
|
14
|
+
firstWord,
|
|
15
|
+
headerKind,
|
|
16
|
+
nonEmpty,
|
|
17
|
+
parseClassAssign,
|
|
18
|
+
parseClassDef,
|
|
19
|
+
parseHref,
|
|
20
|
+
quoteMask,
|
|
21
|
+
splitColon,
|
|
22
|
+
splitOnce,
|
|
23
|
+
statementsOf,
|
|
24
|
+
takeTags,
|
|
25
|
+
words,
|
|
26
|
+
} from '../statements.ts'
|
|
27
|
+
|
|
28
|
+
export const classDiagram: Diagram = {
|
|
29
|
+
kind: 'class',
|
|
30
|
+
headers: ['classdiagram', 'classdiagram-v2'],
|
|
31
|
+
render(src, limits) {
|
|
32
|
+
const graph = parseClass(src)
|
|
33
|
+
if (graph === null) return null
|
|
34
|
+
const canvas = layoutClass(graph, limits)
|
|
35
|
+
if (canvas === null) return null
|
|
36
|
+
return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Relation operators, longest-first so `--|>` wins over `--`. */
|
|
41
|
+
const CLASS_OPS: [string, Head, Head, LineKind][] = [
|
|
42
|
+
['<|--', 'triangle', 'none', 'solid'],
|
|
43
|
+
['--|>', 'none', 'triangle', 'solid'],
|
|
44
|
+
['<|..', 'triangle', 'none', 'dotted'],
|
|
45
|
+
['..|>', 'none', 'triangle', 'dotted'],
|
|
46
|
+
['*--', 'diamondFill', 'none', 'solid'],
|
|
47
|
+
['--*', 'none', 'diamondFill', 'solid'],
|
|
48
|
+
['o--', 'diamondOpen', 'none', 'solid'],
|
|
49
|
+
['--o', 'none', 'diamondOpen', 'solid'],
|
|
50
|
+
['<--', 'arrow', 'none', 'solid'],
|
|
51
|
+
['-->', 'none', 'arrow', 'solid'],
|
|
52
|
+
['<..', 'arrow', 'none', 'dotted'],
|
|
53
|
+
['..>', 'none', 'arrow', 'dotted'],
|
|
54
|
+
['--', 'none', 'none', 'solid'],
|
|
55
|
+
['..', 'none', 'none', 'dotted'],
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const MAX_CLASS_OP = 4
|
|
59
|
+
|
|
60
|
+
function parseClass(src: string): Graph | null {
|
|
61
|
+
const statements = statementsOf(src)
|
|
62
|
+
const kind = headerKind(statements)
|
|
63
|
+
if (kind === null || !classDiagram.headers.includes(kind)) return null
|
|
64
|
+
|
|
65
|
+
const graph = new Graph()
|
|
66
|
+
/** Declare a class from an id token, attaching any `:::` tags it carries. */
|
|
67
|
+
const declare = (token: string): number | null => {
|
|
68
|
+
const { id, classes } = takeTags(token)
|
|
69
|
+
const idx = graph.nodeIndex(id, null, 'rect')
|
|
70
|
+
if (idx !== null) {
|
|
71
|
+
graph.nodes[idx].sections ??= [[displayGenerics(id)], [], []]
|
|
72
|
+
for (const cls of classes) graph.addClass(idx, cls)
|
|
73
|
+
}
|
|
74
|
+
return idx
|
|
75
|
+
}
|
|
76
|
+
/** The open `{` body: a class index, `'skip'` for a dropped class, or none. */
|
|
77
|
+
let curClass: number | 'skip' | null = null
|
|
78
|
+
/** `class A,B name` / `cssClass "A,B" name` assignments, applied post-walk. */
|
|
79
|
+
const classAssignments: [string[], string[]][] = []
|
|
80
|
+
/** `link A "url"` / `click A href "url"` targets, applied post-walk. */
|
|
81
|
+
const hrefs: [string, string][] = []
|
|
82
|
+
|
|
83
|
+
for (const st of statements.slice(1)) {
|
|
84
|
+
if (curClass !== null) {
|
|
85
|
+
if (st === '}') curClass = null
|
|
86
|
+
else if (curClass !== 'skip') pushMember(graph.nodes[curClass], st)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const first = asciiLower(firstWord(st))
|
|
91
|
+
if (first === 'direction') {
|
|
92
|
+
graph.dir = parseDir(words(st)[1] ?? '')
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (first === 'classdef') {
|
|
96
|
+
const def = parseClassDef(st.slice(firstWord(st).length))
|
|
97
|
+
if (def) for (const name of def.names) graph.classDefs[name] = def.props
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (['note', 'callback', 'style', 'namespace', '}'].includes(first)) {
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (first === 'link' || first === 'click') {
|
|
104
|
+
const target = parseHref(st.slice(firstWord(st).length))
|
|
105
|
+
if (target) hrefs.push(target)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (first === 'cssclass') {
|
|
109
|
+
const assign = parseClassAssign(st.slice(firstWord(st).length).replace(/"/g, ''))
|
|
110
|
+
if (assign) classAssignments.push(assign)
|
|
111
|
+
else graph.drop(st)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (first === 'class') {
|
|
115
|
+
const rest = st.slice('class'.length).trim()
|
|
116
|
+
const open = rest.endsWith('{')
|
|
117
|
+
let name = open ? rest.slice(0, -1).trim() : rest
|
|
118
|
+
// `class A["Label"]` (mermaid ≥10.1): the label titles the box, the id
|
|
119
|
+
// keys relations. Peel it before the space test — labels carry spaces.
|
|
120
|
+
let label: string | null = null
|
|
121
|
+
const labeled = /^(\S+?)\[(.+)\](:::\S+)?$/.exec(name)
|
|
122
|
+
if (labeled) {
|
|
123
|
+
name = labeled[1] + (labeled[3] ?? '')
|
|
124
|
+
label = nonEmpty(cleanLabel(labeled[2]))
|
|
125
|
+
}
|
|
126
|
+
if (!open && /\s/.test(name)) {
|
|
127
|
+
// Class names carry no spaces, so `class Agent focus` is the
|
|
128
|
+
// assignment form, as in flowcharts.
|
|
129
|
+
const assign = parseClassAssign(name)
|
|
130
|
+
if (assign) classAssignments.push(assign)
|
|
131
|
+
else graph.drop(st)
|
|
132
|
+
} else if (name === '' || /\s/.test(name)) {
|
|
133
|
+
// A bad declaration that opened a body swallows it whole; reading the
|
|
134
|
+
// members as top-level statements would misparse everything inside.
|
|
135
|
+
graph.drop(st)
|
|
136
|
+
if (open) curClass = 'skip'
|
|
137
|
+
} else {
|
|
138
|
+
const idx = declare(name)
|
|
139
|
+
if (idx !== null && label !== null) {
|
|
140
|
+
const node = graph.nodes[idx]
|
|
141
|
+
node.label = label
|
|
142
|
+
// The name is the last title line (an annotation may precede it).
|
|
143
|
+
const title = node.sections?.[0]
|
|
144
|
+
if (title) title[title.length - 1] = label
|
|
145
|
+
}
|
|
146
|
+
if (open) curClass = idx ?? 'skip'
|
|
147
|
+
}
|
|
148
|
+
} else if (st.startsWith('<<')) {
|
|
149
|
+
const split = splitOnce(st.slice(2), '>>')
|
|
150
|
+
const name = split ? split[1].trim() : ''
|
|
151
|
+
if (split === null || name === '' || /\s/.test(name)) {
|
|
152
|
+
graph.drop(st)
|
|
153
|
+
} else {
|
|
154
|
+
const idx = declare(name)
|
|
155
|
+
if (idx !== null) setAnnotation(graph.nodes[idx], split[0].trim())
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
const rel = parseClassRelation(st)
|
|
159
|
+
if (rel !== null) {
|
|
160
|
+
const f = declare(rel.from)
|
|
161
|
+
const t = f === null ? null : declare(rel.to)
|
|
162
|
+
if (f !== null && t !== null) {
|
|
163
|
+
graph.pushEdge({
|
|
164
|
+
from: f,
|
|
165
|
+
to: t,
|
|
166
|
+
label: rel.label,
|
|
167
|
+
cardFrom: rel.cardFrom,
|
|
168
|
+
cardTo: rel.cardTo,
|
|
169
|
+
headTo: rel.headTo,
|
|
170
|
+
headFrom: rel.headFrom,
|
|
171
|
+
line: rel.line,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
const member = splitColon(st)
|
|
176
|
+
const id = member ? member[0].trim() : ''
|
|
177
|
+
const text = member ? member[1].trim() : ''
|
|
178
|
+
if (member === null || id === '' || /\s/.test(id) || text === '') {
|
|
179
|
+
graph.drop(st)
|
|
180
|
+
} else {
|
|
181
|
+
const idx = declare(id)
|
|
182
|
+
if (idx !== null) pushMember(graph.nodes[idx], text)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (graph.truncated !== null) {
|
|
187
|
+
graph.warnings.push(`diagram truncated: ${graph.truncated}`)
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
graph.applyClasses(classAssignments)
|
|
193
|
+
graph.applyHrefs(hrefs)
|
|
194
|
+
|
|
195
|
+
return graph.nodes.length === 0 ? null : graph
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Rewrite the title compartment as `«annotation»` over the class name. */
|
|
199
|
+
function setAnnotation(node: Node, annotation: string): void {
|
|
200
|
+
;(node.sections as string[][])[0] = [`«${annotation}»`, displayGenerics(node.label)]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Add a member to the attribute or method compartment, eliding past the cap. */
|
|
204
|
+
function pushMember(node: Node, raw: string): void {
|
|
205
|
+
const sections = node.sections as string[][]
|
|
206
|
+
if (raw.startsWith('<<')) {
|
|
207
|
+
const split = splitOnce(raw.slice(2), '>>')
|
|
208
|
+
if (split) setAnnotation(node, split[0].trim())
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
const member = decodeHtmlEntities(displayGenerics(raw.trim()))
|
|
212
|
+
const list = member.includes('(') ? sections[2] : sections[1]
|
|
213
|
+
if (list.length < MAX_MEMBERS) list.push(member)
|
|
214
|
+
else if (list.length === MAX_MEMBERS) list.push('…')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface ClassRelation {
|
|
218
|
+
from: string
|
|
219
|
+
to: string
|
|
220
|
+
headFrom: Head
|
|
221
|
+
headTo: Head
|
|
222
|
+
line: LineKind
|
|
223
|
+
label: string | null
|
|
224
|
+
cardFrom?: string
|
|
225
|
+
cardTo?: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseClassRelation(st: string): ClassRelation | null {
|
|
229
|
+
const chars = [...st]
|
|
230
|
+
// Skip quoted spans, or the `..` inside a cardinality like `"0..*"` would
|
|
231
|
+
// match the dotted-link operator.
|
|
232
|
+
const quoted = quoteMask(chars)
|
|
233
|
+
let found: { pos: number; op: string; headFrom: Head; headTo: Head; line: LineKind } | null = null
|
|
234
|
+
|
|
235
|
+
outer: for (let pos = 0; pos < chars.length; pos++) {
|
|
236
|
+
if (quoted[pos]) continue
|
|
237
|
+
const tail = chars.slice(pos, pos + MAX_CLASS_OP).join('')
|
|
238
|
+
for (const [op, headFrom, headTo, line] of CLASS_OPS) {
|
|
239
|
+
if (!tail.startsWith(op)) continue
|
|
240
|
+
// `o` is also an identifier character: skip a match glued to a name.
|
|
241
|
+
if (op.startsWith('o') && pos > 0 && isIdChar(chars[pos - 1])) continue
|
|
242
|
+
const after = chars[pos + [...op].length]
|
|
243
|
+
if (op.endsWith('o') && after !== undefined && isIdChar(after)) continue
|
|
244
|
+
found = { pos, op, headFrom, headTo, line }
|
|
245
|
+
break outer
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!found) return null
|
|
249
|
+
|
|
250
|
+
const lhsRaw = chars.slice(0, found.pos).join('').trim()
|
|
251
|
+
const rhsRaw = chars
|
|
252
|
+
.slice(found.pos + [...found.op].length)
|
|
253
|
+
.join('')
|
|
254
|
+
.trim()
|
|
255
|
+
|
|
256
|
+
const [lhs, cardFrom] = stripCardinalitySuffix(lhsRaw)
|
|
257
|
+
const [rhs, cardTo] = stripCardinalityPrefix(rhsRaw)
|
|
258
|
+
|
|
259
|
+
const split = splitColon(rhs)
|
|
260
|
+
const toId = (split ? split[0] : rhs).trim()
|
|
261
|
+
const relLabel = split ? nonEmpty(decodeHtmlEntities(split[1].trim())) : null
|
|
262
|
+
|
|
263
|
+
if (lhs === '' || toId === '' || /\s/.test(lhs) || /\s/.test(toId)) return null
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
from: lhs,
|
|
267
|
+
to: toId,
|
|
268
|
+
headFrom: found.headFrom,
|
|
269
|
+
headTo: found.headTo,
|
|
270
|
+
line: found.line,
|
|
271
|
+
label: relLabel,
|
|
272
|
+
cardFrom: cardFrom === '' ? undefined : cardFrom,
|
|
273
|
+
cardTo: cardTo === '' ? undefined : cardTo,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `Class "1"` — a quoted cardinality trailing the left-hand name. */
|
|
278
|
+
function stripCardinalitySuffix(s: string): [string, string] {
|
|
279
|
+
const t = s.trimEnd()
|
|
280
|
+
if (t.endsWith('"')) {
|
|
281
|
+
const rest = t.slice(0, -1)
|
|
282
|
+
const q = rest.lastIndexOf('"')
|
|
283
|
+
if (q !== -1) return [rest.slice(0, q).trimEnd(), rest.slice(q + 1)]
|
|
284
|
+
}
|
|
285
|
+
return [t, '']
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** `"0..*" Class` — a quoted cardinality leading the right-hand name. */
|
|
289
|
+
function stripCardinalityPrefix(s: string): [string, string] {
|
|
290
|
+
const t = s.trimStart()
|
|
291
|
+
if (t.startsWith('"')) {
|
|
292
|
+
const rest = t.slice(1)
|
|
293
|
+
const q = rest.indexOf('"')
|
|
294
|
+
if (q !== -1) return [rest.slice(q + 1).trimStart(), rest.slice(0, q)]
|
|
295
|
+
}
|
|
296
|
+
return [t, '']
|
|
297
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `erDiagram`: entities with attribute compartments and crow's-foot
|
|
3
|
+
* relationships. Lenient: an unreadable statement is dropped and recorded in
|
|
4
|
+
* `warnings`.
|
|
5
|
+
*
|
|
6
|
+
* Entities use `Node.sections` as `[title, attrs]`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Graph, type LineKind, MAX_MEMBERS, type Node } from '../graph.ts'
|
|
10
|
+
import { cleanLabel, decodeHtmlEntities, displayGenerics } from '../labels.ts'
|
|
11
|
+
import { layoutClass } from '../graph-render.ts'
|
|
12
|
+
import type { Diagram } from '../registry.ts'
|
|
13
|
+
import { headerKind, nonEmpty, splitOnce, statementsOf, words } from '../statements.ts'
|
|
14
|
+
|
|
15
|
+
export const er: Diagram = {
|
|
16
|
+
kind: 'er',
|
|
17
|
+
headers: ['erdiagram'],
|
|
18
|
+
render(src, limits) {
|
|
19
|
+
const graph = parseEr(src)
|
|
20
|
+
if (graph === null) return null
|
|
21
|
+
const canvas = layoutClass(graph, limits)
|
|
22
|
+
if (canvas === null) return null
|
|
23
|
+
return { canvas, warnings: graph.warnings, classDefs: graph.classDefs }
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseEr(src: string): Graph | null {
|
|
28
|
+
const statements = statementsOf(src)
|
|
29
|
+
const kind = headerKind(statements)
|
|
30
|
+
if (kind === null || !er.headers.includes(kind)) return null
|
|
31
|
+
|
|
32
|
+
const graph = new Graph()
|
|
33
|
+
/** The open `{` body: an entity index, `'skip'` for a dropped one, or none. */
|
|
34
|
+
let curEntity: number | 'skip' | null = null
|
|
35
|
+
|
|
36
|
+
for (const st of statements.slice(1)) {
|
|
37
|
+
if (curEntity !== null) {
|
|
38
|
+
if (st === '}') curEntity = null
|
|
39
|
+
else if (curEntity !== 'skip') pushErAttribute(graph.nodes[curEntity], st)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rel = splitErRelationship(st)
|
|
44
|
+
if (rel) {
|
|
45
|
+
const tokens = erTokens(rel.rel)
|
|
46
|
+
const op = tokens.length === 3 ? parseErOp(tokens[1]) : null
|
|
47
|
+
const f = op === null ? null : erEntity(graph, tokens[0])
|
|
48
|
+
const t = f === null ? null : erEntity(graph, tokens[2])
|
|
49
|
+
if (op === null || f === null || t === null) {
|
|
50
|
+
graph.drop(st)
|
|
51
|
+
} else {
|
|
52
|
+
const relLabel = rel.label === null ? '' : cleanLabel(rel.label)
|
|
53
|
+
graph.pushEdge({
|
|
54
|
+
from: f,
|
|
55
|
+
to: t,
|
|
56
|
+
label: nonEmpty(relLabel),
|
|
57
|
+
cardFrom: op.cardL,
|
|
58
|
+
cardTo: op.cardR,
|
|
59
|
+
headTo: 'none',
|
|
60
|
+
headFrom: 'none',
|
|
61
|
+
line: op.line,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const open = st.endsWith('{')
|
|
66
|
+
const decl = open ? st.slice(0, -1).trim() : st
|
|
67
|
+
if (decl === '' || erTokens(decl).length !== 1) {
|
|
68
|
+
// A bad declaration that opened a body swallows it whole; reading the
|
|
69
|
+
// attributes as top-level statements would misparse everything inside.
|
|
70
|
+
graph.drop(st)
|
|
71
|
+
if (open) curEntity = 'skip'
|
|
72
|
+
} else {
|
|
73
|
+
const idx = erEntity(graph, decl)
|
|
74
|
+
if (open) curEntity = idx ?? 'skip'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (graph.truncated !== null) {
|
|
78
|
+
graph.warnings.push(`diagram truncated: ${graph.truncated}`)
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return graph.nodes.length === 0 ? null : graph
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve an entity token (`NAME`, `"Quoted Name"` or `id[Label]`), keeping
|
|
87
|
+
* its title row fresh. A quoted name is its own identity; the quotes are not
|
|
88
|
+
* part of the title. */
|
|
89
|
+
function erEntity(graph: Graph, token: string): number | null {
|
|
90
|
+
const open = token.startsWith('"') ? -1 : token.indexOf('[')
|
|
91
|
+
let idx: number | null
|
|
92
|
+
if (open !== -1) {
|
|
93
|
+
const id = token.slice(0, open)
|
|
94
|
+
if (!token.endsWith(']')) {
|
|
95
|
+
graph.warnings.push(`entity "${id}": alias is missing its closing \`]\``)
|
|
96
|
+
}
|
|
97
|
+
const label = cleanLabel(token.slice(open + 1).replace(/\]+$/, ''))
|
|
98
|
+
if (id === '' || label === '') return null
|
|
99
|
+
idx = graph.nodeLabel(id, label)
|
|
100
|
+
} else if (token.startsWith('"')) {
|
|
101
|
+
const label = cleanLabel(token)
|
|
102
|
+
if (label === '') return null
|
|
103
|
+
idx = graph.nodeIndex(token, label, 'rect')
|
|
104
|
+
} else {
|
|
105
|
+
idx = graph.nodeIndex(token, null, 'rect')
|
|
106
|
+
}
|
|
107
|
+
if (idx === null) return null
|
|
108
|
+
const node = graph.nodes[idx]
|
|
109
|
+
node.sections ??= [[], []]
|
|
110
|
+
node.sections[0] = [displayGenerics(node.label)]
|
|
111
|
+
return idx
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whitespace tokens, quoted spans kept whole so aliases may contain spaces. */
|
|
115
|
+
function erTokens(s: string): string[] {
|
|
116
|
+
const out: string[] = []
|
|
117
|
+
let cur = ''
|
|
118
|
+
let inQuotes = false
|
|
119
|
+
for (const c of s) {
|
|
120
|
+
if (c === '"') {
|
|
121
|
+
inQuotes = !inQuotes
|
|
122
|
+
cur += c
|
|
123
|
+
} else if (!inQuotes && /\s/.test(c)) {
|
|
124
|
+
if (cur !== '') {
|
|
125
|
+
out.push(cur)
|
|
126
|
+
cur = ''
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
cur += c
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (cur !== '') out.push(cur)
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitErRelationship(st: string): { rel: string; label: string | null } | null {
|
|
137
|
+
const split = splitOnce(st, ':')
|
|
138
|
+
const rel = split ? split[0] : st
|
|
139
|
+
const label = split ? split[1].trim() : null
|
|
140
|
+
return erTokens(rel).some((t) => parseErOp(t) !== null) ? { rel, label } : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const isAscii = (s: string): boolean => {
|
|
144
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A crow's-foot operator: two cardinality glyphs around `--` or `..`. */
|
|
149
|
+
function parseErOp(tok: string): { cardL: string; cardR: string; line: LineKind } | null {
|
|
150
|
+
if (tok.length !== 6 || !isAscii(tok)) return null
|
|
151
|
+
const mid = tok.slice(2, 4)
|
|
152
|
+
const line: LineKind | null = mid === '--' ? 'solid' : mid === '..' ? 'dotted' : null
|
|
153
|
+
if (line === null) return null
|
|
154
|
+
const cardL = erCard(tok.slice(0, 2))
|
|
155
|
+
const cardR = erCard(tok.slice(4, 6))
|
|
156
|
+
return cardL === null || cardR === null ? null : { cardL, cardR, line }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function erCard(tok: string): string | null {
|
|
160
|
+
switch (tok) {
|
|
161
|
+
case '|o':
|
|
162
|
+
case 'o|':
|
|
163
|
+
return '0..1'
|
|
164
|
+
case '||':
|
|
165
|
+
return '1'
|
|
166
|
+
case '}o':
|
|
167
|
+
case 'o{':
|
|
168
|
+
return '*'
|
|
169
|
+
case '}|':
|
|
170
|
+
case '|{':
|
|
171
|
+
return '1..*'
|
|
172
|
+
default:
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** ER attributes are `type name`; a trailing quoted comment is dropped. */
|
|
178
|
+
function pushErAttribute(node: Node, raw: string): void {
|
|
179
|
+
const attrs = (node.sections as string[][])[1]
|
|
180
|
+
const parts: string[] = []
|
|
181
|
+
for (const tok of words(raw)) {
|
|
182
|
+
if (tok.startsWith('"')) break
|
|
183
|
+
parts.push(tok)
|
|
184
|
+
}
|
|
185
|
+
if (parts.length === 0) return
|
|
186
|
+
const line = decodeHtmlEntities(parts.join(' '))
|
|
187
|
+
if (attrs.length < MAX_MEMBERS) attrs.push(line)
|
|
188
|
+
else if (attrs.length === MAX_MEMBERS) attrs.push('…')
|
|
189
|
+
}
|