streamdown-vue
brings Streamdown-style streaming Markdown to Vue 3 & Nuxt 3. It ships a <StreamMarkdown>
component that incrementally renders Markdown as it arrives (token‑by‑token, chunk‑by‑chunk), plus helper utilities to keep partially received text valid.
- Features
- Installation
- Quick Start (Basic SSR + CSR)
- Deep Dive Tutorial (Streaming from an AI / SSE source)
- Props Reference (All
<StreamMarkdown>
props) - Component Slots & Overrides
- Built‑in Components & Data Attributes
- Security Model (Link/Image hardening)
- Syntax Highlighting (Shiki) & Copy Buttons
- Mermaid Diagrams
- Math & LaTeX Fixes
- Utilities (
parseBlocks
,parseIncompleteMarkdown
, LaTeX helpers) - Performance Tips
- Nuxt 3 Usage & SSR Notes
- Recipe Gallery
- FAQ
- Development & Contributing
- GitHub‑flavored Markdown (tables, task lists, strikethrough) via
remark-gfm
- KaTeX math (
remark-math
+rehype-katex
) with extra repairs (matrices, stray$
) - Shiki syntax highlighting (light + dark themes) with reactive copy buttons
- Mermaid diagrams with caching, async render & graceful error recovery
- Incremental rendering + repair of incomplete Markdown tokens while streaming
- Secure allow‑list based hardening of link & image URLs (blocks
javascript:
etc.) - Component override layer (swap any tag / embed custom Vue components)
- Data attributes for each semantic element (
data-streamdown="..."
) for styling/testing - Designed for SSR (Vue / Nuxt) & fast hydration; tree‑shakable, side‑effects minimized
bun add streamdown-vue
npm install streamdown-vue
# pnpm add streamdown-vue
# yarn add streamdown-vue
You must also install peer deps vue
(and optionally typescript
).
Include KaTeX stylesheet once (if you use math):
import 'katex/dist/katex.min.css';
main.ts
:
import { createApp } from 'vue';
import App from './App.vue';
import 'katex/dist/katex.min.css';
createApp(App).mount('#app');
App.vue
:
<template>
<StreamMarkdown class="prose" :content="markdown" />
</template>
<script setup lang="ts">
import { StreamMarkdown } from 'streamdown-vue';
const markdown = `# Hello\n\nSome *markdown* with $e^{i\\pi}+1=0$.`;
</script>
SSR (server) minimal snippet:
import { renderToString } from '@vue/server-renderer';
import { createSSRApp, h } from 'vue';
import { StreamMarkdown } from 'streamdown-vue';
const app = createSSRApp({
render: () => h(StreamMarkdown, { content: '# SSR' }),
});
const html = await renderToString(app);
When receiving tokens / partial chunks you typically want to:
- Append new text chunk into a buffer.
- Repair the partial Markdown (
parseIncompleteMarkdown
). - Split into safe blocks for re-render (
parseBlocks
). - Feed the concatenated repaired text to
<StreamMarkdown>
.
Composable example (client side):
// useStreamedMarkdown.ts
import { ref } from 'vue';
import { parseBlocks, parseIncompleteMarkdown } from 'streamdown-vue';
export function useStreamedMarkdown() {
const rawBuffer = ref('');
const rendered = ref('');
const blocks = ref<string[]>([]);
const pushChunk = (text: string) => {
rawBuffer.value += text;
// repair incomplete tokens (unclosed **, `, $$, etc.)
const repaired = parseIncompleteMarkdown(rawBuffer.value);
blocks.value = parseBlocks(repaired);
rendered.value = blocks.value.join('');
};
return { rawBuffer, rendered, blocks, pushChunk };
}
Using Server-Sent Events (SSE):
const { rendered, pushChunk } = useStreamedMarkdown();
const es = new EventSource('/api/chat');
es.onmessage = (e) => {
pushChunk(e.data);
};
es.onerror = () => es.close();
Template:
<StreamMarkdown :content="rendered" />
Why repair first? Without repair, a trailing **
or lone ``` will invalidate the final tree and cause flicker or lost highlighting. Repairing keeps intermediate renders stable.
Prop | Type | Default | Description |
---|---|---|---|
content |
string |
'' |
The full (or partially streamed) markdown source. |
class / className |
string |
'' |
Optional wrapper classes; both accepted (React-style alias). |
components |
Record<string,Component> |
{} |
Map to override built-ins (e.g. { p: MyP } ). |
remarkPlugins |
any[] |
[] |
Extra remark plugins (functions or default exports). |
rehypePlugins |
any[] |
[] |
Extra rehype plugins. |
defaultOrigin |
string? |
undefined |
Base URL used to resolve relative links/images before allow‑list checks. |
allowedImagePrefixes |
string[] |
['https://','http://'] |
Allowed (lowercased) URL prefixes for <img> . Blocked => image dropped. |
allowedLinkPrefixes |
string[] |
['https://','http://'] |
Allowed prefixes for <a href> . Blocked => link text only. |
parseIncompleteMarkdown |
boolean |
true |
(Future toggle) Auto apply repair internally. Currently you repair outside using utility; prop reserved. |
shikiTheme |
string |
'github-light' |
Shiki theme to use for syntax highlighting (any loaded Shiki theme name). |
All unrecognised props are ignored (no arbitrary HTML injection for safety).
<StreamMarkdown>
does not expose custom slots for content fragments (the pipeline is AST-driven). To customize rendering you override tags via the components
prop:
import type { Component } from 'vue';
import { StreamMarkdown } from 'streamdown-vue';
const FancyP: Component = {
setup(_, { slots }) { return () => h('p', { class: 'text-pink-600 font-serif' }, slots.default?.()); }
};
<StreamMarkdown :components="{ p: FancyP }" :content="md" />
If a tag is missing from components
it falls back to the built-in map.
Each semantic node receives a data-streamdown="name"
attribute to make styling and querying reliable, even if classes are overridden:
Element / Component | Data Attribute | Notes / Styling Hook |
---|---|---|
Paragraph <p> |
p |
Base text blocks |
Anchor <a> |
a |
Hardened links (target+rel enforced) |
Inline code <code> |
inline-code |
Single backtick spans |
Code block wrapper | code-block |
Outer container (header + body) |
Code block header bar | code-block-header |
Holds language label + copy button |
Code language badge | code-lang |
Language label span |
Empty language placeholder | code-lang-empty |
Present when no language specified (reserved space) |
Copy button | copy-button |
The actionable copy control |
Code block body container | code-body |
Wraps highlighted <pre> ; horizontal scroll applied here |
Unordered list <ul> |
ul |
|
Ordered list <ol> |
ol |
|
List item <li> |
li |
|
Horizontal rule <hr> |
hr |
|
Strong <strong> |
strong |
Bold emphasis |
Emphasis <em> |
em |
Italic emphasis |
Headings <h1> –<h6> |
h1 … h6 |
Each level individually tagged |
Blockquote <blockquote> |
blockquote |
|
Table <table> |
table |
Logical table element |
Table wrapper <div> |
table-wrapper |
Scroll container around table |
Table head <thead> |
thead |
|
Table body <tbody> |
tbody |
|
Table row <tr> |
tr |
|
Table header cell <th> |
th |
|
Table data cell <td> |
td |
|
Image <img> |
img |
Only if src passes hardening |
Mermaid wrapper | mermaid |
Replaced with rendered SVG / diagram |
KaTeX output | katex |
Class emitted by KaTeX (not set by us but styled via global KaTeX CSS) |
Because every semantic node has a stable data-streamdown
marker, you can author zero‑collision styles (or component library themes) without relying on brittle tag chains. Example – customize the code block body and header:
/* Remove borders & add extra bottom padding inside code body */
.message-body :deep([data-streamdown='code-body']) pre {
border: none;
margin-bottom: 0;
padding-bottom: 30px;
}
/* Header bar tweaks */
.message-body :deep([data-streamdown='code-block-header']) {
background: linear-gradient(to right, #f5f5f5, #e8e8e8);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* Language badge */
.message-body :deep([data-streamdown='code-lang']) {
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Table wrapper scroll shadows */
.message-body :deep([data-streamdown='table-wrapper']) {
position: relative;
}
.message-body :deep([data-streamdown='table-wrapper']::after) {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 12px;
pointer-events: none;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.08)
);
}
Tips:
- Scope via a parent (e.g.
.message-body
) or component root to avoid leaking styles. - Use
:deep()
(Vue SFC) /::v-deep
where needed to pierce scoped boundaries. - Prefer attribute selectors over tag names so overrides survive internal structural changes.
- For dark mode, pair selectors with media queries or a
.dark
ancestor.
Testing example (Vitest / Bun):
expect(html).toContain('data-streamdown="inline-code"');
Only absolute URLs starting with an allowed prefix pass. Steps:
- Resolve relative (
/x
) againstdefaultOrigin
if provided. - Lowercase & check
javascript:
scheme (blocked). - Check each allowed prefix (case-insensitive).
- If any fail, the element is dropped (link/text downgraded, image removed).
Example – allow only your CDN images & HTTPS links:
<StreamMarkdown
:allowed-link-prefixes="['https://']"
:allowed-image-prefixes="['https://cdn.example.com/']"
default-origin="https://example.com"
:content="md"
/>
Code fences are rendered by the internal CodeBlock
component:
```ts
const x: number = 1;
```
Override with your custom block:
import { defineComponent, h } from 'vue';
import { useShikiHighlighter } from 'streamdown-vue';
const MyCode = defineComponent({
props: { code: { type: String, required: true }, language: { type: String, default: '' } },
async setup(props) {
const highlighter = await useShikiHighlighter();
const html = highlighter.codeToHtml(props.code, { lang: props.language || 'text', themes: { light: 'github-light', dark: 'github-dark' } });
return () => h('div', { class: 'my-code', innerHTML: html });
}
});
<StreamMarkdown :components="{ codeblock: MyCode }" />
You can switch the built‑in highlighting theme via the shikiTheme
prop (default: github-light
):
<StreamMarkdown :content="md" shiki-theme="github-dark" />
Any valid Shiki theme name you have available can be passed. If you need multiple themes based on dark/light mode, you can conditionally bind the prop:
<StreamMarkdown
:content="md"
:shiki-theme="isDark ? 'github-dark' : 'github-light'"
/>
Note: The highlighter preloads a small set of common languages (ts, js, json, bash, python, diff, markdown, vue). Additional languages will be auto‑loaded by Shiki if requested.
The default copy button uses the Clipboard API and toggles an icon for UX.
Fenced block:
```mermaid
graph TD;A-->B;B-->C;
```
The MermaidBlock
component handles:
- Deduplicated initialization
- Simple hash based caching
- Error fallback (last good diagram)
- Copy diagram source
You can override it via components
if you need advanced theming.
By default the pipeline runs remark-math
/ rehype-katex
for $$...$$
and inline $...$
with no pre‑munging of dollar signs (to keep streaming safe when a closing $
may arrive later). Optional helpers you can call yourself before rendering:
Helper | Purpose (opt‑in) |
---|---|
fixDollarSignMath |
(Optional) Escape truly stray $ you decide are currency, if desired. |
fixMatrix |
Ensure matrix environments have proper row \\ line breaks. |
Example (opt‑in):
import { fixMatrix, fixDollarSignMath } from 'streamdown-vue';
const safe = fixMatrix(fixDollarSignMath(markdown));
In streaming scenarios prefer leaving $
untouched until you know a delimiter is unmatched at the final flush.
Repairs incomplete constructs (unclosed **
, _
, `
, ~~
, $$
blocks, links/images) so partial buffers still render.
Tokenizes markdown into stable block strings; combining repaired buffer pieces reduces re‑parsing cost vs re‑feeding the whole document each keystroke.
Usage inside a stream loop (see Tutorial above). Both exported from package root.
- Debounce UI updates: apply repairs & re-render at ~30–60fps (e.g.
requestAnimationFrame
). - Reuse a single
<StreamMarkdown>
instance; change onlycontent
prop. - Avoid running large custom remark/rehype plugins on every partial—they run on full text.
- If highlighting is heavy for enormous fences, lazy-replace code block component after final chunk.
- Use server-side rendering for initial payload to reduce Total Blocking Time.
Benchmarks (see docs/performance.md
) show ~56ms render of the complex fixture under Bun (subject to change).
This section shows end‑to‑end integration in a Nuxt 3 project: installation, global registration, a streaming composable, and a server route that emits incremental Markdown.
npm i streamdown-vue
# or: bun add streamdown-vue
Create plugins/streamdown.client.ts
(client only so Shiki & Mermaid load in browser):
// plugins/streamdown.client.ts
import 'katex/dist/katex.min.css'; // once globally
// (Optional) warm the Shiki highlighter so first code block is instant
import { useShikiHighlighter } from 'streamdown-vue';
useShikiHighlighter();
Nuxt auto‑registers anything in plugins/
. No manual config required unless you disabled auto import.
<!-- pages/index.vue -->
<template>
<div class="prose mx-auto p-6">
<StreamMarkdown :content="md" />
</div>
<footer class="text-xs opacity-60 mt-8">
Rendered with streamdown-vue
</footer>
</template>
<script setup lang="ts">
import { StreamMarkdown } from 'streamdown-vue';
const md =
'# Welcome to Nuxt\\n\\nThis **Markdown** is rendered *streamdown style*.';
</script>
If you prefer auto‑import without explicit import each time, add an alias export file:
// components/StreamMarkdown.client.ts
export { StreamMarkdown as default } from 'streamdown-vue';
Now <StreamMarkdown />
is available automatically (Nuxt scans components/
).
In any page/component:
<StreamMarkdown
:content="md"
:allowed-link-prefixes="['https://', '/']"
:allowed-image-prefixes="['https://cdn.myapp.com/']"
default-origin="https://myapp.com"
/>
Relative links (e.g. /about
) will resolve against defaultOrigin
then be validated.
Create a route that emits partial Markdown pieces:
// server/api/chat.get.ts
export default defineEventHandler(async (event) => {
const encoder = new TextEncoder();
const parts = [
'# Chat Log\n',
'\nHello **world',
'** from',
' streamed',
' markdown.',
];
const stream = new ReadableStream({
start(controller) {
let i = 0;
const tick = () => {
if (i < parts.length) {
controller.enqueue(encoder.encode(parts[i++]));
setTimeout(tick, 300);
} else controller.close();
};
tick();
},
});
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
return stream; // Nuxt will send as a stream
});
// composables/useStreamedMarkdown.ts
import { ref } from 'vue';
import { parseBlocks, parseIncompleteMarkdown } from 'streamdown-vue';
export function useStreamedMarkdown(url: string) {
const rendered = ref('');
const raw = ref('');
const start = async () => {
const res = await fetch(url);
const reader = res.body!.getReader();
let buf = '';
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// repair, split, join
const repaired = parseIncompleteMarkdown(buf);
rendered.value = parseBlocks(repaired).join('');
raw.value = buf;
}
};
return { rendered, raw, start };
}
<!-- pages/stream.vue -->
<template>
<button @click="start" class="border px-3 py-1 mb-4">Start Stream</button>
<StreamMarkdown :content="rendered" class="prose" />
</template>
<script setup lang="ts">
import { StreamMarkdown } from 'streamdown-vue';
import { useStreamedMarkdown } from '@/composables/useStreamedMarkdown';
const { rendered, start } = useStreamedMarkdown('/api/chat');
</script>
- The stream loop runs only client-side; on first SSR render you may want a placeholder skeleton.
- Shiki highlighting of large code blocks happens client-side; if you need critical highlighted code for SEO, pre-process the markdown on the server and send the HTML (future enhancement: server highlight hook).
- Ensure Mermaid is only executed client-side (the provided plugin pattern handles this since the component executes render logic on mount).
Symptom | Fix |
---|---|
Copy button not showing | Ensure default CodeBlock not overridden or your custom block renders the button. |
Links stripped | Adjust allowed-link-prefixes / set default-origin to resolve relative paths first. |
Images missing | Add CDN prefix to allowed-image-prefixes . |
Flash of unstyled math | Confirm KaTeX CSS loaded in client plugin before first render. |
High CPU on huge streams | Throttle updates (wrap repair/render in requestAnimationFrame or batch by char count). |
That’s it—Nuxt integration is essentially drop‑in plus an optional streaming composable.
Goal | Snippet |
---|---|
AI Chat | Combine streaming buffer + <StreamMarkdown> (tutorial §4) |
Restrict to CDN images | Set :allowed-image-prefixes |
Override <table> style |
:components="{ table: MyTable }" |
Add custom remark plugin | :remark-plugins="[myRemark]" |
Append footer paragraph automatically | remark plugin injecting node |
Basic local Vue example | See examples/basic in repo |
Custom remark plugin skeleton:
const remarkAppend = () => (tree: any) => {
tree.children.push({ type: 'paragraph', children: [{ type: 'text', value: 'Tail note.' }] });
};
<StreamMarkdown :remark-plugins="[remarkAppend]" />
Why repair outside instead of inside the component? Control & transparency. You can decide when to re-render; the component focuses on a deterministic AST transform.
Can I disable KaTeX or Mermaid? For now they are bundled if you use their fences. Future option could allow toggling; PRs welcome.
Does it sanitize HTML? Inline HTML is not allowed (passed through remark/rehype with allowDangerousHtml: false
). Add a sanitizer plugin if you purposely enable raw HTML.
Dark mode highlighting? Shiki is initialized with both a light & dark theme; you can swap classes on a container and CSS variables from Shiki handle the rest.
bun install
bun test # run tests (fast)
bun run build # build library (types + bundles)
PRs for: improved matrix handling, plugin toggles, directive support, performance instrumentation are appreciated.
Licensed under the Apache License, Version 2.0.
let buffer = '';
for await (const chunk of stream) {
buffer += chunk;
buffer = parseIncompleteMarkdown(buffer);
const blocks = parseBlocks(buffer);
state.markdown = blocks.join('');
}
Happy streaming! 🚀