opentui-markdown-render 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentui-markdown-render",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "OpenTUI Markdown renderer with optional native LaTeX math",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,12 +56,12 @@
56
56
  "string-width": "7.2.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@opentui/core": "0.5.12",
60
- "@opentui/solid": "0.5.12",
59
+ "@opentui/core": "0.8.2",
60
+ "@opentui/solid": "0.8.2",
61
61
  "@tsconfig/bun": "1.0.9",
62
62
  "@types/bun": "1.4.0",
63
63
  "@typescript/native-preview": "7.0.0-dev.20251207.1",
64
- "solid-js": "1.9.15"
64
+ "solid-js": "1.9.18"
65
65
  },
66
66
  "engines": {
67
67
  "bun": ">=1.4.0"
package/src/app.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CliRenderer } from "@opentui/core"
2
- import { createMemo, createSignal } from "solid-js"
2
+ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
3
3
  import { useCanvasGraphics } from "./graphics"
4
4
  import { resolveHeadingStyles } from "./headings"
5
5
  import { rgbaToHex } from "./math"
@@ -9,6 +9,7 @@ import { parseDocument, type Document, type FrameConfig, type TablesConfig } fro
9
9
  import { MarkdownFrame } from "./view/frame"
10
10
  import { DocumentView, type RenderUnit } from "./view/document"
11
11
  import type { Theme } from "./theme/types"
12
+ import { generateSyntax } from "./theme/syntax"
12
13
 
13
14
  export type { ThemeModeSetting } from "./config"
14
15
  export { loadRendererConfig } from "./config"
@@ -154,6 +155,14 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
154
155
  // The look of the heading levels is a configuration of its own: the colours
155
156
  // come from the theme, the attributes and the spacing from the config file.
156
157
  const headings = createMemo(() => resolveHeadingStyles(config().headings))
