opentui-markdown-render 0.0.0-stage → 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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +223 -2
  4. package/bin/otmd.tsx +99 -0
  5. package/native/Cargo.lock +3708 -0
  6. package/native/Cargo.toml +28 -0
  7. package/native/build.rs +3 -0
  8. package/native/src/lib.rs +880 -0
  9. package/package.json +67 -4
  10. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  11. package/src/app.tsx +180 -0
  12. package/src/cli.ts +63 -0
  13. package/src/config/index.ts +46 -0
  14. package/src/config/load.ts +337 -0
  15. package/src/config/resolve.ts +36 -0
  16. package/src/config/theme.ts +110 -0
  17. package/src/config/types.ts +105 -0
  18. package/src/core/document.ts +44 -0
  19. package/src/core/frame.ts +144 -0
  20. package/src/core/image.ts +9 -0
  21. package/src/core/index.ts +6 -0
  22. package/src/core/layout.ts +27 -0
  23. package/src/core/math.ts +170 -0
  24. package/src/core/parse.ts +208 -0
  25. package/src/core/tables.ts +58 -0
  26. package/src/graphics.ts +85 -0
  27. package/src/headings.ts +77 -0
  28. package/src/index.ts +107 -0
  29. package/src/math/index.ts +86 -0
  30. package/src/math/native.ts +39 -0
  31. package/src/math/renderer.ts +84 -0
  32. package/src/math/validate.ts +44 -0
  33. package/src/media.tsx +267 -0
  34. package/src/merman/class/dagre-layout.ts +171 -0
  35. package/src/merman/class/diagram.ts +8 -0
  36. package/src/merman/class/drawing.ts +343 -0
  37. package/src/merman/class/labels.ts +38 -0
  38. package/src/merman/class/layout.ts +531 -0
  39. package/src/merman/class/parser.ts +308 -0
  40. package/src/merman/class/render-grid.ts +18 -0
  41. package/src/merman/class/style.ts +27 -0
  42. package/src/merman/class/types.ts +57 -0
  43. package/src/merman/core/canvas.ts +228 -0
  44. package/src/merman/core/color/style.ts +56 -0
  45. package/src/merman/core/drawing.ts +307 -0
  46. package/src/merman/core/geometry.ts +347 -0
  47. package/src/merman/core/mermaid.ts +38 -0
  48. package/src/merman/core/render-grid.ts +51 -0
  49. package/src/merman/core/spatial.ts +243 -0
  50. package/src/merman/core/text-lines.ts +32 -0
  51. package/src/merman/core/text.ts +44 -0
  52. package/src/merman/detect.ts +19 -0
  53. package/src/merman/diagnostics.ts +16 -0
  54. package/src/merman/diagram.ts +268 -0
  55. package/src/merman/flowchart/drawing.ts +433 -0
  56. package/src/merman/flowchart/labels.ts +144 -0
  57. package/src/merman/flowchart/layout.ts +1057 -0
  58. package/src/merman/flowchart/options.ts +12 -0
  59. package/src/merman/flowchart/parser.ts +477 -0
  60. package/src/merman/flowchart/render.ts +10 -0
  61. package/src/merman/flowchart/routing.ts +1429 -0
  62. package/src/merman/flowchart/style.ts +79 -0
  63. package/src/merman/flowchart/types.ts +63 -0
  64. package/src/merman/gantt/diagram.ts +8 -0
  65. package/src/merman/gantt/drawing.ts +193 -0
  66. package/src/merman/gantt/parser.ts +198 -0
  67. package/src/merman/gantt/render-grid.ts +17 -0
  68. package/src/merman/gantt/style.ts +36 -0
  69. package/src/merman/gantt/types.ts +45 -0
  70. package/src/merman/gitgraph/diagram.ts +8 -0
  71. package/src/merman/gitgraph/drawing.ts +234 -0
  72. package/src/merman/gitgraph/parser.ts +234 -0
  73. package/src/merman/gitgraph/render-grid.ts +17 -0
  74. package/src/merman/gitgraph/style.ts +37 -0
  75. package/src/merman/gitgraph/types.ts +36 -0
  76. package/src/merman/palette.ts +60 -0
  77. package/src/merman/sequence/diagram.ts +8 -0
  78. package/src/merman/sequence/drawing.ts +288 -0
  79. package/src/merman/sequence/options.ts +8 -0
  80. package/src/merman/sequence/parser.ts +303 -0
  81. package/src/merman/sequence/placement.ts +692 -0
  82. package/src/merman/sequence/render-grid.ts +22 -0
  83. package/src/merman/sequence/style.ts +81 -0
  84. package/src/merman/sequence/types.ts +74 -0
  85. package/src/merman/state/diagram.ts +8 -0
  86. package/src/merman/state/drawing.ts +363 -0
  87. package/src/merman/state/endpoint.ts +15 -0
  88. package/src/merman/state/layout.ts +1134 -0
  89. package/src/merman/state/note.ts +59 -0
  90. package/src/merman/state/options.ts +11 -0
  91. package/src/merman/state/parser.ts +245 -0
  92. package/src/merman/state/render-grid.ts +22 -0
  93. package/src/merman/state/routing.ts +1522 -0
  94. package/src/merman/state/search.ts +103 -0
  95. package/src/merman/state/style.ts +52 -0
  96. package/src/merman/state/types.ts +63 -0
  97. package/src/merman/state/visible-model.ts +77 -0
  98. package/src/merman/timeline/diagram.ts +8 -0
  99. package/src/merman/timeline/drawing.ts +109 -0
  100. package/src/merman/timeline/parser.ts +119 -0
  101. package/src/merman/timeline/render-grid.ts +17 -0
  102. package/src/merman/timeline/style.ts +36 -0
  103. package/src/merman/timeline/types.ts +31 -0
  104. package/src/merman.ts +3 -0
  105. package/src/statusline.tsx +183 -0
  106. package/src/theme/colors.ts +11 -0
  107. package/src/theme/defaults.ts +190 -0
  108. package/src/theme/index.ts +7 -0
  109. package/src/theme/load.ts +39 -0
  110. package/src/theme/resolve.ts +167 -0
  111. package/src/theme/syntax.ts +96 -0
  112. package/src/theme/types.ts +151 -0
  113. package/src/view/code.tsx +27 -0
  114. package/src/view/document-slots.ts +111 -0
  115. package/src/view/document-table.tsx +98 -0
  116. package/src/view/document.tsx +195 -0
  117. package/src/view/frame.tsx +83 -0
  118. package/src/view/inline-flow.tsx +113 -0
  119. package/src/view/inline-layout.ts +149 -0
  120. package/src/view/inline-math.ts +42 -0
  121. package/src/view/inline-style.ts +103 -0
  122. package/src/view/math.tsx +85 -0
  123. package/src/view/mermaid.tsx +128 -0
  124. package/src/view/table.ts +100 -0
  125. package/themes/dark.json +32 -0
  126. package/themes/example.jsonc +98 -0
  127. package/themes/light.json +32 -0
  128. package/themes/solarized-dark.json +32 -0
  129. package/themes/solarized-light.json +32 -0
