Skip to main content

StreamingMarkdown

Append tokens to content as they arrive and the tree updates in place; the parser tolerates an unfinished document (an open code fence, a table whose separator row has not arrived yet) so nothing jumps. While streaming the root is aria-busy and a caret pulses after the last block, and a lone | header | row already renders as a table; once the stream ends a header row without its | --- | separator is a paragraph, as in GFM.

import StreamingMarkdown, {useStreamingText} from '@zuilib/ai/streaming-markdown'

Basic

Loading example

What it renders

ATX and setext headings, paragraphs, bold / italic / inline code, links, images, fenced and indented code blocks (with data-language), nested ordered and unordered lists including task items (loose lists, with a blank line between items, stay one list), tables with column alignment, blockquotes, rules and citation markers. It is deliberately not a full CommonMark implementation: raw HTML, reference links and footnote bodies are out of scope, so the output of an assistant stays predictable. Code blocks and tables scroll horizontally inside themselves, so a wide one never widens the thread.

Loading example

An image's src goes through the same safeHref filter as a link; an unsafe one renders the alt text (data-slot="streaming-markdown-image-text"). A list item's nested content (a sub-list, a second paragraph, a fence) is ListItem.blocks in the parse tree.

Citations

Give sources (an array of CitationSource) and [n] or [^n] markers in the text render as Citation chips over sources[n - 1]; a marker with no source stays as text, and without the prop markers are plain text. citationAnchor={false} keeps the popover in place (inside a subtree theme or a demo frame).

Loading example

The model writes the markdown, so a link target is untrusted input. Hrefs are scheme-filtered by safeHref (exported from @zuilib/ai/streaming-markdown): http, https, mailto, tel and relative or #anchor targets become anchors; any other scheme (javascript:, data:, file:, a protocol-relative //host) renders the link text as plain text with no href (data-slot="streaming-markdown-link-text").

A safe link is <a target="_blank" rel="noreferrer noopener">. onLinkClick(event, href) is called with the sanitised href; call event.preventDefault() to route it yourself. linkComponent replaces the anchor with your router's Link, which receives href, className, data-slot and children.

Announcing

The root is never a live region: re-reading a growing document on every token is noise. While busy it carries aria-busy, and a separate visually hidden role="status" region announces each completed sentence (debounced by announceDelay), then completeLabel ("Response complete") once the stream has ended. announce={false} removes that region. Blocks are keyed by their character offset and parsed incrementally (parseMarkdownIncremental), so settled blocks keep their DOM while the tail grows.

Speed

charactersPerSecond reveals content at that pace instead of at once: for a demo, or to smooth a stream that arrives in large chunks. The caret stays until the reveal has caught up.

useStreamingText

The same reveal as a hook, for your own renderer:

const {text, done} = useStreamingText(source, {enabled: true, charactersPerSecond: 80})

charactersPerSecond defaults to 80; enabled: false returns source whole with done: true. One interval runs for the life of the hook (per enabled / charactersPerSecond) and reads the latest source through a ref: when source grows the reveal continues without restarting the timer; when it changes to something that is not an extension of the previous text, the reveal restarts from zero.

Props

PropTypeDefaultDescription
contentrequiredstringThe markdown so far
streamingbooleanfalseCaret + aria-busy
charactersPerSecondnumberCharacters per second to reveal at
onLinkClick(event, href: string) => voidpreventDefault to route yourself
linkComponentComponentType<MarkdownLinkProps>Renders every link; {href, className, data-slot, children}
sourcesCitationSource[][n] / [^n] markers become Citation chips over sources[n - 1]
citationAnchorPopoverAnchor | false'top'false renders the popover in place
announcebooleantrueSentence-by-sentence status region + completion status
announceDelaynumber400Milliseconds a completed sentence waits before it is announced
completeLabelstring'Response complete'
classNamestring

parseMarkdown(source, {partial, citations}), parseMarkdownIncremental, parseInline(source, {citations}) and inlineToText are re-exported from this entry and from @zuilib/ai/markdown-parser; partial: true is what the component passes while busy (the lone header row, an unmatched trailing **, ` or ![ shown as plain text), citations: true turns [n] markers into citation nodes. safeHref is exported from this entry too.