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,337 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { isFrameAlign, isFrameStyle } from "../core/frame"
|
|
5
|
+
import { isTableColumnFitter, isTableOverflow, isTableWidthMode } from "../core/tables"
|
|
6
|
+
import { isHeadingLevelKey, isStatusLineField, isStatusLineStyle, type HeadingLevelConfig, type HeadingsConfig, type MarkdownConfig, type StatusLineField } from "./types"
|
|
7
|
+
import { validateThemeConfig } from "./theme"
|
|
8
|
+
|
|
9
|
+
export const configFilenames = ["opentui-markdown-render.jsonc", "opentui-markdown-render.json"]
|
|
10
|
+
export const xdgConfigFilenames = ["config.jsonc", "config.json"]
|
|
11
|
+
export const xdgConfigDirectory = "otmd"
|
|
12
|
+
|
|
13
|
+
// Config warnings normally go to stderr. A host that embeds the renderer can
|
|
14
|
+
// collect them instead and show them in its own UI.
|
|
15
|
+
export type ConfigReporter = (message: string) => void
|
|
16
|
+
|
|
17
|
+
function warn(report: ConfigReporter | undefined, message: string) {
|
|
18
|
+
if (report) report(message)
|
|
19
|
+
else console.warn(`[opentui-markdown-render] ${message}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The config is an ordinary JSON/JSONC object. Parsing is intentionally
|
|
23
|
+
// forgiving: comments and trailing commas are stripped, everything else has to
|
|
24
|
+
// be valid JSON.
|
|
25
|
+
export function loadConfig(explicitPath?: string, cwd?: string, report?: ConfigReporter): MarkdownConfig {
|
|
26
|
+
const file = explicitPath ?? discoverConfig(cwd ?? process.cwd())
|
|
27
|
+
if (!file) return {}
|
|
28
|
+
if (!existsSync(file)) {
|
|
29
|
+
if (explicitPath) throw new Error(`Config not found: ${file}`)
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
return parseConfig(readFileSync(file, "utf8"), file, report)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Component-facing adapter: malformed or missing explicit files should report
|
|
36
|
+
// diagnostics, not abort the Solid tree. Embedders can disable discovery.
|
|
37
|
+
export function loadRendererConfig(
|
|
38
|
+
configPath?: string,
|
|
39
|
+
cwd?: string,
|
|
40
|
+
options: { readonly discover?: boolean; readonly report?: ConfigReporter } = {},
|
|
41
|
+
): MarkdownConfig {
|
|
42
|
+
if (options.discover === false && !configPath) return {}
|
|
43
|
+
try {
|
|
44
|
+
return loadConfig(configPath, cwd, options.report)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
47
|
+
warn(options.report, message)
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ConfigEnvironment = {
|
|
53
|
+
readonly XDG_CONFIG_HOME?: string
|
|
54
|
+
readonly HOME?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function discoverConfig(
|
|
58
|
+
start: string,
|
|
59
|
+
environment: ConfigEnvironment = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HOME: process.env.HOME },
|
|
60
|
+
): string | undefined {
|
|
61
|
+
let directory = path.resolve(start)
|
|
62
|
+
while (true) {
|
|
63
|
+
for (const name of configFilenames) {
|
|
64
|
+
const candidate = path.join(directory, name)
|
|
65
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
|
|
66
|
+
}
|
|
67
|
+
const parent = path.dirname(directory)
|
|
68
|
+
if (parent === directory) break
|
|
69
|
+
directory = parent
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return discoverXdgConfig(environment)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function discoverXdgConfig(environment: ConfigEnvironment): string | undefined {
|
|
76
|
+
const configured = environment.XDG_CONFIG_HOME
|
|
77
|
+
const configHome = configured && path.isAbsolute(configured)
|
|
78
|
+
? configured
|
|
79
|
+
: path.join(environment.HOME || os.homedir(), ".config")
|
|
80
|
+
const directory = path.join(configHome, xdgConfigDirectory)
|
|
81
|
+
for (const name of xdgConfigFilenames) {
|
|
82
|
+
const candidate = path.join(directory, name)
|
|
83
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseConfig(source: string, file = "config", report?: ConfigReporter): MarkdownConfig {
|
|
89
|
+
const parsed = parseJsonc(source, file)
|
|
90
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
91
|
+
throw new Error(`Invalid config ${file}: expected a JSON object`)
|
|
92
|
+
}
|
|
93
|
+
return validateConfig(parsed as Record<string, unknown>, file, report)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseJsonc(source: string, file = "config"): unknown {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(stripJsonComments(source))
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(`Invalid config ${file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sections = ["theme", "statusline", "images", "mermaid", "headings", "frame", "tables"] as const
|
|
105
|
+
|
|
106
|
+
function validateConfig(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig {
|
|
107
|
+
for (const key of Object.keys(value)) {
|
|
108
|
+
if (key === "$schema") continue
|
|
109
|
+
if (!sections.includes(key as (typeof sections)[number])) {
|
|
110
|
+
warn(report, `Unknown config section "${key}" in ${file}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
theme: isRecord(value.theme) ? validateThemeConfig(value.theme, file, report) : undefined,
|
|
115
|
+
statusline: isRecord(value.statusline) ? validateStatusLine(value.statusline, file, report) : undefined,
|
|
116
|
+
images: isRecord(value.images) ? validateImages(value.images) : undefined,
|
|
117
|
+
mermaid: isRecord(value.mermaid) ? validateMermaid(value.mermaid, file, report) : undefined,
|
|
118
|
+
headings: isRecord(value.headings) ? validateHeadings(value.headings, file, report) : undefined,
|
|
119
|
+
frame: isRecord(value.frame) ? validateFrame(value.frame, file, report) : undefined,
|
|
120
|
+
tables: isRecord(value.tables) ? validateTables(value.tables, file, report) : undefined,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateStatusLine(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["statusline"] {
|
|
125
|
+
const fields: StatusLineField[] = []
|
|
126
|
+
if (Array.isArray(value.fields)) {
|
|
127
|
+
for (const entry of value.fields) {
|
|
128
|
+
if (isStatusLineField(entry)) fields.push(entry)
|
|
129
|
+
else warn(report, `Unknown status line field "${String(entry)}" in ${file}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (value.style !== undefined && !isStatusLineStyle(value.style)) {
|
|
133
|
+
warn(report, `Unknown status line style "${String(value.style)}" in ${file}`)
|
|
134
|
+
}
|
|
135
|
+
if (value.highlight !== undefined && !isStatusLineField(value.highlight)) {
|
|
136
|
+
warn(report, `Unknown status line field "${String(value.highlight)}" in ${file}`)
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : undefined,
|
|
140
|
+
fields: fields.length ? fields : undefined,
|
|
141
|
+
separator: typeof value.separator === "string" ? value.separator : undefined,
|
|
142
|
+
border: value.border === "none" || value.border === "top" || value.border === "bottom" ? value.border : undefined,
|
|
143
|
+
style: isStatusLineStyle(value.style) ? value.style : undefined,
|
|
144
|
+
highlight: isStatusLineField(value.highlight) ? value.highlight : undefined,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateImages(value: Record<string, unknown>): MarkdownConfig["images"] {
|
|
149
|
+
return {
|
|
150
|
+
maxColumns: positiveInteger(value.maxColumns),
|
|
151
|
+
maxRows: positiveInteger(value.maxRows),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateMermaid(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["mermaid"] {
|
|
156
|
+
if (value.classLayout !== undefined && value.classLayout !== "builtin" && value.classLayout !== "dagre") {
|
|
157
|
+
warn(report, `Unknown Mermaid class layout "${String(value.classLayout)}" in ${file}`)
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
compact: typeof value.compact === "boolean" ? value.compact : undefined,
|
|
161
|
+
layoutMaxWidth: positiveInteger(value.layoutMaxWidth),
|
|
162
|
+
classLayout: value.classLayout === "dagre" || value.classLayout === "builtin" ? value.classLayout : undefined,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The frame is the viewer window. An option the renderer does not know is
|
|
167
|
+
// reported and ignored, so a typo in the file never takes the document down.
|
|
168
|
+
function validateFrame(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["frame"] {
|
|
169
|
+
const option = <T>(name: string, check: (input: unknown) => input is T): T | undefined => {
|
|
170
|
+
const entry = value[name]
|
|
171
|
+
if (entry === undefined) return undefined
|
|
172
|
+
if (check(entry)) return entry
|
|
173
|
+
warn(report, `Invalid frame option "${name}" in ${file}`)
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
const padding = (name: string) => {
|
|
177
|
+
const entry = value[name]
|
|
178
|
+
if (entry === undefined) return undefined
|
|
179
|
+
if (isNonNegativeInteger(entry)) return entry
|
|
180
|
+
warn(report, `Invalid frame option "${name}" in ${file}`)
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
const boolean = (name: string) => {
|
|
184
|
+
const entry = value[name]
|
|
185
|
+
if (entry === undefined) return undefined
|
|
186
|
+
if (typeof entry === "boolean") return entry
|
|
187
|
+
warn(report, `Invalid frame option "${name}" in ${file}`)
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
const width = (() => {
|
|
191
|
+
const entry = value.width
|
|
192
|
+
if (entry === undefined) return undefined
|
|
193
|
+
if (typeof entry === "number" && Number.isInteger(entry) && entry > 0) return entry
|
|
194
|
+
warn(report, `Invalid frame option "width" in ${file}`)
|
|
195
|
+
return undefined
|
|
196
|
+
})()
|
|
197
|
+
const title = value.title
|
|
198
|
+
if (title !== undefined && typeof title !== "string") {
|
|
199
|
+
warn(report, `Invalid frame option "title" in ${file}`)
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
enabled: boolean("enabled"),
|
|
203
|
+
width,
|
|
204
|
+
align: option("align", isFrameAlign),
|
|
205
|
+
style: option("style", isFrameStyle),
|
|
206
|
+
paddingX: padding("paddingX"),
|
|
207
|
+
paddingY: padding("paddingY"),
|
|
208
|
+
title: typeof title === "string" ? title : undefined,
|
|
209
|
+
titleAlign: option("titleAlign", isFrameAlign),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateTables(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["tables"] {
|
|
214
|
+
const option = <T>(name: string, check: (input: unknown) => input is T): T | undefined => {
|
|
215
|
+
const entry = value[name]
|
|
216
|
+
if (entry === undefined) return undefined
|
|
217
|
+
if (check(entry)) return entry
|
|
218
|
+
warn(report, `Invalid tables option "${name}" in ${file}`)
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
const padding = value.cellPaddingX
|
|
222
|
+
if (padding !== undefined && !isNonNegativeInteger(padding)) {
|
|
223
|
+
warn(report, `Invalid tables option "cellPaddingX" in ${file}`)
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
widthMode: option("widthMode", isTableWidthMode),
|
|
227
|
+
overflow: option("overflow", isTableOverflow),
|
|
228
|
+
columnFitter: option("columnFitter", isTableColumnFitter),
|
|
229
|
+
cellPaddingX: isNonNegativeInteger(padding) ? padding : undefined,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The heading styles are addressed by level. A level or an option that the
|
|
234
|
+
// renderer does not know is reported and ignored, so a typo in the file never
|
|
235
|
+
// takes the document down.
|
|
236
|
+
function validateHeadings(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["headings"] {
|
|
237
|
+
if (value.levels !== undefined && !isRecord(value.levels)) {
|
|
238
|
+
warn(report, `Invalid heading levels in ${file}`)
|
|
239
|
+
return undefined
|
|
240
|
+
}
|
|
241
|
+
const levels: Record<string, HeadingLevelConfig> = {}
|
|
242
|
+
for (const [key, entry] of Object.entries(value.levels ?? {})) {
|
|
243
|
+
if (!isHeadingLevelKey(key)) {
|
|
244
|
+
warn(report, `Unknown heading level "${key}" in ${file}`)
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
if (!isRecord(entry)) {
|
|
248
|
+
warn(report, `Invalid heading level "${key}" in ${file}`)
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
levels[key] = validateHeadingLevel(key, entry, file, report)
|
|
252
|
+
}
|
|
253
|
+
return Object.keys(levels).length ? { levels: levels as HeadingsConfig["levels"] } : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function validateHeadingLevel(key: string, value: Record<string, unknown>, file: string, report?: ConfigReporter): HeadingLevelConfig {
|
|
257
|
+
const style: Record<string, unknown> = {}
|
|
258
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
259
|
+
switch (name) {
|
|
260
|
+
case "bold":
|
|
261
|
+
case "italic":
|
|
262
|
+
case "underline":
|
|
263
|
+
case "dim":
|
|
264
|
+
case "uppercase":
|
|
265
|
+
if (typeof entry !== "boolean") {
|
|
266
|
+
warn(report, `Invalid heading option "${name}" for level ${key} in ${file}`)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
style[name] = entry
|
|
270
|
+
break
|
|
271
|
+
case "marginTop":
|
|
272
|
+
case "marginBottom":
|
|
273
|
+
if (!isNonNegativeInteger(entry)) {
|
|
274
|
+
warn(report, `Invalid heading option "${name}" for level ${key} in ${file}`)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
style[name] = entry
|
|
278
|
+
break
|
|
279
|
+
default:
|
|
280
|
+
warn(report, `Unknown heading option "${name}" for level ${key} in ${file}`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return style as HeadingLevelConfig
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
287
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function positiveInteger(value: unknown): number | undefined {
|
|
291
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
295
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function stripJsonComments(source: string) {
|
|
299
|
+
let result = ""
|
|
300
|
+
let index = 0
|
|
301
|
+
let inString = false
|
|
302
|
+
let quote = ""
|
|
303
|
+
while (index < source.length) {
|
|
304
|
+
const character = source[index]!
|
|
305
|
+
if (inString) {
|
|
306
|
+
result += character
|
|
307
|
+
if (character === "\\") {
|
|
308
|
+
result += source[index + 1] ?? ""
|
|
309
|
+
index += 2
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (character === quote) inString = false
|
|
313
|
+
index++
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
if (character === '"' || character === "'") {
|
|
317
|
+
inString = true
|
|
318
|
+
quote = character
|
|
319
|
+
result += character
|
|
320
|
+
index++
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (character === "/" && source[index + 1] === "/") {
|
|
324
|
+
while (index < source.length && source[index] !== "\n") index++
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (character === "/" && source[index + 1] === "*") {
|
|
328
|
+
index += 2
|
|
329
|
+
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) index++
|
|
330
|
+
index += 2
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
result += character
|
|
334
|
+
index++
|
|
335
|
+
}
|
|
336
|
+
return result.replace(/,(\s*[}\]])/g, "$1")
|
|
337
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Theme, ThemePreset } from "../theme/types"
|
|
2
|
+
import { resolveTheme } from "../theme/resolve"
|
|
3
|
+
import { loadTheme } from "../theme/load"
|
|
4
|
+
import { createDocumentLayout, type DocumentLayout, type FrameConfig, type TablesConfig } from "../core"
|
|
5
|
+
import type { MarkdownConfig } from "./types"
|
|
6
|
+
|
|
7
|
+
export type ThemeModeSetting = ThemePreset
|
|
8
|
+
|
|
9
|
+
// The configuration describes the document's defaults. The host supplies live
|
|
10
|
+
// values separately; a ready-made theme is never merged with the file theme.
|
|
11
|
+
export type ThemeRuntime = {
|
|
12
|
+
readonly theme?: Theme
|
|
13
|
+
readonly themeMode?: ThemeModeSetting | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveConfiguredTheme(config: MarkdownConfig, runtime: ThemeRuntime = {}): Theme {
|
|
17
|
+
if (runtime.theme) return runtime.theme
|
|
18
|
+
const explicit = runtime.themeMode ?? undefined
|
|
19
|
+
const override = loadTheme(explicit ?? config.theme?.name ?? "light")
|
|
20
|
+
const mode = override?.mode ?? "light"
|
|
21
|
+
return resolveTheme({ mode, override })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type LayoutRuntime = {
|
|
25
|
+
readonly viewport: number
|
|
26
|
+
readonly frame?: FrameConfig
|
|
27
|
+
readonly tables?: TablesConfig
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveConfiguredLayout(config: MarkdownConfig, runtime: LayoutRuntime): DocumentLayout {
|
|
31
|
+
return createDocumentLayout({
|
|
32
|
+
viewport: runtime.viewport,
|
|
33
|
+
frame: runtime.frame ?? config.frame,
|
|
34
|
+
tables: runtime.tables ?? config.tables,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ThemeOverride } from "../theme/types"
|
|
2
|
+
import type { ThemeConfig } from "./types"
|
|
3
|
+
import type { ConfigReporter } from "./load"
|
|
4
|
+
|
|
5
|
+
// A JSON file only carries strings, not RGBA instances. OpenTUI accepts these
|
|
6
|
+
// colour names and hex forms; rejecting anything else here avoids its silent
|
|
7
|
+
// magenta fallback for misspelled colours.
|
|
8
|
+
const colorNames = new Set([
|
|
9
|
+
"transparent", "black", "white", "red", "green", "blue", "yellow", "cyan", "magenta",
|
|
10
|
+
"silver", "gray", "grey", "maroon", "olive", "lime", "aqua", "teal", "navy",
|
|
11
|
+
"fuchsia", "purple", "orange", "brightblack", "brightred", "brightgreen",
|
|
12
|
+
"brightblue", "brightyellow", "brightcyan", "brightmagenta", "brightwhite",
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
type Field = "name" | "color" | "mode" | "colors" | { readonly [key: string]: Field }
|
|
16
|
+
const colors = <K extends string>(keys: readonly K[]): Record<K, Field> =>
|
|
17
|
+
Object.fromEntries(keys.map((key) => [key, "color"])) as Record<K, Field>
|
|
18
|
+
|
|
19
|
+
// The allowlist follows ThemeOverride, not the resolved Theme: headingLevels
|
|
20
|
+
// is an internal six-colour array, while heading1..heading6 are file options.
|
|
21
|
+
const themeFileFields: Record<string, Field> = {
|
|
22
|
+
mode: "mode",
|
|
23
|
+
palette: "colors",
|
|
24
|
+
accent: "color",
|
|
25
|
+
categorical: "colors",
|
|
26
|
+
text: {
|
|
27
|
+
base: "color",
|
|
28
|
+
muted: "color",
|
|
29
|
+
feedback: colors(["error", "warning", "success", "info"]),
|
|
30
|
+
action: { primary: { focused: "color" } },
|
|
31
|
+
},
|
|
32
|
+
background: { base: "color" },
|
|
33
|
+
border: { base: "color" },
|
|
34
|
+
frame: colors(["border", "title"]),
|
|
35
|
+
statusline: colors(["background", "foreground", "title", "muted", "border"]),
|
|
36
|
+
markdown: colors([
|
|
37
|
+
"text", "heading", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6",
|
|
38
|
+
"link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule",
|
|
39
|
+
"listItem", "listEnumeration", "image", "imageText", "codeBlock",
|
|
40
|
+
]),
|
|
41
|
+
syntax: colors(["comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation"]),
|
|
42
|
+
diff: {
|
|
43
|
+
text: colors(["added", "removed", "context"]),
|
|
44
|
+
background: colors(["added", "removed", "context"]),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isColor(value: unknown): value is string {
|
|
49
|
+
return typeof value === "string" && (
|
|
50
|
+
/^#?(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value) ||
|
|
51
|
+
colorNames.has(value.toLowerCase())
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validateTheme(value: Record<string, unknown>, file: string, report?: ConfigReporter): ThemeOverride {
|
|
60
|
+
const warn = (message: string) => {
|
|
61
|
+
if (report) report(message)
|
|
62
|
+
else console.warn(`[opentui-markdown-render] ${message}`)
|
|
63
|
+
}
|
|
64
|
+
const validate = (source: Record<string, unknown>, fields: Record<string, Field>, prefix: string): Record<string, unknown> => {
|
|
65
|
+
const result: Record<string, unknown> = Object.create(null)
|
|
66
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
67
|
+
const field = Object.hasOwn(fields, key) ? fields[key] : undefined
|
|
68
|
+
const location = `${prefix}.${key}`
|
|
69
|
+
if (field === undefined) {
|
|
70
|
+
warn(`Unknown theme option "${location}" in ${file}`)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (field === "color" && isColor(entry)) result[key] = entry
|
|
74
|
+
else if (field === "mode" && (entry === "light" || entry === "dark" || entry === "solarized-light" || entry === "solarized-dark")) result[key] = entry
|
|
75
|
+
else if (field === "colors" && Array.isArray(entry)) {
|
|
76
|
+
result[key] = entry.filter((color, index) => {
|
|
77
|
+
if (isColor(color)) return true
|
|
78
|
+
warn(`Invalid theme option "${location}[${index}]" in ${file}`)
|
|
79
|
+
return false
|
|
80
|
+
})
|
|
81
|
+
} else if (typeof field === "object" && isRecord(entry)) {
|
|
82
|
+
result[key] = validate(entry, field, location)
|
|
83
|
+
} else {
|
|
84
|
+
warn(`Invalid theme option "${location}" in ${file}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
return validate(value, themeFileFields, "theme") as ThemeOverride
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateThemeConfig(value: Record<string, unknown>, file: string, report?: ConfigReporter): ThemeConfig {
|
|
93
|
+
const warn = (message: string) => {
|
|
94
|
+
if (report) report(message)
|
|
95
|
+
else console.warn(`[opentui-markdown-render] ${message}`)
|
|
96
|
+
}
|
|
97
|
+
const result: { name?: string } = {}
|
|
98
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
99
|
+
if (key !== "name") {
|
|
100
|
+
warn(`Unknown theme option "theme.${key}" in ${file}`)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (typeof entry !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(entry)) {
|
|
104
|
+
warn(`Invalid theme option "theme.name" in ${file}`)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
result.name = entry
|
|
108
|
+
}
|
|
109
|
+
return result
|
|
110
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { FrameConfig } from "../core/frame"
|
|
2
|
+
import type { TablesConfig } from "../core/tables"
|
|
3
|
+
|
|
4
|
+
// The configuration file is a plain JSON/JSONC object. Every section is
|
|
5
|
+
// optional; a missing section keeps the bundled default.
|
|
6
|
+
export type MarkdownConfig = {
|
|
7
|
+
// Selects a complete theme file from the bundled or XDG theme directory.
|
|
8
|
+
readonly theme?: ThemeConfig
|
|
9
|
+
readonly statusline?: StatusLineConfig
|
|
10
|
+
readonly images?: ImagesConfig
|
|
11
|
+
readonly mermaid?: MermaidConfig
|
|
12
|
+
readonly headings?: HeadingsConfig
|
|
13
|
+
// How the viewer window is drawn around the document.
|
|
14
|
+
readonly frame?: FrameConfig
|
|
15
|
+
// How tables size themselves and what happens when they are too wide.
|
|
16
|
+
readonly tables?: TablesConfig
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ThemeConfig = {
|
|
20
|
+
readonly name?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The status line is a single row of labelled fields. `fields` selects and
|
|
24
|
+
// orders them; a field without a value is skipped.
|
|
25
|
+
export type StatusLineConfig = {
|
|
26
|
+
readonly enabled?: boolean
|
|
27
|
+
readonly fields?: readonly StatusLineField[]
|
|
28
|
+
readonly separator?: string
|
|
29
|
+
readonly border?: StatusLineBorder
|
|
30
|
+
// "plain" keeps the line in the page colours, "bar" gives it its own
|
|
31
|
+
// background and an accent border.
|
|
32
|
+
readonly style?: StatusLineStyle
|
|
33
|
+
// The field that gets the accent treatment.
|
|
34
|
+
readonly highlight?: StatusLineField
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StatusLineField = "title" | "mode" | "protocol" | "cell" | "mathScale" | "position" | "hint" | "text"
|
|
38
|
+
|
|
39
|
+
export type StatusLineBorder = "none" | "top" | "bottom"
|
|
40
|
+
|
|
41
|
+
export type StatusLineStyle = "plain" | "bar"
|
|
42
|
+
|
|
43
|
+
export const statusLineStyles: readonly StatusLineStyle[] = ["plain", "bar"]
|
|
44
|
+
|
|
45
|
+
export function isStatusLineStyle(value: unknown): value is StatusLineStyle {
|
|
46
|
+
return typeof value === "string" && statusLineStyles.includes(value as StatusLineStyle)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ImagesConfig = {
|
|
50
|
+
// Maximum width of a rendered image in terminal columns.
|
|
51
|
+
readonly maxColumns?: number
|
|
52
|
+
// Maximum height of a rendered image in terminal rows.
|
|
53
|
+
readonly maxRows?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MermaidConfig = {
|
|
57
|
+
readonly compact?: boolean
|
|
58
|
+
readonly layoutMaxWidth?: number
|
|
59
|
+
/** Class diagrams only; defaults to the built-in layout. */
|
|
60
|
+
readonly classLayout?: "builtin" | "dagre"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The heading levels are addressed by their number, the way Markdown writes
|
|
64
|
+
// them.
|
|
65
|
+
export type HeadingLevelKey = "1" | "2" | "3" | "4" | "5" | "6"
|
|
66
|
+
|
|
67
|
+
// The look of a single heading level. Every option is optional: a missing one
|
|
68
|
+
// keeps the bundled default for that level.
|
|
69
|
+
export type HeadingLevelConfig = {
|
|
70
|
+
readonly bold?: boolean
|
|
71
|
+
readonly italic?: boolean
|
|
72
|
+
readonly underline?: boolean
|
|
73
|
+
readonly dim?: boolean
|
|
74
|
+
// Render the text of the heading in upper case.
|
|
75
|
+
readonly uppercase?: boolean
|
|
76
|
+
// Empty rows above the heading.
|
|
77
|
+
readonly marginTop?: number
|
|
78
|
+
// Empty rows below the heading.
|
|
79
|
+
readonly marginBottom?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type HeadingsConfig = {
|
|
83
|
+
readonly levels?: { readonly [K in HeadingLevelKey]?: HeadingLevelConfig }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const headingLevelKeys: readonly HeadingLevelKey[] = ["1", "2", "3", "4", "5", "6"]
|
|
87
|
+
|
|
88
|
+
export function isHeadingLevelKey(value: string): value is HeadingLevelKey {
|
|
89
|
+
return (headingLevelKeys as readonly string[]).includes(value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const statusLineFields: readonly StatusLineField[] = [
|
|
93
|
+
"title",
|
|
94
|
+
"mode",
|
|
95
|
+
"protocol",
|
|
96
|
+
"cell",
|
|
97
|
+
"mathScale",
|
|
98
|
+
"position",
|
|
99
|
+
"hint",
|
|
100
|
+
"text",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
export function isStatusLineField(value: unknown): value is StatusLineField {
|
|
104
|
+
return typeof value === "string" && statusLineFields.includes(value as StatusLineField)
|
|
105
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type SourceRange = { readonly start: number; readonly end: number }
|
|
2
|
+
|
|
3
|
+
export type NodeBase = { readonly raw: string; readonly source: SourceRange | null }
|
|
4
|
+
|
|
5
|
+
export type Document = { readonly source: string; readonly blocks: readonly Block[] }
|
|
6
|
+
|
|
7
|
+
export type Block =
|
|
8
|
+
| (NodeBase & { readonly type: 'paragraph'; readonly children: readonly Inline[] })
|
|
9
|
+
| (NodeBase & { readonly type: 'heading'; readonly level: 1 | 2 | 3 | 4 | 5 | 6; readonly children: readonly Inline[] })
|
|
10
|
+
| (NodeBase & { readonly type: 'quote'; readonly blocks: readonly Block[] })
|
|
11
|
+
| (NodeBase & { readonly type: 'list'; readonly ordered: boolean; readonly start: number; readonly loose: boolean; readonly items: readonly ListItem[] })
|
|
12
|
+
| (NodeBase & { readonly type: 'table'; readonly align: readonly ('left' | 'center' | 'right' | null)[]; readonly header: readonly TableCell[]; readonly rows: readonly (readonly TableCell[])[] })
|
|
13
|
+
| (NodeBase & { readonly type: 'code'; readonly language: string | null; readonly content: string; readonly fenced: boolean })
|
|
14
|
+
| (NodeBase & { readonly type: 'mermaid'; readonly content: string; readonly language: string })
|
|
15
|
+
| (NodeBase & { readonly type: 'rule' })
|
|
16
|
+
| (NodeBase & { readonly type: 'display-math'; readonly content: string; readonly open: '$$' | '\\['; readonly close: '$$' | '\\]' })
|
|
17
|
+
| (NodeBase & { readonly type: 'image'; readonly href: string; readonly alt: string; readonly title: string | null })
|
|
18
|
+
| (NodeBase & { readonly type: 'literal'; readonly text: string })
|
|
19
|
+
|
|
20
|
+
export type ListItem = {
|
|
21
|
+
readonly task: boolean
|
|
22
|
+
readonly checked: boolean | null
|
|
23
|
+
readonly loose: boolean
|
|
24
|
+
readonly blocks: readonly Block[]
|
|
25
|
+
readonly source: SourceRange | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type UnitInfo =
|
|
29
|
+
| { readonly kind: 'block'; readonly block: Exclude<Block, { type: 'list' }>; readonly source: SourceRange | null }
|
|
30
|
+
| { readonly kind: 'list-item'; readonly item: ListItem; readonly source: SourceRange | null }
|
|
31
|
+
|
|
32
|
+
export type TableCell = { readonly children: readonly Inline[]; readonly header: boolean; readonly align: 'left' | 'center' | 'right' | null }
|
|
33
|
+
|
|
34
|
+
export type Inline =
|
|
35
|
+
| { readonly type: 'text' | 'code'; readonly text: string }
|
|
36
|
+
| { readonly type: 'strong' | 'em' | 'del'; readonly children: readonly Inline[] }
|
|
37
|
+
| { readonly type: 'link'; readonly href: string; readonly title: string | null; readonly children: readonly Inline[] }
|
|
38
|
+
| { readonly type: 'inline-image'; readonly href: string; readonly alt: string; readonly title: string | null }
|
|
39
|
+
| { readonly type: 'break'; readonly hard: boolean }
|
|
40
|
+
| { readonly type: 'math'; readonly content: string; readonly open: '$' | '\\('; readonly close: '$' | '\\)'; readonly raw: string }
|
|
41
|
+
|
|
42
|
+
export function assertNever(value: never): never {
|
|
43
|
+
throw new Error(`Unexpected document node: ${String(value)}`)
|
|
44
|
+
}
|