opentui-markdown-render 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,6 +77,25 @@ Press `c` to open the configuration panel. Use Up/Down (or `j`/`k`) to select an
77
77
 
78
78
  See [Examples and demo fixtures](examples/README.md) for the complete fixture matrix and [Getting started](docs/getting-started.md) for a step-by-step walkthrough.
79
79
 
80
+ ### Install the `otmd` CLI
81
+
82
+ The package also provides `otmd`, a terminal Markdown viewer. npm can install the
83
+ package globally, but Bun is still required at runtime because the CLI executes
84
+ TypeScript and uses the Bun native addon loader:
85
+
86
+ ```sh
87
+ npm install -g opentui-markdown-render
88
+ # or: bun add -g opentui-markdown-render
89
+
90
+ otmd --version
91
+ otmd README.md
92
+ cat README.md | otmd -
93
+ ```
94
+
95
+ Use `otmd --help` for options such as `--config` and `--no-config`. The current
96
+ native math prebuild targets Linux x64 with glibc; other platforms keep readable
97
+ math source when the addon is unavailable.
98
+
80
99
  ### Install in an OpenTUI application
81
100
 
82
101
  ```sh
@@ -85,6 +104,37 @@ bun add opentui-markdown-render @opentui/core @opentui/solid solid-js
85
104
 
86
105
  `@opentui/core`, `@opentui/solid`, and `solid-js` are peer dependencies. The package currently targets Bun 1.4+, OpenTUI 0.5.12+, and Solid 1.9+. Native prebuild coverage is currently Linux x64 with glibc.
87
106
 
107
+ ### Configuration discovery
108
+
109
+ With automatic discovery enabled, the nearest project configuration wins. The
110
+ loader checks `opentui-markdown-render.jsonc` and then
111
+ `opentui-markdown-render.json` while walking from the current working directory
112
+ up through its parents. If no project file exists, it checks the XDG user
113
+ configuration directory:
114
+
115
+ ```text
116
+ $XDG_CONFIG_HOME/otmd/config.jsonc # or ~/.config when XDG_CONFIG_HOME is unset
117
+ $XDG_CONFIG_HOME/otmd/config.json
118
+ ```
119
+
120
+ Pass `--config PATH` to `otmd` or `configPath` to `MarkdownRenderer` to select a
121
+ file explicitly. Embedded hosts can pass `discoverConfig={false}` when they own
122
+ configuration themselves.
123
+
124
+ Themes are complete JSON/JSONC files. Select a bundled theme or a user theme
125
+ from `$XDG_CONFIG_HOME/otmd/themes` with:
126
+
127
+ ```jsonc
128
+ {
129
+ "theme": { "name": "dark" }
130
+ }
131
+ ```
132
+
133
+ The repository includes [`themes/example.jsonc`](themes/example.jsonc), a fully
134
+ commented template showing every theme setting. Copy it to
135
+ `~/.config/otmd/themes/my-theme.jsonc` and select it with
136
+ `{ "theme": { "name": "my-theme" } }`.
137
+
88
138
  The smallest useful component looks like this:
89
139
 
90
140
  ```tsx
