@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,195 @@
1
+ /**
2
+ * Entry points for the layered diagrams (flowchart, state, class, ER):
3
+ * choose what each node box holds, lay the graph out, paint it, orient it.
4
+ * Subgraphs recurse: each becomes a framed box holding its own canvas.
5
+ */
6
+
7
+ import { Canvas } from './canvas.ts'
8
+ import type { Edge, Node } from './graph.ts'
9
+ import { Graph } from './graph.ts'
10
+ import type { Limits } from './labels.ts'
11
+ import { layout, type NodeExtra } from './layout.ts'
12
+ import { orient, paint } from './paint.ts'
13
+
14
+ /** A laid-out canvas, or `null` when the diagram is empty or over the cell cap. */
15
+ export type CanvasResult = Canvas | null
16
+
17
+ /** Flowchart and state diagrams: plain boxes, no extra content. */
18
+ export function layoutFlowchart(graph: Graph, limits: Limits): CanvasResult {
19
+ const extras: NodeExtra[] = graph.nodes.map(() => ({ kind: 'plain' }))
20
+ const canvas = layoutCanvas(graph, extras, limits)
21
+ return canvas && orient(canvas, graph)
22
+ }
23
+
24
+ /** Class and ER diagrams: boxes divided into title / attribute / method rows. */
25
+ export function layoutClass(graph: Graph, limits: Limits): CanvasResult {
26
+ const extras: NodeExtra[] = graph.nodes.map((node) => ({
27
+ kind: 'compartments',
28
+ sections: node.sections ?? [[node.label]],
29
+ }))
30
+ const canvas = layoutCanvas(graph, extras, limits)
31
+ return canvas && orient(canvas, graph)
32
+ }
33
+
34
+ // -------------------------------------------------------------------- groups
35
+
36
+ /** An endpoint inside a scope: a plain node or a (proxied) subgraph. */
37
+ interface ScopeItem {
38
+ group: boolean
39
+ i: number
40
+ }
41
+
42
+ /**
43
+ * Lay out a flowchart that uses `subgraph`.
44
+ *
45
+ * Each subgraph becomes a framed box holding its own independently laid-out
46
+ * canvas. An edge is drawn in the innermost scope containing both endpoints;
47
+ * one crossing a subgraph boundary attaches to the frame instead of the node.
48
+ */
49
+ export function layoutGrouped(graph: Graph, limits: Limits): CanvasResult {
50
+ // A node whose id matches a subgraph id stands in for that subgraph.
51
+ const proxy = new Map<number, number>()
52
+ graph.groups.forEach((g, gi) => {
53
+ const ni = graph.index.get(g.id)
54
+ if (ni !== undefined) proxy.set(ni, gi)
55
+ })
56
+
57
+ const groupChain = (g: number | null): number[] => {
58
+ const chain: number[] = []
59
+ let cur = g
60
+ while (cur !== null) {
61
+ chain.push(cur)
62
+ cur = graph.groups[cur].parent
63
+ }
64
+ return chain.reverse()
65
+ }
66
+ const endpoint = (n: number): { item: ScopeItem; chain: number[] } => {
67
+ const gi = proxy.get(n)
68
+ return gi === undefined
69
+ ? { item: { group: false, i: n }, chain: groupChain(graph.nodeGroup[n]) }
70
+ : { item: { group: true, i: gi }, chain: groupChain(graph.groups[gi].parent) }
71
+ }
72
+
73
+ /** Edges bucketed by the scope that draws them; `null` is the top level. */
74
+ const scopeEdges = new Map<number | null, [ScopeItem, ScopeItem, number][]>()
75
+ const referenced = new Array<boolean>(graph.groups.length).fill(false)
76
+ graph.edges.forEach((e, ei) => {
77
+ const f = endpoint(e.from)
78
+ const t = endpoint(e.to)
79
+ let k = 0
80
+ while (k < f.chain.length && k < t.chain.length && f.chain[k] === t.chain[k]) k++
81
+ const scope = k === 0 ? null : f.chain[k - 1]
82
+ const fItem = f.chain.length > k ? { group: true, i: f.chain[k] } : f.item
83
+ const tItem = t.chain.length > k ? { group: true, i: t.chain[k] } : t.item
84
+ for (const item of [fItem, tItem]) {
85
+ if (item.group) referenced[item.i] = true
86
+ }
87
+ const list = scopeEdges.get(scope)
88
+ if (list) list.push([fItem, tItem, ei])
89
+ else scopeEdges.set(scope, [[fItem, tItem, ei]])
90
+ })
91
+
92
+ const directNodes = new Map<number | null, number[]>()
93
+ graph.nodeGroup.forEach((g, ni) => {
94
+ if (proxy.has(ni)) return
95
+ const list = directNodes.get(g)
96
+ if (list) list.push(ni)
97
+ else directNodes.set(g, [ni])
98
+ })
99
+
100
+ // Drop empty subgraphs, but keep any that an edge attaches to. Walked by
101
+ // the actual child relation: state `--` regions reparent earlier groups
102
+ // under later ones, so index order says nothing about depth.
103
+ const childGroups: number[][] = graph.groups.map(() => [])
104
+ graph.groups.forEach((g, gi) => {
105
+ if (g.parent !== null) childGroups[g.parent].push(gi)
106
+ })
107
+ const keep = new Array<boolean>(graph.groups.length).fill(false)
108
+ const visit = (gi: number): boolean => {
109
+ let kept = referenced[gi] || (directNodes.get(gi) ?? []).length > 0
110
+ for (const c of childGroups[gi]) if (visit(c)) kept = true
111
+ keep[gi] = kept
112
+ return kept
113
+ }
114
+ graph.groups.forEach((g, gi) => {
115
+ if (g.parent === null) visit(gi)
116
+ })
117
+
118
+ const canvas = buildScope(graph, null, scopeEdges, directNodes, keep, limits)
119
+ return canvas && orient(canvas, graph)
120
+ }
121
+
122
+ function buildScope(
123
+ graph: Graph,
124
+ scope: number | null,
125
+ scopeEdges: Map<number | null, [ScopeItem, ScopeItem, number][]>,
126
+ directNodes: Map<number | null, number[]>,
127
+ keep: boolean[],
128
+ limits: Limits,
129
+ ): CanvasResult {
130
+ const items: ScopeItem[] = (directNodes.get(scope) ?? []).map((i) => ({ group: false, i }))
131
+ const childGroups = graph.groups
132
+ .map((_, gi) => gi)
133
+ .filter((gi) => graph.groups[gi].parent === scope && keep[gi])
134
+ items.push(...childGroups.map((i) => ({ group: true, i })))
135
+
136
+ if (items.length === 0) return new Canvas(1, 1)
137
+
138
+ const nodeAt = new Map<number, number>()
139
+ const groupAt = new Map<number, number>()
140
+ const nodes: Node[] = []
141
+ const extras: NodeExtra[] = []
142
+ for (const item of items) {
143
+ ;(item.group ? groupAt : nodeAt).set(item.i, nodes.length)
144
+ if (!item.group) {
145
+ nodes.push({
146
+ label: graph.nodes[item.i].label,
147
+ shape: graph.nodes[item.i].shape,
148
+ classes: graph.nodes[item.i].classes,
149
+ href: graph.nodes[item.i].href,
150
+ })
151
+ extras.push({ kind: 'plain' })
152
+ } else {
153
+ const sub = buildScope(graph, item.i, scopeEdges, directNodes, keep, limits)
154
+ if (sub === null) return null
155
+ nodes.push({ label: graph.groups[item.i].label, shape: 'rect' })
156
+ extras.push({ kind: 'frame', sub })
157
+ }
158
+ }
159
+
160
+ const edges: Edge[] = []
161
+ for (const [f, t, ei] of scopeEdges.get(scope) ?? []) {
162
+ const fi = (f.group ? groupAt : nodeAt).get(f.i)
163
+ const ti = (t.group ? groupAt : nodeAt).get(t.i)
164
+ if (fi === undefined || ti === undefined) continue
165
+ const e = graph.edges[ei]
166
+ const collapsed: Edge = {
167
+ from: fi,
168
+ to: ti,
169
+ label: e.label,
170
+ headTo: e.headTo,
171
+ headFrom: e.headFrom,
172
+ line: e.line,
173
+ }
174
+ // Edges from (or to) different nodes inside one frame collapse onto
175
+ // the frame and become indistinguishable; draw them once.
176
+ const twin = (a: Edge, b: Edge): boolean =>
177
+ a.from === b.from && a.to === b.to && a.label === b.label && a.headTo === b.headTo && a.headFrom === b.headFrom && a.line === b.line
178
+ if ((f.group || t.group) && edges.some((x) => twin(x, collapsed))) continue
179
+ edges.push(collapsed)
180
+ }
181
+
182
+ // Layout only reads nodes/edges/dir, so a bare Graph carrying those is enough.
183
+ const synth = new Graph(graph.dir)
184
+ synth.nodes = nodes
185
+ synth.edges = edges
186
+ return layoutCanvas(synth, extras, limits)
187
+ }
188
+
189
+ /** Lay out and paint one scope. */
190
+ function layoutCanvas(graph: Graph, extras: NodeExtra[], limits: Limits): CanvasResult {
191
+ const lay = layout(graph, extras, limits)
192
+ return lay === null ? null : paint(graph, extras, lay)
193
+ }
194
+
195
+ // ------------------------------------------------------------------- drawing
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The shared diagram model. Flowchart, state, class and ER sources all parse
3
+ * into a `Graph`; only sequence diagrams have their own model.
4
+ */
5
+
6
+ import { asciiUpper } from './labels.ts'
7
+
8
+ /** Caps that keep layout bounded; exceeding one drops the diagram to fallback. */
9
+ export const MAX_NODES = 128
10
+ export const MAX_EDGES = 512
11
+ export const MAX_GROUPS = 24
12
+ export const MAX_GROUP_DEPTH = 6
13
+ /** Class members / ER attributes listed per box before eliding with `…`. */
14
+ export const MAX_MEMBERS = 8
15
+
16
+ export type Shape = 'rect' | 'round' | 'diamond'
17
+
18
+ /** Decoration at one end of an edge. */
19
+ export type Head =
20
+ | 'none'
21
+ | 'arrow'
22
+ | 'circle'
23
+ | 'cross'
24
+ | 'triangle'
25
+ | 'diamondFill'
26
+ | 'diamondOpen'
27
+
28
+ export type LineKind = 'solid' | 'dotted' | 'thick'
29
+
30
+ type Dir = 'down' | 'up' | 'right' | 'left'
31
+
32
+ export interface Node {
33
+ label: string
34
+ shape: Shape
35
+ /**
36
+ * Compartment content for class and ER boxes, pre-formatted by the parser:
37
+ * one string per row, one array per compartment (title, attributes,
38
+ * methods). Absent on plain nodes; layout draws whatever is here verbatim,
39
+ * separated by horizontal rules.
40
+ */
41
+ sections?: string[][]
42
+ /**
43
+ * Author-assigned class names, from `:::name` or a `class A,B name`
44
+ * statement. The renderer never interprets them; the cells the node paints
45
+ * carry them out through `Span.classes`.
46
+ */
47
+ classes?: string[]
48
+ /**
49
+ * Link target from a `click A "url"` / `link A "url"` statement, carried
50
+ * out through `Span.href` the way classes are; `toAnsi` emits it as an
51
+ * OSC 8 hyperlink.
52
+ */
53
+ href?: string
54
+ }
55
+
56
+ export interface Edge {
57
+ from: number
58
+ to: number
59
+ label: string | null
60
+ /**
61
+ * Cardinalities (ER crow's-foot, class multiplicities), painted at their
62
+ * own end of the edge so position says which side a number belongs to.
63
+ */
64
+ cardFrom?: string
65
+ cardTo?: string
66
+ headTo: Head
67
+ headFrom: Head
68
+ line: LineKind
69
+ }
70
+
71
+ export interface Group {
72
+ id: string
73
+ label: string
74
+ parent: number | null
75
+ }
76
+
77
+ /** `LR`/`RL`/`BT` as written in a header or `direction` statement; else `down`. */
78
+ export function parseDir(token: string): Dir {
79
+ switch (asciiUpper(token)) {
80
+ case 'LR':
81
+ return 'right'
82
+ case 'RL':
83
+ return 'left'
84
+ case 'BT':
85
+ return 'up'
86
+ default:
87
+ return 'down'
88
+ }
89
+ }
90
+
91
+ export class Graph {
92
+ nodes: Node[] = []
93
+ edges: Edge[] = []
94
+ index = new Map<string, number>()
95
+ groups: Group[] = []
96
+ /** Innermost subgraph each node was declared in, parallel to `nodes`. */
97
+ nodeGroup: (number | null)[] = []
98
+ curGroup: number | null = null
99
+ /**
100
+ * Set when a size cap was hit, naming the cap. The parser stops consuming
101
+ * statements and renders the prefix, warning about the truncation — a
102
+ * streamed diagram that outgrows a cap can never shrink back under it, so
103
+ * a stable truncated render beats flipping to the source box for good.
104
+ */
105
+ truncated: string | null = null
106
+ /**
107
+ * Source the grammar could not read and dropped. Parsing is lenient in
108
+ * every grammar: a statement either contributes what parsed or is dropped
109
+ * and recorded here, so the reader can tell a clean diagram from one that
110
+ * is missing something they wrote.
111
+ */
112
+ warnings: string[] = []
113
+ /** Parsed `classDef` declarations: name -> property map. */
114
+ classDefs: Record<string, Record<string, string>> = {}
115
+ dir: Dir = 'down'
116
+
117
+ constructor(dir: Dir = 'down') {
118
+ this.dir = dir
119
+ }
120
+
121
+ /**
122
+ * Index of `id`, creating the node if new. A later declaration carrying a
123
+ * label overwrites the placeholder one an edge created. Returns `null` once
124
+ * `MAX_NODES` is reached, which truncates the parse.
125
+ */
126
+ nodeIndex(id: string, label: string | null, shape: Shape): number | null {
127
+ const existing = this.index.get(id)
128
+ if (existing !== undefined) {
129
+ if (label !== null) {
130
+ this.nodes[existing].label = label
131
+ this.nodes[existing].shape = shape
132
+ }
133
+ return existing
134
+ }
135
+ if (this.nodes.length >= MAX_NODES) {
136
+ this.truncated ??= `node cap (${MAX_NODES}) reached`
137
+ return null
138
+ }
139
+ this.index.set(id, this.nodes.length)
140
+ this.nodes.push({ label: label ?? id, shape })
141
+ this.nodeGroup.push(this.curGroup)
142
+ return this.nodes.length - 1
143
+ }
144
+
145
+ /** Set a node's label without disturbing its shape, creating it if new. */
146
+ nodeLabel(id: string, label: string): number | null {
147
+ const existing = this.index.get(id)
148
+ if (existing !== undefined) {
149
+ this.nodes[existing].label = label
150
+ return existing
151
+ }
152
+ return this.nodeIndex(id, label, 'round')
153
+ }
154
+
155
+ /** Attach an author class name to a node, ignoring a repeat. */
156
+ addClass(idx: number, name: string): void {
157
+ const node = this.nodes[idx]
158
+ node.classes ??= []
159
+ if (!node.classes.includes(name)) node.classes.push(name)
160
+ }
161
+
162
+ /**
163
+ * Apply collected `[ids, names]` class assignments. Run after the statement
164
+ * walk so a `class A,B name` (or `:::` tag) may precede the nodes it names;
165
+ * unknown ids are ignored.
166
+ */
167
+ applyClasses(assignments: [string[], string[]][]): void {
168
+ for (const [ids, names] of assignments) {
169
+ for (const id of ids) {
170
+ const idx = this.index.get(id.trim())
171
+ if (idx === undefined) continue
172
+ for (const name of names) {
173
+ if (name.trim() !== '') this.addClass(idx, name.trim())
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Apply `[id, url]` link targets; the last one per id wins, unknown ids
180
+ * are ignored. Deferred like `applyClasses`, for the same ordering reason. */
181
+ applyHrefs(hrefs: [string, string][]): void {
182
+ for (const [id, url] of hrefs) {
183
+ const idx = this.index.get(id.trim())
184
+ if (idx !== undefined) this.nodes[idx].href = url
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Record an unreadable statement; the diagram renders without it. Skipped
190
+ * once truncated — a cap-caused parse failure is not the statement's fault.
191
+ */
192
+ drop(st: string): void {
193
+ if (this.truncated === null) this.warnings.push(`dropped, unreadable statement: "${st}"`)
194
+ }
195
+
196
+ /** Append an edge, or flag `truncated` when `MAX_EDGES` is reached. */
197
+ pushEdge(edge: Edge): boolean {
198
+ if (this.edges.length >= MAX_EDGES) {
199
+ this.truncated ??= `edge cap (${MAX_EDGES}) reached`
200
+ return false
201
+ }
202
+ this.edges.push(edge)
203
+ return true
204
+ }
205
+ }
@@ -0,0 +1,78 @@
1
+ import type { Canvas } from './canvas.ts'
2
+ import { LIMITS, stripControls } from './labels.ts'
3
+ import { type Diagram, diagramFor } from './registry.ts'
4
+ import { frontmatterTitle } from './statements.ts'
5
+ import type { MermaidArt } from './types.ts'
6
+ import { stringWidth } from './width.ts'
7
+
8
+ export { type AnsiTheme, classSgr, DEFAULT_THEME, toAnsi } from './ansi.ts'
9
+ export { type ClassStyle, contrastOn, resolveClassStyle } from './class-style.ts'
10
+ export { type DiagramKind, diagramKind } from './registry.ts'
11
+ export { sourceBox } from './source-box.ts'
12
+ export type { MermaidArt, Role, Span } from './types.ts'
13
+
14
+ /**
15
+ * Render a Mermaid source block as Unicode box-drawing art.
16
+ *
17
+ * Supported: `graph`/`flowchart` (including `subgraph`), `stateDiagram`,
18
+ * `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`, `mindmap`,
19
+ * `timeline` and `gitGraph`.
20
+ *
21
+ * The diagram is laid out at whatever size it needs; `art.width` reports the
22
+ * columns that turned out to be. Given `maxWidth`, a diagram wider than that
23
+ * is laid out again with progressively tighter label limits and the first
24
+ * fit is returned. Deciding what to do when even the tightest exceeds the
25
+ * space at hand is the caller's — `sourceBox` is the usual answer:
26
+ *
27
+ * ```ts
28
+ * const art = render(src, { maxWidth: cols })
29
+ * show(art && art.width <= cols ? art : sourceBox(src, cols))
30
+ * ```
31
+ *
32
+ * `null` means there is no art to show: blank input, a diagram type this
33
+ * renderer does not draw, a source in which not one statement parsed, or a
34
+ * diagram large enough that laying it out is refused. `diagramKind` separates
35
+ * the middle two.
36
+ *
37
+ * Rendering is best-effort in every grammar: a statement either contributes
38
+ * what parsed or is dropped, and a diagram over a size cap renders its prefix.
39
+ * Everything given up on is listed in `art.warnings` — advisory only, never a
40
+ * reason to withhold the art.
41
+ */
42
+ export function render(src: string, options: { maxWidth?: number } = {}): MermaidArt | null {
43
+ src = stripControls(src)
44
+ if (src.trim() === '') return null
45
+ const diagram = diagramFor(src)
46
+ if (diagram === null) return null
47
+ // Too wide for the space given: lay out again with shorter labels and
48
+ // tighter wrapping, tightest last, and keep the first that fits (else
49
+ // the tightest, for the caller to judge against `art.width`).
50
+ let drawn: ReturnType<Diagram['render']> = null
51
+ let art: ReturnType<Canvas['toLines']> = { plain: [], styled: [], width: 0 }
52
+ for (const limits of LIMITS) {
53
+ drawn = diagram.render(src, limits)
54
+ if (drawn === null) return null
55
+ art = drawn.canvas.toLines()
56
+ if (options.maxWidth === undefined || art.width <= options.maxWidth) break
57
+ }
58
+ if (drawn === null) return null
59
+
60
+ // A frontmatter `title:` is centred above the art, in the `title` role.
61
+ const title = frontmatterTitle(src)
62
+ if (title !== null) {
63
+ const tw = stringWidth(title)
64
+ art.width = Math.max(art.width, tw)
65
+ const pad = ' '.repeat(Math.floor((art.width - tw) / 2))
66
+ art.plain.unshift(pad + title, '')
67
+ art.styled.unshift(
68
+ pad === ''
69
+ ? [{ text: title, role: 'title' }]
70
+ : [
71
+ { text: pad, role: 'none' },
72
+ { text: title, role: 'title' },
73
+ ],
74
+ [],
75
+ )
76
+ }
77
+ return { ...art, classDefs: drawn.classDefs, warnings: drawn.warnings }
78
+ }