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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +173 -2
- 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 +62 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/config/index.ts +45 -0
- package/src/config/load.ts +308 -0
- package/src/config/resolve.ts +37 -0
- package/src/config/theme.ts +88 -0
- package/src/config/types.ts +102 -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 +98 -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 +5 -0
- package/src/theme/resolve.ts +166 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +150 -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/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentui-markdown-render",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
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
|
+
}
|