package/bin/otmd.tsx ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bun
2
+ import { createCliRenderer } from "@opentui/core"
3
+ import { render } from "@opentui/solid"
4
+ import { createSignal, onCleanup, Show } from "solid-js"
5
+ import path from "node:path"
6
+ import {
7
+ MarkdownRenderer,
8
+ StatusLine,
9
+ cliUsage,
10
+ loadRendererConfig,
11
+ parseCliArgs,
12
+ resolveRendererTheme,
13
+ useCanvasGraphics,
14
+ } from "../src"
15
+
16
+ const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json() as { version: string }
17
+
18
+ function fail(message: string): never {
19
+ console.error(`otmd: ${message}`)
20
+ console.error("Try 'otmd --help' for usage.")
21
+ process.exit(2)
22
+ }
23
+
24
+ let command
25
+ try {
26
+ command = parseCliArgs(process.argv.slice(2))
27
+ } catch (error) {
28
+ fail(error instanceof Error ? error.message : String(error))
29
+ }
30
+
31
+ if (command.kind === "help") {
32
+ process.stdout.write(cliUsage)
33
+ process.exit(0)
34
+ }
35
+ if (command.kind === "version") {
36
+ process.stdout.write(`${manifest.version}\n`)
37
+ process.exit(0)
38
+ }
39
+
40
+ const file = command.file
41
+ const filePath = file && file !== "-" ? path.resolve(file) : undefined
42
+ const content = file === "-"
43
+ ? await new Response(Bun.stdin).text()
44
+ : filePath
45
+ ? await readFile(filePath)
46
+ : fail("a Markdown file is required (use - to read stdin)")
47
+ const assetBase = filePath ? path.dirname(filePath) : process.cwd()
48
+ const config = loadRendererConfig(command.configPath, process.cwd(), { discover: command.discoverConfig })
49
+ const renderer = await createCliRenderer({})
50
+
51
+ function Otmd() {
52
+ const graphics = useCanvasGraphics(renderer)
53
+ const theme = () => resolveRendererTheme(renderer, { config, cwd: process.cwd() })
54
+ const [position] = createSignal("1/1")
55
+ const onKeypress = (key: { name?: string }) => {
56
+ if (key.name === "q" || key.name === "escape") {
57
+ renderer.destroy()
58
+ process.exit(0)
59
+ }
60
+ }
61
+ renderer.keyInput.on("keypress", onKeypress)
62
+ onCleanup(() => renderer.keyInput.off("keypress", onKeypress))
63
+ const state = () => ({
64
+ title: filePath ? path.basename(filePath) : "stdin",
65
+ protocol: graphics.protocol(),
66
+ cell: graphics.cell() ? `${graphics.cell()!.width}x${graphics.cell()!.height}` : undefined,
67
+ position: position(),
68
+ })
69
+
70
+ return (
71
+ <box width="100%" height="100%" flexDirection="column" backgroundColor={theme().background.base}>
72
+ <scrollbox width="100%" flexGrow={1} contentOptions={{ paddingLeft: 1, paddingRight: 1, paddingTop: 1, paddingBottom: 1 }}>
73
+ <MarkdownRenderer
74
+ content={() => content}
75
+ renderer={renderer}
76
+ assetBase={() => assetBase}
77
+ config={() => config}
78
+ cwd={process.cwd()}
79
+ discoverConfig={false}
80
+ />
81
+ </scrollbox>
82
+ <Show when={config.statusline?.enabled !== false}>
83
+ <StatusLine theme={theme()} state={state} fields={config.statusline?.fields} separator={config.statusline?.separator} border={config.statusline?.border} style={config.statusline?.style} highlight={config.statusline?.highlight} />
84
+ </Show>
85
+ </box>
86
+ )
87
+ }
88
+
89
+ await render(() => <Otmd />, renderer)
90
+
91
+ async function readFile(filePath: string) {
92
+ const file = Bun.file(filePath)
93
+ if (!(await file.exists())) fail(`file not found: ${filePath}`)
94
+ try {
95
+ return await file.text()
96
+ } catch (error) {
97
+ fail(`unable to read ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
98
+ }
99
+ }
package/package.json CHANGED
@@ -1,20 +1,25 @@
1
1
  {
2
2
  "name": "opentui-markdown-render",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenTUI Markdown renderer with optional native LaTeX math",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "bin": {
8
+ "otmd": "bin/otmd.tsx"
9
+ },
7
10
  "exports": {
8
11
  ".": "./src/index.ts",
9
12
  "./core": "./src/core/index.ts"
10
13
  },
11
14
  "files": [
12
15
  "src",
16
+ "bin",
13
17
  "native/Cargo.toml",
14
18
  "native/Cargo.lock",
15
19
  "native/build.rs",
16
20
  "native/src",
17
21
  "prebuilds",
22
+ "themes",
18
23
  "README.md",
19
24
  "LICENSE",
20
25
  "NOTICE"
package/src/cli.ts ADDED
@@ -0,0 +1,63 @@
1
+ export type CliCommand =
2
+ | { readonly kind: "help" }
3
+ | { readonly kind: "version" }
4
+ | {
5
+ readonly kind: "run"
6
+ readonly file?: string
7
+ readonly configPath?: string
8
+ readonly discoverConfig: boolean
9
+ }
10
+
11
+ export const cliUsage = `Usage: otmd [options] [file]
12
+
13
+ Render a Markdown file in the terminal. Use - to read Markdown from stdin.
14
+
15
+ Options:
16
+ -c, --config <path> Load a specific JSON/JSONC config file
17
+ --no-config Disable automatic config discovery
18
+ -h, --help Show this help
19
+ -v, --version Show the package version
20
+ `
21
+
22
+ export function parseCliArgs(args: readonly string[]): CliCommand {
23
+ let configPath: string | undefined
24
+ let discoverConfig = true
25
+ let file: string | undefined
26
+
27
+ for (let index = 0; index < args.length; index++) {
28
+ const argument = args[index]!
29
+ if (argument === "--") {
30
+ if (file !== undefined || index + 1 >= args.length) throw new Error("Expected one Markdown file after --")
31
+ file = args[++index]
32
+ continue
33
+ }
34
+ if (argument === "-h" || argument === "--help") return { kind: "help" }
35
+ if (argument === "-v" || argument === "--version") return { kind: "version" }
36
+ if (argument === "--no-config") {
37
+ discoverConfig = false
38
+ continue
39
+ }
40
+ if (argument === "-") {
41
+ if (file !== undefined) throw new Error("Only one Markdown file can be rendered")
42
+ file = argument
43
+ continue
44
+ }
45
+ if (argument === "-c" || argument === "--config") {
46
+ const value = args[++index]
47
+ if (!value) throw new Error(`${argument} requires a path`)
48
+ configPath = value
49
+ continue
50
+ }
51
+ if (argument.startsWith("--config=")) {
52
+ const value = argument.slice("--config=".length)
53
+ if (!value) throw new Error("--config requires a path")
54
+ configPath = value
55
+ continue
56
+ }
57
+ if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}`)
58
+ if (file !== undefined) throw new Error("Only one Markdown file can be rendered")
59
+ file = argument
60
+ }
61
+
62
+ return { kind: "run", file, configPath, discoverConfig }
63
+ }
@@ -1,4 +1,4 @@
1
- export { loadConfig, loadRendererConfig, discoverConfig, parseConfig, configFilenames, type ConfigReporter } from "./load"
1
+ export { loadConfig, loadRendererConfig, discoverConfig, parseConfig, configFilenames, xdgConfigFilenames, xdgConfigDirectory, type ConfigEnvironment, type ConfigReporter } from "./load"
2
2
  export {
3
3
  resolveConfiguredTheme,
4
4
  resolveConfiguredLayout,
@@ -31,6 +31,7 @@ export type {
31
31
  StatusLineStyle,
32
32
  ImagesConfig,
33
33
  MermaidConfig,
34
+ ThemeConfig,
34
35
  HeadingsConfig,
35
36
  HeadingLevelConfig,
36
37
  HeadingLevelKey,
@@ -1,11 +1,14 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import os from "node:os"
2
3
  import path from "node:path"
3
4
  import { isFrameAlign, isFrameStyle } from "../core/frame"
4
5
  import { isTableColumnFitter, isTableOverflow, isTableWidthMode } from "../core/tables"
5
6
  import { isHeadingLevelKey, isStatusLineField, isStatusLineStyle, type HeadingLevelConfig, type HeadingsConfig, type MarkdownConfig, type StatusLineField } from "./types"
6
- import { validateTheme } from "./theme"
7
+ import { validateThemeConfig } from "./theme"
7
8
 
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"
9
12
 
10
13
  // Config warnings normally go to stderr. A host that embeds the renderer can
11
14
  // collect them instead and show them in its own UI.
@@ -46,7 +49,15 @@ export function loadRendererConfig(
46
49
  }
47
50
  }
48
51
 
49
- export function discoverConfig(start: string): string | undefined {
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 {
50
61
  let directory = path.resolve(start)
51
62
  while (true) {
52
63
  for (const name of configFilenames) {
@@ -54,24 +65,42 @@ export function discoverConfig(start: string): string | undefined {
54
65
  if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
55
66
  }
56
67
  const parent = path.dirname(directory)
57
- if (parent === directory) return undefined
68
+ if (parent === directory) break
58
69
  directory = parent
59
70
  }
71
+
72
+ return discoverXdgConfig(environment)
60
73
  }
61
74
 
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)}`)
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
68
84
  }
85
+ return undefined
86
+ }
87
+
88
+ export function parseConfig(source: string, file = "config", report?: ConfigReporter): MarkdownConfig {
89
+ const parsed = parseJsonc(source, file)
69
90
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
70
91
  throw new Error(`Invalid config ${file}: expected a JSON object`)
71
92
  }
72
93
  return validateConfig(parsed as Record<string, unknown>, file, report)
73
94
  }
74
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
+
75
104
  const sections = ["theme", "statusline", "images", "mermaid", "headings", "frame", "tables"] as const
76
105
 
77
106
  function validateConfig(value: Record<string, unknown>, file: string, report?: ConfigReporter): MarkdownConfig {
@@ -82,7 +111,7 @@ function validateConfig(value: Record<string, unknown>, file: string, report?: C
82
111
  }
83
112
  }
84
113
  return {
85
- theme: isRecord(value.theme) ? validateTheme(value.theme, file, report) : undefined,
114
+ theme: isRecord(value.theme) ? validateThemeConfig(value.theme, file, report) : undefined,
86
115
  statusline: isRecord(value.statusline) ? validateStatusLine(value.statusline, file, report) : undefined,
87
116
  images: isRecord(value.images) ? validateImages(value.images) : undefined,
88
117
  mermaid: isRecord(value.mermaid) ? validateMermaid(value.mermaid, file, report) : undefined,
@@ -1,5 +1,6 @@
1
1
  import type { Theme, ThemePreset } from "../theme/types"
2
2
  import { resolveTheme } from "../theme/resolve"
3
+ import { loadTheme } from "../theme/load"
3
4
  import { createDocumentLayout, type DocumentLayout, type FrameConfig, type TablesConfig } from "../core"
4
5
  import type { MarkdownConfig } from "./types"
5
6
 
@@ -15,10 +16,8 @@ export type ThemeRuntime = {
15
16
  export function resolveConfiguredTheme(config: MarkdownConfig, runtime: ThemeRuntime = {}): Theme {
16
17
  if (runtime.theme) return runtime.theme
17
18
  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
19
+ const override = loadTheme(explicit ?? config.theme?.name ?? "light")
20
+ const mode = override?.mode ?? "light"
22
21
  return resolveTheme({ mode, override })
23
22
  }
24
23
 
@@ -1,4 +1,5 @@
1
1
  import type { ThemeOverride } from "../theme/types"
2
+ import type { ThemeConfig } from "./types"
2
3
  import type { ConfigReporter } from "./load"
3
4
 
4
5
  // A JSON file only carries strings, not RGBA instances. OpenTUI accepts these
@@ -11,14 +12,15 @@ const colorNames = new Set([
11
12
  "brightblue", "brightyellow", "brightcyan", "brightmagenta", "brightwhite",
12
13
  ])
13
14
 
14
- type Field = "color" | "mode" | "colors" | { readonly [key: string]: Field }
15
+ type Field = "name" | "color" | "mode" | "colors" | { readonly [key: string]: Field }
15
16
  const colors = <K extends string>(keys: readonly K[]): Record<K, Field> =>
16
17
  Object.fromEntries(keys.map((key) => [key, "color"])) as Record<K, Field>
17
18
 
18
19
  // The allowlist follows ThemeOverride, not the resolved Theme: headingLevels
19
20
  // is an internal six-colour array, while heading1..heading6 are file options.
20
- const themeFields: Record<string, Field> = {
21
+ const themeFileFields: Record<string, Field> = {
21
22
  mode: "mode",
23
+ palette: "colors",
22
24
  accent: "color",
23
25
  categorical: "colors",
24
26
  text: {
@@ -84,5 +86,25 @@ export function validateTheme(value: Record<string, unknown>, file: string, repo
84
86
  }
85
87
  return result
86
88
  }
87
- return validate(value, themeFields, "theme") as ThemeOverride
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
88
110
  }
@@ -1,12 +1,11 @@
1
1
  import type { FrameConfig } from "../core/frame"
2
2
  import type { TablesConfig } from "../core/tables"
3
- import type { ThemeOverride } from "../theme/types"
4
3
 
5
4
  // The configuration file is a plain JSON/JSONC object. Every section is
6
5
  // optional; a missing section keeps the bundled default.
7
6
  export type MarkdownConfig = {
8
- // Colour scheme. Mirrors the theme override shape.
9
- readonly theme?: ThemeOverride
7
+ // Selects a complete theme file from the bundled or XDG theme directory.
8
+ readonly theme?: ThemeConfig
10
9
  readonly statusline?: StatusLineConfig
11
10
  readonly images?: ImagesConfig
12
11
  readonly mermaid?: MermaidConfig
@@ -17,6 +16,10 @@ export type MarkdownConfig = {
17
16
  readonly tables?: TablesConfig
18
17
  }
19
18
 
19
+ export type ThemeConfig = {
20
+ readonly name?: string
21
+ }
22
+
20
23
  // The status line is a single row of labelled fields. `fields` selects and
21
24
  // orders them; a field without a value is skipped.
22
25
  export type StatusLineConfig = {
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { MarkdownRenderer, resolveRendererTheme, loadRendererConfig, type MarkdownRendererProps, type MarkdownRendererCommonProps, type MarkdownDiagnostic, type ThemeModeSetting } from "./app"
2
+ export { cliUsage, parseCliArgs, type CliCommand } from "./cli"
2
3
  export type { RenderUnit } from "./view/document"
3
4
  export { parseDocument, type Document, type Block, type Inline, type ListItem, type TableCell, type SourceRange, type UnitInfo } from "./core"
4
5
  export {
@@ -58,6 +59,8 @@ export {
58
59
  discoverConfig,
59
60
  parseConfig,
60
61
  configFilenames,
62
+ xdgConfigFilenames,
63
+ xdgConfigDirectory,
61
64
  resolveConfiguredTheme,
62
65
  resolveConfiguredLayout,
63
66
  statusLineFields,
@@ -71,15 +74,20 @@ export {
71
74
  type StatusLineStyle,
72
75
  type ImagesConfig,
73
76
  type MermaidConfig,
77
+ type ThemeConfig,
74
78
  type HeadingsConfig,
75
79
  type HeadingLevelConfig,
76
80
  type HeadingLevelKey,
77
81
  type ConfigReporter,
82
+ type ConfigEnvironment,
78
83
  type ThemeRuntime,
79
84
  type LayoutRuntime,
80
85
  } from "./config"
81
86
  export {
82
87
  defaultTheme,
88
+ loadTheme,
89
+ bundledThemeNames,
90
+ themeFileNames,
83
91
  generateSyntax,
84
92
  resolveTheme,
85
93
  mergeTheme,
@@ -89,6 +97,7 @@ export {
89
97
  type ThemeMode,
90
98
  type ThemePreset,
91
99
  type ThemeOverride,
100
+ type ThemeEnvironment,
92
101
  type StatusLineTheme,
93
102
  type StatusLineThemeOverride,
94
103
  type MarkdownThemeOverride,
@@ -1,4 +1,6 @@
1
1
  export { defaultTheme } from "./defaults"
2
+ export { loadTheme, bundledThemeNames, themeFileNames } from "./load"
3
+ export type { ThemeEnvironment } from "./load"
2
4
  export { generateSyntax } from "./syntax"
3
5
  export { resolveTheme, mergeTheme, deriveStatusLineTheme } from "./resolve"
4
6
  export { mixColors } from "./colors"
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { parseJsonc } from "../config/load"
5
+ import { validateTheme } from "../config/theme"
6
+ import type { ThemeOverride } from "./types"
7
+
8
+ export const bundledThemeNames = ["light", "dark", "solarized-light", "solarized-dark"] as const
9
+ export const themeFileNames = [".jsonc", ".json"] as const
10
+
11
+ export type ThemeEnvironment = {
12
+ readonly XDG_CONFIG_HOME?: string
13
+ readonly HOME?: string
14
+ }
15
+
16
+ export function loadTheme(
17
+ name: string,
18
+ environment: ThemeEnvironment = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HOME: process.env.HOME },
19
+ ): ThemeOverride | undefined {
20
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) return undefined
21
+ const configured = environment.XDG_CONFIG_HOME
22
+ const configHome = configured && path.isAbsolute(configured)
23
+ ? configured
24
+ : path.join(environment.HOME || os.homedir(), ".config")
25
+ const directories = [
26
+ path.join(configHome, "otmd", "themes"),
27
+ path.resolve(import.meta.dir, "..", "..", "themes"),
28
+ ]
29
+ for (const directory of directories) {
30
+ for (const suffix of themeFileNames) {
31
+ const file = path.join(directory, `${name}${suffix}`)
32
+ if (!existsSync(file) || !statSync(file).isFile()) continue
33
+ const parsed = parseJsonc(readFileSync(file, "utf8"), file)
34
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
35
+ return validateTheme(parsed as Record<string, unknown>, file)
36
+ }
37
+ }
38
+ return undefined
39
+ }
@@ -37,6 +37,7 @@ export function mergeTheme(base: Theme, override: ThemeOverride | undefined): Th
37
37
  if (!override) return base
38
38
  const color = (value: ColorInput | undefined, fallback: RGBA) => (value === undefined ? fallback : parseColor(value))
39
39
  const accent = color(override.accent, base.accent)
40
+ const palette = override.palette ? override.palette.map((entry) => parseColor(entry)) : base.palette
40
41
  const background = { base: color(override.background?.base, base.background.base) }
41
42
  const text = {
42
43
  base: color(override.text?.base, base.text.base),
@@ -56,10 +57,10 @@ export function mergeTheme(base: Theme, override: ThemeOverride | undefined): Th
56
57
  ),
57
58
  }
58
59
  const mode = override.mode ? (override.mode.endsWith("light") ? "light" : "dark") : base.mode
59
- const derived = deriveStatusLineTheme({ mode, accent, background, text, palette: base.palette })
60
+ const derived = deriveStatusLineTheme({ mode, accent, background, text, palette })
60
61
  return {
61
62
  mode,
62
- palette: base.palette,
63
+ palette,
63
64
  accent,
64
65
  categorical: override.categorical ? override.categorical.map((entry) => parseColor(entry)) : base.categorical,
65
66
  text: {
@@ -120,10 +120,11 @@ export type MarkdownThemeOverride = {
120
120
  readonly heading6?: ColorInput
121
121
  }
122
122
 
123
- // Host themes and the config file provide a partial, color-input override that
124
- // is merged over the bundled default for the selected mode.
123
+ // Host-side theme APIs accept a partial color-input override. JSON/JSONC
124
+ // application config selects a complete theme file by name instead.
125
125
  export type ThemeOverride = {
126
126
  readonly mode?: ThemePreset
127
+ readonly palette?: readonly ColorInput[]
127
128
  readonly accent?: ColorInput
128
129
  readonly categorical?: readonly ColorInput[]
129
130
  readonly text?: {
@@ -0,0 +1,32 @@
1
+ {
2
+ "mode": "dark",
3
+ "accent": "#58a6ff",
4
+ "categorical": ["#58a6ff", "#d2a8ff", "#3fb950", "#d29922", "#f85149", "#ffa657"],
5
+ "text": {
6
+ "base": "#e6edf3",
7
+ "muted": "#8b949e",
8
+ "feedback": { "error": "#f85149", "warning": "#d29922", "success": "#3fb950", "info": "#58a6ff" },
9
+ "action": { "primary": { "focused": "#58a6ff" } }
10
+ },
11
+ "background": { "base": "#0d1117" },
12
+ "border": { "base": "#30363d" },
13
+ "frame": { "border": "#30363d", "title": "#8b949e" },
14
+ "markdown": {
15
+ "text": "#e6edf3", "heading": "#d2a8ff",
16
+ "heading1": "#58a6ff", "heading2": "#d2a8ff", "heading3": "#d2a8ff",
17
+ "heading4": "#8b949e", "heading5": "#8b949e", "heading6": "#8b949e",
18
+ "link": "#58a6ff", "linkText": "#58a6ff", "code": "#ffa657",
19
+ "blockQuote": "#8b949e", "emphasis": "#e6edf3", "strong": "#e6edf3",
20
+ "horizontalRule": "#30363d", "listItem": "#e6edf3", "listEnumeration": "#e6edf3",
21
+ "image": "#58a6ff", "imageText": "#58a6ff", "codeBlock": "#e6edf3"
22
+ },
23
+ "syntax": {
24
+ "comment": "#8b949e", "keyword": "#ff7b72", "function": "#d2a8ff",
25
+ "variable": "#ffa657", "string": "#a5d6ff", "number": "#79c0ff",
26
+ "type": "#ffa657", "operator": "#ff7b72", "punctuation": "#c9d1d9"
27
+ },
28
+ "diff": {
29
+ "text": { "added": "#3fb950", "removed": "#f85149", "context": "#8b949e" },
30
+ "background": { "added": "#12261e", "removed": "#25171c", "context": "#161b22" }
31
+ }
32
+ }
@@ -0,0 +1,98 @@
1
+ {
2
+ // The semantic mode controls contrast-sensitive derived colours.
3
+ "mode": "dark",
4
+
5
+ // Optional fixed Mermaid diagram palette. Omit it to derive diagram colours.
6
+ "palette": [
7
+ "#58a6ff", "#d2a8ff", "#3fb950", "#d29922", "#f85149", "#ffa657"
8
+ ],
9
+
10
+ // Main accent and the categorical colours used by diagrams and UI elements.
11
+ "accent": "#58a6ff",
12
+ "categorical": ["#58a6ff", "#d2a8ff", "#3fb950", "#d29922", "#f85149", "#ffa657"],
13
+
14
+ "text": {
15
+ "base": "#e6edf3",
16
+ "muted": "#8b949e",
17
+ "feedback": {
18
+ "error": "#f85149",
19
+ "warning": "#d29922",
20
+ "success": "#3fb950",
21
+ "info": "#58a6ff"
22
+ },
23
+ "action": {
24
+ "primary": {
25
+ "focused": "#58a6ff"
26
+ }
27
+ }
28
+ },
29
+
30
+ "background": {
31
+ "base": "#0d1117"
32
+ },
33
+ "border": {
34
+ "base": "#30363d"
35
+ },
36
+
37
+ // Frame and statusline are optional in the resolved API; these examples show
38
+ // the complete file shape and can be adjusted independently.
39
+ "frame": {
40
+ "border": "#30363d",
41
+ "title": "#8b949e"
42
+ },
43
+ "statusline": {
44
+ "background": "#161b22",
45
+ "foreground": "#e6edf3",
46
+ "title": "#58a6ff",
47
+ "muted": "#8b949e",
48
+ "border": "#58a6ff"
49
+ },
50
+
51
+ "markdown": {
52
+ "text": "#e6edf3",
53
+ "heading": "#d2a8ff",
54
+ "heading1": "#58a6ff",
55
+ "heading2": "#d2a8ff",
56
+ "heading3": "#d2a8ff",
57
+ "heading4": "#8b949e",
58
+ "heading5": "#8b949e",
59
+ "heading6": "#8b949e",
60
+ "link": "#58a6ff",
61
+ "linkText": "#58a6ff",
62
+ "code": "#ffa657",
63
+ "blockQuote": "#8b949e",
64
+ "emphasis": "#e6edf3",
65
+ "strong": "#e6edf3",
66
+ "horizontalRule": "#30363d",
67
+ "listItem": "#e6edf3",
68
+ "listEnumeration": "#e6edf3",
69
+ "image": "#58a6ff",
70
+ "imageText": "#58a6ff",
71
+ "codeBlock": "#e6edf3"
72
+ },
73
+
74
+ "syntax": {
75
+ "comment": "#8b949e",
76
+ "keyword": "#ff7b72",
77
+ "function": "#d2a8ff",
78
+ "variable": "#ffa657",
79
+ "string": "#a5d6ff",
80
+ "number": "#79c0ff",
81
+ "type": "#ffa657",
82
+ "operator": "#ff7b72",
83
+ "punctuation": "#c9d1d9"
84
+ },
85
+
86
+ "diff": {
87
+ "text": {
88
+ "added": "#3fb950",
89
+ "removed": "#f85149",
90
+ "context": "#8b949e"
91
+ },
92
+ "background": {
93
+ "added": "#12261e",
94
+ "removed": "#25171c",
95
+ "context": "#161b22"
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "mode": "light",
3
+ "accent": "#0969da",
4
+ "categorical": ["#0969da", "#8250df", "#1a7f37", "#9a6700", "#cf222e", "#bc4c00"],
5
+ "text": {
6
+ "base": "#1f2328",
7
+ "muted": "#59636e",
8
+ "feedback": { "error": "#cf222e", "warning": "#9a6700", "success": "#1a7f37", "info": "#0969da" },
9
+ "action": { "primary": { "focused": "#0969da" } }
10
+ },
11
+ "background": { "base": "#ffffff" },
12
+ "border": { "base": "#8c959f" },
13
+ "frame": { "border": "#8c959f", "title": "#59636e" },
14
+ "markdown": {
15
+ "text": "#1f2328", "heading": "#8250df",
16
+ "heading1": "#0969da", "heading2": "#8250df", "heading3": "#8250df",
17
+ "heading4": "#6f42c1", "heading5": "#3d444d", "heading6": "#3d444d",
18
+ "link": "#0969da", "linkText": "#0969da", "code": "#953800",
19
+ "blockQuote": "#59636e", "emphasis": "#1f2328", "strong": "#1f2328",
20
+ "horizontalRule": "#d1d9e0", "listItem": "#1f2328", "listEnumeration": "#1f2328",
21
+ "image": "#0969da", "imageText": "#0969da", "codeBlock": "#1f2328"
22
+ },
23
+ "syntax": {
24
+ "comment": "#59636e", "keyword": "#cf222e", "function": "#8250df",
25
+ "variable": "#1f2328", "string": "#0a3069", "number": "#0550ae",
26
+ "type": "#0550ae", "operator": "#0550ae", "punctuation": "#1f2328"
27
+ },
28
+ "diff": {
29
+ "text": { "added": "#1a7f37", "removed": "#cf222e", "context": "#59636e" },
30
+ "background": { "added": "#dafbe1", "removed": "#ffebe9", "context": "#f6f8fa" }
31
+ }
32
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "mode": "dark",
3
+ "palette": ["#002b36", "#073642", "#586e75", "#657b83", "#839496", "#93a1a1", "#eee8d5", "#fdf6e3", "#b58900", "#cb4b16", "#dc322f", "#d33682", "#6c71c4", "#268bd2", "#2aa198", "#859900"],
4
+ "accent": "#268bd2",
5
+ "categorical": ["#268bd2", "#6c71c4", "#859900", "#b58900", "#dc322f", "#cb4b16"],
6
+ "text": {
7
+ "base": "#93a1a1", "muted": "#839496",
8
+ "feedback": { "error": "#dc322f", "warning": "#b58900", "success": "#859900", "info": "#268bd2" },
9
+ "action": { "primary": { "focused": "#268bd2" } }
10
+ },
11
+ "background": { "base": "#002b36" },
12
+ "border": { "base": "#586e75" },
13
+ "frame": { "border": "#586e75", "title": "#93a1a1" },
14
+ "markdown": {
15
+ "text": "#93a1a1", "heading": "#268bd2",
16
+ "heading1": "#268bd2", "heading2": "#268bd2", "heading3": "#268bd2",
17
+ "heading4": "#268bd2", "heading5": "#268bd2", "heading6": "#268bd2",
18
+ "link": "#268bd2", "linkText": "#268bd2", "code": "#cb4b16",
19
+ "blockQuote": "#839496", "emphasis": "#93a1a1", "strong": "#93a1a1",
20
+ "horizontalRule": "#586e75", "listItem": "#93a1a1", "listEnumeration": "#93a1a1",
21
+ "image": "#268bd2", "imageText": "#268bd2", "codeBlock": "#93a1a1"
22
+ },
23
+ "syntax": {
24
+ "comment": "#839496", "keyword": "#dc322f", "function": "#6c71c4",
25
+ "variable": "#93a1a1", "string": "#2aa198", "number": "#d33682",
26
+ "type": "#b58900", "operator": "#268bd2", "punctuation": "#93a1a1"
27
+ },
28
+ "diff": {
29
+ "text": { "added": "#859900", "removed": "#dc322f", "context": "#839496" },
30
+ "background": { "added": "#073642", "removed": "#073642", "context": "#073642" }
31
+ }
32
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "mode": "light",
3
+ "palette": ["#002b36", "#073642", "#586e75", "#657b83", "#839496", "#93a1a1", "#eee8d5", "#fdf6e3", "#b58900", "#cb4b16", "#dc322f", "#d33682", "#6c71c4", "#268bd2", "#2aa198", "#859900"],
4
+ "accent": "#268bd2",
5
+ "categorical": ["#268bd2", "#6c71c4", "#859900", "#b58900", "#dc322f", "#cb4b16"],
6
+ "text": {
7
+ "base": "#586e75", "muted": "#657b83",
8
+ "feedback": { "error": "#dc322f", "warning": "#b58900", "success": "#859900", "info": "#268bd2" },
9
+ "action": { "primary": { "focused": "#268bd2" } }
10
+ },
11
+ "background": { "base": "#fdf6e3" },
12
+ "border": { "base": "#657b83" },
13
+ "frame": { "border": "#657b83", "title": "#586e75" },
14
+ "markdown": {
15
+ "text": "#586e75", "heading": "#268bd2",
16
+ "heading1": "#268bd2", "heading2": "#268bd2", "heading3": "#268bd2",
17
+ "heading4": "#268bd2", "heading5": "#268bd2", "heading6": "#268bd2",
18
+ "link": "#268bd2", "linkText": "#268bd2", "code": "#cb4b16",
19
+ "blockQuote": "#657b83", "emphasis": "#586e75", "strong": "#586e75",
20
+ "horizontalRule": "#657b83", "listItem": "#586e75", "listEnumeration": "#586e75",
21
+ "image": "#268bd2", "imageText": "#268bd2", "codeBlock": "#586e75"
22
+ },
23
+ "syntax": {
24
+ "comment": "#657b83", "keyword": "#dc322f", "function": "#6c71c4",
25
+ "variable": "#586e75", "string": "#2aa198", "number": "#d33682",
26
+ "type": "#b58900", "operator": "#268bd2", "punctuation": "#586e75"
27
+ },
28
+ "diff": {
29
+ "text": { "added": "#859900", "removed": "#dc322f", "context": "#657b83" },
30
+ "background": { "added": "#eee8d5", "removed": "#eee8d5", "context": "#eee8d5" }
31
+ }
32
+ }