Skip to content

Commit ad21f7d

Browse files
committed
Resolves #317
1 parent bd167da commit ad21f7d

File tree

11 files changed

+1061
-5
lines changed

11 files changed

+1061
-5
lines changed

apps/website/app/[lang]/playground/components/playground-editor.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { math } from "@streamdown/math";
66
import { mermaid } from "@streamdown/mermaid";
77
import { SettingsIcon } from "lucide-react";
88
import { useCallback, useMemo, useRef, useState } from "react";
9+
import type { CustomRenderer } from "streamdown";
910
import { Streamdown } from "streamdown";
1011
import {
1112
Conversation,
@@ -26,6 +27,7 @@ import {
2627
SelectValue,
2728
} from "@/components/ui/select";
2829
import { Textarea } from "@/components/ui/textarea";
30+
import { VegaLiteRenderer } from "./vega-lite-renderer";
2931

3032
const defaultMarkdown = `# Streamdown Feature Showcase
3133
@@ -237,6 +239,35 @@ stateDiagram-v2
237239
238240
---
239241
242+
## Vega-Lite Charts (Custom Renderer)
243+
244+
\`\`\`vega-lite
245+
{
246+
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
247+
"description": "A bar chart showing monthly revenue.",
248+
"width": "container",
249+
"height": 200,
250+
"data": {
251+
"values": [
252+
{"month": "Jan", "revenue": 28},
253+
{"month": "Feb", "revenue": 55},
254+
{"month": "Mar", "revenue": 43},
255+
{"month": "Apr", "revenue": 91},
256+
{"month": "May", "revenue": 81},
257+
{"month": "Jun", "revenue": 53}
258+
]
259+
},
260+
"mark": {"type": "bar", "cornerRadiusTopLeft": 4, "cornerRadiusTopRight": 4},
261+
"encoding": {
262+
"x": {"field": "month", "type": "nominal", "axis": {"labelAngle": 0}},
263+
"y": {"field": "revenue", "type": "quantitative", "title": "Revenue ($k)"},
264+
"color": {"field": "month", "type": "nominal", "legend": null, "scale": {"scheme": "tableau10"}}
265+
}
266+
}
267+
\`\`\`
268+
269+
---
270+
240271
## CJK Support
241272
242273
**Chinese:** **你好世界。** Streamdown 支持中文排版。
@@ -258,6 +289,10 @@ Three dashes create a horizontal rule:
258289
© 2025 — Streamdown • Built with ♥
259290
`;
260291

292+
const renderers: CustomRenderer[] = [
293+
{ language: ["vega-lite", "vega"], component: VegaLiteRenderer },
294+
];
295+
261296
const PlaygroundEditor = () => {
262297
const [markdown, setMarkdown] = useState(defaultMarkdown);
263298
const [mode, setMode] = useState<"static" | "streaming">("static");
@@ -540,7 +575,7 @@ const PlaygroundEditor = () => {
540575
caret={caret === "none" ? undefined : caret}
541576
isAnimating={isStreaming}
542577
mode={mode}
543-
plugins={{ code, mermaid, math, cjk }}
578+
plugins={{ code, mermaid, math, cjk, renderers }}
544579
>
545580
{markdown}
546581
</Streamdown>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import type { CustomRendererProps } from "streamdown";
5+
import { CodeBlockContainer, CodeBlockHeader } from "streamdown";
6+
7+
export const VegaLiteRenderer = ({
8+
code,
9+
language,
10+
isIncomplete,
11+
}: CustomRendererProps) => {
12+
const containerRef = useRef<HTMLDivElement>(null);
13+
14+
useEffect(() => {
15+
if (isIncomplete || !containerRef.current) {
16+
return;
17+
}
18+
19+
let cancelled = false;
20+
21+
const render = async () => {
22+
try {
23+
const spec = JSON.parse(code);
24+
const vegaEmbed = (await import("vega-embed")).default;
25+
26+
if (cancelled || !containerRef.current) {
27+
return;
28+
}
29+
30+
containerRef.current.innerHTML = "";
31+
await vegaEmbed(containerRef.current, spec, {
32+
actions: false,
33+
renderer: "svg",
34+
theme: "vox",
35+
});
36+
} catch {
37+
if (!cancelled && containerRef.current) {
38+
containerRef.current.innerHTML =
39+
'<p class="text-sm text-destructive p-4">Invalid Vega-Lite spec</p>';
40+
}
41+
}
42+
};
43+
44+
render();
45+
46+
return () => {
47+
cancelled = true;
48+
};
49+
}, [code, isIncomplete]);
50+
51+
return (
52+
<CodeBlockContainer isIncomplete={isIncomplete} language={language}>
53+
<CodeBlockHeader language={language} />
54+
{isIncomplete ? (
55+
<div className="flex h-48 items-center justify-center rounded-md bg-muted">
56+
<span className="text-muted-foreground text-sm">
57+
Loading chart...
58+
</span>
59+
</div>
60+
) : (
61+
<div
62+
className="flex items-center justify-center overflow-hidden rounded-md bg-white p-4 [&_.vega-embed]:w-full [&_svg]:max-w-full"
63+
ref={containerRef}
64+
/>
65+
)}
66+
</CodeBlockContainer>
67+
);
68+
};
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
---
2+
title: Custom renderers
3+
description: Register custom renderers for arbitrary code fence languages.
4+
type: reference
5+
summary: Map code fence languages to custom React components for rendering charts, diagrams, and more.
6+
prerequisites:
7+
- /docs/configuration
8+
related:
9+
- /docs/plugins/mermaid
10+
- /docs/code-blocks
11+
- /docs/plugins
12+
---
13+
14+
The `renderers` field on `PluginConfig` lets you register custom React components for arbitrary code fence languages. Use this to render Vega-Lite charts, D2 diagrams, PlantUML, or any other visualization — without forking Streamdown.
15+
16+
Custom renderers take priority over default code blocks. If a custom renderer matches a language, it renders instead of the default `CodeBlock`. You can even override mermaid by registering a renderer for the `"mermaid"` language.
17+
18+
## Usage
19+
20+
Pass an array of `{ language, component }` objects to `plugins.renderers`:
21+
22+
```tsx title="chat.tsx" lineNumbers
23+
import { Streamdown } from "streamdown";
24+
import type { CustomRendererProps } from "streamdown";
25+
26+
const VegaLiteChart = ({ code, language, isIncomplete }: CustomRendererProps) => {
27+
if (isIncomplete) {
28+
return <div className="animate-pulse h-48 bg-muted rounded-lg" />;
29+
}
30+
31+
const spec = JSON.parse(code);
32+
return <MyVegaRenderer spec={spec} />;
33+
};
34+
35+
export default function Chat() {
36+
return (
37+
<Streamdown
38+
plugins={{
39+
renderers: [
40+
{ language: "vega-lite", component: VegaLiteChart },
41+
],
42+
}}
43+
>
44+
{markdown}
45+
</Streamdown>
46+
);
47+
}
48+
```
49+
50+
## Multiple languages
51+
52+
The `language` field accepts a string or an array of strings:
53+
54+
```tsx
55+
const renderers = [
56+
{ language: ["vega", "vega-lite"], component: VegaLiteChart },
57+
{ language: "d2", component: D2Diagram },
58+
{ language: "plantuml", component: PlantUMLDiagram },
59+
];
60+
61+
<Streamdown plugins={{ renderers }}>{markdown}</Streamdown>
62+
```
63+
64+
## Custom renderer props
65+
66+
Every custom renderer receives these props:
67+
68+
| Prop | Type | Description |
69+
|------|------|-------------|
70+
| `code` | `string` | The raw text content inside the code fence |
71+
| `language` | `string` | The language identifier from the code fence |
72+
| `isIncomplete` | `boolean` | `true` while the code fence is still being streamed |
73+
74+
## Reusing built-in components
75+
76+
Streamdown exports its internal code block components so your custom renderers can reuse them for consistent styling:
77+
78+
```tsx title="vega-renderer.tsx" lineNumbers
79+
import {
80+
CodeBlockContainer,
81+
CodeBlockHeader,
82+
CodeBlockCopyButton,
83+
CodeBlockDownloadButton,
84+
} from "streamdown";
85+
import type { CustomRendererProps } from "streamdown";
86+
87+
const VegaLiteChart = ({ code, language, isIncomplete }: CustomRendererProps) => {
88+
if (isIncomplete) {
89+
return (
90+
<CodeBlockContainer language={language} isIncomplete>
91+
<CodeBlockHeader language={language} />
92+
<div className="animate-pulse h-48 bg-muted rounded-lg" />
93+
</CodeBlockContainer>
94+
);
95+
}
96+
97+
return (
98+
<CodeBlockContainer language={language}>
99+
<CodeBlockHeader language={language} />
100+
<MyVegaRenderer spec={JSON.parse(code)} />
101+
</CodeBlockContainer>
102+
);
103+
};
104+
```
105+
106+
### Exported components
107+
108+
| Component | Description |
109+
|-----------|-------------|
110+
| `CodeBlock` | Full code block with syntax highlighting and controls |
111+
| `CodeBlockContainer` | Outer wrapper with border and styling |
112+
| `CodeBlockHeader` | Language label header |
113+
| `CodeBlockCopyButton` | Copy-to-clipboard button |
114+
| `CodeBlockDownloadButton` | Download button |
115+
| `CodeBlockSkeleton` | Loading skeleton placeholder |
116+
117+
## Streaming considerations
118+
119+
During streaming, `isIncomplete` is `true` while the code fence is still being written. Use this to show a loading state and avoid parsing incomplete content:
120+
121+
```tsx
122+
const VegaLiteChart = ({ code, isIncomplete }: CustomRendererProps) => {
123+
if (isIncomplete) {
124+
return (
125+
<div className="animate-pulse h-48 bg-muted rounded-lg flex items-center justify-center">
126+
<span className="text-muted-foreground">Loading chart...</span>
127+
</div>
128+
);
129+
}
130+
131+
return <MyVegaRenderer spec={JSON.parse(code)} />;
132+
};
133+
```
134+
135+
## Combining with other plugins
136+
137+
Custom renderers work alongside all other plugins. The rendering priority is:
138+
139+
1. Custom renderers (checked first)
140+
2. Mermaid plugin (if configured)
141+
3. Default code block with syntax highlighting
142+
143+
```tsx
144+
import { mermaid } from "@streamdown/mermaid";
145+
import { code } from "@streamdown/code";
146+
147+
<Streamdown
148+
plugins={{
149+
code,
150+
mermaid,
151+
renderers: [
152+
{ language: "vega-lite", component: VegaLiteChart },
153+
],
154+
}}
155+
>
156+
{markdown}
157+
</Streamdown>
158+
```
159+
160+
## Type reference
161+
162+
```tsx
163+
interface CustomRendererProps {
164+
code: string;
165+
language: string;
166+
isIncomplete: boolean;
167+
}
168+
169+
interface CustomRenderer {
170+
language: string | string[];
171+
component: React.ComponentType<CustomRendererProps>;
172+
}
173+
174+
interface PluginConfig {
175+
// ...existing fields
176+
renderers?: CustomRenderer[];
177+
}
178+
```

apps/website/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
"tailwind-merge": "^3.4.0",
5050
"use-stick-to-bottom": "^1.1.1",
5151
"vaul": "^1.1.2",
52+
"vega": "^6.2.0",
53+
"vega-embed": "^7.1.0",
54+
"vega-lite": "^6.4.2",
5255
"zod": "^4.1.13"
5356
},
5457
"devDependencies": {

0 commit comments

Comments
 (0)