diff --git a/bun.lock b/bun.lock index 928ee91d7..a884920b8 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", + "marked": "17.0.1", "yoga-layout": "3.2.1", }, "devDependencies": { @@ -220,6 +221,18 @@ "@opentui/core": ["@opentui/core@workspace:packages/core"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.62", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IohPhCkD/DbZEH4M5ft1/o1pI6Vvw2pdxdyoouW/TO1g21W5G8usaWTSRDXO+16BT115Nfb9/DT69H5pzAc2Eg=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.62", "", { "os": "darwin", "cpu": "x64" }, "sha512-BqbjQl2sLYrJ1Pq1b3H1I2CFedRiMz0QtZX08IMbyZ5kok+J0A8eQS5tmlbfqoS/VH0de9XiEbuHjG09/nSj1A=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.62", "", { "os": "linux", "cpu": "arm64" }, "sha512-P5FleF+W8O4uGubqBvV8DB1AK0+fJhJS8HvfmTZQ2DhSSJJH9Af/WXqitD7ILQY9ltlaUP7l38BC5cVdxnWzCQ=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.62", "", { "os": "linux", "cpu": "x64" }, "sha512-l9ab5tgOGcdf8k3NU4TzK/3C8UC0+QuMxgLA/j60BhB1e9bwJleFeYJc+wLIktTUu9QwqCsU4YcuGHL+C2lCzA=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.62", "", { "os": "win32", "cpu": "arm64" }, "sha512-U1zsOpQl3EGhs8BwoehKAwwVONe+XOXRnXTxMhXw8huF0WWXDWOUL5psjBvfSWPm1rLmagxkQsH84jTSWA/vLA=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.62", "", { "os": "win32", "cpu": "x64" }, "sha512-JgLZXSaE4q7gUIQb9x6fLWFF3BYlMod2VBhOT1qGBdeveZxsM6ZAno/g+CL9IDUydWfLFadOIBjdYFDVWV2Z2w=="], + "@opentui/react": ["@opentui/react@workspace:packages/react"], "@opentui/solid": ["@opentui/solid@workspace:packages/solid"], @@ -360,6 +373,10 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], diff --git a/packages/core/package.json b/packages/core/package.json index db150ea69..2bf685fe8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,6 +37,7 @@ "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", + "marked": "17.0.1", "yoga-layout": "3.2.1" }, "peerDependencies": { diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 208b2f3fa..6373f6df0 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -57,8 +57,9 @@ import * as sliderDemo from "./slider-demo" import * as terminalDemo from "./terminal" import * as diffDemo from "./diff-demo" import * as keypressDebugDemo from "./keypress-debug-demo" -import * as linkDemo from "./link-demo" import * as extmarksDemo from "./extmarks-demo" +import * as markdownDemo from "./markdown-demo" +import * as linkDemo from "./link-demo" import * as opacityExample from "./opacity-example" import * as scrollboxOverlayHitTest from "./scrollbox-overlay-hit-test" import * as scrollboxMouseTest from "./scrollbox-mouse-test" @@ -157,6 +158,12 @@ const examples: Example[] = [ run: diffDemo.run, destroy: diffDemo.destroy, }, + { + name: "Markdown Demo", + description: "Markdown rendering with table alignment, syntax highlighting, and theme switching", + run: markdownDemo.run, + destroy: markdownDemo.destroy, + }, { name: "Live State Management Demo", description: "Test automatic renderer lifecycle management with live renderables", @@ -205,6 +212,12 @@ const examples: Example[] = [ run: transparencyDemo.run, destroy: transparencyDemo.destroy, }, + { + name: "Opacity Demo", + description: "Interactive opacity/alpha demonstration with animated boxes", + run: opacityExample.run, + destroy: opacityExample.destroy, + }, { name: "Static Sprite", description: "Static sprite rendering demo", @@ -325,6 +338,12 @@ const examples: Example[] = [ run: editorDemo.run, destroy: editorDemo.destroy, }, + { + name: "Extmarks Demo", + description: "Virtual extmarks - text ranges that the cursor jumps over, with deletion handling", + run: extmarksDemo.run, + destroy: extmarksDemo.destroy, + }, { name: "Slider Demo", description: "Interactive slider components with various orientations and configurations", diff --git a/packages/core/src/examples/markdown-demo.ts b/packages/core/src/examples/markdown-demo.ts new file mode 100644 index 000000000..eb156d455 --- /dev/null +++ b/packages/core/src/examples/markdown-demo.ts @@ -0,0 +1,565 @@ +import { + CliRenderer, + createCliRenderer, + BoxRenderable, + TextRenderable, + type ParsedKey, + ScrollBoxRenderable, +} from "../index" +import { MarkdownRenderable } from "../renderables/Markdown" +import { setupCommonDemoKeys } from "./lib/standalone-keys" +import { parseColor } from "../lib/RGBA" +import { SyntaxStyle } from "../syntax-style" + +// Rich markdown example showcasing various features +const markdownContent = `# OpenTUI Markdown Demo + +Welcome to the **MarkdownRenderable** showcase! This demonstrates automatic table alignment and syntax highlighting. + +## Features + +- Automatic **table column alignment** based on content width +- Proper handling of \`inline code\`, **bold**, and *italic* in tables +- Multiple syntax themes to choose from +- Conceal mode hides formatting markers + +## Comparison Table + +| Feature | Status | Priority | Notes | +|---|---|---|---| +| Table alignment | **Done** | High | Uses \`marked\` parser | +| Conceal mode | *Working* | Medium | Hides \`**\`, \`\`\`, etc. | +| Theme switching | **Done** | Low | 3 themes available | +| Unicode support | ζ—₯本θͺž | High | CJK characters | + +## Code Examples + +Here's how to use it: + +\`\`\`typescript +import { MarkdownRenderable } from "@opentui/core" + +const md = new MarkdownRenderable(renderer, { + content: "# Hello World", + syntaxStyle: mySyntaxStyle, + conceal: true, // Hide formatting markers +}) +\`\`\` + +### API Reference + +| Method | Parameters | Returns | Description | +|---|---|---|---| +| \`constructor\` | \`ctx, options\` | \`MarkdownRenderable\` | Create new instance | +| \`clearCache\` | none | \`void\` | Force re-render content | + +## Inline Formatting Examples + +| Style | Syntax | Rendered | +|---|---|---| +| Bold | \`**text**\` | **bold text** | +| Italic | \`*text*\` | *italic text* | +| Code | \`code\` | \`inline code\` | +| Link | \`[text](url)\` | [OpenTUI](https://github.com) | + +## Mixed Content + +> **Note**: This blockquote contains **bold** and \`code\` formatting. +> It should render correctly with proper styling. + +### Emoji Support + +| Emoji | Name | Category | +|---|---|---| +| πŸš€ | Rocket | Transport | +| 🎨 | Palette | Art | +| ⚑ | Lightning | Nature | +| πŸ”₯ | Fire | Nature | + +--- + +## Alignment Examples + +| Left | Center | Right | +|:---|:---:|---:| +| L1 | C1 | R1 | +| Left aligned | Centered text | Right aligned | +| Short | Medium length | Longer content here | + +## Performance + +The table alignment uses: +1. AST-based parsing with \`marked\` +2. Caching for repeated content +3. Smart width calculation accounting for concealed chars + +--- + +*Press \`?\` for keybindings* +` + +// Theme definitions +const themes = { + github: { + name: "GitHub Dark", + bg: "#0D1117", + styles: { + keyword: { fg: parseColor("#FF7B72"), bold: true }, + string: { fg: parseColor("#A5D6FF") }, + comment: { fg: parseColor("#8B949E"), italic: true }, + number: { fg: parseColor("#79C0FF") }, + function: { fg: parseColor("#D2A8FF") }, + type: { fg: parseColor("#FFA657") }, + operator: { fg: parseColor("#FF7B72") }, + variable: { fg: parseColor("#E6EDF3") }, + property: { fg: parseColor("#79C0FF") }, + "punctuation.bracket": { fg: parseColor("#F0F6FC") }, + "punctuation.delimiter": { fg: parseColor("#C9D1D9") }, + "markup.heading": { fg: parseColor("#58A6FF"), bold: true }, + "markup.heading.1": { fg: parseColor("#00FF88"), bold: true, underline: true }, + "markup.heading.2": { fg: parseColor("#00D7FF"), bold: true }, + "markup.heading.3": { fg: parseColor("#FF69B4") }, + "markup.bold": { fg: parseColor("#F0F6FC"), bold: true }, + "markup.strong": { fg: parseColor("#F0F6FC"), bold: true }, + "markup.italic": { fg: parseColor("#F0F6FC"), italic: true }, + "markup.list": { fg: parseColor("#FF7B72") }, + "markup.quote": { fg: parseColor("#8B949E"), italic: true }, + "markup.raw": { fg: parseColor("#A5D6FF"), bg: parseColor("#161B22") }, + "markup.raw.block": { fg: parseColor("#A5D6FF"), bg: parseColor("#161B22") }, + "markup.raw.inline": { fg: parseColor("#A5D6FF"), bg: parseColor("#161B22") }, + "markup.link": { fg: parseColor("#58A6FF"), underline: true }, + "markup.link.label": { fg: parseColor("#A5D6FF"), underline: true }, + "markup.link.url": { fg: parseColor("#58A6FF"), underline: true }, + label: { fg: parseColor("#7EE787") }, + conceal: { fg: parseColor("#6E7681") }, + "punctuation.special": { fg: parseColor("#8B949E") }, + default: { fg: parseColor("#E6EDF3") }, + }, + }, + monokai: { + name: "Monokai", + bg: "#272822", + styles: { + keyword: { fg: parseColor("#F92672"), bold: true }, + string: { fg: parseColor("#E6DB74") }, + comment: { fg: parseColor("#75715E"), italic: true }, + number: { fg: parseColor("#AE81FF") }, + function: { fg: parseColor("#A6E22E") }, + type: { fg: parseColor("#66D9EF"), italic: true }, + operator: { fg: parseColor("#F92672") }, + variable: { fg: parseColor("#F8F8F2") }, + property: { fg: parseColor("#A6E22E") }, + "punctuation.bracket": { fg: parseColor("#F8F8F2") }, + "punctuation.delimiter": { fg: parseColor("#F8F8F2") }, + "markup.heading": { fg: parseColor("#A6E22E"), bold: true }, + "markup.heading.1": { fg: parseColor("#F92672"), bold: true, underline: true }, + "markup.heading.2": { fg: parseColor("#66D9EF"), bold: true }, + "markup.heading.3": { fg: parseColor("#E6DB74") }, + "markup.bold": { fg: parseColor("#F8F8F2"), bold: true }, + "markup.strong": { fg: parseColor("#F8F8F2"), bold: true }, + "markup.italic": { fg: parseColor("#F8F8F2"), italic: true }, + "markup.list": { fg: parseColor("#F92672") }, + "markup.quote": { fg: parseColor("#75715E"), italic: true }, + "markup.raw": { fg: parseColor("#E6DB74"), bg: parseColor("#3E3D32") }, + "markup.raw.block": { fg: parseColor("#E6DB74"), bg: parseColor("#3E3D32") }, + "markup.raw.inline": { fg: parseColor("#E6DB74"), bg: parseColor("#3E3D32") }, + "markup.link": { fg: parseColor("#66D9EF"), underline: true }, + "markup.link.label": { fg: parseColor("#E6DB74"), underline: true }, + "markup.link.url": { fg: parseColor("#66D9EF"), underline: true }, + label: { fg: parseColor("#A6E22E") }, + conceal: { fg: parseColor("#75715E") }, + "punctuation.special": { fg: parseColor("#75715E") }, + default: { fg: parseColor("#F8F8F2") }, + }, + }, + nord: { + name: "Nord", + bg: "#2E3440", + styles: { + keyword: { fg: parseColor("#81A1C1"), bold: true }, + string: { fg: parseColor("#A3BE8C") }, + comment: { fg: parseColor("#616E88"), italic: true }, + number: { fg: parseColor("#B48EAD") }, + function: { fg: parseColor("#88C0D0") }, + type: { fg: parseColor("#8FBCBB") }, + operator: { fg: parseColor("#81A1C1") }, + variable: { fg: parseColor("#D8DEE9") }, + property: { fg: parseColor("#88C0D0") }, + "punctuation.bracket": { fg: parseColor("#ECEFF4") }, + "punctuation.delimiter": { fg: parseColor("#D8DEE9") }, + "markup.heading": { fg: parseColor("#88C0D0"), bold: true }, + "markup.heading.1": { fg: parseColor("#8FBCBB"), bold: true, underline: true }, + "markup.heading.2": { fg: parseColor("#81A1C1"), bold: true }, + "markup.heading.3": { fg: parseColor("#B48EAD") }, + "markup.bold": { fg: parseColor("#ECEFF4"), bold: true }, + "markup.strong": { fg: parseColor("#ECEFF4"), bold: true }, + "markup.italic": { fg: parseColor("#ECEFF4"), italic: true }, + "markup.list": { fg: parseColor("#81A1C1") }, + "markup.quote": { fg: parseColor("#616E88"), italic: true }, + "markup.raw": { fg: parseColor("#A3BE8C"), bg: parseColor("#3B4252") }, + "markup.raw.block": { fg: parseColor("#A3BE8C"), bg: parseColor("#3B4252") }, + "markup.raw.inline": { fg: parseColor("#A3BE8C"), bg: parseColor("#3B4252") }, + "markup.link": { fg: parseColor("#88C0D0"), underline: true }, + "markup.link.label": { fg: parseColor("#A3BE8C"), underline: true }, + "markup.link.url": { fg: parseColor("#88C0D0"), underline: true }, + label: { fg: parseColor("#A3BE8C") }, + conceal: { fg: parseColor("#4C566A") }, + "punctuation.special": { fg: parseColor("#616E88") }, + default: { fg: parseColor("#D8DEE9") }, + }, + }, +} + +type ThemeKey = keyof typeof themes +const themeKeys = Object.keys(themes) as ThemeKey[] + +let renderer: CliRenderer | null = null +let keyboardHandler: ((key: ParsedKey) => void) | null = null +let parentContainer: BoxRenderable | null = null +let markdownScrollBox: ScrollBoxRenderable | null = null +let markdownDisplay: MarkdownRenderable | null = null +let statusText: TextRenderable | null = null +let syntaxStyle: SyntaxStyle | null = null +let helpModal: BoxRenderable | null = null +let currentThemeIndex = 0 +let concealEnabled = true +let showingHelp = false +let streamingMode = false +let streamingTimer: Timer | null = null +let streamPosition = 0 +let endlessMode = false + +// Streaming speed presets: [minDelay, maxDelay] in milliseconds +const streamSpeeds = [ + { name: "Slowest", min: 200, max: 500 }, // 0: Default + { name: "Slower", min: 150, max: 350 }, // 1 + { name: "Slow", min: 100, max: 250 }, // 2 + { name: "Medium", min: 70, max: 150 }, // 3 + { name: "Fast", min: 40, max: 100 }, // 4 + { name: "Faster", min: 20, max: 60 }, // 5 + { name: "Fastest", min: 10, max: 50 }, // 6 +] +let currentSpeedIndex = 0 + +function getCurrentTheme() { + return themes[themeKeys[currentThemeIndex]] +} + +function getCurrentSpeed() { + return streamSpeeds[currentSpeedIndex] +} + +function stopStreaming() { + if (streamingTimer) { + clearTimeout(streamingTimer) + streamingTimer = null + } + streamingMode = false + streamPosition = 0 +} + +function startStreaming() { + stopStreaming() + streamingMode = true + streamPosition = 0 + + if (!markdownDisplay || !markdownScrollBox) return + + // Reset to empty and enable streaming mode + markdownDisplay.streaming = true + markdownDisplay.content = "" + + // Enable sticky scroll to bottom for streaming + markdownScrollBox.stickyScroll = true + + markdownScrollBox.stickyStart = "bottom" + + // Update status + if (statusText) { + const theme = getCurrentTheme() + const speed = getCurrentSpeed() + const mode = endlessMode ? "ENDLESS" : "NORMAL" + statusText.content = `Theme: ${theme.name} | Conceal: ${concealEnabled ? "ON" : "OFF"} | Streaming: IN PROGRESS (${speed.name}, ${mode}) | Press X to stop` + } + + function streamNextChunk() { + if (!streamingMode || !markdownDisplay) return + + // Random chunk size between 1 and 50 characters + const chunkSize = Math.floor(Math.random() * 50) + 1 + + // Calculate which iteration we're on and position within that iteration + const positionInCurrentIteration = streamPosition % markdownContent.length + const nextPositionInIteration = Math.min(positionInCurrentIteration + chunkSize, markdownContent.length) + + // Build content by repeating the markdown as many times as needed + const fullIterations = Math.floor(streamPosition / markdownContent.length) + const currentIterationContent = markdownContent.slice(0, nextPositionInIteration) + + // Construct full content: (full iterations of content) + (partial current iteration) + let fullContent = markdownContent.repeat(fullIterations) + currentIterationContent + + markdownDisplay.content = fullContent + streamPosition += chunkSize + + // In endless mode, never stop. In normal mode, stop after first iteration + const shouldContinue = endlessMode || streamPosition < markdownContent.length + + if (shouldContinue) { + // Random delay based on current speed setting + const speed = getCurrentSpeed() + const delayRange = speed.max - speed.min + const delay = Math.floor(Math.random() * delayRange) + speed.min + streamingTimer = setTimeout(streamNextChunk, delay) + } else { + // Normal mode - streaming complete + streamingMode = false + if (statusText) { + const theme = getCurrentTheme() + const speed = getCurrentSpeed() + statusText.content = `Theme: ${theme.name} | Conceal: ${concealEnabled ? "ON" : "OFF"} | Streaming: COMPLETE (${speed.name}) | Press S to restart` + } + } + } + + streamNextChunk() +} + +export async function run(rendererInstance: CliRenderer): Promise { + renderer = rendererInstance + renderer.start() + + const theme = getCurrentTheme() + renderer.setBackgroundColor(theme.bg) + + parentContainer = new BoxRenderable(renderer, { + id: "parent-container", + zIndex: 10, + padding: 1, + }) + renderer.root.add(parentContainer) + + const titleBox = new BoxRenderable(renderer, { + id: "title-box", + height: 3, + borderStyle: "double", + borderColor: "#4ECDC4", + backgroundColor: theme.bg, + title: "Markdown Demo - Table Alignment + Syntax Highlighting", + titleAlignment: "center", + border: true, + }) + parentContainer.add(titleBox) + + const instructionsText = new TextRenderable(renderer, { + id: "instructions", + content: "ESC to return | Press ? for keybindings", + fg: "#888888", + }) + titleBox.add(instructionsText) + + // Create help modal (hidden by default) + helpModal = new BoxRenderable(renderer, { + id: "help-modal", + position: "absolute", + left: "50%", + top: "50%", + width: 60, + height: 20, + marginLeft: -30, + marginTop: -10, + border: true, + borderStyle: "double", + borderColor: "#4ECDC4", + backgroundColor: theme.bg, + title: "Keybindings", + titleAlignment: "center", + padding: 2, + zIndex: 100, + visible: false, + }) + + const helpContent = new TextRenderable(renderer, { + id: "help-content", + content: `Theme: + T : Cycle through themes (GitHub/Monokai/Nord) + +View Controls: + C : Toggle concealment (hide **, \`, etc.) + +Streaming: + S : Start/restart streaming simulation + E : Toggle endless mode (repeats content forever) + X : Stop streaming (when in endless mode) + [ : Decrease speed (slower) + ] : Increase speed (faster) + +Other: + ? : Toggle this help screen + ESC : Return to main menu`, + fg: "#E6EDF3", + }) + + helpModal.add(helpContent) + renderer.root.add(helpModal) + + markdownScrollBox = new ScrollBoxRenderable(renderer, { + id: "markdown-scroll-box", + borderStyle: "single", + + borderColor: "#6BCF7F", + backgroundColor: theme.bg, + title: `MarkdownRenderable - ${theme.name}`, + titleAlignment: "left", + border: true, + scrollY: true, + scrollX: false, + flexGrow: 1, + flexShrink: 1, + padding: 2, + }) + markdownScrollBox.focus() + parentContainer.add(markdownScrollBox) + + // Create syntax style from current theme + syntaxStyle = SyntaxStyle.fromStyles(theme.styles) + + // Create markdown display using MarkdownRenderable + markdownDisplay = new MarkdownRenderable(renderer, { + id: "markdown-display", + content: markdownContent, + syntaxStyle, + conceal: concealEnabled, + width: "100%", + }) + + markdownScrollBox.add(markdownDisplay) + + statusText = new TextRenderable(renderer, { + id: "status-display", + content: "", + fg: "#A5D6FF", + wrapMode: "word", + flexShrink: 0, + }) + parentContainer.add(statusText) + + const updateStatusText = () => { + if (statusText) { + const theme = getCurrentTheme() + const speed = getCurrentSpeed() + const streamStatus = streamingMode ? "STREAMING" : "NORMAL" + const endlessStatus = endlessMode ? " [ENDLESS]" : "" + statusText.content = `Theme: ${theme.name} | Conceal: ${concealEnabled ? "ON" : "OFF"} | Mode: ${streamStatus}${endlessStatus} | Speed: ${speed.name} | Press T/C/S/E/[/]` + } + } + + updateStatusText() + + keyboardHandler = (key: ParsedKey) => { + // Handle help modal toggle + if (key.raw === "?" && helpModal) { + showingHelp = !showingHelp + helpModal.visible = showingHelp + return + } + + // Don't process other keys when help is showing + if (showingHelp) return + + if (key.name === "s" && !key.ctrl && !key.meta) { + // Start/restart streaming simulation + startStreaming() + } else if (key.name === "e" && !key.ctrl && !key.meta) { + // Toggle endless mode + endlessMode = !endlessMode + updateStatusText() + } else if (key.name === "x" && !key.ctrl && !key.meta) { + // Stop streaming (for endless mode) + stopStreaming() + if (markdownDisplay) { + markdownDisplay.streaming = false + } + updateStatusText() + } else if (key.raw === "[" && !key.ctrl && !key.meta) { + // Decrease streaming speed (slower) + if (currentSpeedIndex > 0) { + currentSpeedIndex-- + updateStatusText() + } + } else if (key.raw === "]" && !key.ctrl && !key.meta) { + // Increase streaming speed (faster) + if (currentSpeedIndex < streamSpeeds.length - 1) { + currentSpeedIndex++ + updateStatusText() + } + } else if (key.name === "t" && !key.ctrl && !key.meta) { + // Cycle through themes + currentThemeIndex = (currentThemeIndex + 1) % themeKeys.length + const theme = getCurrentTheme() + + // Update background color + renderer?.setBackgroundColor(theme.bg) + + // Update syntax style + syntaxStyle = SyntaxStyle.fromStyles(theme.styles) + + if (markdownDisplay) { + markdownDisplay.syntaxStyle = syntaxStyle + } + + if (markdownScrollBox) { + markdownScrollBox.title = `MarkdownRenderable - ${theme.name}` + markdownScrollBox.backgroundColor = theme.bg + } + + if (helpModal) { + helpModal.backgroundColor = theme.bg + } + + updateStatusText() + } else if (key.name === "c" && !key.ctrl && !key.meta) { + // Stop streaming when toggling conceal + stopStreaming() + + concealEnabled = !concealEnabled + if (markdownDisplay) { + markdownDisplay.conceal = concealEnabled + markdownDisplay.streaming = false + markdownDisplay.content = markdownContent + } + updateStatusText() + } + } + + rendererInstance.keyInput.on("keypress", keyboardHandler) +} + +export function destroy(rendererInstance: CliRenderer): void { + stopStreaming() + + if (keyboardHandler) { + rendererInstance.keyInput.off("keypress", keyboardHandler) + keyboardHandler = null + } + + parentContainer?.destroy() + helpModal?.destroy() + parentContainer = null + markdownScrollBox = null + markdownDisplay = null + statusText = null + syntaxStyle = null + helpModal = null + + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + targetFps: 60, + }) + run(renderer) + setupCommonDemoKeys(renderer) +} diff --git a/packages/core/src/renderables/Markdown.ts b/packages/core/src/renderables/Markdown.ts new file mode 100644 index 000000000..fc1aaad06 --- /dev/null +++ b/packages/core/src/renderables/Markdown.ts @@ -0,0 +1,840 @@ +import { Renderable, type RenderableOptions } from "../Renderable" +import { type RenderContext } from "../types" +import { SyntaxStyle, type StyleDefinition } from "../syntax-style" +import { StyledText } from "../lib/styled-text" +import type { TextChunk } from "../text-buffer" +import { createTextAttributes } from "../utils" +import { Lexer, type MarkedToken, type Token, type Tokens } from "marked" +import { TextRenderable } from "./Text" +import { CodeRenderable } from "./Code" +import { BoxRenderable } from "./Box" +import type { TreeSitterClient } from "../lib/tree-sitter" +import { parseMarkdownIncremental, type ParseState } from "./markdown-parser" +import type { OptimizedBuffer } from "../buffer" + +export interface MarkdownOptions extends RenderableOptions { + content?: string + syntaxStyle: SyntaxStyle + conceal?: boolean + treeSitterClient?: TreeSitterClient + /** + * Enable streaming mode for incremental content updates. + * When true, trailing tokens are kept unstable to handle incomplete content. + */ + streaming?: boolean + /** + * Custom node renderer. Return a Renderable to override default rendering, + * or undefined/null to use default rendering. + */ + renderNode?: (token: Token, context: RenderNodeContext) => Renderable | undefined | null +} + +export interface RenderNodeContext { + syntaxStyle: SyntaxStyle + conceal: boolean + treeSitterClient?: TreeSitterClient + /** Creates default renderable for this token */ + defaultRender: () => Renderable | null +} + +export interface BlockState { + token: MarkedToken + tokenRaw: string // Cache raw for comparison + renderable: Renderable +} + +export type { ParseState } + +export class MarkdownRenderable extends Renderable { + private _content: string = "" + private _syntaxStyle: SyntaxStyle + private _conceal: boolean + private _treeSitterClient?: TreeSitterClient + private _renderNode?: MarkdownOptions["renderNode"] + + _parseState: ParseState | null = null + private _streaming: boolean = false + _blockStates: BlockState[] = [] + private _styleDirty: boolean = false + + protected _contentDefaultOptions = { + content: "", + conceal: true, + streaming: false, + } satisfies Partial + + constructor(ctx: RenderContext, options: MarkdownOptions) { + super(ctx, { + ...options, + flexDirection: "column", + }) + + this._syntaxStyle = options.syntaxStyle + this._conceal = options.conceal ?? this._contentDefaultOptions.conceal + this._content = options.content ?? this._contentDefaultOptions.content + this._treeSitterClient = options.treeSitterClient + this._renderNode = options.renderNode + this._streaming = options.streaming ?? this._contentDefaultOptions.streaming + + this.updateBlocks() + } + + get content(): string { + return this._content + } + + set content(value: string) { + if (this._content !== value) { + this._content = value + this.updateBlocks() + this.requestRender() + } + } + + get syntaxStyle(): SyntaxStyle { + return this._syntaxStyle + } + + set syntaxStyle(value: SyntaxStyle) { + if (this._syntaxStyle !== value) { + this._syntaxStyle = value + // Mark dirty - actual re-render happens in renderSelf + this._styleDirty = true + } + } + + get conceal(): boolean { + return this._conceal + } + + set conceal(value: boolean) { + if (this._conceal !== value) { + this._conceal = value + // Mark dirty - actual re-render happens in renderSelf + this._styleDirty = true + } + } + + get streaming(): boolean { + return this._streaming + } + + set streaming(value: boolean) { + if (this._streaming !== value) { + this._streaming = value + // Don't clear parseState - incremental parser handles streaming correctly + this.updateBlocks() + this.requestRender() + } + } + + private getStyle(group: string): StyleDefinition | undefined { + let style = this._syntaxStyle.getStyle(group) + if (!style && group.includes(".")) { + const baseName = group.split(".")[0] + style = this._syntaxStyle.getStyle(baseName) + } + return style + } + + private createChunk(text: string, group: string): TextChunk { + const style = this.getStyle(group) || this.getStyle("default") + return { + __isChunk: true, + text, + fg: style?.fg, + bg: style?.bg, + attributes: style + ? createTextAttributes({ + bold: style.bold, + italic: style.italic, + underline: style.underline, + dim: style.dim, + }) + : 0, + } + } + + private createDefaultChunk(text: string): TextChunk { + return this.createChunk(text, "default") + } + + private renderInlineContent(tokens: Token[], chunks: TextChunk[]): void { + for (const token of tokens) { + this.renderInlineToken(token as MarkedToken, chunks) + } + } + + private renderInlineToken(token: MarkedToken, chunks: TextChunk[]): void { + switch (token.type) { + case "text": + chunks.push(this.createDefaultChunk(token.text)) + break + + case "escape": + chunks.push(this.createDefaultChunk(token.text)) + break + + case "codespan": + if (this._conceal) { + chunks.push(this.createChunk(token.text, "markup.raw")) + } else { + chunks.push(this.createChunk("`", "markup.raw")) + chunks.push(this.createChunk(token.text, "markup.raw")) + chunks.push(this.createChunk("`", "markup.raw")) + } + break + + case "strong": + if (!this._conceal) { + chunks.push(this.createChunk("**", "markup.strong")) + } + for (const child of token.tokens) { + this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strong") + } + if (!this._conceal) { + chunks.push(this.createChunk("**", "markup.strong")) + } + break + + case "em": + if (!this._conceal) { + chunks.push(this.createChunk("*", "markup.italic")) + } + for (const child of token.tokens) { + this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.italic") + } + if (!this._conceal) { + chunks.push(this.createChunk("*", "markup.italic")) + } + break + + case "del": + if (!this._conceal) { + chunks.push(this.createChunk("~~", "markup.strikethrough")) + } + for (const child of token.tokens) { + this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.strikethrough") + } + if (!this._conceal) { + chunks.push(this.createChunk("~~", "markup.strikethrough")) + } + break + + case "link": + if (this._conceal) { + for (const child of token.tokens) { + this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.link.label") + } + chunks.push(this.createChunk(" (", "markup.link")) + chunks.push(this.createChunk(token.href, "markup.link.url")) + chunks.push(this.createChunk(")", "markup.link")) + } else { + chunks.push(this.createChunk("[", "markup.link")) + for (const child of token.tokens) { + this.renderInlineTokenWithStyle(child as MarkedToken, chunks, "markup.link.label") + } + chunks.push(this.createChunk("](", "markup.link")) + chunks.push(this.createChunk(token.href, "markup.link.url")) + chunks.push(this.createChunk(")", "markup.link")) + } + break + + case "image": + if (this._conceal) { + chunks.push(this.createChunk(token.text || "image", "markup.link.label")) + } else { + chunks.push(this.createChunk("![", "markup.link")) + chunks.push(this.createChunk(token.text || "", "markup.link.label")) + chunks.push(this.createChunk("](", "markup.link")) + chunks.push(this.createChunk(token.href, "markup.link.url")) + chunks.push(this.createChunk(")", "markup.link")) + } + break + + case "br": + chunks.push(this.createDefaultChunk("\n")) + break + + default: + if ("tokens" in token && Array.isArray(token.tokens)) { + this.renderInlineContent(token.tokens, chunks) + } else if ("text" in token && typeof token.text === "string") { + chunks.push(this.createDefaultChunk(token.text)) + } + break + } + } + + private renderInlineTokenWithStyle(token: MarkedToken, chunks: TextChunk[], styleGroup: string): void { + switch (token.type) { + case "text": + chunks.push(this.createChunk(token.text, styleGroup)) + break + + case "escape": + chunks.push(this.createChunk(token.text, styleGroup)) + break + + case "codespan": + if (this._conceal) { + chunks.push(this.createChunk(token.text, "markup.raw")) + } else { + chunks.push(this.createChunk("`", "markup.raw")) + chunks.push(this.createChunk(token.text, "markup.raw")) + chunks.push(this.createChunk("`", "markup.raw")) + } + break + + default: + this.renderInlineToken(token, chunks) + break + } + } + + private renderHeadingChunks(token: Tokens.Heading): TextChunk[] { + const chunks: TextChunk[] = [] + const group = `markup.heading.${token.depth}` + const marker = "#".repeat(token.depth) + " " + + if (!this._conceal) { + chunks.push(this.createChunk(marker, group)) + } + + for (const child of token.tokens) { + this.renderInlineTokenWithStyle(child as MarkedToken, chunks, group) + } + + return chunks + } + + private renderParagraphChunks(token: Tokens.Paragraph): TextChunk[] { + const chunks: TextChunk[] = [] + this.renderInlineContent(token.tokens, chunks) + return chunks + } + + private renderBlockquoteChunks(token: Tokens.Blockquote): TextChunk[] { + const chunks: TextChunk[] = [] + for (const child of token.tokens) { + chunks.push(this.createChunk("> ", "punctuation.special")) + const childChunks = this.renderTokenToChunks(child as MarkedToken) + chunks.push(...childChunks) + chunks.push(this.createDefaultChunk("\n")) + } + return chunks + } + + private renderListChunks(token: Tokens.List): TextChunk[] { + const chunks: TextChunk[] = [] + let index = typeof token.start === "number" ? token.start : 1 + + for (const item of token.items) { + if (token.ordered) { + chunks.push(this.createChunk(`${index}. `, "markup.list")) + index++ + } else { + chunks.push(this.createChunk("- ", "markup.list")) + } + + for (let i = 0; i < item.tokens.length; i++) { + const child = item.tokens[i] + if (child.type === "text" && i === 0 && "tokens" in child && child.tokens) { + this.renderInlineContent(child.tokens, chunks) + chunks.push(this.createDefaultChunk("\n")) + } else if (child.type === "paragraph" && i === 0) { + this.renderInlineContent((child as Tokens.Paragraph).tokens, chunks) + chunks.push(this.createDefaultChunk("\n")) + } else { + const childChunks = this.renderTokenToChunks(child as MarkedToken) + chunks.push(...childChunks) + chunks.push(this.createDefaultChunk("\n")) + } + } + } + + return chunks + } + + private renderThematicBreakChunks(): TextChunk[] { + return [this.createChunk("---", "punctuation.special")] + } + + private renderTokenToChunks(token: MarkedToken): TextChunk[] { + switch (token.type) { + case "heading": + return this.renderHeadingChunks(token) + case "paragraph": + return this.renderParagraphChunks(token) + case "blockquote": + return this.renderBlockquoteChunks(token) + case "list": + return this.renderListChunks(token) + case "hr": + return this.renderThematicBreakChunks() + case "space": + return [] + default: + if ("raw" in token && token.raw) { + return [this.createDefaultChunk(token.raw)] + } + return [] + } + } + + private createTextRenderable(chunks: TextChunk[], id: string, marginBottom: number = 0): TextRenderable { + return new TextRenderable(this.ctx, { + id, + content: new StyledText(chunks), + width: "100%", + marginBottom, + }) + } + + private createCodeRenderable(token: Tokens.Code, id: string, marginBottom: number = 0): Renderable { + return new CodeRenderable(this.ctx, { + id, + content: token.text, + filetype: token.lang || undefined, + syntaxStyle: this._syntaxStyle, + conceal: this._conceal, + treeSitterClient: this._treeSitterClient, + width: "100%", + marginBottom, + }) + } + + /** + * Update an existing table renderable in-place for style/conceal changes. + * Much faster than rebuilding the entire table structure. + */ + private updateTableRenderable(tableBox: Renderable, table: Tokens.Table, marginBottom: number): void { + tableBox.marginBottom = marginBottom + const borderColor = this.getStyle("conceal")?.fg ?? "#888888" + const headingStyle = this.getStyle("markup.heading") || this.getStyle("default") + + const rowsToRender = this._streaming && table.rows.length > 0 ? table.rows.slice(0, -1) : table.rows + const colCount = table.header.length + + // Traverse existing table structure: tableBox -> columnBoxes -> cells + const columns = (tableBox as any)._childrenInLayoutOrder as Renderable[] + for (let col = 0; col < colCount; col++) { + const columnBox = columns[col] + if (!columnBox) continue + + // Update column border colors + if (columnBox instanceof BoxRenderable) { + columnBox.borderColor = borderColor + } + + const columnChildren = (columnBox as any)._childrenInLayoutOrder as Renderable[] + + // Update header (first child of column) + const headerBox = columnChildren[0] + if (headerBox instanceof BoxRenderable) { + headerBox.borderColor = borderColor + const headerChildren = (headerBox as any)._childrenInLayoutOrder as Renderable[] + const headerText = headerChildren[0] + if (headerText instanceof TextRenderable) { + const headerCell = table.header[col] + const headerChunks: TextChunk[] = [] + this.renderInlineContent(headerCell.tokens, headerChunks) + const styledHeaderChunks = headerChunks.map((chunk) => ({ + ...chunk, + fg: headingStyle?.fg ?? chunk.fg, + bg: headingStyle?.bg ?? chunk.bg, + attributes: headingStyle + ? createTextAttributes({ + bold: headingStyle.bold, + italic: headingStyle.italic, + underline: headingStyle.underline, + dim: headingStyle.dim, + }) + : chunk.attributes, + })) + headerText.content = new StyledText(styledHeaderChunks) + } + } + + // Update data rows (remaining children) + for (let row = 0; row < rowsToRender.length; row++) { + const childIndex = row + 1 // +1 because header is first child + const cellContainer = columnChildren[childIndex] + + let cellText: TextRenderable | undefined + if (cellContainer instanceof BoxRenderable) { + // Cell has a border box wrapper + cellContainer.borderColor = borderColor + const cellChildren = (cellContainer as any)._childrenInLayoutOrder as Renderable[] + cellText = cellChildren[0] as TextRenderable + } else if (cellContainer instanceof TextRenderable) { + // Last row, no border box + cellText = cellContainer + } + + if (cellText) { + const cell = rowsToRender[row][col] + const cellChunks: TextChunk[] = [] + if (cell) { + this.renderInlineContent(cell.tokens, cellChunks) + } + cellText.content = new StyledText(cellChunks.length > 0 ? cellChunks : [this.createDefaultChunk(" ")]) + } + } + } + } + + private createTableRenderable(table: Tokens.Table, id: string, marginBottom: number = 0): Renderable { + const colCount = table.header.length + + // During streaming, skip the last row (might be incomplete) + const rowsToRender = this._streaming && table.rows.length > 0 ? table.rows.slice(0, -1) : table.rows + + if (colCount === 0 || rowsToRender.length === 0) { + return this.createTextRenderable([this.createDefaultChunk(table.raw)], id, marginBottom) + } + + const tableBox = new BoxRenderable(this.ctx, { + id, + flexDirection: "row", + marginBottom, + }) + + const borderColor = this.getStyle("conceal")?.fg ?? "#888888" + + for (let col = 0; col < colCount; col++) { + const isFirstCol = col === 0 + const isLastCol = col === colCount - 1 + + const columnBox = new BoxRenderable(this.ctx, { + id: `${id}-col-${col}`, + flexDirection: "column", + border: isLastCol ? true : ["top", "bottom", "left"], + borderColor, + // Use T-joins for non-first columns to connect with previous column + customBorderChars: isFirstCol + ? undefined + : { + topLeft: "┬", + topRight: "┐", + bottomLeft: "β”΄", + bottomRight: "β”˜", + horizontal: "─", + vertical: "β”‚", + topT: "┬", + bottomT: "β”΄", + leftT: "β”œ", + rightT: "─", + cross: "β”Ό", + }, + }) + + const headerCell = table.header[col] + const headerChunks: TextChunk[] = [] + this.renderInlineContent(headerCell.tokens, headerChunks) + const headingStyle = this.getStyle("markup.heading") || this.getStyle("default") + const styledHeaderChunks = headerChunks.map((chunk) => ({ + ...chunk, + fg: headingStyle?.fg ?? chunk.fg, + bg: headingStyle?.bg ?? chunk.bg, + attributes: headingStyle + ? createTextAttributes({ + bold: headingStyle.bold, + italic: headingStyle.italic, + underline: headingStyle.underline, + dim: headingStyle.dim, + }) + : chunk.attributes, + })) + + const headerBox = new BoxRenderable(this.ctx, { + id: `${id}-col-${col}-header-box`, + border: ["bottom"], + borderColor, + }) + headerBox.add( + new TextRenderable(this.ctx, { + id: `${id}-col-${col}-header`, + content: new StyledText(styledHeaderChunks), + height: 1, + overflow: "hidden", + paddingLeft: 1, + paddingRight: 1, + }), + ) + columnBox.add(headerBox) + + for (let row = 0; row < rowsToRender.length; row++) { + const cell = rowsToRender[row][col] + const cellChunks: TextChunk[] = [] + if (cell) { + this.renderInlineContent(cell.tokens, cellChunks) + } + + const isLastRow = row === rowsToRender.length - 1 + const cellText = new TextRenderable(this.ctx, { + id: `${id}-col-${col}-row-${row}`, + content: new StyledText(cellChunks.length > 0 ? cellChunks : [this.createDefaultChunk(" ")]), + height: 1, + overflow: "hidden", + paddingLeft: 1, + paddingRight: 1, + }) + + if (isLastRow) { + columnBox.add(cellText) + } else { + const cellBox = new BoxRenderable(this.ctx, { + id: `${id}-col-${col}-row-${row}-box`, + border: ["bottom"], + borderColor, + }) + cellBox.add(cellText) + columnBox.add(cellBox) + } + } + + tableBox.add(columnBox) + } + + return tableBox + } + + private createDefaultRenderable(token: MarkedToken, index: number, hasNextToken: boolean = false): Renderable | null { + const id = `${this.id}-block-${index}` + const marginBottom = hasNextToken ? 1 : 0 + + if (token.type === "code") { + return this.createCodeRenderable(token, id, marginBottom) + } + + if (token.type === "table") { + return this.createTableRenderable(token, id, marginBottom) + } + + if (token.type === "space") { + return null + } + + const chunks = this.renderTokenToChunks(token) + if (chunks.length === 0) { + return null + } + + return this.createTextRenderable(chunks, id, marginBottom) + } + + private updateBlockRenderable(state: BlockState, token: MarkedToken, index: number, hasNextToken: boolean): void { + const marginBottom = hasNextToken ? 1 : 0 + + if (token.type === "code") { + const codeRenderable = state.renderable as CodeRenderable + const codeToken = token as Tokens.Code + codeRenderable.content = codeToken.text + if (codeToken.lang) { + codeRenderable.filetype = codeToken.lang + } + codeRenderable.marginBottom = marginBottom + return + } + + if (token.type === "table") { + const prevTable = state.token as Tokens.Table + const newTable = token as Tokens.Table + + // During streaming, only rebuild when complete row count changes (skip incomplete last row) + if (this._streaming) { + const prevCompleteRows = Math.max(0, prevTable.rows.length - 1) + const newCompleteRows = Math.max(0, newTable.rows.length - 1) + + // Check if both previous and new are in raw fallback mode (no complete rows to render) + const prevIsRawFallback = prevTable.header.length === 0 || prevCompleteRows === 0 + const newIsRawFallback = newTable.header.length === 0 || newCompleteRows === 0 + + if (prevCompleteRows === newCompleteRows && prevTable.header.length === newTable.header.length) { + // If both are in raw fallback mode and the raw content changed, update the TextRenderable + if (prevIsRawFallback && newIsRawFallback && prevTable.raw !== newTable.raw) { + const textRenderable = state.renderable as TextRenderable + textRenderable.content = new StyledText([this.createDefaultChunk(newTable.raw)]) + textRenderable.marginBottom = marginBottom + } + return + } + } + + this.remove(state.renderable.id) + const newRenderable = this.createTableRenderable(newTable, `${this.id}-block-${index}`, marginBottom) + this.add(newRenderable) + state.renderable = newRenderable + return + } + + // Text-based renderables (paragraph, heading, list, blockquote, hr) + const textRenderable = state.renderable as TextRenderable + const chunks = this.renderTokenToChunks(token) + textRenderable.content = new StyledText(chunks) + textRenderable.marginBottom = marginBottom + } + + private updateBlocks(): void { + if (!this._content) { + for (const state of this._blockStates) { + this.remove(state.renderable.id) + } + this._blockStates = [] + this._parseState = null + return + } + + const trailingUnstable = this._streaming ? 2 : 0 + this._parseState = parseMarkdownIncremental(this._content, this._parseState, trailingUnstable) + + const tokens = this._parseState.tokens + + // Parse failure fallback + if (tokens.length === 0 && this._content.length > 0) { + for (const state of this._blockStates) { + this.remove(state.renderable.id) + } + const text = this.createTextRenderable([this.createDefaultChunk(this._content)], `${this.id}-fallback`) + this.add(text) + this._blockStates = [ + { + token: { type: "text", raw: this._content, text: this._content } as MarkedToken, + tokenRaw: this._content, + renderable: text, + }, + ] + return + } + + const blockTokens: Array<{ token: MarkedToken; originalIndex: number }> = [] + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].type !== "space") { + blockTokens.push({ token: tokens[i], originalIndex: i }) + } + } + + const lastBlockIndex = blockTokens.length - 1 + + let blockIndex = 0 + for (let i = 0; i < blockTokens.length; i++) { + const { token } = blockTokens[i] + const hasNextToken = i < lastBlockIndex + const existing = this._blockStates[blockIndex] + + // Same token object reference means unchanged + if (existing && existing.token === token) { + blockIndex++ + continue + } + + // Same content, update reference + if (existing && existing.tokenRaw === token.raw && existing.token.type === token.type) { + existing.token = token + blockIndex++ + continue + } + + // Same type, different content - update in place + if (existing && existing.token.type === token.type) { + this.updateBlockRenderable(existing, token, blockIndex, hasNextToken) + existing.token = token + existing.tokenRaw = token.raw + blockIndex++ + continue + } + + // Different type or new block + if (existing) { + this.remove(existing.renderable.id) + } + + let renderable: Renderable | undefined + + if (this._renderNode) { + const context: RenderNodeContext = { + syntaxStyle: this._syntaxStyle, + conceal: this._conceal, + treeSitterClient: this._treeSitterClient, + defaultRender: () => this.createDefaultRenderable(token, blockIndex, hasNextToken), + } + const custom = this._renderNode(token, context) + if (custom) { + renderable = custom + } + } + + if (!renderable) { + renderable = this.createDefaultRenderable(token, blockIndex, hasNextToken) ?? undefined + } + + if (renderable) { + this.add(renderable) + this._blockStates[blockIndex] = { + token, + tokenRaw: token.raw, + renderable, + } + } + blockIndex++ + } + + while (this._blockStates.length > blockIndex) { + const removed = this._blockStates.pop()! + this.remove(removed.renderable.id) + } + } + + private clearBlockStates(): void { + for (const state of this._blockStates) { + this.remove(state.renderable.id) + } + this._blockStates = [] + } + + /** + * Re-render existing blocks without rebuilding the parse state or block structure. + * Used when only style/conceal changes - much faster than full rebuild. + */ + private rerenderBlocks(): void { + for (let i = 0; i < this._blockStates.length; i++) { + const state = this._blockStates[i] + const hasNextToken = i < this._blockStates.length - 1 + + if (state.token.type === "code") { + // CodeRenderable handles style/conceal changes efficiently + const codeRenderable = state.renderable as CodeRenderable + codeRenderable.syntaxStyle = this._syntaxStyle + codeRenderable.conceal = this._conceal + } else if (state.token.type === "table") { + // Tables - update in place for better performance + const marginBottom = hasNextToken ? 1 : 0 + this.updateTableRenderable(state.renderable, state.token as Tokens.Table, marginBottom) + } else { + // TextRenderable blocks - regenerate chunks with new style/conceal + const textRenderable = state.renderable as TextRenderable + const chunks = this.renderTokenToChunks(state.token) + if (chunks.length > 0) { + textRenderable.content = new StyledText(chunks) + } + } + } + } + + public clearCache(): void { + this._parseState = null + this.clearBlockStates() + this.updateBlocks() + this.requestRender() + } + + protected renderSelf(buffer: OptimizedBuffer, deltaTime: number): void { + // Check if style/conceal changed - re-render blocks before rendering + if (this._styleDirty) { + this._styleDirty = false + this.rerenderBlocks() + } + super.renderSelf(buffer, deltaTime) + } +} diff --git a/packages/core/src/renderables/__tests__/Markdown.test.ts b/packages/core/src/renderables/__tests__/Markdown.test.ts new file mode 100644 index 000000000..98b3a4cfe --- /dev/null +++ b/packages/core/src/renderables/__tests__/Markdown.test.ts @@ -0,0 +1,1705 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { MarkdownRenderable } from "../Markdown" +import { SyntaxStyle } from "../../syntax-style" +import { RGBA } from "../../lib/RGBA" +import { createTestRenderer, type TestRenderer } from "../../testing" + +let renderer: TestRenderer +let renderOnce: () => Promise +let captureFrame: () => string + +const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, +}) + +beforeEach(async () => { + const testRenderer = await createTestRenderer({ width: 60, height: 40 }) + renderer = testRenderer.renderer + renderOnce = testRenderer.renderOnce + captureFrame = testRenderer.captureCharFrame +}) + +afterEach(async () => { + if (renderer) { + renderer.destroy() + } +}) + +async function renderMarkdown(markdown: string, conceal: boolean = true): Promise { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: markdown, + syntaxStyle, + conceal, + }) + + renderer.root.add(md) + await renderOnce() + + const lines = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + return "\n" + lines.join("\n").trimEnd() +} + +test("basic table alignment", async () => { + const markdown = `| Name | Age | +|---|---| +| Alice | 30 | +| Bob | 5 |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β” + β”‚Name β”‚Age β”‚ + │───────│─────│ + β”‚Alice β”‚30 β”‚ + │───────│─────│ + β”‚Bob β”‚5 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with inline code (backticks)", async () => { + const markdown = `| Command | Description | +|---|---| +| \`npm install\` | Install deps | +| \`npm run build\` | Build project | +| \`npm test\` | Run tests |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Command β”‚Description β”‚ + │───────────────│───────────────│ + β”‚npm install β”‚Install deps β”‚ + │───────────────│───────────────│ + β”‚npm run build β”‚Build project β”‚ + │───────────────│───────────────│ + β”‚npm test β”‚Run tests β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with bold text", async () => { + const markdown = `| Feature | Status | +|---|---| +| **Authentication** | Done | +| **API** | WIP |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Feature β”‚Status β”‚ + │────────────────│────────│ + β”‚Authentication β”‚Done β”‚ + │────────────────│────────│ + β”‚API β”‚WIP β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with italic text", async () => { + const markdown = `| Item | Note | +|---|---| +| One | *important* | +| Two | *ok* |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Item β”‚Note β”‚ + │──────│───────────│ + β”‚One β”‚important β”‚ + │──────│───────────│ + β”‚Two β”‚ok β”‚ + β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with mixed formatting", async () => { + const markdown = `| Type | Value | Notes | +|---|---|---| +| **Bold** | \`code\` | *italic* | +| Plain | **strong** | \`cmd\` |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Type β”‚Value β”‚Notes β”‚ + │───────│────────│────────│ + β”‚Bold β”‚code β”‚italic β”‚ + │───────│────────│────────│ + β”‚Plain β”‚strong β”‚cmd β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with alignment markers (left, center, right)", async () => { + const markdown = `| Left | Center | Right | +|:---|:---:|---:| +| A | B | C | +| Long text | X | Y |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β” + β”‚Left β”‚Center β”‚Right β”‚ + │───────────│────────│───────│ + β”‚A β”‚B β”‚C β”‚ + │───────────│────────│───────│ + β”‚Long text β”‚X β”‚Y β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with empty cells", async () => { + const markdown = `| A | B | +|---|---| +| X | | +| | Y |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”¬β”€β”€β”€β” + β”‚A β”‚B β”‚ + │───│───│ + β”‚X β”‚ β”‚ + │───│───│ + β”‚ β”‚Y β”‚ + β””β”€β”€β”€β”΄β”€β”€β”€β”˜" + `) +}) + +test("table with long header and short content", async () => { + const markdown = `| Very Long Column Header | Short | +|---|---| +| A | B |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β” + β”‚Very Long Column Header β”‚Short β”‚ + │─────────────────────────│───────│ + β”‚A β”‚B β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with short header and long content", async () => { + const markdown = `| X | Y | +|---|---| +| This is very long content | Short |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β” + β”‚X β”‚Y β”‚ + │───────────────────────────│───────│ + β”‚This is very long content β”‚Short β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table inside code block should NOT be formatted", async () => { + const markdown = `\`\`\` +| Not | A | Table | +|---|---|---| +| Should | Stay | Raw | +\`\`\` + +| Real | Table | +|---|---| +| Is | Formatted |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + | Not | A | Table | + |---|---|---| + | Should | Stay | Raw | + + β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Real β”‚Table β”‚ + │──────│───────────│ + β”‚Is β”‚Formatted β”‚ + β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("multiple tables in same document", async () => { + const markdown = `| Table1 | A | +|---|---| +| X | Y | + +Some text between. + +| Table2 | BB | +|---|---| +| Long content | Z |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β” + β”‚Table1 β”‚A β”‚ + │────────│───│ + β”‚X β”‚Y β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”˜ + + Some text between. + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚Table2 β”‚BB β”‚ + │──────────────│────│ + β”‚Long content β”‚Z β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜" + `) +}) + +test("table with escaped pipe character", async () => { + const markdown = `| Command | Output | +|---|---| +| echo | Hello | +| ls \\| grep | Filtered |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Command β”‚Output β”‚ + │───────────│──────────│ + β”‚echo β”‚Hello β”‚ + │───────────│──────────│ + β”‚ls | grep β”‚Filtered β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with unicode characters", async () => { + const markdown = `| Emoji | Name | +|---|---| +| πŸŽ‰ | Party | +| πŸš€ | Rocket | +| ζ—₯本θͺž | Japanese |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Emoji β”‚Name β”‚ + │────────│──────────│ + β”‚πŸŽ‰ β”‚Party β”‚ + │────────│──────────│ + β”‚πŸš€ β”‚Rocket β”‚ + │────────│──────────│ + β”‚ζ—₯本θͺž β”‚Japanese β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with links", async () => { + const markdown = `| Name | Link | +|---|---| +| Google | [link](https://google.com) | +| GitHub | [gh](https://github.com) |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Name β”‚Link β”‚ + │────────│───────────────────────────│ + β”‚Google β”‚link (https://google.com) β”‚ + │────────│───────────────────────────│ + β”‚GitHub β”‚gh (https://github.com) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("single row table (header + delimiter only)", async () => { + const markdown = `| Only | Header | +|---|---|` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + | Only | Header | + |---|---|" + `) +}) + +test("table with many columns", async () => { + const markdown = `| A | B | C | D | E | +|---|---|---|---|---| +| 1 | 2 | 3 | 4 | 5 |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β” + β”‚A β”‚B β”‚C β”‚D β”‚E β”‚ + │───│───│───│───│───│ + β”‚1 β”‚2 β”‚3 β”‚4 β”‚5 β”‚ + β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜" + `) +}) + +test("no tables returns original content", async () => { + const markdown = `# Just a heading + +Some paragraph text. + +- List item` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Just a heading + + Some paragraph text. + + - List item" + `) +}) + +test("table with nested inline formatting", async () => { + const markdown = `| Description | +|---| +| This has **bold and \`code\`** together | +| And *italic with **nested bold*** |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Description β”‚ + │─────────────────────────────────│ + β”‚This has bold and code together β”‚ + │─────────────────────────────────│ + β”‚And italic with nested bold β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +// Tests with conceal=false - formatting markers should be visible and columns sized accordingly + +test("conceal=false: table with bold text", async () => { + const markdown = `| Feature | Status | +|---|---| +| **Authentication** | Done | +| **API** | WIP |` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Feature β”‚Status β”‚ + │────────────────────│────────│ + β”‚**Authentication** β”‚Done β”‚ + │────────────────────│────────│ + β”‚**API** β”‚WIP β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("conceal=false: table with inline code", async () => { + const markdown = `| Command | Description | +|---|---| +| \`npm install\` | Install deps | +| \`npm run build\` | Build project |` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Command β”‚Description β”‚ + │─────────────────│───────────────│ + β”‚\`npm install\` β”‚Install deps β”‚ + │─────────────────│───────────────│ + β”‚\`npm run build\` β”‚Build project β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("conceal=false: table with italic text", async () => { + const markdown = `| Item | Note | +|---|---| +| One | *important* | +| Two | *ok* |` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Item β”‚Note β”‚ + │──────│─────────────│ + β”‚One β”‚*important* β”‚ + │──────│─────────────│ + β”‚Two β”‚*ok* β”‚ + β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("conceal=false: table with mixed formatting", async () => { + const markdown = `| Type | Value | Notes | +|---|---|---| +| **Bold** | \`code\` | *italic* | +| Plain | **strong** | \`cmd\` |` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Type β”‚Value β”‚Notes β”‚ + │──────────│────────────│──────────│ + β”‚**Bold** β”‚\`code\` β”‚*italic* β”‚ + │──────────│────────────│──────────│ + β”‚Plain β”‚**strong** β”‚\`cmd\` β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("conceal=false: table with unicode characters", async () => { + const markdown = `| Emoji | Name | +|---|---| +| πŸŽ‰ | Party | +| πŸš€ | Rocket | +| ζ—₯本θͺž | Japanese |` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚Emoji β”‚Name β”‚ + │────────│──────────│ + β”‚πŸŽ‰ β”‚Party β”‚ + │────────│──────────│ + β”‚πŸš€ β”‚Rocket β”‚ + │────────│──────────│ + β”‚ζ—₯本θͺž β”‚Japanese β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("conceal=false: basic table alignment", async () => { + const markdown = `| Name | Age | +|---|---| +| Alice | 30 | +| Bob | 5 |` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β” + β”‚Name β”‚Age β”‚ + │───────│─────│ + β”‚Alice β”‚30 β”‚ + │───────│─────│ + β”‚Bob β”‚5 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("table with paragraphs before and after", async () => { + const markdown = `This is a paragraph before the table. + +| Name | Age | +|---|---| +| Alice | 30 | + +This is a paragraph after the table.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + This is a paragraph before the table. + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β” + β”‚Name β”‚Age β”‚ + │───────│─────│ + β”‚Alice β”‚30 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜ + + This is a paragraph after the table." + `) +}) + +// Code block tests + +test("code block with language", async () => { + const markdown = `\`\`\`typescript +const x = 1; +console.log(x); +\`\`\`` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + const x = 1; + console.log(x);" + `) +}) + +test("code block without language", async () => { + const markdown = `\`\`\` +plain code block +with multiple lines +\`\`\`` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + plain code block + with multiple lines" + `) +}) + +test("code block mixed with text", async () => { + const markdown = `Here is some code: + +\`\`\`js +function hello() { + return "world"; +} +\`\`\` + +And here is more text after.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Here is some code: + + function hello() { + return "world"; + } + + And here is more text after." + `) +}) + +test("multiple code blocks", async () => { + const markdown = `First block: + +\`\`\`python +print("hello") +\`\`\` + +Second block: + +\`\`\`rust +fn main() {} +\`\`\`` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + First block: + + print("hello") + + Second block: + + fn main() {}" + `) +}) + +test("code block in conceal=false mode", async () => { + const markdown = `\`\`\`js +const x = 1; +\`\`\`` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + const x = 1;" + `) +}) + +// Heading tests + +test("headings h1 through h3", async () => { + const markdown = `# Heading 1 + +## Heading 2 + +### Heading 3` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Heading 1 + + Heading 2 + + Heading 3" + `) +}) + +test("headings with conceal=false show markers", async () => { + const markdown = `# Heading 1 + +## Heading 2` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + # Heading 1 + + ## Heading 2" + `) +}) + +// List tests + +test("unordered list", async () => { + const markdown = `- Item one +- Item two +- Item three` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + - Item one + - Item two + - Item three" + `) +}) + +test("ordered list", async () => { + const markdown = `1. First item +2. Second item +3. Third item` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + 1. First item + 2. Second item + 3. Third item" + `) +}) + +test("list with inline formatting", async () => { + const markdown = `- **Bold** item +- *Italic* item +- \`Code\` item` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + - Bold item + - Italic item + - Code item" + `) +}) + +// Blockquote tests + +test("simple blockquote", async () => { + const markdown = `> This is a quote +> spanning multiple lines` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + > This is a quote + spanning multiple lines" + `) +}) + +// Inline formatting tests + +test("bold text", async () => { + const markdown = `This has **bold** text in it.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + This has bold text in it." + `) +}) + +test("italic text", async () => { + const markdown = `This has *italic* text in it.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + This has italic text in it." + `) +}) + +test("inline code", async () => { + const markdown = `Use \`console.log()\` to debug.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Use console.log() to debug." + `) +}) + +test("mixed inline formatting", async () => { + const markdown = `**Bold**, *italic*, and \`code\` together.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Bold, italic, and code together." + `) +}) + +test("inline formatting with conceal=false", async () => { + const markdown = `**Bold**, *italic*, and \`code\` together.` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + **Bold**, *italic*, and \`code\` together." + `) +}) + +// Link tests + +test("links with conceal mode", async () => { + const markdown = `Check out [OpenTUI](https://github.com/sst/opentui) for more.` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Check out OpenTUI (https://github.com/sst/opentui) for more." + `) +}) + +test("links with conceal=false", async () => { + const markdown = `Check out [OpenTUI](https://github.com/sst/opentui) for more.` + + expect(await renderMarkdown(markdown, false)).toMatchInlineSnapshot(` + " + Check out [OpenTUI](https://github.com/sst/opentui) for + more." + `) +}) + +// Horizontal rule + +test("horizontal rule", async () => { + const markdown = `Before + +--- + +After` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Before + + --- + + After" + `) +}) + +// Complex document + +test("complex markdown document", async () => { + const markdown = `# Project Title + +Welcome to **OpenTUI**, a terminal UI library. + +## Features + +- Automatic table alignment +- \`inline code\` support +- *Italic* and **bold** text + +## Code Example + +\`\`\`typescript +const md = new MarkdownRenderable(ctx, { + content: "# Hello", +}) +\`\`\` + +## Links + +Visit [GitHub](https://github.com) for more. + +--- + +*Press \`?\` for help*` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Project Title + + Welcome to OpenTUI, a terminal UI library. + + Features + + - Automatic table alignment + - inline code support + - Italic and bold text + + + Code Example + + const md = new MarkdownRenderable(ctx, { + content: "# Hello", + }) + + Links + + Visit GitHub (https://github.com) for more. + + --- + + Press ? for help" + `) +}) + +// Custom renderNode tests + +test("custom renderNode can override heading rendering", async () => { + const { TextRenderable } = await import("../Text") + const { StyledText } = await import("../../lib/styled-text") + + // Helper to extract text from marked tokens + const extractText = (node: any): string => { + if (node.type === "text") return node.text + if (node.tokens) return node.tokens.map(extractText).join("") + return "" + } + + const md = new MarkdownRenderable(renderer, { + id: "custom-heading", + content: `# Custom Heading + +Regular paragraph.`, + syntaxStyle, + renderNode: (node, ctx) => { + if (node.type === "heading") { + const text = extractText(node) + return new TextRenderable(renderer, { + id: "custom", + content: new StyledText([{ __isChunk: true, text: `[CUSTOM] ${text}`, attributes: 0 }]), + width: "100%", + }) + } + return ctx.defaultRender() + }, + }) + + renderer.root.add(md) + await renderOnce() + + const lines = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(` + " + [CUSTOM] Custom Heading + Regular paragraph." + `) +}) + +test("custom renderNode can override code block rendering", async () => { + const { BoxRenderable } = await import("../Box") + const { TextRenderable } = await import("../Text") + + const md = new MarkdownRenderable(renderer, { + id: "custom-code", + content: `\`\`\`js +const x = 1; +\`\`\``, + syntaxStyle, + renderNode: (node, ctx) => { + if (node.type === "code") { + const box = new BoxRenderable(renderer, { + id: "code-box", + border: true, + borderStyle: "single", + }) + box.add( + new TextRenderable(renderer, { + id: "code-text", + content: `CODE: ${(node as any).text}`, + }), + ) + return box + } + return ctx.defaultRender() + }, + }) + + renderer.root.add(md) + await renderOnce() + + const lines = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚CODE: const x = 1; β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + `) +}) + +test("custom renderNode returning null uses default", async () => { + const md = new MarkdownRenderable(renderer, { + id: "custom-null", + content: `# Heading + +Paragraph text.`, + syntaxStyle, + renderNode: () => null, + }) + + renderer.root.add(md) + await renderOnce() + + const lines = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + expect("\n" + lines.join("\n").trimEnd()).toMatchInlineSnapshot(` + " + Heading + + Paragraph text." + `) +}) + +// Incomplete/invalid markdown tests + +test("incomplete code block (no closing fence)", async () => { + const markdown = `Here is some code: + +\`\`\`javascript +const x = 1; +console.log(x);` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Here is some code: + + const x = 1; + console.log(x);" + `) +}) + +test("incomplete bold (no closing **)", async () => { + const markdown = `This has **unclosed bold text` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + This has **unclosed bold text" + `) +}) + +test("incomplete italic (no closing *)", async () => { + const markdown = `This has *unclosed italic text` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + This has *unclosed italic text" + `) +}) + +test("incomplete link (no closing paren)", async () => { + const markdown = `Check out [this link](https://example.com` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Check out [this link](https://example.com (https://example. + com)" + `) +}) + +test("incomplete table (only header)", async () => { + const markdown = `| Header1 | Header2 |` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + | Header1 | Header2 |" + `) +}) + +test("incomplete table (header + delimiter, no rows)", async () => { + const markdown = `| Header1 | Header2 | +|---|---|` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + | Header1 | Header2 | + |---|---|" + `) +}) + +test("streaming-like content with partial code block", async () => { + const markdown = `# Title + +Some text before code. + +\`\`\`py` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Title + + Some text before code." + `) +}) + +test("malformed table with missing pipes", async () => { + const markdown = `| A | B +|---|--- +| 1 | 2` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”¬β”€β”€β”€β” + β”‚A β”‚B β”‚ + │───│───│ + β”‚1 β”‚2 β”‚ + β””β”€β”€β”€β”΄β”€β”€β”€β”˜" + `) +}) + +test("trailing blank lines do not add spacing", async () => { + const markdown = `# Heading + +Paragraph text. + + +` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Heading + + Paragraph text." + `) +}) + +test("multiple trailing blank lines do not add spacing", async () => { + const markdown = `First paragraph. + +Second paragraph. + + + +` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + First paragraph. + + Second paragraph." + `) +}) + +test("blank lines between blocks add spacing", async () => { + const markdown = `First + +Second + +Third` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + First + + Second + + Third" + `) +}) + +test("code block at end with trailing blank lines", async () => { + const markdown = `Text before + +\`\`\`js +const x = 1; +\`\`\` + +` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + Text before + + const x = 1;" + `) +}) + +test("table at end with trailing blank lines", async () => { + const markdown = `| A | B | +|---|---| +| 1 | 2 | + + +` + + expect(await renderMarkdown(markdown)).toMatchInlineSnapshot(` + " + β”Œβ”€β”€β”€β”¬β”€β”€β”€β” + β”‚A β”‚B β”‚ + │───│───│ + β”‚1 β”‚2 β”‚ + β””β”€β”€β”€β”΄β”€β”€β”€β”˜" + `) +}) + +// Incremental parsing tests +test("incremental update reuses unchanged blocks when appending", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello\n\nParagraph 1", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + // Get reference to first block + const firstBlockBefore = md._blockStates[0]?.renderable + + // Append content + md.content = "# Hello\n\nParagraph 1\n\nParagraph 2" + await renderOnce() + + // First block should be reused (same object reference) + const firstBlockAfter = md._blockStates[0]?.renderable + expect(firstBlockAfter).toBe(firstBlockBefore) +}) + +test("streaming mode keeps trailing tokens unstable", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + const frame1 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + expect(frame1).toContain("Hello") + + // Extend the heading + md.content = "# Hello World" + await renderOnce() + + const frame2 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + expect(frame2).toContain("Hello World") +}) + +test("non-streaming mode parses all tokens as stable", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello\n\nPara 1\n\nPara 2", + syntaxStyle, + streaming: false, + }) + + renderer.root.add(md) + await renderOnce() + + // Get parse state + const parseState = md._parseState + expect(parseState).not.toBeNull() + expect(parseState!.tokens.length).toBeGreaterThan(0) +}) + +test("content update with same text does not rebuild", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello", + syntaxStyle, + }) + + renderer.root.add(md) + await renderOnce() + + const blockBefore = md._blockStates[0]?.renderable + + // Set same content + md.content = "# Hello" + await renderOnce() + + const blockAfter = md._blockStates[0]?.renderable + expect(blockAfter).toBe(blockBefore) +}) + +test("block type change creates new renderable", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello", + syntaxStyle, + }) + + renderer.root.add(md) + await renderOnce() + + const blockBefore = md._blockStates[0]?.renderable + + // Change from heading to paragraph + md.content = "Hello" + await renderOnce() + + const blockAfter = md._blockStates[0]?.renderable + // Should be different renderable since type changed + expect(blockAfter).not.toBe(blockBefore) +}) + +test("streaming property can be toggled", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello", + syntaxStyle, + streaming: false, + }) + + renderer.root.add(md) + await renderOnce() + + expect(md.streaming).toBe(false) + + md.streaming = true + expect(md.streaming).toBe(true) + + await renderOnce() + + const frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trimEnd() + expect(frame).toContain("Hello") +}) + +test("clearCache forces full rebuild", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello\n\nWorld", + syntaxStyle, + }) + + renderer.root.add(md) + await renderOnce() + + const parseStateBefore = md._parseState + + md.clearCache() + await renderOnce() + + const parseStateAfter = md._parseState + // Parse state should be different (was cleared and rebuilt) + expect(parseStateAfter).not.toBe(parseStateBefore) +}) + +test("table only rebuilds when complete row count changes during streaming", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| A |\n|---|\n| 1 |", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + // During streaming with 1 row, we show 0 complete rows (last row is incomplete) + const tableBefore = md._blockStates[0]?.renderable + + // Change cell content but same row count - should NOT rebuild + md.content = "| B |\n|---|\n| 2 |" + await renderOnce() + + const tableAfterSameRows = md._blockStates[0]?.renderable + expect(tableAfterSameRows).toBe(tableBefore) + + // Add second row - now we have 1 complete row, should rebuild + md.content = "| B |\n|---|\n| 2 |\n| 3 |" + await renderOnce() + + const tableAfterNewRow = md._blockStates[0]?.renderable + expect(tableAfterNewRow).not.toBe(tableBefore) +}) + +test("table shows all rows when streaming is false", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| A |\n|---|\n| 1 |", + syntaxStyle, + streaming: false, + }) + + renderer.root.add(md) + await renderOnce() + + // Non-streaming should show all rows including the last + const frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + expect(frame).toContain("1") +}) + +test("table updates content when not streaming", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| A |\n|---|\n| 1 |", + syntaxStyle, + streaming: false, + }) + + renderer.root.add(md) + await renderOnce() + + const frame1 = captureFrame() + expect(frame1).toContain("1") + + // Change cell content - should update immediately when not streaming + md.content = "| A |\n|---|\n| 2 |" + await renderOnce() + + const frame2 = captureFrame() + expect(frame2).toContain("2") + expect(frame2).not.toContain("1") +}) + +test("streaming table with incomplete first row falls back to raw text and updates", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| A |\n|---|\n|", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + // With streaming=true and 1 data row, rowsToRender drops last row -> length 0 + // Should show raw fallback text + const frame1 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + + // Raw fallback should show the incomplete table markdown + expect(frame1).toContain("| A |") + expect(frame1).toContain("|---|") + // Should NOT have box drawing characters yet + expect(frame1).not.toMatch(/[β”Œβ”‚β””]/) + + // Now append more characters to the incomplete row + md.content = "| A |\n|---|\n| 1" + await renderOnce() + + const frame2 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + + // Should update to show the new content in raw fallback + expect(frame2).toContain("| 1") + // Still no box drawing + expect(frame2).not.toMatch(/[β”Œβ”‚β””]/) + + // Complete the row by adding closing pipe - still only 1 row, so still 0 complete rows + md.content = "| A |\n|---|\n| 1 |" + await renderOnce() + + const frame3 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + + // Still showing raw fallback with completed first row + expect(frame3).toContain("| 1 |") + // Still no box drawing + expect(frame3).not.toMatch(/[β”Œβ”‚β””]/) + + // Add second row - now we have 1 complete row (first row), should render as table + md.content = "| A |\n|---|\n| 1 |\n| 2 |" + await renderOnce() + + const frame4 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + + // Should now render as a proper table with box drawing and show the first complete row + expect(frame4).toMatch(/[β”Œβ”‚β””]/) // Box drawing characters + expect(frame4).toContain("1") + // Second row should not be shown (it's the incomplete trailing row) + expect(frame4).not.toContain("2") + + // Complete the second row - now we have 2 rows, so 1 complete row still (drops last) + md.content = "| A |\n|---|\n| 1 |\n| 2 |" + await renderOnce() + + const frame5 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + + // Should still show proper table with only first row + expect(frame5).toMatch(/[β”Œβ”‚β””]/) + expect(frame5).toContain("1") + expect(frame5).not.toContain("2") + + // Add third row - now we have 2 complete rows to show + md.content = "| A |\n|---|\n| 1 |\n| 2 |\n| 3 |" + await renderOnce() + + const frame6 = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + + // Should show proper table with first two rows (third is incomplete) + expect(frame6).toMatch(/[β”Œβ”‚β””]/) + expect(frame6).toContain("1") + expect(frame6).toContain("2") + expect(frame6).not.toContain("3") +}) + +test("streaming table transitions cleanly from raw fallback to proper table", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| Header |", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + // Just header, no delimiter yet - raw fallback + let frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + expect(frame).toContain("| Header |") + expect(frame).not.toMatch(/[β”Œβ”‚β””]/) + + // Add delimiter + md.content = "| Header |\n|---|" + await renderOnce() + + frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // Still raw fallback (no data rows) + expect(frame).toContain("|---|") + expect(frame).not.toMatch(/[β”Œβ”‚β””]/) + + // Start first data row + md.content = "| Header |\n|---|\n| D" + await renderOnce() + + frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // Still raw fallback (incomplete first row) + expect(frame).toContain("| D") + expect(frame).not.toMatch(/[β”Œβ”‚β””]/) + + // Complete first row - still only 1 row total, so 0 complete (drops last) + md.content = "| Header |\n|---|\n| Data1 |" + await renderOnce() + + frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // Still raw fallback + expect(frame).toContain("| Data1 |") + expect(frame).not.toMatch(/[β”Œβ”‚β””]/) + + // Add start of second row + md.content = "| Header |\n|---|\n| Data1 |\n| D" + await renderOnce() + + frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // NOW should render as proper table showing first complete row + expect(frame).toMatch(/[β”Œβ”‚β””]/) + expect(frame).toContain("Data1") + // Should NOT show the raw markdown pipes anymore + expect(frame).not.toContain("|---|") + // Should not show incomplete second row + expect(frame).not.toContain("| D") +}) + +test("streaming table can transition back to raw fallback when rows are removed", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "| A |\n|---|\n| 1 |\n| 2 |", + syntaxStyle, + streaming: true, + }) + + renderer.root.add(md) + await renderOnce() + + // With 2 rows, we have 1 complete row - should render as table + let frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + expect(frame).toMatch(/[β”Œβ”‚β””]/) + expect(frame).toContain("1") + + // Remove second row - back to 1 row, so 0 complete rows + md.content = "| A |\n|---|\n| 1 |" + await renderOnce() + + frame = captureFrame() + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // Should fall back to raw text + expect(frame).not.toMatch(/[β”Œβ”‚β””]/) + expect(frame).toContain("| A |") + expect(frame).toContain("|---|") + expect(frame).toContain("| 1 |") +}) + +test("conceal change updates rendered content", async () => { + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content: "# Hello **bold**", + syntaxStyle, + conceal: true, + }) + + renderer.root.add(md) + await renderOnce() + + const frame1 = captureFrame() + expect(frame1).not.toContain("**") + expect(frame1).not.toContain("#") + + md.conceal = false + await renderOnce() + + const frame2 = captureFrame() + expect(frame2).toContain("**") + expect(frame2).toContain("#") +}) + +test("theme switching (syntaxStyle change)", async () => { + const theme1 = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 0, 0, 1) }, // Red + "markup.heading.1": { fg: RGBA.fromValues(0, 1, 0, 1), bold: true }, // Green + }) + + const theme2 = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(0, 0, 1, 1) }, // Blue + "markup.heading.1": { fg: RGBA.fromValues(1, 1, 0, 1), bold: true }, // Yellow + }) + + // Use the EXACT content from markdown-demo.ts to reproduce the issue + const content = `# OpenTUI Markdown Demo + +Welcome to the **MarkdownRenderable** showcase! This demonstrates automatic table alignment and syntax highlighting. + +## Features + +- Automatic **table column alignment** based on content width +- Proper handling of \`inline code\`, **bold**, and *italic* in tables +- Multiple syntax themes to choose from +- Conceal mode hides formatting markers + +## Comparison Table + +| Feature | Status | Priority | Notes | +|---|---|---|---| +| Table alignment | **Done** | High | Uses \`marked\` parser | +| Conceal mode | *Working* | Medium | Hides \`**\`, \`\`\`, etc. | +| Theme switching | **Done** | Low | 3 themes available | +| Unicode support | ζ—₯本θͺž | High | CJK characters | + +## Code Examples + +Here's how to use it: + +\`\`\`typescript +import { MarkdownRenderable } from "@opentui/core" + +const md = new MarkdownRenderable(renderer, { + content: "# Hello World", + syntaxStyle: mySyntaxStyle, + conceal: true, // Hide formatting markers +}) +\`\`\` + +### API Reference + +| Method | Parameters | Returns | Description | +|---|---|---|---| +| \`constructor\` | \`ctx, options\` | \`MarkdownRenderable\` | Create new instance | +| \`clearCache\` | none | \`void\` | Force re-render content | + +## Inline Formatting Examples + +| Style | Syntax | Rendered | +|---|---|---| +| Bold | \`**text**\` | **bold text** | +| Italic | \`*text*\` | *italic text* | +| Code | \`code\` | \`inline code\` | +| Link | \`[text](url)\` | [OpenTUI](https://github.com) | + +## Mixed Content + +> **Note**: This blockquote contains **bold** and \`code\` formatting. +> It should render correctly with proper styling. + +### Emoji Support + +| Emoji | Name | Category | +|---|---|---| +| πŸš€ | Rocket | Transport | +| 🎨 | Palette | Art | +| ⚑ | Lightning | Nature | +| πŸ”₯ | Fire | Nature | + +--- + +## Alignment Examples + +| Left | Center | Right | +|:---|:---:|---:| +| L1 | C1 | R1 | +| Left aligned | Centered text | Right aligned | +| Short | Medium length | Longer content here | + +## Performance + +The table alignment uses: +1. AST-based parsing with \`marked\` +2. Caching for repeated content +3. Smart width calculation accounting for concealed chars + +--- + +*Press \`?\` for keybindings* +` + + const md = new MarkdownRenderable(renderer, { + id: "markdown", + content, + syntaxStyle: theme1, + conceal: true, + }) + + renderer.root.add(md) + await renderOnce() + + const frame1 = captureFrame() + expect(frame1).toContain("OpenTUI") + + // Switch theme + const startTime = performance.now() + md.syntaxStyle = theme2 + await renderOnce() + const endTime = performance.now() + + const frame2 = captureFrame() + expect(frame2).toContain("OpenTUI") + + // Theme switch should be fast (< 10ms for this content after optimization) + // This is a performance regression test + const renderTime = endTime - startTime + if (renderTime >= 10) { + console.warn(`Theme switch took ${renderTime.toFixed(2)}ms, expected < 10ms`) + } + expect(renderTime).toBeLessThan(10) +}) diff --git a/packages/core/src/renderables/__tests__/markdown-parser.test.ts b/packages/core/src/renderables/__tests__/markdown-parser.test.ts new file mode 100644 index 000000000..b52173a57 --- /dev/null +++ b/packages/core/src/renderables/__tests__/markdown-parser.test.ts @@ -0,0 +1,128 @@ +import { test, expect } from "bun:test" +import { parseMarkdownIncremental, type ParseState } from "../markdown-parser" + +test("first parse returns all tokens", () => { + const state = parseMarkdownIncremental("# Hello\n\nParagraph", null) + + expect(state.content).toBe("# Hello\n\nParagraph") + expect(state.tokens.length).toBeGreaterThan(0) + expect(state.tokens[0].type).toBe("heading") +}) + +test("reuses unchanged tokens when appending content", () => { + const state1 = parseMarkdownIncremental("# Hello\n\nPara 1\n\n", null) + const state2 = parseMarkdownIncremental("# Hello\n\nPara 1\n\nPara 2", state1, 0) // No trailing unstable + + // First tokens should be same object reference (reused) + expect(state2.tokens[0]).toBe(state1.tokens[0]) // heading + expect(state2.tokens[1]).toBe(state1.tokens[1]) // paragraph +}) + +test("trailing unstable tokens are re-parsed", () => { + const state1 = parseMarkdownIncremental("# Hello\n\nPara 1\n\n", null) + const state2 = parseMarkdownIncremental("# Hello\n\nPara 1\n\nPara 2", state1, 2) + + // With trailingUnstable=2, last 2 tokens from state1 should be re-parsed + // state1 has: heading, paragraph, space (3 tokens) + // With trailing=2, only first token (heading) is stable + // So heading token should NOT be reused (since we only have 3 tokens and skip last 2) + // Actually with 3 tokens and trailingUnstable=2, we keep 1 token stable + expect(state2.tokens.length).toBeGreaterThan(0) + // The new tokens are re-parsed versions + expect(state2.tokens[0].type).toBe("heading") +}) + +test("handles content that diverges from start", () => { + const state1 = parseMarkdownIncremental("# Hello", null) + const state2 = parseMarkdownIncremental("## World", state1) + + // Content changed from start, no tokens can be reused + expect(state2.tokens[0]).not.toBe(state1.tokens[0]) + expect(state2.tokens[0].type).toBe("heading") +}) + +test("handles empty content", () => { + const state = parseMarkdownIncremental("", null) + + expect(state.content).toBe("") + expect(state.tokens).toEqual([]) +}) + +test("handles empty previous state", () => { + const prevState: ParseState = { content: "", tokens: [] } + const state = parseMarkdownIncremental("# Hello", prevState) + + expect(state.tokens.length).toBeGreaterThan(0) + expect(state.tokens[0].type).toBe("heading") +}) + +test("handles content truncation", () => { + const state1 = parseMarkdownIncremental("# Hello\n\nPara 1\n\nPara 2", null) + const state2 = parseMarkdownIncremental("# Hello", state1) + + expect(state2.tokens.length).toBe(1) + expect(state2.tokens[0].type).toBe("heading") +}) + +test("handles partial token match", () => { + const state1 = parseMarkdownIncremental("# Hello World", null) + const state2 = parseMarkdownIncremental("# Hello", state1) + + // Token at start doesn't match exactly, so it's re-parsed + expect(state2.tokens[0]).not.toBe(state1.tokens[0]) +}) + +test("handles multiple stable tokens with explicit boundaries", () => { + // Use content with clear token boundaries that won't change + const content1 = "Para 1\n\nPara 2\n\nPara 3\n\n" + const state1 = parseMarkdownIncremental(content1, null) + + const content2 = content1 + "Para 4" + const state2 = parseMarkdownIncremental(content2, state1, 0) + + // All original tokens should be reused (same object reference) + for (let i = 0; i < state1.tokens.length; i++) { + expect(state2.tokens[i]).toBe(state1.tokens[i]) + } + // And there should be a new token at the end + expect(state2.tokens.length).toBe(state1.tokens.length + 1) +}) + +test("code blocks are parsed correctly", () => { + const state = parseMarkdownIncremental("```js\nconst x = 1;\n```", null) + + const codeToken = state.tokens.find((t) => t.type === "code") + expect(codeToken).toBeDefined() + expect((codeToken as any).lang).toBe("js") +}) + +test("streaming scenario with incremental typing", () => { + let state: ParseState | null = null + + // Simulate typing character by character + state = parseMarkdownIncremental("#", state, 2) + expect(state.tokens.length).toBe(1) + + state = parseMarkdownIncremental("# ", state, 2) + state = parseMarkdownIncremental("# H", state, 2) + state = parseMarkdownIncremental("# He", state, 2) + state = parseMarkdownIncremental("# Hel", state, 2) + state = parseMarkdownIncremental("# Hell", state, 2) + state = parseMarkdownIncremental("# Hello", state, 2) + + expect(state.tokens[0].type).toBe("heading") + expect((state.tokens[0] as any).text).toBe("Hello") +}) + +test("token identity is preserved for stable tokens", () => { + // Create initial state with multiple paragraphs + const state1 = parseMarkdownIncremental("A\n\nB\n\nC\n\n", null) + + // Append content - with trailingUnstable=0, all tokens should be reused + const state2 = parseMarkdownIncremental("A\n\nB\n\nC\n\nD", state1, 0) + + // Verify token identity (same object reference) + expect(state2.tokens[0]).toBe(state1.tokens[0]) + expect(state2.tokens[1]).toBe(state1.tokens[1]) + expect(state2.tokens[2]).toBe(state1.tokens[2]) +}) diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 2abdd9827..68b6a11f5 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -8,6 +8,7 @@ export * from "./Diff" export * from "./FrameBuffer" export * from "./Input" export * from "./LineNumberRenderable" +export * from "./Markdown" export * from "./ScrollBar" export * from "./ScrollBox" export * from "./Select" diff --git a/packages/core/src/renderables/markdown-parser.ts b/packages/core/src/renderables/markdown-parser.ts new file mode 100644 index 000000000..156c341e8 --- /dev/null +++ b/packages/core/src/renderables/markdown-parser.ts @@ -0,0 +1,61 @@ +import { Lexer, type MarkedToken } from "marked" + +export interface ParseState { + content: string + tokens: MarkedToken[] +} + +/** + * Incrementally parse markdown, reusing unchanged tokens from previous parse. + * Compares token.raw at each offset - matching tokens keep same object reference. + */ +export function parseMarkdownIncremental( + newContent: string, + prevState: ParseState | null, + trailingUnstable: number = 2, +): ParseState { + if (!prevState || prevState.tokens.length === 0) { + try { + const tokens = Lexer.lex(newContent, { gfm: true }) as MarkedToken[] + return { content: newContent, tokens } + } catch { + return { content: newContent, tokens: [] } + } + } + + // Find how many tokens from start are unchanged + let offset = 0 + let reuseCount = 0 + + for (const token of prevState.tokens) { + const tokenEnd = offset + token.raw.length + if (tokenEnd <= newContent.length && newContent.slice(offset, tokenEnd) === token.raw) { + reuseCount++ + offset = tokenEnd + } else { + break + } + } + + // Keep last N tokens unstable (e.g. "# Hello" might become "# Hello World") + reuseCount = Math.max(0, reuseCount - trailingUnstable) + + offset = 0 + for (let i = 0; i < reuseCount; i++) { + offset += prevState.tokens[i].raw.length + } + + const stableTokens = prevState.tokens.slice(0, reuseCount) + const remainingContent = newContent.slice(offset) + + if (!remainingContent) { + return { content: newContent, tokens: stableTokens } + } + + try { + const newTokens = Lexer.lex(remainingContent, { gfm: true }) as MarkedToken[] + return { content: newContent, tokens: [...stableTokens, ...newTokens] } + } catch { + return { content: newContent, tokens: stableTokens } + } +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 94f568c12..d115cf73e 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -5,6 +5,7 @@ import { DiffRenderable, InputRenderable, LineNumberRenderable, + MarkdownRenderable, ScrollBoxRenderable, SelectRenderable, TabSelectRenderable, @@ -26,6 +27,7 @@ export const baseComponents = { text: TextRenderable, code: CodeRenderable, diff: DiffRenderable, + markdown: MarkdownRenderable, input: InputRenderable, select: SelectRenderable, textarea: TextareaRenderable, diff --git a/packages/react/src/types/components.ts b/packages/react/src/types/components.ts index fe63d1142..749ff4dd4 100644 --- a/packages/react/src/types/components.ts +++ b/packages/react/src/types/components.ts @@ -12,6 +12,8 @@ import type { InputRenderableOptions, LineNumberOptions, LineNumberRenderable, + MarkdownOptions, + MarkdownRenderable, RenderableOptions, RenderContext, ScrollBoxOptions, @@ -92,7 +94,9 @@ export type GetNonStyledProperties = | "treeSitterClient" | "conceal" | "drawUnstyledText" - : NonStyledProps + : TConstructor extends RenderableConstructor + ? NonStyledProps | "content" | "syntaxStyle" | "treeSitterClient" | "conceal" | "renderNode" + : NonStyledProps // ============================================================================ // Component Props System @@ -142,6 +146,8 @@ export type TextareaProps = ComponentProps export type CodeProps = ComponentProps +export type MarkdownProps = ComponentProps + export type DiffProps = ComponentProps export type SelectProps = ComponentProps & { diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index bf46a04d5..c1f258db3 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -5,6 +5,7 @@ import { DiffRenderable, InputRenderable, LineNumberRenderable, + MarkdownRenderable, ScrollBoxRenderable, SelectRenderable, TabSelectRenderable, @@ -102,6 +103,7 @@ export const baseComponents = { code: CodeRenderable, diff: DiffRenderable, line_number: LineNumberRenderable, + markdown: MarkdownRenderable, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/solid/src/types/elements.ts b/packages/solid/src/types/elements.ts index 410abb7e2..60fe351fa 100644 --- a/packages/solid/src/types/elements.ts +++ b/packages/solid/src/types/elements.ts @@ -8,6 +8,9 @@ import type { CodeRenderable, InputRenderable, InputRenderableOptions, + KeyEvent, + MarkdownOptions, + MarkdownRenderable, RenderableOptions, RenderContext, ScrollBoxOptions, @@ -23,7 +26,6 @@ import type { TextNodeRenderable, TextOptions, TextRenderable, - KeyEvent, } from "@opentui/core" import type { Ref } from "solid-js" import type { JSX } from "../../jsx-runtime" @@ -79,7 +81,9 @@ export type GetNonStyledProperties = ? NonStyledProps | "placeholder" | "value" : TConstructor extends RenderableConstructor ? NonStyledProps | "content" | "filetype" | "syntaxStyle" | "treeSitterClient" - : NonStyledProps + : TConstructor extends RenderableConstructor + ? NonStyledProps | "content" | "syntaxStyle" | "treeSitterClient" | "conceal" | "renderNode" + : NonStyledProps // ============================================================================ // Component Props System @@ -152,6 +156,8 @@ export type ScrollBoxProps = ComponentProps, Sc export type CodeProps = ComponentProps +export type MarkdownProps = ComponentProps + // ============================================================================ // Extended/Dynamic Component System // ============================================================================