@@ -0,0 +1,308 @@
1
+ import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
2
+ import { MermaidSyntaxError } from "../diagnostics.js"
3
+ import type {
4
+ ClassDefinition,
5
+ ClassDiagram,
6
+ ClassDiagramDirection,
7
+ ClassMember,
8
+ ClassRelationship,
9
+ ClassRelationshipKind,
10
+ } from "./types.js"
11
+
12
+ const HEADER_RE = /^classDiagram(?:\s+(TB|BT|LR|RL))?$/i
13
+ const DIRECTION_RE = /^direction\s+(TB|BT|LR|RL)$/i
14
+ const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
15
+ const CLASS_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-~]*$/
16
+ const ANNOTATION_RE = /^<<(.+?)>>\s+(.+)$/
17
+ const STEREOTYPE_ONLY_RE = /^<<(.+?)>>$/
18
+ const CLASS_LABEL_RE = /^([A-Za-z0-9_][A-Za-z0-9_.\-~]*)\s*\[(["'])(.+)\2\]$/
19
+ const CLASS_NOTE_RE = /^note\s+for\s+(\S+)\s+(["'])(.+)\2$/i
20
+
21
+ interface RelationshipOperator {
22
+ token: string
23
+ kind: ClassRelationshipKind
24
+ headAt: "from" | "to" | "none"
25
+ dashed: boolean
26
+ }
27
+
28
+ // Longest first so `--|>` wins over `--` and `-->` over `--` at the same index.
29
+ const RELATIONSHIP_OPERATOR_LIST: RelationshipOperator[] = [
30
+ { token: "<|--", kind: "inheritance", headAt: "from", dashed: false },
31
+ { token: "--|>", kind: "inheritance", headAt: "to", dashed: false },
32
+ { token: "<|..", kind: "realization", headAt: "from", dashed: true },
33
+ { token: "..|>", kind: "realization", headAt: "to", dashed: true },
34
+ { token: "*--", kind: "composition", headAt: "from", dashed: false },
35
+ { token: "--*", kind: "composition", headAt: "to", dashed: false },
36
+ { token: "o--", kind: "aggregation", headAt: "from", dashed: false },
37
+ { token: "--o", kind: "aggregation", headAt: "to", dashed: false },
38
+ { token: "-->", kind: "association", headAt: "to", dashed: false },
39
+ { token: "<--", kind: "association", headAt: "from", dashed: false },
40
+ { token: "..>", kind: "dependency", headAt: "to", dashed: true },
41
+ { token: "<..", kind: "dependency", headAt: "from", dashed: true },
42
+ { token: "--", kind: "link", headAt: "none", dashed: false },
43
+ { token: "..", kind: "link", headAt: "none", dashed: true },
44
+ ]
45
+ const RELATIONSHIP_OPERATORS: RelationshipOperator[] = [...RELATIONSHIP_OPERATOR_LIST].sort(
46
+ (left, right) => right.token.length - left.token.length,
47
+ )
48
+
49
+ export function isMermaidClassDiagram(content: string): boolean {
50
+ return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
51
+ }
52
+
53
+ export function parseMermaidClassDiagram(content: string): ClassDiagram {
54
+ const firstLine = firstMeaningfulMermaidLine(content)
55
+ if (!HEADER_RE.test(firstLine ?? "")) throw syntaxError(1, firstLine ?? "", "Class diagram header is required")
56
+
57
+ const classes: ClassDefinition[] = []
58
+ const byId = new Map<string, ClassDefinition>()
59
+ const relationships: ClassRelationship[] = []
60
+ let direction: ClassDiagramDirection = "TB"
61
+ let current: ClassDefinition | undefined
62
+ let inAccessibilityDescription = false
63
+ let lastLineNumber = 1
64
+ let lastLineText = firstLine ?? ""
65
+
66
+ const ensureClass = (id: string): ClassDefinition => {
67
+ let definition = byId.get(id)
68
+ if (!definition) {
69
+ definition = { id, members: [] }
70
+ byId.set(id, definition)
71
+ classes.push(definition)
72
+ }
73
+ return definition
74
+ }
75
+
76
+ for (const source of meaningfulNumberedMermaidLines(content)) {
77
+ lastLineNumber = source.lineNumber
78
+ const line = stripComment(source.text)
79
+ lastLineText = line
80
+ if (inAccessibilityDescription) {
81
+ if (line === "}") inAccessibilityDescription = false
82
+ continue
83
+ }
84
+ if (/^accDescr\s*\{$/i.test(line)) {
85
+ inAccessibilityDescription = true
86
+ continue
87
+ }
88
+ if (!line || ACCESSIBILITY_RE.test(line)) continue
89
+
90
+ if (current) {
91
+ if (line === "}") {
92
+ current = undefined
93
+ continue
94
+ }
95
+ const stereotype = line.match(STEREOTYPE_ONLY_RE)
96
+ if (stereotype) {
97
+ setStereotype(current, stereotype[1]!, source.lineNumber, line)
98
+ continue
99
+ }
100
+ current.members.push(parseMember(line))
101
+ continue
102
+ }
103
+
104
+ const header = line.match(HEADER_RE)
105
+ if (header) {
106
+ direction = (header[1]?.toUpperCase() as ClassDiagramDirection | undefined) ?? direction
107
+ continue
108
+ }
109
+
110
+ const directionMatch = line.match(DIRECTION_RE)
111
+ if (directionMatch) {
112
+ direction = directionMatch[1]!.toUpperCase() as ClassDiagramDirection
113
+ continue
114
+ }
115
+
116
+ const noteMatch = line.match(CLASS_NOTE_RE)
117
+ if (noteMatch) {
118
+ const id = normalizeClassName(noteMatch[1]!)
119
+ if (!id) throw syntaxError(source.lineNumber, line, "Class note target is not a valid class name")
120
+ const definition = ensureClass(id)
121
+ definition.notes ??= []
122
+ definition.notes.push(stripMermaidQuotes(noteMatch[2]! + noteMatch[3]! + noteMatch[2]!))
123
+ continue
124
+ }
125
+
126
+ const annotation = line.match(ANNOTATION_RE)
127
+ if (annotation) {
128
+ const id = normalizeClassName(annotation[2]!)
129
+ if (!id) throw syntaxError(source.lineNumber, line, "Class annotation target is not a valid class name")
130
+ setStereotype(ensureClass(id), annotation[1]!, source.lineNumber, line)
131
+ continue
132
+ }
133
+
134
+ const classMatch = line.match(/^class\s+(.+)$/i)
135
+ if (classMatch) {
136
+ let raw = classMatch[1]!.trim()
137
+ let openBody = false
138
+ if (raw.endsWith("{")) {
139
+ openBody = true
140
+ raw = raw.slice(0, -1).trim()
141
+ }
142
+ const labelMatch = raw.match(CLASS_LABEL_RE)
143
+ const id = normalizeClassName(labelMatch?.[1] ?? raw)
144
+ if (!id) throw syntaxError(source.lineNumber, line, "Class name is not valid")
145
+ const definition = ensureClass(id)
146
+ if (labelMatch) definition.label = stripMermaidQuotes(labelMatch[2]! + labelMatch[3]! + labelMatch[2]!)
147
+ if (openBody) current = definition
148
+ continue
149
+ }
150
+
151
+ const found = findOperator(line)
152
+ if (found) {
153
+ relationships.push(parseRelationship(line, found.operator, found.index, source.lineNumber, ensureClass))
154
+ continue
155
+ }
156
+
157
+ const colon = line.indexOf(":")
158
+ if (colon > 0) {
159
+ const id = normalizeClassName(line.slice(0, colon))
160
+ if (id) {
161
+ const right = line.slice(colon + 1).trim()
162
+ const stereotype = right.match(STEREOTYPE_ONLY_RE)
163
+ if (stereotype) setStereotype(ensureClass(id), stereotype[1]!, source.lineNumber, line)
164
+ else if (right) ensureClass(id).members.push(parseMember(right))
165
+ continue
166
+ }
167
+ }
168
+
169
+ throw syntaxError(source.lineNumber, line, unsupportedReason(line))
170
+ }
171
+
172
+ if (current) throw syntaxError(lastLineNumber, lastLineText, "Unclosed class body")
173
+ return { direction, classes, relationships }
174
+ }
175
+
176
+ function parseMember(text: string): ClassMember {
177
+ const value = text.trim()
178
+ return { text: value, method: value.includes("(") && value.includes(")") }
179
+ }
180
+
181
+ function setStereotype(definition: ClassDefinition, stereotype: string, lineNumber: number, line: string): void {
182
+ const value = stereotype.trim()
183
+ if (!value) throw syntaxError(lineNumber, line, "Class stereotype cannot be empty")
184
+ definition.stereotype = value
185
+ }
186
+
187
+ function findOperator(line: string): { operator: RelationshipOperator; index: number } | undefined {
188
+ let quote: '"' | "'" | undefined
189
+ for (let index = 0; index < line.length; index++) {
190
+ const char = line[index]
191
+ if (char === '"' || char === "'") {
192
+ if (quote === undefined) quote = char
193
+ else if (quote === char) quote = undefined
194
+ continue
195
+ }
196
+ // Cardinalities and quoted class names can contain operator-looking text such
197
+ // as `"0..*"`, so never treat operator tokens inside quotes as the separator.
198
+ if (quote !== undefined) continue
199
+ for (const operator of RELATIONSHIP_OPERATORS) {
200
+ if (line.startsWith(operator.token, index)) return { operator, index }
201
+ }
202
+ }
203
+ return undefined
204
+ }
205
+
206
+ function parseRelationship(
207
+ line: string,
208
+ operator: RelationshipOperator,
209
+ index: number,
210
+ lineNumber: number,
211
+ ensureClass: (id: string) => ClassDefinition,
212
+ ): ClassRelationship {
213
+ const leftRaw = line.slice(0, index).trim()
214
+ const rightRaw = line.slice(index + operator.token.length).trim()
215
+
216
+ let targetPart = rightRaw
217
+ let label: string | undefined
218
+ const colon = rightRaw.indexOf(":")
219
+ if (colon >= 0) {
220
+ targetPart = rightRaw.slice(0, colon).trim()
221
+ label = stripMermaidQuotes(rightRaw.slice(colon + 1).trim()) || undefined
222
+ }
223
+
224
+ const from = splitSourceCardinality(leftRaw)
225
+ const to = splitTargetCardinality(targetPart)
226
+ if (!from.name) throw syntaxError(lineNumber, line, "Relationship source is missing")
227
+ if (!to.name) throw syntaxError(lineNumber, line, "Relationship target is missing")
228
+ const fromId = normalizeClassName(from.name)
229
+ const toId = normalizeClassName(to.name)
230
+ if (!fromId) throw syntaxError(lineNumber, line, "Relationship source is not a valid class name")
231
+ if (!toId) throw syntaxError(lineNumber, line, "Relationship target is not a valid class name")
232
+ ensureClass(fromId)
233
+ ensureClass(toId)
234
+
235
+ return {
236
+ from: fromId,
237
+ to: toId,
238
+ kind: operator.kind,
239
+ headAt: operator.headAt,
240
+ dashed: operator.dashed,
241
+ ...(label === undefined ? {} : { label }),
242
+ ...(from.cardinality === undefined ? {} : { fromCardinality: from.cardinality }),
243
+ ...(to.cardinality === undefined ? {} : { toCardinality: to.cardinality }),
244
+ }
245
+ }
246
+
247
+ function splitSourceCardinality(value: string): { name: string; cardinality?: string } {
248
+ const match = value.match(/^(.*?)\s*(?:"([^"]*)"|'([^']*)')$/)
249
+ if (match?.[1]?.trim()) return { name: match[1].trim(), cardinality: match[2] ?? match[3] }
250
+ return { name: value }
251
+ }
252
+
253
+ function splitTargetCardinality(value: string): { name: string; cardinality?: string } {
254
+ const match = value.match(/^(?:"([^"]*)"|'([^']*)')\s+(.+)$/)
255
+ if (match) return { name: match[3]!.trim(), cardinality: match[1] ?? match[2] }
256
+ return { name: value }
257
+ }
258
+
259
+ function normalizeClassName(raw: string): string | undefined {
260
+ const trimmed = raw.trim()
261
+ if (!trimmed) return undefined
262
+ const quoted =
263
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))
264
+ if (quoted) {
265
+ const value = trimmed.slice(1, -1).trim()
266
+ return value || undefined
267
+ }
268
+ return CLASS_NAME_RE.test(trimmed) ? trimmed : undefined
269
+ }
270
+
271
+ function stripComment(value: string): string {
272
+ let quote: '"' | "'" | undefined
273
+ for (let index = 0; index < value.length - 1; index += 1) {
274
+ const char = value[index]
275
+ if ((char === '"' || char === "'") && (quote === undefined || quote === char)) {
276
+ quote = quote === char ? undefined : char
277
+ continue
278
+ }
279
+ if (quote === undefined && char === "%" && value[index + 1] === "%") return value.slice(0, index).trim()
280
+ }
281
+ return value.trim()
282
+ }
283
+
284
+ function unsupportedReason(line: string): string {
285
+ const keyword = line.split(/[\s:{]/, 1)[0]?.toLowerCase() ?? ""
286
+ switch (keyword) {
287
+ case "note":
288
+ return "Standalone class diagram notes are not supported"
289
+ case "namespace":
290
+ return "Class diagram namespaces are not supported"
291
+ case "package":
292
+ return "Class diagram packages are not supported"
293
+ case "click":
294
+ case "link":
295
+ case "callback":
296
+ return "Class diagram hyperlinks are not supported"
297
+ case "style":
298
+ case "classdef":
299
+ case "cssclass":
300
+ return "Class diagram styling is not supported"
301
+ default:
302
+ return "Unsupported class diagram syntax"
303
+ }
304
+ }
305
+
306
+ function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
307
+ return new MermaidSyntaxError("classDiagram", lineNumber, sourceLine, reason)
308
+ }
@@ -0,0 +1,18 @@
1
+ import type { StyledText } from "@opentui/core"
2
+ import type { DiagramCanvas } from "../core/canvas.js"
3
+ import { renderDiagramGridStyledText } from "../core/render-grid.js"
4
+ import type { ClassStyleColors } from "./style.js"
5
+ import type { ClassCellStyle } from "./types.js"
6
+
7
+ export type ClassGrid = DiagramCanvas<ClassCellStyle>
8
+
9
+ export function renderClassGridText(grid: ClassGrid): string {
10
+ return grid.toString({ trimTop: true, trimBottom: true })
11
+ }
12
+
13
+ export function renderClassGridStyledText(grid: ClassGrid, colors: ClassStyleColors): StyledText {
14
+ return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
15
+ trimTop: true,
16
+ trimBottom: true,
17
+ })
18
+ }
@@ -0,0 +1,27 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import { rgba, type DiagramRgb } from "../core/color/style.js"
3
+ import type { ClassCellStyle } from "./types.js"
4
+
5
+ const DEFAULT_THEME_RGB = {
6
+ border: [141, 169, 155],
7
+ title: [228, 239, 232],
8
+ stereotype: [154, 184, 169],
9
+ member: [215, 229, 221],
10
+ edge: [134, 225, 200],
11
+ label: [134, 225, 200],
12
+ } as const satisfies Record<ClassCellStyle, DiagramRgb>
13
+
14
+ export type ClassStyleColors = Required<Record<ClassCellStyle, RGBA>>
15
+
16
+ export function resolveClassStyleColors(
17
+ colors: Partial<Record<ClassCellStyle, RGBA | undefined>> = {},
18
+ ): ClassStyleColors {
19
+ return {
20
+ border: colors.border ?? rgba(DEFAULT_THEME_RGB.border),
21
+ title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
22
+ stereotype: colors.stereotype ?? colors.title ?? rgba(DEFAULT_THEME_RGB.stereotype),
23
+ member: colors.member ?? rgba(DEFAULT_THEME_RGB.member),
24
+ edge: colors.edge ?? rgba(DEFAULT_THEME_RGB.edge),
25
+ label: colors.label ?? colors.edge ?? rgba(DEFAULT_THEME_RGB.label),
26
+ }
27
+ }
@@ -0,0 +1,57 @@
1
+ export type ClassDiagramDirection = "TB" | "BT" | "LR" | "RL"
2
+ export type ClassLayoutEngine = "builtin" | "dagre"
3
+
4
+ export type ClassRelationshipKind =
5
+ | "inheritance"
6
+ | "composition"
7
+ | "aggregation"
8
+ | "association"
9
+ | "dependency"
10
+ | "realization"
11
+ | "link"
12
+
13
+ export interface ClassMember {
14
+ /** Display text exactly as written, e.g. `+String name` or `+getAge()`. */
15
+ text: string
16
+ /** Members written with a call shape are methods. */
17
+ method: boolean
18
+ }
19
+
20
+ export interface ClassDefinition {
21
+ id: string
22
+ /** Display label from `class Id["Readable label"]`. */
23
+ label?: string
24
+ /** Annotation written between `<<` and `>>`, e.g. `interface`. */
25
+ stereotype?: string
26
+ /** Notes attached with `note for Id "..."`. */
27
+ notes?: string[]
28
+ members: ClassMember[]
29
+ }
30
+
31
+ export interface ClassRelationship {
32
+ from: string
33
+ to: string
34
+ kind: ClassRelationshipKind
35
+ /** Which endpoint carries the triangle/diamond/arrowhead marker. */
36
+ headAt: "from" | "to" | "none"
37
+ /** Dependency and realization edges draw as dashed lines. */
38
+ dashed: boolean
39
+ label?: string
40
+ fromCardinality?: string
41
+ toCardinality?: string
42
+ }
43
+
44
+ export interface ClassDiagram {
45
+ direction: ClassDiagramDirection
46
+ classes: ClassDefinition[]
47
+ relationships: ClassRelationship[]
48
+ }
49
+
50
+ export interface ClassDiagramRenderOptions {
51
+ /** Fold horizontally over this many columns. Defaults to 120. */
52
+ layoutMaxWidth?: number
53
+ /** Choose the built-in terminal layout or Dagre's layered layout. */
54
+ classLayout?: ClassLayoutEngine
55
+ }
56
+
57
+ export type ClassCellStyle = "border" | "title" | "stereotype" | "member" | "edge" | "label"
@@ -0,0 +1,228 @@
1
+ import stringWidth from "string-width"
2
+ import { diagramTextGraphemes } from "./text.js"
3
+
4
+ export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
5
+ char: string
6
+ style?: Style
7
+ } & Partial<Metadata>
8
+
9
+ export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
10
+ text: string
11
+ style: Style | undefined
12
+ cell: DiagramCanvasCell<Style, Metadata>
13
+ }
14
+
15
+ export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
16
+ measure?: (text: string) => number
17
+ mergeCell?: (
18
+ existing: DiagramCanvasCell<Style, Metadata>,
19
+ incoming: DiagramCanvasCell<Style, Metadata>,
20
+ ) => DiagramCanvasCell<Style, Metadata>
21
+ }
22
+
23
+ export interface DiagramCanvasTextOptions {
24
+ trimTop?: boolean
25
+ trimBottom?: boolean
26
+ }
27
+
28
+ export interface DiagramCanvasTextSize {
29
+ width: number
30
+ height: number
31
+ }
32
+
33
+ export type DiagramCanvasTextMetadata<Metadata extends object> =
34
+ | Partial<Metadata>
35
+ | ((x: number, y: number) => Partial<Metadata>)
36
+
37
+ export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
38
+ key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
39
+ trimTop?: boolean
40
+ trimBottom?: boolean
41
+ }
42
+
43
+ const MAX_DIAGRAM_CELLS = 250_000
44
+
45
+ export class DiagramCanvasSizeError extends Error {
46
+ constructor(
47
+ readonly width: number,
48
+ readonly height: number,
49
+ ) {
50
+ const invalid = !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 0 || height < 0
51
+ super(
52
+ invalid
53
+ ? `Diagram canvas dimensions must be non-negative safe integers, received ${width}x${height}`
54
+ : `Diagram canvas ${width}x${height} exceeds the ${MAX_DIAGRAM_CELLS.toLocaleString()} cell limit`,
55
+ )
56
+ this.name = "DiagramCanvasSizeError"
57
+ }
58
+ }
59
+
60
+ function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
61
+ return { char: " " } as DiagramCanvasCell<Style, Metadata>
62
+ }
63
+
64
+ function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
65
+ return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
66
+ }
67
+
68
+ export class DiagramCanvas<Style extends string, Metadata extends object = object> {
69
+ private readonly cells: Array<Array<DiagramCanvasCell<Style, Metadata>>>
70
+ private readonly measure: (text: string) => number
71
+ private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
72
+ private readonly rowEnds: Uint32Array
73
+
74
+ constructor(
75
+ readonly width: number,
76
+ readonly height: number,
77
+ options: DiagramCanvasOptions<Style, Metadata> = {},
78
+ ) {
79
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 0 || height < 0) {
80
+ throw new DiagramCanvasSizeError(width, height)
81
+ }
82
+ if (width * height > MAX_DIAGRAM_CELLS) throw new DiagramCanvasSizeError(width, height)
83
+ this.measure = options.measure ?? stringWidth
84
+ this.mergeCell = options.mergeCell
85
+ this.cells = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
86
+ this.rowEnds = new Uint32Array(height)
87
+ }
88
+
89
+ get rows(): ReadonlyArray<ReadonlyArray<Readonly<DiagramCanvasCell<Style, Metadata>>>> {
90
+ return this.cells
91
+ }
92
+
93
+ private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd: number): string {
94
+ return row
95
+ .slice(0, rowEnd)
96
+ .map((cell) => cell.char)
97
+ .join("")
98
+ }
99
+
100
+ private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
101
+ let start = 0
102
+ let end = this.cells.length
103
+ if (trimTop) while (start < end && this.rowEnds[start] === 0) start += 1
104
+ if (trimBottom) while (end > start && this.rowEnds[end - 1] === 0) end -= 1
105
+ return { start, end }
106
+ }
107
+
108
+ setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
109
+ this.writeCell(x, y, char, style, metadata, true)
110
+ }
111
+
112
+ replaceCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
113
+ this.writeCell(x, y, char, style, metadata, false)
114
+ }
115
+
116
+ private writeCell(
117
+ x: number,
118
+ y: number,
119
+ char: string,
120
+ style: Style | undefined,
121
+ metadata: Partial<Metadata> | undefined,
122
+ merge: boolean,
123
+ ): void {
124
+ if (y < 0 || y >= this.cells.length || x < 0 || x >= this.cells[y]!.length) return
125
+ const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
126
+ const cell = merge ? (this.mergeCell?.(this.cells[y]![x]!, incoming) ?? incoming) : incoming
127
+ this.cells[y]![x] = cell
128
+ if (cell.char !== " ") {
129
+ this.rowEnds[y] = Math.max(this.rowEnds[y]!, x + 1)
130
+ } else if (this.rowEnds[y] === x + 1) {
131
+ let end = x
132
+ while (end > 0 && this.cells[y]![end - 1]?.char === " ") end -= 1
133
+ this.rowEnds[y] = end
134
+ }
135
+ }
136
+
137
+ getCell(x: number, y: number): Readonly<DiagramCanvasCell<Style, Metadata>> | undefined {
138
+ return this.cells[y]?.[x]
139
+ }
140
+
141
+ setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
142
+ const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
143
+ if (this.measure === stringWidth && /^[\x20-\x7e]*$/.test(text)) {
144
+ for (let index = 0; index < text.length; index++) {
145
+ this.setCell(x + index, y, text[index]!, style, metadataAt(x + index))
146
+ }
147
+ return
148
+ }
149
+
150
+ let offset = 0
151
+ for (const grapheme of diagramTextGraphemes(text)) {
152
+ const width = Math.max(1, this.measure(grapheme))
153
+ if (x + offset < 0 || x + offset + width > this.width) {
154
+ offset += width
155
+ continue
156
+ }
157
+ this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
158
+ for (let continuation = 1; continuation < width; continuation++) {
159
+ this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
160
+ }
161
+ offset += width
162
+ }
163
+ }
164
+
165
+ toString(options: DiagramCanvasTextOptions = {}): string {
166
+ const lines: string[] = []
167
+ const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
168
+ for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
169
+ lines.push(this.rowText(this.cells[rowIndex]!, this.rowEnds[rowIndex]!))
170
+ }
171
+ return lines.join("\n")
172
+ }
173
+
174
+ getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
175
+ const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
176
+ let width = 0
177
+ for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
178
+ const row = this.cells[rowIndex]!
179
+ const rowEnd = this.rowEnds[rowIndex]!
180
+ if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
181
+ }
182
+ return { width, height: rows.end - rows.start }
183
+ }
184
+
185
+ getTextHeight(options: DiagramCanvasTextOptions = {}): number {
186
+ const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
187
+ return rows.end - rows.start
188
+ }
189
+
190
+ forEachRun(
191
+ onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
192
+ onLineEnd: () => void,
193
+ options: DiagramCanvasRunOptions<Style, Metadata> = {},
194
+ ): void {
195
+ const key = options.key
196
+ const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
197
+
198
+ for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
199
+ const row = this.cells[rowIndex]!
200
+ let rowEnd = this.rowEnds[rowIndex]!
201
+ while (rowEnd < row.length && row[rowEnd]?.style !== undefined) rowEnd += 1
202
+
203
+ let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
204
+ let currentKey: readonly unknown[] | undefined
205
+ let currentText = ""
206
+ const flush = () => {
207
+ if (!currentText || !currentCell) return
208
+ onRun({ text: currentText, style: currentCell.style, cell: currentCell })
209
+ currentText = ""
210
+ }
211
+
212
+ for (let x = 0; x < rowEnd; x++) {
213
+ const cell = row[x]!
214
+ const nextKey = key?.(cell)
215
+ const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
216
+ if (!sameRun) {
217
+ flush()
218
+ currentCell = cell
219
+ currentKey = nextKey
220
+ }
221
+ currentText += cell.char
222
+ }
223
+
224
+ flush()
225
+ if (rowIndex < rows.end - 1) onLineEnd()
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,56 @@
1
+ import { RGBA } from "@opentui/core"
2
+
3
+ export type DiagramRgb = readonly [number, number, number]
4
+ export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
5
+
6
+ export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
7
+
8
+ export function numberedStyleKeys<Prefix extends string, Step extends number>(
9
+ prefix: Prefix,
10
+ steps: readonly Step[],
11
+ ): Array<`${Prefix}${Step}`> {
12
+ return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
13
+ }
14
+
15
+ export function rgba(rgb: DiagramRgb): RGBA {
16
+ return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
17
+ }
18
+
19
+ export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
20
+ export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
21
+ export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
22
+ if (!from && !to) return undefined
23
+ if (!from) return to
24
+ if (!to) return from
25
+
26
+ const [fromR, fromG, fromB, fromA] = from.toInts()
27
+ const [toR, toG, toB, toA] = to.toInts()
28
+ const mix = (left: number, right: number) => left + (right - left) * amount
29
+
30
+ return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
31
+ }
32
+
33
+ export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
34
+ if (!left || !right) return left === right
35
+ return left.equals(right)
36
+ }
37
+
38
+ export function createColorRampTheme<Style extends string>(
39
+ styles: readonly Style[],
40
+ from: RGBA,
41
+ to: RGBA,
42
+ ): Record<Style, RGBA>
43
+ export function createColorRampTheme<Style extends string>(
44
+ styles: readonly Style[],
45
+ from: RGBA | undefined,
46
+ to: RGBA | undefined,
47
+ ): Record<Style, RGBA | undefined>
48
+ export function createColorRampTheme<Style extends string>(
49
+ styles: readonly Style[],
50
+ from: RGBA | undefined,
51
+ to: RGBA | undefined,
52
+ ): Record<Style, RGBA | undefined> {
53
+ return Object.fromEntries(
54
+ styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
55
+ ) as Record<Style, RGBA | undefined>
56
+ }