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,144 @@
1
+ import stringWidth from "string-width"
2
+
3
+ // The frame is the chrome of the viewer: a window the document is drawn in. It
4
+ // only ever constrains and decorates the area around the document; the document
5
+ // itself stays anchored to the left edge of that area.
6
+ export const frameAligns = ["left", "center", "right"] as const
7
+
8
+ export type FrameAlign = (typeof frameAligns)[number]
9
+
10
+ export function isFrameAlign(value: unknown): value is FrameAlign {
11
+ return typeof value === "string" && (frameAligns as readonly string[]).includes(value)
12
+ }
13
+
14
+ export const frameStyles = ["box", "ascii", "rounded", "heavy", "double"] as const
15
+
16
+ // "box" is the bundled single-line window. "ascii" draws the same window with
17
+ // `+ - |` for terminals and fonts where the Unicode box characters do not line
18
+ // up. The rest are the native OpenTUI border sets.
19
+ export type FrameStyle = (typeof frameStyles)[number]
20
+
21
+ export function isFrameStyle(value: unknown): value is FrameStyle {
22
+ return typeof value === "string" && (frameStyles as readonly string[]).includes(value)
23
+ }
24
+
25
+ export type FrameConfig = {
26
+ // The frame is opt-in: without it the document uses the whole measured area,
27
+ // which is what an embedded host (a message bubble, a sidebar) wants.
28
+ readonly enabled?: boolean
29
+ // The width of the frame in terminal columns, border and padding included.
30
+ // Omitted means "as wide as the area allows".
31
+ readonly width?: number
32
+ // Where the frame sits when it is narrower than the area around it.
33
+ readonly align?: FrameAlign
34
+ readonly style?: FrameStyle
35
+ // Empty columns between the border and the document.
36
+ readonly paddingX?: number
37
+ // Empty rows between the border and the document.
38
+ readonly paddingY?: number
39
+ // A label carved out of the middle of the top border.
40
+ readonly title?: string
41
+ readonly titleAlign?: FrameAlign
42
+ }
43
+
44
+ export type FrameInsets = {
45
+ readonly left: number
46
+ readonly right: number
47
+ readonly top: number
48
+ readonly bottom: number
49
+ }
50
+
51
+ export type ResolvedFrame = {
52
+ readonly enabled: boolean
53
+ // The width every block of the document is laid out for. This is the single
54
+ // content width shared across the document.
55
+ readonly contentWidth: number
56
+ // The outer width, border and padding included.
57
+ readonly width: number
58
+ // The column the frame starts at inside the area around it.
59
+ readonly offsetX: number
60
+ readonly insets: FrameInsets
61
+ readonly align: FrameAlign
62
+ readonly style: FrameStyle
63
+ readonly paddingX: number
64
+ readonly paddingY: number
65
+ readonly title?: string
66
+ readonly titleAlign: FrameAlign
67
+ }
68
+
69
+ const borderWidth = 1
70
+ const noInsets: FrameInsets = { left: 0, right: 0, top: 0, bottom: 0 }
71
+
72
+ function whole(value: number | undefined, fallback: number) {
73
+ if (value === undefined) return fallback
74
+ if (!Number.isFinite(value)) return fallback
75
+ return Math.max(0, Math.floor(value))
76
+ }
77
+
78
+ function alignOffset(align: FrameAlign, available: number, width: number) {
79
+ if (align === "right") return Math.max(0, available - width)
80
+ if (align === "center") return Math.max(0, Math.floor((available - width) / 2))
81
+ return 0
82
+ }
83
+
84
+ // A title wider than the top border would be clipped by the terminal instead of
85
+ // by us, so it is cut here where the layout knows the frame width.
86
+ function fitTitle(title: string | undefined, columns: number) {
87
+ if (!title || columns <= 0) return undefined
88
+ let result = ""
89
+ for (const character of title) {
90
+ if (stringWidth(result + character) > columns) break
91
+ result += character
92
+ }
93
+ return result || undefined
94
+ }
95
+
96
+ // `resolveFrame` is the single place that turns the host's area width and the
97
+ // frame configuration into the widths the renderer uses. It has no dependency on
98
+ // OpenTUI or Solid, so a host can call it without mounting anything.
99
+ export function resolveFrame(viewport: number, config?: FrameConfig): ResolvedFrame {
100
+ const available = Math.max(1, Math.floor(viewport) || 1)
101
+ const align = config?.align ?? "left"
102
+ const style = config?.style ?? "box"
103
+ const titleAlign = config?.titleAlign ?? "left"
104
+ const enabled = config?.enabled ?? false
105
+ if (!enabled) {
106
+ return {
107
+ enabled: false,
108
+ contentWidth: available,
109
+ width: available,
110
+ offsetX: 0,
111
+ insets: noInsets,
112
+ align,
113
+ style,
114
+ paddingX: 0,
115
+ paddingY: 0,
116
+ title: undefined,
117
+ titleAlign,
118
+ }
119
+ }
120
+ const paddingX = whole(config?.paddingX, 1)
121
+ const paddingY = whole(config?.paddingY, 0)
122
+ const insets: FrameInsets = {
123
+ left: borderWidth + paddingX,
124
+ right: borderWidth + paddingX,
125
+ top: borderWidth + paddingY,
126
+ bottom: borderWidth + paddingY,
127
+ }
128
+ const minWidth = insets.left + insets.right + 1
129
+ const width = config?.width === undefined ? available : Math.max(minWidth, Math.min(available, Math.floor(config.width)))
130
+ const contentWidth = Math.max(1, width - insets.left - insets.right)
131
+ return {
132
+ enabled: true,
133
+ contentWidth,
134
+ width,
135
+ offsetX: alignOffset(align, available, width),
136
+ insets,
137
+ align,
138
+ style,
139
+ paddingX,
140
+ paddingY,
141
+ title: fitTitle(config?.title, Math.max(0, width - borderWidth * 2 - 2)),
142
+ titleAlign,
143
+ }
144
+ }
@@ -0,0 +1,9 @@
1
+ // Retained as a public helper for hosts validating a standalone image before
2
+ // passing the Markdown to the renderer.
3
+ export type MarkdownImage = { readonly alt: string; readonly href: string }
4
+
5
+ export function standaloneImage(source: string): MarkdownImage | undefined {
6
+ const match = /^!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))\)\s*$/.exec(source.trim())
7
+ if (!match) return undefined
8
+ return { alt: match[1] ?? "", href: match[2] ?? match[3] ?? "" }
9
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./image"
2
+ export * from "./frame"
3
+ export * from "./layout"
4
+ export * from "./tables"
5
+ export * from "./document"
6
+ export { parseDocument } from "./parse"
@@ -0,0 +1,27 @@
1
+ import { resolveFrame, type FrameConfig, type ResolvedFrame } from "./frame"
2
+ import { resolveTableOptions, type ResolvedTableOptions, type TablesConfig } from "./tables"
3
+
4
+ export type DocumentLayoutInput = {
5
+ // The width the host gives the document, in terminal columns.
6
+ readonly viewport: number
7
+ readonly frame?: FrameConfig
8
+ readonly tables?: TablesConfig
9
+ }
10
+
11
+ // Everything a host needs to size the document without mounting the renderer.
12
+ // The frame width and the content width are resolved together so no block can
13
+ // disagree about where the right edge is.
14
+ export type DocumentLayout = {
15
+ readonly frame: ResolvedFrame
16
+ readonly contentWidth: number
17
+ readonly tables: ResolvedTableOptions
18
+ }
19
+
20
+ export function createDocumentLayout(input: DocumentLayoutInput): DocumentLayout {
21
+ const frame = resolveFrame(input.viewport, input.frame)
22
+ return {
23
+ frame,
24
+ contentWidth: frame.contentWidth,
25
+ tables: resolveTableOptions(input.tables),
26
+ }
27
+ }
@@ -0,0 +1,170 @@
1
+ import type { TokenizerAndRendererExtension } from "marked"
2
+
3
+ export type MathInlineToken = {
4
+ type: "mathInline"
5
+ raw: string
6
+ text: string
7
+ content: string
8
+ open: "$" | "\\("
9
+ close: "$" | "\\)"
10
+ }
11
+
12
+ export type MathDisplayToken = {
13
+ type: "mathDisplay"
14
+ raw: string
15
+ text: string
16
+ content: string
17
+ open: "$$" | "\\["
18
+ close: "$$" | "\\]"
19
+ }
20
+
21
+ export type MathToken = MathInlineToken | MathDisplayToken
22
+
23
+ function isEscaped(src: string, index: number): boolean {
24
+ let backslashes = 0
25
+ let i = index - 1
26
+ while (i >= 0 && src[i] === "\\") {
27
+ backslashes++
28
+ i--
29
+ }
30
+ return backslashes % 2 === 1
31
+ }
32
+
33
+ function closing(src: string, delimiter: string, start: number, singleLine: boolean): number {
34
+ for (let index = start; index < src.length; index++) {
35
+ if (singleLine && src[index] === "\n") break
36
+ if (src.startsWith(delimiter, index) && !isEscaped(src, index)) return index
37
+ }
38
+ return -1
39
+ }
40
+
41
+ export function createMathInlineExtension(): TokenizerAndRendererExtension {
42
+ return {
43
+ name: "mathInline",
44
+ level: "inline",
45
+ start(src: string) {
46
+ let i = 0
47
+ while (i < src.length) {
48
+ const ch = src[i]
49
+ if (ch === "\\") {
50
+ if (src[i + 1] === "(") {
51
+ if (!isEscaped(src, i)) return i
52
+ }
53
+ i += 2
54
+ continue
55
+ }
56
+ if (ch === "$") {
57
+ if (src[i + 1] === "$") {
58
+ i += 2
59
+ continue
60
+ }
61
+ if (!isEscaped(src, i)) return i
62
+ }
63
+ i++
64
+ }
65
+ return
66
+ },
67
+ tokenizer(src: string) {
68
+ // Try \(...\)
69
+ if (src.startsWith("\\(")) {
70
+ // look for \)
71
+ const end = closing(src, "\\)", 2, true)
72
+ if (end > 2 && src.slice(2, end).indexOf("\n") === -1) {
73
+ const content = src.slice(2, end)
74
+ const raw = src.slice(0, end + 2)
75
+ return {
76
+ type: "mathInline",
77
+ raw,
78
+ text: content,
79
+ content,
80
+ open: "\\(",
81
+ close: "\\)",
82
+ }
83
+ }
84
+ }
85
+ // Try single $
86
+ if (src[0] === "$" && src[1] !== "$") {
87
+ // find closing $ not escaped
88
+ let j = 1
89
+ while (j < src.length) {
90
+ if (src[j] === "\n") break
91
+ if (src[j] === "$" && !isEscaped(src, j)) {
92
+ if (j === 1) break // empty
93
+ const content = src.slice(1, j)
94
+ const raw = src.slice(0, j + 1)
95
+ return {
96
+ type: "mathInline",
97
+ raw,
98
+ text: content,
99
+ content,
100
+ open: "$",
101
+ close: "$",
102
+ }
103
+ }
104
+ j++
105
+ }
106
+ }
107
+ return
108
+ },
109
+ }
110
+ }
111
+
112
+ export function createMathDisplayExtension(): TokenizerAndRendererExtension {
113
+ return {
114
+ name: "mathDisplay",
115
+ level: "inline",
116
+ start(src: string) {
117
+ let i = 0
118
+ while (i < src.length) {
119
+ const ch = src[i]
120
+ if (ch === "\\") {
121
+ if (src[i + 1] === "[") {
122
+ if (!isEscaped(src, i)) return i
123
+ }
124
+ i += 2
125
+ continue
126
+ }
127
+ if (ch === "$") {
128
+ if (src[i + 1] === "$" && !isEscaped(src, i)) return i
129
+ }
130
+ i++
131
+ }
132
+ return
133
+ },
134
+ tokenizer(src: string) {
135
+ // $$...$$
136
+ if (src.startsWith("$$")) {
137
+ const end = closing(src, "$$", 2, false)
138
+ if (end > 2) {
139
+ const content = src.slice(2, end)
140
+ const raw = src.slice(0, end + 2)
141
+ return {
142
+ type: "mathDisplay",
143
+ raw,
144
+ text: content,
145
+ content,
146
+ open: "$$",
147
+ close: "$$",
148
+ }
149
+ }
150
+ }
151
+ // \[...\]
152
+ if (src.startsWith("\\[")) {
153
+ const end = closing(src, "\\]", 2, false)
154
+ if (end > 2) {
155
+ const content = src.slice(2, end)
156
+ const raw = src.slice(0, end + 2)
157
+ return {
158
+ type: "mathDisplay",
159
+ raw,
160
+ text: content,
161
+ content,
162
+ open: "\\[",
163
+ close: "\\]",
164
+ }
165
+ }
166
+ }
167
+ return
168
+ },
169
+ }
170
+ }
@@ -0,0 +1,208 @@
1
+ import { Marked, type Token, type Tokens } from "marked"
2
+ import { createMathDisplayExtension, createMathInlineExtension, type MathDisplayToken, type MathInlineToken } from "./math"
3
+ import type { Block, Document, Inline, SourceRange, TableCell } from "./document"
4
+
5
+ const parser = new Marked({
6
+ gfm: true,
7
+ breaks: false,
8
+ async: false,
9
+ extensions: [createMathDisplayExtension(), createMathInlineExtension()],
10
+ })
11
+
12
+ type ParserToken = Token | MathDisplayToken | MathInlineToken
13
+
14
+ function isDisplay(token: ParserToken): token is MathDisplayToken {
15
+ return token.type === "mathDisplay"
16
+ }
17
+
18
+ function isInlineMath(token: ParserToken): token is MathInlineToken {
19
+ return token.type === "mathInline"
20
+ }
21
+
22
+ function childrenOf(token: ParserToken): ParserToken[] {
23
+ return "tokens" in token && Array.isArray(token.tokens) ? token.tokens : []
24
+ }
25
+
26
+ function inlineToken(token: ParserToken): Inline[] {
27
+ if (isInlineMath(token)) {
28
+ return [{ type: "math", content: token.content, raw: token.raw, open: token.open, close: token.close }]
29
+ }
30
+ if (isDisplay(token)) return [{ type: "text", text: token.raw }]
31
+ switch (token.type) {
32
+ case "text":
33
+ return childrenOf(token).length ? convertInline(childrenOf(token)) : [{ type: "text", text: token.text }]
34
+ case "escape":
35
+ return [{ type: "text", text: token.text }]
36
+ case "codespan":
37
+ return [{ type: "code", text: token.text }]
38
+ case "strong":
39
+ case "em":
40
+ case "del":
41
+ return [{ type: token.type, children: convertInline(token.tokens ?? []) }]
42
+ case "link":
43
+ return [{ type: "link", href: token.href, title: token.title ?? null, children: convertInline(token.tokens ?? []) }]
44
+ case "image":
45
+ return [{ type: "inline-image", href: token.href, title: token.title ?? null, alt: token.text }]
46
+ case "br":
47
+ return [{ type: "break", hard: true }]
48
+ case "checkbox":
49
+ return [] // ListItem.task/checked already carry the marker.
50
+ default:
51
+ return token.raw ? [{ type: "text", text: token.raw }] : []
52
+ }
53
+ }
54
+
55
+ function convertInline(tokens: readonly ParserToken[]): Inline[] {
56
+ return tokens.flatMap(inlineToken)
57
+ }
58
+
59
+ function directInline(tokens: readonly ParserToken[]): ParserToken[] {
60
+ // Marked wraps the inline tokens of tight list items in a `text` block.
61
+ return tokens.flatMap((token) => token.type === "text" && childrenOf(token).length ? childrenOf(token) : [token])
62
+ }
63
+
64
+ function convertParagraph(token: ParserToken, source: SourceRange | null): Block[] {
65
+ const tokens = directInline(childrenOf(token))
66
+ const visible = convertInline(tokens).filter((node) => node.type !== "text" || node.text.trim().length > 0)
67
+ if (visible.length === 1 && visible[0].type === "inline-image") {
68
+ const image = visible[0]
69
+ return [{ type: "image", href: image.href, alt: image.alt, title: image.title, raw: token.raw, source }]
70
+ }
71
+
72
+ if (!tokens.some(isDisplay)) return [{ type: "paragraph", children: convertInline(tokens), raw: token.raw, source }]
73
+
74
+ const blocks: Block[] = []
75
+ let pending: ParserToken[] = []
76
+ // Only direct top-level children with exact raw alignment receive a range;
77
+ // Marked normalizes tokens inside nested quotes and list items.
78
+ let offset = source?.start ?? 0
79
+ let aligned = source !== null
80
+ let pendingStart = offset
81
+ const flush = () => {
82
+ if (!pending.length) return
83
+ const raw = pending.map((child) => child.raw).join("")
84
+ const children = convertInline(pending)
85
+ if (children.length) blocks.push({ type: "paragraph", raw, source: aligned ? { start: pendingStart, end: offset } : null, children })
86
+ pending = []
87
+ pendingStart = offset
88
+ }
89
+ for (const child of tokens) {
90
+ if (aligned && token.raw.slice(offset - source!.start, offset - source!.start + child.raw.length) !== child.raw) aligned = false
91
+ if (isDisplay(child)) {
92
+ flush()
93
+ offset += child.raw.length
94
+ blocks.push({
95
+ type: "display-math", content: child.content, open: child.open, close: child.close,
96
+ raw: child.raw, source: aligned ? { start: offset - child.raw.length, end: offset } : null,
97
+ })
98
+ pendingStart = offset
99
+ } else {
100
+ pending.push(child)
101
+ offset += child.raw.length
102
+ }
103
+ }
104
+ flush()
105
+ return blocks
106
+ }
107
+
108
+ function convertBlocks(tokens: readonly ParserToken[], source: string | null, start = 0): Block[] {
109
+ const blocks: Block[] = []
110
+ let cursor = start
111
+ for (const token of tokens) {
112
+ const range: SourceRange | null = source !== null && source.slice(cursor, cursor + token.raw.length) === token.raw
113
+ ? { start: cursor, end: cursor + token.raw.length }
114
+ : null
115
+ // `space` and `def` are invisible, but still occupy source positions.
116
+ cursor += token.raw.length
117
+ if (token.type === "space" || token.type === "def" || token.type === "checkbox") continue
118
+ if (isDisplay(token)) {
119
+ blocks.push({ type: "display-math", content: token.content, open: token.open, close: token.close, raw: token.raw, source: range })
120
+ continue
121
+ }
122
+ switch (token.type) {
123
+ case "paragraph":
124
+ case "text":
125
+ blocks.push(...convertParagraph(token, range))
126
+ break
127
+ case "heading":
128
+ blocks.push({ type: "heading", level: token.depth as 1 | 2 | 3 | 4 | 5 | 6, children: convertInline(childrenOf(token)), raw: token.raw, source: range })
129
+ break
130
+ case "blockquote":
131
+ blocks.push({ type: "quote", blocks: convertBlocks(childrenOf(token), null), raw: token.raw, source: range })
132
+ break
133
+ case "list": {
134
+ const list = token as Tokens.List
135
+ let cursor = 0
136
+ const items = list.items.map((item) => {
137
+ let itemSource: SourceRange | null = null
138
+ if (range !== null && list.raw.slice(cursor, cursor + item.raw.length) === item.raw) {
139
+ itemSource = { start: range.start + cursor, end: range.start + cursor + item.raw.length }
140
+ }
141
+ cursor += item.raw.length
142
+ return {
143
+ task: item.task, checked: item.task ? (item.checked ?? false) : null, loose: item.loose,
144
+ blocks: convertBlocks(item.tokens, null),
145
+ source: itemSource,
146
+ }
147
+ })
148
+ blocks.push({
149
+ type: "list", raw: list.raw, source: range, ordered: list.ordered,
150
+ start: list.start === "" ? 1 : list.start, loose: list.loose,
151
+ items,
152
+ })
153
+ break
154
+ }
155
+ case "table": {
156
+ const table = token as Tokens.Table
157
+ const convertCell = (cell: Tokens.TableCell | undefined, header: boolean, align: TableCell["align"]): TableCell => ({
158
+ children: cell ? convertInline(cell.tokens) : [], header, align,
159
+ })
160
+ const align = table.align
161
+ const header = table.header.map((cell, i) => convertCell(cell, true, align[i] ?? null))
162
+ const rows = table.rows.map((row) => header.map((_, i) => convertCell(row[i], false, align[i] ?? null)))
163
+ blocks.push({ type: "table", align, header, rows, raw: table.raw, source: range })
164
+ break
165
+ }
166
+ case "code": {
167
+ const language = token.lang ?? null
168
+ if (language?.trim().split(/\s+/)[0]?.toLowerCase() === "mermaid") {
169
+ blocks.push({ type: "mermaid", content: token.text, language: "mermaid", raw: token.raw, source: range })
170
+ } else {
171
+ blocks.push({ type: "code", content: token.text, language, fenced: /^ {0,3}(?:`{3,}|~{3,})/.test(token.raw), raw: token.raw, source: range })
172
+ }
173
+ break
174
+ }
175
+ case "hr":
176
+ blocks.push({ type: "rule", raw: token.raw, source: range })
177
+ break
178
+ default:
179
+ // HTML and unknown extensions are literal, never executed or silently lost.
180
+ if (token.raw) blocks.push({ type: "literal", text: token.raw, raw: token.raw, source: range })
181
+ }
182
+ }
183
+ return blocks
184
+ }
185
+
186
+ export function parseDocument(source: string): Document {
187
+ // Marked token raw text uses LF even for CRLF input. Parse against the same
188
+ // normalized text, then translate proven offsets back into the host's source.
189
+ const normalized = source.replace(/\r\n?/g, "\n")
190
+ const blocks = convertBlocks(parser.lexer(normalized), normalized)
191
+ if (normalized === source) return { source, blocks }
192
+
193
+ const offsets: number[] = [0]
194
+ for (let index = 0; index < source.length; index++) {
195
+ if (source[index] === "\r" && source[index + 1] === "\n") index++
196
+ offsets.push(index + 1)
197
+ }
198
+ const originalRange = (range: SourceRange | null): SourceRange | null => range === null ? null
199
+ : { start: offsets[range.start]!, end: offsets[range.end]! }
200
+ const originalBlock = (block: Block): Block => {
201
+ if (block.type === "list") return {
202
+ ...block, source: originalRange(block.source),
203
+ items: block.items.map((item) => ({ ...item, source: originalRange(item.source) })),
204
+ }
205
+ return { ...block, source: originalRange(block.source) }
206
+ }
207
+ return { source, blocks: blocks.map(originalBlock) }
208
+ }
@@ -0,0 +1,58 @@
1
+ // Table sizing is a layout decision, not a renderer one: the answer is a set of
2
+ // options OpenTUI understands, and it can be computed without a render context.
3
+ export const tableWidthModes = ["content", "full"] as const
4
+
5
+ // "content" sizes the columns to the cells, which keeps a two-column table from
6
+ // stretching over the whole window. "full" is OpenTUI's grid default.
7
+ export type TableWidthMode = (typeof tableWidthModes)[number]
8
+
9
+ export function isTableWidthMode(value: unknown): value is TableWidthMode {
10
+ return typeof value === "string" && (tableWidthModes as readonly string[]).includes(value)
11
+ }
12
+
13
+ export const tableOverflows = ["scroll", "wrap"] as const
14
+
15
+ // A table wider than the document either scrolls sideways (like the Mermaid
16
+ // diagrams) or wraps its cells to fit.
17
+ export type TableOverflow = (typeof tableOverflows)[number]
18
+
19
+ export function isTableOverflow(value: unknown): value is TableOverflow {
20
+ return typeof value === "string" && (tableOverflows as readonly string[]).includes(value)
21
+ }
22
+
23
+ export const tableColumnFitters = ["proportional", "balanced"] as const
24
+
25
+ export type TableColumnFitter = (typeof tableColumnFitters)[number]
26
+
27
+ export function isTableColumnFitter(value: unknown): value is TableColumnFitter {
28
+ return typeof value === "string" && (tableColumnFitters as readonly string[]).includes(value)
29
+ }
30
+
31
+ export type TablesConfig = {
32
+ readonly widthMode?: TableWidthMode
33
+ readonly overflow?: TableOverflow
34
+ readonly columnFitter?: TableColumnFitter
35
+ readonly cellPaddingX?: number
36
+ }
37
+
38
+ export type ResolvedTableOptions = {
39
+ readonly widthMode: TableWidthMode
40
+ readonly overflow: TableOverflow
41
+ readonly columnFitter: TableColumnFitter
42
+ readonly cellPaddingX: number
43
+ // A table that may scroll keeps its intrinsic width. A table that has to fit
44
+ // wraps its cells instead of being clipped at the window edge.
45
+ readonly wrapMode: "none" | "word"
46
+ }
47
+
48
+ export function resolveTableOptions(config?: TablesConfig): ResolvedTableOptions {
49
+ const widthMode = config?.widthMode ?? "content"
50
+ const overflow = config?.overflow ?? "scroll"
51
+ return {
52
+ widthMode,
53
+ overflow,
54
+ columnFitter: config?.columnFitter ?? "balanced",
55
+ cellPaddingX: config?.cellPaddingX ?? 1,
56
+ wrapMode: overflow === "scroll" && widthMode === "content" ? "none" : "word",
57
+ }
58
+ }