@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,111 @@
1
+ /**
2
+ * The raw source in a framed box.
3
+ *
4
+ * What to show when `render` returns `null`, or returns art too wide for the
5
+ * space at hand. Both are the caller's call, so this is theirs to invoke — and
6
+ * theirs to caption, since only they know whether some other view of the
7
+ * diagram exists to point the reader at.
8
+ */
9
+
10
+ import { srcLines, stripControls } from './labels.ts'
11
+ import { sat } from './layout.ts'
12
+ import type { MermaidArt, Span } from './types.ts'
13
+ import { measured, stringWidth } from './width.ts'
14
+
15
+ /**
16
+ * Frame `src` in a titled box, hard-wrapping its lines to `maxWidth` columns.
17
+ *
18
+ * The result can still exceed `maxWidth`: the body wraps to
19
+ * `max(8, maxWidth - 4)` and the ` mermaid: <kind> ` title is never truncated,
20
+ * so a long first token sets a floor. Check `width` if it matters.
21
+ */
22
+ export function sourceBox(src: string, maxWidth?: number): MermaidArt {
23
+ src = stripControls(src)
24
+ const header = src.split(/\s+/).filter((w) => w !== '')[0] ?? 'diagram'
25
+ const title = ` mermaid: ${header} `
26
+ const limit = maxWidth === undefined ? undefined : Math.max(8, sat(maxWidth, 4))
27
+
28
+ const body = srcLines(src)
29
+ .map((l) => expandTabs(l).replace(/\s+$/, ''))
30
+ .reduce<{ started: boolean; lines: string[] }>(
31
+ (acc, l) => {
32
+ if (!acc.started && l === '') return acc
33
+ acc.started = true
34
+ acc.lines.push(...chunkLine(l, limit))
35
+ return acc
36
+ },
37
+ { started: false, lines: [] },
38
+ ).lines
39
+
40
+ // reduce, not a spread: a spread of stringWidths overflows the arg limit
41
+ // on very large sources.
42
+ const contentW = body.reduce((w, l) => Math.max(w, stringWidth(l)), stringWidth(title))
43
+ const inner = contentW + 2
44
+
45
+ const plain: string[] = []
46
+ const styled: Span[][] = []
47
+
48
+ const rule = '─'.repeat(sat(inner, stringWidth(title)))
49
+ plain.push(`╭${title}${rule}╮`)
50
+ styled.push([
51
+ { text: '╭', role: 'border' },
52
+ { text: title, role: 'title' },
53
+ { text: `${rule}╮`, role: 'border' },
54
+ ])
55
+
56
+ for (const line of body) {
57
+ const pad = ' '.repeat(sat(contentW, stringWidth(line)))
58
+ plain.push(`│ ${line}${pad} │`)
59
+ styled.push([
60
+ { text: '│ ', role: 'border' },
61
+ { text: line, role: 'text' },
62
+ { text: `${pad} │`, role: 'border' },
63
+ ])
64
+ }
65
+
66
+ const bottom = `╰${'─'.repeat(inner)}╯`
67
+ plain.push(bottom)
68
+ styled.push([{ text: bottom, role: 'border' }])
69
+
70
+ return { plain, styled, width: inner + 2, classDefs: {}, warnings: [] }
71
+ }
72
+
73
+ /**
74
+ * Expand tabs to 4-column stops. A literal tab measures one column here but
75
+ * whatever the terminal's tab stops say there, so the frame would misalign.
76
+ */
77
+ function expandTabs(line: string): string {
78
+ if (!line.includes('\t')) return line
79
+ let out = ''
80
+ let col = 0
81
+ for (const [c, cw] of measured(line)) {
82
+ if (c === '\t') {
83
+ const pad = 4 - (col % 4)
84
+ out += ' '.repeat(pad)
85
+ col += pad
86
+ } else {
87
+ out += c
88
+ col += cw
89
+ }
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** Hard-break a line at `limit` columns, never splitting a wide glyph. */
95
+ function chunkLine(line: string, limit: number | undefined): string[] {
96
+ if (limit === undefined || stringWidth(line) <= limit) return [line]
97
+ const out: string[] = []
98
+ let cur = ''
99
+ let curW = 0
100
+ for (const [c, cw] of measured(line)) {
101
+ if (curW + cw > limit && cur !== '') {
102
+ out.push(cur)
103
+ cur = ''
104
+ curW = 0
105
+ }
106
+ cur += c
107
+ curW += cw
108
+ }
109
+ if (cur !== '') out.push(cur)
110
+ return out
111
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The shared statement layer: source text to statements, plus the small
3
+ * string-reading helpers every grammar leans on.
4
+ */
5
+
6
+ import { asciiLower, srcLines } from './labels.ts'
7
+
8
+ function flushStatement(cur: string, out: string[]): string {
9
+ const trimmed = cur.trim()
10
+ if (trimmed !== '') out.push(trimmed)
11
+ return ''
12
+ }
13
+
14
+ /**
15
+ * Split one source line into statements on `;`, stopping at a `%%` comment.
16
+ *
17
+ * Quoted spans are opaque, so a label may contain `;` and `%%`.
18
+ */
19
+ function splitStatements(line: string, out: string[]): void {
20
+ const chars = [...line]
21
+ let cur = ''
22
+ let inQuotes = false
23
+ for (let i = 0; i < chars.length; i++) {
24
+ const c = chars[i]
25
+ if (inQuotes) {
26
+ if (c === '"') inQuotes = false
27
+ cur += c
28
+ } else if (c === '"') {
29
+ inQuotes = true
30
+ cur += c
31
+ } else if (c === '%' && chars[i + 1] === '%') {
32
+ break
33
+ } else if (c === ';') {
34
+ cur = flushStatement(cur, out)
35
+ } else {
36
+ cur += c
37
+ }
38
+ }
39
+ flushStatement(cur, out)
40
+ }
41
+
42
+ /**
43
+ * Index just past a leading YAML frontmatter block (`---` … `---`), or 0 when
44
+ * there is none. While the block is still unterminated everything is
45
+ * frontmatter, so a streamed diagram stays blank until it closes.
46
+ */
47
+ export function frontmatterEnd(lines: string[]): number {
48
+ let i = 0
49
+ while (i < lines.length && lines[i].trim() === '') i++
50
+ if (lines[i]?.trim() !== '---') return 0
51
+ i++
52
+ while (i < lines.length && lines[i].trim() !== '---') i++
53
+ return i + 1
54
+ }
55
+
56
+ /**
57
+ * All statements in a source block, in order. A leading YAML frontmatter
58
+ * block, part of the mermaid grammar since v10, is skipped.
59
+ */
60
+ export function statementsOf(src: string): string[] {
61
+ const lines = srcLines(src)
62
+ const out: string[] = []
63
+ for (const line of lines.slice(frontmatterEnd(lines))) splitStatements(line, out)
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * The `title:` of a leading frontmatter block, or null. The one frontmatter
69
+ * key with terminal meaning — `config` and friends style mermaid's own
70
+ * renderers and are deliberately ignored.
71
+ */
72
+ export function frontmatterTitle(src: string): string | null {
73
+ const lines = srcLines(src)
74
+ const end = frontmatterEnd(lines)
75
+ for (const line of lines.slice(0, end)) {
76
+ const kv = splitOnce(line, ':')
77
+ // Untrimmed on the left: an indented `title:` is nested under some other
78
+ // key, not the diagram's.
79
+ if (kv === null || kv[0].trimEnd() !== 'title') continue
80
+ const t = kv[1].trim()
81
+ const quoted =
82
+ t.length > 1 &&
83
+ ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))
84
+ const title = (quoted ? t.slice(1, -1) : t).trim()
85
+ return title === '' ? null : title
86
+ }
87
+ return null
88
+ }
89
+
90
+ /** Per-char flags: 1 where the char lies inside a double-quoted span (quotes included). */
91
+ export function quoteMask(chars: string[]): Uint8Array {
92
+ const mask = new Uint8Array(chars.length)
93
+ let inQuotes = false
94
+ for (let i = 0; i < chars.length; i++) {
95
+ if (chars[i] === '"') {
96
+ mask[i] = 1
97
+ inQuotes = !inQuotes
98
+ } else if (inQuotes) {
99
+ mask[i] = 1
100
+ }
101
+ }
102
+ return mask
103
+ }
104
+
105
+ /**
106
+ * Split on separator chars sitting outside double quotes and parentheses,
107
+ * dropping empty segments — the one splitter every grammar shares, so quote
108
+ * rules cannot drift between them.
109
+ */
110
+ export function splitTop(s: string, isSep: (c: string) => boolean): string[] {
111
+ const out: string[] = []
112
+ let cur = ''
113
+ let inQuotes = false
114
+ let depth = 0
115
+ for (const c of s) {
116
+ if (c === '"') inQuotes = !inQuotes
117
+ else if (!inQuotes && c === '(') depth++
118
+ else if (!inQuotes && c === ')' && depth > 0) depth--
119
+ if (!inQuotes && depth === 0 && isSep(c)) {
120
+ if (cur !== '') out.push(cur)
121
+ cur = ''
122
+ } else {
123
+ cur += c
124
+ }
125
+ }
126
+ if (cur !== '') out.push(cur)
127
+ return out
128
+ }
129
+
130
+ /**
131
+ * Split `head : rest` at the first label colon, skipping `:::` tag runs so
132
+ * `A:::hot : desc` keeps its tag with the id. `null` when there is no colon.
133
+ */
134
+ export function splitColon(s: string): [string, string] | null {
135
+ const chars = [...s]
136
+ for (let i = 0; i < chars.length; i++) {
137
+ if (chars[i] !== ':') continue
138
+ let run = i
139
+ while (run < chars.length && chars[run] === ':') run++
140
+ if (run - i >= 3) {
141
+ i = run - 1
142
+ continue
143
+ }
144
+ return [chars.slice(0, i).join(''), chars.slice(i + 1).join('')]
145
+ }
146
+ return null
147
+ }
148
+
149
+ /** Strip trailing `:::name` tags from an id token: `A:::hot` → id `A`, classes `[hot]`. */
150
+ export function takeTags(token: string): { id: string; classes: string[] } {
151
+ const parts = token.split(':::')
152
+ if (parts.length === 1 || parts[0] === '') return { id: token, classes: [] }
153
+ return { id: parts[0], classes: parts.slice(1).filter((c) => c !== '') }
154
+ }
155
+
156
+ /**
157
+ * The body of a `class A,B name` statement → `[ids, names]`. The last
158
+ * whitespace-separated token is the name list, everything before it the ids —
159
+ * so a space after a comma (`class A, B warn`) still reads as two ids.
160
+ */
161
+ export function parseClassAssign(rest: string): [string[], string[]] | null {
162
+ const body = rest.trim()
163
+ const ws = body.search(/\s\S*$/)
164
+ if (ws === -1) return null
165
+ const split = (s: string): string[] =>
166
+ s
167
+ .split(',')
168
+ .map((t) => t.trim())
169
+ .filter((t) => t !== '')
170
+ return [split(body.slice(0, ws)), split(body.slice(ws))]
171
+ }
172
+
173
+ /**
174
+ * `A "url" [tooltip]` / `A href "url" …` → `[id, url]`. The callback forms
175
+ * (`call`/`callback`) return null — their quoted string is a tooltip.
176
+ */
177
+ export function parseHref(rest: string): [string, string] | null {
178
+ const [id, second] = words(rest)
179
+ if (id === undefined || second === 'call' || second === 'callback') return null
180
+ const url = rest.match(/"([^"]+)"/)
181
+ return url === null ? null : [id, url[1]]
182
+ }
183
+
184
+ export const firstWord = (s: string): string => s.split(/\s+/).filter((w) => w !== '')[0] ?? ''
185
+ export const words = (s: string): string[] => s.split(/\s+/).filter((w) => w !== '')
186
+
187
+ /** Split on the first occurrence of `sep`, Rust's `split_once`. */
188
+ export function splitOnce(s: string, sep: string): [string, string] | null {
189
+ const i = s.indexOf(sep)
190
+ return i === -1 ? null : [s.slice(0, i), s.slice(i + sep.length)]
191
+ }
192
+
193
+ export const nonEmpty = (s: string): string | null => (s === '' ? null : s)
194
+
195
+ /** Diagram kind from the header statement, lowercased. */
196
+ export function headerKind(statements: string[]): string | null {
197
+ const header = statements[0]
198
+ if (header === undefined) return null
199
+ const kind = firstWord(header)
200
+ return kind === '' ? null : asciiLower(kind)
201
+ }
202
+
203
+ /**
204
+ * Parse the body of a `classDef` statement: `name[,name2] k1:v1,k2:v2`.
205
+ * Values are kept verbatim; malformed pairs are skipped.
206
+ */
207
+ export function parseClassDef(
208
+ rest: string,
209
+ ): { names: string[]; props: Record<string, string> } | null {
210
+ const body = rest.trim()
211
+ const ws = body.search(/\s/)
212
+ if (ws === -1) return null
213
+ const names = body
214
+ .slice(0, ws)
215
+ .split(',')
216
+ .map((s) => s.trim())
217
+ .filter((s) => s !== '')
218
+ const props: Record<string, string> = {}
219
+ // splitTop keeps `rgb(255,0,0)` whole; a bare comma still separates pairs.
220
+ for (const pair of splitTop(body.slice(ws).trim(), (c) => c === ',')) {
221
+ const kv = splitOnce(pair, ':')
222
+ if (kv === null) continue
223
+ const k = kv[0].trim()
224
+ const v = kv[1].trim()
225
+ if (k !== '' && v !== '') props[k] = v
226
+ }
227
+ return names.length === 0 ? null : { names, props }
228
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Semantic role of a run of cells — what a cell *is*, decided by the
3
+ * renderer. The renderer never knows about colour; consumers map roles to
4
+ * their own theme (see `toAnsi` for the common case).
5
+ *
6
+ * - `border` box outlines, subgraph frames, compartment rules
7
+ * - `text` node / participant / compartment labels
8
+ * - `edge` connector lines and arrowheads
9
+ * - `edgeLabel` text sitting on an edge
10
+ * - `title` the `mermaid: <kind>` header of a source box
11
+ * - `none` blank filler
12
+ *
13
+ * Distinct from `classes`, which is what the *author* assigned.
14
+ */
15
+ export type Role = 'border' | 'text' | 'edge' | 'edgeLabel' | 'title' | 'none'
16
+
17
+ /** A run of adjacent cells sharing one role and one set of author classes. */
18
+ export interface Span {
19
+ text: string
20
+ role: Role
21
+ /**
22
+ * Author-assigned class names of the node these cells belong to, from a
23
+ * `:::name` shorthand or a `class A,B name` statement. The renderer never
24
+ * interprets them — pair with `MermaidArt.classDefs` to style. Absent on
25
+ * cells that belong to no classed node.
26
+ */
27
+ classes?: string[]
28
+ /**
29
+ * Link target of the node these cells belong to, from a `click A "url"`
30
+ * (flowchart) or `link A "url"` (class diagram) statement. `toAnsi` emits
31
+ * it as an OSC 8 hyperlink; other consumers map it to their own linking.
32
+ */
33
+ href?: string
34
+ }
35
+
36
+ /**
37
+ * A rendered diagram. `plain[i]` and `styled[i]` describe the same row:
38
+ * `plain` is right-trimmed for display width and copy/paste, `styled` keeps
39
+ * the run structure needed to colour it.
40
+ *
41
+ * `width` is the display columns the widest row needs — the number to compare
42
+ * against the space you have. It cannot be recovered from `plain`, whose rows
43
+ * are strings of code points, not columns.
44
+ *
45
+ * `classDefs` are the diagram's `classDef` declarations, parsed:
46
+ * `classDef warning fill:#f96,stroke:#333` becomes
47
+ * `{ warning: { fill: '#f96', stroke: '#333' } }`. The renderer ignores them;
48
+ * a consumer can map them onto its own styling of the classes spans carry.
49
+ *
50
+ * `warnings` lists source the grammar could not read and dropped, and any
51
+ * size-cap truncation. Non-empty means the art is real but incomplete — some
52
+ * of what was written is not in it.
53
+ *
54
+ * They are advisory. Do not gate rendering on them: the art is the best drawing
55
+ * of the source either way, and a diagram being typed or streamed warns at
56
+ * nearly every intermediate state. Show them alongside, or once it settles.
57
+ */
58
+ export interface MermaidArt {
59
+ plain: string[]
60
+ styled: Span[][]
61
+ width: number
62
+ classDefs: Record<string, Record<string, string>>
63
+ warnings: string[]
64
+ }