opentui-markdown-render 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +223 -2
  4. package/bin/otmd.tsx +99 -0
  5. package/native/Cargo.lock +3708 -0
  6. package/native/Cargo.toml +28 -0
  7. package/native/build.rs +3 -0
  8. package/native/src/lib.rs +880 -0
  9. package/package.json +67 -4
  10. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  11. package/src/app.tsx +180 -0
  12. package/src/cli.ts +63 -0
  13. package/src/config/index.ts +46 -0
  14. package/src/config/load.ts +337 -0
  15. package/src/config/resolve.ts +36 -0
  16. package/src/config/theme.ts +110 -0
  17. package/src/config/types.ts +105 -0
  18. package/src/core/document.ts +44 -0
  19. package/src/core/frame.ts +144 -0
  20. package/src/core/image.ts +9 -0
  21. package/src/core/index.ts +6 -0
  22. package/src/core/layout.ts +27 -0
  23. package/src/core/math.ts +170 -0
  24. package/src/core/parse.ts +208 -0
  25. package/src/core/tables.ts +58 -0
  26. package/src/graphics.ts +85 -0
  27. package/src/headings.ts +77 -0
  28. package/src/index.ts +107 -0
  29. package/src/math/index.ts +86 -0
  30. package/src/math/native.ts +39 -0
  31. package/src/math/renderer.ts +84 -0
  32. package/src/math/validate.ts +44 -0
  33. package/src/media.tsx +267 -0
  34. package/src/merman/class/dagre-layout.ts +171 -0
  35. package/src/merman/class/diagram.ts +8 -0
  36. package/src/merman/class/drawing.ts +343 -0
  37. package/src/merman/class/labels.ts +38 -0
  38. package/src/merman/class/layout.ts +531 -0
  39. package/src/merman/class/parser.ts +308 -0
  40. package/src/merman/class/render-grid.ts +18 -0
  41. package/src/merman/class/style.ts +27 -0
  42. package/src/merman/class/types.ts +57 -0
  43. package/src/merman/core/canvas.ts +228 -0
  44. package/src/merman/core/color/style.ts +56 -0
  45. package/src/merman/core/drawing.ts +307 -0
  46. package/src/merman/core/geometry.ts +347 -0
  47. package/src/merman/core/mermaid.ts +38 -0
  48. package/src/merman/core/render-grid.ts +51 -0
  49. package/src/merman/core/spatial.ts +243 -0
  50. package/src/merman/core/text-lines.ts +32 -0
  51. package/src/merman/core/text.ts +44 -0
  52. package/src/merman/detect.ts +19 -0
  53. package/src/merman/diagnostics.ts +16 -0
  54. package/src/merman/diagram.ts +268 -0
  55. package/src/merman/flowchart/drawing.ts +433 -0
  56. package/src/merman/flowchart/labels.ts +144 -0
  57. package/src/merman/flowchart/layout.ts +1057 -0
  58. package/src/merman/flowchart/options.ts +12 -0
  59. package/src/merman/flowchart/parser.ts +477 -0
  60. package/src/merman/flowchart/render.ts +10 -0
  61. package/src/merman/flowchart/routing.ts +1429 -0
  62. package/src/merman/flowchart/style.ts +79 -0
  63. package/src/merman/flowchart/types.ts +63 -0
  64. package/src/merman/gantt/diagram.ts +8 -0
  65. package/src/merman/gantt/drawing.ts +193 -0
  66. package/src/merman/gantt/parser.ts +198 -0
  67. package/src/merman/gantt/render-grid.ts +17 -0
  68. package/src/merman/gantt/style.ts +36 -0
  69. package/src/merman/gantt/types.ts +45 -0
  70. package/src/merman/gitgraph/diagram.ts +8 -0
  71. package/src/merman/gitgraph/drawing.ts +234 -0
  72. package/src/merman/gitgraph/parser.ts +234 -0
  73. package/src/merman/gitgraph/render-grid.ts +17 -0
  74. package/src/merman/gitgraph/style.ts +37 -0
  75. package/src/merman/gitgraph/types.ts +36 -0
  76. package/src/merman/palette.ts +60 -0
  77. package/src/merman/sequence/diagram.ts +8 -0
  78. package/src/merman/sequence/drawing.ts +288 -0
  79. package/src/merman/sequence/options.ts +8 -0
  80. package/src/merman/sequence/parser.ts +303 -0
  81. package/src/merman/sequence/placement.ts +692 -0
  82. package/src/merman/sequence/render-grid.ts +22 -0
  83. package/src/merman/sequence/style.ts +81 -0
  84. package/src/merman/sequence/types.ts +74 -0
  85. package/src/merman/state/diagram.ts +8 -0
  86. package/src/merman/state/drawing.ts +363 -0
  87. package/src/merman/state/endpoint.ts +15 -0
  88. package/src/merman/state/layout.ts +1134 -0
  89. package/src/merman/state/note.ts +59 -0
  90. package/src/merman/state/options.ts +11 -0
  91. package/src/merman/state/parser.ts +245 -0
  92. package/src/merman/state/render-grid.ts +22 -0
  93. package/src/merman/state/routing.ts +1522 -0
  94. package/src/merman/state/search.ts +103 -0
  95. package/src/merman/state/style.ts +52 -0
  96. package/src/merman/state/types.ts +63 -0
  97. package/src/merman/state/visible-model.ts +77 -0
  98. package/src/merman/timeline/diagram.ts +8 -0
  99. package/src/merman/timeline/drawing.ts +109 -0
  100. package/src/merman/timeline/parser.ts +119 -0
  101. package/src/merman/timeline/render-grid.ts +17 -0
  102. package/src/merman/timeline/style.ts +36 -0
  103. package/src/merman/timeline/types.ts +31 -0
  104. package/src/merman.ts +3 -0
  105. package/src/statusline.tsx +183 -0
  106. package/src/theme/colors.ts +11 -0
  107. package/src/theme/defaults.ts +190 -0
  108. package/src/theme/index.ts +7 -0
  109. package/src/theme/load.ts +39 -0
  110. package/src/theme/resolve.ts +167 -0
  111. package/src/theme/syntax.ts +96 -0
  112. package/src/theme/types.ts +151 -0
  113. package/src/view/code.tsx +27 -0
  114. package/src/view/document-slots.ts +111 -0
  115. package/src/view/document-table.tsx +98 -0
  116. package/src/view/document.tsx +195 -0
  117. package/src/view/frame.tsx +83 -0
  118. package/src/view/inline-flow.tsx +113 -0
  119. package/src/view/inline-layout.ts +149 -0
  120. package/src/view/inline-math.ts +42 -0
  121. package/src/view/inline-style.ts +103 -0
  122. package/src/view/math.tsx +85 -0
  123. package/src/view/mermaid.tsx +128 -0
  124. package/src/view/table.ts +100 -0
  125. package/themes/dark.json +32 -0
  126. package/themes/example.jsonc +98 -0
  127. package/themes/light.json +32 -0
  128. package/themes/solarized-dark.json +32 -0
  129. package/themes/solarized-light.json +32 -0
