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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +173 -2
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +62 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/config/index.ts +45 -0
- package/src/config/load.ts +308 -0
- package/src/config/resolve.ts +37 -0
- package/src/config/theme.ts +88 -0
- package/src/config/types.ts +102 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +98 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/resolve.ts +166 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +150 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- 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
|
+
}
|