opentui-markdown-render 0.0.0-stage → 0.1.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.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +173 -2
  4. package/native/Cargo.lock +3708 -0
  5. package/native/Cargo.toml +28 -0
  6. package/native/build.rs +3 -0
  7. package/native/src/lib.rs +880 -0
  8. package/package.json +62 -4
  9. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  10. package/src/app.tsx +180 -0
  11. package/src/config/index.ts +45 -0
  12. package/src/config/load.ts +308 -0
  13. package/src/config/resolve.ts +37 -0
  14. package/src/config/theme.ts +88 -0
  15. package/src/config/types.ts +102 -0
  16. package/src/core/document.ts +44 -0
  17. package/src/core/frame.ts +144 -0
  18. package/src/core/image.ts +9 -0
  19. package/src/core/index.ts +6 -0
  20. package/src/core/layout.ts +27 -0
  21. package/src/core/math.ts +170 -0
  22. package/src/core/parse.ts +208 -0
  23. package/src/core/tables.ts +58 -0
  24. package/src/graphics.ts +85 -0
  25. package/src/headings.ts +77 -0
  26. package/src/index.ts +98 -0
  27. package/src/math/index.ts +86 -0
  28. package/src/math/native.ts +39 -0
  29. package/src/math/renderer.ts +84 -0
  30. package/src/math/validate.ts +44 -0
  31. package/src/media.tsx +267 -0
  32. package/src/merman/class/dagre-layout.ts +171 -0
  33. package/src/merman/class/diagram.ts +8 -0
  34. package/src/merman/class/drawing.ts +343 -0
  35. package/src/merman/class/labels.ts +38 -0
  36. package/src/merman/class/layout.ts +531 -0
  37. package/src/merman/class/parser.ts +308 -0
  38. package/src/merman/class/render-grid.ts +18 -0
  39. package/src/merman/class/style.ts +27 -0
  40. package/src/merman/class/types.ts +57 -0
  41. package/src/merman/core/canvas.ts +228 -0
  42. package/src/merman/core/color/style.ts +56 -0
  43. package/src/merman/core/drawing.ts +307 -0
  44. package/src/merman/core/geometry.ts +347 -0
  45. package/src/merman/core/mermaid.ts +38 -0
  46. package/src/merman/core/render-grid.ts +51 -0
  47. package/src/merman/core/spatial.ts +243 -0
  48. package/src/merman/core/text-lines.ts +32 -0
  49. package/src/merman/core/text.ts +44 -0
  50. package/src/merman/detect.ts +19 -0
  51. package/src/merman/diagnostics.ts +16 -0
  52. package/src/merman/diagram.ts +268 -0
  53. package/src/merman/flowchart/drawing.ts +433 -0
  54. package/src/merman/flowchart/labels.ts +144 -0
  55. package/src/merman/flowchart/layout.ts +1057 -0
  56. package/src/merman/flowchart/options.ts +12 -0
  57. package/src/merman/flowchart/parser.ts +477 -0
  58. package/src/merman/flowchart/render.ts +10 -0
  59. package/src/merman/flowchart/routing.ts +1429 -0
  60. package/src/merman/flowchart/style.ts +79 -0
  61. package/src/merman/flowchart/types.ts +63 -0
  62. package/src/merman/gantt/diagram.ts +8 -0
  63. package/src/merman/gantt/drawing.ts +193 -0
  64. package/src/merman/gantt/parser.ts +198 -0
  65. package/src/merman/gantt/render-grid.ts +17 -0
  66. package/src/merman/gantt/style.ts +36 -0
  67. package/src/merman/gantt/types.ts +45 -0
  68. package/src/merman/gitgraph/diagram.ts +8 -0
  69. package/src/merman/gitgraph/drawing.ts +234 -0
  70. package/src/merman/gitgraph/parser.ts +234 -0
  71. package/src/merman/gitgraph/render-grid.ts +17 -0
  72. package/src/merman/gitgraph/style.ts +37 -0
  73. package/src/merman/gitgraph/types.ts +36 -0
  74. package/src/merman/palette.ts +60 -0
  75. package/src/merman/sequence/diagram.ts +8 -0
  76. package/src/merman/sequence/drawing.ts +288 -0
  77. package/src/merman/sequence/options.ts +8 -0
  78. package/src/merman/sequence/parser.ts +303 -0
  79. package/src/merman/sequence/placement.ts +692 -0
  80. package/src/merman/sequence/render-grid.ts +22 -0
  81. package/src/merman/sequence/style.ts +81 -0
  82. package/src/merman/sequence/types.ts +74 -0
  83. package/src/merman/state/diagram.ts +8 -0
  84. package/src/merman/state/drawing.ts +363 -0
  85. package/src/merman/state/endpoint.ts +15 -0
  86. package/src/merman/state/layout.ts +1134 -0
  87. package/src/merman/state/note.ts +59 -0
  88. package/src/merman/state/options.ts +11 -0
  89. package/src/merman/state/parser.ts +245 -0
  90. package/src/merman/state/render-grid.ts +22 -0
  91. package/src/merman/state/routing.ts +1522 -0
  92. package/src/merman/state/search.ts +103 -0
  93. package/src/merman/state/style.ts +52 -0
  94. package/src/merman/state/types.ts +63 -0
  95. package/src/merman/state/visible-model.ts +77 -0
  96. package/src/merman/timeline/diagram.ts +8 -0
  97. package/src/merman/timeline/drawing.ts +109 -0
  98. package/src/merman/timeline/parser.ts +119 -0
  99. package/src/merman/timeline/render-grid.ts +17 -0
  100. package/src/merman/timeline/style.ts +36 -0
  101. package/src/merman/timeline/types.ts +31 -0
  102. package/src/merman.ts +3 -0
  103. package/src/statusline.tsx +183 -0
  104. package/src/theme/colors.ts +11 -0
  105. package/src/theme/defaults.ts +190 -0
  106. package/src/theme/index.ts +5 -0
  107. package/src/theme/resolve.ts +166 -0
  108. package/src/theme/syntax.ts +96 -0
  109. package/src/theme/types.ts +150 -0
  110. package/src/view/code.tsx +27 -0
  111. package/src/view/document-slots.ts +111 -0
  112. package/src/view/document-table.tsx +98 -0
  113. package/src/view/document.tsx +195 -0
  114. package/src/view/frame.tsx +83 -0
  115. package/src/view/inline-flow.tsx +113 -0
  116. package/src/view/inline-layout.ts +149 -0
  117. package/src/view/inline-math.ts +42 -0
  118. package/src/view/inline-style.ts +103 -0
  119. package/src/view/math.tsx +85 -0
  120. package/src/view/mermaid.tsx +128 -0
  121. package/src/view/table.ts +100 -0
