opentui-markdown-render 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +173 -2
  4. package/native/Cargo.lock +3708 -0
  5. package/native/Cargo.toml +28 -0
  6. package/native/build.rs +3 -0
  7. package/native/src/lib.rs +880 -0
  8. package/package.json +62 -4
  9. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  10. package/src/app.tsx +180 -0
  11. package/src/config/index.ts +45 -0
  12. package/src/config/load.ts +308 -0
  13. package/src/config/resolve.ts +37 -0
  14. package/src/config/theme.ts +88 -0
  15. package/src/config/types.ts +102 -0
  16. package/src/core/document.ts +44 -0
  17. package/src/core/frame.ts +144 -0
  18. package/src/core/image.ts +9 -0
  19. package/src/core/index.ts +6 -0
  20. package/src/core/layout.ts +27 -0
  21. package/src/core/math.ts +170 -0
  22. package/src/core/parse.ts +208 -0
  23. package/src/core/tables.ts +58 -0
  24. package/src/graphics.ts +85 -0
  25. package/src/headings.ts +77 -0
  26. package/src/index.ts +98 -0
  27. package/src/math/index.ts +86 -0
  28. package/src/math/native.ts +39 -0
  29. package/src/math/renderer.ts +84 -0
  30. package/src/math/validate.ts +44 -0
  31. package/src/media.tsx +267 -0
  32. package/src/merman/class/dagre-layout.ts +171 -0
  33. package/src/merman/class/diagram.ts +8 -0
  34. package/src/merman/class/drawing.ts +343 -0
  35. package/src/merman/class/labels.ts +38 -0
  36. package/src/merman/class/layout.ts +531 -0
  37. package/src/merman/class/parser.ts +308 -0
  38. package/src/merman/class/render-grid.ts +18 -0
  39. package/src/merman/class/style.ts +27 -0
  40. package/src/merman/class/types.ts +57 -0
  41. package/src/merman/core/canvas.ts +228 -0
  42. package/src/merman/core/color/style.ts +56 -0
  43. package/src/merman/core/drawing.ts +307 -0
  44. package/src/merman/core/geometry.ts +347 -0
  45. package/src/merman/core/mermaid.ts +38 -0
  46. package/src/merman/core/render-grid.ts +51 -0
  47. package/src/merman/core/spatial.ts +243 -0
  48. package/src/merman/core/text-lines.ts +32 -0
  49. package/src/merman/core/text.ts +44 -0
  50. package/src/merman/detect.ts +19 -0
  51. package/src/merman/diagnostics.ts +16 -0
  52. package/src/merman/diagram.ts +268 -0
  53. package/src/merman/flowchart/drawing.ts +433 -0
  54. package/src/merman/flowchart/labels.ts +144 -0
  55. package/src/merman/flowchart/layout.ts +1057 -0
  56. package/src/merman/flowchart/options.ts +12 -0
  57. package/src/merman/flowchart/parser.ts +477 -0
  58. package/src/merman/flowchart/render.ts +10 -0
  59. package/src/merman/flowchart/routing.ts +1429 -0
  60. package/src/merman/flowchart/style.ts +79 -0
  61. package/src/merman/flowchart/types.ts +63 -0
  62. package/src/merman/gantt/diagram.ts +8 -0
  63. package/src/merman/gantt/drawing.ts +193 -0
  64. package/src/merman/gantt/parser.ts +198 -0
  65. package/src/merman/gantt/render-grid.ts +17 -0
  66. package/src/merman/gantt/style.ts +36 -0
  67. package/src/merman/gantt/types.ts +45 -0
  68. package/src/merman/gitgraph/diagram.ts +8 -0
  69. package/src/merman/gitgraph/drawing.ts +234 -0
  70. package/src/merman/gitgraph/parser.ts +234 -0
  71. package/src/merman/gitgraph/render-grid.ts +17 -0
  72. package/src/merman/gitgraph/style.ts +37 -0
  73. package/src/merman/gitgraph/types.ts +36 -0
  74. package/src/merman/palette.ts +60 -0
  75. package/src/merman/sequence/diagram.ts +8 -0
  76. package/src/merman/sequence/drawing.ts +288 -0
  77. package/src/merman/sequence/options.ts +8 -0
  78. package/src/merman/sequence/parser.ts +303 -0
  79. package/src/merman/sequence/placement.ts +692 -0
  80. package/src/merman/sequence/render-grid.ts +22 -0
  81. package/src/merman/sequence/style.ts +81 -0
  82. package/src/merman/sequence/types.ts +74 -0
  83. package/src/merman/state/diagram.ts +8 -0
  84. package/src/merman/state/drawing.ts +363 -0
  85. package/src/merman/state/endpoint.ts +15 -0
  86. package/src/merman/state/layout.ts +1134 -0
  87. package/src/merman/state/note.ts +59 -0
  88. package/src/merman/state/options.ts +11 -0
  89. package/src/merman/state/parser.ts +245 -0
  90. package/src/merman/state/render-grid.ts +22 -0
  91. package/src/merman/state/routing.ts +1522 -0
  92. package/src/merman/state/search.ts +103 -0
  93. package/src/merman/state/style.ts +52 -0
  94. package/src/merman/state/types.ts +63 -0
  95. package/src/merman/state/visible-model.ts +77 -0
  96. package/src/merman/timeline/diagram.ts +8 -0
  97. package/src/merman/timeline/drawing.ts +109 -0
  98. package/src/merman/timeline/parser.ts +119 -0
  99. package/src/merman/timeline/render-grid.ts +17 -0
  100. package/src/merman/timeline/style.ts +36 -0
  101. package/src/merman/timeline/types.ts +31 -0
  102. package/src/merman.ts +3 -0
  103. package/src/statusline.tsx +183 -0
  104. package/src/theme/colors.ts +11 -0
  105. package/src/theme/defaults.ts +190 -0
  106. package/src/theme/index.ts +5 -0
  107. package/src/theme/resolve.ts +166 -0
  108. package/src/theme/syntax.ts +96 -0
  109. package/src/theme/types.ts +150 -0
  110. package/src/view/code.tsx +27 -0
  111. package/src/view/document-slots.ts +111 -0
  112. package/src/view/document-table.tsx +98 -0
  113. package/src/view/document.tsx +195 -0
  114. package/src/view/frame.tsx +83 -0
  115. package/src/view/inline-flow.tsx +113 -0
  116. package/src/view/inline-layout.ts +149 -0
  117. package/src/view/inline-math.ts +42 -0
  118. package/src/view/inline-style.ts +103 -0
  119. package/src/view/math.tsx +85 -0
  120. package/src/view/mermaid.tsx +128 -0
  121. package/src/view/table.ts +100 -0
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "opentui-markdown-render",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "OpenTUI Markdown renderer with optional native LaTeX math",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./core": "./src/core/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "native/Cargo.toml",
14
+ "native/Cargo.lock",
15
+ "native/build.rs",
16
+ "native/src",
17
+ "prebuilds",
18
+ "README.md",
19
+ "LICENSE",
20
+ "NOTICE"
21
+ ],
22
+ "scripts": {
23
+ "build:native": "bun run scripts/build-native.ts",
24
+ "generate:examples": "bun run scripts/generate-example-assets.ts",
25
+ "demo": "bun --preload @opentui/solid/preload examples/app.tsx",
26
+ "demo:plain": "bun --preload @opentui/solid/preload examples/app.tsx plain.md",
27
+ "demo:tables": "bun --preload @opentui/solid/preload examples/app.tsx tables.md",
28
+ "demo:mermaid": "bun --preload @opentui/solid/preload examples/app.tsx mermaid.md",
29
+ "demo:merman-ext": "bun --preload @opentui/solid/preload examples/app.tsx merman-ext.md",
30
+ "demo:math": "bun --preload @opentui/solid/preload examples/app.tsx math.md",
31
+ "demo:images": "bun --preload @opentui/solid/preload examples/app.tsx images.md",
32
+ "demo:mixed": "bun --preload @opentui/solid/preload examples/app.tsx rendered.md",
33
+ "demo:frame": "bun --preload @opentui/solid/preload examples/app.tsx frame.md",
34
+ "test": "bun test --preload @opentui/solid/preload --timeout 30000",
35
+ "typecheck": "tsgo --noEmit",
36
+ "verify": "bun run build:native && bun run typecheck && bun run test && bun run verify:native",
37
+ "verify:native": "cargo test --manifest-path native/Cargo.toml",
38
+ "prepack": "bun run build:native",
39
+ "demo:all": "bun --preload @opentui/solid/preload examples/app.tsx all.md"
40
+ },
41
+ "peerDependencies": {
42
+ "@opentui/core": ">=0.5.12",
43
+ "@opentui/solid": ">=0.5.12",
44
+ "solid-js": ">=1.9.0"
45
+ },
46
+ "dependencies": {
47
+ "@dagrejs/dagre": "3.1.3",
48
+ "entities": "7.0.1",
49
+ "gifuct-js": "2.1.2",
50
+ "marked": "17.0.1",
51
+ "string-width": "7.2.0"
52
+ },
53
+ "devDependencies": {
54
+ "@opentui/core": "0.5.12",
55
+ "@opentui/solid": "0.5.12",
56
+ "@tsconfig/bun": "1.0.9",
57
+ "@types/bun": "1.4.0",
58
+ "@typescript/native-preview": "7.0.0-dev.20251207.1",
59
+ "solid-js": "1.9.15"
60
+ },
61
+ "engines": {
62
+ "bun": ">=1.4.0"
63
+ }
64
+ }
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: package/prebuilds/linux-x64-gnu/opentui_markdown_render.node
3
+ size: 61904384 bytes
4
+ sha256: cf4087e529aed7ecccc19760625ac4f3da122f9cff616d46c821a98bab8780f4
package/src/app.tsx ADDED
@@ -0,0 +1,180 @@
1
+ import type { CliRenderer } from "@opentui/core"
2
+ import { createMemo, createSignal } from "solid-js"
3
+ import { useCanvasGraphics } from "./graphics"
4
+ import { resolveHeadingStyles } from "./headings"
5
+ import { rgbaToHex } from "./math"
6
+ import { loadRendererConfig, resolveConfiguredTheme, resolveConfiguredLayout } from "./config"
7
+ import type { ImagesConfig, MarkdownConfig, ThemeModeSetting } from "./config"
8
+ import { parseDocument, type Document, type FrameConfig, type TablesConfig } from "./core"
9
+ import { MarkdownFrame } from "./view/frame"
10
+ import { DocumentView, type RenderUnit } from "./view/document"
11
+ import type { Theme } from "./theme/types"
12
+
13
+ export type { ThemeModeSetting } from "./config"
14
+ export { loadRendererConfig } from "./config"
15
+
16
+ // A diagnostic is a warning or a failure the host may want to show in its own
17
+ // UI instead of the terminal's stderr.
18
+ export type MarkdownDiagnostic = {
19
+ readonly level: "warning" | "error"
20
+ readonly message: string
21
+ }
22
+
23
+ export type MarkdownRendererCommonProps = {
24
+ readonly renderer: CliRenderer
25
+ readonly columns?: () => number
26
+ readonly assetBase?: () => string | undefined
27
+ // A host that already resolved a theme wins over the config file and default.
28
+ readonly theme?: Theme | (() => Theme)
29
+ // A ready-made configuration. It replaces the file lookup entirely.
30
+ readonly config?: MarkdownConfig | (() => MarkdownConfig)
31
+ // An explicit JSON/JSONC config path. Without one the package walks up from
32
+ // `cwd` looking for `opentui-markdown-render.json[c]`.
33
+ readonly configPath?: string
34
+ readonly themeMode?: ThemeModeSetting | (() => ThemeModeSetting | null | undefined)
35
+ readonly cwd?: string
36
+ // The viewer window. `enabled: false` keeps the document in the whole area.
37
+ readonly frame?: FrameConfig | (() => FrameConfig)
38
+ // Table sizing and overflow. Defaults to content-sized, horizontally
39
+ // scrollable tables.
40
+ readonly tables?: TablesConfig | (() => TablesConfig)
41
+ // An embedded host passes `false` so the renderer never walks the file system
42
+ // for a config that belongs to a standalone viewer.
43
+ readonly discoverConfig?: boolean
44
+ // Warnings and errors are reported here instead of stderr, which lets a host
45
+ // put them in its own UI.
46
+ readonly onDiagnostic?: (diagnostic: MarkdownDiagnostic) => void
47
+ // The host may wrap every top-level block and every item of a top-level list.
48
+ readonly renderUnit?: RenderUnit
49
+ }
50
+
51
+ // The renderer takes either Markdown text or a document the host already parsed.
52
+ // A ready document skips the parser entirely.
53
+ export type MarkdownRendererProps = MarkdownRendererCommonProps & (
54
+ | { readonly content: () => string; readonly document?: never }
55
+ | { readonly document: () => Document; readonly content?: never }
56
+ )
57
+
58
+ // Keep the public host helper, but use the same config/runtime resolution as
59
+ // the component. Loading the file remains an optional concern of this adapter.
60
+ // The renderer argument stays for existing callers, but its terminal theme
61
+ // does not determine the default palette.
62
+ export function resolveRendererTheme(
63
+ renderer: CliRenderer,
64
+ options: {
65
+ theme?: Theme | (() => Theme)
66
+ config?: MarkdownConfig
67
+ configPath?: string
68
+ themeMode?: ThemeModeSetting | null | undefined
69
+ cwd?: string
70
+ } = {},
71
+ ): Theme {
72
+ const host = typeof options.theme === "function" ? options.theme() : options.theme
73
+ if (host) return host
74
+ const config = options.config ?? loadRendererConfig(options.configPath, options.cwd)
75
+ return resolveConfiguredTheme(config, {
76
+ themeMode: options.themeMode,
77
+ })
78
+ }
79
+
80
+ export function MarkdownRenderer(props: MarkdownRendererProps) {
81
+ const renderer = props.renderer
82
+ const graphics = useCanvasGraphics(renderer)
83
+ const surface = () => rgbaToHex(theme().background.base)
84
+ // The document is laid out for the width of the area it is rendered in, not
85
+ // for the width of the terminal: a host that insets the document (a scroll
86
+ // box with padding, a sidebar, a border) leaves less room, and a line that
87
+ // reaches past it is cut off by the terminal. The root box measures itself,
88
+ // so the layout follows the host. An explicit `columns` wins over it.
89
+ const [measured, setMeasured] = createSignal(0)
90
+ // The area the host gives the viewer: the terminal is only the fallback for
91
+ // the first frame, before the root box has been measured.
92
+ const viewportWidth = () => {
93
+ const explicit = props.columns?.()
94
+ if (explicit !== undefined && explicit > 0) return Math.max(8, explicit)
95
+ const width = measured()
96
+ return Math.max(8, width > 0 ? width : renderer.terminalWidth)
97
+ }
98
+
99
+ const themeModeSetting = () => (typeof props.themeMode === "function" ? props.themeMode() : props.themeMode)
100
+ const config = createMemo<MarkdownConfig>(() => {
101
+ const provided = typeof props.config === "function" ? props.config() : props.config
102
+ if (provided) return provided
103
+ return loadRendererConfig(props.configPath, props.cwd, {
104
+ discover: props.discoverConfig,
105
+ report: (message) => {
106
+ const handler = props.onDiagnostic
107
+ if (handler) {
108
+ handler({ level: "warning", message })
109
+ return
110
+ }
111
+ console.warn(`[opentui-markdown-render] ${message}`)
112
+ },
113
+ })
114
+ })
115
+ const theme = createMemo<Theme>(() =>
116
+ resolveRendererTheme(renderer, {
117
+ theme: props.theme,
118
+ config: config(),
119
+ themeMode: themeModeSetting(),
120
+ }),
121
+ )
122
+
123
+ // The frame decides how much of the measured area the document gets. Every
124
+ // block engine reads the same `contentWidth`, so text, tables and formulas
125
+ // cannot disagree about where the right edge is.
126
+ const frameSetting = createMemo<FrameConfig | undefined>(() => {
127
+ const explicit = typeof props.frame === "function" ? props.frame() : props.frame
128
+ return explicit ?? config().frame
129
+ })
130
+ const tableSetting = createMemo<TablesConfig | undefined>(() => {
131
+ const explicit = typeof props.tables === "function" ? props.tables() : props.tables
132
+ return explicit ?? config().tables
133
+ })
134
+ const layout = createMemo(() =>
135
+ resolveConfiguredLayout(config(), { viewport: viewportWidth(), frame: frameSetting(), tables: tableSetting() }),
136
+ )
137
+ const frame = () => layout().frame
138
+ const contentWidth = () => layout().contentWidth
139
+ const tableOptions = () => layout().tables
140
+
141
+ const document = createMemo<Document>(() => {
142
+ const ready = props.document
143
+ if (ready) return ready()
144
+ const content = props.content
145
+ if (!content) throw new Error("MarkdownRenderer requires either a content or a document accessor")
146
+ return parseDocument(content())
147
+ })
148
+ // The look of the heading levels is a configuration of its own: the colours
149
+ // come from the theme, the attributes and the spacing from the config file.
150
+ const headings = createMemo(() => resolveHeadingStyles(config().headings))
151
+ return (
152
+ <box
153
+ width="100%"
154
+ flexDirection="column"
155
+ flexShrink={0}
156
+ backgroundColor={theme().background.base}
157
+ onSizeChange={function () {
158
+ const width = this.width
159
+ if (width !== viewportWidth()) queueMicrotask(() => setMeasured(width))
160
+ }}
161
+ >
162
+ <MarkdownFrame frame={frame()} theme={theme()}>
163
+ <DocumentView
164
+ document={document()}
165
+ columns={contentWidth()}
166
+ theme={theme()}
167
+ headings={headings()}
168
+ graphics={graphics}
169
+ surface={surface}
170
+ assetBase={() => props.assetBase?.()}
171
+ imageLimits={config().images}
172
+ tableOptions={tableOptions()}
173
+ mermaidOptions={config().mermaid}
174
+ onDiagnostic={props.onDiagnostic}
175
+ renderUnit={props.renderUnit}
176
+ />
177
+ </MarkdownFrame>
178
+ </box>
179
+ )
180
+ }
@@ -0,0 +1,45 @@
1
+ export { loadConfig, loadRendererConfig, discoverConfig, parseConfig, configFilenames, type ConfigReporter } from "./load"
2
+ export {
3
+ resolveConfiguredTheme,
4
+ resolveConfiguredLayout,
5
+ type ThemeModeSetting,
6
+ type ThemeRuntime,
7
+ type LayoutRuntime,
8
+ } from "./resolve"
9
+ export {
10
+ statusLineFields,
11
+ isStatusLineField,
12
+ statusLineStyles,
13
+ isStatusLineStyle,
14
+ headingLevelKeys,
15
+ isHeadingLevelKey,
16
+ } from "./types"
17
+ export { isFrameAlign, isFrameStyle, frameAligns, frameStyles } from "../core/frame"
18
+ export {
19
+ isTableWidthMode,
20
+ isTableOverflow,
21
+ isTableColumnFitter,
22
+ tableWidthModes,
23
+ tableOverflows,
24
+ tableColumnFitters,
25
+ } from "../core/tables"
26
+ export type {
27
+ MarkdownConfig,
28
+ StatusLineConfig,
29
+ StatusLineField,
30
+ StatusLineBorder,
31
+ StatusLineStyle,
32
+ ImagesConfig,
33
+ MermaidConfig,
34
+ HeadingsConfig,
35
+ HeadingLevelConfig,
36
+ HeadingLevelKey,
37
+ } from "./types"
38
+ export type { FrameConfig, FrameAlign, FrameStyle, FrameInsets, ResolvedFrame } from "../core/frame"
39
+ export type {
40
+ TablesConfig,
41
+ TableWidthMode,
42
+ TableOverflow,
43
+ TableColumnFitter,
44
+ ResolvedTableOptions,
45
+ } from "../core/tables"
@@ -0,0 +1,308 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { isFrameAlign, isFrameStyle } from "../core/frame"
4
+ import { isTableColumnFitter, isTableOverflow, isTableWidthMode } from "../core/tables"
5
+ import { isHeadingLevelKey, isStatusLineField, isStatusLineStyle, type HeadingLevelConfig, type HeadingsConfig, type MarkdownConfig, type StatusLineField } from "./types"
6
+ import { validateTheme } from "./theme"
7
+
8
+ export const configFilenames = ["opentui-markdown-render.jsonc", "opentui-markdown-render.json"]
9
+
10
+ // Config warnings normally go to stderr. A host that embeds the renderer can
11
+ // collect them instead and show them in its own UI.
12
+ export type ConfigReporter = (message: string) => void
13
+
14
+ function warn(report: ConfigReporter | undefined, message: string) {
15
+ if (report) report(message)
16
+ else console.warn(`[opentui-markdown-render] ${message}`)
17
+ }
18
+
19
+ // The config is an ordinary JSON/JSONC object. Parsing is intentionally
20
+ // forgiving: comments and trailing commas are stripped, everything else has to
21
+ // be valid JSON.
22
+ export function loadConfig(explicitPath?: string, cwd?: string, report?: ConfigReporter): MarkdownConfig {
23
+ const file = explicitPath ?? discoverConfig(cwd ?? process.cwd())
24
+ if (!file) return {}
25
+ if (!existsSync(file)) {
26
+ if (explicitPath) throw new Error(`Config not found: ${file}`)
27
+ return {}
28
+ }
29
+ return parseConfig(readFileSync(file, "utf8"), file, report)
30
+ }
31
+
32
+ // Component-facing adapter: malformed or missing explicit files should report
33
+ // diagnostics, not abort the Solid tree. Embedders can disable discovery.
34
+ export function loadRendererConfig(
35
+ configPath?: string,
36
+ cwd?: string,
37
+ options: { readonly discover?: boolean; readonly report?: ConfigReporter } = {},
38
+ ): MarkdownConfig {
39
+ if (options.discover === false && !configPath) return {}
40
+ try {
41
+ return loadConfig(configPath, cwd, options.report)
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error)
44
+ warn(options.report, message)
45
+ return {}
46
+ }
47
+ }
48
+
49
+ export function discoverConfig(start: string): string | undefined {
50
+ let directory = path.resolve(start)
51
+ while (true) {
52
+ for (const name of configFilenames) {
53
+ const candidate = path.join(directory, name)
54
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
55
+ }
56
+ const parent = path.dirname(directory)
57
+ if (parent === directory) return undefined
58
+ directory = parent
59
+ }
60
+ }
61
+
62
+ export function parseConfig(source: string, file = "config", report?: ConfigReporter): MarkdownConfig {
63
+ let parsed: unknown
64
+ try {
65
+ parsed = JSON.parse(stripJsonComments(source))
66
+ } catch (error) {
67
+ throw new Error(`Invalid config ${file}: ${error instanceof Error ? error.message : String(error)}`)
68
+ }
69
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
70
+ throw new Error(`Invalid config ${file}: expected a JSON object`)
71
+ }
72
+ return validateConfig(parsed as Record<string, unknown>, file, report)
73
+ }
74
+
75
+ const sections = ["theme", "statusline", "images", "mermaid", "headings", "frame", "tables"] as const
76
+
77
+ function validateConfig(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig {
78
+ for (const key of Object.keys(value)) {
79
+ if (key === "$schema") continue
80
+ if (!sections.includes(key as (typeof sections)[number])) {
81
+ warn(report, `Unknown config section "${key}" in ${file}`)
82
+ }
83
+ }
84
+ return {
85
+ theme: isRecord(value.theme) ? validateTheme(value.theme, file, report) : undefined,
86
+ statusline: isRecord(value.statusline) ? validateStatusLine(value.statusline, file, report) : undefined,
87
+ images: isRecord(value.images) ? validateImages(value.images) : undefined,
88
+ mermaid: isRecord(value.mermaid) ? validateMermaid(value.mermaid, file, report) : undefined,
89
+ headings: isRecord(value.headings) ? validateHeadings(value.headings, file, report) : undefined,
90
+ frame: isRecord(value.frame) ? validateFrame(value.frame, file, report) : undefined,
91
+ tables: isRecord(value.tables) ? validateTables(value.tables, file, report) : undefined,
92
+ }
93
+ }
94
+
95
+ function validateStatusLine(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["statusline"] {
96
+ const fields: StatusLineField[] = []
97
+ if (Array.isArray(value.fields)) {
98
+ for (const entry of value.fields) {
99
+ if (isStatusLineField(entry)) fields.push(entry)
100
+ else warn(report, `Unknown status line field "${String(entry)}" in ${file}`)
101
+ }
102
+ }
103
+ if (value.style !== undefined && !isStatusLineStyle(value.style)) {
104
+ warn(report, `Unknown status line style "${String(value.style)}" in ${file}`)
105
+ }
106
+ if (value.highlight !== undefined && !isStatusLineField(value.highlight)) {
107
+ warn(report, `Unknown status line field "${String(value.highlight)}" in ${file}`)
108
+ }
109
+ return {
110
+ enabled: typeof value.enabled === "boolean" ? value.enabled : undefined,
111
+ fields: fields.length ? fields : undefined,
112
+ separator: typeof value.separator === "string" ? value.separator : undefined,
113
+ border: value.border === "none" || value.border === "top" || value.border === "bottom" ? value.border : undefined,
114
+ style: isStatusLineStyle(value.style) ? value.style : undefined,
115
+ highlight: isStatusLineField(value.highlight) ? value.highlight : undefined,
116
+ }
117
+ }
118
+
119
+ function validateImages(value: Record<string, unknown>): MarkdownConfig["images"] {
120
+ return {
121
+ maxColumns: positiveInteger(value.maxColumns),
122
+ maxRows: positiveInteger(value.maxRows),
123
+ }
124
+ }
125
+
126
+ function validateMermaid(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["mermaid"] {
127
+ if (value.classLayout !== undefined && value.classLayout !== "builtin" && value.classLayout !== "dagre") {
128
+ warn(report, `Unknown Mermaid class layout "${String(value.classLayout)}" in ${file}`)
129
+ }
130
+ return {
131
+ compact: typeof value.compact === "boolean" ? value.compact : undefined,
132
+ layoutMaxWidth: positiveInteger(value.layoutMaxWidth),
133
+ classLayout: value.classLayout === "dagre" || value.classLayout === "builtin" ? value.classLayout : undefined,
134
+ }
135
+ }
136
+
137
+ // The frame is the viewer window. An option the renderer does not know is
138
+ // reported and ignored, so a typo in the file never takes the document down.
139
+ function validateFrame(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["frame"] {
140
+ const option = <T>(name: string, check: (input: unknown) => input is T): T | undefined => {
141
+ const entry = value[name]
142
+ if (entry === undefined) return undefined
143
+ if (check(entry)) return entry
144
+ warn(report, `Invalid frame option "${name}" in ${file}`)
145
+ return undefined
146
+ }
147
+ const padding = (name: string) => {
148
+ const entry = value[name]
149
+ if (entry === undefined) return undefined
150
+ if (isNonNegativeInteger(entry)) return entry
151
+ warn(report, `Invalid frame option "${name}" in ${file}`)
152
+ return undefined
153
+ }
154
+ const boolean = (name: string) => {
155
+ const entry = value[name]
156
+ if (entry === undefined) return undefined
157
+ if (typeof entry === "boolean") return entry
158
+ warn(report, `Invalid frame option "${name}" in ${file}`)
159
+ return undefined
160
+ }
161
+ const width = (() => {
162
+ const entry = value.width
163
+ if (entry === undefined) return undefined
164
+ if (typeof entry === "number" && Number.isInteger(entry) && entry > 0) return entry
165
+ warn(report, `Invalid frame option "width" in ${file}`)
166
+ return undefined
167
+ })()
168
+ const title = value.title
169
+ if (title !== undefined && typeof title !== "string") {
170
+ warn(report, `Invalid frame option "title" in ${file}`)
171
+ }
172
+ return {
173
+ enabled: boolean("enabled"),
174
+ width,
175
+ align: option("align", isFrameAlign),
176
+ style: option("style", isFrameStyle),
177
+ paddingX: padding("paddingX"),
178
+ paddingY: padding("paddingY"),
179
+ title: typeof title === "string" ? title : undefined,
180
+ titleAlign: option("titleAlign", isFrameAlign),
181
+ }
182
+ }
183
+
184
+ function validateTables(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["tables"] {
185
+ const option = <T>(name: string, check: (input: unknown) => input is T): T | undefined => {
186
+ const entry = value[name]
187
+ if (entry === undefined) return undefined
188
+ if (check(entry)) return entry
189
+ warn(report, `Invalid tables option "${name}" in ${file}`)
190
+ return undefined
191
+ }
192
+ const padding = value.cellPaddingX
193
+ if (padding !== undefined && !isNonNegativeInteger(padding)) {
194
+ warn(report, `Invalid tables option "cellPaddingX" in ${file}`)
195
+ }
196
+ return {
197
+ widthMode: option("widthMode", isTableWidthMode),
198
+ overflow: option("overflow", isTableOverflow),
199
+ columnFitter: option("columnFitter", isTableColumnFitter),
200
+ cellPaddingX: isNonNegativeInteger(padding) ? padding : undefined,
201
+ }
202
+ }
203
+
204
+ // The heading styles are addressed by level. A level or an option that the
205
+ // renderer does not know is reported and ignored, so a typo in the file never
206
+ // takes the document down.
207
+ function validateHeadings(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig["headings"] {
208
+ if (value.levels !== undefined && !isRecord(value.levels)) {
209
+ warn(report, `Invalid heading levels in ${file}`)
210
+ return undefined
211
+ }
212
+ const levels: Record<string, HeadingLevelConfig> = {}
213
+ for (const [key, entry] of Object.entries(value.levels ?? {})) {
214
+ if (!isHeadingLevelKey(key)) {
215
+ warn(report, `Unknown heading level "${key}" in ${file}`)
216
+ continue
217
+ }
218
+ if (!isRecord(entry)) {
219
+ warn(report, `Invalid heading level "${key}" in ${file}`)
220
+ continue
221
+ }
222
+ levels[key] = validateHeadingLevel(key, entry, file, report)
223
+ }
224
+ return Object.keys(levels).length ? { levels: levels as HeadingsConfig["levels"] } : undefined
225
+ }
226
+
227
+ function validateHeadingLevel(key: string, value: Record<string, unknown>, file: string, report?: ConfigReporter): HeadingLevelConfig {
228
+ const style: Record<string, unknown> = {}
229
+ for (const [name, entry] of Object.entries(value)) {
230
+ switch (name) {
231
+ case "bold":
232
+ case "italic":
233
+ case "underline":
234
+ case "dim":
235
+ case "uppercase":
236
+ if (typeof entry !== "boolean") {
237
+ warn(report, `Invalid heading option "${name}" for level ${key} in ${file}`)
238
+ continue
239
+ }
240
+ style[name] = entry
241
+ break
242
+ case "marginTop":
243
+ case "marginBottom":
244
+ if (!isNonNegativeInteger(entry)) {
245
+ warn(report, `Invalid heading option "${name}" for level ${key} in ${file}`)
246
+ continue
247
+ }
248
+ style[name] = entry
249
+ break
250
+ default:
251
+ warn(report, `Unknown heading option "${name}" for level ${key} in ${file}`)
252
+ }
253
+ }
254
+ return style as HeadingLevelConfig
255
+ }
256
+
257
+ function isNonNegativeInteger(value: unknown): value is number {
258
+ return typeof value === "number" && Number.isInteger(value) && value >= 0
259
+ }
260
+
261
+ function positiveInteger(value: unknown): number | undefined {
262
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined
263
+ }
264
+
265
+ function isRecord(value: unknown): value is Record<string, unknown> {
266
+ return typeof value === "object" && value !== null && !Array.isArray(value)
267
+ }
268
+
269
+ function stripJsonComments(source: string) {
270
+ let result = ""
271
+ let index = 0
272
+ let inString = false
273
+ let quote = ""
274
+ while (index < source.length) {
275
+ const character = source[index]!
276
+ if (inString) {
277
+ result += character
278
+ if (character === "\\") {
279
+ result += source[index + 1] ?? ""
280
+ index += 2
281
+ continue
282
+ }
283
+ if (character === quote) inString = false
284
+ index++
285
+ continue
286
+ }
287
+ if (character === '"' || character === "'") {
288
+ inString = true
289
+ quote = character
290
+ result += character
291
+ index++
292
+ continue
293
+ }
294
+ if (character === "/" && source[index + 1] === "/") {
295
+ while (index < source.length && source[index] !== "\n") index++
296
+ continue
297
+ }
298
+ if (character === "/" && source[index + 1] === "*") {
299
+ index += 2
300
+ while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) index++
301
+ index += 2
302
+ continue
303
+ }
304
+ result += character
305
+ index++
306
+ }
307
+ return result.replace(/,(\s*[}\]])/g, "$1")
308
+ }
@@ -0,0 +1,37 @@
1
+ import type { Theme, ThemePreset } from "../theme/types"
2
+ import { resolveTheme } from "../theme/resolve"
3
+ import { createDocumentLayout, type DocumentLayout, type FrameConfig, type TablesConfig } from "../core"
4
+ import type { MarkdownConfig } from "./types"
5
+
6
+ export type ThemeModeSetting = ThemePreset
7
+
8
+ // The configuration describes the document's defaults. The host supplies live
9
+ // values separately; a ready-made theme is never merged with the file theme.
10
+ export type ThemeRuntime = {
11
+ readonly theme?: Theme
12
+ readonly themeMode?: ThemeModeSetting | null
13
+ }
14
+
15
+ export function resolveConfiguredTheme(config: MarkdownConfig, runtime: ThemeRuntime = {}): Theme {
16
+ if (runtime.theme) return runtime.theme
17
+ const explicit = runtime.themeMode ?? undefined
18
+ const mode = explicit ?? config.theme?.mode ?? "light"
19
+ // resolveTheme normally gives override.mode precedence over mode. An explicit
20
+ // host setting must win over the mode in the file, including for defaults.
21
+ const override = explicit ? { ...config.theme, mode } : config.theme
22
+ return resolveTheme({ mode, override })
23
+ }
24
+
25
+ export type LayoutRuntime = {
26
+ readonly viewport: number
27
+ readonly frame?: FrameConfig
28
+ readonly tables?: TablesConfig
29
+ }
30
+
31
+ export function resolveConfiguredLayout(config: MarkdownConfig, runtime: LayoutRuntime): DocumentLayout {
32
+ return createDocumentLayout({
33
+ viewport: runtime.viewport,
34
+ frame: runtime.frame ?? config.frame,
35
+ tables: runtime.tables ?? config.tables,
36
+ })
37
+ }