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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- 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,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
|
+
}
|
package/src/core/math.ts
ADDED
|
@@ -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
|
+
}
|