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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- 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 +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -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 +107 -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 +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -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
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- 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
|
+
}
|