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,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
+ }