Skip to main content

Markdown

import { Markdown } from '@react-x11/components/markdown';

<box style={{ overflow: 'scroll', flexGrow: 1 }}>
<Markdown
source={streamed}
partial={stillStreaming}
onLink={(href) => open(href)}
style={{ padding: 16 }}
/>
</box>;

A GFM renderer built for streamed model output — the Streamdown use case, rendered natively. Feed it a growing source and every instant renders clean: unclosed **bold, `code or a half-arrived [link](… never flash their raw markers, an ambiguous --- tail is held until it can be read, an open fence is already a code block. When the stream ends, flip partial off and the tail is re-read under final-document rules.

There is no markdown→HTML pass anywhere. The parser is this package's own, and rendering is <box> and <richtext> composition. This component replaces core's ntk-backed <markdown> element; ntk's MarkdownView and HtmlView are being deprecated, and there is deliberately no <html> successor.

Props

PropTypeNotes
sourcestringThe markdown text. Append to it as chunks stream in. Required.
partialbooleanWhether more source may still arrive. Default true — set it false when the stream ends.
selectablebooleanMouse selection, Ctrl+A / Ctrl+C, PRIMARY. Default true.
onLink(href: string, ev: X11MouseEvent) => voidA link was activated. Absent means clicks do nothing — this component never navigates by itself.
fontSizenumberBase text size. Default: the theme's (14).
fontFamilystringDefault sans-serif.
monoFamilystringCode font. Default 'monospace' — there is no theme token for it.
selectionColorstringSelection band fill. Default: the theme accent at 35% opacity.
highlightbooleanSyntax colouring in fenced code. Default true.
resolveLanguage(tag) => Language | nullA Language for a fence tag the built-ins do not cover — hljsLanguage goes here. Needs a stable identity, like fences.
fencesRecord<string, (f: FenceInfo) => ReactNode>Custom renderers for fenced blocks, by language — see "Custom fences" below.
styleStyle | Style[]The root box — width, padding, margins, overflow.
data-testnamestringFor react-x11/test queries.

What it renders

GFM, parsed by src/markdown/parse.ts:

  • headings, both ATX (##) and setext;
  • emphasis through the real CommonMark delimiter-run algorithm, including intraword rules and ***both***;
  • inline code spans, hard breaks and entities;
  • inline links, <autolinks> and bare email autolinks;
  • images as their alt text, linked to the image source — nothing is fetched, ever;
  • lists, nested, ordered, bullet and task (- [x]), with the tight/loose distinction;
  • blockquotes, including lazy continuation;
  • tables with per-column alignment and measured column widths;
  • thematic breaks;
  • fenced and indented code, highlighted through the same language seam <CodeEditor> uses; resolveLanguage is where tags the built-ins do not cover come from.

The deliberate deviations

All of them in the direction streamed model output wants:

  • Streaming tolerance is in the parser, not a repair pre-pass. One pass over the text instead of repair-then-reparse, and none of the "was that backtick inside a fence?" re-scans a repair pass needs. The behavioural spec is Streamdown's remend package — the handlers were read, not imported.
  • A single ~ is never strikethrough, though GFM allows it. ~~ only, so 20~25 needs no escaping heuristic.
  • Reference links ([text][ref]) stay literal. They need a definitions pass over the whole document, and streamed output essentially never uses them.
  • Raw HTML is literal text. There is no HTML pass anywhere in this component, by design. <Component /> syntax is reserved for the future MDX extension.

Selection is the point

Text selects across every block — drag, double-click a word, triple-click a block, Ctrl+A, Ctrl+C — and a mouse-up with a selection takes the X11 PRIMARY selection, so middle-click paste works everywhere.

All of that is core's selectable (react-x11#291). What this component adds is which parts are chrome, so copied text is clean: list markers stay behind, and the separators come from the layout — which for a table is exactly cells joined with tabs and rows with newlines.

Custom fences

A fenced block whose language has an entry in fences renders through it instead of as a code block:

import { Formula } from '@react-x11/components/formula';

const FENCES = {
math: ({ text, partial }) => <Formula tex={text} display partial={partial} />,
};

<Markdown source={doc} partial={streaming} fences={FENCES} />;

The renderer gets { lang, text, partial }partial is true while the fence is the live tail of a streaming document and its text is still growing. The seam is a map so this component never imports the components it hosts (the no-lateral-imports rule); the returned element lands in a keyed slot, so it needs no key of its own. Whatever it returns joins the document's selection if it answers core's text accessors, which core's own elements and everything in this package already do — so do not also make the fence's component its own selection surface (<Formula> inside a document stays selectable={false}, the default).

Two things to hold: the map's identity is an epoch for the block cache, so define it at module scope (or memoize) rather than inline in JSX; and the key is matched against the fence's info string lowercased, first word only ( ```Math title looks up math).

Streaming

Rendering is cached per top-level block, keyed on the raw source text of that block, so appending to the tail re-renders the tail alone rather than the document. That is what makes a token-by-token stream cheap.

partial is the difference between "this document is finished" and "more may arrive". While it is true the live tail is rendered friendly, and constructs that cannot yet be read are held rather than shown half-formed. It defaults to true, so the streaming case is the one you get without saying anything.

The parser is exported

import { parseMarkdown } from '@react-x11/components/markdown';
import type {
MarkdownDocument,
BlockNode,
InlineNode,
ParseOptions,
} from '@react-x11/components/markdown';

Same parser, same tolerance, no renderer attached — for a table of contents, a word count, or a second renderer of your own.

MDX

On the roadmap, not in the box. The AST reserves a component node and the renderer is ordinary React composition, so user components can interleave — including mid-stream — once the parser learns the syntax. The reserved seam is the only part that exists today.

Example

npm run examples:markdown streams a document in live, so the partial-render behaviour is visible rather than described.