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
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.
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).
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").
Links
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
| Prop | Type | Default | Description |
|---|---|---|---|
contentrequired | string | — | The markdown so far |
streaming | boolean | false | Caret + aria-busy |
charactersPerSecond | number | — | Characters per second to reveal at |
onLinkClick | (event, href: string) => void | — | preventDefault to route yourself |
linkComponent | ComponentType<MarkdownLinkProps> | — | Renders every link; {href, className, data-slot, children} |
sources | CitationSource[] | — | [n] / [^n] markers become Citation chips over sources[n - 1] |
citationAnchor | PopoverAnchor | false | 'top' | false renders the popover in place |
announce | boolean | true | Sentence-by-sentence status region + completion status |
announceDelay | number | 400 | Milliseconds a completed sentence waits before it is announced |
completeLabel | string | 'Response complete' | |
className | string | — |
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.