@tooee/renderers 0.1.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/README.md +5 -0
- package/dist/CodeView.d.ts +14 -0
- package/dist/CodeView.d.ts.map +1 -0
- package/dist/CodeView.js +69 -0
- package/dist/CodeView.js.map +1 -0
- package/dist/CommandPalette.d.ts +15 -0
- package/dist/CommandPalette.d.ts.map +1 -0
- package/dist/CommandPalette.js +73 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ImageView.d.ts +15 -0
- package/dist/ImageView.d.ts.map +1 -0
- package/dist/ImageView.js +115 -0
- package/dist/ImageView.js.map +1 -0
- package/dist/MarkdownView.d.ts +15 -0
- package/dist/MarkdownView.d.ts.map +1 -0
- package/dist/MarkdownView.js +219 -0
- package/dist/MarkdownView.js.map +1 -0
- package/dist/Table.d.ts +35 -0
- package/dist/Table.d.ts.map +1 -0
- package/dist/Table.js +172 -0
- package/dist/Table.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers.d.ts +22 -0
- package/dist/parsers.d.ts.map +1 -0
- package/dist/parsers.js +143 -0
- package/dist/parsers.js.map +1 -0
- package/dist/table-types.d.ts +7 -0
- package/dist/table-types.d.ts.map +1 -0
- package/dist/table-types.js +2 -0
- package/dist/table-types.js.map +1 -0
- package/package.json +45 -0
- package/src/CodeView.tsx +117 -0
- package/src/CommandPalette.tsx +137 -0
- package/src/ImageView.tsx +183 -0
- package/src/MarkdownView.tsx +379 -0
- package/src/Table.tsx +263 -0
- package/src/index.ts +19 -0
- package/src/parsers.ts +146 -0
- package/src/table-types.ts +7 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import { useTheme } from "@tooee/themes"
|
|
4
|
+
|
|
5
|
+
export interface CommandPaletteEntry {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
hotkey?: string
|
|
9
|
+
category?: string
|
|
10
|
+
icon?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CommandPaletteProps {
|
|
14
|
+
commands: CommandPaletteEntry[]
|
|
15
|
+
onSelect: (commandId: string) => void
|
|
16
|
+
onClose: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fuzzyMatch(query: string, text: string): number | null {
|
|
20
|
+
const lowerQuery = query.toLowerCase()
|
|
21
|
+
const lowerText = text.toLowerCase()
|
|
22
|
+
|
|
23
|
+
let qi = 0
|
|
24
|
+
let score = 0
|
|
25
|
+
let lastMatchIndex = -2
|
|
26
|
+
|
|
27
|
+
for (let ti = 0; ti < lowerText.length && qi < lowerQuery.length; ti++) {
|
|
28
|
+
if (lowerText[ti] === lowerQuery[qi]) {
|
|
29
|
+
// Start of string bonus
|
|
30
|
+
if (ti === 0) score += 3
|
|
31
|
+
// Word boundary bonus (after space, hyphen, dot, slash)
|
|
32
|
+
else if (" -./".includes(lowerText[ti - 1]!)) score += 2
|
|
33
|
+
// Consecutive bonus
|
|
34
|
+
else if (ti === lastMatchIndex + 1) score += 1
|
|
35
|
+
|
|
36
|
+
lastMatchIndex = ti
|
|
37
|
+
qi++
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return qi === lowerQuery.length ? score : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function CommandPalette({ commands, onSelect, onClose }: CommandPaletteProps) {
|
|
45
|
+
const { theme } = useTheme()
|
|
46
|
+
const [filter, setFilter] = useState("")
|
|
47
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
48
|
+
|
|
49
|
+
const filtered = useMemo(() => {
|
|
50
|
+
if (!filter) return commands
|
|
51
|
+
const results: { entry: CommandPaletteEntry; score: number }[] = []
|
|
52
|
+
for (const entry of commands) {
|
|
53
|
+
const score = fuzzyMatch(filter, entry.title)
|
|
54
|
+
if (score !== null) results.push({ entry, score })
|
|
55
|
+
}
|
|
56
|
+
results.sort((a, b) => b.score - a.score)
|
|
57
|
+
return results.map((r) => r.entry)
|
|
58
|
+
}, [commands, filter])
|
|
59
|
+
|
|
60
|
+
const handleSelect = useCallback(() => {
|
|
61
|
+
const item = filtered[activeIndex]
|
|
62
|
+
if (item) {
|
|
63
|
+
onSelect(item.id)
|
|
64
|
+
}
|
|
65
|
+
}, [filtered, activeIndex, onSelect])
|
|
66
|
+
|
|
67
|
+
useKeyboard((key) => {
|
|
68
|
+
if (key.name === "escape") {
|
|
69
|
+
key.preventDefault()
|
|
70
|
+
onClose()
|
|
71
|
+
} else if (key.name === "return") {
|
|
72
|
+
key.preventDefault()
|
|
73
|
+
handleSelect()
|
|
74
|
+
} else if (key.name === "up") {
|
|
75
|
+
key.preventDefault()
|
|
76
|
+
setActiveIndex((i) => Math.max(0, i - 1))
|
|
77
|
+
} else if (key.name === "down") {
|
|
78
|
+
key.preventDefault()
|
|
79
|
+
setActiveIndex((i) => Math.min(filtered.length - 1, i + 1))
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box
|
|
85
|
+
position="absolute"
|
|
86
|
+
left="20%"
|
|
87
|
+
right="20%"
|
|
88
|
+
top={2}
|
|
89
|
+
maxHeight="60%"
|
|
90
|
+
flexDirection="column"
|
|
91
|
+
backgroundColor={theme.backgroundPanel}
|
|
92
|
+
border
|
|
93
|
+
borderColor={theme.border}
|
|
94
|
+
>
|
|
95
|
+
{/* Filter row */}
|
|
96
|
+
<box flexDirection="row" paddingLeft={1} paddingRight={1} height={1}>
|
|
97
|
+
<text content=":" fg={theme.accent} />
|
|
98
|
+
<input
|
|
99
|
+
focused
|
|
100
|
+
placeholder="Filter commands..."
|
|
101
|
+
onInput={(value: string) => {
|
|
102
|
+
setFilter(value)
|
|
103
|
+
setActiveIndex(0)
|
|
104
|
+
}}
|
|
105
|
+
backgroundColor="transparent"
|
|
106
|
+
focusedBackgroundColor="transparent"
|
|
107
|
+
textColor={theme.text}
|
|
108
|
+
placeholderColor={theme.textMuted}
|
|
109
|
+
cursorColor={theme.accent}
|
|
110
|
+
style={{ flexGrow: 1 }}
|
|
111
|
+
/>
|
|
112
|
+
<text content={` ${filtered.length}`} fg={theme.textMuted} />
|
|
113
|
+
</box>
|
|
114
|
+
|
|
115
|
+
{/* Separator */}
|
|
116
|
+
<box height={1} width="100%" backgroundColor={theme.border} />
|
|
117
|
+
|
|
118
|
+
{/* Command list */}
|
|
119
|
+
<scrollbox focused={false} style={{ flexGrow: 1 }}>
|
|
120
|
+
{filtered.map((entry, i) => (
|
|
121
|
+
<box
|
|
122
|
+
key={entry.id}
|
|
123
|
+
flexDirection="row"
|
|
124
|
+
paddingLeft={1}
|
|
125
|
+
paddingRight={1}
|
|
126
|
+
height={1}
|
|
127
|
+
backgroundColor={i === activeIndex ? theme.backgroundElement : undefined}
|
|
128
|
+
>
|
|
129
|
+
<text content={entry.title} fg={theme.text} style={{ flexGrow: 1 }} />
|
|
130
|
+
{entry.hotkey && <text content={entry.hotkey} fg={theme.textMuted} />}
|
|
131
|
+
{entry.category && <text content={` ${entry.category}`} fg={theme.textMuted} />}
|
|
132
|
+
</box>
|
|
133
|
+
))}
|
|
134
|
+
</scrollbox>
|
|
135
|
+
</box>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useState, useEffect } from "react"
|
|
2
|
+
import { Jimp } from "jimp"
|
|
3
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
4
|
+
import { useTheme } from "@tooee/themes"
|
|
5
|
+
|
|
6
|
+
interface ImageViewProps {
|
|
7
|
+
/** File path to the image */
|
|
8
|
+
src: string
|
|
9
|
+
/** Maximum width in characters (defaults to terminal width) */
|
|
10
|
+
maxWidth?: number
|
|
11
|
+
/** Maximum height in characters (defaults to terminal height) */
|
|
12
|
+
maxHeight?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PixelRow {
|
|
16
|
+
key: number
|
|
17
|
+
cells: PixelCell[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PixelCell {
|
|
21
|
+
char: string
|
|
22
|
+
fg: string
|
|
23
|
+
bg: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Renders an image in the terminal using half-block characters.
|
|
28
|
+
* Each character cell displays two vertical pixels using foreground (top) and background (bottom) colors.
|
|
29
|
+
*/
|
|
30
|
+
export function ImageView({ src, maxWidth, maxHeight }: ImageViewProps) {
|
|
31
|
+
const { theme } = useTheme()
|
|
32
|
+
const { width: termWidth, height: termHeight } = useTerminalDimensions()
|
|
33
|
+
const [rows, setRows] = useState<PixelRow[] | null>(null)
|
|
34
|
+
const [error, setError] = useState<string | null>(null)
|
|
35
|
+
const [imageInfo, setImageInfo] = useState<{ width: number; height: number } | null>(null)
|
|
36
|
+
|
|
37
|
+
const effectiveMaxWidth = maxWidth ?? termWidth - 2
|
|
38
|
+
const effectiveMaxHeight = maxHeight ?? (termHeight - 4) * 2 // *2 because each char = 2 pixels
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false
|
|
42
|
+
|
|
43
|
+
async function loadImage() {
|
|
44
|
+
try {
|
|
45
|
+
const image = await Jimp.read(src)
|
|
46
|
+
if (cancelled) return
|
|
47
|
+
|
|
48
|
+
const originalWidth = image.width
|
|
49
|
+
const originalHeight = image.height
|
|
50
|
+
|
|
51
|
+
// Calculate scale to fit within bounds while preserving aspect ratio
|
|
52
|
+
// Terminal characters are roughly 2:1 (height:width), so we need to account for this
|
|
53
|
+
const _charAspectRatio = 2 // Each character is roughly 2x as tall as it is wide
|
|
54
|
+
const adjustedMaxHeight = effectiveMaxHeight
|
|
55
|
+
|
|
56
|
+
const scaleX = effectiveMaxWidth / originalWidth
|
|
57
|
+
const scaleY = adjustedMaxHeight / originalHeight
|
|
58
|
+
const scale = Math.min(scaleX, scaleY, 1) // Don't upscale
|
|
59
|
+
|
|
60
|
+
const newWidth = Math.floor(originalWidth * scale)
|
|
61
|
+
const newHeight = Math.floor(originalHeight * scale)
|
|
62
|
+
|
|
63
|
+
// Resize image
|
|
64
|
+
if (newWidth !== originalWidth || newHeight !== originalHeight) {
|
|
65
|
+
image.resize({ w: newWidth, h: newHeight })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Convert to half-block characters
|
|
69
|
+
const pixelRows = convertToHalfBlocks(image)
|
|
70
|
+
|
|
71
|
+
if (!cancelled) {
|
|
72
|
+
setImageInfo({ width: newWidth, height: Math.ceil(newHeight / 2) })
|
|
73
|
+
setRows(pixelRows)
|
|
74
|
+
setError(null)
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
setError(err instanceof Error ? err.message : "Failed to load image")
|
|
79
|
+
setRows(null)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
loadImage()
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true
|
|
87
|
+
}
|
|
88
|
+
}, [src, effectiveMaxWidth, effectiveMaxHeight])
|
|
89
|
+
|
|
90
|
+
if (error) {
|
|
91
|
+
return (
|
|
92
|
+
<box style={{ flexDirection: "column", padding: 1 }}>
|
|
93
|
+
<text content={`Error loading image: ${error}`} fg={theme.error} />
|
|
94
|
+
</box>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!rows) {
|
|
99
|
+
return (
|
|
100
|
+
<box style={{ padding: 1 }}>
|
|
101
|
+
<text content="Loading image..." fg={theme.textMuted} />
|
|
102
|
+
</box>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<box style={{ flexDirection: "column" }}>
|
|
108
|
+
{rows.map((row) => (
|
|
109
|
+
<text key={row.key} height={1}>
|
|
110
|
+
{row.cells.map((cell, i) => (
|
|
111
|
+
<span key={i} fg={cell.fg} bg={cell.bg}>
|
|
112
|
+
{cell.char}
|
|
113
|
+
</span>
|
|
114
|
+
))}
|
|
115
|
+
</text>
|
|
116
|
+
))}
|
|
117
|
+
{imageInfo && (
|
|
118
|
+
<text
|
|
119
|
+
content={`${imageInfo.width}×${imageInfo.height * 2} pixels`}
|
|
120
|
+
fg={theme.textMuted}
|
|
121
|
+
style={{ marginTop: 1 }}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
</box>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convert an image to half-block characters.
|
|
130
|
+
* Uses ▀ (upper half block) where top pixel is fg, bottom pixel is bg.
|
|
131
|
+
*/
|
|
132
|
+
function convertToHalfBlocks(image: Awaited<ReturnType<typeof Jimp.read>>): PixelRow[] {
|
|
133
|
+
const width = image.width
|
|
134
|
+
const height = image.height
|
|
135
|
+
const rows: PixelRow[] = []
|
|
136
|
+
|
|
137
|
+
// Process two rows at a time (top and bottom of each character cell)
|
|
138
|
+
for (let y = 0; y < height; y += 2) {
|
|
139
|
+
const cells: PixelCell[] = []
|
|
140
|
+
|
|
141
|
+
for (let x = 0; x < width; x++) {
|
|
142
|
+
const topColor = getPixelColor(image, x, y)
|
|
143
|
+
const bottomColor = y + 1 < height ? getPixelColor(image, x, y + 1) : topColor
|
|
144
|
+
|
|
145
|
+
cells.push({
|
|
146
|
+
char: "▀",
|
|
147
|
+
fg: topColor,
|
|
148
|
+
bg: bottomColor,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
rows.push({ key: y, cells })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return rows
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getPixelColor(image: Awaited<ReturnType<typeof Jimp.read>>, x: number, y: number): string {
|
|
159
|
+
const pixel = image.getPixelColor(x, y)
|
|
160
|
+
// Jimp returns RGBA as a single integer: 0xRRGGBBAA
|
|
161
|
+
const r = (pixel >> 24) & 0xff
|
|
162
|
+
const g = (pixel >> 16) & 0xff
|
|
163
|
+
const b = (pixel >> 8) & 0xff
|
|
164
|
+
const a = pixel & 0xff
|
|
165
|
+
|
|
166
|
+
// Handle transparency by blending with a dark background
|
|
167
|
+
if (a < 255) {
|
|
168
|
+
const alpha = a / 255
|
|
169
|
+
const bgR = 0x1a
|
|
170
|
+
const bgG = 0x1b
|
|
171
|
+
const bgB = 0x26
|
|
172
|
+
const blendedR = Math.round(r * alpha + bgR * (1 - alpha))
|
|
173
|
+
const blendedG = Math.round(g * alpha + bgG * (1 - alpha))
|
|
174
|
+
const blendedB = Math.round(b * alpha + bgB * (1 - alpha))
|
|
175
|
+
return rgbToHex(blendedR, blendedG, blendedB)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return rgbToHex(r, g, b)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
182
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
|
|
183
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { marked, type Token, type Tokens } from "marked"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { useTheme, type ResolvedTheme } from "@tooee/themes"
|
|
4
|
+
import type { SyntaxStyle } from "@opentui/core"
|
|
5
|
+
import { Table } from "./Table.jsx"
|
|
6
|
+
|
|
7
|
+
interface MarkdownViewProps {
|
|
8
|
+
content: string
|
|
9
|
+
activeBlock?: number
|
|
10
|
+
selectedBlocks?: { start: number; end: number }
|
|
11
|
+
matchingBlocks?: Set<number>
|
|
12
|
+
currentMatchBlock?: number
|
|
13
|
+
toggledBlocks?: Set<number>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function MarkdownView({
|
|
17
|
+
content,
|
|
18
|
+
activeBlock,
|
|
19
|
+
selectedBlocks,
|
|
20
|
+
matchingBlocks,
|
|
21
|
+
currentMatchBlock,
|
|
22
|
+
toggledBlocks,
|
|
23
|
+
}: MarkdownViewProps) {
|
|
24
|
+
const { theme, syntax } = useTheme()
|
|
25
|
+
const tokens = marked.lexer(content)
|
|
26
|
+
const blocks = tokens.filter((t) => t.type !== "space")
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<box style={{ flexDirection: "column" }}>
|
|
30
|
+
{blocks.map((token, index) => {
|
|
31
|
+
const { accent: accentColor, background: bgColor } = getBlockStyle(
|
|
32
|
+
index,
|
|
33
|
+
theme,
|
|
34
|
+
activeBlock,
|
|
35
|
+
selectedBlocks,
|
|
36
|
+
matchingBlocks,
|
|
37
|
+
currentMatchBlock,
|
|
38
|
+
toggledBlocks,
|
|
39
|
+
)
|
|
40
|
+
const blockContent = (
|
|
41
|
+
<TokenRenderer key={index} token={token} theme={theme} syntax={syntax} />
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (accentColor) {
|
|
45
|
+
return (
|
|
46
|
+
<box
|
|
47
|
+
key={index}
|
|
48
|
+
style={{ flexDirection: "row" }}
|
|
49
|
+
backgroundColor={bgColor ?? undefined}
|
|
50
|
+
>
|
|
51
|
+
<text content="▎" fg={accentColor} />
|
|
52
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>{blockContent}</box>
|
|
53
|
+
</box>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return <box key={index}>{blockContent}</box>
|
|
58
|
+
})}
|
|
59
|
+
</box>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getBlockStyle(
|
|
64
|
+
index: number,
|
|
65
|
+
theme: ResolvedTheme,
|
|
66
|
+
activeBlock?: number,
|
|
67
|
+
selectedBlocks?: { start: number; end: number },
|
|
68
|
+
matchingBlocks?: Set<number>,
|
|
69
|
+
currentMatchBlock?: number,
|
|
70
|
+
toggledBlocks?: Set<number>,
|
|
71
|
+
): { accent: string | null; background: string | null } {
|
|
72
|
+
// Priority: cursor > toggled > current match > match > selection
|
|
73
|
+
if (activeBlock === index) return { accent: theme.primary, background: theme.backgroundElement }
|
|
74
|
+
if (toggledBlocks?.has(index)) return { accent: theme.secondary, background: theme.backgroundPanel }
|
|
75
|
+
if (currentMatchBlock === index) return { accent: theme.accent, background: null }
|
|
76
|
+
if (matchingBlocks?.has(index)) return { accent: theme.warning, background: null }
|
|
77
|
+
if (selectedBlocks && index >= selectedBlocks.start && index <= selectedBlocks.end) {
|
|
78
|
+
return { accent: theme.secondary, background: theme.backgroundPanel }
|
|
79
|
+
}
|
|
80
|
+
return { accent: null, background: null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function TokenRenderer({
|
|
84
|
+
token,
|
|
85
|
+
theme,
|
|
86
|
+
syntax,
|
|
87
|
+
}: {
|
|
88
|
+
token: Token
|
|
89
|
+
theme: ResolvedTheme
|
|
90
|
+
syntax: SyntaxStyle
|
|
91
|
+
}): ReactNode {
|
|
92
|
+
switch (token.type) {
|
|
93
|
+
case "heading":
|
|
94
|
+
return <HeadingRenderer token={token as Tokens.Heading} theme={theme} />
|
|
95
|
+
case "paragraph":
|
|
96
|
+
return <ParagraphRenderer token={token as Tokens.Paragraph} theme={theme} />
|
|
97
|
+
case "code":
|
|
98
|
+
return <CodeBlockRenderer token={token as Tokens.Code} theme={theme} syntax={syntax} />
|
|
99
|
+
case "blockquote":
|
|
100
|
+
return <BlockquoteRenderer token={token as Tokens.Blockquote} theme={theme} />
|
|
101
|
+
case "list":
|
|
102
|
+
return <ListRenderer token={token as Tokens.List} theme={theme} />
|
|
103
|
+
case "table":
|
|
104
|
+
return <MarkdownTableRenderer token={token as Tokens.Table} />
|
|
105
|
+
case "hr":
|
|
106
|
+
return <HorizontalRule theme={theme} />
|
|
107
|
+
case "space":
|
|
108
|
+
return null
|
|
109
|
+
case "html":
|
|
110
|
+
return null
|
|
111
|
+
default:
|
|
112
|
+
if ("text" in token && typeof token.text === "string") {
|
|
113
|
+
return (
|
|
114
|
+
<text
|
|
115
|
+
content={token.text}
|
|
116
|
+
style={{
|
|
117
|
+
fg: theme.markdownText,
|
|
118
|
+
marginBottom: 1,
|
|
119
|
+
marginTop: 0,
|
|
120
|
+
marginLeft: 1,
|
|
121
|
+
marginRight: 1,
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function HeadingRenderer({ token, theme }: { token: Tokens.Heading; theme: ResolvedTheme }) {
|
|
131
|
+
const headingColors: Record<number, string> = {
|
|
132
|
+
1: theme.markdownHeading,
|
|
133
|
+
2: theme.secondary,
|
|
134
|
+
3: theme.accent,
|
|
135
|
+
4: theme.text,
|
|
136
|
+
5: theme.textMuted,
|
|
137
|
+
6: theme.textMuted,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const prefixes: Record<number, string> = {
|
|
141
|
+
1: "# ",
|
|
142
|
+
2: "## ",
|
|
143
|
+
3: "### ",
|
|
144
|
+
4: "#### ",
|
|
145
|
+
5: "##### ",
|
|
146
|
+
6: "###### ",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const headingText = getPlainText(token.tokens || [])
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<box style={{ marginTop: 1, marginBottom: 1 }}>
|
|
153
|
+
<text style={{ fg: headingColors[token.depth] || theme.text }}>
|
|
154
|
+
<span fg={theme.textMuted}>{prefixes[token.depth]}</span>
|
|
155
|
+
<strong>{headingText}</strong>
|
|
156
|
+
</text>
|
|
157
|
+
</box>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ParagraphRenderer({ token, theme }: { token: Tokens.Paragraph; theme: ResolvedTheme }) {
|
|
162
|
+
return (
|
|
163
|
+
<box style={{ marginBottom: 1, marginLeft: 1, marginRight: 1 }}>
|
|
164
|
+
<text style={{ fg: theme.markdownText }}>
|
|
165
|
+
<InlineTokens tokens={token.tokens || []} theme={theme} />
|
|
166
|
+
</text>
|
|
167
|
+
</box>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function CodeBlockRenderer({
|
|
172
|
+
token,
|
|
173
|
+
theme,
|
|
174
|
+
syntax,
|
|
175
|
+
}: {
|
|
176
|
+
token: Tokens.Code
|
|
177
|
+
theme: ResolvedTheme
|
|
178
|
+
syntax: SyntaxStyle
|
|
179
|
+
}) {
|
|
180
|
+
return (
|
|
181
|
+
<box
|
|
182
|
+
style={{
|
|
183
|
+
marginTop: 0,
|
|
184
|
+
marginBottom: 1,
|
|
185
|
+
marginLeft: 1,
|
|
186
|
+
marginRight: 1,
|
|
187
|
+
border: true,
|
|
188
|
+
borderColor: theme.border,
|
|
189
|
+
backgroundColor: theme.backgroundElement,
|
|
190
|
+
flexDirection: "column",
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<code content={token.text} filetype={token.lang} syntaxStyle={syntax} />
|
|
194
|
+
</box>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function BlockquoteRenderer({ token, theme }: { token: Tokens.Blockquote; theme: ResolvedTheme }) {
|
|
199
|
+
const quoteText = token.tokens
|
|
200
|
+
? token.tokens
|
|
201
|
+
.map((t) => {
|
|
202
|
+
const innerTokens = "tokens" in t ? (t as { tokens?: Token[] }).tokens : undefined
|
|
203
|
+
const textContent = "text" in t ? (t as { text?: string }).text : ""
|
|
204
|
+
return getPlainText(innerTokens || []) || textContent || ""
|
|
205
|
+
})
|
|
206
|
+
.join("\n")
|
|
207
|
+
: ""
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1, marginRight: 1, paddingLeft: 2 }}>
|
|
211
|
+
<text style={{ fg: theme.markdownBlockQuote }} content="│ " />
|
|
212
|
+
<text style={{ fg: theme.textMuted }} content={quoteText} />
|
|
213
|
+
</box>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ListRenderer({ token, theme }: { token: Tokens.List; theme: ResolvedTheme }) {
|
|
218
|
+
return (
|
|
219
|
+
<box style={{ marginBottom: 1, marginLeft: 3, marginRight: 1, flexDirection: "column" }}>
|
|
220
|
+
{token.items.map((item, index) => (
|
|
221
|
+
<ListItemRenderer
|
|
222
|
+
key={index}
|
|
223
|
+
item={item}
|
|
224
|
+
ordered={token.ordered}
|
|
225
|
+
index={index + (token.start || 1)}
|
|
226
|
+
theme={theme}
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
</box>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ListItemRenderer({
|
|
234
|
+
item,
|
|
235
|
+
ordered,
|
|
236
|
+
index,
|
|
237
|
+
theme,
|
|
238
|
+
}: {
|
|
239
|
+
item: Tokens.ListItem
|
|
240
|
+
ordered: boolean
|
|
241
|
+
index: number
|
|
242
|
+
theme: ResolvedTheme
|
|
243
|
+
}) {
|
|
244
|
+
const bullet = ordered ? `${index}. ` : "- "
|
|
245
|
+
const itemContent = item.tokens || []
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<box style={{ flexDirection: "row" }}>
|
|
249
|
+
<text style={{ fg: theme.markdownListItem }} content={bullet} />
|
|
250
|
+
<box style={{ flexShrink: 1, flexDirection: "column" }}>
|
|
251
|
+
{itemContent.map((token, idx) => {
|
|
252
|
+
if (token.type === "text" && "tokens" in token && token.tokens) {
|
|
253
|
+
return (
|
|
254
|
+
<text key={idx} style={{ fg: theme.markdownText }}>
|
|
255
|
+
<InlineTokens tokens={token.tokens} theme={theme} />
|
|
256
|
+
</text>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
if (token.type === "paragraph" && token.tokens) {
|
|
260
|
+
return (
|
|
261
|
+
<text key={idx} style={{ fg: theme.markdownText }}>
|
|
262
|
+
<InlineTokens tokens={token.tokens} theme={theme} />
|
|
263
|
+
</text>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
if ("text" in token && typeof token.text === "string") {
|
|
267
|
+
return <text key={idx} style={{ fg: theme.markdownText }} content={token.text} />
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
})}
|
|
271
|
+
</box>
|
|
272
|
+
</box>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function MarkdownTableRenderer({ token }: { token: Tokens.Table }) {
|
|
277
|
+
const seen = new Map<string, number>()
|
|
278
|
+
const columns = token.header.map((cell, index) => {
|
|
279
|
+
const header = getPlainText(cell.tokens)
|
|
280
|
+
const trimmed = header.trim()
|
|
281
|
+
const base = trimmed || `column_${index + 1}`
|
|
282
|
+
const count = seen.get(base) ?? 0
|
|
283
|
+
seen.set(base, count + 1)
|
|
284
|
+
const key = count === 0 ? base : `${base}_${count + 1}`
|
|
285
|
+
return {
|
|
286
|
+
key,
|
|
287
|
+
header: trimmed || undefined,
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
const rows = token.rows.map((row) => {
|
|
291
|
+
const record: Record<string, string> = {}
|
|
292
|
+
row.forEach((cell, index) => {
|
|
293
|
+
const column = columns[index]
|
|
294
|
+
if (column) {
|
|
295
|
+
record[column.key] = getPlainText(cell.tokens)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
return record
|
|
299
|
+
})
|
|
300
|
+
return <Table columns={columns} rows={rows} />
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function HorizontalRule({ theme }: { theme: ResolvedTheme }) {
|
|
304
|
+
return (
|
|
305
|
+
<box style={{ marginTop: 0, marginBottom: 1, marginLeft: 1, marginRight: 1 }}>
|
|
306
|
+
<text style={{ fg: theme.markdownHorizontalRule }} content={"─".repeat(40)} />
|
|
307
|
+
</box>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getPlainText(tokens: Token[]): string {
|
|
312
|
+
return tokens
|
|
313
|
+
.map((token) => {
|
|
314
|
+
if (token.type === "text") return token.text
|
|
315
|
+
if (token.type === "codespan") return (token as Tokens.Codespan).text
|
|
316
|
+
if ("tokens" in token && token.tokens) return getPlainText(token.tokens as Token[])
|
|
317
|
+
if ("text" in token) return (token as { text: string }).text
|
|
318
|
+
return ""
|
|
319
|
+
})
|
|
320
|
+
.join("")
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function InlineTokens({ tokens, theme }: { tokens: Token[]; theme: ResolvedTheme }): ReactNode {
|
|
324
|
+
const result: ReactNode[] = []
|
|
325
|
+
|
|
326
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
327
|
+
const token = tokens[i]
|
|
328
|
+
if (!token) continue
|
|
329
|
+
const key = i
|
|
330
|
+
|
|
331
|
+
switch (token.type) {
|
|
332
|
+
case "text":
|
|
333
|
+
result.push((token as Tokens.Text).text)
|
|
334
|
+
break
|
|
335
|
+
case "strong":
|
|
336
|
+
result.push(
|
|
337
|
+
<strong key={key}>{getPlainText((token as Tokens.Strong).tokens || [])}</strong>,
|
|
338
|
+
)
|
|
339
|
+
break
|
|
340
|
+
case "em":
|
|
341
|
+
result.push(<em key={key}>{getPlainText((token as Tokens.Em).tokens || [])}</em>)
|
|
342
|
+
break
|
|
343
|
+
case "codespan":
|
|
344
|
+
result.push(
|
|
345
|
+
<span key={key} fg={theme.markdownCode} bg={theme.backgroundPanel}>
|
|
346
|
+
{` ${(token as Tokens.Codespan).text} `}
|
|
347
|
+
</span>,
|
|
348
|
+
)
|
|
349
|
+
break
|
|
350
|
+
case "link": {
|
|
351
|
+
const linkToken = token as Tokens.Link
|
|
352
|
+
result.push(
|
|
353
|
+
<u key={key}>
|
|
354
|
+
<a href={linkToken.href} fg={theme.markdownLink}>
|
|
355
|
+
{getPlainText(linkToken.tokens || [])}
|
|
356
|
+
</a>
|
|
357
|
+
</u>,
|
|
358
|
+
)
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
case "br":
|
|
362
|
+
result.push("\n")
|
|
363
|
+
break
|
|
364
|
+
case "escape":
|
|
365
|
+
result.push((token as Tokens.Escape).text)
|
|
366
|
+
break
|
|
367
|
+
case "space":
|
|
368
|
+
result.push(" ")
|
|
369
|
+
break
|
|
370
|
+
default:
|
|
371
|
+
if ("text" in token && typeof (token as { text?: string }).text === "string") {
|
|
372
|
+
result.push((token as { text: string }).text)
|
|
373
|
+
}
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return <>{result}</>
|
|
379
|
+
}
|