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,96 @@
1
+ import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
2
+ import { defaultHeadingStyles, headingColor, headingLevels, type HeadingStyles } from "../headings"
3
+ import type { HeadingLevel, Theme } from "./types"
4
+
5
+ export function generateSyntax(theme: Theme, headings: HeadingStyles = defaultHeadingStyles, defaultColor: RGBA = theme.text.base) {
6
+ const { markdown, syntax, text } = theme
7
+ const feedback = text.feedback
8
+
9
+ // A heading level is one colour and one set of attributes, so the levels
10
+ // read as a hierarchy. The scope names are the ones the Markdown parser
11
+ // reports for a heading.
12
+ const headingStyle = (level: HeadingLevel) => {
13
+ const style = headings[level]
14
+ return {
15
+ bold: style.bold,
16
+ italic: style.italic,
17
+ underline: style.underline,
18
+ dim: style.dim,
19
+ }
20
+ }
21
+ const headingRules = headingLevels.map((level) =>
22
+ rule([`markup.heading.${level}`], headingColor(theme, level), headingStyle(level)),
23
+ )
24
+
25
+ return SyntaxStyle.fromTheme([
26
+ rule(["default"], defaultColor),
27
+ rule(["prompt"], theme.accent),
28
+ rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
29
+ rule(["string", "symbol", "character.special", "character"], syntax.string),
30
+ rule(["number", "boolean", "constant", "float"], syntax.number),
31
+ rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
32
+ italic: true,
33
+ }),
34
+ rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
35
+ rule(["keyword.function", "function.method"], syntax.function),
36
+ rule(["keyword"], syntax.keyword, { italic: true }),
37
+ rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
38
+ rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
39
+ rule(
40
+ ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
41
+ syntax.variable,
42
+ ),
43
+ rule(["variable.member", "function", "constructor"], syntax.function),
44
+ rule(["type", "module", "class", "namespace"], syntax.type),
45
+ rule(["type.definition"], syntax.type, { bold: true }),
46
+ rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
47
+ rule(
48
+ ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
49
+ feedback.error,
50
+ ),
51
+ rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
52
+ rule(["punctuation.special", "tag.delimiter"], syntax.operator),
53
+ // Every level has its own rule, so the scope the parser reports for a
54
+ // heading decides how it looks. The generic scope is the fallback for a
55
+ // heading whose level the parser did not report.
56
+ rule(["markup.heading"], headingColor(theme, 1), headingStyle(1)),
57
+ ...headingRules,
58
+ rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
59
+ rule(["markup.italic"], markdown.emphasis, { italic: true }),
60
+ // OpenTUI emits markup.list for both bullet and numbered markers. A
61
+ // dedicated node renderer handles numbered markers when their colour differs.
62
+ rule(["markup.list"], markdown.listItem),
63
+ rule(["markup.list.enumeration"], markdown.listEnumeration),
64
+ rule(["markup.quote"], markdown.blockQuote, { italic: true }),
65
+ rule(["markup.raw"], markdown.code),
66
+ rule(["markup.raw.block"], markdown.codeBlock),
67
+ rule(["markup.raw.inline"], markdown.code, { background: theme.background.base }),
68
+ rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
69
+ underline: true,
70
+ }),
71
+ rule(["markup.link.label"], markdown.linkText, { underline: true }),
72
+ rule(["label"], markdown.linkText),
73
+ rule(["markup.underline"], text.base, { underline: true }),
74
+ rule(["markup.strikethrough", "markup.list.unchecked", "debug"], text.muted),
75
+ rule(["markup.list.checked"], feedback.success),
76
+ rule(["comment.error"], feedback.error, { italic: true, bold: true }),
77
+ rule(["comment.warning"], feedback.warning, { italic: true, bold: true }),
78
+ rule(["comment.todo", "comment.note"], feedback.info, { italic: true, bold: true }),
79
+ rule(["attribute", "annotation"], feedback.warning),
80
+ rule(["tag"], feedback.error),
81
+ rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
82
+ rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
83
+ rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
84
+ rule(["error"], feedback.error, { bold: true }),
85
+ rule(["warning"], feedback.warning, { bold: true }),
86
+ rule(["info"], feedback.info),
87
+ ])
88
+ }
89
+
90
+ function rule(
91
+ scope: string[],
92
+ foreground: RGBA,
93
+ style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
94
+ ): ThemeTokenStyle {
95
+ return { scope, style: { foreground, ...style } }
96
+ }
@@ -0,0 +1,151 @@
1
+ import type { ColorInput, RGBA } from "@opentui/core"
2
+
3
+ export type ThemeMode = "light" | "dark"
4
+ // A palette selects the colours; mode on the resolved Theme remains the
5
+ // light/dark semantic used by components such as the status line.
6
+ export type ThemePreset = ThemeMode | "solarized-light" | "solarized-dark"
7
+
8
+ // The six heading levels a Markdown document can have.
9
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
10
+
11
+ export type Theme = {
12
+ readonly mode: ThemeMode
13
+ // When present, generated diagram colours are snapped to these fixed swatches.
14
+ readonly palette?: readonly RGBA[]
15
+ readonly accent: RGBA
16
+ readonly categorical: readonly RGBA[]
17
+ readonly text: {
18
+ readonly base: RGBA
19
+ readonly muted: RGBA
20
+ readonly feedback: {
21
+ readonly error: RGBA
22
+ readonly warning: RGBA
23
+ readonly success: RGBA
24
+ readonly info: RGBA
25
+ }
26
+ readonly action: {
27
+ readonly primary: { readonly focused: RGBA }
28
+ }
29
+ }
30
+ readonly background: {
31
+ readonly base: RGBA
32
+ }
33
+ readonly border: {
34
+ readonly base: RGBA
35
+ }
36
+ // The frame (viewer window) colours. Optional so a theme built by hand keeps
37
+ // working; the frame falls back to the border and the muted text.
38
+ readonly frame?: FrameTheme
39
+ // Colours of the status line bar. Optional so a host that builds a theme by
40
+ // hand keeps working; the bar falls back to colours derived from the theme.
41
+ readonly statusline?: StatusLineTheme
42
+ readonly markdown: {
43
+ readonly text: RGBA
44
+ readonly heading: RGBA
45
+ // The colour of each heading level. `heading` is the base colour every
46
+ // level falls back to, so a theme that only knows one heading colour keeps
47
+ // working.
48
+ readonly headingLevels: readonly [RGBA, RGBA, RGBA, RGBA, RGBA, RGBA]
49
+ readonly link: RGBA
50
+ readonly linkText: RGBA
51
+ readonly code: RGBA
52
+ readonly blockQuote: RGBA
53
+ readonly emphasis: RGBA
54
+ readonly strong: RGBA
55
+ readonly horizontalRule: RGBA
56
+ readonly listItem: RGBA
57
+ readonly listEnumeration: RGBA
58
+ readonly image: RGBA
59
+ readonly imageText: RGBA
60
+ readonly codeBlock: RGBA
61
+ }
62
+ readonly syntax: {
63
+ readonly comment: RGBA
64
+ readonly keyword: RGBA
65
+ readonly function: RGBA
66
+ readonly variable: RGBA
67
+ readonly string: RGBA
68
+ readonly number: RGBA
69
+ readonly type: RGBA
70
+ readonly operator: RGBA
71
+ readonly punctuation: RGBA
72
+ }
73
+ readonly diff: {
74
+ readonly text: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
75
+ readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
76
+ }
77
+ }
78
+
79
+ // The status line bar has its own background so it reads as a separate strip
80
+ // instead of another line of the page.
81
+ export type StatusLineTheme = {
82
+ readonly background: RGBA
83
+ readonly foreground: RGBA
84
+ readonly title: RGBA
85
+ readonly muted: RGBA
86
+ readonly border: RGBA
87
+ }
88
+
89
+ export type StatusLineThemeOverride = {
90
+ readonly background?: ColorInput
91
+ readonly foreground?: ColorInput
92
+ readonly title?: ColorInput
93
+ readonly muted?: ColorInput
94
+ readonly border?: ColorInput
95
+ }
96
+
97
+ // The frame is the window around the document: its border and the label carved
98
+ // into the top edge.
99
+ export type FrameTheme = {
100
+ readonly border: RGBA
101
+ readonly title: RGBA
102
+ }
103
+
104
+ export type FrameThemeOverride = {
105
+ readonly border?: ColorInput
106
+ readonly title?: ColorInput
107
+ }
108
+
109
+ // The colours of the Markdown surface. `heading1`..`heading6` override the
110
+ // colour of a single heading level; `heading` overrides the colour of every
111
+ // level that has no colour of its own.
112
+ export type MarkdownThemeOverride = {
113
+ readonly [K in Exclude<keyof Theme["markdown"], "headingLevels">]?: ColorInput
114
+ } & {
115
+ readonly heading1?: ColorInput
116
+ readonly heading2?: ColorInput
117
+ readonly heading3?: ColorInput
118
+ readonly heading4?: ColorInput
119
+ readonly heading5?: ColorInput
120
+ readonly heading6?: ColorInput
121
+ }
122
+
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
+ export type ThemeOverride = {
126
+ readonly mode?: ThemePreset
127
+ readonly palette?: readonly ColorInput[]
128
+ readonly accent?: ColorInput
129
+ readonly categorical?: readonly ColorInput[]
130
+ readonly text?: {
131
+ readonly base?: ColorInput
132
+ readonly muted?: ColorInput
133
+ readonly feedback?: {
134
+ readonly error?: ColorInput
135
+ readonly warning?: ColorInput
136
+ readonly success?: ColorInput
137
+ readonly info?: ColorInput
138
+ }
139
+ readonly action?: { readonly primary?: { readonly focused?: ColorInput } }
140
+ }
141
+ readonly background?: { readonly base?: ColorInput }
142
+ readonly border?: { readonly base?: ColorInput }
143
+ readonly frame?: FrameThemeOverride
144
+ readonly statusline?: StatusLineThemeOverride
145
+ readonly markdown?: MarkdownThemeOverride
146
+ readonly syntax?: { readonly [K in keyof Theme["syntax"]]?: ColorInput }
147
+ readonly diff?: {
148
+ readonly text?: { readonly added?: ColorInput; readonly removed?: ColorInput; readonly context?: ColorInput }
149
+ readonly background?: { readonly added?: ColorInput; readonly removed?: ColorInput; readonly context?: ColorInput }
150
+ }
151
+ }
@@ -0,0 +1,27 @@
1
+ import { infoStringToFiletype } from "@opentui/core"
2
+ import { generateSyntax } from "../theme/syntax"
3
+ import type { Theme } from "../theme/types"
4
+ import type { HeadingStyles } from "../headings"
5
+
6
+ export type CodeBlockProps = {
7
+ content: string
8
+ language: string | null
9
+ theme: Theme
10
+ headings: HeadingStyles
11
+ }
12
+
13
+ export function CodeBlock(props: CodeBlockProps) {
14
+ return (
15
+ <code
16
+ content={props.content}
17
+ filetype={infoStringToFiletype(props.language ?? "")}
18
+ syntaxStyle={generateSyntax(props.theme, props.headings, props.theme.markdown.codeBlock)}
19
+ fg={props.theme.markdown.codeBlock}
20
+ bg={props.theme.background.base}
21
+ conceal={false}
22
+ drawUnstyledText={true}
23
+ streaming={false}
24
+ width="100%"
25
+ />
26
+ )
27
+ }
@@ -0,0 +1,111 @@
1
+ import { createSignal } from "solid-js"
2
+ import type { Block, SourceRange } from "../core/document"
3
+
4
+ export type Slot = {
5
+ kind: Block["type"]
6
+ // Rendering observes block; unit metadata observes latest. Moving a source
7
+ // range must not recreate a stateful rendered block.
8
+ block: () => Block
9
+ update: (b: Block) => void
10
+ latest: () => Block
11
+ updateLatest: (b: Block) => void
12
+ }
13
+
14
+ function sameKind(old: Block, next: Block): boolean {
15
+ if (old.type !== next.type) return false
16
+ return old.type !== "mermaid" || (next.type === "mermaid" && old.language === next.language)
17
+ }
18
+
19
+ function sameValue(a: unknown, b: unknown): boolean {
20
+ if (Object.is(a, b)) return true
21
+ if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false
22
+ if (Array.isArray(a) !== Array.isArray(b)) return false
23
+ if (Array.isArray(a)) {
24
+ const other = b as unknown[]
25
+ return a.length === other.length && a.every((value, index) => sameValue(value, other[index]))
26
+ }
27
+ const left = a as Record<string, unknown>
28
+ const right = b as Record<string, unknown>
29
+ const keys = Object.keys(left)
30
+ const otherKeys = Object.keys(right)
31
+ return keys.filter((key) => key !== "source").length === otherKeys.filter((key) => key !== "source").length &&
32
+ keys.every((key) => key === "source" || (Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key])))
33
+ }
34
+
35
+ function sameBlock(old: Block, next: Block): boolean {
36
+ return sameValue(old, next)
37
+ }
38
+
39
+ function sameRange(old: SourceRange | null | undefined, next: SourceRange | null | undefined): boolean {
40
+ return old?.start === next?.start && old?.end === next?.end
41
+ }
42
+
43
+ function sameUnitRanges(old: Block, next: Block): boolean {
44
+ if (!sameRange(old.source, next.source)) return false
45
+ if (old.type !== "list" || next.type !== "list") return true
46
+ return old.items.length === next.items.length &&
47
+ old.items.every((item, index) => sameRange(item.source, next.items[index]?.source))
48
+ }
49
+
50
+ function refresh(slot: Slot, next: Block): void {
51
+ const changed = !sameBlock(slot.block(), next)
52
+ if (changed) slot.update(next)
53
+ // Even when the render tree is identical, the host needs current offsets.
54
+ if (changed || !sameUnitRanges(slot.latest(), next)) slot.updateLatest(next)
55
+ }
56
+
57
+ function createSlot(block: Block): Slot {
58
+ const [get, set] = createSignal(block)
59
+ const [latest, updateLatest] = createSignal(block)
60
+ return { kind: block.type, block: get, update: set, latest, updateLatest }
61
+ }
62
+
63
+ export function reconcileSlots(prev: Slot[], next: Block[]): Slot[] {
64
+ if (prev.length === 0) {
65
+ return next.map(createSlot)
66
+ }
67
+ // prefix
68
+ let prefix = 0
69
+ while (prefix < prev.length && prefix < next.length) {
70
+ const old = prev[prefix].block()
71
+ const incoming = next[prefix]
72
+ if (!sameKind(old, incoming) || old.raw !== incoming.raw) break
73
+ refresh(prev[prefix], incoming)
74
+ prefix++
75
+ }
76
+ // suffix
77
+ let suffix = 0
78
+ while (
79
+ suffix < prev.length - prefix &&
80
+ suffix < next.length - prefix
81
+ ) {
82
+ const old = prev[prev.length - 1 - suffix].block()
83
+ const incoming = next[next.length - 1 - suffix]
84
+ if (!sameKind(old, incoming) || old.raw !== incoming.raw) break
85
+ refresh(prev[prev.length - 1 - suffix], incoming)
86
+ suffix++
87
+ }
88
+ const result: Slot[] = []
89
+ // prefix
90
+ for (let i = 0; i < prefix; i++) result.push(prev[i])
91
+ // middle
92
+ if (prev.length - prefix - suffix === 1 && next.length - prefix - suffix === 1) {
93
+ // reuse slot
94
+ const slot = prev[prefix]
95
+ const incoming = next[prefix]
96
+ if (sameKind(slot.block(), incoming)) {
97
+ refresh(slot, incoming)
98
+ result.push(slot)
99
+ } else {
100
+ result.push(createSlot(incoming))
101
+ }
102
+ } else {
103
+ for (let i = prefix; i < next.length - suffix; i++) {
104
+ const b = next[i]
105
+ result.push(createSlot(b))
106
+ }
107
+ }
108
+ // suffix
109
+ for (let i = prev.length - suffix; i < prev.length; i++) result.push(prev[i])
110
+ return result
111
+ }
@@ -0,0 +1,98 @@
1
+ import { TextAttributes, TextTableRenderable, type RenderContext, type RGBA, type TextChunk, type TextTableContent } from "@opentui/core"
2
+ import { extend } from "@opentui/solid"
3
+ import { resolveTableOptions, type ResolvedTableOptions } from "../core/tables"
4
+ import type { Block } from "../core/document"
5
+ import { ScrollableTableRenderable } from "./table"
6
+ import { toTableChunks } from "./inline-style"
7
+ import type { HeadingStyles } from "../headings"
8
+ import type { Theme } from "../theme/types"
9
+
10
+ export type DocumentTableOptions = {
11
+ tableData: TextTableContent
12
+ resolved: ResolvedTableOptions
13
+ foreground: RGBA
14
+ background: RGBA
15
+ tableBorderColor: RGBA
16
+ }
17
+
18
+ export class MarkdownTableRenderable extends ScrollableTableRenderable {
19
+ constructor(ctx: RenderContext, _options?: Partial<DocumentTableOptions> & { id?: string }) {
20
+ const defaults = resolveTableOptions()
21
+ const table = new TextTableRenderable(ctx, {
22
+ content: [],
23
+ columnWidthMode: defaults.widthMode,
24
+ columnFitter: defaults.columnFitter,
25
+ cellPaddingX: defaults.cellPaddingX,
26
+ wrapMode: defaults.wrapMode,
27
+ showBorders: true, border: true, outerBorder: true, borderStyle: "single",
28
+ selectable: true,
29
+ })
30
+ super(ctx, table)
31
+ }
32
+
33
+ // The Solid reconciler stringifies every `content` prop. Use a different
34
+ // public setter so TextChunk[][] reaches the OpenTUI table intact.
35
+ get tableData(): TextTableContent { return this.table.content }
36
+ set tableData(value: TextTableContent) {
37
+ this.table.content = value
38
+ this.syncOverflowState()
39
+ }
40
+
41
+ get resolved(): ResolvedTableOptions {
42
+ return {
43
+ widthMode: this.table.columnWidthMode,
44
+ columnFitter: this.table.columnFitter,
45
+ cellPaddingX: this.table.cellPaddingX,
46
+ wrapMode: this.table.wrapMode === "none" ? "none" : "word",
47
+ overflow: this.panEnabled ? "scroll" : "wrap",
48
+ }
49
+ }
50
+ set resolved(value: ResolvedTableOptions) {
51
+ this.table.columnWidthMode = value.widthMode
52
+ this.table.columnFitter = value.columnFitter
53
+ this.table.cellPaddingX = value.cellPaddingX
54
+ this.table.wrapMode = value.wrapMode
55
+ this.panEnabled = value.widthMode === "content" && value.overflow === "scroll"
56
+ this.syncOverflowState()
57
+ }
58
+
59
+ get foreground(): RGBA { return this.table.fg }
60
+ set foreground(value: RGBA) { this.table.fg = value; this.syncOverflowState() }
61
+ get tableBorderColor(): RGBA { return this.table.borderColor }
62
+ set tableBorderColor(value: RGBA) { this.table.borderColor = value }
63
+ get background(): RGBA { return this.backgroundColor }
64
+ set background(value: RGBA) {
65
+ this.backgroundColor = value
66
+ this.syncOverflowState()
67
+ }
68
+ }
69
+
70
+ // Register once, through the public Solid extension API.
71
+ extend({ md_text_table: MarkdownTableRenderable })
72
+
73
+ declare module "@opentui/solid" {
74
+ interface OpenTUIComponents {
75
+ md_text_table: typeof MarkdownTableRenderable
76
+ }
77
+ }
78
+
79
+ export function TableBlock(props: {
80
+ block: Extract<Block, { type: "table" }>
81
+ options: ResolvedTableOptions
82
+ theme: Theme
83
+ headings: HeadingStyles
84
+ hyperlinks?: boolean
85
+ }) {
86
+ const cell = (inlines: readonly import("../core/document").Inline[], header: boolean): TextChunk[] => {
87
+ const chunks = toTableChunks(inlines, props.theme, props.headings, props.hyperlinks)
88
+ if (!header) return chunks
89
+ return chunks.map((chunk) => ({ ...chunk, fg: props.theme.markdown.heading, attributes: TextAttributes.BOLD }))
90
+ }
91
+ const content = (): TextTableContent => [
92
+ props.block.header.map((value) => cell(value.children, true)),
93
+ ...props.block.rows.map((row) => row.map((value) => cell(value.children, false))),
94
+ ]
95
+ return <md_text_table tableData={content()} resolved={props.options}
96
+ foreground={props.theme.markdown.text} background={props.theme.background.base}
97
+ tableBorderColor={props.theme.border.base} />
98
+ }
@@ -0,0 +1,195 @@
1
+ import { createMemo, For, type Accessor, type JSX } from "solid-js"
2
+ import stringWidth from "string-width"
3
+ import { type TextChunk } from "@opentui/core"
4
+ import { assertNever, type Block, type Document, type ListItem, type UnitInfo } from "../core/document"
5
+ import type { ResolvedTableOptions } from "../core/tables"
6
+ import type { ImagesConfig, MermaidConfig } from "../config/types"
7
+ import type { CanvasGraphics } from "../graphics"
8
+ import { headingLevels, type HeadingStyles } from "../headings"
9
+ import { LocalImage } from "../media"
10
+ import type { Theme } from "../theme/types"
11
+ import { CodeBlock } from "./code"
12
+ import { reconcileSlots, type Slot } from "./document-slots"
13
+ import { TableBlock } from "./document-table"
14
+ import { InlineFlow } from "./inline-flow"
15
+ import { DisplayMath } from "./math"
16
+ import { MermaidDiagramView } from "./mermaid"
17
+
18
+ type ListBlock = Extract<Block, { type: "list" }>
19
+
20
+ export type RenderUnit = (info: Accessor<UnitInfo>, content: JSX.Element) => JSX.Element
21
+
22
+ export type DocumentViewProps = {
23
+ document: Document
24
+ columns: number
25
+ theme: Theme
26
+ headings: HeadingStyles
27
+ graphics: CanvasGraphics
28
+ surface: () => string
29
+ assetBase: () => string | undefined
30
+ imageLimits?: ImagesConfig
31
+ tableOptions: ResolvedTableOptions
32
+ mermaidOptions?: MermaidConfig
33
+ onDiagnostic?: (diagnostic: { level: "warning" | "error"; message: string }) => void
34
+ renderUnit?: RenderUnit
35
+ }
36
+
37
+ function listMarker(list: ListBlock, index: number, item: ListItem): string {
38
+ const bullet = list.ordered ? `${list.start + index}.` : "-"
39
+ return item.task ? `${bullet} ${item.checked ? "[x]" : "[ ]"}` : bullet
40
+ }
41
+
42
+ export function DocumentView(props: DocumentViewProps) {
43
+ let previous: Slot[] = []
44
+ const slots = createMemo(() => {
45
+ previous = reconcileSlots(previous, [...props.document.blocks])
46
+ return previous
47
+ })
48
+
49
+ function flow(
50
+ children: Extract<Block, { type: "paragraph" }>["children"],
51
+ columns: number,
52
+ quote: boolean | undefined,
53
+ prefix?: (index: number) => string,
54
+ prefixColor?: () => Theme["text"]["base"],
55
+ prefixChunks?: (index: number) => TextChunk[],
56
+ ) {
57
+ return <InlineFlow inlines={children} columns={columns} theme={props.theme} headings={props.headings}
58
+ graphics={props.graphics} surface={props.surface} quote={quote} prefix={prefix}
59
+ prefixColor={prefixColor} prefixChunks={prefixChunks} />
60
+ }
61
+
62
+ function BlockView(input: { block: () => Block; columns: number; quote?: boolean }) {
63
+ // A reused Mermaid slot must retain this component's local last-good state
64
+ // while its fenced source changes. The other variants can remount on edit.
65
+ if (input.block().type === "mermaid") {
66
+ return <MermaidDiagramView source={(input.block() as Extract<Block, { type: "mermaid" }>).content}
67
+ theme={props.theme} columns={input.columns} mermaidOptions={props.mermaidOptions}
68
+ headings={props.headings} onDiagnostic={props.onDiagnostic} />
69
+ }
70
+ const view = createMemo(() => {
71
+ const block = input.block()
72
+ const columns = Math.max(1, input.columns)
73
+ switch (block.type) {
74
+ case "paragraph": return flow(block.children, columns, input.quote)
75
+ case "heading": {
76
+ const level = headingLevels[block.level - 1]
77
+ const style = props.headings[level]
78
+ const children = style.uppercase ? block.children.map((child) => child.type === "text" ? { ...child, text: child.text.toUpperCase() } : child) : block.children
79
+ return <box width="100%" flexDirection="column" flexShrink={0} marginTop={style.marginTop} marginBottom={style.marginBottom}>
80
+ <InlineFlow inlines={children} columns={columns} theme={props.theme} headings={props.headings}
81
+ graphics={props.graphics} surface={props.surface} heading={{ level }}
82
+ prefix={(index) => index === 0 ? `${"#".repeat(level)} ` : " ".repeat(level + 1)} prefixColor={() => props.theme.text.muted} />
83
+ </box>
84
+ }
85
+ case "rule": return <box height={1} width="100%" border={["top"]} borderColor={props.theme.markdown.horizontalRule} />
86
+ case "image": return <LocalImage href={block.href} alt={block.alt} base={props.assetBase}
87
+ columns={() => columns} cell={props.graphics.cell} background={props.surface}
88
+ labelColor={() => props.theme.markdown.image} textColor={() => props.theme.markdown.imageText}
89
+ limits={props.imageLimits} />
90
+ case "display-math": return <DisplayMath content={block.content} open={block.open} close={block.close}
91
+ maxWidth={() => columns} theme={() => props.theme} graphics={props.graphics} surface={props.surface} />
92
+ case "quote": return <box border={columns > 2 ? ["left"] : []} borderColor={props.theme.markdown.blockQuote}
93
+ paddingLeft={columns > 2 ? 1 : 0} width="100%" flexDirection="column">
94
+ <For each={block.blocks}>{(child) => <BlockView block={() => child}
95
+ columns={columns > 2 ? columns - 2 : columns} quote />}</For>
96
+ </box>
97
+ case "list": return <ListBlockView block={() => block} columns={columns} quote={input.quote} wrapItems={false} />
98
+ case "code": return <CodeBlock content={block.content} language={block.language} theme={props.theme} headings={props.headings} />
99
+ case "mermaid": return <MermaidDiagramView source={block.content} theme={props.theme} columns={columns}
100
+ mermaidOptions={props.mermaidOptions} headings={props.headings} onDiagnostic={props.onDiagnostic} />
101
+ case "table": return <TableBlock block={block} options={props.tableOptions} theme={props.theme} headings={props.headings}
102
+ hyperlinks={props.graphics.hyperlinks?.() ?? true} />
103
+ case "literal": return <text fg={props.theme.markdown.text} wrapMode="word" content={block.text} />
104
+ default: return assertNever(block)
105
+ }
106
+ })
107
+ return <box width="100%" flexDirection="column" flexShrink={0}>{view()}</box>
108
+ }
109
+
110
+ function ListItemView(input: {
111
+ block: () => ListBlock
112
+ latest: () => ListBlock
113
+ item: () => ListItem
114
+ index: Accessor<number>
115
+ markerWidth: Accessor<number>
116
+ columns: number
117
+ quote?: boolean
118
+ wrapUnit: boolean
119
+ }) {
120
+ const list = input.block
121
+ const item = input.item
122
+ const markerWidth = input.markerWidth
123
+ const hanging = () => markerWidth() + 1
124
+ const marker = () => listMarker(list(), input.index(), item())
125
+
126
+ const content = <box flexDirection="column" width="100%" flexShrink={0}
127
+ marginBottom={list().loose && input.index() < list().items.length - 1 ? 1 : 0}>
128
+ <For each={item().blocks}>{(child, childIndex) => childIndex() === 0 && child.type === "paragraph"
129
+ ? flow(child.children, input.columns, input.quote, (row) => row === 0
130
+ ? `${marker().padEnd(markerWidth())} ` : " ".repeat(hanging()),
131
+ () => list().ordered ? props.theme.markdown.listEnumeration : props.theme.markdown.listItem,
132
+ item().task ? (row) => row === 0
133
+ ? [
134
+ { __isChunk: true, text: `${list().ordered ? `${list().start + input.index()}.` : "-"} `,
135
+ fg: list().ordered ? props.theme.markdown.listEnumeration : props.theme.markdown.listItem },
136
+ { __isChunk: true, text: `${item().checked ? "[x]" : "[ ]"}${" ".repeat(markerWidth() - stringWidth(marker()) + 1)}`,
137
+ fg: item().checked ? props.theme.text.feedback.success : props.theme.text.muted },
138
+ ] : [{ __isChunk: true, text: " ".repeat(hanging()), fg: props.theme.text.muted }]
139
+ : undefined)
140
+ : childIndex() === 0
141
+ ? <box flexDirection="row" width="100%" flexShrink={0}>
142
+ <text width={hanging()} flexShrink={0}
143
+ fg={list().ordered ? props.theme.markdown.listEnumeration : props.theme.markdown.listItem}>
144
+ {`${marker().padEnd(markerWidth())} `}
145
+ </text>
146
+ <BlockView block={() => child} columns={Math.max(1, input.columns - hanging())} quote={input.quote} />
147
+ </box>
148
+ : <box marginLeft={hanging()} width={Math.max(1, input.columns - hanging())} flexDirection="column">
149
+ <BlockView block={() => child} columns={Math.max(1, input.columns - hanging())} quote={input.quote} />
150
+ </box>}
151
+ </For>
152
+ {item().blocks.length === 0 && <text fg={props.theme.markdown.listItem}>{marker()}</text>}
153
+ </box>
154
+
155
+ const info = () => {
156
+ const current = item()
157
+ const source = input.latest().items[input.index()]?.source ?? null
158
+ return { kind: "list-item" as const, item: current.source === source ? current : { ...current, source }, source }
159
+ }
160
+ return <>{input.wrapUnit && props.renderUnit ? props.renderUnit(info, content) : content}</>
161
+ }
162
+
163
+ function ListBlockView(input: { block: () => ListBlock; latest?: () => ListBlock; columns: number; quote?: boolean; wrapItems: boolean }) {
164
+ const markerWidth = createMemo(() => {
165
+ const list = input.block()
166
+ return Math.max(1, ...list.items.map((entry, index) => stringWidth(listMarker(list, index, entry))))
167
+ })
168
+ return <box flexDirection="column" width="100%" flexShrink={0}>
169
+ <For each={input.block().items}>{(item, index) => <ListItemView block={input.block}
170
+ latest={input.latest ?? input.block} markerWidth={markerWidth}
171
+ item={() => input.block().items[index()] ?? item} index={index} columns={input.columns}
172
+ quote={input.quote} wrapUnit={input.wrapItems} />}</For>
173
+ </box>
174
+ }
175
+
176
+ function TopLevelSlot(input: { slot: Slot; columns: number }) {
177
+ const block = input.slot.block
178
+ if (block().type === "list") {
179
+ return <ListBlockView block={() => block() as ListBlock} latest={() => input.slot.latest() as ListBlock}
180
+ columns={input.columns} wrapItems />
181
+ }
182
+ const info = () => {
183
+ const current = block()
184
+ if (current.type === "list") throw new Error("A top-level list is rendered as list items, not as a block unit")
185
+ const source = input.slot.latest().source ?? null
186
+ return { kind: "block" as const, block: current.source === source ? current : { ...current, source }, source }
187
+ }
188
+ const content = <BlockView block={block} columns={input.columns} />
189
+ return <>{props.renderUnit ? props.renderUnit(info, content) : content}</>
190
+ }
191
+
192
+ return <box width="100%" flexDirection="column" flexShrink={0}>
193
+ <For each={slots()}>{(slot) => <TopLevelSlot slot={slot} columns={props.columns} />}</For>
194
+ </box>
195
+ }