Introduction
x-markdown-vue is a standalone Markdown rendering library extracted from vue-element-plus-x, purpose-built for rich text display and streaming output in AI chat scenarios.
✨ Features
- 🚀 Streaming Rendering — Real-time output animation for AI chat, with per-token fade-in effects
- 📝 GitHub Flavored Markdown — Full GFM support (tables, task lists, strikethrough, etc.)
- 🎨 Syntax Highlighting — Powered by Shiki with 100+ languages and multiple themes
- 🧮 LaTeX Math — Inline
$...$and block$$...$$math formulas - 📊 Mermaid Diagrams — Flowcharts, sequence diagrams, Gantt charts, class diagrams, and more
- 🌗 Dark Mode — Built-in light/dark theme support
- 🔌 Highly Customizable — Custom render slots, custom attributes, custom code block renderers
- 🎭 Plugin System — remark and rehype plugin extensions
- 🔒 Secure — Optional HTML sanitization to prevent XSS attacks
Starting from v2.0.0, vue-element-plus-x no longer bundles Typewriter / XMarkdown / XMarkdownAsync. Install x-markdown-vue separately.
- NPM: x-markdown-vue
- GitHub: element-plus-x/x-markdown
- Live Demo: x-markdown.netlify.app
Installation
# pnpm (recommended)
pnpm add x-markdown-vue
# npm
npm install x-markdown-vue
# yarn
yarn add x-markdown-vue2
3
4
5
6
7
8
Optional Dependencies
x-markdown-vue uses optional peer dependencies for extra features:
# Syntax highlighting (Shiki)
pnpm add shiki shiki-stream
# Mermaid diagrams
pnpm add mermaid
# LaTeX math formulas (also requires importing the CSS)
pnpm add katex2
3
4
5
6
7
8
💡 Tip
If shiki / shiki-stream are not installed, code blocks will fall back to plain text rendering.
Code Examples
Basic Usage
Use MarkdownRenderer to render static Markdown content with GFM, task lists, tables, and syntax highlighting.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `# Hello, Element Plus X 👋
This is **bold**, *italic*, ~~strikethrough~~ and \`inline code\`.
## Task List
- [x] Completed task
- [ ] Pending task
## Table
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| markdown | string | \`''\` | Markdown content |
| is-dark | boolean | \`false\` | Dark mode |
| enable-animate | boolean | \`false\` | Streaming animation |
## Code Block
\`\`\`typescript
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
const messages: ChatMessage[] = [
{ role: 'user', content: 'Hello!' },
{ role: 'assistant', content: 'Hi! How can I help you?' },
];
\`\`\`
`;
</script>
<template>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Streaming Animation
Enable enable-animate to add a fade-in animation for each newly appended token — ideal for AI streaming output.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const fullText = `# AI Assistant Reply
Streaming Markdown content in real-time...
- Supports **bold** and *italic*
- Supports \`inline code\`
- Supports task lists
- [x] Completed step
- [ ] Pending task
\`\`\`typescript
const greeting: string = 'Hello World';
console.log(greeting);
\`\`\`
`;
const streamContent = ref('');
let intervalId: ReturnType<typeof setInterval> | null = null;
const isStreaming = ref(false);
function startStream() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
streamContent.value = '';
isStreaming.value = true;
let index = 0;
intervalId = setInterval(() => {
if (index < fullText.length) {
streamContent.value += fullText[index++];
} else {
clearInterval(intervalId!);
intervalId = null;
isStreaming.value = false;
}
}, 30);
}
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
<template>
<div>
<el-button
type="primary"
:loading="isStreaming"
style="margin-bottom: 12px"
@click="startStream"
>
{{ isStreaming ? 'Streaming...' : 'Start Streaming' }}
</el-button>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="streamContent"
:enable-animate="true"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
Dark Mode
Use the is-dark prop to toggle between light and dark themes. Shiki syntax highlighting themes switch automatically.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const isDark = ref(false);
const content = `# Dark Mode Example
A code block with syntax highlighting:
\`\`\`typescript
const isDark: boolean = true;
const theme = isDark ? 'vitesse-dark' : 'vitesse-light';
console.log(\`Current theme: \${theme}\`);
\`\`\`
> In dark mode, the code highlighting theme switches automatically.
`;
</script>
<template>
<div>
<el-switch
v-model="isDark"
active-text="Dark"
inactive-text="Light"
style="margin-bottom: 12px"
/>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:is-dark="isDark"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Code Block Configuration
Configure code block header visibility, max height, and sticky header behavior.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `\`\`\`typescript
// A longer code block to demonstrate max height and line numbers
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
createdAt: Date;
}
function createUser(data: Partial<User>): User {
return {
id: Math.floor(Math.random() * 10000),
name: data.name ?? 'Anonymous',
email: data.email ?? '',
role: data.role ?? 'user',
createdAt: new Date(),
};
}
const user = createUser({ name: 'Alice', email: 'alice@example.com' });
console.log(user);
\`\`\``;
const showHeader = ref(true);
const stickyHeader = ref(false);
const codeMaxHeight = ref('200px');
const enableLineNumber = ref(true);
const rendererProps = computed(() => ({
markdown: content,
showCodeBlockHeader: showHeader.value,
stickyCodeBlockHeader: stickyHeader.value,
codeMaxHeight: codeMaxHeight.value,
enableCodeLineNumber: enableLineNumber.value
}));
</script>
<template>
<div>
<div
style="
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
align-items: center;
"
>
<el-checkbox v-model="showHeader"> Show Header </el-checkbox>
<el-checkbox v-model="stickyHeader">
Sticky Header (fixes on page scroll)
</el-checkbox>
<el-checkbox v-model="enableLineNumber"> Show Line Numbers </el-checkbox>
<span>
Max Height:
<el-input v-model="codeMaxHeight" size="small" style="width: 100px" />
</span>
</div>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
v-bind="rendererProps"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
Sticky Code Header with BubbleList
Place MarkdownRenderer inside BubbleList's #content slot so BubbleList becomes the scroll container. When the list is scrolled, the code block header sticks to the top of the list's visible area — never occluded by the page navbar.
Place MarkdownRenderer inside BubbleList's #content slot so BubbleList acts as the scroll container. When the list is scrolled, the code block language label and action bar stick to the top of BubbleList's visible area — not the page viewport — so the document navbar never occludes it.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
interface MessageItem {
key: number;
placement: 'start' | 'end';
content: string;
avatar: string;
}
const messages: MessageItem[] = [
{
key: 1,
placement: 'end',
avatar: 'https://avatars.githubusercontent.com/u/1?s=40&v=4',
content: 'Can you write a complete TypeScript user management module?'
},
{
key: 2,
placement: 'start',
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
content: `Sure! Here is a full TypeScript user management module:
\`\`\`typescript
// user.ts — User management module
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface User {
id: number;
name: string;
email: string;
role: UserRole;
createdAt: Date;
updatedAt: Date;
}
export interface CreateUserDTO {
name: string;
email: string;
role?: UserRole;
}
export interface UpdateUserDTO {
name?: string;
email?: string;
role?: UserRole;
}
const store = new Map<number, User>();
let nextId = 1;
export function createUser(dto: CreateUserDTO): User {
const now = new Date();
const user: User = {
id: nextId++,
name: dto.name,
email: dto.email,
role: dto.role ?? 'viewer',
createdAt: now,
updatedAt: now,
};
store.set(user.id, user);
return user;
}
export function getUserById(id: number): User | undefined {
return store.get(id);
}
export function updateUser(id: number, dto: UpdateUserDTO): User {
const user = store.get(id);
if (!user) throw new Error(\`User \${id} not found\`);
Object.assign(user, dto, { updatedAt: new Date() });
return user;
}
export function deleteUser(id: number): boolean {
return store.delete(id);
}
export function listUsers(role?: UserRole): User[] {
const all = Array.from(store.values());
return role ? all.filter(u => u.role === role) : all;
}
// Usage
const alice = createUser({ name: 'Alice', email: 'alice@example.com', role: 'admin' });
const bob = createUser({ name: 'Bob', email: 'bob@example.com' });
console.log(listUsers()); // [alice, bob]
console.log(listUsers('admin')); // [alice]
updateUser(bob.id, { role: 'editor' });
console.log(getUserById(bob.id)); // { ...bob, role: 'editor' }
deleteUser(alice.id);
console.log(listUsers()); // [bob]
\`\`\`
The module includes type definitions, CRUD operations and an in-memory store — swap the store for a database adapter as needed.`
},
{
key: 3,
placement: 'end',
avatar: 'https://avatars.githubusercontent.com/u/1?s=40&v=4',
content: 'Now show me a Vue 3 Composition API counter component.'
},
{
key: 4,
placement: 'start',
avatar: 'https://avatars.githubusercontent.com/u/76239030?s=40&v=4',
content: `Here is a fully typed Vue 3 counter component using the Composition API:
\`\`\`vue
<!-- Counter.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue';
interface Props {
min?: number;
max?: number;
step?: number;
}
const props = withDefaults(defineProps<Props>(), {
min: 0,
max: 100,
step: 1,
});
const emit = defineEmits<{
change: [value: number];
}>();
const count = ref(props.min);
const canDecrement = computed(() => count.value - props.step >= props.min);
const canIncrement = computed(() => count.value + props.step <= props.max);
function decrement() {
if (canDecrement.value) {
count.value -= props.step;
emit('change', count.value);
}
}
function increment() {
if (canIncrement.value) {
count.value += props.step;
emit('change', count.value);
}
}
function reset() {
count.value = props.min;
emit('change', count.value);
}
<\/script>
<template>
<div class="counter">
<button :disabled="!canDecrement" @click="decrement">-</button>
<span class="value">{{ count }}</span>
<button :disabled="!canIncrement" @click="increment">+</button>
<button class="reset" @click="reset">Reset</button>
</div>
</template>
<style scoped>
.counter {
display: inline-flex;
align-items: center;
gap: 8px;
}
.value {
min-width: 3ch;
text-align: center;
font-size: 1.2em;
font-weight: 600;
}
button {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid #ddd;
cursor: pointer;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.reset {
font-size: 0.85em;
color: #666;
}
</style>
\`\`\`
The component accepts \`min\`, \`max\` and \`step\` props and emits a \`change\` event with the new value.`
}
];
const rendererProps = computed(() => ({
showCodeBlockHeader: true,
stickyCodeBlockHeader: true,
enableCodeLineNumber: true
}));
</script>
<template>
<div class="bubble-sticky-demo">
<div class="hint">
💡 Scroll the list down — the code block header will stick to the
<strong>top of the BubbleList viewport</strong>, not the page top.
</div>
<div class="list-stage">
<BubbleList :list="messages">
<template #content="{ item }">
<component
:is="MarkdownRenderer"
v-if="item.placement === 'start' && MarkdownRenderer"
:markdown="item.content"
v-bind="rendererProps"
/>
<span v-else>{{ item.content }}</span>
</template>
</BubbleList>
</div>
</div>
</template>
<style scoped lang="scss">
.bubble-sticky-demo {
display: flex;
flex-direction: column;
gap: 12px;
.hint {
padding: 10px 14px;
border-radius: 8px;
background: linear-gradient(135deg, #ecf5ff 0%, #f0f9eb 100%);
border: 1px solid #d9ecff;
font-size: 13px;
color: #409eff;
strong {
color: #67c23a;
}
}
.list-stage {
height: 460px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
overflow: hidden;
padding: 8px 10px;
}
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
LaTeX Math Formulas
Install katex and import its CSS:
import 'katex/dist/katex.min.css';Enable LaTeX math with enable-latex. Install katex and import its CSS file.
<script setup lang="ts">
import 'katex/dist/katex.min.css';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `### Inline Formulas
Euler's formula: $e^{i\\pi} + 1 = 0$
Quadratic formula: $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$
Dot product: $\\vec{a} \\cdot \\vec{b} = a_x b_x + a_y b_y + a_z b_z$
### Block Formulas
Fourier Transform:
$$
F(\\omega) = \\int_{-\\infty}^{\\infty} f(t) e^{-i\\omega t} dt
$$
Matrix multiplication:
$$
\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix} \\cdot \\begin{bmatrix} x \\\\ y \\end{bmatrix} = \\begin{bmatrix} ax+by \\\\ cx+dy \\end{bmatrix}
$$
Boxed formula:
$$\\boxed{E = mc^2}$$
`;
</script>
<template>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:enable-latex="true"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Mermaid Diagrams
Install mermaid first: pnpm add mermaid
Enable enable-mermaid to render flowcharts, sequence diagrams, and more. Install mermaid first.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `\`\`\`mermaid
graph TD
A[Start] --> B{Logged in?}
B -->|Yes| C[Home Page]
B -->|No| D[Login Page]
D --> E[Enter Credentials]
E --> F{Valid?}
F -->|Yes| C
F -->|No| G[Show Error]
G --> D
\`\`\`
\`\`\`mermaid
sequenceDiagram
User->>Frontend: Click Send
Frontend->>Server: POST /api/chat
Server-->>Frontend: Streaming Response (SSE)
Frontend-->>User: Real-time Markdown Render
\`\`\`
`;
</script>
<template>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:enable-mermaid="true"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Custom Code Block Actions
Pass a code-block-actions array to add custom buttons to code block headers. Use the show callback to conditionally display buttons per language.
onClick receives a CodeBlockSlotProps object:
| Property | Type | Description |
|---|---|---|
language | string | Code language |
code | string | Code content |
copy | (text: string) => void | Copy function |
copied | boolean | Whether copied |
collapsed | boolean | Whether collapsed |
toggleCollapse | () => void | Toggle collapse |
Pass a code-block-actions array to add custom buttons to code block headers. Use show to conditionally display buttons.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `\`\`\`typescript
function greet(name: string): string {
return \`Hello, \${name}!\`;
}
console.log(greet('World'));
\`\`\`
\`\`\`python
def greet(name: str) -> str:
return f"Hello, {name}!"
print(greet("World"))
\`\`\`
`;
const lastAction = ref('');
const codeBlockActions = [
{
key: 'run',
title: 'Run Code',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7L8 5z"/></svg>`,
show: (props: any) =>
['typescript', 'ts', 'javascript', 'js'].includes(props.language),
onClick: (props: any) => {
lastAction.value = `Ran ${props.language} code (${props.code.length} chars)`;
}
},
{
key: 'insert',
title: 'Insert to Editor',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`,
onClick: (props: any) => {
lastAction.value = `Inserted ${props.language} code block to editor`;
}
}
];
</script>
<template>
<div>
<div
v-if="lastAction"
style="
margin-bottom: 8px;
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
color: #0369a1;
font-size: 13px;
"
>
Action log: {{ lastAction }}
</div>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:code-block-actions="codeBlockActions"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
Custom Mermaid Actions
Pass a mermaid-actions array to add custom buttons to the Mermaid diagram toolbar.
onClick receives a MermaidSlotProps object:
| Property | Type | Description |
|---|---|---|
showSourceCode | boolean | Whether showing source code view |
svg | string | Rendered SVG string |
rawContent | string | Raw Mermaid source code |
isLoading | boolean | Whether currently rendering |
zoomIn | () => void | Zoom in |
zoomOut | () => void | Zoom out |
reset | () => void | Reset zoom |
fullscreen | () => void | Fullscreen |
toggleCode | () => void | Toggle source/diagram view |
copyCode | () => Promise<void> | Copy source code |
download | () => void | Download SVG |
Pass a mermaid-actions array to add custom buttons to the Mermaid diagram toolbar.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `\`\`\`mermaid
graph LR
A[Client] --> B[Load Balancer]
B --> C[Service A]
B --> D[Service B]
C --> E[(Database)]
D --> E
\`\`\`
`;
const lastAction = ref('');
const mermaidActions = [
{
key: 'share',
title: 'Share Diagram',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg>`,
onClick: (props: any) => {
lastAction.value = `Shared diagram, SVG length: ${props.svg.length} chars`;
}
},
{
key: 'edit-online',
title: 'Edit Online',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`,
show: (props: any) => !props.showSourceCode,
onClick: (props: any) => {
lastAction.value = `Open editor, content: ${props.rawContent.substring(0, 40)}...`;
}
}
];
</script>
<template>
<div>
<div
v-if="lastAction"
style="
margin-bottom: 8px;
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
color: #0369a1;
font-size: 13px;
"
>
Action log: {{ lastAction }}
</div>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:mermaid-actions="mermaidActions"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
Custom Code Block Renderer
Use code-x-render to replace the default code block renderer for specific languages with a fully custom component.
<script setup lang="ts">
import { h } from 'vue';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `\`\`\`json
{
"name": "element-plus-x",
"version": "2.0.0",
"description": "An AI component library for Vue 3",
"keywords": ["vue", "ai", "chat", "markdown"],
"license": "MIT"
}
\`\`\`
\`\`\`typescript
// TypeScript code blocks still render normally
const msg: string = 'Hello, World!';
console.log(msg);
\`\`\`
`;
function codeXRender(props: { language: string; code: string }) {
if (props.language !== 'json') return null;
let parsed: any;
try {
parsed = JSON.parse(props.code);
} catch {
return h('pre', { style: 'color: red;' }, 'Invalid JSON');
}
const entries = Object.entries(parsed);
return h(
'div',
{
style:
'border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; font-family: monospace; font-size: 13px;'
},
[
h(
'div',
{
style:
'background: #f3f4f6; padding: 6px 12px; font-size: 12px; color: #6b7280; border-bottom: 1px solid #e5e7eb;'
},
'JSON Viewer'
),
h(
'div',
{ style: 'padding: 12px;' },
entries.map(([key, val]) =>
h('div', { style: 'display: flex; gap: 8px; padding: 3px 0;' }, [
h(
'span',
{ style: 'color: #9333ea; min-width: 120px;' },
`"${key}"`
),
h('span', { style: 'color: #6b7280;' }, ':'),
h(
'span',
{
style: `color: ${typeof val === 'string' ? '#16a34a' : '#2563eb'};`
},
typeof val === 'string'
? `"${val}"`
: Array.isArray(val)
? `[${(val as string[]).join(', ')}]`
: String(val)
)
])
)
)
]
);
}
</script>
<template>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:code-x-render="codeXRender"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
Custom Attributes
Add custom HTML attributes to any Markdown element via custom-attrs. Useful for styling or behavior customization.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `# First Heading (Blue)
## Second Heading (Green)
### Third Heading (Orange)
[External Link (opens in new tab)](https://github.com/element-plus-x)

`;
const customAttrs = {
h1: { style: 'color: #3b82f6;' },
h2: { style: 'color: #22c55e;' },
h3: { style: 'color: #f97316;' },
a: { target: '_blank', rel: 'noopener noreferrer' },
img: { loading: 'lazy' }
};
</script>
<template>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:custom-attrs="customAttrs"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Custom Slots
Supported slot names:
| Slot | Description |
|---|---|
heading / h1 ~ h6 | Headings |
code / inline-code / block-code | Code |
blockquote | Blockquote |
list / ul / ol / li / list-item | Lists |
table / thead / tbody / tr / td / th | Tables |
a | Links |
img | Images |
p / strong / em | Inline elements |
| Any standard HTML tag name | — |
Override Markdown element rendering using named slots for complete control over the HTML output.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `# Custom Heading Slot
## Another Section Heading
> This is a blockquote styled with the custom slot.
> It demonstrates full rendering control.
[Click me](https://github.com/element-plus-x) — this link uses a custom slot.
`;
</script>
<template>
<component :is="MarkdownRenderer" v-if="MarkdownRenderer" :markdown="content">
<template #heading="{ children, depth }">
<component
:is="`h${depth}`"
:style="{
borderLeft: `4px solid ${['#3b82f6', '#22c55e', '#f97316'][depth - 1] || '#888'}`,
paddingLeft: '12px',
margin: '16px 0 8px'
}"
>
<component :is="() => children" />
</component>
</template>
<template #blockquote="{ children }">
<blockquote
style="
background: linear-gradient(135deg, #eff6ff, #f0fdf4);
border-left: 4px solid #3b82f6;
padding: 12px 16px;
border-radius: 0 8px 8px 0;
margin: 12px 0;
"
>
<component :is="() => children" />
</blockquote>
</template>
<template #a="{ node, children }">
<a
:href="node.properties?.href as string"
target="_blank"
rel="noopener noreferrer"
style="
color: #7c3aed;
text-decoration: none;
border-bottom: 1px dashed #7c3aed;
"
>
<component :is="() => children" />↗
</a>
</template>
</component>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
Custom Table with el-table
Use the #table slot to replace all GFM tables in Markdown with el-table. The hast node passed to the slot contains the full table structure — a helper extracts columns and rows, then feeds them to el-table.
Override Markdown GFM table rendering via the #table slot. The slot exposes children (a function returning a VNode array) — recursively walk <thead> / <tbody> to collect columns and rows, then render with el-table for borders, stripes, and more.
<script setup lang="ts">
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `## Frontend Framework Comparison
| Framework | Latest Version | GitHub Stars | Learning Curve | TypeScript |
|-----------|---------------|-------------|----------------|-----------|
| Vue | 3.4 | 48k | Low | ✅ |
| React | 18.3 | 225k | Medium | ✅ |
| Angular | 17 | 93k | High | ✅ |
| Svelte | 5.0 | 78k | Low | ✅ |
## Component Library Comparison
| Library | Framework | Theming | Enterprise | i18n |
|---------|-----------|---------|------------|------|
| Element Plus | Vue 3 | ✅ | ✅ | ✅ |
| Ant Design | React | ✅ | ✅ | ✅ |
| Vuetify | Vue 3 | ✅ | ✅ | ✅ |
| Naive UI | Vue 3 | ✅ | ❌ | ✅ |
`;
function vnodeText(vn: any): string {
if (vn == null || vn === false || vn === true) return '';
if (typeof vn === 'string' || typeof vn === 'number') return String(vn);
if (Array.isArray(vn)) return vn.map(vnodeText).join('');
if (typeof vn === 'object' && 'children' in vn)
return vnodeText((vn as any).children);
return '';
}
function findByTag(children: any, tag: string): any[] {
if (!children) return [];
const list = Array.isArray(children) ? children : [children];
return list.filter(
c => c && typeof c === 'object' && (c as any).type === tag
);
}
function extractTableData(children: any) {
const columns: string[] = [];
const rows: Record<string, string>[] = [];
const top = Array.isArray(children) ? children : [children];
for (const section of top) {
if (!section || typeof section !== 'object') continue;
if ((section as any).type === 'thead') {
const tr = findByTag((section as any).children, 'tr')[0];
const ths = findByTag(tr?.children, 'th');
for (const th of ths) columns.push(vnodeText(th.children));
} else if ((section as any).type === 'tbody') {
const trs = findByTag((section as any).children, 'tr');
for (const tr of trs) {
const tds = findByTag(tr.children, 'td');
const row: Record<string, string> = {};
tds.forEach((td, i) => {
row[columns[i] ?? String(i)] = vnodeText(td.children);
});
rows.push(row);
}
}
}
return { columns, rows };
}
</script>
<template>
<component :is="MarkdownRenderer" v-if="MarkdownRenderer" :markdown="content">
<!-- Replace all Markdown tables with el-table -->
<template #table="{ children }">
<div style="margin: 16px 0">
<template v-if="children">
<el-table
:data="extractTableData(children()).rows"
border
stripe
style="width: 100%"
size="small"
>
<el-table-column
v-for="col in extractTableData(children()).columns"
:key="col"
:prop="col"
:label="col"
min-width="100"
/>
</el-table>
</template>
</div>
</template>
</component>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
Custom Code Block Components (el-table & my-echarts)
Define your own "language" tags via code-x-render. Write JSON inside a fenced code block, set the language to el-table or my-echarts, and the renderer maps each block to a real Vue component.
el-table— parses JSON{ columns, rows }and renders as an Element Plus tablemy-echarts— parses an EChartsoptionJSON and renders as an interactive chart
Use code-x-render to render specific code block languages as Vue components:
el-tableblocks — render JSON data as an Element Plus tablemy-echartsblocks — render ECharts JSON config as an interactive chart
<script setup lang="ts">
import { ElTable, ElTableColumn } from 'element-plus';
import {
defineComponent,
h,
computed as vComputed,
onMounted as vOnMounted,
ref as vRef
} from 'vue';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const content = `## Display Data with \`el-table\`
Write JSON in a code block and set the language to \`el-table\` to auto-render as an Element Plus table:
\`\`\`el-table
{
"columns": ["Name", "Role", "Department", "Year Joined", "Status"],
"rows": [
{ "Name": "Alice", "Role": "Frontend Engineer", "Department": "R&D", "Year Joined": "2021", "Status": "Active" },
{ "Name": "Bob", "Role": "Product Manager", "Department": "Product", "Year Joined": "2020", "Status": "Active" },
{ "Name": "Charlie", "Role": "UI Designer", "Department": "Design", "Year Joined": "2022", "Status": "Active" },
{ "Name": "Diana", "Role": "Backend Engineer", "Department": "R&D", "Year Joined": "2019", "Status": "Active" },
{ "Name": "Evan", "Role": "QA Engineer", "Department": "QA", "Year Joined": "2023", "Status": "Probation" }
]
}
\`\`\`
## Display Charts with \`my-echarts\`
Write an ECharts option JSON and set the language to \`my-echarts\` to auto-render as an interactive chart:
\`\`\`my-echarts
{
"title": { "text": "Team Distribution by Department", "left": "center" },
"tooltip": { "trigger": "item", "formatter": "{b}: {c} ({d}%)" },
"legend": { "bottom": "5%" },
"series": [{
"type": "pie",
"radius": ["40%", "70%"],
"avoidLabelOverlap": true,
"itemStyle": { "borderRadius": 8, "borderWidth": 2 },
"label": { "show": true },
"data": [
{ "value": 45, "name": "R&D" },
{ "value": 20, "name": "Product" },
{ "value": 15, "name": "Design" },
{ "value": 12, "name": "QA" },
{ "value": 8, "name": "Marketing" }
]
}]
}
\`\`\`
`;
// ─── el-table component ───────────────────────────────────────────────────────
const ElTableBlock = defineComponent({
name: 'ElTableBlock',
props: {
rawJson: { type: String, required: true }
},
setup(props) {
const parsed = vComputed(() => {
try {
const v = JSON.parse(props.rawJson);
return {
columns: Array.isArray(v.columns) ? v.columns : [],
rows: Array.isArray(v.rows) ? v.rows : []
};
} catch {
return { columns: [] as string[], rows: [] as any[] };
}
});
return () =>
h('div', { style: 'margin:16px 0;' }, [
h(
ElTable as any,
{
data: parsed.value.rows,
border: true,
stripe: true,
style: { width: '100%' },
size: 'small'
},
{
default: () =>
parsed.value.columns.map((col: string) =>
h(ElTableColumn as any, {
key: col,
prop: col,
label: col,
minWidth: 100
})
)
}
)
]);
}
});
// ─── ECharts component ────────────────────────────────────────────────────────
const MyEchartsBlock = defineComponent({
name: 'MyEchartsBlock',
props: {
option: { type: Object, required: true }
},
setup(props) {
const chartEl = vRef<HTMLDivElement>();
vOnMounted(async () => {
if (!chartEl.value) return;
try {
const echarts = await import('echarts');
const chart = echarts.init(chartEl.value);
chart.setOption(props.option);
const ro = new ResizeObserver(() => chart.resize());
ro.observe(chartEl.value);
} catch (e) {
console.warn('[my-echarts] echarts init failed', e);
}
});
return () =>
h('div', {
ref: chartEl,
style: 'width:100%;height:320px;margin:16px 0;'
});
}
});
// ─── codeXRender map ──────────────────────────────────────────────────────────
const codeXRender = {
'el-table': (props: any) => h(ElTableBlock, { rawJson: props.raw.content }),
'my-echarts': (props: any) => {
try {
const option = JSON.parse(props.raw.content);
return h(MyEchartsBlock, { option });
} catch {
return h(
'div',
{
style:
'color:#f56c6c;padding:8px;border:1px dashed #f56c6c;border-radius:4px;'
},
'⚠ Failed to parse chart JSON — check the syntax.'
);
}
}
};
</script>
<template>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="content"
:code-x-render="codeXRender"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
Plugin System
Extend the parsing pipeline with remark/rehype plugins:
import remarkEmoji from 'remark-emoji';
import rehypeSlug from 'rehype-slug';
const remarkPlugins = [remarkEmoji];
const rehypePlugins = [rehypeSlug];2
3
4
5
<MarkdownRenderer
:markdown="content"
:remark-plugins="remarkPlugins"
:rehype-plugins="rehypePlugins"
/>2
3
4
5
Security
Enable sanitize to strip unsafe HTML before rendering, preventing XSS:
<MarkdownRenderer
:markdown="content"
:sanitize="true"
:sanitize-options="{ allowedTags: ['b', 'i', 'em', 'strong', 'a'] }"
/>2
3
4
5
Streaming Custom Code Blocks (with Skeleton Placeholder)
A real-world AI streaming scenario: while the JSON is being assembled, show an el-skeleton placeholder; once the data becomes parseable, swap to the real el-table / el-form / my-echarts component.
A real-world AI streaming scenario: while the JSON inside an `el-table` / `el-form` / `my-echarts` code block is still being assembled, show an el-skeleton placeholder; once the JSON becomes valid, swap to the actual component.
The trick: inside code-x-render, try JSON.parse(props.raw.content). Parse fails → skeleton; parse succeeds → real component. Combined with streaming updates this naturally produces a "skeleton → content" transition.
<script setup lang="ts">
import {
ElForm,
ElFormItem,
ElInput,
ElSkeleton,
ElTable,
ElTableColumn
} from 'element-plus';
import { defineComponent, h, onMounted as vOnMounted, ref as vRef } from 'vue';
import 'shiki';
import 'shiki-stream';
const MarkdownRenderer = shallowRef();
onMounted(async () => {
if (typeof window === 'undefined') return;
await import('x-markdown-vue/style');
const mod = await import('x-markdown-vue');
MarkdownRenderer.value = mod.MarkdownRenderer ?? mod.default ?? mod;
});
const fullText = `## Streaming Output Example
Each block below shows a skeleton while streaming and swaps to the real component once its JSON is complete:
\`\`\`el-table
{
"columns": ["Name", "Role", "Department"],
"rows": [
{ "Name": "Alice", "Role": "Frontend Engineer", "Department": "R&D" },
{ "Name": "Bob", "Role": "Product Manager", "Department": "Product" },
{ "Name": "Charlie", "Role": "UI Designer", "Department": "Design" }
]
}
\`\`\`
\`\`\`el-form
{
"fields": [
{ "label": "Name", "value": "Alice" },
{ "label": "Email", "value": "alice@example.com" },
{ "label": "Dept", "value": "R&D" }
]
}
\`\`\`
\`\`\`my-echarts
{
"title": { "text": "Quarterly Sales", "left": "center" },
"tooltip": {},
"xAxis": { "type": "category", "data": ["Q1","Q2","Q3","Q4"] },
"yAxis": { "type": "value" },
"series": [{ "type": "bar", "data": [120, 200, 150, 320], "itemStyle": { "borderRadius": 6 } }]
}
\`\`\`
`;
const streamContent = ref('');
let intervalId: ReturnType<typeof setInterval> | null = null;
const isStreaming = ref(false);
function startStream() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
streamContent.value = '';
isStreaming.value = true;
let index = 0;
intervalId = setInterval(() => {
if (index < fullText.length) {
const step = 4;
streamContent.value += fullText.slice(index, index + step);
index += step;
} else {
clearInterval(intervalId!);
intervalId = null;
isStreaming.value = false;
}
}, 40);
}
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
// ─── Real components ─────────────────────────────────────────────────────────
const ElTableBlock = defineComponent({
name: 'ElTableBlock',
props: { data: { type: Object, required: true } },
setup(props) {
return () =>
h('div', { style: 'margin:16px 0;' }, [
h(
ElTable as any,
{
data: props.data.rows ?? [],
border: true,
stripe: true,
style: { width: '100%' },
size: 'small'
},
{
default: () =>
(props.data.columns ?? []).map((col: string) =>
h(ElTableColumn as any, {
key: col,
prop: col,
label: col,
minWidth: 100
})
)
}
)
]);
}
});
const ElFormBlock = defineComponent({
name: 'ElFormBlock',
props: { data: { type: Object, required: true } },
setup(props) {
return () =>
h(
'div',
{
style:
'margin:16px 0;padding:12px;border:1px solid #ebeef5;border-radius:6px;'
},
[
h(
ElForm as any,
{ labelWidth: '80px', size: 'small' },
{
default: () =>
(props.data.fields ?? []).map((f: any, i: number) =>
h(
ElFormItem as any,
{ key: i, label: f.label },
{
default: () =>
h(ElInput as any, {
modelValue: f.value,
'onUpdate:modelValue': () => {}
})
}
)
)
}
)
]
);
}
});
const MyEchartsBlock = defineComponent({
name: 'MyEchartsBlock',
props: { option: { type: Object, required: true } },
setup(props) {
const chartEl = vRef<HTMLDivElement>();
vOnMounted(async () => {
if (!chartEl.value) return;
try {
const echarts = await import('echarts');
const chart = echarts.init(chartEl.value);
chart.setOption(props.option);
const ro = new ResizeObserver(() => chart.resize());
ro.observe(chartEl.value);
} catch (e) {
console.warn('[my-echarts] init failed', e);
}
});
return () =>
h('div', {
ref: chartEl,
style: 'width:100%;height:300px;margin:16px 0;'
});
}
});
// ─── Skeleton placeholder ────────────────────────────────────────────────────
function skeletonOf(kind: 'el-table' | 'el-form' | 'my-echarts') {
const rows = kind === 'my-echarts' ? 6 : 4;
return h(
'div',
{
style:
'margin:16px 0;padding:12px;border:1px dashed #dcdfe6;border-radius:6px;background:#fafafa;'
},
[
h(
'div',
{ style: 'font-size:12px;color:#909399;margin-bottom:8px;' },
`⏳ Streaming ${kind} data...`
),
h(ElSkeleton as any, { rows, animated: true })
]
);
}
// ─── codeXRender: parse fail → skeleton; success → real component ────────────
function safeParse(s: string): { ok: true; value: any } | { ok: false } {
try {
return { ok: true, value: JSON.parse(s) };
} catch {
return { ok: false };
}
}
const codeXRender = {
'el-table': (props: any) => {
const r = safeParse(props.raw.content);
return r.ok ? h(ElTableBlock, { data: r.value }) : skeletonOf('el-table');
},
'el-form': (props: any) => {
const r = safeParse(props.raw.content);
return r.ok ? h(ElFormBlock, { data: r.value }) : skeletonOf('el-form');
},
'my-echarts': (props: any) => {
const r = safeParse(props.raw.content);
return r.ok
? h(MyEchartsBlock, { option: r.value })
: skeletonOf('my-echarts');
}
};
</script>
<template>
<div>
<el-button
type="primary"
:loading="isStreaming"
style="margin-bottom: 12px"
@click="startStream"
>
{{ isStreaming ? 'Streaming...' : 'Start Streaming' }}
</el-button>
<component
:is="MarkdownRenderer"
v-if="MarkdownRenderer"
:markdown="streamContent"
:code-x-render="codeXRender"
/>
<div v-else style="padding: 16px; color: #999">Loading...</div>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
markdown | string | '' | Markdown text content |
allow-html | boolean | false | Allow raw HTML in content |
enable-latex | boolean | true | Enable LaTeX math rendering |
enable-animate | boolean | false | Enable streaming animation |
enable-breaks | boolean | true | Convert single newlines to <br> |
enable-gfm | boolean | true | Enable GitHub Flavored Markdown |
enable-shiki | boolean | true | Enable Shiki syntax highlighting |
enable-mermaid | boolean | true | Enable Mermaid diagram rendering |
is-dark | boolean | false | Dark mode |
shiki-theme | [BuiltinTheme, BuiltinTheme] | ['vitesse-light', 'vitesse-dark'] | Shiki light/dark themes |
show-code-block-header | boolean | true | Show code block header bar |
sticky-code-block-header | boolean | false | Sticky code block header |
code-max-height | string | undefined | Max height of code blocks, e.g. '300px' |
code-block-actions | CodeBlockAction[] | [] | Custom code block action buttons |
mermaid-actions | MermaidAction[] | [] | Custom Mermaid action buttons |
mermaid-config | object | {} | Mermaid initialization config |
code-x-render | object | {} | Custom code block render functions |
custom-attrs | CustomAttrs | {} | Custom element attributes |
remark-plugins | PluggableList | [] | remark plugins (run after built-ins) |
remark-plugins-ahead | PluggableList | [] | remark plugins (run before built-ins) |
rehype-plugins | PluggableList | [] | rehype plugins (run after built-ins) |
rehype-plugins-ahead | PluggableList | [] | rehype plugins (run before built-ins) |
rehype-options | object | {} | rehype processor options |
sanitize | boolean | false | Enable HTML sanitization (XSS prevention) |
sanitize-options | SanitizeOptions | {} | Sanitization options |
CodeBlockAction Type
interface CodeBlockAction {
key: string;
icon?: Component | FunctionalComponent | string;
title?: string;
onClick?: (props: CodeBlockSlotProps) => void;
disabled?: boolean;
class?: string;
style?: Record<string, string>;
show?: (props: CodeBlockSlotProps) => boolean;
}2
3
4
5
6
7
8
9
10
MermaidAction Type
interface MermaidAction {
key: string;
icon?: Component | FunctionalComponent | string;
title?: string;
onClick?: (props: MermaidSlotProps) => void;
disabled?: boolean;
class?: string;
style?: Record<string, string>;
show?: (props: MermaidSlotProps) => boolean;
}2
3
4
5
6
7
8
9
10