@@ -0,0 +1,183 @@
1
+ import { For, Show } from "solid-js"
2
+ import { TextAttributes, type RGBA } from "@opentui/core"
3
+ import { statusLineFields } from "./config/types"
4
+ import { deriveStatusLineTheme } from "./theme/resolve"
5
+ import type { Theme } from "./theme/types"
6
+ import type { StatusLineBorder, StatusLineField, StatusLineStyle } from "./config/types"
7
+
8
+ // The host owns the values; the library owns the layout and the colours.
9
+ export type StatusLineState = {
10
+ readonly title?: string
11
+ readonly mode?: string
12
+ readonly protocol?: string
13
+ readonly cell?: string
14
+ readonly mathScale?: number
15
+ readonly position?: string
16
+ readonly hint?: string
17
+ readonly text?: readonly string[]
18
+ }
19
+
20
+ export type StatusLineSegment = {
21
+ readonly text: string
22
+ readonly emphasis: "strong" | "muted"
23
+ }
24
+
25
+ // The colours the row is drawn with. `plain` keeps the line in the page
26
+ // colours, `bar` gives it its own background and an accent border.
27
+ export type StatusLinePalette = {
28
+ readonly background: RGBA
29
+ readonly foreground: RGBA
30
+ readonly title: RGBA
31
+ readonly muted: RGBA
32
+ readonly border: RGBA
33
+ }
34
+
35
+ // `text` is a list field, so it is only rendered when a host asks for it.
36
+ export const defaultStatusLineFields: readonly StatusLineField[] = statusLineFields.filter(
37
+ (field) => field !== "text",
38
+ )
39
+
40
+ // A field without a value is skipped, so a host only has to provide what it
41
+ // knows. `text` expands to every entry of the state's text list.
42
+ export function statusLineSegments(
43
+ state: StatusLineState,
44
+ fields: readonly StatusLineField[] = defaultStatusLineFields,
45
+ highlight: StatusLineField = "title",
46
+ ): StatusLineSegment[] {
47
+ const segments: StatusLineSegment[] = []
48
+ for (const field of fields) {
49
+ if (field === "text") {
50
+ for (const entry of state.text ?? []) {
51
+ if (entry) segments.push({ text: entry, emphasis: "muted" })
52
+ }
53
+ continue
54
+ }
55
+ const value = statusLineValue(state, field)
56
+ if (value === undefined) continue
57
+ segments.push({ text: value, emphasis: field === highlight ? "strong" : "muted" })
58
+ }
59
+ return segments
60
+ }
61
+
62
+ export function formatStatusLine(
63
+ state: StatusLineState,
64
+ fields: readonly StatusLineField[] = defaultStatusLineFields,
65
+ separator = " · ",
66
+ ): string {
67
+ return statusLineSegments(state, fields)
68
+ .map((segment) => segment.text)
69
+ .join(separator)
70
+ }
71
+
72
+ // The effective colours of the row. The bar style uses the theme's status line
73
+ // section, falling back to colours derived from the accent colour.
74
+ export function statusLinePalette(theme: Theme, style: StatusLineStyle = "bar"): StatusLinePalette {
75
+ if (style === "plain") {
76
+ return {
77
+ background: theme.background.base,
78
+ foreground: theme.text.muted,
79
+ title: theme.text.base,
80
+ muted: theme.text.muted,
81
+ border: theme.border.base,
82
+ }
83
+ }
84
+ return theme.statusline ?? deriveStatusLineTheme(theme)
85
+ }
86
+
87
+ function statusLineValue(state: StatusLineState, field: StatusLineField): string | undefined {
88
+ switch (field) {
89
+ case "title":
90
+ return state.title || undefined
91
+ case "mode":
92
+ return state.mode || undefined
93
+ case "protocol":
94
+ return state.protocol ? `img:${state.protocol}` : undefined
95
+ case "cell":
96
+ return state.cell
97
+ case "mathScale":
98
+ return state.mathScale === undefined ? undefined : `${state.mathScale.toFixed(1)}x`
99
+ case "position":
100
+ return state.position
101
+ case "hint":
102
+ return state.hint
103
+ case "text":
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ type StatusLinePart = {
109
+ readonly text: string
110
+ readonly kind: "value" | "separator"
111
+ readonly emphasis: "strong" | "muted"
112
+ // The separator that follows the highlighted field is drawn in the accent
113
+ // colour, so the highlighted field is cut off from the rest of the row.
114
+ readonly divider: boolean
115
+ }
116
+
117
+ function partColor(part: StatusLinePart, palette: StatusLinePalette): RGBA {
118
+ if (part.kind === "separator") return part.divider ? palette.border : palette.muted
119
+ return part.emphasis === "strong" ? palette.title : palette.foreground
120
+ }
121
+
122
+ export function StatusLine(props: {
123
+ readonly theme: Theme
124
+ readonly state: () => StatusLineState
125
+ readonly fields?: readonly StatusLineField[]
126
+ readonly separator?: string
127
+ readonly border?: StatusLineBorder
128
+ readonly style?: StatusLineStyle
129
+ readonly highlight?: StatusLineField
130
+ }) {
131
+ const separator = () => props.separator ?? " · "
132
+ const style = () => props.style ?? "bar"
133
+ const highlight = () => props.highlight ?? "title"
134
+ const palette = () => statusLinePalette(props.theme, style())
135
+ const parts = (): StatusLinePart[] => {
136
+ const segments = statusLineSegments(props.state(), props.fields, highlight())
137
+ const result: StatusLinePart[] = []
138
+ let previousStrong = false
139
+ for (const segment of segments) {
140
+ if (result.length) {
141
+ result.push({ text: separator(), kind: "separator", emphasis: "muted", divider: previousStrong })
142
+ }
143
+ result.push({ text: segment.text, kind: "value", emphasis: segment.emphasis, divider: false })
144
+ previousStrong = segment.emphasis === "strong"
145
+ }
146
+ return result
147
+ }
148
+ const border = () => {
149
+ if (props.border === "top") return ["top"] as ["top"]
150
+ if (props.border === "bottom") return ["bottom"] as ["bottom"]
151
+ // An empty side list keeps the border off. Passing `false` together with
152
+ // a border colour makes OpenTUI draw a full frame instead.
153
+ return [] as []
154
+ }
155
+ return (
156
+ <box
157
+ width="100%"
158
+ flexShrink={0}
159
+ flexDirection="row"
160
+ alignItems="center"
161
+ paddingLeft={1}
162
+ paddingRight={1}
163
+ border={border()}
164
+ borderColor={palette().border}
165
+ backgroundColor={palette().background}
166
+ >
167
+ <Show when={parts().length}>
168
+ <For each={parts()}>
169
+ {(part) => (
170
+ <text
171
+ fg={partColor(part, palette())}
172
+ attributes={part.kind === "value" && part.emphasis === "strong" ? TextAttributes.BOLD : TextAttributes.NONE}
173
+ flexShrink={0}
174
+ wrapMode="none"
175
+ >
176
+ {part.text}
177
+ </text>
178
+ )}
179
+ </For>
180
+ </Show>
181
+ </box>
182
+ )
183
+ }
@@ -0,0 +1,11 @@
1
+ import { RGBA } from "@opentui/core"
2
+
3
+ // Mixes `over` into `base` with the given weight. The result is opaque, so a
4
+ // tint computed this way can be used as a solid background.
5
+ export function mixColors(over: RGBA, base: RGBA, ratio: number): RGBA {
6
+ const weight = Math.min(Math.max(ratio, 0), 1)
7
+ const [overR, overG, overB] = over.toInts()
8
+ const [baseR, baseG, baseB] = base.toInts()
9
+ const channel = (from: number, to: number) => Math.round(to + (from - to) * weight)
10
+ return RGBA.fromInts(channel(overR, baseR), channel(overG, baseG), channel(overB, baseB), 255)
11
+ }
@@ -0,0 +1,190 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import type { Theme, ThemePreset } from "./types"
3
+
4
+ const h = (hex: string) => RGBA.fromHex(hex)
5
+
6
+ // The canonical Solarized base tones and accents (8 + 8), without interpolated
7
+ // shades. Both presets share these swatches and differ only in how they use them.
8
+ const solarizedPalette = [
9
+ "#002b36", "#073642", "#586e75", "#657b83", "#839496", "#93a1a1", "#eee8d5", "#fdf6e3",
10
+ "#b58900", "#cb4b16", "#dc322f", "#d33682", "#6c71c4", "#268bd2", "#2aa198", "#859900",
11
+ ].map(h)
12
+
13
+ const light: Theme = {
14
+ mode: "light",
15
+ accent: h("#0969da"),
16
+ categorical: [h("#0969da"), h("#8250df"), h("#1a7f37"), h("#9a6700"), h("#cf222e"), h("#bc4c00")],
17
+ text: {
18
+ base: h("#1f2328"),
19
+ muted: h("#59636e"),
20
+ feedback: {
21
+ error: h("#cf222e"),
22
+ warning: h("#9a6700"),
23
+ success: h("#1a7f37"),
24
+ info: h("#0969da"),
25
+ },
26
+ action: { primary: { focused: h("#0969da") } },
27
+ },
28
+ background: { base: h("#ffffff") },
29
+ border: { base: h("#8c959f") },
30
+ frame: { border: h("#8c959f"), title: h("#59636e") },
31
+ markdown: {
32
+ text: h("#1f2328"),
33
+ heading: h("#8250df"),
34
+ // The ramp of the heading levels: the top level takes the accent, the
35
+ // middle levels the heading colour, the lowest ones a readable text colour.
36
+ headingLevels: [h("#0969da"), h("#8250df"), h("#8250df"), h("#6f42c1"), h("#3d444d"), h("#3d444d")],
37
+ link: h("#0969da"),
38
+ linkText: h("#0969da"),
39
+ code: h("#953800"),
40
+ blockQuote: h("#59636e"),
41
+ emphasis: h("#1f2328"),
42
+ strong: h("#1f2328"),
43
+ horizontalRule: h("#d1d9e0"),
44
+ listItem: h("#1f2328"),
45
+ listEnumeration: h("#1f2328"),
46
+ image: h("#0969da"),
47
+ imageText: h("#0969da"),
48
+ codeBlock: h("#1f2328"),
49
+ },
50
+ syntax: {
51
+ comment: h("#59636e"),
52
+ keyword: h("#cf222e"),
53
+ function: h("#8250df"),
54
+ variable: h("#1f2328"),
55
+ string: h("#0a3069"),
56
+ number: h("#0550ae"),
57
+ type: h("#0550ae"),
58
+ operator: h("#0550ae"),
59
+ punctuation: h("#1f2328"),
60
+ },
61
+ diff: {
62
+ text: { added: h("#1a7f37"), removed: h("#cf222e"), context: h("#59636e") },
63
+ background: { added: h("#dafbe1"), removed: h("#ffebe9"), context: h("#f6f8fa") },
64
+ },
65
+ }
66
+
67
+ const dark: Theme = {
68
+ mode: "dark",
69
+ accent: h("#58a6ff"),
70
+ categorical: [h("#58a6ff"), h("#d2a8ff"), h("#3fb950"), h("#d29922"), h("#f85149"), h("#ffa657")],
71
+ text: {
72
+ base: h("#e6edf3"),
73
+ muted: h("#8b949e"),
74
+ feedback: {
75
+ error: h("#f85149"),
76
+ warning: h("#d29922"),
77
+ success: h("#3fb950"),
78
+ info: h("#58a6ff"),
79
+ },
80
+ action: { primary: { focused: h("#58a6ff") } },
81
+ },
82
+ background: { base: h("#0d1117") },
83
+ border: { base: h("#30363d") },
84
+ frame: { border: h("#30363d"), title: h("#8b949e") },
85
+ markdown: {
86
+ text: h("#e6edf3"),
87
+ heading: h("#d2a8ff"),
88
+ headingLevels: [h("#58a6ff"), h("#d2a8ff"), h("#d2a8ff"), h("#8b949e"), h("#8b949e"), h("#8b949e")],
89
+ link: h("#58a6ff"),
90
+ linkText: h("#58a6ff"),
91
+ code: h("#ffa657"),
92
+ blockQuote: h("#8b949e"),
93
+ emphasis: h("#e6edf3"),
94
+ strong: h("#e6edf3"),
95
+ horizontalRule: h("#30363d"),
96
+ listItem: h("#e6edf3"),
97
+ listEnumeration: h("#e6edf3"),
98
+ image: h("#58a6ff"),
99
+ imageText: h("#58a6ff"),
100
+ codeBlock: h("#e6edf3"),
101
+ },
102
+ syntax: {
103
+ comment: h("#8b949e"),
104
+ keyword: h("#ff7b72"),
105
+ function: h("#d2a8ff"),
106
+ variable: h("#ffa657"),
107
+ string: h("#a5d6ff"),
108
+ number: h("#79c0ff"),
109
+ type: h("#ffa657"),
110
+ operator: h("#ff7b72"),
111
+ punctuation: h("#c9d1d9"),
112
+ },
113
+ diff: {
114
+ text: { added: h("#3fb950"), removed: h("#f85149"), context: h("#8b949e") },
115
+ background: { added: h("#12261e"), removed: h("#25171c"), context: h("#161b22") },
116
+ },
117
+ }
118
+
119
+ const solarizedLight: Theme = {
120
+ mode: "light",
121
+ palette: solarizedPalette,
122
+ accent: h("#268bd2"),
123
+ categorical: [h("#268bd2"), h("#6c71c4"), h("#859900"), h("#b58900"), h("#dc322f"), h("#cb4b16")],
124
+ text: {
125
+ base: h("#586e75"), muted: h("#657b83"),
126
+ feedback: { error: h("#dc322f"), warning: h("#b58900"), success: h("#859900"), info: h("#268bd2") },
127
+ action: { primary: { focused: h("#268bd2") } },
128
+ },
129
+ background: { base: h("#fdf6e3") },
130
+ border: { base: h("#657b83") },
131
+ frame: { border: h("#657b83"), title: h("#586e75") },
132
+ markdown: {
133
+ text: h("#586e75"), heading: h("#268bd2"),
134
+ headingLevels: [h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2")],
135
+ link: h("#268bd2"), linkText: h("#268bd2"), code: h("#cb4b16"), blockQuote: h("#657b83"),
136
+ emphasis: h("#586e75"), strong: h("#586e75"), horizontalRule: h("#657b83"),
137
+ listItem: h("#586e75"), listEnumeration: h("#586e75"), image: h("#268bd2"),
138
+ imageText: h("#268bd2"), codeBlock: h("#586e75"),
139
+ },
140
+ syntax: {
141
+ comment: h("#657b83"), keyword: h("#dc322f"), function: h("#6c71c4"), variable: h("#586e75"),
142
+ string: h("#2aa198"), number: h("#d33682"), type: h("#b58900"), operator: h("#268bd2"),
143
+ punctuation: h("#586e75"),
144
+ },
145
+ diff: {
146
+ text: { added: h("#859900"), removed: h("#dc322f"), context: h("#657b83") },
147
+ background: { added: h("#eee8d5"), removed: h("#eee8d5"), context: h("#eee8d5") },
148
+ },
149
+ }
150
+
151
+ const solarizedDark: Theme = {
152
+ mode: "dark",
153
+ palette: solarizedPalette,
154
+ accent: h("#268bd2"),
155
+ categorical: [h("#268bd2"), h("#6c71c4"), h("#859900"), h("#b58900"), h("#dc322f"), h("#cb4b16")],
156
+ text: {
157
+ base: h("#93a1a1"), muted: h("#839496"),
158
+ feedback: { error: h("#dc322f"), warning: h("#b58900"), success: h("#859900"), info: h("#268bd2") },
159
+ action: { primary: { focused: h("#268bd2") } },
160
+ },
161
+ background: { base: h("#002b36") },
162
+ border: { base: h("#586e75") },
163
+ frame: { border: h("#586e75"), title: h("#93a1a1") },
164
+ markdown: {
165
+ text: h("#93a1a1"), heading: h("#268bd2"),
166
+ headingLevels: [h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2"), h("#268bd2")],
167
+ link: h("#268bd2"), linkText: h("#268bd2"), code: h("#cb4b16"), blockQuote: h("#839496"),
168
+ emphasis: h("#93a1a1"), strong: h("#93a1a1"), horizontalRule: h("#586e75"),
169
+ listItem: h("#93a1a1"), listEnumeration: h("#93a1a1"), image: h("#268bd2"),
170
+ imageText: h("#268bd2"), codeBlock: h("#93a1a1"),
171
+ },
172
+ syntax: {
173
+ comment: h("#839496"), keyword: h("#dc322f"), function: h("#6c71c4"), variable: h("#93a1a1"),
174
+ string: h("#2aa198"), number: h("#d33682"), type: h("#b58900"), operator: h("#268bd2"),
175
+ punctuation: h("#93a1a1"),
176
+ },
177
+ diff: {
178
+ text: { added: h("#859900"), removed: h("#dc322f"), context: h("#839496") },
179
+ background: { added: h("#073642"), removed: h("#073642"), context: h("#073642") },
180
+ },
181
+ }
182
+
183
+ export function defaultTheme(preset: ThemePreset): Theme {
184
+ switch (preset) {
185
+ case "light": return light
186
+ case "dark": return dark
187
+ case "solarized-light": return solarizedLight
188
+ case "solarized-dark": return solarizedDark
189
+ }
190
+ }
@@ -0,0 +1,7 @@
1
+ export { defaultTheme } from "./defaults"
2
+ export { loadTheme, bundledThemeNames, themeFileNames } from "./load"
3
+ export type { ThemeEnvironment } from "./load"
4
+ export { generateSyntax } from "./syntax"
5
+ export { resolveTheme, mergeTheme, deriveStatusLineTheme } from "./resolve"
6
+ export { mixColors } from "./colors"
7
+ export type { Theme, ThemeMode, ThemePreset, ThemeOverride, StatusLineTheme, StatusLineThemeOverride, FrameTheme, FrameThemeOverride, MarkdownThemeOverride, HeadingLevel } from "./types"
@@ -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
+ }
@@ -0,0 +1,167 @@
1
+ import { parseColor, type ColorInput, type RGBA } from "@opentui/core"
2
+ import { defaultTheme } from "./defaults"
3
+ import { mixColors } from "./colors"
4
+ import type { StatusLineTheme, StatusLineThemeOverride, Theme, ThemeMode, ThemePreset, ThemeOverride, MarkdownThemeOverride } from "./types"
5
+
6
+ export function resolveTheme(options: {
7
+ mode: ThemePreset | null | undefined
8
+ override?: ThemeOverride
9
+ }): Theme {
10
+ const override = options.override
11
+ const mode = override?.mode ?? options.mode ?? "light"
12
+ return mergeTheme(defaultTheme(mode), override)
13
+ }
14
+
15
+ // The bar background is the accent colour toned down over the page background,
16
+ // so the strip separates from the page without a loud block of colour.
17
+ export function deriveStatusLineTheme(theme: {
18
+ readonly mode: ThemeMode
19
+ readonly palette?: readonly RGBA[]
20
+ readonly accent: RGBA
21
+ readonly background: { readonly base: RGBA }
22
+ readonly text: { readonly base: RGBA; readonly muted: RGBA }
23
+ }): StatusLineTheme {
24
+ // The same weight in both modes: a soft tint in light mode and a slightly
25
+ // stronger one in dark mode.
26
+ const ratio = theme.mode === "light" ? 0.12 : 0.14
27
+ return {
28
+ background: theme.palette?.[theme.mode === "light" ? 6 : 1] ?? mixColors(theme.accent, theme.background.base, ratio),
29
+ foreground: theme.text.base,
30
+ title: theme.accent,
31
+ muted: theme.text.muted,
32
+ border: theme.accent,
33
+ }
34
+ }
35
+
36
+ export function mergeTheme(base: Theme, override: ThemeOverride | undefined): Theme {
37
+ if (!override) return base
38
+ const color = (value: ColorInput | undefined, fallback: RGBA) => (value === undefined ? fallback : parseColor(value))
39
+ const accent = color(override.accent, base.accent)
40
+ const palette = override.palette ? override.palette.map((entry) => parseColor(entry)) : base.palette
41
+ const background = { base: color(override.background?.base, base.background.base) }
42
+ const text = {
43
+ base: color(override.text?.base, base.text.base),
44
+ muted: color(override.text?.muted, base.text.muted),
45
+ }
46
+ const border = { base: color(override.border?.base, base.border.base) }
47
+ // An explicit border or muted-text override moves the frame with it, unless
48
+ // the frame names its own colour. A hand-built theme keeps its frame colours.
49
+ const frame = {
50
+ border: color(
51
+ override.frame?.border,
52
+ override.border?.base !== undefined ? border.base : (base.frame?.border ?? border.base),
53
+ ),
54
+ title: color(
55
+ override.frame?.title,
56
+ override.text?.muted !== undefined ? text.muted : (base.frame?.title ?? text.muted),
57
+ ),
58
+ }
59
+ const mode = override.mode ? (override.mode.endsWith("light") ? "light" : "dark") : base.mode
60
+ const derived = deriveStatusLineTheme({ mode, accent, background, text, palette })
61
+ return {
62
+ mode,
63
+ palette,
64
+ accent,
65
+ categorical: override.categorical ? override.categorical.map((entry) => parseColor(entry)) : base.categorical,
66
+ text: {
67
+ base: text.base,
68
+ muted: text.muted,
69
+ feedback: {
70
+ error: color(override.text?.feedback?.error, base.text.feedback.error),
71
+ warning: color(override.text?.feedback?.warning, base.text.feedback.warning),
72
+ success: color(override.text?.feedback?.success, base.text.feedback.success),
73
+ info: color(override.text?.feedback?.info, base.text.feedback.info),
74
+ },
75
+ action: {
76
+ primary: { focused: color(override.text?.action?.primary?.focused, base.text.action.primary.focused) },
77
+ },
78
+ },
79
+ background,
80
+ border,
81
+ frame,
82
+ // The bar colours follow the merged accent, so overriding the accent in
83
+ // the config file moves the bar with it. A theme that already carries
84
+ // explicit bar colours keeps them.
85
+ statusline: mergeStatusLineTheme(derived, base.statusline, override.statusline),
86
+ markdown: mergeMarkdownTheme(base.markdown, override.markdown),
87
+ syntax: mergeColors(base.syntax, override.syntax),
88
+ diff: {
89
+ text: {
90
+ added: color(override.diff?.text?.added, base.diff.text.added),
91
+ removed: color(override.diff?.text?.removed, base.diff.text.removed),
92
+ context: color(override.diff?.text?.context, base.diff.text.context),
93
+ },
94
+ background: {
95
+ added: color(override.diff?.background?.added, base.diff.background.added),
96
+ removed: color(override.diff?.background?.removed, base.diff.background.removed),
97
+ context: color(override.diff?.background?.context, base.diff.background.context),
98
+ },
99
+ },
100
+ }
101
+ }
102
+
103
+ function mergeStatusLineTheme(
104
+ derived: StatusLineTheme,
105
+ base: StatusLineTheme | undefined,
106
+ override: StatusLineThemeOverride | undefined,
107
+ ): StatusLineTheme {
108
+ const color = (value: ColorInput | undefined, fallback: RGBA) => (value === undefined ? fallback : parseColor(value))
109
+ const pick = (key: keyof StatusLineTheme) => color(override?.[key], base?.[key] ?? derived[key])
110
+ return {
111
+ background: pick("background"),
112
+ foreground: pick("foreground"),
113
+ title: pick("title"),
114
+ muted: pick("muted"),
115
+ border: pick("border"),
116
+ }
117
+ }
118
+
119
+ function mergeColors<T extends Record<string, RGBA>>(
120
+ base: T,
121
+ override: Partial<Record<keyof T, ColorInput>> | undefined,
122
+ ): T {
123
+ if (!override) return base
124
+ const merged = { ...base } as Record<string, RGBA>
125
+ for (const key of Object.keys(base) as (keyof T)[]) {
126
+ const value = override[key]
127
+ if (value !== undefined) merged[key as string] = parseColor(value)
128
+ }
129
+ return merged as T
130
+ }
131
+
132
+ // The heading colours are resolved on their own: a colour for a single level
133
+ // only replaces that level, while a base heading colour replaces the whole
134
+ // ramp, which is how the theme behaved before the levels existed.
135
+ function mergeMarkdownTheme(
136
+ base: Theme["markdown"],
137
+ override: MarkdownThemeOverride | undefined,
138
+ ): Theme["markdown"] {
139
+ const levels = (override ?? {}) as Record<string, ColorInput | undefined>
140
+ const pick = (level: number, fallback: RGBA) => {
141
+ const color = levels[`heading${level}`]
142
+ if (color !== undefined) return parseColor(color)
143
+ if (levels.heading !== undefined) return parseColor(levels.heading)
144
+ return fallback
145
+ }
146
+ // A theme that was built by hand may not carry the ramp; every level then
147
+ // takes the base heading colour.
148
+ const [one, two, three, four, five, six] = base.headingLevels ?? [
149
+ base.heading,
150
+ base.heading,
151
+ base.heading,
152
+ base.heading,
153
+ base.heading,
154
+ base.heading,
155
+ ]
156
+ const headingLevels: Theme["markdown"]["headingLevels"] = [
157
+ pick(1, one),
158
+ pick(2, two),
159
+ pick(3, three),
160
+ pick(4, four),
161
+ pick(5, five),
162
+ pick(6, six),
163
+ ]
164
+ const { headingLevels: _levels, ...colors } = base
165
+ const merged = mergeColors(colors, override)
166
+ return { ...merged, headingLevels }
167
+ }