@@ -0,0 +1,51 @@
1
+ import { StyledText, type TextChunk } from "@opentui/core"
2
+ import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
3
+
4
+ export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
5
+ grid: DiagramCanvas<Style, Metadata>,
6
+ fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
7
+ bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
8
+ options?: DiagramCanvasRunOptions<Style, Metadata> & {
9
+ attributes?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["attributes"]
10
+ },
11
+ ): StyledText {
12
+ const chunks: TextChunk[] = []
13
+
14
+ grid.forEachRun(
15
+ (run) => {
16
+ chunks.push({
17
+ __isChunk: true,
18
+ text: run.text,
19
+ fg: fg(run),
20
+ bg: bg?.(run),
21
+ attributes: options?.attributes?.(run),
22
+ })
23
+ },
24
+ () => {
25
+ chunks.push({ __isChunk: true, text: "\n" })
26
+ },
27
+ options,
28
+ )
29
+
30
+ return new StyledText(chunks)
31
+ }
32
+
33
+ export function renderDiagramGridStyledTextByStyle<Style extends string, Metadata extends object = object>(
34
+ grid: DiagramCanvas<Style, Metadata>,
35
+ foregrounds: Readonly<Record<Style, TextChunk["fg"]>>,
36
+ backgrounds?: Readonly<Partial<Record<Style, TextChunk["bg"]>>>,
37
+ options?: DiagramCanvasRunOptions<Style, Metadata> & {
38
+ attributes?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["attributes"]
39
+ },
40
+ ): StyledText {
41
+ const background =
42
+ backgrounds && Object.values(backgrounds).some((value) => value !== undefined)
43
+ ? (run: DiagramCanvasRun<Style, Metadata>) => (run.style ? backgrounds[run.style] : undefined)
44
+ : undefined
45
+ return renderDiagramGridStyledText(
46
+ grid,
47
+ (run) => (run.style ? foregrounds[run.style] : undefined),
48
+ background,
49
+ options,
50
+ )
51
+ }
@@ -0,0 +1,243 @@
1
+ import { orthogonalPathPoints, type DiagramBounds, type DiagramPoint } from "./geometry.js"
2
+
3
+ export type SpatialRole = "body" | "boundary" | "terminal" | "route" | "label"
4
+
5
+ export interface SpatialSpan {
6
+ readonly y: number
7
+ readonly fromX: number
8
+ readonly toX: number
9
+ }
10
+
11
+ export interface SpatialClaim {
12
+ readonly id: string
13
+ readonly owner: string
14
+ readonly role: SpatialRole
15
+ readonly spans: readonly SpatialSpan[]
16
+ }
17
+
18
+ export interface SpatialContact {
19
+ owner: string
20
+ points: readonly DiagramPoint[]
21
+ }
22
+
23
+ export interface SpatialConflict {
24
+ moving: SpatialClaim
25
+ existing: SpatialClaim
26
+ point: DiagramPoint
27
+ }
28
+
29
+ export interface SpatialClearance {
30
+ x: number
31
+ y: number
32
+ }
33
+
34
+ export interface SpatialCollisionPolicy {
35
+ contacts?: readonly SpatialContact[]
36
+ clearance?: number | SpatialClearance | Partial<Record<SpatialRole, number | SpatialClearance>>
37
+ }
38
+
39
+ function normalizedSpan(y: number, fromX: number, toX: number): SpatialSpan {
40
+ return { y, fromX: Math.min(fromX, toX), toX: Math.max(fromX, toX) }
41
+ }
42
+
43
+ function assertFiniteInteger(value: number, name: string): void {
44
+ if (!Number.isFinite(value) || !Number.isInteger(value)) throw new RangeError(`${name} must be a finite integer`)
45
+ }
46
+
47
+ export function spatialRectSpans(bounds: Pick<DiagramBounds, "left" | "top" | "width" | "height">): SpatialSpan[] {
48
+ assertFiniteInteger(bounds.left, "bounds.left")
49
+ assertFiniteInteger(bounds.top, "bounds.top")
50
+ assertFiniteInteger(bounds.width, "bounds.width")
51
+ assertFiniteInteger(bounds.height, "bounds.height")
52
+ if (bounds.width <= 0 || bounds.height <= 0) throw new RangeError("Spatial bounds must have positive dimensions")
53
+ return Array.from({ length: bounds.height }, (_, offset) =>
54
+ normalizedSpan(bounds.top + offset, bounds.left, bounds.left + bounds.width - 1),
55
+ )
56
+ }
57
+
58
+ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[] {
59
+ for (const [index, point] of points.entries()) {
60
+ assertFiniteInteger(point.x, `points[${index}].x`)
61
+ assertFiniteInteger(point.y, `points[${index}].y`)
62
+ if (index > 0 && point.x !== points[index - 1]!.x && point.y !== points[index - 1]!.y) {
63
+ throw new RangeError("Spatial paths must be orthogonal")
64
+ }
65
+ }
66
+ const cells = new Map<number, Set<number>>()
67
+ const add = (point: DiagramPoint): void => {
68
+ const row = cells.get(point.y) ?? new Set<number>()
69
+ row.add(point.x)
70
+ cells.set(point.y, row)
71
+ }
72
+
73
+ if (points.length === 1) add(points[0]!)
74
+ for (const point of orthogonalPathPoints(points)) add(point)
75
+
76
+ return [...cells.entries()]
77
+ .sort(([left], [right]) => left - right)
78
+ .flatMap(([y, xs]) => {
79
+ const sorted = [...xs].sort((left, right) => left - right)
80
+ const [first, ...rest] = sorted
81
+ if (first === undefined) return []
82
+ const spans: SpatialSpan[] = []
83
+ let start = first
84
+ let end = first
85
+ for (const x of rest) {
86
+ if (x === end + 1) {
87
+ end = x
88
+ continue
89
+ }
90
+ spans.push(normalizedSpan(y, start, end))
91
+ start = x
92
+ end = x
93
+ }
94
+ spans.push(normalizedSpan(y, start, end))
95
+ return spans
96
+ })
97
+ }
98
+
99
+ export function spatialRectClaim(
100
+ id: string,
101
+ owner: string,
102
+ role: SpatialRole,
103
+ bounds: Pick<DiagramBounds, "left" | "top" | "width" | "height">,
104
+ ): SpatialClaim {
105
+ return { id, owner, role, spans: spatialRectSpans(bounds) }
106
+ }
107
+
108
+ export function spatialPathClaim(
109
+ id: string,
110
+ owner: string,
111
+ role: Extract<SpatialRole, "boundary" | "route">,
112
+ points: readonly DiagramPoint[],
113
+ ): SpatialClaim {
114
+ return { id, owner, role, spans: spatialPathSpans(points) }
115
+ }
116
+
117
+ function compareClaims(left: SpatialClaim, right: SpatialClaim): number {
118
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0
119
+ }
120
+
121
+ function sameClaim(left: SpatialClaim, right: SpatialClaim): boolean {
122
+ return (
123
+ left.id === right.id &&
124
+ left.owner === right.owner &&
125
+ left.role === right.role &&
126
+ left.spans.length === right.spans.length &&
127
+ left.spans.every(
128
+ (span, index) =>
129
+ span.y === right.spans[index]!.y &&
130
+ span.fromX === right.spans[index]!.fromX &&
131
+ span.toX === right.spans[index]!.toX,
132
+ )
133
+ )
134
+ }
135
+
136
+ function pointIsContact(point: DiagramPoint, existing: SpatialClaim, contacts: readonly SpatialContact[]): boolean {
137
+ return contacts.some(
138
+ (contact) =>
139
+ contact.owner === existing.owner &&
140
+ contact.points.some((candidate) => candidate.x === point.x && candidate.y === point.y),
141
+ )
142
+ }
143
+
144
+ function rolesMayOverlap(moving: SpatialClaim, existing: SpatialClaim): boolean {
145
+ if (moving.owner === existing.owner) return true
146
+ return moving.role === "route" && existing.role === "route"
147
+ }
148
+
149
+ function normalizeClearance(clearance: number | SpatialClearance | undefined): SpatialClearance {
150
+ const x = typeof clearance === "number" ? clearance : (clearance?.x ?? 0)
151
+ const y = typeof clearance === "number" ? clearance : (clearance?.y ?? 0)
152
+ assertFiniteInteger(x, "clearance.x")
153
+ assertFiniteInteger(y, "clearance.y")
154
+ if (x < 0 || y < 0) throw new RangeError("Spatial clearance cannot be negative")
155
+ return { x, y }
156
+ }
157
+
158
+ function inflateSpan(span: SpatialSpan, clearance: SpatialClearance): SpatialSpan {
159
+ return { y: span.y, fromX: span.fromX - clearance.x, toX: span.toX + clearance.x }
160
+ }
161
+
162
+ export class SpatialIndex {
163
+ static empty(): SpatialIndex {
164
+ return new SpatialIndex([])
165
+ }
166
+
167
+ readonly claims: readonly SpatialClaim[]
168
+
169
+ private constructor(claims: readonly SpatialClaim[]) {
170
+ this.claims = Object.freeze(
171
+ claims.map((claim) =>
172
+ Object.freeze({
173
+ ...claim,
174
+ spans: Object.freeze(
175
+ [...claim.spans]
176
+ .map((span) => Object.freeze({ ...span }))
177
+ .sort((left, right) => left.y - right.y || left.fromX - right.fromX || left.toX - right.toX),
178
+ ),
179
+ }),
180
+ ),
181
+ )
182
+ }
183
+
184
+ add(...claims: readonly SpatialClaim[]): SpatialIndex {
185
+ return this.overlay(new SpatialIndex(claims))
186
+ }
187
+
188
+ overlay(other: SpatialIndex): SpatialIndex {
189
+ const claims = new Map(this.claims.map((claim) => [claim.id, claim]))
190
+ for (const claim of other.claims) {
191
+ const existing = claims.get(claim.id)
192
+ if (existing && !sameClaim(existing, claim)) throw new Error(`Conflicting spatial claim id: ${claim.id}`)
193
+ claims.set(claim.id, claim)
194
+ }
195
+ return new SpatialIndex([...claims.values()].sort(compareClaims))
196
+ }
197
+
198
+ conflicts(moving: SpatialClaim, policy: SpatialCollisionPolicy = {}): SpatialConflict[] {
199
+ const contacts = policy.contacts ?? []
200
+ const conflicts: SpatialConflict[] = []
201
+
202
+ for (const existing of this.claims) {
203
+ if (rolesMayOverlap(moving, existing)) continue
204
+ const configuredClearance =
205
+ typeof policy.clearance === "number" || (policy.clearance && "x" in policy.clearance)
206
+ ? policy.clearance
207
+ : policy.clearance?.[existing.role]
208
+ const clearance = normalizeClearance(configuredClearance)
209
+ for (const movingSpan of moving.spans) {
210
+ for (let dy = -clearance.y; dy <= clearance.y; dy++) {
211
+ const inflated = inflateSpan({ ...movingSpan, y: movingSpan.y + dy }, clearance)
212
+ for (const existingSpan of existing.spans) {
213
+ if (inflated.y !== existingSpan.y) continue
214
+ const fromX = Math.max(inflated.fromX, existingSpan.fromX)
215
+ const toX = Math.min(inflated.toX, existingSpan.toX)
216
+ for (let x = fromX; x <= toX; x++) {
217
+ const point = { x, y: inflated.y }
218
+ const movingOccupiesPoint = moving.spans.some(
219
+ (span) => span.y === point.y && point.x >= span.fromX && point.x <= span.toX,
220
+ )
221
+ if (!(movingOccupiesPoint && pointIsContact(point, existing, contacts))) {
222
+ conflicts.push({ moving, existing, point })
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ return conflicts
231
+ }
232
+
233
+ isFree(claim: SpatialClaim, policy: SpatialCollisionPolicy = {}): boolean {
234
+ return this.conflicts(claim, policy).length === 0
235
+ }
236
+
237
+ firstFit<T extends { claim: SpatialClaim }>(
238
+ candidates: readonly T[],
239
+ policy: SpatialCollisionPolicy = {},
240
+ ): T | undefined {
241
+ return candidates.find((candidate) => this.isFree(candidate.claim, policy))
242
+ }
243
+ }
@@ -0,0 +1,32 @@
1
+ export interface DiagramTextRun {
2
+ text: string
3
+ italic: boolean
4
+ }
5
+
6
+ export interface DiagramTextLine {
7
+ text: string
8
+ runs: DiagramTextRun[]
9
+ }
10
+
11
+ export function parseDiagramTextLines(value: string): DiagramTextLine[] {
12
+ return value.split(/<br\s*\/?>/i).map((line) => {
13
+ const runs: DiagramTextRun[] = []
14
+ const source = line.trim()
15
+ const tag = /<\/?(?:i|em)\s*>/gi
16
+ let italic = false
17
+ let offset = 0
18
+
19
+ for (const match of source.matchAll(tag)) {
20
+ if (match.index > offset) runs.push({ text: source.slice(offset, match.index), italic })
21
+ italic = !match[0].startsWith("</")
22
+ offset = match.index + match[0].length
23
+ }
24
+ if (offset < source.length) runs.push({ text: source.slice(offset), italic })
25
+
26
+ return { text: runs.map((run) => run.text).join(""), runs }
27
+ })
28
+ }
29
+
30
+ export function splitDiagramLines(value: string): string[] {
31
+ return parseDiagramTextLines(value).map((line) => line.text)
32
+ }
@@ -0,0 +1,44 @@
1
+ import stringWidth from "string-width"
2
+ import { splitDiagramLines } from "./text-lines.js"
3
+
4
+ export { parseDiagramTextLines, splitDiagramLines } from "./text-lines.js"
5
+
6
+ export interface DiagramTextBoxSize {
7
+ width: number
8
+ height: number
9
+ lines: string[]
10
+ }
11
+
12
+ export function diagramTextWidth(value: string): number {
13
+ return stringWidth(value)
14
+ }
15
+
16
+ export const DIAGRAM_LABEL_PADDING_X = 1
17
+
18
+ export function padDiagramLabelLine(value: string): string {
19
+ const padding = " ".repeat(DIAGRAM_LABEL_PADDING_X)
20
+ return `${padding}${value}${padding}`
21
+ }
22
+
23
+ export function measureDiagramLabelWidth(value: string, measure = diagramTextWidth): number {
24
+ return Math.max(...splitDiagramLines(value).map((line) => measure(line) + DIAGRAM_LABEL_PADDING_X * 2))
25
+ }
26
+
27
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
28
+
29
+ export function* diagramTextGraphemes(value: string): Generator<string> {
30
+ for (const { segment } of graphemeSegmenter.segment(value)) yield segment
31
+ }
32
+
33
+ export function measureDiagramTextBox(
34
+ value: string,
35
+ options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
36
+ ): DiagramTextBoxSize {
37
+ const lines = splitDiagramLines(value)
38
+ const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
39
+ return {
40
+ width: innerWidth + (options.paddingX ?? 0) * 2,
41
+ height: lines.length + (options.paddingY ?? 0) * 2,
42
+ lines,
43
+ }
44
+ }
@@ -0,0 +1,19 @@
1
+ import type { MermaidDiagramKind } from "./diagnostics.js"
2
+ import { isMermaidClassDiagram } from "./class/parser.js"
3
+ import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
4
+ import { isMermaidGanttDiagram } from "./gantt/parser.js"
5
+ import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js"
6
+ import { isMermaidSequenceDiagram } from "./sequence/parser.js"
7
+ import { isMermaidStateDiagram } from "./state/parser.js"
8
+ import { isMermaidTimelineDiagram } from "./timeline/parser.js"
9
+
10
+ export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
11
+ if (isMermaidClassDiagram(content)) return "classDiagram"
12
+ if (isMermaidFlowchartDiagram(content)) return "flowchart"
13
+ if (isMermaidGanttDiagram(content)) return "gantt"
14
+ if (isMermaidGitGraphDiagram(content)) return "gitGraph"
15
+ if (isMermaidSequenceDiagram(content)) return "sequence"
16
+ if (isMermaidStateDiagram(content)) return "state"
17
+ if (isMermaidTimelineDiagram(content)) return "timeline"
18
+ return undefined
19
+ }
@@ -0,0 +1,16 @@
1
+ export type MermaidDiagramKind = "classDiagram" | "flowchart" | "sequence" | "state" | "timeline" | "gitGraph" | "gantt"
2
+
3
+ /** An otherwise valid diagram contains syntax that this renderer does not support. */
4
+ export class MermaidSyntaxError extends Error {
5
+ readonly _tag = "MermaidSyntaxError"
6
+
7
+ constructor(
8
+ readonly kind: MermaidDiagramKind,
9
+ readonly lineNumber: number,
10
+ readonly sourceLine: string,
11
+ reason = "Unsupported syntax",
12
+ ) {
13
+ super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
14
+ this.name = "MermaidSyntaxError"
15
+ }
16
+ }
@@ -0,0 +1,268 @@
1
+ import {
2
+ parseColor,
3
+ StyledText,
4
+ type ColorInput,
5
+ type RGBA,
6
+ } from "@opentui/core"
7
+ import type { OpenCodeDiagramPalette } from "./palette.js"
8
+ import { drawClassDiagramGrid } from "./class/drawing.js"
9
+ import type { ClassLayoutEngine } from "./class/types.js"
10
+ import { parseMermaidClassDiagram } from "./class/parser.js"
11
+ import { renderClassGridStyledText } from "./class/render-grid.js"
12
+ import { resolveClassStyleColors } from "./class/style.js"
13
+ import { detectMermaidDiagram } from "./detect.js"
14
+ import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js"
15
+ import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
16
+ import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js"
17
+ import { drawGanttDiagramGrid } from "./gantt/drawing.js"
18
+ import { parseMermaidGanttDiagram } from "./gantt/parser.js"
19
+ import { renderGanttGridStyledText } from "./gantt/render-grid.js"
20
+ import { resolveGanttStyleColors } from "./gantt/style.js"
21
+ import type { GanttDiagramRenderOptions } from "./gantt/types.js"
22
+ import { drawGitGraphDiagramGrid } from "./gitgraph/drawing.js"
23
+ import { parseMermaidGitGraphDiagram } from "./gitgraph/parser.js"
24
+ import { renderGitGraphGridStyledText } from "./gitgraph/render-grid.js"
25
+ import { resolveGitGraphStyleColors } from "./gitgraph/style.js"
26
+ import { drawSequenceDiagramGrid } from "./sequence/drawing.js"
27
+ import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
28
+ import { renderSequenceGridStyledText } from "./sequence/render-grid.js"
29
+ import { resolveSequenceStyleColors } from "./sequence/style.js"
30
+ import { drawStateDiagramGrid } from "./state/drawing.js"
31
+ import { parseMermaidStateDiagram } from "./state/parser.js"
32
+ import { renderStateGridStyledText } from "./state/render-grid.js"
33
+ import { resolveStateStyleColors } from "./state/style.js"
34
+ import { drawTimelineDiagramGrid } from "./timeline/drawing.js"
35
+ import { parseMermaidTimelineDiagram } from "./timeline/parser.js"
36
+ import { renderTimelineGridStyledText } from "./timeline/render-grid.js"
37
+ import { resolveTimelineStyleColors } from "./timeline/style.js"
38
+
39
+ type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
40
+
41
+ export interface PreparedDiagram {
42
+ readonly kind: DiagramKind
43
+ readonly source: string
44
+ readonly text: StyledText
45
+ readonly height: number
46
+ }
47
+
48
+ export interface MermaidDiagramOptions {
49
+ /** Use terminal-optimized diagram spacing. Defaults to true. */
50
+ compact?: boolean
51
+ /** Fold responsive horizontal diagrams that exceed this width. Defaults to 120 columns. */
52
+ layoutMaxWidth?: number
53
+ /** Class diagram layout engine. Defaults to builtin. */
54
+ classLayout?: ClassLayoutEngine
55
+ /** Gantt-specific terminal rendering options. */
56
+ gantt?: Omit<GanttDiagramRenderOptions, "layoutMaxWidth">
57
+ colors?: Partial<Record<keyof OpenCodeDiagramPalette, ColorInput>>
58
+ /** Restrict generated diagram colours to a fixed palette (e.g. Solarized). */
59
+ palette?: readonly RGBA[]
60
+ }
61
+
62
+ function color(value: ColorInput | undefined): RGBA | undefined {
63
+ return value === undefined ? undefined : parseColor(value)
64
+ }
65
+
66
+ function prepareDiagram(
67
+ kind: DiagramKind,
68
+ source: string,
69
+ options: MermaidDiagramOptions,
70
+ layoutMaxWidth: number,
71
+ ): PreparedDiagram {
72
+ const colors = options.colors ?? {}
73
+ const compact = options.compact ?? true
74
+ switch (kind) {
75
+ case "classDiagram": {
76
+ const grid = drawClassDiagramGrid(parseMermaidClassDiagram(source), { layoutMaxWidth, classLayout: options.classLayout })
77
+ const size = grid.getTextSize({ trimTop: true, trimBottom: true })
78
+ return {
79
+ kind,
80
+ source,
81
+ text: renderClassGridStyledText(
82
+ grid,
83
+ resolveClassStyleColors({
84
+ border: color(colors.boxBorder ?? colors.muted),
85
+ title: color(colors.boxText ?? colors.primary),
86
+ stereotype: color(colors.groupText ?? colors.group ?? colors.muted),
87
+ member: color(colors.text),
88
+ edge: color(colors.line ?? colors.secondary),
89
+ label: color(colors.text),
90
+ }),
91
+ ),
92
+ height: size.height,
93
+ }
94
+ }
95
+ case "flowchart": {
96
+ const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), {
97
+ compact,
98
+ layoutMaxWidth,
99
+ })
100
+ const size = grid.getTextSize({ trimTop: true, trimBottom: true })
101
+ return {
102
+ kind,
103
+ source,
104
+ text: renderGridStyledText(
105
+ grid,
106
+ resolveFlowchartStyleColors({
107
+ node: color(colors.boxText ?? colors.primary),
108
+ nodeBorder: color(colors.boxBorder ?? colors.muted),
109
+ database: color(colors.boxText ?? colors.primary),
110
+ databaseBorder: color(colors.boxBorder ?? colors.muted),
111
+ edge: color(colors.line ?? colors.secondary),
112
+ label: color(colors.text),
113
+ group: color(colors.group ?? colors.muted),
114
+ groupLabel: color(colors.groupText ?? colors.group ?? colors.muted),
115
+ }),
116
+ { label: color(colors.labelBackground) },
117
+ ),
118
+ height: size.height,
119
+ }
120
+ }
121
+ case "gitGraph": {
122
+ const grid = drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(source))
123
+ const size = grid.getTextSize({ trimBottom: true })
124
+ return {
125
+ kind,
126
+ source,
127
+ text: renderGitGraphGridStyledText(
128
+ grid,
129
+ resolveGitGraphStyleColors({
130
+ primary: color(colors.primary),
131
+ secondary: color(colors.secondary),
132
+ muted: color(colors.muted),
133
+ warning: color(colors.warning),
134
+ text: color(colors.text),
135
+ }),
136
+ ),
137
+ height: size.height,
138
+ }
139
+ }
140
+ case "gantt": {
141
+ const grid = drawGanttDiagramGrid(parseMermaidGanttDiagram(source), { ...options.gantt, layoutMaxWidth })
142
+ const size = grid.getTextSize({ trimBottom: true })
143
+ return {
144
+ kind,
145
+ source,
146
+ text: renderGanttGridStyledText(
147
+ grid,
148
+ resolveGanttStyleColors({
149
+ title: color(colors.text),
150
+ axis: color(colors.muted),
151
+ background: color(colors.background),
152
+ section: color(colors.secondary),
153
+ task: color(colors.primary),
154
+ active: color(colors.primary),
155
+ critical: color(colors.warning),
156
+ done: color(colors.muted),
157
+ }),
158
+ ),
159
+ height: size.height,
160
+ }
161
+ }
162
+ case "sequence": {
163
+ const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact })
164
+ const size = grid.getTextSize()
165
+ return {
166
+ kind,
167
+ source,
168
+ text: renderSequenceGridStyledText(
169
+ grid,
170
+ resolveSequenceStyleColors({
171
+ participant: color(colors.primary),
172
+ lifeline: color(colors.muted),
173
+ group: color(colors.secondary),
174
+ request: color(colors.request ?? colors.primary),
175
+ response: color(colors.response ?? colors.primary),
176
+ fragment: color(colors.secondary),
177
+ fragmentLabelBg: color(colors.background),
178
+ note: color(colors.note ?? colors.warning),
179
+ noteBg: color(colors.noteBackground ?? colors.background),
180
+ }),
181
+ ),
182
+ height: size.height,
183
+ }
184
+ }
185
+ case "state": {
186
+ const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source), { layoutMaxWidth })
187
+ const size = grid.getTextSize({ trimTop: true, trimBottom: true })
188
+ return {
189
+ kind,
190
+ source,
191
+ text: renderStateGridStyledText(
192
+ grid,
193
+ resolveStateStyleColors({
194
+ state: color(colors.boxText ?? colors.primary),
195
+ stateBorder: color(colors.boxBorder ?? colors.primary),
196
+ composite: color(colors.group ?? colors.muted),
197
+ compositeLabel: color(colors.groupText ?? colors.group ?? colors.muted),
198
+ transition: color(colors.line ?? colors.secondary),
199
+ label: color(colors.text),
200
+ noteBorder: color(colors.noteBorder ?? colors.warning),
201
+ noteText: color(colors.noteText ?? colors.warning),
202
+ noteConnector: color(colors.noteConnector ?? colors.muted),
203
+ start: color(colors.marker ?? colors.muted),
204
+ end: color(colors.marker ?? colors.muted),
205
+ choice: color(colors.marker ?? colors.secondary),
206
+ }),
207
+ { label: color(colors.labelBackground) },
208
+ ),
209
+ height: size.height,
210
+ }
211
+ }
212
+ case "timeline": {
213
+ const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source))
214
+ const size = grid.getTextSize({ trimBottom: true })
215
+ return {
216
+ kind,
217
+ source,
218
+ text: renderTimelineGridStyledText(
219
+ grid,
220
+ resolveTimelineStyleColors({
221
+ title: color(colors.text),
222
+ section: color(colors.secondary),
223
+ period: color(colors.warning),
224
+ spine: color(colors.muted),
225
+ event: color(colors.primary),
226
+ }),
227
+ ),
228
+ height: size.height,
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // The view and the tests both need the prepared diagram without a render
235
+ // context. `undefined` means the fence is not a Mermaid diagram at all.
236
+ export function measureMermaidDiagram(
237
+ source: string,
238
+ options: MermaidDiagramOptions = {},
239
+ ): PreparedDiagram | undefined {
240
+ const kind = detectMermaidDiagram(source)
241
+ if (!kind) return undefined
242
+ const configuredMaxWidth =
243
+ options.layoutMaxWidth === undefined ? 120 : Math.max(1, Math.trunc(options.layoutMaxWidth))
244
+ const prepared = prepareDiagram(kind, source, options, configuredMaxWidth)
245
+ const palette = options.palette
246
+ if (!palette?.length) return prepared
247
+ const nearest = (color: RGBA): RGBA => {
248
+ const [r, g, b] = color.toInts()
249
+ let closest = palette[0]!
250
+ let distance = Number.POSITIVE_INFINITY
251
+ for (const candidate of palette) {
252
+ const [cr, cg, cb] = candidate.toInts()
253
+ const difference = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
254
+ if (difference < distance) { distance = difference; closest = candidate }
255
+ }
256
+ return closest
257
+ }
258
+ return {
259
+ ...prepared,
260
+ text: new StyledText(prepared.text.chunks.map((chunk) => ({
261
+ ...chunk,
262
+ fg: chunk.fg ? nearest(chunk.fg) : undefined,
263
+ // Transparent label overlays should remain transparent, not turn into
264
+ // opaque blocks when snapped to one of the 16 palette swatches.
265
+ bg: chunk.bg && chunk.bg.a >= 1 ? nearest(chunk.bg) : undefined,
266
+ }))),
267
+ }
268
+ }