158
+ const syntaxStyle = createMemo(() => {
159
+ const nativeScene = (renderer as CliRenderer & { readonly nativeScene?: object }).nativeScene
160
+ return generateSyntax(theme(), headings(), theme().markdown.codeBlock, nativeScene)
161
+ })
162
+ createEffect(() => {
163
+ const style = syntaxStyle()
164
+ onCleanup(() => void renderer.idle().then(() => style.destroy()))
165
+ })
157
166
  return (
158
167
  <box
159
168
  width="100%"
@@ -178,6 +187,7 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
178
187
  tableOptions={tableOptions()}
179
188
  mermaidOptions={config().mermaid}
180
189
  mathScale={mathScale()}
190
+ syntaxStyle={syntaxStyle()}
181
191
  onDiagnostic={props.onDiagnostic}
182
192
  renderUnit={props.renderUnit}
183
193
  />
package/src/index.ts CHANGED
@@ -89,6 +89,7 @@ export {
89
89
  bundledThemeNames,
90
90
  themeFileNames,
91
91
  generateSyntax,
92
+ type SyntaxResourceOwner,
92
93
  resolveTheme,
93
94
  mergeTheme,
94
95
  deriveStatusLineTheme,
package/src/media.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NativeImage, imageInfo, parseColor, type ColorInput, type RGBA } from "@opentui/core"
2
- import { decompressFrames, parseGIF } from "gifuct-js"
2
+ import { useRenderer } from "@opentui/solid"
3
+ import { decompressFrame, parseGIF, type ParsedGif } from "gifuct-js"
3
4
  import path from "node:path"
4
5
  import { createEffect, createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
5
6
 
@@ -44,8 +45,16 @@ export function rgbToHex(color: Rgb) {
44
45
  return `#${channel(color[0])}${channel(color[1])}${channel(color[2])}`
45
46
  }
46
47
 
47
- type AnimatedFrame = { readonly image: NativeImage; readonly delay: number }
48
- type Media = { readonly box: { columns: number; rows: number }; readonly image?: NativeImage; readonly frames?: readonly AnimatedFrame[] }
48
+ type GifFrame = Parameters<typeof decompressFrame>[0]
49
+ type GifAnimation = {
50
+ readonly width: number
51
+ readonly height: number
52
+ readonly frames: readonly GifFrame[]
53
+ readonly colorTable: ParsedGif["gct"]
54
+ readonly target: { width: number; height: number }
55
+ readonly background: Rgb
56
+ }
57
+ type Media = { readonly box: { columns: number; rows: number }; readonly image?: NativeImage; readonly animation?: GifAnimation }
49
58
 
50
59
  export { standaloneImage, type MarkdownImage } from "./core/image"
51
60
 
@@ -88,6 +97,7 @@ export function LocalImage(props: {
88
97
  readonly textColor?: () => RGBA
89
98
  readonly limits?: ImageLimits
90
99
  }) {
100
+ const renderer = useRenderer()
91
101
  const [failure, setFailure] = createSignal<string>()
92
102
  // The cell size is part of the key so the image is reloaded and re-fitted once
93
103
  // the terminal reports its pixel resolution (kitty queries it after startup).
@@ -145,26 +155,40 @@ export function LocalImage(props: {
145
155
 
146
156
  createEffect(() => {
147
157
  const value = media()
148
- if (!value?.frames?.length) {
158
+ if (!value?.animation) {
149
159
  setFrame(value?.image)
150
160
  return
151
161
  }
162
+ const animation = value.animation
163
+ const canvas = new Uint8Array(animation.width * animation.height * 4)
152
164
  let index = 0
165
+ let current: NativeImage | undefined
153
166
  let timer: ReturnType<typeof setTimeout> | undefined
154
167
  const next = () => {
155
- const current = value.frames![index]!
156
- setFrame(current.image)
157
- index = (index + 1) % value.frames!.length
158
- timer = setTimeout(next, current.delay)
168
+ const decoded = decompressFrame(animation.frames[index]!, animation.colorTable, true)
169
+ const before = decoded.disposalType === 3 ? canvas.slice() : undefined
170
+ drawPatch(canvas, animation.width, animation.height, decoded.patch, decoded.dims.left, decoded.dims.top, decoded.dims.width, decoded.dims.height)
171
+ const image = prepareImage(NativeImage.fromRgba(canvas.slice(), animation.width, animation.height), animation.target, animation.background)
172
+ const previous = current
173
+ current = image
174
+ setFrame(image)
175
+ if (previous) void renderer.idle().then(() => previous.dispose())
176
+ if (decoded.disposalType === 2) clearPatch(canvas, animation.width, decoded.dims.left, decoded.dims.top, decoded.dims.width, decoded.dims.height)
177
+ if (before) canvas.set(before)
178
+ index = (index + 1) % animation.frames.length
179
+ timer = setTimeout(next, Math.max(50, decoded.delay || 100))
159
180
  }
160
181
  next()
161
182
  onCleanup(() => {
162
183
  if (timer) clearTimeout(timer)
184
+ const image = current
185
+ if (image) void renderer.idle().then(() => image.dispose())
186
+ setFrame()
163
187
  })
164
188
  })
165
189
  createEffect(() => {
166
190
  const value = media()
167
- onCleanup(() => disposeMedia(value))
191
+ onCleanup(() => void renderer.idle().then(() => disposeMedia(value)))
168
192
  })
169
193
 
170
194
  return (
@@ -197,21 +221,13 @@ export function LocalImage(props: {
197
221
  )
198
222
  }
199
223
 
200
- function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
224
+ export function loadGif(bytes: Uint8Array, box: { columns: number; rows: number }, target: { width: number; height: number }, background: Rgb) {
201
225
  const gif = parseGIF(bytes.slice().buffer as ArrayBuffer)
202
226
  const width = gif.lsd.width
203
227
  const height = gif.lsd.height
204
- const canvas = new Uint8Array(width * height * 4)
205
- const frames = decompressFrames(gif, true).map((frame) => {
206
- const before = canvas.slice()
207
- drawPatch(canvas, width, height, frame.patch, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
208
- const image = prepareImage(NativeImage.fromRgba(canvas.slice(), width, height), target, background)
209
- if (frame.disposalType === 2) clearPatch(canvas, width, frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height)
210
- if (frame.disposalType === 3) canvas.set(before)
211
- return { image, delay: Math.max(50, frame.delay || 100) }
212
- })
228
+ const frames = gif.frames.filter((frame): frame is GifFrame => "image" in frame)
213
229
  if (!frames.length) throw new Error("GIF contains no frames")
214
- return { box, frames }
230
+ return { box, animation: { width, height, frames, colorTable: gif.gct, target, background } }
215
231
  }
216
232
 
217
233
  function drawPatch(canvas: Uint8Array, width: number, height: number, patch: Uint8ClampedArray, left: number, top: number, patchWidth: number, patchHeight: number) {
@@ -263,5 +279,4 @@ function prepareImage(image: NativeImage, target: { width: number; height: numbe
263
279
 
264
280
  function disposeMedia(media: Media | undefined) {
265
281
  media?.image?.dispose()
266
- media?.frames?.forEach((frame) => frame.image.dispose())
267
282
  }
@@ -1,7 +1,7 @@
1
1
  export { defaultTheme } from "./defaults"
2
2
  export { loadTheme, bundledThemeNames, themeFileNames } from "./load"
3
3
  export type { ThemeEnvironment } from "./load"
4
- export { generateSyntax } from "./syntax"
4
+ export { generateSyntax, type SyntaxResourceOwner } from "./syntax"
5
5
  export { resolveTheme, mergeTheme, deriveStatusLineTheme } from "./resolve"
6
6
  export { mixColors } from "./colors"
7
7
  export type { Theme, ThemeMode, ThemePreset, ThemeOverride, StatusLineTheme, StatusLineThemeOverride, FrameTheme, FrameThemeOverride, MarkdownThemeOverride, HeadingLevel } from "./types"
@@ -2,7 +2,14 @@ import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
2
2
  import { defaultHeadingStyles, headingColor, headingLevels, type HeadingStyles } from "../headings"
3
3
  import type { HeadingLevel, Theme } from "./types"
4
4
 
5
- export function generateSyntax(theme: Theme, headings: HeadingStyles = defaultHeadingStyles, defaultColor: RGBA = theme.text.base) {
5
+ export type SyntaxResourceOwner = object
6
+
7
+ export function generateSyntax(
8
+ theme: Theme,
9
+ headings: HeadingStyles = defaultHeadingStyles,
10
+ defaultColor: RGBA = theme.text.base,
11
+ owner?: SyntaxResourceOwner,
12
+ ) {
6
13
  const { markdown, syntax, text } = theme
7
14
  const feedback = text.feedback
8
15
 
@@ -22,7 +29,7 @@ export function generateSyntax(theme: Theme, headings: HeadingStyles = defaultHe
22
29
  rule([`markup.heading.${level}`], headingColor(theme, level), headingStyle(level)),
23
30
  )
24
31
 
25
- return SyntaxStyle.fromTheme([
32
+ const rules = [
26
33
  rule(["default"], defaultColor),
27
34
  rule(["prompt"], theme.accent),
28
35
  rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
@@ -84,7 +91,11 @@ export function generateSyntax(theme: Theme, headings: HeadingStyles = defaultHe
84
91
  rule(["error"], feedback.error, { bold: true }),
85
92
  rule(["warning"], feedback.warning, { bold: true }),
86
93
  rule(["info"], feedback.info),
87
- ])
94
+ ]
95
+ // OpenTUI 0.8 makes native resource ownership explicit. Passing the owner is
96
+ // harmless on older supported releases, whose factory ignores extra args.
97
+ const fromTheme = SyntaxStyle.fromTheme as (rules: ThemeTokenStyle[], owner?: SyntaxResourceOwner) => SyntaxStyle
98
+ return fromTheme(rules, owner)
88
99
  }
89
100
 
90
101
  function rule(
package/src/view/code.tsx CHANGED
@@ -1,4 +1,6 @@
1
- import { infoStringToFiletype } from "@opentui/core"
1
+ import { infoStringToFiletype, type SyntaxStyle } from "@opentui/core"
2
+ import { useRenderer } from "@opentui/solid"
3
+ import { onCleanup } from "solid-js"
2
4
  import { generateSyntax } from "../theme/syntax"
3
5
  import type { Theme } from "../theme/types"
4
6
  import type { HeadingStyles } from "../headings"
@@ -8,14 +10,26 @@ export type CodeBlockProps = {
8
10
  language: string | null
9
11
  theme: Theme
10
12
  headings: HeadingStyles
13
+ syntaxStyle?: SyntaxStyle
14
+ }
15
+
16
+ export function resolveCodeFiletype(language: string | null) {
17
+ const token = language?.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? ""
18
+ return token === "jsonc" ? "json" : infoStringToFiletype(language ?? "")
11
19
  }
12
20
 
13
21
  export function CodeBlock(props: CodeBlockProps) {
22
+ const renderer = useRenderer()
23
+ const nativeScene = (renderer as typeof renderer & { readonly nativeScene?: object }).nativeScene
24
+ const owned = props.syntaxStyle ? undefined : generateSyntax(props.theme, props.headings, props.theme.markdown.codeBlock, nativeScene)
25
+ onCleanup(() => {
26
+ if (owned) void renderer.idle().then(() => owned.destroy())
27
+ })
14
28
  return (
15
29
  <code
16
30
  content={props.content}
17
- filetype={infoStringToFiletype(props.language ?? "")}
18
- syntaxStyle={generateSyntax(props.theme, props.headings, props.theme.markdown.codeBlock)}
31
+ filetype={resolveCodeFiletype(props.language)}
32
+ syntaxStyle={props.syntaxStyle ?? owned!}
19
33
  fg={props.theme.markdown.codeBlock}
20
34
  bg={props.theme.background.base}
21
35
  conceal={false}
@@ -1,6 +1,6 @@
1
1
  import { createMemo, For, type Accessor, type JSX } from "solid-js"
2
2
  import stringWidth from "string-width"
3
- import { type TextChunk } from "@opentui/core"
3
+ import { type SyntaxStyle, type TextChunk } from "@opentui/core"
4
4
  import { assertNever, type Block, type Document, type ListItem, type UnitInfo } from "../core/document"
5
5
  import type { ResolvedTableOptions } from "../core/tables"
6
6
  import type { ImagesConfig, MermaidConfig } from "../config/types"
@@ -31,6 +31,7 @@ export type DocumentViewProps = {
31
31
  tableOptions: ResolvedTableOptions
32
32
  mermaidOptions?: MermaidConfig
33
33
  mathScale?: number
34
+ syntaxStyle?: SyntaxStyle
34
35
  onDiagnostic?: (diagnostic: { level: "warning" | "error"; message: string }) => void
35
36
  renderUnit?: RenderUnit
36
37
  }
@@ -66,7 +67,7 @@ export function DocumentView(props: DocumentViewProps) {
66
67
  if (input.block().type === "mermaid") {
67
68
  return <MermaidDiagramView source={(input.block() as Extract<Block, { type: "mermaid" }>).content}
68
69
  theme={props.theme} columns={input.columns} mermaidOptions={props.mermaidOptions}
69
- headings={props.headings} onDiagnostic={props.onDiagnostic} />
70
+ headings={props.headings} syntaxStyle={props.syntaxStyle} onDiagnostic={props.onDiagnostic} />
70
71
  }
71
72
  const view = createMemo(() => {
72
73
  const block = input.block()
@@ -97,9 +98,11 @@ export function DocumentView(props: DocumentViewProps) {
97
98
  columns={columns > 2 ? columns - 2 : columns} quote />}</For>
98
99
  </box>
99
100
  case "list": return <ListBlockView block={() => block} columns={columns} quote={input.quote} wrapItems={false} />
100
- case "code": return <CodeBlock content={block.content} language={block.language} theme={props.theme} headings={props.headings} />
101
+ case "code": return <CodeBlock content={block.content} language={block.language} theme={props.theme}
102
+ headings={props.headings} syntaxStyle={props.syntaxStyle} />
101
103
  case "mermaid": return <MermaidDiagramView source={block.content} theme={props.theme} columns={columns}
102
- mermaidOptions={props.mermaidOptions} headings={props.headings} onDiagnostic={props.onDiagnostic} />
104
+ mermaidOptions={props.mermaidOptions} headings={props.headings} syntaxStyle={props.syntaxStyle}
105
+ onDiagnostic={props.onDiagnostic} />
103
106
  case "table": return <TableBlock block={block} options={props.tableOptions} theme={props.theme} headings={props.headings}
104
107
  hyperlinks={props.graphics.hyperlinks?.() ?? true} />
105
108
  case "literal": return <text fg={props.theme.markdown.text} wrapMode="word" content={block.text} />
@@ -25,24 +25,34 @@ export type InlineFlowProps = {
25
25
 
26
26
  function PrefixSegment(props: { chunks: () => TextChunk[]; width: number }) {
27
27
  let ref: TextRenderable | undefined
28
- createEffect(() => {
29
- if (ref) ref.content = new StyledText(props.chunks())
30
- })
31
- return <text ref={ref} wrapMode="none" width={props.width} flexShrink={0} />
28
+ const update = () => {
29
+ const content = new StyledText(props.chunks())
30
+ if (ref) ref.content = content
31
+ return content
32
+ }
33
+ createEffect(update)
34
+ return <text ref={(renderable) => {
35
+ ref = renderable
36
+ renderable.content = update()
37
+ }} wrapMode="none" width={props.width} flexShrink={0} />
32
38
  }
33
39
 
34
40
  function StyledSegment(props: { entry: LayoutRun; textRow: number }) {
35
41
  let ref: TextRenderable | undefined
36
- createEffect(() => {
42
+ const update = () => {
37
43
  const atom = props.entry.atom
38
- if (ref && atom.type === "text") {
39
- ref.content = new StyledText([{
40
- __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
41
- ...(atom.href ? { link: { url: atom.href } } : {}),
42
- }])
43
- }
44
- })
45
- return <text ref={ref} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
44
+ const content = atom.type === "text" ? new StyledText([{
45
+ __isChunk: true, text: atom.text, fg: atom.fg, attributes: atom.attributes,
46
+ ...(atom.href ? { link: { url: atom.href } } : {}),
47
+ }]) : new StyledText([])
48
+ if (ref) ref.content = content
49
+ return content
50
+ }
51
+ createEffect(update)
52
+ return <text ref={(renderable) => {
53
+ ref = renderable
54
+ renderable.content = update()
55
+ }} width={props.entry.columns} flexShrink={0} wrapMode="none" marginTop={props.textRow} />
46
56
  }
47
57
 
48
58
  export function InlineFlow(props: InlineFlowProps) {
@@ -1,5 +1,5 @@
1
1
  import { createEffect, createMemo, Show, type Accessor } from "solid-js"
2
- import { type MouseEvent, type TextRenderable } from "@opentui/core"
2
+ import { type MouseEvent, type SyntaxStyle, type TextRenderable } from "@opentui/core"
3
3
  import { measureMermaidDiagram, type PreparedDiagram } from "../merman/diagram"
4
4
  import { detectMermaidDiagram } from "../merman/detect"
5
5
  import { MermaidSyntaxError } from "../merman/diagnostics"
@@ -16,6 +16,7 @@ export type MermaidDiagramViewProps = {
16
16
  columns: number
17
17
  mermaidOptions?: MermaidConfig
18
18
  headings?: HeadingStyles
19
+ syntaxStyle?: SyntaxStyle
19
20
  onDiagnostic?: (diagnostic: { level: "error"; message: string }) => void
20
21
  }
21
22
 
@@ -121,7 +122,7 @@ export function MermaidDiagramView(props: MermaidDiagramViewProps) {
121
122
  })
122
123
  return <box width="100%" flexDirection="column" flexShrink={0}>
123
124
  <Show when={prepared()} fallback={<CodeBlock content={props.source} language="mermaid"
124
- theme={props.theme} headings={props.headings ?? defaultHeadingStyles} />}>
125
+ theme={props.theme} headings={props.headings ?? defaultHeadingStyles} syntaxStyle={props.syntaxStyle} />}>
125
126
  {(value) => <DiagramText prepared={value} />}
126
127
  </Show>
127
128
  </box>