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.
Both APIs streaming the same content simultaneously. Pending blocks are highlighted while they form.
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.
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)
}
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
}
import {
renderMarkdown,
sanitizeRenderedMarkdown,
} from '@copse/streaming-markdown'
// Output is untrusted HTML — always sanitize at the sink.
el.innerHTML = sanitizeRenderedMarkdown(
renderMarkdown(markdown)
)
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)
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.
// 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()
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)
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)
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