diff --git a/docs/content/3.rendering/9.opentui.md b/docs/content/3.rendering/9.opentui.md new file mode 100644 index 00000000..7cc9f0f9 --- /dev/null +++ b/docs/content/3.rendering/9.opentui.md @@ -0,0 +1,174 @@ +--- +title: OpenTUI Terminal Rendering +description: Render Markdown as a terminal UI layout tree with OpenTUI, for CLIs and agent interfaces that need real layout, scrolling and syntax highlighting. +navigation: + title: OpenTUI (Terminal UI) + icon: i-lucide-square-terminal +links: + - label: ANSI Renderer + icon: i-lucide-terminal + to: /rendering/ansi + color: neutral + variant: soft + - label: Streaming API + icon: i-lucide-radio + to: /api/auto-close + color: neutral + variant: soft +--- + +The `@comark/opentui` package renders Markdown into [OpenTUI](https://github.com/sst/opentui) renderables — a real layout tree rather than a string. + +Use it over [`@comark/ansi`](/rendering/ansi) when the Markdown lives inside a terminal *application*: content takes part in flexbox layout, reflows to the terminal width, scrolls, and sits alongside your other widgets. Use `@comark/ansi` when you just want to print. + +## Installation + +::code-group + +```bash [pnpm] +pnpm add @comark/opentui @opentui/core @opentui/react +``` + +```bash [npm] +npm install @comark/opentui @opentui/core @opentui/react +``` + +```bash [yarn] +yarn add @comark/opentui @opentui/core @opentui/react +``` + +```bash [bun] +bun add @comark/opentui @opentui/core @opentui/react +``` + +:: + +## `` + +```tsx +/** @jsxImportSource @opentui/react */ +import { Markdown } from '@comark/opentui' + +export function Answer({ text, isStreaming }: { text: string, isStreaming: boolean }) { + return ( + + {text} + + ) +} +``` + +| Prop | Type | Description | +| ---- | ---- | ----------- | +| `children` / `value` | `string \| MarkdownDocument` | Markdown source, or an already-parsed document. | +| `streaming` | `boolean` | Re-parse as the source grows, closing unterminated constructs. | +| `caret` | `boolean \| { class: string }` | Append a cursor to the last text node. | +| `components` | `Record` | Component overrides, merged over the defaults. | +| `plugins` | `ComarkPlugin[]` | Parser plugins. Must be a stable reference. | +| `options` | `ParserOptions` | Parser options. Must be a stable reference. | +| `theme` | `Partial` | Colour and glyph overrides, merged over `defaultTheme`. | +| `data` | `Record` | Runtime data for `:`-prefixed props. | + +### Pre-parsed documents + +`MarkdownDocument` skips the parser entirely, for hosts that parse in a worker, another process, or a build step: + +```tsx +import { MarkdownDocument } from '@comark/opentui' + + +``` + +## Streaming + +`streaming` re-parses as the source grows and auto-closes dangling constructs, so a half-arrived `**bold` renders bold instead of showing its asterisks until the closer lands. The previous frame is held while a new parse is in flight, so output never blanks between deltas. + +## Theming + +```tsx + + {text} + +``` + +Every field is optional and merges over `defaultTheme`. Pass `syntaxStyle` to make the tree-sitter fallback follow the host application's theme. + +## Layout + +`` renders a normal box tree, so it participates in the surrounding flex layout. One flexbox detail is worth knowing when embedding it in a scroll region: + +```tsx + + {/* header */} + + + {text} + + + {/* footer */} + +``` + +A flex child will not shrink below its content height by default. A long document therefore makes the scroll region hold its ground, and Yoga takes the rows out of the surrounding chrome instead — collapsing a header and drawing its border through its own text. `minHeight={0}` on the scroll region and `flexShrink={0}` on the chrome keeps everything where you put it, whatever the document does. + +## Syntax highlighting + +Fenced code takes one of two paths: + +1. **Shiki tokens**, when [the Shiki plugin](/plugins/shiki) is registered — the colours already in the document are reused, so nothing extra has to be installed. Where Shiki emits both light and dark variants, the dark one wins. +2. Otherwise OpenTUI's `CodeRenderable`, which highlights with tree-sitter. That path only produces colour for languages whose grammar the host registered through OpenTUI's `addDefaultParsers`, and OpenTUI ships none. + +Shiki registers a fixed language set (vue, tsx, svelte, typescript, javascript, bash, json, yaml, astro) and does **not** load languages on demand, so anything else needs its grammar passed in: + +```ts +import shiki from 'comark/plugins/shiki' +import python from 'shiki/dist/langs/python.mjs' + +const plugins = [shiki({ languages: [python] })] +``` + +A `language [filename]` info string renders as a dimmed header above the block. + +## Components + +`::components` and tag overrides go through `components`, as in every other renderer: + +```tsx +import { Markdown, Prose, withNode } from '@comark/opentui' + +const Alert = withNode(({ children, __node }) => ( + + {children} + +)) + +{text} +``` + +::warning +Wrap a component's children in `Prose`, not a bare `box`. A one-paragraph component body is auto-unwrapped by the parser, so `children` can be loose strings — and OpenTUI throws if a string is not inside a text node. `Prose` sorts children into text hosts and blocks, and follows the `#default` slot when the component uses named slots. +:: + +`withNode` opts a component into receiving the raw Comark node on `__node`. Named slots arrive as `slotTitle`, `slotFooter`, and so on; `#default` arrives as `children`. + +## GitHub alerts + +`> [!NOTE]` and friends parse to a blockquote carrying `as: "note"`, and Comark resolves components from `as` — so `note`, `tip`, `important`, `warning` and `caution` are registered under those names. Colours come from `theme.alert`; override a kind through `components` to change its layout. + +## Runtime + +Rendering needs native FFI, which OpenTUI reaches through `bun:ffi` or, from Node 26.1, `node:ffi` behind `--experimental-ffi`: + +```bash +node --experimental-ffi --import tsx app.tsx +``` + +Parsing and the component map carry no such requirement, so a host that only builds documents runs anywhere. + +## Example + +`examples/3.cli/opentui-gallery` in the repository renders every supported construct on one scrollable page, with theme cycling and a streaming replay: + +```bash +pnpm dev:opentui +``` diff --git a/examples/3.cli/opentui-gallery/alternate-scroll.ts b/examples/3.cli/opentui-gallery/alternate-scroll.ts new file mode 100644 index 00000000..08f1ddfb --- /dev/null +++ b/examples/3.cli/opentui-gallery/alternate-scroll.ts @@ -0,0 +1,27 @@ +/** + * Alternate scroll mode (DEC private mode 1007). + * + * While the alternate screen is up, a terminal in this mode turns the wheel + * into cursor up/down instead of scrolling its own scrollback — which is what + * lets an app scroll with the wheel without capturing the mouse. + * + * The gallery needs it because it declines OpenTUI's mouse capture, so links + * stay clickable (see `gallery.tsx`). Terminals differ on the default: iTerm2 + * has it on, Ghostty follows xterm and leaves it off, so without this the wheel + * does nothing there. Written by hand because OpenTUI performs terminal setup + * itself and exposes no option for this mode. + */ +const ENABLE = '\x1b[?1007h' +const DISABLE = '\x1b[?1007l' + +/** + * Ask the terminal to send cursor keys on wheel, and give the mode back when + * the process ends. + * + * Restoring on `exit` rather than from the quit handler covers ctrl-c, which + * OpenTUI's `exitOnCtrlC` handles inside the renderer. + */ +export function enableAlternateScroll(): void { + process.stdout.write(ENABLE) + process.on('exit', () => process.stdout.write(DISABLE)) +} diff --git a/examples/3.cli/opentui-gallery/app.tsx b/examples/3.cli/opentui-gallery/app.tsx new file mode 100644 index 00000000..37f062a2 --- /dev/null +++ b/examples/3.cli/opentui-gallery/app.tsx @@ -0,0 +1,250 @@ +/** @jsxImportSource @opentui/react */ +import { Markdown, Prose, defaultTheme, withNode, type MarkdownTheme } from '@comark/opentui' +import type { ScrollBoxRenderable } from '@opentui/core' +import { useKeyboard } from '@opentui/react' +import { createMarkdownParser, type ElementNode } from 'comark' +import math from 'comark/plugins/math' +import shiki from 'comark/plugins/shiki' +import python from 'shiki/dist/langs/python.mjs' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import React, { useEffect, useRef, useState } from 'react' + +const here = dirname(fileURLToPath(import.meta.url)) + +export const SOURCE = readFileSync(join(here, 'gallery.md'), 'utf-8') + +/** + * Same plugin set as the ANSI demo, so both renderers are driven from an + * identical parse. Declared at module scope because `Markdown` treats `plugins` + * as a stable reference. + * + * Shiki is what produces the highlighting: it registers vue, tsx, svelte, + * typescript, javascript, bash, json, yaml and astro by default, and does *not* + * load languages on demand — an unregistered one is simply left as plain text. + * Python is in this document, so its grammar is passed explicitly. + */ +export const PLUGINS = [math(), shiki({ languages: [python as never] })] + +// Frontmatter never reaches the node tree, so the title is read from a parse of +// its own — the header bar showing it is the proof it was picked up. +const { frontmatter } = await createMarkdownParser({ plugins: PLUGINS })(SOURCE) + +export const TITLE = typeof frontmatter?.title === 'string' ? frontmatter.title : 'untitled' + +export const THEMES: { name: string; theme: Partial }[] = [ + { name: 'default', theme: defaultTheme }, + { + name: 'warm', + theme: { + heading: ['#ffd7ba', '#ffb787', '#f0883e', '#d97706', '#a16207', '#a16207'], + codeFg: '#ffd7ba', + codeBg: '#2d1b0e', + quoteBorder: '#f0883e', + marker: '#f0883e', + bullet: '▸', + rule: '#5c3317', + tableBorder: '#5c3317', + }, + }, + { + name: 'mono', + theme: { + heading: ['#ffffff', '#ffffff', '#e6e6e6', '#cccccc', '#b3b3b3', '#b3b3b3'], + codeFg: '#ffffff', + codeBg: '#1c1c1c', + quoteBorder: '#666666', + marker: '#999999', + bullet: '-', + muted: '#999999', + rule: '#444444', + tableBorder: '#444444', + }, + }, +] + +/** + * Host-supplied component, proving `::alert` reaches user code — including its + * named slots. `#title` and `#footer` arrive as `slotTitle` / `slotFooter`; + * `#default` arrives as `children`. + * + * `Prose` rather than a bare `{children}`: a one-paragraph body is auto-unwrapped + * by the parser, so children can be loose strings, which cannot sit directly in + * a box. `withNode` is what gets `__node` handed over. + */ +const Alert = withNode<{ + type?: string + children?: React.ReactNode + slotTitle?: React.ReactNode + slotFooter?: React.ReactNode + __node?: ElementNode +}>(({ type, children, slotTitle, slotFooter, __node }) => { + const color = type === 'warning' ? '#f0883e' : '#58a6ff' + + return ( + + {slotTitle ?? (type ?? 'note').toUpperCase()} + {children} + {slotFooter ? {slotFooter} : null} + + ) +}) + +const COMPONENTS = { alert: Alert } + +/** + * Where the streaming replay starts: just past the frontmatter. + * + * Frontmatter is document metadata, not streamed prose — a model emits the body. + * Replaying from character zero also spends the first frames with `---` parsed as + * a horizontal rule, which is correct for a partial document but paints a stray + * line under the header and misrepresents what streaming looks like. + */ +const BODY_START = SOURCE.indexOf('\n---\n', 3) + '\n---\n'.length +const BODY_LENGTH = SOURCE.length - BODY_START + +/** Characters appended per tick when replaying the document as a stream. */ +const STREAM_CHUNK = 4 +const STREAM_INTERVAL_MS = 16 + +export interface GalleryProps { + onQuit?: () => void + /** Start on a given theme — used by the smoke test. */ + initialThemeIndex?: number +} + +export function Gallery({ onQuit, initialThemeIndex = 0 }: GalleryProps) { + const [themeIndex, setThemeIndex] = useState(initialThemeIndex) + const [streaming, setStreaming] = useState(false) + // Counts body characters, so 0 means "frontmatter only". + const [revealed, setRevealed] = useState(BODY_LENGTH) + const scroller = useRef(null) + + // Replay the document as if it were arriving from a model — this is what + // exercises auto-close: half-written `**bold` and unterminated fences. + useEffect(() => { + if (!streaming) { + return + } + + const id = setInterval(() => { + setRevealed((count) => { + if (count >= BODY_LENGTH) { + clearInterval(id) + return count + } + + return Math.min(count + STREAM_CHUNK, BODY_LENGTH) + }) + }, STREAM_INTERVAL_MS) + + return () => clearInterval(id) + }, [streaming]) + + useKeyboard((key) => { + switch (key.name) { + case 'q': + case 'escape': + onQuit?.() + break + case 't': + setThemeIndex((index) => (index + 1) % THEMES.length) + break + case 's': + setStreaming((on) => !on) + setRevealed((count) => (count >= BODY_LENGTH ? 0 : count)) + break + case 'r': + setStreaming(false) + setRevealed(BODY_LENGTH) + break + case 'down': + scroller.current?.scrollBy(2) + break + case 'up': + scroller.current?.scrollBy(-2) + break + case 'pagedown': + case 'space': + scroller.current?.scrollBy(20) + break + case 'pageup': + scroller.current?.scrollBy(-20) + break + case 'home': + scroller.current?.scrollTo(0) + break + } + }) + + const active = THEMES[themeIndex]! + const source = streaming ? SOURCE.slice(0, BODY_START + revealed) : SOURCE + const progress = streaming ? ` ${Math.round((revealed / BODY_LENGTH) * 100)}%` : '' + + return ( + + {/* + * `flexShrink={0}` on the chrome and `minHeight={0}` on the scroll region: + * a flex child will not shrink below its content height by default, so a + * long document made the scrollbox hold its ground and Yoga took the rows + * out of the header instead — collapsing it to one row and drawing its + * bottom border straight through the title. Shorter content, as in the + * first frames of a streaming replay, released the pressure and the header + * grew back, which read as the chrome jumping. + */} + + + {`${TITLE} · theme: ${active.name} · ${streaming ? `streaming${progress}` : 'static'}`} + + + + + + {source} + + + + + ↑↓ pgup/pgdn scroll · s stream · r reset · t theme · q quit + + + ) +} diff --git a/examples/3.cli/opentui-gallery/gallery.md b/examples/3.cli/opentui-gallery/gallery.md new file mode 100644 index 00000000..47f92162 --- /dev/null +++ b/examples/3.cli/opentui-gallery/gallery.md @@ -0,0 +1,173 @@ +--- +title: Comark OpenTUI Demo +--- + +# Comark OpenTUI Renderer + +Render **Comark** markdown as a _terminal UI layout tree_. Every construct the +renderer handles is on this page — if something looks wrong here, it is wrong. + +## Text Formatting + +You can use **bold**, _italic_, **_both_**, ~~strikethrough~~, and `inline code`. + +Links look like this: [comark.dev](https://comark.dev) — OSC 8, so cmd-click it. + +An image degrades to its alt text: ![a diagram](diagram.png). + +This paragraph is wrapped at eighty columns in the source on purpose, because a +soft line break in markdown is a space: the renderer has to collapse them so the +terminal can reflow to **its own width**, keeping emphasis intact across breaks. + +## Code Block + +```typescript [main.ts] +import { parseMarkdown } from 'comark' +import { Markdown } from '@comark/opentui' + +const tree = await parseMarkdown('# Hello World') + +export function App() { + return +} +``` + +Another language, to check the highlighter switches. Python is not in the Shiki +plugin's default set, so the app registers its grammar — without that it renders +as plain text: + +```python +def fib(n: int) -> int: + return n if n < 2 else fib(n - 1) + fib(n - 2) +``` + +Unlabelled, so there is nothing to highlight: + +``` +$ comark render README.md +wrote 4.1 kB +``` + +## Lists + +Unordered: + +- First item +- Second item + - Nested item + - Third level +- Third item + +Ordered: + +1. Step one +2. Step two +3. Step three + +Starting partway through: + +7. Seven +8. Eight + +Tasks, which arrive as `input` nodes rather than text: + +- [x] Done +- [ ] Not done +- [x] Done with **bold** + +An item carrying a block child, which makes the container change shape: + +- Run this first: + + ```bash + pnpm install + ``` + +- Then check the output + +## Blockquote + +> The terminal is not just a tool, +> it is a way of life. + +One holding block children: + +> First paragraph of the quote. +> +> - a list inside a quote +> - second item + +## GitHub Alerts + +> [!NOTE] +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention due to potential risks. + +> [!CAUTION] +> Negative potential consequences of an action. + +## Component Slots + +::alert +#title +Hello from the title slot + +#default +This is the **default** slot with _markdown_ content. + +#footer +Footer slot content here. +:: + +An unregistered component, which falls back to a passthrough container rather +than throwing: + +::not-registered +Body of a component nobody mapped. +:: + +## Math + +Inline: the energy equation $E = mc^2$ is fundamental to physics. + +Block display math: + +$$ +\frac{-b \pm \sqrt{b^2 - 4ac}}{2a} +$$ + +## Table + +| Feature | Renderer | Status | +| ----------- | -------------------- | ------ | +| Headings | text weight + colour | ✅ | +| Bold/Italic | native span hosts | ✅ | +| Code blocks | tree-sitter | ✅ | +| Tables | measured columns | ✅ | +| Lists | Yoga hanging indent | ✅ | + +Columns are sized to their widest cell, counted in code points. `✅` occupies two +terminal cells but counts as one, so a column whose widest cell is a +double-width glyph under-measures and pushes the columns after it out of line. +Harmless above, where the header is the widest cell and absorbs the difference. + +## Raw HTML + +The html plugin is on by default, so these arrive as `div` and `span` nodes that +OpenTUI has no host for. They should render, not crash: + +
A raw HTML block.
+ +Inline html span and an unknown mark tag mid-sentence. + +--- + +_Press `s` to replay this document as a stream._ diff --git a/examples/3.cli/opentui-gallery/gallery.tsx b/examples/3.cli/opentui-gallery/gallery.tsx new file mode 100644 index 00000000..e9474785 --- /dev/null +++ b/examples/3.cli/opentui-gallery/gallery.tsx @@ -0,0 +1,16 @@ +/** @jsxImportSource @opentui/react */ +import { createCliRenderer } from '@opentui/core' +import { createRoot } from '@opentui/react' +import { enableAlternateScroll } from './alternate-scroll.ts' +import { Gallery } from './app.tsx' + +const renderer = await createCliRenderer({ targetFps: 30, exitOnCtrlC: true, useMouse: false }) + +enableAlternateScroll() + +function quit() { + renderer.destroy() + process.exit(0) +} + +createRoot(renderer).render() diff --git a/examples/3.cli/opentui-gallery/package.json b/examples/3.cli/opentui-gallery/package.json new file mode 100644 index 00000000..09f95ba0 --- /dev/null +++ b/examples/3.cli/opentui-gallery/package.json @@ -0,0 +1,26 @@ +{ + "name": "opentui-gallery", + "version": "0.0.1", + "private": true, + "description": "Every @comark/opentui component on one scrollable page", + "license": "MIT", + "author": "Comark team", + "type": "module", + "scripts": { + "dev": "pnpm --filter @comark/opentui build && node run.mjs gallery.tsx", + "smoke": "pnpm --filter @comark/opentui build && node run.mjs smoke.tsx" + }, + "dependencies": { + "@comark/opentui": "workspace:*", + "@opentui/core": "^0.4.5", + "@opentui/react": "^0.4.5", + "comark": "workspace:*", + "react": "^19.2.7", + "shiki": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/3.cli/opentui-gallery/run.mjs b/examples/3.cli/opentui-gallery/run.mjs new file mode 100644 index 00000000..7d81651d --- /dev/null +++ b/examples/3.cli/opentui-gallery/run.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Launches a gallery entry with native FFI enabled. + * + * node run.mjs gallery.tsx + * + * OpenTUI paints through native FFI, which Node exposes from 26.1 behind + * `--experimental-ffi`. Two things make this more than a flag in the package + * script: + * + * - putting the flag there directly makes an older Node bail with + * `node: bad option` before any of our code can explain why; + * - version managers that shim `node` (Volta, in particular) rewrite PATH for + * the whole child-process tree from the pin resolved at the *workspace root*, + * so `PATH=…:$PATH pnpm dev:opentui` and a pin in this package are both + * ignored. + * + * So: check the running Node, and if it is too old, look for one that is new + * enough rather than telling the user to change their global toolchain. Set + * `COMARK_NODE` to skip the search. + * + * `stdio: 'inherit'` hands the real TTY to the child, which the renderer needs + * for raw mode and resize events. + */ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' + +const MIN_MAJOR = 26 +const MIN_MINOR = 1 + +const entry = process.argv[2] + +if (!entry) { + console.error('usage: node run.mjs ') + process.exit(1) +} + +function isNewEnough(version) { + const [major, minor] = version.replace(/^v/, '').split('.').map(Number) + + return major > MIN_MAJOR || (major === MIN_MAJOR && minor >= MIN_MINOR) +} + +/** Version of a candidate binary, or null when it is missing or not runnable. */ +function versionOf(binary) { + if (binary.includes('/') && !existsSync(binary)) { + return null + } + + const { status, stdout } = spawnSync(binary, ['--version'], { encoding: 'utf-8' }) + + return status === 0 ? stdout.trim() : null +} + +function findNode() { + if (isNewEnough(process.versions.node)) { + return process.execPath + } + + const candidates = [ + process.env.COMARK_NODE, + '/opt/homebrew/opt/node@26/bin/node', + '/opt/homebrew/bin/node', + '/usr/local/opt/node@26/bin/node', + '/usr/local/bin/node', + ].filter(Boolean) + + for (const candidate of candidates) { + const version = versionOf(candidate) + + if (version && isNewEnough(version)) { + return candidate + } + } + + return null +} + +const node = findNode() + +if (!node) { + console.error( + `\nThis example needs Node >= ${MIN_MAJOR}.${MIN_MINOR}, and the one running it is ${process.versions.node}.\n\n` + + `OpenTUI renders through native FFI, which Node only exposes from ${MIN_MAJOR}.${MIN_MINOR}\n` + + `behind --experimental-ffi. No Node that new was found.\n\n` + + ` COMARK_NODE=/path/to/node pnpm dev:opentui # point at one directly\n` + + ` volta install node@${MIN_MAJOR} # or nvm/fnm equivalent\n\n` + + `Note for Volta users: prefixing PATH does not work here, because the shim\n` + + `sets the Node for the whole process tree from the workspace root's pin.\n\n` + + `Nothing else in the repo needs this: parsing, the component map and\n` + + `\`pnpm test\` all run on any supported Node.\n` + ) + process.exit(1) +} + +if (node !== process.execPath) { + console.error(`[gallery] using ${node} (${versionOf(node)}) for native FFI`) +} + +const { status } = spawnSync(node, ['--experimental-ffi', '--import', 'tsx', entry, ...process.argv.slice(3)], { + stdio: 'inherit', +}) + +process.exit(status ?? 1) diff --git a/examples/3.cli/opentui-gallery/smoke.tsx b/examples/3.cli/opentui-gallery/smoke.tsx new file mode 100644 index 00000000..f50966e4 --- /dev/null +++ b/examples/3.cli/opentui-gallery/smoke.tsx @@ -0,0 +1,182 @@ +/** @jsxImportSource @opentui/react */ +/** + * Headless render of the gallery, for checking it still paints without opening a + * terminal. Prints the frame and fails if a construct went missing. + * + * pnpm smoke + */ +import { createMockKeys } from '@opentui/core/testing' +import { testRender } from '@opentui/react/test-utils' +import { act } from 'react' +import { Gallery } from './app.tsx' + +const EXPECTED = [ + // headings, inline marks, link, image alt + 'Comark OpenTUI Renderer', + 'Text Formatting', + 'strikethrough', + 'comark.dev', + '[a diagram]', + // fenced code: header (language + filename), body, second language, plain + 'typescript', + 'main.ts', + 'parseMarkdown', + 'def fib', + 'comark render README.md', + // lists + '• First item', + '1. Step one', + '7. Seven', + '[x] Done', + '[ ] Not done', + 'pnpm install', + // blockquote and every GitHub alert kind + 'it is a way of life.', + 'NOTE', + 'TIP', + 'IMPORTANT', + 'WARNING', + 'CAUTION', + // component slots, and the unregistered fallback + 'Hello from the title slot', + 'default', + 'Footer slot content here.', + 'Body of a component nobody mapped.', + // math, inline and block + 'E = mc^2', + '4ac', + // table and raw html + 'measured columns', + 'A raw HTML block.', + 'html span', + 'mark tag', +] + +async function pump(ui: { renderOnce: () => Promise }, frames = 24) { + for (let i = 0; i < frames; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 8)) + }) + await ui.renderOnce() + } +} + +const ui = await testRender(, { width: 96, height: 260 }) + +await pump(ui) + +const frame = ui.captureCharFrame() as string + +console.log( + frame + .split('\n') + .map((row) => row.trimEnd()) + .join('\n') +) + +const missing = EXPECTED.filter((needle) => !frame.includes(needle)) + +if (missing.length > 0) { + console.error(`\n✗ missing from the frame:\n${missing.map((m) => ` - ${m}`).join('\n')}`) + process.exit(1) +} + +console.error(`\n✓ all ${EXPECTED.length} markers present`) + +/** + * The chrome must not move when the content does. + * + * A flex child will not shrink below its content height by default, so the full + * document used to make the scroll region hold its ground and Yoga took the rows + * out of the header instead — collapsing it to one row and drawing its bottom + * border through the title. Toggling the streaming replay shrank the content, + * released the pressure, and the header grew back, which read as the chrome + * jumping. Measured at a realistic terminal height, where the pressure exists. + */ +const chrome = await testRender(, { width: 90, height: 40 }) + +function chromeRows(label: string) { + const rows = (chrome.captureCharFrame() as string).split('\n') + const title = rows[0] ?? '' + const separator = rows.findIndex((row) => row.includes('────────')) + const footer = rows.findIndex((row) => row.includes('pgup')) + + // Only the chrome is asserted. How far down the content starts is a property of + // how much has been revealed, and is legitimately empty early in a replay. + const problems = [ + separator === 1 ? null : `separator on row ${separator}, expected 1`, + footer === 39 ? null : `footer on row ${footer}, expected 39`, + // The symptom of the collapse: the header's bottom border drawn through the + // title instead of on its own row. + title.includes('─') ? `border bled into the title row: ${JSON.stringify(title.trim())}` : null, + title.includes('Comark OpenTUI Demo') ? null : 'title missing from row 0', + ].filter(Boolean) + + if (problems.length > 0) { + console.error(`\n✗ chrome shifted (${label}):\n${problems.map((p) => ` - ${p}`).join('\n')}`) + process.exit(1) + } +} + +/** + * Body rows, excluding the chrome. Used to check what streaming actually shows. + */ +function bodyRows() { + const rows = (chrome.captureCharFrame() as string).split('\n') + + return rows + .slice(2, 39) + .map((row) => row.trimEnd()) + .filter((row) => row !== '') +} + +await pump(chrome, 12) +chromeRows('static') + +const staticRows = bodyRows().length + +const keys = createMockKeys((chrome as unknown as { renderer: never }).renderer) + +// Wrapped because the handler sets React state. +await act(async () => { + keys.pressKey('s') +}) + +/* + * The first streamed frames are where two defects showed up: + * + * - the previous document was held while the new parse ran, so pressing `s` + * left the whole document on screen for a frame before it restarted; + * - the replay began at character zero, and `---` of the frontmatter parses as + * a horizontal rule, painting a stray line right under the header's own. + */ +await pump(chrome, 2) + +const firstStreamed = bodyRows() + +if (firstStreamed.length >= staticRows) { + console.error(`\n✗ streaming did not restart: ${firstStreamed.length} body rows, static had ${staticRows}`) + process.exit(1) +} + +if (firstStreamed[0]?.includes('────')) { + console.error(`\n✗ frontmatter streamed as a horizontal rule: ${JSON.stringify(firstStreamed[0])}`) + process.exit(1) +} + +chromeRows('streaming 1') + +for (const sample of ['streaming 2', 'streaming 3']) { + await pump(chrome, 6) + chromeRows(sample) +} + +await act(async () => { + keys.pressKey('r') +}) + +await pump(chrome, 8) +chromeRows('back to static') + +console.error('✓ chrome holds position across a streaming toggle') +process.exit(0) diff --git a/examples/3.cli/opentui-gallery/tsconfig.json b/examples/3.cli/opentui-gallery/tsconfig.json new file mode 100644 index 00000000..9d60a36e --- /dev/null +++ b/examples/3.cli/opentui-gallery/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + // The OpenTUI runtime is picked per file by the `@jsxImportSource` pragma, + // not set project-wide: doing that would also point @comark/react's own JSX + // at `@opentui/react/jsx-runtime`, which does not resolve from its directory. + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["*.tsx"] +} diff --git a/package.json b/package.json index dc18643c..3cdf3ff4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev:svelte": "pnpm --filter comark-svelte run dev", "dev:angular": "pnpm --filter comark-angular run dev", "dev:ansi": "pnpm --filter comark-ansi run dev", + "dev:opentui": "pnpm --filter opentui-gallery run dev", "dev:vue": "pnpm --filter comark-vue run dev", "dev:vue:mermaid": "pnpm --filter comark-vue-vite-mermaid run dev", "dev:vue:math": "pnpm --filter comark-vue-vite-math run dev", diff --git a/packages/comark-opentui/README.md b/packages/comark-opentui/README.md new file mode 100644 index 00000000..9bc63a12 --- /dev/null +++ b/packages/comark-opentui/README.md @@ -0,0 +1,195 @@ +Comark banner + +# @comark/opentui + +OpenTUI renderer for Comark. Renders Markdown as a terminal UI layout tree. + +Unlike [`@comark/ansi`](../comark-ansi), which returns a styled string, this +builds real [OpenTUI](https://github.com/sst/opentui) renderables — so markdown +takes part in flexbox layout, wraps to the terminal width, and gets tree-sitter +syntax highlighting on fenced code. + +## Install + +```bash +npm install @comark/opentui @opentui/core @opentui/react +``` + +## Usage + +```tsx +/** @jsxImportSource @opentui/react */ +import { Markdown } from '@comark/opentui' + +export function Answer({ text, isStreaming }: { text: string; isStreaming: boolean }) { + return ( + + {text} + + ) +} +``` + +Already have a parsed document — from a worker, a build step, another process — +render it directly and keep the parser out of the render path: + +```tsx +import { MarkdownDocument } from '@comark/opentui' + + +``` + +### Streaming + +`streaming` re-parses as the source grows and closes dangling constructs, so a +half-arrived `**bold` renders bold instead of showing its asterisks until the +closer lands. The previous frame is held while a new parse is in flight, so the +output never blanks between deltas. `caret` appends a cursor to the last text +node. + +### Theming + +```tsx + + {text} + +``` + +Every field is optional and merges over `defaultTheme`. Pass `syntaxStyle` to +make fenced code follow the host application's theme; without it a neutral one +is created on first paint. + +### Components + +`::components` and tag overrides go through `components`, same as the other +renderers: + +```tsx +import { Markdown, Prose, withNode } from '@comark/opentui' + +const Alert = withNode(({ children, __node }) => ( + + {children} + +)) + +{text} +``` + +**Wrap children in `Prose`, not a bare `box`.** A component body arrives in two +shapes and `children` alone does not say which: the parser's `autoUnwrap` strips +the paragraph off a single-paragraph body, handing over loose strings, while a +multi-paragraph body hands over `p` elements. Loose strings inside a `box` make +OpenTUI throw `Text must be created inside of a text node`. `Prose` sorts the +children into text hosts and blocks. Use `{children}` instead only +if the component is inline-only by construction. + +`withNode` is what opts a component into receiving the raw Comark node on +`__node` (it sets `propTypes = { __node: null }`, which is the flag Comark's +walker looks for). The built-in `pre` uses it to reach the fence body, and +`table` to measure its columns. + +## Gallery + +Every construct on one scrollable page, with theme cycling and a streaming +replay: + +```bash +pnpm dev:opentui +``` + +Needs Node >= 26.1. Source in +[`examples/3.cli/opentui-gallery`](../../examples/3.cli/opentui-gallery), where +`pnpm smoke` renders it headlessly and checks nothing went missing. + +## Notes + +**Inline versus block.** OpenTUI throws if a string or span lands outside a text +node, and markdown mixes the two inside one container — a tight list item holds +bare text, a loose one holds paragraphs. Containers group runs of inline +children into a single `text` and let block children through, so both shapes +work. + +**Unknown tags.** Comark's html plugin is on by default, so a `
` in the +source becomes a `div` node. Any tag outside the built-in map resolves to a +passthrough container instead of throwing, which matters when the markdown comes +from a language model. + +**GitHub alerts.** `> [!NOTE]` and friends parse to a blockquote carrying +`as: "note"`, and Comark resolves components from `as` — so `note`, `tip`, +`important`, `warning` and `caution` are registered under those names, not under +`blockquote`. Colours come from `theme.alert`; override a kind through +`components` to change the layout. + +**Named slots.** `#title` arrives as `slotTitle`, `#footer` as `slotFooter`, and +`#default` as `children`. `Prose` follows the default template automatically, so +`` is right whether or not the component uses slots. + +**Math.** Rendered as its TeX source — a terminal cannot typeset it. Mapping it +is not optional: math nodes carry no block/inline meta, so the generic fallback +would put a box inside a paragraph. + +**Fenced code** has two highlighting paths: + +- With the Shiki plugin registered, the token colours already in the AST are + used. Where Shiki emits both light and dark variants, the dark one wins. + + Its language set is fixed at startup — vue, tsx, svelte, typescript, + javascript, bash, json, yaml, astro — and there is **no load-on-demand**, so a + fence in any other language renders flat until you pass its grammar: + + ```ts + import shiki from 'comark/plugins/shiki' + import python from 'shiki/dist/langs/python.mjs' + + {text} + ``` + +- Otherwise OpenTUI's `CodeRenderable` highlights with tree-sitter, styled by + `theme.syntaxStyle`. That path is incremental and follows the terminal's theme, + but it only produces colour for languages whose grammar the host registered + through `addDefaultParsers` — OpenTUI ships none — so **plain fences render + unhighlighted unless you install grammars**. Registering `shiki()` is the + simplest way to get highlighting. + +A `language [filename]` info string renders as a dimmed header above the block. + +**Native tags.** `strong`, `em`, `b`, `i`, `u`, `a`, `br` and `span` are left to +OpenTUI's own text-node renderables, including OSC 8 hyperlinks for `a`. +`code` and `input` are mapped explicitly, because OpenTUI hosts of those names +are a block-level highlighted panel and an interactive text field. + +Opening those hyperlinks is the terminal's job, and a terminal stops opening +them for an app that has captured the mouse — OpenTUI's default. iTerm2 and VS +Code open them anyway; Ghostty and other terminals following xterm give the +click to the app, so the link only opens on shift-click. A host that wants a +plain click to work should pass `useMouse: false` to `createCliRenderer` and +enable alternate scroll mode (`\x1b[?1007h`), which keeps the wheel scrolling by +sending cursor keys instead. `examples/3.cli/opentui-gallery` does this. + +## Runtime + +Rendering needs native FFI, which OpenTUI reaches through `bun:ffi` or — from +Node 26.1 — `node:ffi` behind `--experimental-ffi`: + +```bash +node --experimental-ffi --import tsx app.tsx +``` + +Parsing and the component map have no such requirement, so a host that only +builds documents runs anywhere. + +## Testing + +The suite is split along that line: + +```bash +pnpm test # tag coverage, layout logic — any supported Node +pnpm test:paint # rendered frames — needs Node >= 26.1 +``` + +`test:paint` reports and exits cleanly on an older Node rather than failing, so +it is safe to wire into a pipeline that has not moved yet. diff --git a/packages/comark-opentui/package.json b/packages/comark-opentui/package.json new file mode 100644 index 00000000..0b97c98a --- /dev/null +++ b/packages/comark-opentui/package.json @@ -0,0 +1,72 @@ +{ + "name": "@comark/opentui", + "version": "0.6.1", + "description": "OpenTUI renderer for Comark. Render Markdown as a terminal UI layout tree, with streaming support for AI output.", + "keywords": [ + "ai", + "cli", + "comark", + "components", + "markdown", + "mdc", + "opentui", + "renderer", + "streaming", + "terminal", + "tui" + ], + "homepage": "https://comark.dev/rendering/opentui", + "bugs": { + "url": "https://github.com/comarkdown/comark/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/comarkdown/comark.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./plugins/*": "./dist/plugins/*.js", + "./utils": "./dist/utils.js", + "./parse": "./dist/parse.js", + "./render": "./dist/render.js", + "./theme": "./dist/theme.js", + "./components/*": "./dist/components/*.js" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "stub": "node ../../scripts/stub.mjs", + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:paint": "node scripts/paint.mjs", + "prepack": "tsc", + "release": "release-it" + }, + "dependencies": { + "@comark/react": "workspace:*", + "comark": "workspace:*" + }, + "devDependencies": { + "@opentui/core": "^0.4.5", + "@opentui/react": "^0.4.5", + "@types/react": "catalog:", + "react": "^19.2.7", + "shiki": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@opentui/core": "^0.4.0", + "@opentui/react": "^0.4.0", + "react": "^19.0.0" + } +} diff --git a/packages/comark-opentui/scripts/paint.mjs b/packages/comark-opentui/scripts/paint.mjs new file mode 100644 index 00000000..5b77ded4 --- /dev/null +++ b/packages/comark-opentui/scripts/paint.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * Runs the paint suite, which needs a live OpenTUI renderer and therefore + * native FFI. + * + * Node exposes `node:ffi` from 26.1 behind `--experimental-ffi`. The flag has to + * arrive via `NODE_OPTIONS` rather than on this process's argv: Vitest runs test + * files in forked workers, which inherit the environment but not the parent's + * flags (`poolOptions.forks.execArgv` does not get it through either). + * + * Wrapped in a script rather than inlined as `NODE_OPTIONS=… vitest` so it also + * works in shells without env-prefix syntax, and so an unsupported Node gets a + * real message instead of `node: bad option`. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +const MIN_MAJOR = 26 +const MIN_MINOR = 1 + +function isNewEnough(version) { + const [major, minor] = version.replace(/^v/, '').split('.').map(Number) + + return major > MIN_MAJOR || (major === MIN_MAJOR && minor >= MIN_MINOR) +} + +/** + * A Node new enough to have `node:ffi`, or null. + * + * The search matters because version managers that shim `node` — Volta above all + * — set the Node for the whole child-process tree from the workspace root's pin, + * so a maintainer cannot opt this one script in by prefixing PATH. Set + * `COMARK_NODE` to skip it. + */ +function findNode() { + if (isNewEnough(process.versions.node)) { + return process.execPath + } + + const candidates = [ + process.env.COMARK_NODE, + '/opt/homebrew/opt/node@26/bin/node', + '/opt/homebrew/bin/node', + '/usr/local/opt/node@26/bin/node', + '/usr/local/bin/node', + ].filter(Boolean) + + for (const candidate of candidates) { + if (candidate.includes('/') && !existsSync(candidate)) { + continue + } + + const { status, stdout } = spawnSync(candidate, ['--version'], { encoding: 'utf-8' }) + + if (status === 0 && isNewEnough(stdout.trim())) { + return candidate + } + } + + return null +} + +const node = findNode() + +if (!node) { + console.error( + `Skipping the paint suite: needs Node >= ${MIN_MAJOR}.${MIN_MINOR} for node:ffi, found ${process.versions.node} ` + + 'and no newer one on this machine.\n' + + 'Point COMARK_NODE at a suitable binary to run it. The runtime-agnostic suite ' + + '(`pnpm test`) covers tag resolution and layout logic on any Node.' + ) + process.exit(0) +} + +if (node !== process.execPath) { + console.error(`[paint] using ${node} for native FFI`) +} + +// Resolved through the manifest because Vitest's exports map does not expose its +// CLI entry directly. +const manifestPath = createRequire(import.meta.url).resolve('vitest/package.json') +const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) +const vitest = join(dirname(manifestPath), typeof bin === 'string' ? bin : bin.vitest) + +const { status } = spawnSync(node, [vitest, 'run', '--config', 'vitest.paint.config.ts', ...process.argv.slice(2)], { + stdio: 'inherit', + env: { + ...process.env, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --experimental-ffi`.trim(), + }, +}) + +process.exit(status ?? 1) diff --git a/packages/comark-opentui/src/components/block.tsx b/packages/comark-opentui/src/components/block.tsx new file mode 100644 index 00000000..6afd56b0 --- /dev/null +++ b/packages/comark-opentui/src/components/block.tsx @@ -0,0 +1,253 @@ +/** @jsxImportSource @opentui/react */ +import { TextAttributes } from '@opentui/core' +import type { ElementNode } from 'comark' +import React, { createContext, useContext } from 'react' +import { useMarkdownTheme } from '../theme.ts' +import { contentNode, withNode } from '../utils.ts' +import { groupChildrenByFlow, reflowInline } from './flow.tsx' + +interface ChildrenProps { + children?: React.ReactNode +} + +interface NodeProps extends ChildrenProps { + __node?: ElementNode +} + +export const Paragraph: React.FC = ({ children }) => { + return {reflowInline(children)} +} + +/** + * One component for `h1`–`h6`: the level is read off the source node rather + * than passed per tag, so the map can point all six at this. + * + * No `#` prefix — weight and colour carry the hierarchy, matching how OpenTUI's + * own markdown renderable presents headings. + */ +export const Heading = withNode(({ children, __node }) => { + const theme = useMarkdownTheme() + const level = Number(__node?.[0]?.slice(1)) || 1 + const fg = theme.heading[Math.min(level, theme.heading.length) - 1] + + return ( + + {reflowInline(children)} + + ) +}) + +/** + * Blockquote. The left bar is the box's own left border, so it spans the + * quote's full height for free instead of needing a glyph per wrapped line. + */ +export const Blockquote = withNode(({ children, __node }) => { + const theme = useMarkdownTheme() + + return ( + + {groupChildrenByFlow(__node, children)} + + ) +}) + +/** + * GitHub alert (`> [!NOTE]`, `> [!WARNING]`, …). + * + * Comark parses these as `["blockquote", { as: "note" }, …]`, and its walker + * resolves the component from `as` when present — so these never reach + * {@link Blockquote} and have to be mapped under the alert names themselves. + * The kind is read back off `as`, letting all five share one component. + */ +export const Alert = withNode(({ children, __node }) => { + const theme = useMarkdownTheme() + const kind = String(__node?.[1]?.as ?? 'note').toLowerCase() + const color = theme.alert[kind] ?? theme.quoteBorder + + return ( + + + {kind.toUpperCase()} + + {children} + + ) +}) + +export const Rule: React.FC = () => { + const theme = useMarkdownTheme() + + return ( + + ) +} + +/** + * Images degrade to their alt text. A terminal cannot show the bitmap and the + * alt text is the only thing a reader can act on. + */ +export const Image: React.FC<{ alt?: string; src?: string }> = ({ alt, src }) => { + const theme = useMarkdownTheme() + + return {`[${alt || src || 'image'}]`} +} + +/** + * Task-list checkbox. Comark emits GFM task items as an `input` node, and + * OpenTUI's native `input` is an interactive text field — mapping this is what + * keeps `- [x] done` from mounting a focusable widget mid-sentence. + */ +export const Checkbox: React.FC<{ checked?: unknown }> = ({ checked }) => { + const done = checked === true || checked === 'true' + + // No trailing space: Comark keeps the source's own separator on the text node + // that follows, so adding one here doubles it. + return {done ? '[x]' : '[ ]'} +} + +interface ListItemContextValue { + ordered: boolean + /** 1-based ordinal, already offset by an `ol`'s `start`. */ + index: number +} + +const ListItemContext = createContext({ ordered: false, index: 1 }) + +interface ListProps extends ChildrenProps { + start?: number | string +} + +function List({ children, ordered, start }: ListProps & { ordered: boolean }) { + const base = ordered ? Number(start ?? 1) || 1 : 1 + let ordinal = 0 + + const items = React.Children.map(children, (child) => { + // A list's children are `li` elements; anything else is stray inline content + // that still needs a text host to live in. + if (!React.isValidElement(child)) { + return {child} + } + + const value: ListItemContextValue = { ordered, index: base + ordinal++ } + + return {child} + }) + + return {items} +} + +export const UnorderedList: React.FC = (props) => ( + +) + +export const OrderedList: React.FC = (props) => ( + +) + +/** + * List item. Marker and body are siblings in a row so the body owns the + * remaining width and wrapped lines hang under the first character rather than + * under the bullet — Yoga does what the ANSI renderer has to do by padding + * strings. + */ +export const ListItem = withNode(({ children, __node }) => { + const theme = useMarkdownTheme() + const { ordered, index } = useContext(ListItemContext) + const marker = ordered ? `${index}. ` : `${theme.bullet} ` + + return ( + + {marker} + + {groupChildrenByFlow(__node, children)} + + + ) +}) + +/** + * Container for markdown children of unknown shape. Use this in custom + * `::component` implementations instead of dropping `children` straight into a + * `box`. + * + * A component's body arrives in one of two shapes and the difference is not + * visible from `children` alone: Comark's `autoUnwrap` strips the paragraph off + * a single-paragraph body, so `::alert` with one line hands over bare strings + * and inline nodes, while a multi-paragraph body hands over `p` elements. Bare + * strings inside a `box` make OpenTUI throw `Text must be created inside of a + * text node`, so the two cannot share a container. + * + * Pass the `__node` a component receives via {@link withNode} so the children + * can be matched against their source. + * + * @example + * ```tsx + * const Alert = withNode(({ children, __node }) => ( + * + * {children} + * + * )) + * ``` + */ +export function Prose({ children, node, __node, gap = 1 }: NodeProps & { gap?: number; node?: ElementNode }) { + return ( + + {groupChildrenByFlow(contentNode(node ?? __node), children)} + + ) +} + +/** + * Fallback for tags this renderer has no opinion on — most importantly the raw + * HTML Comark's html plugin emits (`
` in a markdown source becomes a `div` + * node). Unmapped, those reach OpenTUI's reconciler and throw + * `Unknown component type`, which for an LLM-fed terminal means arbitrary model + * output can take the UI down. + * + * Inline or block is read off the `$.block` meta the html plugin sets, because + * the two need different containers and picking wrong throws either way: a box + * inside a text node, or a span outside one. Tags with no meta (an unregistered + * `::component`) are treated as block, matching MDC's block-by-default syntax. + */ +export const UnknownTag = withNode(({ children, __node }) => { + if (__node?.[1]?.$?.block === 0) { + return {children} + } + + return {children} +}) diff --git a/packages/comark-opentui/src/components/flow.tsx b/packages/comark-opentui/src/components/flow.tsx new file mode 100644 index 00000000..1dcc72a2 --- /dev/null +++ b/packages/comark-opentui/src/components/flow.tsx @@ -0,0 +1,138 @@ +/** @jsxImportSource @opentui/react */ +import type { ElementNode } from 'comark' +import React from 'react' +import { childNodes, isBlockNode } from '../utils.ts' + +/** + * Split a container's rendered children into box-level groups, wrapping every + * run of inline children in a `text`. + * + * OpenTUI enforces this: strings and span-likes throw unless their parent is a + * text node, so a `li` holding `"a"` and a `li` holding `[p, ul]` cannot use the + * same container. Markdown mixes the two freely — a task-list item is an `input` + * span followed by bare text, a loose list item is a paragraph plus a nested + * list — so containers group rather than pick one shape. + * + * Comark hands components rendered React children with no trace of their source + * flow, hence the paired walk over the original node's children. + */ +/** A newline plus the indentation around it, inside a run of inline text. */ +const SOFT_BREAK = /\s*\n\s*/g + +/** Host elements OpenTUI only accepts inside a text node. */ +const INLINE_HOSTS = new Set(['span', 'b', 'strong', 'i', 'em', 'u', 'a', 'br']) + +function isInlineChild(child: React.ReactNode): boolean { + if (typeof child === 'string' || typeof child === 'number') { + return true + } + + return React.isValidElement(child) && typeof child.type === 'string' && INLINE_HOSTS.has(child.type) +} + +/** + * Group inline children into text hosts without consulting the source AST, + * deciding from the rendered elements alone. + * + * Needed where there is no node to pair against — the document root. Comark + * appends the streaming caret to the last top-level node holding a string, and + * when that node has none (an `hr`, or the `---` of frontmatter part-way through + * a stream) it pushes the caret as a top-level node instead. That is a bare + * `span`, which throws if it lands straight in a box. + */ +export function groupInlineRuns(children: React.ReactNode): React.ReactNode[] { + const rendered = React.Children.toArray(children) + const groups: React.ReactNode[] = [] + let run: React.ReactNode[] = [] + + const flushRun = () => { + if (run.length > 0) { + groups.push({reflowInline(run)}) + run = [] + } + } + + for (const child of rendered) { + if (isInlineChild(child)) { + run.push(child) + continue + } + + flushRun() + groups.push(child) + } + + flushRun() + + return groups +} + +/** + * Collapse soft line breaks so the terminal can reflow inline text to its own + * width. + * + * Markdown treats a single newline inside a paragraph as a space, but OpenTUI's + * text renderer honours `\n` as a hard break — so source wrapped at 80 columns + * would stay wrapped at 80 in a 200-column terminal. Genuine hard breaks are + * `br` nodes and pass through untouched, as does fenced code, which never + * reaches here. + * + * Recurses into elements because the newline can fall inside an emphasis run. + */ +export function reflowInline(children: React.ReactNode): React.ReactNode { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + return child.replace(SOFT_BREAK, ' ') + } + + if (!React.isValidElement(child)) { + return child + } + + const nested = (child.props as { children?: React.ReactNode }).children + + if (nested === undefined) { + return child + } + + return React.cloneElement(child, undefined, reflowInline(nested)) + }) +} + +export function groupChildrenByFlow(node: ElementNode | undefined, children: React.ReactNode): React.ReactNode[] { + const rendered = React.Children.toArray(children) + const nodes = childNodes(node) + + // Pairing only holds when the walker emitted one React child per surviving + // source child. If anything shifted — a plugin injecting nodes, a shape this + // renderer has not met — treat the lot as inline: a flattened paragraph reads + // worse than proper blocks but still renders, where a mispaired block child + // would throw inside the reconciler. + if (nodes.length !== rendered.length) { + return rendered.length > 0 ? [{reflowInline(rendered)}] : [] + } + + const groups: React.ReactNode[] = [] + let run: React.ReactNode[] = [] + + const flushRun = () => { + if (run.length > 0) { + groups.push({reflowInline(run)}) + run = [] + } + } + + nodes.forEach((child, index) => { + if (isBlockNode(child)) { + flushRun() + groups.push(rendered[index]) + return + } + + run.push(rendered[index]) + }) + + flushRun() + + return groups +} diff --git a/packages/comark-opentui/src/components/index.ts b/packages/comark-opentui/src/components/index.ts new file mode 100644 index 00000000..fa3978f3 --- /dev/null +++ b/packages/comark-opentui/src/components/index.ts @@ -0,0 +1,99 @@ +import type React from 'react' +import { + Alert, + Blockquote, + Checkbox, + Heading, + Image, + ListItem, + OrderedList, + Paragraph, + Rule, + UnknownTag, + UnorderedList, +} from './block.tsx' +import { InlineCode, Strikethrough } from './inline.tsx' +import { Math } from './math.tsx' +import { CodeBlock } from './pre.tsx' +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './table.tsx' + +export * from './block.tsx' +export * from './inline.tsx' +export * from './math.tsx' +export * from './pre.tsx' +export * from './table.tsx' +export { groupChildrenByFlow } from './flow.tsx' + +/** + * Tag → renderable map for OpenTUI. + * + * `strong`, `em`, `b`, `i`, `u`, `a`, `br` and `span` are deliberately absent: + * OpenTUI registers all of them as native text-node hosts, so Comark's walker + * falling through to the tag name already produces the right renderable — + * including OSC 8 hyperlinks for `a`. + * + * Everything else has to be here. An unmapped tag reaches + * `createInstance` and throws `Unknown component type`, and two of the + * collisions are silent traps: native `code` is a block-level highlighted panel + * rather than an inline chip, and native `input` is an interactive field rather + * than a task-list checkbox. + */ +export const components: Record> = { + p: Paragraph, + h1: Heading, + h2: Heading, + h3: Heading, + h4: Heading, + h5: Heading, + h6: Heading, + blockquote: Blockquote, + // GitHub alerts. Comark emits these as a blockquote carrying `as: ""`, + // and its walker resolves components from `as`, so they must be registered + // under the kind names rather than under `blockquote`. + note: Alert, + tip: Alert, + important: Alert, + warning: Alert, + caution: Alert, + math: Math, + hr: Rule, + img: Image, + input: Checkbox, + ul: UnorderedList, + ol: OrderedList, + li: ListItem, + pre: CodeBlock, + code: InlineCode, + del: Strikethrough, + s: Strikethrough, + table: Table, + thead: TableHead, + tbody: TableBody, + tr: TableRow, + th: TableHeaderCell, + td: TableCell, +} + +/** + * Tags OpenTUI already registers as native text-node hosts. The manifest has to + * decline these: Comark consults it for anything absent from + * {@link components}, so answering every tag would shadow the native + * renderables and flatten bold, italic and links into plain spans. + */ +export const NATIVE_TAGS = new Set(['strong', 'em', 'b', 'i', 'u', 'a', 'br', 'span']) + +/** + * Resolver for every tag outside {@link components} and {@link NATIVE_TAGS} — + * raw HTML nodes from the html plugin, unregistered `::components`, tags a + * future Comark version adds. Without it those reach the reconciler and throw, + * so a terminal fed by a model could be taken down by a stray `
`. + * + * Returning `undefined` is what lets a tag fall through to its native host. + */ +export function componentsManifest(name: string): React.ComponentType | undefined { + if (NATIVE_TAGS.has(name)) { + return undefined + } + + return UnknownTag +} diff --git a/packages/comark-opentui/src/components/inline.tsx b/packages/comark-opentui/src/components/inline.tsx new file mode 100644 index 00000000..db1c0881 --- /dev/null +++ b/packages/comark-opentui/src/components/inline.tsx @@ -0,0 +1,31 @@ +/** @jsxImportSource @opentui/react */ +import { TextAttributes } from '@opentui/core' +import type React from 'react' +import { useMarkdownTheme } from '../theme.ts' + +interface ChildrenProps { + children?: React.ReactNode +} + +/** + * Inline `code`. Mapped explicitly because OpenTUI's native `code` host is a + * `CodeRenderable` — a block-level, syntax-highlighted panel. Leaving this + * unmapped puts one of those inside a paragraph's text node. + */ +export const InlineCode: React.FC = ({ children }) => { + const theme = useMarkdownTheme() + + return ( + + {children} + + ) +} + +/** `~~struck~~`. OpenTUI has no native host for it, unlike bold and italic. */ +export const Strikethrough: React.FC = ({ children }) => { + return {children} +} diff --git a/packages/comark-opentui/src/components/math.tsx b/packages/comark-opentui/src/components/math.tsx new file mode 100644 index 00000000..96260052 --- /dev/null +++ b/packages/comark-opentui/src/components/math.tsx @@ -0,0 +1,36 @@ +/** @jsxImportSource @opentui/react */ +import type { ElementNode } from 'comark' +import type React from 'react' +import { useMarkdownTheme } from '../theme.ts' +import { withNode } from '../utils.ts' + +interface MathProps { + children?: React.ReactNode + __node?: ElementNode +} + +/** + * TeX from the math plugin, shown as its source. + * + * A terminal cannot typeset it, and the expression is what a reader can copy or + * act on. Mapping it is not only cosmetic: the node carries no `$.block` meta, + * so the generic fallback would treat inline math as block and put a box inside + * a paragraph's text node. Inline versus block comes off the plugin's + * `class="math inline"` / `"math block"`. + */ +export const Math = withNode(({ children, __node }) => { + const theme = useMarkdownTheme() + const attrs = __node?.[1] + const source = typeof attrs?.content === 'string' ? attrs.content : children + const isBlock = String(attrs?.class ?? '').includes('block') + + if (isBlock) { + return ( + + {source} + + ) + } + + return {source} +}) diff --git a/packages/comark-opentui/src/components/pre.tsx b/packages/comark-opentui/src/components/pre.tsx new file mode 100644 index 00000000..2b485722 --- /dev/null +++ b/packages/comark-opentui/src/components/pre.tsx @@ -0,0 +1,176 @@ +/** @jsxImportSource @opentui/react */ +import { TextAttributes } from '@opentui/core' +import type { ElementNode, Node } from 'comark' +import { textContent } from 'comark/utils' +import { resolveSyntaxStyle, useMarkdownTheme } from '../theme.ts' +import { isElementNode, withNode } from '../utils.ts' + +/** + * Fence info string, from either the `pre`'s own `language` attribute or the + * `language-*` class Comark puts on the inner `code` node. + */ +export function fenceLanguage(node: ElementNode | undefined): string | undefined { + if (!node) { + return undefined + } + + const declared = node[1]?.language + + if (typeof declared === 'string' && declared.length > 0) { + return declared + } + + const code = node[2] + + if (!Array.isArray(code)) { + return undefined + } + + const className = code[1]?.class + + if (typeof className !== 'string') { + return undefined + } + + return className + .split(' ') + .find((entry) => entry.startsWith('language-')) + ?.slice('language-'.length) +} + +interface Token { + text: string + fg?: string +} + +/** + * Colour out of a Shiki inline style. + * + * Shiki emits `color:` for its light theme and `--shiki-dark:` for its dark one. + * Terminals are dark far more often than not, so the dark variant wins when both + * are present. + */ +function tokenColor(style: unknown): string | undefined { + if (typeof style !== 'string') { + return undefined + } + + const dark = /--shiki-dark:\s*(#[0-9a-fA-F]{3,8})/.exec(style) + + if (dark) { + return dark[1] + } + + return /(?:^|;)\s*color:\s*(#[0-9a-fA-F]{3,8})/.exec(style)?.[1] +} + +/** Flatten one Shiki line span into its coloured leaves. */ +function lineTokens(node: ElementNode, inherited?: string, into: Token[] = []): Token[] { + const fg = tokenColor(node[1]?.style) ?? inherited + + for (const child of node.slice(2) as Node[]) { + if (typeof child === 'string') { + into.push({ text: child.replace(/\n/g, ''), fg }) + continue + } + + if (isElementNode(child)) { + lineTokens(child, fg, into) + } + } + + return into +} + +/** + * Per-line tokens when the Shiki plugin has highlighted this fence, else null. + * + * Shiki rewrites the `code` node into one `span.line` per line, each holding + * coloured token spans. Reusing them means code is highlighted with the theme the + * host chose, for every language Shiki knows, with no grammar to install. + */ +export function shikiTokens(node: ElementNode | undefined): Token[][] | null { + const code = node?.[2] + + if (!Array.isArray(code)) { + return null + } + + const lines: Token[][] = [] + + for (const child of code.slice(2) as Node[]) { + if (isElementNode(child) && child[0] === 'span') { + lines.push(lineTokens(child)) + } + } + + return lines.length > 0 ? lines : null +} + +/** + * Fenced code block. + * + * Two highlighting paths, because they need very different things from the host: + * + * - Shiki tokens, when the plugin is registered. Colours come from the AST, so + * nothing has to be installed and every Shiki language works. + * - otherwise OpenTUI's `CodeRenderable`, which highlights with tree-sitter. + * That is the faster, incremental path, but it only produces colour for + * languages whose grammar the host registered via `addDefaultParsers` — + * OpenTUI ships none — and it needs a populated `theme.syntaxStyle`. + * + * The body is read off the source node rather than from `children` so either + * shape flattens back to the original source. + */ +export const CodeBlock = withNode<{ __node?: ElementNode }>(({ __node }) => { + const theme = useMarkdownTheme() + const language = fenceLanguage(__node) + const filename = typeof __node?.[1]?.filename === 'string' ? __node[1].filename : undefined + const tokens = shikiTokens(__node) + + const header = + language || filename ? ( + + {language ? ( + + {language} + + ) : null} + {filename ? {language ? ` ${filename}` : filename} : null} + + ) : null + + if (tokens) { + return ( + + {header} + {tokens.map((line, index) => ( + + {line.map((token, position) => ( + + {token.text} + + ))} + + ))} + + ) + } + + return ( + + {header} + + + ) +}) diff --git a/packages/comark-opentui/src/components/table.tsx b/packages/comark-opentui/src/components/table.tsx new file mode 100644 index 00000000..d1c44407 --- /dev/null +++ b/packages/comark-opentui/src/components/table.tsx @@ -0,0 +1,156 @@ +/** @jsxImportSource @opentui/react */ +import { TextAttributes } from '@opentui/core' +import type { ElementNode, Node } from 'comark' +import { textContent } from 'comark/utils' +import React, { createContext, useContext, useMemo } from 'react' +import { useMarkdownTheme } from '../theme.ts' +import { isElementNode, withNode } from '../utils.ts' +import { reflowInline } from './flow.tsx' + +/** Cell padding, and the floor a column can shrink to. */ +const CELL_GUTTER = 1 +const MIN_COLUMN_WIDTH = 3 + +/** + * Ceiling on a measured column so one long cell cannot push the table past a + * sane terminal width. Cells above it wrap inside their column. + */ +const MAX_COLUMN_WIDTH = 40 + +const ColumnWidthsContext = createContext([]) +const CellIndexContext = createContext(0) + +function rowsOf(node: ElementNode | undefined): ElementNode[] { + if (!node) { + return [] + } + + const rows: ElementNode[] = [] + + const walk = (children: Node[]) => { + for (const child of children) { + if (!isElementNode(child)) { + continue + } + + if (child[0] === 'tr') { + rows.push(child) + continue + } + + walk(child.slice(2) as Node[]) + } + } + + walk(node.slice(2) as Node[]) + + return rows +} + +/** + * Measure every column across every row. + * + * Column alignment cannot be done by a cell on its own — it needs the widest + * cell in its column, which lives in sibling rows. The table is the only node + * that sees them all, so it measures once from the source AST and passes the + * result down; `td` / `th` just claim their slot. + */ +export function measureColumns(node: ElementNode | undefined): number[] { + const widths: number[] = [] + + for (const row of rowsOf(node)) { + const cells = (row.slice(2) as Node[]).filter(isElementNode) + + cells.forEach((cell, index) => { + const width = Math.min(textContent(cell).trim().length + CELL_GUTTER, MAX_COLUMN_WIDTH) + + widths[index] = Math.max(widths[index] ?? MIN_COLUMN_WIDTH, width) + }) + } + + return widths +} + +interface NodeProps { + children?: React.ReactNode + __node?: ElementNode +} + +export const Table = withNode(({ children, __node }) => { + const widths = useMemo(() => measureColumns(__node), [__node]) + + return ( + + {children} + + ) +}) + +/** Header group, with the rule that separates it from the body. */ +export const TableHead: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const widths = useContext(ColumnWidthsContext) + const theme = useMarkdownTheme() + + return ( + + {children} + + {widths.map((width, index) => ( + + {'─'.repeat(Math.max(width - CELL_GUTTER, 1))} + + ))} + + + ) +} + +export const TableBody: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return {children} +} + +/** + * Row. Each cell is told its column index here — a cell cannot work out its own + * position, and the index is what maps it to a measured width. + */ +export const TableRow: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + let column = 0 + + const cells = React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return null + } + + return {child} + }) + + return {cells} +} + +function Cell({ children, header }: { children?: React.ReactNode; header: boolean }) { + const widths = useContext(ColumnWidthsContext) + const index = useContext(CellIndexContext) + + return ( + + {reflowInline(children)} + + ) +} + +export const TableHeaderCell: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} +) + +export const TableCell: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} +) diff --git a/packages/comark-opentui/src/index.tsx b/packages/comark-opentui/src/index.tsx new file mode 100644 index 00000000..6fad2015 --- /dev/null +++ b/packages/comark-opentui/src/index.tsx @@ -0,0 +1,191 @@ +/** @jsxImportSource @opentui/react */ +import { MarkdownDocument as ComarkDocument } from '@comark/react' +import { parseMarkdown, type MarkdownDocument as MarkdownDocumentType, type ParserOptions } from 'comark' +import { isMarkdownDocument } from 'comark/utils' +import React, { useEffect, useMemo, useState } from 'react' +import { groupInlineRuns } from './components/flow.tsx' +import { components, componentsManifest } from './components/index.ts' +import { defaultTheme, MarkdownThemeProvider, type MarkdownTheme } from './theme.ts' + +export * from './components/index.ts' +export * from './theme.ts' +export { BLOCK_TAGS, childNodes, contentNode, isBlockNode, isElementNode, withNode } from './utils.ts' +export type * from 'comark' + +/** + * Top-level container. Comark's React renderer wraps output in a `div`, which + * OpenTUI's reconciler rejects outright — `wrapper` is what lets a non-DOM host + * substitute its own root. The one-row gap stands in for the blank line between + * markdown blocks. + * + * Children are grouped rather than placed directly: the document root can hold + * an inline node, because a streaming caret with nowhere to attach is pushed + * there as a bare `span`, and a bare span in a box throws. + */ +const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( + + {groupInlineRuns(children)} + + ) +} + +export interface MarkdownProps { + /** Markdown source. Equivalent to passing `value` a string. */ + children?: string + + /** Markdown source, or a document already parsed by `parseMarkdown`. */ + value?: string | MarkdownDocumentType + + /** Parser options (excluding plugins). */ + options?: Exclude + + /** Additional parser plugins. */ + plugins?: ParserOptions['plugins'] + + /** + * Component overrides, merged over the OpenTUI defaults. Use this both for + * `::components` and to restyle a built-in tag. + */ + components?: Record> + + /** + * Re-parse as the source grows and auto-close unterminated constructs, so a + * half-streamed `**bold` renders bold instead of flashing its asterisks. + */ + streaming?: boolean + + /** Append a caret to the last text node — a typing cursor for streamed output. */ + caret?: boolean | { class: string } + + /** Runtime data addressed from markdown via `:`-prefixed props. */ + data?: Record + + /** Colour and glyph overrides, merged over {@link defaultTheme}. */ + theme?: Partial +} + +/** + * Render markdown as an OpenTUI layout tree. + * + * @example + * ```tsx + * import { Markdown } from '@comark/opentui' + * + * + * {content} + * + * ``` + */ +/** + * Parse markdown source, holding on to the previous document while a new parse + * is in flight so a growing stream keeps its last good frame instead of + * blanking between deltas. + * + * Comark's own `MarkdownClient` does this with `use()` behind Suspense. That + * path does not commit under OpenTUI's reconciler — a suspended subtree stays + * hidden after its promise resolves — and an effect works in any host, so this + * renderer parses here instead. + * + * Only `source` is tracked: `options` and `plugins` are expected to be stable + * references, matching `MarkdownClient`. + */ +function useParsedMarkdown( + source: string, + options: MarkdownProps['options'], + plugins: MarkdownProps['plugins'] +): MarkdownDocumentType | null { + const [parsed, setParsed] = useState<{ source: string; document: MarkdownDocumentType } | null>(null) + + useEffect(() => { + let active = true + + void Promise.resolve(parseMarkdown(source, { ...options, plugins })).then((document) => { + if (active) { + setParsed({ source, document: document as MarkdownDocumentType }) + } + }) + + return () => { + active = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [source]) + + /* + * Keep showing the last document only while the stream is still growing, which + * is what stops a delta from blanking the screen mid-parse. Any other change — + * a reset back to the start of a replay, a different document entirely — has to + * drop it, or the old content stays on screen for a frame or two before the new + * parse lands and reads as content flashing in twice. + */ + if (!parsed || !source.startsWith(parsed.source)) { + return null + } + + return parsed.document +} + +export function Markdown({ children, value, options, plugins, components: overrides, theme, ...rest }: MarkdownProps) { + const mergedComponents = useMemo(() => (overrides ? { ...components, ...overrides } : components), [overrides]) + + const mergedTheme = useMemo(() => (theme ? { ...defaultTheme, ...theme } : defaultTheme), [theme]) + + const source = children ?? value ?? '' + const isDocument = isMarkdownDocument(source) + const parsed = useParsedMarkdown(isDocument ? '' : (source as string), options, plugins) + const document = isDocument ? (source as MarkdownDocumentType) : parsed + + // Nothing to paint until the first parse resolves. + if (!document) { + return null + } + + /* + * Built with `createElement` rather than JSX: React 19 types a + * `FunctionComponent` as returning `ReactNode | Promise` to allow + * async components, while OpenTUI declares `JSX.Element = React.ReactNode`, + * which does not accept the nested promise. Both are fine at runtime, and + * `createElement` skips the JSX-element check without a cast. + */ + return ( + + {React.createElement(ComarkDocument, { + value: document, + components: mergedComponents, + componentsManifest, + wrapper: Wrapper, + ...rest, + })} + + ) +} + +export interface MarkdownDocumentProps extends Omit { + value?: MarkdownDocumentType | { nodes: MarkdownDocumentType['nodes'] } +} + +/** + * Render an already-parsed document — for hosts that parse elsewhere (a worker, + * the main process of an Electron app, a build step) and only want the render. + */ +export function MarkdownDocument({ components: overrides, theme, ...rest }: MarkdownDocumentProps) { + const mergedComponents = useMemo(() => (overrides ? { ...components, ...overrides } : components), [overrides]) + + const mergedTheme = useMemo(() => (theme ? { ...defaultTheme, ...theme } : defaultTheme), [theme]) + + // See the note in `Markdown` on why this is not JSX. + return ( + + {React.createElement(ComarkDocument, { + components: mergedComponents, + componentsManifest, + wrapper: Wrapper, + ...rest, + })} + + ) +} diff --git a/packages/comark-opentui/src/parse.ts b/packages/comark-opentui/src/parse.ts new file mode 100644 index 00000000..b75726ce --- /dev/null +++ b/packages/comark-opentui/src/parse.ts @@ -0,0 +1 @@ +export * from 'comark/parse' diff --git a/packages/comark-opentui/src/plugins/math.ts b/packages/comark-opentui/src/plugins/math.ts new file mode 100644 index 00000000..f71c4873 --- /dev/null +++ b/packages/comark-opentui/src/plugins/math.ts @@ -0,0 +1,4 @@ +export * from 'comark/plugins/math' +export { default } from 'comark/plugins/math' + +export { Math } from '../components/math.tsx' diff --git a/packages/comark-opentui/src/render.ts b/packages/comark-opentui/src/render.ts new file mode 100644 index 00000000..069c1f08 --- /dev/null +++ b/packages/comark-opentui/src/render.ts @@ -0,0 +1 @@ +export * from 'comark/render' diff --git a/packages/comark-opentui/src/theme.ts b/packages/comark-opentui/src/theme.ts new file mode 100644 index 00000000..d9d0c514 --- /dev/null +++ b/packages/comark-opentui/src/theme.ts @@ -0,0 +1,135 @@ +import { SyntaxStyle } from '@opentui/core' +import { createContext, useContext } from 'react' + +/** + * Colours and glyphs the renderer paints with. Every field is a plain value so + * a host application can drive it from its own theme without importing + * OpenTUI's colour helpers. + */ +export interface MarkdownTheme { + /** Secondary colour — image placeholders, blockquote body, table rules. */ + muted: string + /** Heading colour per level, index 0 being `h1`. Deeper levels clamp to the last entry. */ + heading: string[] + /** Inline `code` chip foreground. */ + codeFg: string + /** Inline `code` chip background. */ + codeBg: string + /** Left bar drawn down a blockquote. */ + quoteBorder: string + /** `hr` rule colour. */ + rule: string + /** List marker colour (bullet and ordinal). */ + marker: string + /** Bullet glyph for unordered lists. */ + bullet: string + /** Table grid colour. */ + tableBorder: string + + /** + * Colour per GitHub alert kind, keyed by the lowercased `[!NOTE]` label. + * Unknown kinds fall back to {@link MarkdownTheme.quoteBorder}. + */ + alert: Record + /** + * Style table handed to the `CodeRenderable` behind fenced code blocks. + * + * Left unset, {@link resolveSyntaxStyle} lazily creates one via + * `SyntaxStyle.create()` on first paint — by which point a renderer exists. + * Pass your own to make fenced code follow the host application's theme. + */ + syntaxStyle?: SyntaxStyle +} + +/** Neutral defaults tuned for a dark terminal. */ +export const defaultTheme: MarkdownTheme = { + muted: '#8b949e', + heading: ['#f0f6fc', '#e6edf3', '#c9d1d9', '#b1bac4', '#8b949e', '#8b949e'], + codeFg: '#79c0ff', + codeBg: '#161b22', + quoteBorder: '#484f58', + rule: '#30363d', + marker: '#8b949e', + bullet: '•', + tableBorder: '#30363d', + alert: { + note: '#58a6ff', + tip: '#3fb950', + important: '#a371f7', + warning: '#d29922', + caution: '#f85149', + }, +} + +const MarkdownThemeContext = createContext(defaultTheme) + +export const MarkdownThemeProvider = MarkdownThemeContext.Provider + +export function useMarkdownTheme(): MarkdownTheme { + return useContext(MarkdownThemeContext) +} + +/** + * Tree-sitter capture styles for the fallback highlighter, in a dark palette. + * + * `SyntaxStyle.create()` builds an *empty* table, which resolves every capture to + * no style and paints code in one flat colour — so a real map is the difference + * between highlighted and not. Keys are the standard capture names; unmatched + * captures fall back to `default`. + */ +const DEFAULT_SYNTAX_STYLES: Record = { + default: { fg: '#c9d1d9' }, + keyword: { fg: '#ff7b72' }, + 'keyword.import': { fg: '#ff7b72' }, + 'keyword.function': { fg: '#ff7b72' }, + 'keyword.return': { fg: '#ff7b72' }, + 'keyword.operator': { fg: '#ff7b72' }, + string: { fg: '#a5d6ff' }, + 'string.escape': { fg: '#79c0ff' }, + 'string.special': { fg: '#a5d6ff' }, + character: { fg: '#a5d6ff' }, + comment: { fg: '#8b949e', italic: true }, + number: { fg: '#79c0ff' }, + boolean: { fg: '#79c0ff' }, + constant: { fg: '#79c0ff' }, + 'constant.builtin': { fg: '#79c0ff' }, + function: { fg: '#d2a8ff' }, + 'function.call': { fg: '#d2a8ff' }, + 'function.method': { fg: '#d2a8ff' }, + 'function.builtin': { fg: '#d2a8ff' }, + type: { fg: '#ffa657' }, + 'type.builtin': { fg: '#ffa657' }, + constructor: { fg: '#ffa657' }, + variable: { fg: '#c9d1d9' }, + 'variable.parameter': { fg: '#ffa657' }, + 'variable.builtin': { fg: '#ffa657' }, + property: { fg: '#79c0ff' }, + label: { fg: '#79c0ff' }, + operator: { fg: '#ff7b72' }, + punctuation: { fg: '#8b949e' }, + 'punctuation.bracket': { fg: '#8b949e' }, + 'punctuation.delimiter': { fg: '#8b949e' }, + 'punctuation.special': { fg: '#8b949e' }, + tag: { fg: '#7ee787' }, + 'tag.attribute': { fg: '#79c0ff' }, + attribute: { fg: '#79c0ff' }, +} + +/** + * Lazily created stand-in for a caller-supplied `syntaxStyle`. + * + * `CodeOptions.syntaxStyle` is required and `SyntaxStyle.fromStyles` reaches into + * the native render lib, so it cannot run at module load — only once a renderer + * is up. Cached because every fenced code block would otherwise allocate one. + */ +let fallbackSyntaxStyle: SyntaxStyle | undefined + +export function resolveSyntaxStyle(theme: MarkdownTheme): SyntaxStyle { + if (theme.syntaxStyle) { + return theme.syntaxStyle + } + + fallbackSyntaxStyle ??= SyntaxStyle.fromStyles(DEFAULT_SYNTAX_STYLES) + + return fallbackSyntaxStyle +} diff --git a/packages/comark-opentui/src/utils.ts b/packages/comark-opentui/src/utils.ts new file mode 100644 index 00000000..9bec889e --- /dev/null +++ b/packages/comark-opentui/src/utils.ts @@ -0,0 +1,107 @@ +export * from 'comark/utils' + +import type { ElementNode, Node } from 'comark' +import type React from 'react' + +/** + * Tags this renderer paints as their own box-level renderable. Anything else is + * inline and has to live inside a `text` — OpenTUI's reconciler throws + * `Text must be created inside of a text node` otherwise, so the distinction is + * load-bearing rather than cosmetic. + */ +export const BLOCK_TAGS = new Set([ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'pre', + 'blockquote', + 'table', + 'hr', +]) + +export function isElementNode(node: Node): node is ElementNode { + return Array.isArray(node) && typeof node[0] === 'string' +} + +export function isBlockNode(node: Node): boolean { + return isElementNode(node) && BLOCK_TAGS.has(node[0]) +} + +/** + * Children that never reach a custom component's `children`: + * + * - comment nodes carry a `null` tag and Comark's walker drops them; + * - `template` children are pulled out as named slots before children are + * assembled. + * + * They have to be skipped when lining rendered children back up against the + * source node — see {@link groupChildrenByFlow}. + */ +export function rendersNothing(node: Node): boolean { + if (typeof node === 'string') { + return false + } + + return node[0] === null || node[0] === 'template' +} + +/** + * Tags whose children this renderer resolves through {@link BLOCK_TAGS}. + * + * Comark hands a component only its rendered React children, which carry no + * indication of whether they came out inline or block. Recovering that needs the + * source node, which is why the components asking for this set + * {@link withNode}. + */ +export function childNodes(node: ElementNode | undefined): Node[] { + if (!node) { + return [] + } + + return (node.slice(2) as Node[]).filter((child) => !rendersNothing(child)) +} + +/** + * The node whose children line up with what a component received as `children`. + * + * For a component written with named slots, Comark passes the `#default` + * template's children as `children` while the node's own children are the + * `template` elements — so pairing against the node itself would misalign. This + * resolves to the default template when one is present, and is a no-op + * otherwise. + */ +export function contentNode(node: ElementNode | undefined): ElementNode | undefined { + if (!node) { + return undefined + } + + const templates = (node.slice(2) as Node[]).filter( + (child): child is ElementNode => isElementNode(child) && child[0] === 'template' + ) + + if (templates.length === 0) { + return node + } + + return templates.find((template) => (template[1]?.name ?? 'default') === 'default') ?? node +} + +/** + * Marks a component as wanting the raw Comark node on a `__node` prop. + * + * Comark's React walker only passes it when `propTypes.__node` is defined + * (`MarkdownDocument.tsx`), so this is the documented opt-in rather than a + * private hook. React 19 no longer validates `propTypes`, so the object is + * inert beyond acting as that flag. + */ +export function withNode

(component: React.FC

): React.FC

{ + ;(component as { propTypes?: unknown }).propTypes = { __node: null } + + return component +} diff --git a/packages/comark-opentui/test/paint/render.test.tsx b/packages/comark-opentui/test/paint/render.test.tsx new file mode 100644 index 00000000..cd77ad84 --- /dev/null +++ b/packages/comark-opentui/test/paint/render.test.tsx @@ -0,0 +1,629 @@ +/** @jsxImportSource @opentui/react */ +import type { CapturedFrame } from '@opentui/core' +import { testRender } from '@opentui/react/test-utils' +import type { ElementNode } from 'comark' +import math from 'comark/plugins/math' +import shiki from 'comark/plugins/shiki' +import python from 'shiki/dist/langs/python.mjs' +import { createRequire } from 'node:module' +import React, { act } from 'react' +import { describe, expect, it } from 'vitest' +import { Markdown, Prose } from '../../src/index.tsx' + +/** + * OpenTUI paints through native FFI, which Node only exposes from 26.1 behind + * `--experimental-ffi`. Run these with `pnpm test:paint`; on an older Node — as + * on any CI pinned below 26 — they skip rather than fail, and the runtime- + * agnostic half of the suite still covers tag resolution and layout logic. + */ +const FFI_AVAILABLE = (() => { + try { + createRequire(import.meta.url)('node:ffi') + return true + } catch { + return false + } +})() + +const paint = describe.skipIf(!FFI_AVAILABLE) + +/** + * Paint markdown into a headless terminal and return the resulting character + * grid. + * + * Each pump is wrapped in `act` because parsing runs through `use()` behind + * Suspense: `testRender` only wraps the initial mount, which commits the + * fallback, so the resolved parse needs a further flush before anything reaches + * the screen. Repeating then gives fenced code time to finish highlighting, + * which is asynchronous. + */ +async function frameFor( + source: string, + options: { + width?: number + frames?: number + components?: Record + plugins?: any[] + streaming?: boolean + caret?: boolean + } = {} +) { + const ui = await testRender( + + {source} + , + { + width: options.width ?? 60, + height: 24, + } + ) + + const frames = options.frames ?? 8 + + for (let i = 0; i < frames; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + }) + + await ui.renderOnce() + } + + return ui.captureCharFrame() as string +} + +paint('inline flow', () => { + it('renders emphasis, strikethrough, inline code and links as text', async () => { + const frame = await frameFor('plain **bold** _italic_ ~~struck~~ `code` [link](https://example.com)') + + expect(frame).toContain('plain') + expect(frame).toContain('bold') + expect(frame).toContain('italic') + expect(frame).toContain('struck') + expect(frame).toContain('code') + expect(frame).toContain('link') + }) + + it('renders headings without markup', async () => { + const frame = await frameFor('# Title\n\n## Subtitle') + + expect(frame).toContain('Title') + expect(frame).toContain('Subtitle') + expect(frame).not.toContain('#') + }) + + it('degrades images to alt text', async () => { + const frame = await frameFor('![a diagram](diagram.png)') + + expect(frame).toContain('[a diagram]') + }) +}) + +paint('lists', () => { + it('renders a tight list with bullets', async () => { + const frame = await frameFor('- first\n- second') + + expect(frame).toContain('• first') + expect(frame).toContain('• second') + }) + + it('numbers an ordered list', async () => { + const frame = await frameFor('1. one\n2. two') + + expect(frame).toContain('1. one') + expect(frame).toContain('2. two') + }) + + it('honours an ordered list start offset', async () => { + const frame = await frameFor('4. four\n5. five') + + expect(frame).toContain('4. four') + expect(frame).toContain('5. five') + }) + + it('renders a nested list', async () => { + const frame = await frameFor('- outer\n - inner') + + expect(frame).toContain('outer') + expect(frame).toContain('inner') + }) + + /** + * Comark emits GFM task items as an `input` node next to bare text. OpenTUI's + * native `input` is an interactive field, so an unmapped tag would mount a + * focusable widget mid-sentence instead of a checkbox. + */ + it('renders task list checkboxes', async () => { + const frame = await frameFor('- [x] done\n- [ ] todo') + + expect(frame).toContain('[x] done') + expect(frame).toContain('[ ] todo') + }) +}) + +paint('blocks', () => { + it('renders fenced code with its body', async () => { + const frame = await frameFor('```ts\nconst answer = 42\n```') + + expect(frame).toContain('const answer = 42') + expect(frame).not.toContain('```') + }) + + it('renders unlabelled fences', async () => { + const frame = await frameFor('```\nplain text\n```') + + expect(frame).toContain('plain text') + }) + + it('renders a blockquote body', async () => { + const frame = await frameFor('> quoted **text**') + + expect(frame).toContain('quoted') + expect(frame).toContain('text') + }) + + it('renders a horizontal rule between paragraphs', async () => { + const frame = await frameFor('above\n\n---\n\nbelow') + + expect(frame).toContain('above') + expect(frame).toContain('below') + expect(frame).toContain('─') + }) + + it('renders a fence nested in a list item', async () => { + const frame = await frameFor('- run this:\n\n ```bash\n ls -la\n ```') + + expect(frame).toContain('run this:') + expect(frame).toContain('ls -la') + }) +}) + +paint('tables', () => { + it('renders header and body cells', async () => { + const frame = await frameFor('| name | size |\n| --- | --- |\n| alpha | 10 |\n| beta | 200 |') + + expect(frame).toContain('name') + expect(frame).toContain('size') + expect(frame).toContain('alpha') + expect(frame).toContain('200') + }) + + it('aligns a column to its widest cell', async () => { + const frame = await frameFor('| a | b |\n| --- | --- |\n| wiiiiiiiide | x |\n| s | y |') + const rows = frame.split('\n') + const wide = rows.find((row) => row.includes('wiiiiiiiide')) + const narrow = rows.find((row) => row.trimEnd().startsWith('s ')) + + expect(wide).toBeDefined() + expect(narrow).toBeDefined() + expect(narrow!.indexOf('y')).toBe(wide!.indexOf('x')) + }) +}) + +/** + * Model output is not trusted markdown. Comark's html plugin is on by default, + * so a stray tag becomes a node with that tag name — which reaches OpenTUI's + * reconciler and throws `Unknown component type` unless the renderer resolves + * it. These cover the crash, not the styling. + */ +paint('untrusted input', () => { + it('renders a raw HTML block without throwing', async () => { + const frame = await frameFor('

inside a div
') + + expect(frame).toContain('inside a div') + }) + + it('renders raw inline HTML without throwing', async () => { + const frame = await frameFor('before marked after') + + expect(frame).toContain('before') + expect(frame).toContain('marked') + expect(frame).toContain('after') + }) + + it('renders an unregistered component block without throwing', async () => { + const frame = await frameFor('::alert\nheads up\n::') + + expect(frame).toContain('heads up') + }) +}) + +/** + * The reason to reach for Comark in a streaming UI: the parser closes dangling + * constructs, so a half-arrived `**bold` renders as bold text rather than + * flashing its asterisks until the closer lands. + */ +paint('streaming', () => { + it('auto-closes an unterminated emphasis run', async () => { + const frame = await frameFor('here is **bo') + + expect(frame).toContain('here is') + expect(frame).toContain('bo') + expect(frame).not.toContain('**') + }) + + it('auto-closes an unterminated fence', async () => { + const frame = await frameFor('```ts\nconst partial =') + + expect(frame).toContain('const partial =') + expect(frame).not.toContain('```') + }) +}) + +/** + * A component's body arrives in two shapes: `autoUnwrap` strips the paragraph + * from a single-paragraph body, handing over bare strings, while a + * multi-paragraph body hands over `p` elements. A host component that drops + * `children` straight into a box throws on the first shape, which is why + * `Prose` exists. + */ +paint('custom components', () => { + function Card({ children, __node }: { children?: React.ReactNode; __node?: ElementNode }) { + return ( + + {children} + + ) + } + Card.propTypes = { __node: null } + + it('renders an auto-unwrapped single-paragraph body', async () => { + const frame = await frameFor('::card\none line with **bold**\n::', { components: { card: Card } }) + + expect(frame).toContain('one line with') + expect(frame).toContain('bold') + }) + + it('renders a multi-paragraph body', async () => { + const frame = await frameFor('::card\nfirst para\n\nsecond para\n::', { + components: { card: Card }, + }) + + expect(frame).toContain('first para') + expect(frame).toContain('second para') + }) + + it('renders a body mixing inline text and a list', async () => { + const frame = await frameFor('::card\nintro text\n\n- item one\n- item two\n::', { + components: { card: Card }, + }) + + expect(frame).toContain('intro text') + expect(frame).toContain('• item one') + }) +}) + +/** + * Markdown soft-wraps: a single newline inside a paragraph is a space. OpenTUI + * honours `\n` as a hard break, so without collapsing them a model's 80-column + * output would stay 80 columns wide in a 200-column terminal. + */ +paint('soft line breaks', () => { + it('reflows source newlines to the terminal width', async () => { + const frame = await frameFor('one two three\nfour five six\nseven eight nine', { width: 70 }) + const firstRow = frame.split('\n')[0]! + + expect(firstRow).toContain('one two three four five six seven eight nine') + }) + + it('keeps an explicit hard break', async () => { + const frame = await frameFor('before \nafter', { width: 70 }) + const rows = frame.split('\n').map((row) => row.trim()) + + expect(rows[0]).toBe('before') + expect(rows[1]).toBe('after') + }) +}) + +/** + * GitHub alerts parse to a blockquote carrying `as: ""`, and Comark's + * walker resolves components from `as` — so these bypass the blockquote + * component entirely and are only styled if registered under the kind names. + */ +paint('github alerts', () => { + const KINDS = ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'] + + for (const kind of KINDS) { + it(`renders a ${kind} alert with its label and body`, async () => { + const frame = await frameFor(`> [!${kind}]\n> Body of the ${kind} alert.`) + + expect(frame).toContain(kind) + expect(frame).toContain(`Body of the ${kind} alert.`) + }) + } + + it('keeps a plain blockquote free of an alert label', async () => { + const frame = await frameFor('> just a quote') + + expect(frame).toContain('just a quote') + expect(frame).not.toContain('NOTE') + }) +}) + +paint('fence header', () => { + it('shows the language and the filename', async () => { + const frame = await frameFor('```typescript [main.ts]\nconst a = 1\n```') + + expect(frame).toContain('typescript') + expect(frame).toContain('main.ts') + expect(frame).toContain('const a = 1') + }) + + it('shows the language alone when there is no filename', async () => { + const frame = await frameFor('```python\nx = 1\n```') + + expect(frame).toContain('python') + expect(frame).toContain('x = 1') + }) + + it('adds no header to an unlabelled fence', async () => { + const frame = await frameFor('```\nbare body\n```') + const rows = frame + .split('\n') + .map((row) => row.trim()) + .filter(Boolean) + + expect(rows[0]).toBe('bare body') + }) +}) + +/** + * Math nodes carry no `$.block` meta, so without a component of their own the + * generic fallback treats inline math as block — a box inside a paragraph's text + * node. This is the crash case, not a styling preference. + */ +paint('math', () => { + it('renders inline math inside its paragraph', async () => { + const frame = await frameFor('energy $E = mc^2$ matters', { plugins: [math()] }) + const row = frame.split('\n').find((line) => line.includes('energy')) + + expect(row).toContain('E = mc^2') + expect(row).toContain('matters') + }) + + it('renders block math on its own', async () => { + const frame = await frameFor('$$\n\\frac{a}{b}\n$$', { plugins: [math()] }) + + expect(frame).toContain('\\frac{a}{b}') + }) +}) + +paint('component slots', () => { + function Panel({ + children, + slotTitle, + slotFooter, + __node, + }: { + children?: React.ReactNode + slotTitle?: React.ReactNode + slotFooter?: React.ReactNode + __node?: ElementNode + }) { + return ( + + {slotTitle} + {children} + {slotFooter} + + ) + } + Panel.propTypes = { __node: null } + + it('passes named slots as slot props and the default slot as children', async () => { + const frame = await frameFor('::panel\n#title\nThe title\n\n#default\nThe **body**.\n\n#footer\nThe footer\n::', { + components: { panel: Panel }, + }) + + expect(frame).toContain('The title') + expect(frame).toContain('The') + expect(frame).toContain('body') + expect(frame).toContain('The footer') + }) + + it('renders a block default slot as blocks, not one flattened line', async () => { + const frame = await frameFor('::panel\n#default\nintro\n\n- one\n- two\n::', { + components: { panel: Panel }, + }) + + expect(frame).toContain('intro') + expect(frame).toContain('• one') + expect(frame).toContain('• two') + }) +}) + +/** + * With the Shiki plugin on, the code node is rewritten into per-token spans. + * This renderer highlights with tree-sitter instead, reading the body back out + * of the node — so the fence has to survive that rewrite intact. + */ +paint('shiki plugin interop', () => { + it('renders a Shiki-tokenised fence as clean source', async () => { + const frame = await frameFor('```ts [x.ts]\nconst answer = 42\n```', { plugins: [shiki()] }) + + expect(frame).toContain('const answer = 42') + expect(frame).toContain('x.ts') + expect(frame).not.toContain('shiki') + expect(frame).not.toContain('--shiki-dark') + }) +}) + +/** Distinct foreground colours on the painted line containing `needle`. */ +async function lineColorsFor(source: string, needle: string, options: Parameters[1] = {}) { + const captured = await captureFor(source, options) + const line = captured.lines.find((row) => + row.spans + .map((span) => span.text) + .join('') + .includes(needle) + ) + + if (!line) { + throw new Error(`no painted line contains ${JSON.stringify(needle)}`) + } + + const colors = new Set() + + for (const span of line.spans) { + if (span.text.trim() !== '' && span.fg) { + colors.add(span.fg.toString()) + } + } + + return colors +} + +/** Distinct foreground colours present in a painted frame. */ +async function captureFor(source: string, options: Parameters[1] = {}): Promise { + const ui = await testRender( + + {source} + , + { width: options.width ?? 60, height: 24 } + ) + + for (let i = 0; i < (options.frames ?? 8); i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + }) + await ui.renderOnce() + } + + return ui.captureSpans() +} + +async function colorsFor(source: string, options: Parameters[1] = {}) { + const captured = await captureFor(source, options) + const colors = new Set() + + for (const line of captured.lines) { + for (const span of line.spans) { + if (span.text.trim() === '' || !span.fg) { + continue + } + + colors.add(span.fg.toString()) + } + } + + return colors +} + +/** + * Highlighting comes from the Shiki plugin's own token colours when it is + * registered. The tree-sitter path needs grammars the host has to install + * (`addDefaultParsers`; OpenTUI ships none), so without this a fence paints in + * one flat colour. + */ +const PY_FENCE = '```python\ndef fib(n: int) -> int:\n return n\n```' + +paint('syntax highlighting', () => { + it('paints a Shiki-highlighted fence in several colours', async () => { + const colors = await colorsFor('```ts\nconst answer = 42\n```', { plugins: [shiki()] }) + + expect(colors.size).toBeGreaterThan(2) + }) + + /** + * Shiki registers a fixed language set — vue, tsx, svelte, typescript, + * javascript, bash, json, yaml, astro — and does not load on demand, so a + * language outside it needs its grammar passed in. Asserted on the code line + * rather than the whole frame, because the fence header alone would otherwise + * supply a second colour and hide the difference. + */ + it('leaves a language outside the default set unhighlighted', async () => { + const colors = await lineColorsFor(PY_FENCE, 'def fib', { plugins: [shiki()] }) + + expect(colors.size).toBe(1) + }) + + it('highlights that language once its grammar is registered', async () => { + const colors = await lineColorsFor(PY_FENCE, 'def fib', { + plugins: [shiki({ languages: [python as never] })], + }) + + expect(colors.size).toBeGreaterThan(1) + }) + + it('paints more colours with the plugin than without it', async () => { + const fence = '```ts\nconst answer = 42\nfunction go() { return answer }\n```' + const highlighted = await colorsFor(fence, { plugins: [shiki()] }) + const plain = await colorsFor(fence) + + expect(highlighted.size).toBeGreaterThan(plain.size) + }) + + it('still renders the body verbatim when highlighted', async () => { + const frame = await frameFor('```ts\nconst answer = 42\n```', { plugins: [shiki()] }) + + expect(frame).toContain('const answer = 42') + }) + + it('preserves blank lines inside a highlighted fence', async () => { + const frame = await frameFor('```ts\nconst a = 1\n\nconst b = 2\n```', { plugins: [shiki()] }) + const rows = frame.split('\n').map((row) => row.trimEnd()) + const first = rows.indexOf('const a = 1') + const second = rows.indexOf('const b = 2') + + expect(first).toBeGreaterThan(-1) + expect(second).toBe(first + 2) + }) +}) + +/** + * Comark looks for somewhere to attach the streaming caret only in the *last* + * top-level node, and pushes it as a top-level node when that one holds no text + * (`MarkdownDocument.tsx`). The caret is a bare `span`, so an `hr` — which is + * also what the `---` of frontmatter looks like part-way through a stream — + * would otherwise put it straight into the root box and throw. + */ +paint('streaming caret', () => { + it('renders a caret after a trailing rule', async () => { + const frame = await frameFor('text above\n\n---', { streaming: true, caret: true }) + + expect(frame).toContain('text above') + }) + + it('renders a bare rule with a caret', async () => { + const frame = await frameFor('---', { streaming: true, caret: true }) + + expect(frame).toContain('─') + }) + + it('renders opening frontmatter fence with a caret', async () => { + const frame = await frameFor('---\ntitle: Demo', { streaming: true, caret: true }) + + expect(frame).toBeTypeOf('string') + }) + + it('renders a caret mid-paragraph', async () => { + const frame = await frameFor('a streamed sentence', { streaming: true, caret: true }) + + expect(frame).toContain('a streamed sentence') + }) + + it('survives every prefix of a document that mixes blocks', async () => { + const source = 'para one\n\n---\n\n- item\n\n```ts\nconst a = 1\n```\n\n> quote' + + for (let length = 1; length <= source.length; length += 3) { + const frame = await frameFor(source.slice(0, length), { + streaming: true, + caret: true, + frames: 2, + }) + + expect(frame, `threw or blanked at prefix length ${length}`).toBeTypeOf('string') + } + }) +}) diff --git a/packages/comark-opentui/test/units.test.ts b/packages/comark-opentui/test/units.test.ts new file mode 100644 index 00000000..da5c886e --- /dev/null +++ b/packages/comark-opentui/test/units.test.ts @@ -0,0 +1,370 @@ +import type { ElementNode, Node } from 'comark' +import { parseMarkdown } from 'comark' +import { createElement, type ReactElement } from 'react' +import { describe, expect, it } from 'vitest' +import { createMarkdownParser } from 'comark' +import { textContent } from 'comark/utils' +import shiki from 'comark/plugins/shiki' +import python from 'shiki/dist/langs/python.mjs' +import { components, componentsManifest, NATIVE_TAGS } from '../src/components/index.ts' +import { groupChildrenByFlow, reflowInline } from '../src/components/flow.tsx' +import { fenceLanguage, shikiTokens } from '../src/components/pre.tsx' +import { measureColumns } from '../src/components/table.tsx' +import { contentNode } from '../src/utils.ts' + +/** + * Anything needing a live renderer lives in `test/paint`, which requires native + * FFI (Node >= 26.1). What is checked here runs on any supported Node: the part + * that decides whether the reconciler throws at all — tag coverage and the + * inline/block split. + */ + +describe('fenceLanguage', () => { + it('reads the language attribute', () => { + expect(fenceLanguage(['pre', { language: 'ts' }, ['code', {}, 'x']])).toBe('ts') + }) + + it('falls back to the code node class', () => { + expect(fenceLanguage(['pre', {}, ['code', { class: 'language-bash' }, 'ls']])).toBe('bash') + }) + + it('returns undefined for an unlabelled fence', () => { + expect(fenceLanguage(['pre', {}, ['code', {}, 'plain']])).toBeUndefined() + expect(fenceLanguage(undefined)).toBeUndefined() + }) +}) + +describe('measureColumns', () => { + it('sizes each column to its widest cell across all rows', async () => { + const document = await parseMarkdown('| a | b |\n| --- | --- |\n| wiiiiiiiide | x |\n| s | y |') + const widths = measureColumns(document.nodes[0] as ElementNode) + + expect(widths).toHaveLength(2) + expect(widths[0]).toBe('wiiiiiiiide'.length + 1) + expect(widths[1]).toBeGreaterThanOrEqual(3) + }) + + it('clamps a runaway cell', async () => { + const document = await parseMarkdown(`| a |\n| --- |\n| ${'x'.repeat(200)} |`) + const widths = measureColumns(document.nodes[0] as ElementNode) + + expect(widths[0]).toBeLessThanOrEqual(40) + }) + + it('returns no columns for a table-less node', () => { + expect(measureColumns(undefined)).toEqual([]) + }) +}) + +describe('groupChildrenByFlow', () => { + it('wraps an all-inline container in a single text host', () => { + const node: ElementNode = ['li', {}, 'a ', ['strong', {}, 'b']] + const groups = groupChildrenByFlow(node, ['a ', createElement('strong', {}, 'b')]) + + expect(groups).toHaveLength(1) + expect((groups[0] as ReactElement).type).toBe('text') + }) + + it('passes block children through untouched', () => { + const node: ElementNode = ['li', {}, ['p', {}, 'a'], ['ul', {}, ['li', {}, 'b']]] + const groups = groupChildrenByFlow(node, [createElement('p'), createElement('ul')]) + + expect(groups).toHaveLength(2) + expect((groups[0] as ReactElement).type).not.toBe('text') + }) + + /** + * The task-list shape: an `input` span and bare text sit next to a nested + * block. The inline pair has to be gathered into one text host, or the string + * hits `createTextInstance` outside a text node and throws. + */ + it('groups an inline run that precedes a block child', () => { + const node: ElementNode = ['li', {}, ['input', { type: 'checkbox' }], ' done', ['ul', {}, ['li', {}, 'nested']]] + const groups = groupChildrenByFlow(node, [createElement('span', {}, '[x]'), ' done', createElement('ul')]) + + expect(groups).toHaveLength(2) + expect((groups[0] as ReactElement).type).toBe('text') + expect((groups[1] as ReactElement).type).toBe('ul') + }) + + it('skips comment and template children when pairing', () => { + const node: ElementNode = ['li', {}, [null, {}, ' a comment '] as unknown as Node, 'text'] + const groups = groupChildrenByFlow(node, ['text']) + + expect(groups).toHaveLength(1) + expect((groups[0] as ReactElement).type).toBe('text') + }) + + it('falls back to inline when children cannot be paired', () => { + const node: ElementNode = ['li', {}, ['p', {}, 'a'], ['p', {}, 'b']] + const groups = groupChildrenByFlow(node, [createElement('p')]) + + expect(groups).toHaveLength(1) + expect((groups[0] as ReactElement).type).toBe('text') + }) + + it('returns nothing for an empty container', () => { + expect(groupChildrenByFlow(['li', {}], [])).toEqual([]) + }) +}) + +/** + * OpenTUI's reconciler throws `Unknown component type` on any tag missing from + * its catalogue, so coverage is a correctness property rather than a nicety. + * Mirrors `baseComponents` in `@opentui/react`. + */ +const OPENTUI_HOSTS = new Set([ + 'box', + 'text', + 'code', + 'diff', + 'markdown', + 'input', + 'select', + 'textarea', + 'scrollbox', + 'ascii-font', + 'tab-select', + 'line-number', + 'span', + 'br', + 'b', + 'strong', + 'i', + 'em', + 'u', + 'a', +]) + +/** Markdown exercising every construct Comark emits a distinct tag for. */ +const CORPUS = [ + '# h1', + '## h2', + '### h3', + '#### h4', + '##### h5', + '###### h6', + 'paragraph **bold** _em_ ~~del~~ `code` [link](https://e.com) ![alt](i.png)', + 'line one \nline two', + '> quote', + '- bullet', + '1. ordered', + '- [x] task', + '```ts\ncode\n```', + '---', + '| a | b |\n| --- | --- |\n| 1 | 2 |', + '
html block
', + 'inline html', + '::unregistered\nbody\n::', +].join('\n\n') + +function collectTags(nodes: Node[], into = new Set()): Set { + for (const node of nodes) { + if (typeof node === 'string' || !Array.isArray(node) || typeof node[0] !== 'string') { + continue + } + + into.add(node[0]) + collectTags(node.slice(2) as Node[], into) + } + + return into +} + +describe('tag coverage', () => { + it('resolves every tag the corpus emits', async () => { + const document = await parseMarkdown(CORPUS) + const tags = collectTags(document.nodes) + + expect(tags.size).toBeGreaterThan(15) + + for (const tag of tags) { + const resolved = components[tag] ?? (NATIVE_TAGS.has(tag) ? 'native' : componentsManifest(tag)) + + expect(resolved, `tag "${tag}" resolves to nothing and would throw`).toBeTruthy() + } + }) + + it('never declares a tag both mapped and native', () => { + for (const tag of NATIVE_TAGS) { + expect(components[tag], `"${tag}" is both mapped and declared native`).toBeUndefined() + } + }) + + it('declines native tags so they reach their own renderable', () => { + for (const tag of NATIVE_TAGS) { + expect(componentsManifest(tag), `"${tag}" would be shadowed by the fallback`).toBeUndefined() + } + }) + + it('only declares tags OpenTUI actually hosts as native', () => { + for (const tag of NATIVE_TAGS) { + expect(OPENTUI_HOSTS.has(tag), `"${tag}" is not an OpenTUI host`).toBe(true) + } + }) + + /** + * `code` and `input` collide with OpenTUI hosts that mean something else + * entirely — a block-level highlighted panel, and an interactive text field. + * Leaving either unmapped is a silent visual break rather than a throw. + */ + it('maps the tags that collide with unrelated OpenTUI hosts', () => { + expect(components.code).toBeDefined() + expect(components.input).toBeDefined() + expect(NATIVE_TAGS.has('code')).toBe(false) + expect(NATIVE_TAGS.has('input')).toBe(false) + }) + + it('answers unknown tags with a fallback', () => { + expect(componentsManifest('div')).toBeDefined() + expect(componentsManifest('some-future-tag')).toBeDefined() + }) +}) + +describe('reflowInline', () => { + it('collapses a soft break and its indentation to one space', () => { + expect(reflowInline(['one\n two'])).toEqual(['one two']) + }) + + it('reaches strings nested inside emphasis', () => { + const bold = createElement('strong', {}, 'spanning\ntwo lines') + const [reflowed] = reflowInline([bold]) as ReactElement[] + + expect((reflowed!.props as { children: unknown }).children).toEqual(['spanning two lines']) + }) + + it('passes a childless element straight through', () => { + // `React.Children.map` re-keys elements, so identity is not preserved. + const [reflowed] = reflowInline([createElement('br')]) as ReactElement[] + + expect(reflowed!.type).toBe('br') + }) +}) + +describe('contentNode', () => { + it('resolves to the default template when a component uses slots', () => { + const node: ElementNode = [ + 'alert', + {}, + ['template', { name: 'title' }, 'Title'], + ['template', { name: 'default' }, 'Body'], + ] + + expect(contentNode(node)?.[1]).toEqual({ name: 'default' }) + }) + + it('falls back to the node when slots exist but none is the default', () => { + const node: ElementNode = ['alert', {}, ['template', { name: 'title' }, 'Title']] + + expect(contentNode(node)?.[0]).toBe('alert') + }) + + it('returns the node itself when there are no slots', () => { + const node: ElementNode = ['li', {}, 'plain'] + + expect(contentNode(node)).toBe(node) + }) + + it('returns undefined for no node', () => { + expect(contentNode(undefined)).toBeUndefined() + }) +}) + +/** + * The Shiki plugin rewrites a fence's code node into per-token spans. This + * renderer highlights with tree-sitter instead and reads the body back off the + * node, so that rewrite must not alter what comes out. + */ +describe('shiki plugin interop', () => { + it('flattens tokenised code back to its exact source', async () => { + const source = 'const a = 1\nconst b = 2' + const parse = createMarkdownParser({ plugins: [shiki()] }) + const document = await parse(`\`\`\`ts [main.ts]\n${source}\n\`\`\``) + const pre = document.nodes[0] as ElementNode + + expect(textContent(pre)).toBe(source) + expect(fenceLanguage(pre)).toBe('ts') + expect(pre[1].filename).toBe('main.ts') + }) + + it('agrees with an unhighlighted parse', async () => { + const source = 'const a = 1' + const fence = `\`\`\`ts\n${source}\n\`\`\`` + const plain = await createMarkdownParser({})(fence) + const highlighted = await createMarkdownParser({ plugins: [shiki()] })(fence) + + expect(textContent(highlighted.nodes[0] as ElementNode)).toBe(textContent(plain.nodes[0] as ElementNode)) + }) +}) + +describe('alert coverage', () => { + it('maps every GitHub alert kind', () => { + for (const kind of ['note', 'tip', 'important', 'warning', 'caution']) { + expect(components[kind], `"${kind}" alerts would fall back to a plain container`).toBeDefined() + } + }) + + it('maps math, which carries no block/inline meta of its own', () => { + expect(components.math).toBeDefined() + }) +}) + +describe('shikiTokens', () => { + it('returns one entry per line, with a colour per token', async () => { + const parse = createMarkdownParser({ plugins: [shiki()] }) + const document = await parse('```ts\nconst a = 1\nconst b = 2\n```') + const lines = shikiTokens(document.nodes[0] as ElementNode) + + expect(lines).toHaveLength(2) + expect(lines![0]!.map((token) => token.text).join('')).toBe('const a = 1') + + const colors = new Set(lines!.flat().map((token) => token.fg)) + + expect(colors.size).toBeGreaterThan(1) + }) + + it('prefers the dark variant when Shiki emits both', async () => { + const parse = createMarkdownParser({ plugins: [shiki()] }) + const document = await parse('```ts\nconst a = 1\n```') + const [first] = shikiTokens(document.nodes[0] as ElementNode)! + + // `const` carries `color:#9C3EDA;--shiki-dark:#C792EA` in the default themes. + expect(first![0]!.fg).toBe('#C792EA') + }) + + it('returns null for a fence the plugin never touched', async () => { + const document = await createMarkdownParser({})('```ts\nconst a = 1\n```') + + expect(shikiTokens(document.nodes[0] as ElementNode)).toBeNull() + }) + + it('returns null for a node that is not a fence', () => { + expect(shikiTokens(undefined)).toBeNull() + expect(shikiTokens(['p', {}, 'text'])).toBeNull() + }) +}) + +/** + * Shiki's language set is fixed at highlighter creation — there is no + * load-on-demand — so a fence in a language outside the plugin's defaults + * (vue, tsx, svelte, typescript, javascript, bash, json, yaml, astro) comes back + * untokenised and renders flat. + */ +describe('shiki language registration', () => { + const fence = '```python\ndef fib(n: int) -> int:\n return n\n```' + + it('leaves a language outside the default set untokenised', async () => { + const document = await createMarkdownParser({ plugins: [shiki()] })(fence) + + expect(shikiTokens(document.nodes[0] as ElementNode)).toBeNull() + }) + + it('tokenises it once the grammar is passed in', async () => { + const parse = createMarkdownParser({ plugins: [shiki({ languages: [python as never] })] }) + const document = await parse(fence) + const lines = shikiTokens(document.nodes[0] as ElementNode) + + expect(lines).toHaveLength(2) + expect(new Set(lines!.flat().map((token) => token.fg)).size).toBeGreaterThan(1) + }) +}) diff --git a/packages/comark-opentui/tsconfig.json b/packages/comark-opentui/tsconfig.json new file mode 100644 index 00000000..a78c3110 --- /dev/null +++ b/packages/comark-opentui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "outDir": "./dist", + "strict": true, + "declaration": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": false, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/comark-opentui/vitest.config.ts b/packages/comark-opentui/vitest.config.ts new file mode 100644 index 00000000..e34d2390 --- /dev/null +++ b/packages/comark-opentui/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + // OpenTUI targets Bun, whose resolver accepts extensionless CommonJS + // subpaths. Node's ESM resolver does not, so `@opentui/react`'s import of + // `react-reconciler/constants` has to be pointed at the real file for the + // suite to load under Vitest. + 'react-reconciler/constants': 'react-reconciler/constants.js', + }, + }, + test: { + // `test/paint` needs a live OpenTUI renderer, whose native FFI is Bun-only. + // Those run via `pnpm test:paint`; Vitest keeps to the runtime-agnostic half. + include: ['test/*.test.ts'], + server: { + deps: { + // Externalised dependencies bypass Vite's resolver, which is where the + // alias above lives — OpenTUI has to be inlined for it to apply. + inline: ['@opentui/react', '@opentui/core'], + }, + }, + // OpenTUI paints through native bindings and a process-wide renderer, so + // suites cannot safely share a worker. + fileParallelism: false, + }, +}) diff --git a/packages/comark-opentui/vitest.paint.config.ts b/packages/comark-opentui/vitest.paint.config.ts new file mode 100644 index 00000000..28a70478 --- /dev/null +++ b/packages/comark-opentui/vitest.paint.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config' + +/** + * Suites that need a live OpenTUI renderer, and therefore native FFI — which + * Node exposes from 26.1 behind `--experimental-ffi`. + * + * Kept in its own config, not merged with the default one, so `pnpm test` stays + * runnable on any supported Node: `mergeConfig` concatenates `include`, which + * would drag the unit suite in here too. + */ +export default defineConfig({ + resolve: { + alias: { + // OpenTUI targets Bun, whose resolver accepts extensionless CommonJS + // subpaths. Node's ESM resolver does not, so `@opentui/react`'s import of + // `react-reconciler/constants` has to be pointed at the real file. + 'react-reconciler/constants': 'react-reconciler/constants.js', + }, + }, + test: { + include: ['test/paint/**/*.test.tsx'], + // Externalised dependencies bypass Vite's resolver, where the alias lives. + server: { deps: { inline: ['@opentui/react', '@opentui/core'] } }, + // The renderer is process-wide native state, so files must not overlap. + fileParallelism: false, + // Note for anyone extending this: the `--experimental-ffi` flag has to come + // from `NODE_OPTIONS` (which `scripts/paint.mjs` sets) because Vitest runs + // test files in workers that inherit the environment but not the parent's + // flags — `poolOptions.forks.execArgv` does not get it through. + }, +}) diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 6b54f797..bb13bec4 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -1,6 +1,6 @@ import React from 'react' import { parseMarkdown } from 'comark' -import type { MarkdownDocument as MarkdownDocumentType, ParserOptions } from 'comark' +import type { ComponentManifest, MarkdownDocument as MarkdownDocumentType, ParserOptions } from 'comark' import { isMarkdownDocument } from 'comark/utils' import { MarkdownDocument } from './MarkdownDocument.tsx' import { MarkdownClient } from './MarkdownClient.tsx' @@ -36,8 +36,12 @@ export interface MarkdownProps { /** * Dynamic component resolver function * Used to resolve components that aren't in the components map + * + * May return a component directly or a promise of a module — the resolver + * lazy-wraps promises and takes anything else as-is. Typed via Comark's + * `ComponentManifest`, matching `MarkdownDocumentProps`. */ - componentsManifest?: (name: string) => Promise<{ default: React.ComponentType }> + componentsManifest?: ComponentManifest /** * Strip wrapper tags from the top level of the document — shorthand for @@ -69,6 +73,13 @@ export interface MarkdownProps { * Additional className for the wrapper div */ className?: string + + /** + * Element wrapping the rendered nodes. Defaults to a `div`. Pass a component + * to render into a React host that has no `div` (terminal renderers, + * react-three-fiber, …), or `false` to emit the nodes bare in a fragment. + */ + wrapper?: React.ComponentType<{ className?: string; children?: React.ReactNode }> | false } /** @@ -114,6 +125,7 @@ export async function Markdown({ caret = false, data, className, + wrapper, }: MarkdownProps) { // Pre-parsed document — skip parsing and render directly if (isMarkdownDocument(value)) { @@ -124,6 +136,7 @@ export async function Markdown({ componentsManifest={componentsManifest} streaming={streaming} className={className} + wrapper={wrapper} caret={caret} data={data} /> @@ -147,6 +160,7 @@ export async function Markdown({ caret={caret} data={data} className={className} + wrapper={wrapper} /> ) } @@ -160,6 +174,7 @@ export async function Markdown({ componentsManifest={componentsManifest} streaming={streaming} className={className} + wrapper={wrapper} caret={caret} data={data} /> diff --git a/packages/comark-react/src/components/MarkdownClient.tsx b/packages/comark-react/src/components/MarkdownClient.tsx index f3d4a9ad..4cbd2ddf 100644 --- a/packages/comark-react/src/components/MarkdownClient.tsx +++ b/packages/comark-react/src/components/MarkdownClient.tsx @@ -19,6 +19,7 @@ function MarkdownContent({ caret = false, data, className, + wrapper, }: MarkdownContentProps) { const parsed = use(parsePromise) @@ -29,6 +30,7 @@ function MarkdownContent({ componentsManifest={componentsManifest} streaming={streaming} className={className} + wrapper={wrapper} caret={caret} data={data} /> diff --git a/packages/comark-react/src/components/MarkdownDocument.tsx b/packages/comark-react/src/components/MarkdownDocument.tsx index 09ca81c8..c1f2db8f 100644 --- a/packages/comark-react/src/components/MarkdownDocument.tsx +++ b/packages/comark-react/src/components/MarkdownDocument.tsx @@ -7,7 +7,7 @@ import type { } from 'comark' import React, { lazy, Suspense, useMemo } from 'react' import { pascalCase, camelCase, resolveAttributes } from 'comark/utils' -import { findLastTextNodeAndAppendNode, getCaret } from '../utils/caret.ts' +import { appendCaretToLastTextNode, getCaret } from '../utils/caret.ts' /** * Helper to get tag from a Node @@ -305,6 +305,17 @@ export interface MarkdownDocumentProps { * Additional className for the wrapper div */ className?: string + + /** + * Element wrapping the rendered nodes. Defaults to a `div`. + * + * Pass a component to render into a React host that has no `div` — terminal + * renderers, react-three-fiber and other custom reconcilers throw on unknown + * host elements — or `false` to emit the nodes bare in a fragment. A custom + * wrapper still receives the `comark-content` className and is free to ignore + * it. + */ + wrapper?: React.ComponentType<{ className?: string; children?: React.ReactNode }> | false } /** @@ -336,6 +347,7 @@ export const MarkdownDocument: React.FC = ({ caret: caretProp = false, data, className, + wrapper, }) => { const document = value ?? { nodes: [] } @@ -346,8 +358,14 @@ export const MarkdownDocument: React.FC = ({ const nodes = [...(document.nodes || [])] if (streaming && caret && nodes.length > 0) { - const hasStreamCaret = findLastTextNodeAndAppendNode(nodes[nodes.length - 1] as ElementNode, caret) - if (!hasStreamCaret) { + // Replaced rather than mutated in place: `nodes` is only a shallow copy of + // the document's own array, so appending into a node would leave the caret + // behind in the parsed document itself. + const withCaret = appendCaretToLastTextNode(nodes[nodes.length - 1] as ElementNode, caret) + + if (withCaret) { + nodes[nodes.length - 1] = withCaret + } else { nodes.push(caret) } } @@ -367,6 +385,11 @@ export const MarkdownDocument: React.FC = ({ .filter((child): child is React.ReactNode => child !== null) }, [document, customComponents, componentsManifest, streaming, caret, data]) - // Wrap in a fragment - return
{renderedNodes}
+ if (wrapper === false) { + return <>{renderedNodes} + } + + const Wrapper = (wrapper ?? 'div') as React.ElementType + + return {renderedNodes} } diff --git a/packages/comark-react/src/utils/caret.ts b/packages/comark-react/src/utils/caret.ts index 0b6fc6cc..4038e0db 100644 --- a/packages/comark-react/src/utils/caret.ts +++ b/packages/comark-react/src/utils/caret.ts @@ -1,23 +1,24 @@ -import type { ElementNode } from 'comark' +import type { ElementNode, Node } from 'comark' interface CaretOptions { class?: string } -const CARET_TEXT = ' ' // thin space is used to avoid wide spaces between text and caret +const CARET_KEY = 'stream-caret' +const CARET_TEXT = ' ' // thin space is used to avoid wide spaces between text and caret const CARET_STYLE = 'background-color: currentColor; display: inline-block; margin-left: 0.25rem; margin-right: 0.25rem; animation: pulse 0.75s cubic-bezier(0.4,0,0.6,1) infinite;' export function getCaret(options: boolean | CaretOptions): ElementNode | null { if (options === true) { - return ['span', { key: 'stream-caret', style: CARET_STYLE }, CARET_TEXT] + return ['span', { key: CARET_KEY, style: CARET_STYLE }, CARET_TEXT] } if (typeof options === 'object') { const userClass = options?.class || '' return [ 'span', { - key: 'stream-caret', + key: CARET_KEY, style: CARET_STYLE, ...(userClass ? { class: userClass } : {}), }, @@ -28,25 +29,53 @@ export function getCaret(options: boolean | CaretOptions): ElementNode | null { return null } -export function findLastTextNodeAndAppendNode(parent: ElementNode, nodeToAppend: ElementNode): boolean { - // Traverse nodes backwards to find the last text node +function isCaret(node: Node): boolean { + return Array.isArray(node) && node[1]?.key === CARET_KEY +} + +/** + * Return a copy of `node` with `caret` appended to the element holding its last + * text node, or `null` when it contains no text. + * + * Copies rather than mutates. The caller's `nodes` array is only ever shallow + * copied, so writing into a node would write into the parsed document itself: + * the caret would survive into whatever else holds that document, and every + * further call would append another one — a settled document accumulated a caret + * per re-render, each with the same React key. + * + * Only the nodes along the path to that text node are rebuilt; the rest of the + * tree is shared, so this stays cheap enough to run on every streamed delta. + */ +export function appendCaretToLastTextNode(parent: ElementNode, caret: ElementNode): ElementNode | null { + // Backwards: the caret belongs after the last text in the document. for (let i = parent.length - 1; i >= 2; i--) { const node = parent[i] - if (typeof node === 'string' && parent[1]?.key !== 'stream-caret') { - // Found a text node - insert stream indicator after it - parent.push(nodeToAppend) + // Already anchored here. Returning the node unchanged keeps the result + // truthy, so a caller that re-runs over its own output is a no-op. + if (isCaret(node as Node)) { + return parent + } - return true + if (typeof node === 'string') { + return [...parent, caret] as ElementNode } if (Array.isArray(node)) { - // This is an element node - recursively check its children - if (findLastTextNodeAndAppendNode(node as ElementNode, nodeToAppend)) { - return true + const replaced = appendCaretToLastTextNode(node as ElementNode, caret) + + if (replaced === node) { + return parent + } + + if (replaced) { + const copy = [...parent] as ElementNode + copy[i] = replaced + + return copy } } } - return false + return null } diff --git a/packages/comark-react/test/caret.test.ts b/packages/comark-react/test/caret.test.ts new file mode 100644 index 00000000..1b8bb189 --- /dev/null +++ b/packages/comark-react/test/caret.test.ts @@ -0,0 +1,79 @@ +import type { ElementNode } from 'comark' +import { parseMarkdown } from 'comark' +import { describe, expect, it } from 'vitest' +import { appendCaretToLastTextNode, getCaret } from '../src/utils/caret' + +const caret = () => getCaret(true)! + +function countCarets(value: unknown): number { + return JSON.stringify(value).split('"stream-caret"').length - 1 +} + +describe('appendCaretToLastTextNode', () => { + it('appends the caret to the element holding the last text node', () => { + const parent: ElementNode = ['p', {}, 'hello world'] + const result = appendCaretToLastTextNode(parent, caret()) + + expect(countCarets(result)).toBe(1) + expect(result![2]).toBe('hello world') + }) + + it('descends into nested elements', () => { + const parent: ElementNode = ['div', {}, ['p', {}, 'nested text']] + const result = appendCaretToLastTextNode(parent, caret()) + + expect(countCarets(result![2])).toBe(1) + }) + + it('returns null when there is no text to anchor to', () => { + expect(appendCaretToLastTextNode(['hr', {}], caret())).toBeNull() + expect(appendCaretToLastTextNode(['ul', {}, ['li', {}]], caret())).toBeNull() + }) + + /** + * The previous implementation pushed into the node it was given. `nodes` is only + * a shallow copy of the document's array, so the caret ended up in the parsed + * document itself — visible to anything else holding it, and appended again on + * every re-render, each copy carrying the same React key. + */ + it('leaves the input untouched', () => { + const parent: ElementNode = ['p', {}, 'hello world'] + const before = JSON.stringify(parent) + + appendCaretToLastTextNode(parent, caret()) + + expect(JSON.stringify(parent)).toBe(before) + }) + + it('does not accumulate across repeated calls', () => { + const parent: ElementNode = ['p', {}, 'hello world'] + + for (let call = 0; call < 5; call++) { + expect(countCarets(appendCaretToLastTextNode(parent, caret()))).toBe(1) + } + }) + + it('does not anchor to a caret that is already present', () => { + const withCaret = appendCaretToLastTextNode(['p', {}, 'text'], caret())! + const again = appendCaretToLastTextNode(withCaret, caret()) + + expect(countCarets(again)).toBe(1) + }) + + it('leaves a parsed document unmodified', async () => { + const document = await parseMarkdown('# Title\n\nBody text') + const before = JSON.stringify(document.nodes) + + appendCaretToLastTextNode(document.nodes[document.nodes.length - 1] as ElementNode, caret()) + + expect(JSON.stringify(document.nodes)).toBe(before) + }) + + it('shares the untouched parts of the tree', () => { + const sibling: ElementNode = ['span', {}, 'sibling'] + const parent: ElementNode = ['div', {}, sibling, ['p', {}, 'text']] + const result = appendCaretToLastTextNode(parent, caret())! + + expect(result[2]).toBe(sibling) + }) +}) diff --git a/packages/comark-react/test/wrapper.test.tsx b/packages/comark-react/test/wrapper.test.tsx new file mode 100644 index 00000000..97029eca --- /dev/null +++ b/packages/comark-react/test/wrapper.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { renderToString } from 'react-dom/server' +import { parseMarkdown } from 'comark' +import { Markdown } from '../src/components/Markdown' +import { MarkdownDocument } from '../src/components/MarkdownDocument' +import { MarkdownLive } from '../src/components/MarkdownLive' + +/** + * `wrapper` overrides the element the rendered nodes are parented to. The + * default stays a `div` so existing output is untouched; a component or `false` + * is what lets a non-DOM React host (terminal renderers, react-three-fiber, …) + * render Comark at all — those reconcilers throw on an unknown `div` host. + */ +function Section({ className, children }: { className?: string; children?: React.ReactNode }) { + return
{children}
+} + +describe('MarkdownDocument wrapper', () => { + it('defaults to a div carrying comark-content', async () => { + const document = await parseMarkdown('Hello **world**') + const html = renderToString() + + expect(html).toContain('
+ ) + + expect(html).toContain('class="comark-content prose"') + }) + + it('renders a custom wrapper component instead of the div', async () => { + const document = await parseMarkdown('Hello **world**') + const html = renderToString( + + ) + + expect(html).toContain('world') + }) + + it('hands the comark-content className to a custom wrapper', async () => { + const document = await parseMarkdown('Hello') + const html = renderToString( + + ) + + expect(html).toContain('class="comark-content prose"') + }) + + it('emits nodes bare when wrapper is false', async () => { + const document = await parseMarkdown('Hello **world**') + const html = renderToString( + + ) + + expect(html).not.toContain('comark-content') + expect(html).not.toContain('') + expect(html).toContain('world') + }) + + it('renders an empty document with wrapper false without crashing', () => { + const html = renderToString( + + ) + + expect(html).toBe('') + }) +}) + +describe('wrapper threading', () => { + it('reaches MarkdownDocument through Markdown with a pre-parsed document', async () => { + const document = await parseMarkdown('Hello **world**') + const element = await Markdown({ value: document, wrapper: Section }) + const html = renderToString(element as React.ReactElement) + + expect(html).toContain(' { + const element = await Markdown({ value: 'Hello **world**', wrapper: Section }) + const html = renderToString(element as React.ReactElement) + + expect(html).toContain(' { + const document = await parseMarkdown('Hello **world**') + const html = renderToString( + + ) + + expect(html).toContain('=14'} + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/react@0.4.5': + resolution: {integrity: sha512-n88Vx0cMmAu++/S14WscjvdcT46IDcxve02jMtcfHQ9j6cySRHfILfGEpOB7xxc8RpCwQceEyOjkKkfzPzm6Jg==} + peerDependencies: + react: '>=19.2.0' + react-devtools-core: ^7.0.1 + ws: ^8.18.0 + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -8018,6 +8131,11 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-ffi-structs@0.2.4: + resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} + peerDependencies: + typescript: ^5 + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -8582,6 +8700,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + docus@5.12.3: resolution: {integrity: sha512-v5CF/Ta3+aAzuUKwPsFwSSCACXh9QRWbBZENwUyheajATnPrSnql+oHbDzANM+GBwDvmPpCBhzHsjKrdZZR0cw==} peerDependencies: @@ -9894,6 +10016,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@17.0.6: resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} engines: {node: '>= 20'} @@ -11245,11 +11372,20 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-devtools-core@7.0.1: + resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: react: ^19.2.7 + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -11772,6 +11908,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -11920,10 +12060,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyclip@0.1.13: - resolution: {integrity: sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ==} - engines: {node: ^16.14.0 || >= 17.3.0} - tinyclip@0.1.15: resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} engines: {node: ^16.14.0 || >= 17.3.0} @@ -12765,6 +12901,14 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -12830,6 +12974,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -14932,7 +15088,7 @@ snapshots: semver: 7.8.5 srvx: 0.11.16 std-env: 4.2.0 - tinyclip: 0.1.13 + tinyclip: 0.1.15 tinyexec: 1.2.4 ufo: 1.6.4 youch: 4.1.1 @@ -15285,7 +15441,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.6(9fc86c8e20f41eb057448a4a69239466)': + '@nuxt/nitro-server@4.4.6(9ce4c0fb49f772d60f206fda87dd499b)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.6(magicast@0.5.3) @@ -15302,8 +15458,8 @@ snapshots: impound: 1.1.5(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(better-sqlite3@12.10.0)(oxc-parser@0.131.0)(rolldown@1.1.5)(srvx@0.11.22)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(esbuild@0.28.1)(eslint@10.4.1(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(oxlint@1.73.0)(rolldown@1.1.5)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.2))(rollup@4.62.2)(sass@1.99.0)(srvx@0.11.22)(terser@5.49.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(yaml@2.9.0) + nitropack: 2.13.4(better-sqlite3@12.10.0)(oxc-parser@0.131.0)(rolldown@1.1.5)(srvx@0.11.16)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(esbuild@0.28.1)(eslint@10.4.1(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(oxlint@1.73.0)(rolldown@1.1.5)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.2))(rollup@4.62.2)(sass@1.99.0)(srvx@0.11.16)(terser@5.49.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.8 ohash: 2.0.11 pathe: 2.0.3 @@ -15594,7 +15750,7 @@ snapshots: - vue - webpack - '@nuxt/vite-builder@4.4.6(8c4bbaa15bba8c2f72cb6f8d79afcba2)': + '@nuxt/vite-builder@4.4.6(4fffb992422c14e65d38c968cc714ab0)': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) @@ -15612,7 +15768,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(esbuild@0.28.1)(eslint@10.4.1(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(oxlint@1.73.0)(rolldown@1.1.5)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.2))(rollup@4.62.2)(sass@1.99.0)(srvx@0.11.22)(terser@5.49.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(yaml@2.9.0) + nuxt: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(esbuild@0.28.1)(eslint@10.4.1(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(oxlint@1.73.0)(rolldown@1.1.5)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.2))(rollup@4.62.2)(sass@1.99.0)(srvx@0.11.16)(terser@5.49.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.8 pathe: 2.0.3 pkg-types: 2.3.1 @@ -16078,6 +16234,61 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/react@0.4.5(react-devtools-core@7.0.1)(react@19.2.7)(typescript@5.9.3)(web-tree-sitter@0.25.10)(ws@8.21.0)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.7 + react-devtools-core: 7.0.1 + react-reconciler: 0.33.0(react@19.2.7) + ws: 8.21.0 + transitivePeerDependencies: + - typescript + - web-tree-sitter + '@oslojs/encoding@1.1.0': {} '@oxc-minify/binding-android-arm-eabi@0.131.0': @@ -18893,6 +19104,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -19439,6 +19654,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docus@5.12.3(bdb4387cb78d540ac036c3d6ff0b164f): dependencies: '@ai-sdk/gateway': 3.0.148(zod@4.4.3) @@ -21211,6 +21428,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + marked@17.0.6: {} marky@1.3.0: {} @@ -21695,7 +21914,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - nitropack@2.13.4(better-sqlite3@12.10.0)(oxc-parser@0.131.0)(rolldown@1.1.5)(srvx@0.11.22)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + nitropack@2.13.4(better-sqlite3@12.10.0)(oxc-parser@0.131.0)(rolldown@1.1.5)(srvx@0.11.16)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.62.2) @@ -21733,7 +21952,7 @@ snapshots: jiti: 2.7.0 klona: 2.0.6 knitwork: 1.3.0 - listhen: 1.10.0(srvx@0.11.22) + listhen: 1.10.0(srvx@0.11.16) magic-string: 0.30.21 magicast: 0.5.3 mime: 4.1.0 @@ -22250,16 +22469,16 @@ snapshots: - uploadthing - vue - nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(esbuild@0.28.1)(eslint@10.4.1(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(oxlint@1.73.0)(rolldown@1.1.5)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.2))(rollup@4.62.2)(sass@1.99.0)(srvx@0.11.22)(terser@5.49.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(yaml@2.9.0): + nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.39)(better-sqlite3@12.10.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.10.0))(esbuild@0.28.1)(eslint@10.4.1(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(oxlint@1.73.0)(rolldown@1.1.5)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.62.2))(rollup@4.62.2)(sass@1.99.0)(srvx@0.11.16)(terser@5.49.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) '@nuxt/cli': 3.35.2(@nuxt/schema@4.4.6)(cac@6.7.14)(magicast@0.5.3) '@nuxt/devtools': 3.2.4(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@5.9.3)) '@nuxt/kit': 4.4.6(magicast@0.5.3) - '@nuxt/nitro-server': 4.4.6(9fc86c8e20f41eb057448a4a69239466) + '@nuxt/nitro-server': 4.4.6(9ce4c0fb49f772d60f206fda87dd499b) '@nuxt/schema': 4.4.6 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.6(magicast@0.5.3)) - '@nuxt/vite-builder': 4.4.6(8c4bbaa15bba8c2f72cb6f8d79afcba2) + '@nuxt/vite-builder': 4.4.6(4fffb992422c14e65d38c968cc714ab0) '@unhead/vue': 2.1.15(vue@3.5.39(typescript@5.9.3)) '@vue/shared': 3.5.35 chokidar: 5.0.0 @@ -23691,11 +23910,24 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-devtools-core@7.0.1: + dependencies: + shell-quote: 1.10.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + react-refresh@0.18.0: {} react@19.2.7: {} @@ -24498,6 +24730,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -24673,8 +24909,6 @@ snapshots: tinybench@2.9.0: {} - tinyclip@0.1.13: {} - tinyclip@0.1.15: {} tinyexec@1.2.4: {} @@ -25509,6 +25743,8 @@ snapshots: web-namespaces@2.0.1: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -25579,6 +25815,8 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.13: {} + ws@8.21.0: {} wsl-utils@0.3.1: diff --git a/scripts/sync-plugins.mjs b/scripts/sync-plugins.mjs index 6ab5c1b6..19443662 100644 --- a/scripts/sync-plugins.mjs +++ b/scripts/sync-plugins.mjs @@ -23,6 +23,7 @@ const frameworkPackages = [ 'comark-ansi', 'comark-nuxt', 'comark-angular', + 'comark-opentui', ] // Collect plugin names from comark/dist/plugins/ (by .js files), including diff --git a/test/bundle.test.ts b/test/bundle.test.ts index ade12b21..fd25ab25 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -64,7 +64,8 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/ansi": "37.0k (94 files)", "@comark/html": "18.2k (54 files)", "@comark/nuxt": "11.4k (54 files)", - "@comark/react": "43.1k (70 files)", + "@comark/opentui": "64.8k (70 files)", + "@comark/react": "46.4k (70 files)", "@comark/svelte": "43.4k (78 files)", "@comark/vue": "60.0k (74 files)", "comark": "411k (154 files)",