@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,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
+ }