Markdown that renders as it arrives

Two emitters — a pure string→HTML function and an incremental DOM renderer — with pending block states that show partial structure without flashing raw syntax. Built for LLM chat UIs.

Live stream — StreamingMarkdownRenderer
— chars/s — updates
optional extras — fetched on first use: highlight.js not loaded shiki not loaded katex not loaded mermaid not loaded

Interactive comparison

Both APIs streaming the same content simultaneously. Pending blocks are highlighted while they form.

highlighter:
optional extras — fetched on first use: highlight.js not loaded shiki not loaded katex not loaded mermaid not loaded
renderStreamingMarkdown(partial)
renderer.update(partial)

API

Two emitters for the same content stream — pick the one that fits your rendering pipeline. The sanitizer, syntax highlighter, mermaid & custom fenced blocks, and link routing are all pluggable; see the extending guide.

String emitter — rerenders on each chunk, minimal setup
import { renderStreamingMarkdown } from '@copse/streaming-markdown'

// Call with the full partial string on each token
for await (const chunk of stream) {
  accumulated += chunk
  el.innerHTML = renderStreamingMarkdown(accumulated)
}
DOM emitter — incremental updates, avoids full innerHTML churn
import { StreamingMarkdownRenderer } from '@copse/streaming-markdown'

const renderer = new StreamingMarkdownRenderer(el)

for await (const chunk of stream) {
  accumulated += chunk
  renderer.update(accumulated) // incremental DOM patch
}
At-rest rendering — sanitized HTML from complete markdown
import {
  renderMarkdown,
  sanitizeRenderedMarkdown,
} from '@copse/streaming-markdown'

// Output is untrusted HTML — always sanitize at the sink.
el.innerHTML = sanitizeRenderedMarkdown(
  renderMarkdown(markdown)
)
Sanitizer backend — swap in DOMPurify for Node/jsdom
import { setSanitizerBackend } from '@copse/streaming-markdown'
import { dompurifyBackend }
  from '@copse/streaming-markdown/sanitizers/dompurify'

// Register once before the first render.
// Default is the native Sanitizer API (zero-dependency).
setSanitizerBackend(dompurifyBackend)

Heavy dependencies are optional — and lazy

The core bundle carries no highlight.js, no mermaid, no KaTeX, and no Shiki, so first paint stays fast. Code fences render immediately as escaped plain text with their final hljs lang-* class; ```mermaid fences and $$…$$ / ```math blocks render as inert source. Each library is fetched only when the stream first needs it — highlight.js grammars as a ~71 KB code-split chunk; mermaid, KaTeX, and Shiki as optional peer dependencies the host installs (this page maps each to a CDN). When one arrives, a re-render upgrades what's already on screen in place — no layout shift, because the element classes never change. That's exactly what this page does: watch the four status chips above flip as the demo streams its first code fence, diagram, and equation — or when you switch the highlighter to Shiki. See the lazy-loading guide for the design.

Lazy syntax highlighting — grammars stay out of your main bundle
// e.g. on the first fenced block, or at idle.
// Until then, fences are safe escaped text.
const { loadHighlightjs } = await import(
  '@copse/streaming-markdown/highlighters/highlightjs'
)
await loadHighlightjs() // registers the backend

// re-render: plain fences upgrade to token spans
rerender()
Lazy diagrams — mermaid is an optional peer, never bundled
import { hydratePendingDiagrams } from '@copse/streaming-markdown'

const { loadMermaid } = await import(
  '@copse/streaming-markdown/diagrams/mermaid'
)
await loadMermaid() // mermaid itself loads on first render

// pending <pre class="mermaid"> scaffolding → SVG
await hydratePendingDiagrams(messageEl)
Lazy math — KaTeX is an optional peer, never bundled
import { hydratePendingMath } from '@copse/streaming-markdown'

const { loadKatex } = await import(
  '@copse/streaming-markdown/math/katex'
)
await loadKatex() // registers the math renderer

// pending $$…$$ / \\[…\\] scaffolding → rendered KaTeX
await hydratePendingMath(messageEl)
Pluggable highlighter — swap highlight.js for Shiki
const { loadShiki, shikiThemeCss } = await import(
  '@copse/streaming-markdown/highlighters/shiki'
)
await loadShiki() // registers over setCodeHighlighter

// inject the theme palette once (class-based tokens)
injectCss(shikiThemeCss())
rerender() // fences upgrade in place