From 815868c4d89875885dd04f44c9d9ffe6fda26599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Guilloux?= Date: Thu, 6 Aug 2026 20:29:36 +0200 Subject: [PATCH 1/2] feat(opentui): add @comark/opentui terminal renderer package Render Markdown into OpenTUI renderables so content participates in terminal flexbox layout, scrolling and syntax highlighting, instead of being flattened to an ANSI string. Adds the package with block, inline, flow, table, pre and math components, a themeable default palette, Shiki-token reuse with a tree-sitter fallback, and unit plus paint-based snapshot tests. Also ships the opentui-gallery example (theme cycling and a streaming replay), rendering docs, and small fixes in @comark/react to the caret utils and Markdown/MarkdownDocument wrappers uncovered while sharing the streaming path, with tests to cover them. --- docs/content/3.rendering/9.opentui.md | 174 +++++ examples/3.cli/opentui-gallery/app.tsx | 250 +++++++ examples/3.cli/opentui-gallery/gallery.md | 173 +++++ examples/3.cli/opentui-gallery/gallery.tsx | 13 + examples/3.cli/opentui-gallery/package.json | 26 + examples/3.cli/opentui-gallery/run.mjs | 104 +++ examples/3.cli/opentui-gallery/smoke.tsx | 182 +++++ examples/3.cli/opentui-gallery/tsconfig.json | 18 + package.json | 1 + packages/comark-opentui/README.md | 187 ++++++ packages/comark-opentui/package.json | 72 ++ packages/comark-opentui/scripts/paint.mjs | 95 +++ .../comark-opentui/src/components/block.tsx | 253 +++++++ .../comark-opentui/src/components/flow.tsx | 138 ++++ .../comark-opentui/src/components/index.ts | 99 +++ .../comark-opentui/src/components/inline.tsx | 31 + .../comark-opentui/src/components/math.tsx | 36 + .../comark-opentui/src/components/pre.tsx | 176 +++++ .../comark-opentui/src/components/table.tsx | 156 +++++ packages/comark-opentui/src/index.tsx | 191 ++++++ packages/comark-opentui/src/parse.ts | 1 + packages/comark-opentui/src/plugins/math.ts | 4 + packages/comark-opentui/src/render.ts | 1 + packages/comark-opentui/src/theme.ts | 135 ++++ packages/comark-opentui/src/utils.ts | 107 +++ .../comark-opentui/test/paint/render.test.tsx | 629 ++++++++++++++++++ packages/comark-opentui/test/units.test.ts | 370 +++++++++++ packages/comark-opentui/tsconfig.json | 21 + packages/comark-opentui/vitest.config.ts | 28 + .../comark-opentui/vitest.paint.config.ts | 31 + .../comark-react/src/components/Markdown.tsx | 19 +- .../src/components/MarkdownClient.tsx | 2 + .../src/components/MarkdownDocument.tsx | 33 +- packages/comark-react/src/utils/caret.ts | 57 +- packages/comark-react/test/caret.test.ts | 79 +++ packages/comark-react/test/wrapper.test.tsx | 136 ++++ pnpm-lock.yaml | 244 +++++++ scripts/sync-plugins.mjs | 1 + test/bundle.test.ts | 3 +- 39 files changed, 4254 insertions(+), 22 deletions(-) create mode 100644 docs/content/3.rendering/9.opentui.md create mode 100644 examples/3.cli/opentui-gallery/app.tsx create mode 100644 examples/3.cli/opentui-gallery/gallery.md create mode 100644 examples/3.cli/opentui-gallery/gallery.tsx create mode 100644 examples/3.cli/opentui-gallery/package.json create mode 100644 examples/3.cli/opentui-gallery/run.mjs create mode 100644 examples/3.cli/opentui-gallery/smoke.tsx create mode 100644 examples/3.cli/opentui-gallery/tsconfig.json create mode 100644 packages/comark-opentui/README.md create mode 100644 packages/comark-opentui/package.json create mode 100644 packages/comark-opentui/scripts/paint.mjs create mode 100644 packages/comark-opentui/src/components/block.tsx create mode 100644 packages/comark-opentui/src/components/flow.tsx create mode 100644 packages/comark-opentui/src/components/index.ts create mode 100644 packages/comark-opentui/src/components/inline.tsx create mode 100644 packages/comark-opentui/src/components/math.tsx create mode 100644 packages/comark-opentui/src/components/pre.tsx create mode 100644 packages/comark-opentui/src/components/table.tsx create mode 100644 packages/comark-opentui/src/index.tsx create mode 100644 packages/comark-opentui/src/parse.ts create mode 100644 packages/comark-opentui/src/plugins/math.ts create mode 100644 packages/comark-opentui/src/render.ts create mode 100644 packages/comark-opentui/src/theme.ts create mode 100644 packages/comark-opentui/src/utils.ts create mode 100644 packages/comark-opentui/test/paint/render.test.tsx create mode 100644 packages/comark-opentui/test/units.test.ts create mode 100644 packages/comark-opentui/tsconfig.json create mode 100644 packages/comark-opentui/vitest.config.ts create mode 100644 packages/comark-opentui/vitest.paint.config.ts create mode 100644 packages/comark-react/test/caret.test.ts create mode 100644 packages/comark-react/test/wrapper.test.tsx 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/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..9b77913b --- /dev/null +++ b/examples/3.cli/opentui-gallery/gallery.tsx @@ -0,0 +1,13 @@ +/** @jsxImportSource @opentui/react */ +import { createCliRenderer } from '@opentui/core' +import { createRoot } from '@opentui/react' +import { Gallery } from './app.tsx' + +const renderer = await createCliRenderer({ targetFps: 30, exitOnCtrlC: true }) + +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..240bdd79 --- /dev/null +++ b/packages/comark-opentui/README.md @@ -0,0 +1,187 @@ +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. + +## 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('=8.0.0'} + '@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==} @@ -7914,6 +8027,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'} @@ -8478,6 +8596,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: @@ -9790,6 +9912,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'} @@ -11141,11 +11268,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'} @@ -11668,6 +11804,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'} @@ -12661,6 +12801,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==} @@ -12726,6 +12874,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'} @@ -15894,6 +16054,61 @@ snapshots: '@opentelemetry/api@1.9.1': {} + '@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': @@ -18709,6 +18924,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 @@ -19255,6 +19474,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) @@ -21027,6 +21248,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + marked@17.0.6: {} marky@1.3.0: {} @@ -23507,11 +23730,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: {} @@ -24314,6 +24550,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 @@ -25325,6 +25565,8 @@ snapshots: web-namespaces@2.0.1: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -25395,6 +25637,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 0f26be8f..791c549e 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) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index f2865511..6fed4955 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -64,7 +64,8 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/ansi": "36.8k (92 files)", "@comark/html": "18.0k (52 files)", "@comark/nuxt": "11.2k (52 files)", - "@comark/react": "43.0k (68 files)", + "@comark/opentui": "64.6k (68 files)", + "@comark/react": "46.2k (68 files)", "@comark/svelte": "43.3k (76 files)", "@comark/vue": "59.8k (72 files)", "comark": "402k (148 files)", From 040522db4ea95b4f3cf92605d9d670629b7c077c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Guilloux?= Date: Wed, 12 Aug 2026 23:59:50 +0200 Subject: [PATCH 2/2] feat(examples): enable alternate scroll mode in opentui gallery The gallery declines OpenTUI's mouse capture so hyperlinks stay clickable, but that leaves the wheel dead in terminals which follow xterm and default DEC private mode 1007 off (e.g. Ghostty). Write the enable sequence by hand at startup and restore it on process exit, since OpenTUI does its own terminal setup and exposes no option for it. Document the mouse capture / hyperlink trade-off and the workaround in the comark-opentui README. --- .../3.cli/opentui-gallery/alternate-scroll.ts | 27 +++++++++++++++++++ examples/3.cli/opentui-gallery/gallery.tsx | 5 +++- packages/comark-opentui/README.md | 8 ++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 examples/3.cli/opentui-gallery/alternate-scroll.ts 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/gallery.tsx b/examples/3.cli/opentui-gallery/gallery.tsx index 9b77913b..e9474785 100644 --- a/examples/3.cli/opentui-gallery/gallery.tsx +++ b/examples/3.cli/opentui-gallery/gallery.tsx @@ -1,9 +1,12 @@ /** @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 }) +const renderer = await createCliRenderer({ targetFps: 30, exitOnCtrlC: true, useMouse: false }) + +enableAlternateScroll() function quit() { renderer.destroy() diff --git a/packages/comark-opentui/README.md b/packages/comark-opentui/README.md index 240bdd79..9bc63a12 100644 --- a/packages/comark-opentui/README.md +++ b/packages/comark-opentui/README.md @@ -162,6 +162,14 @@ 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