@krishanjinbo/vue-markdown-stream 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/components/MarkdownRenderer.d.ts +17 -0
- package/dist/components/blocks/AlertBlock.vue.d.ts +23 -0
- package/dist/components/blocks/DataCard.vue.d.ts +20 -0
- package/dist/composables/useMarkdownParser.d.ts +5 -0
- package/dist/composables/useStreamingText.d.ts +11 -0
- package/dist/index.d.ts +8 -0
- package/dist/utils/autoClose.d.ts +14 -0
- package/dist/utils/htmlToVnodes.d.ts +7 -0
- package/dist/vue-markdown-stream.cjs.js +71 -0
- package/dist/vue-markdown-stream.cjs.js.map +1 -0
- package/dist/vue-markdown-stream.css +1 -0
- package/dist/vue-markdown-stream.es.js +256 -0
- package/dist/vue-markdown-stream.es.js.map +1 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hanlang123
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# vue-markdown-stream
|
|
2
|
+
|
|
3
|
+
> 流式 Markdown 渲染 + Vue 3 组件块,基于 `markdown-it-container`
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@krishanjinbo/vue-markdown-stream)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
在 AI 流式输出场景中,将 `:::alert`、`:::card` 等 Markdown 容器块实时渲染为真实 Vue 3 组件,支持响应式 props 和 slot 内容。
|
|
9
|
+
|
|
10
|
+
**📖 [完整文档](https://hanlang123.github.io/vue-markdown-stream/) · [在线演示](https://hanlang123.github.io/vue-markdown-stream/demo)**
|
|
11
|
+
|
|
12
|
+
## 特性
|
|
13
|
+
|
|
14
|
+
- ⚡ **流式打字机渲染** — 逐字追加,自动补全未闭合 `:::` 块
|
|
15
|
+
- 🧩 **Vue 组件块** — `:::alert` / `:::card` 渲染为真实 Vue 组件(响应式 props + slot)
|
|
16
|
+
- 🔌 **完全可扩展** — 5 行代码注册任意自定义块组件
|
|
17
|
+
- 🪶 **轻量** — 基于 `DOMParser` + `h()` VNode 树,无运行时编译,bundle 增量约 40KB
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @krishanjinbo/vue-markdown-stream markdown-it markdown-it-container
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 快速上手
|
|
26
|
+
|
|
27
|
+
```vue
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { computed } from 'vue'
|
|
30
|
+
import { MarkdownRenderer, useStreamingText } from '@krishanjinbo/vue-markdown-stream'
|
|
31
|
+
|
|
32
|
+
const { text, isStreaming, startStream, resetStream } = useStreamingText()
|
|
33
|
+
const display = computed(() => isStreaming.value ? text.value + '▍' : text.value)
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<button @click="startStream">开始流式输出</button>
|
|
38
|
+
<button @click="resetStream">重置</button>
|
|
39
|
+
<MarkdownRenderer :content="display" />
|
|
40
|
+
</template>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Markdown 语法
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
::: alert info
|
|
47
|
+
**提示**:这是一个 Info 告警块。
|
|
48
|
+
:::
|
|
49
|
+
|
|
50
|
+
::: alert warning
|
|
51
|
+
注意:`未闭合的块`在流式中间态会被自动补全。
|
|
52
|
+
:::
|
|
53
|
+
|
|
54
|
+
::: card 数据卡片标题
|
|
55
|
+
| 列A | 列B |
|
|
56
|
+
|-----|-----|
|
|
57
|
+
| 值1 | 值2 |
|
|
58
|
+
:::
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 渲染架构
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
流式 chunk
|
|
65
|
+
→ autoCloseContainers() 补全未闭合 ::: 块
|
|
66
|
+
→ markdown-it + container 输出含 <vue-block> 的 HTML
|
|
67
|
+
→ DOMParser HTML → DOM 树
|
|
68
|
+
→ h() VNode 构建 <vue-block> → Vue 组件 VNode
|
|
69
|
+
→ MarkdownRenderer 渲染输出
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 本地开发
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install
|
|
76
|
+
npm run dev # http://localhost:5173/
|
|
77
|
+
npm run build # App 构建
|
|
78
|
+
npm run build:lib # 库构建(dist/)
|
|
79
|
+
npm run docs:dev # 文档本地预览
|
|
80
|
+
npm run docs:build # 文档构建
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
[MIT](./LICENSE) © 2026 hanlang123
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PropType, VNode } from 'vue';
|
|
2
|
+
declare const _default: import('vue').DefineComponent<import('vue').ExtractPropTypes<{
|
|
3
|
+
content: {
|
|
4
|
+
type: PropType<string>;
|
|
5
|
+
default: string;
|
|
6
|
+
};
|
|
7
|
+
}>, () => VNode<import('vue').RendererNode, import('vue').RendererElement, {
|
|
8
|
+
[key: string]: any;
|
|
9
|
+
}>, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<import('vue').ExtractPropTypes<{
|
|
10
|
+
content: {
|
|
11
|
+
type: PropType<string>;
|
|
12
|
+
default: string;
|
|
13
|
+
};
|
|
14
|
+
}>> & Readonly<{}>, {
|
|
15
|
+
content: string;
|
|
16
|
+
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
|
|
17
|
+
export default _default;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type AlertType = 'info' | 'success' | 'warning' | 'error';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
type?: AlertType;
|
|
4
|
+
};
|
|
5
|
+
declare function __VLS_template(): {
|
|
6
|
+
attrs: Partial<{}>;
|
|
7
|
+
slots: {
|
|
8
|
+
default?(_: {}): any;
|
|
9
|
+
};
|
|
10
|
+
refs: {};
|
|
11
|
+
rootEl: HTMLDivElement;
|
|
12
|
+
};
|
|
13
|
+
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
14
|
+
declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
|
|
15
|
+
type: AlertType;
|
|
16
|
+
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
17
|
+
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
|
|
18
|
+
export default _default;
|
|
19
|
+
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
20
|
+
new (): {
|
|
21
|
+
$slots: S;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
title?: string;
|
|
3
|
+
};
|
|
4
|
+
declare function __VLS_template(): {
|
|
5
|
+
attrs: Partial<{}>;
|
|
6
|
+
slots: {
|
|
7
|
+
default?(_: {}): any;
|
|
8
|
+
};
|
|
9
|
+
refs: {};
|
|
10
|
+
rootEl: HTMLDivElement;
|
|
11
|
+
};
|
|
12
|
+
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
13
|
+
declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
14
|
+
declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, __VLS_TemplateResult["slots"]>;
|
|
15
|
+
export default _default;
|
|
16
|
+
type __VLS_WithTemplateSlots<T, S> = T & {
|
|
17
|
+
new (): {
|
|
18
|
+
$slots: S;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock 流式文本输出
|
|
3
|
+
* 模拟 AI 逐字输出场景
|
|
4
|
+
*/
|
|
5
|
+
export declare function useStreamingText(): {
|
|
6
|
+
text: import('vue').Ref<string, string>;
|
|
7
|
+
isStreaming: import('vue').Ref<boolean, boolean>;
|
|
8
|
+
startStream: () => void;
|
|
9
|
+
stopStream: () => void;
|
|
10
|
+
resetStream: () => void;
|
|
11
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as MarkdownRenderer } from './components/MarkdownRenderer';
|
|
2
|
+
export { default as AlertBlock } from './components/blocks/AlertBlock.vue';
|
|
3
|
+
export { default as DataCard } from './components/blocks/DataCard.vue';
|
|
4
|
+
export { renderMarkdown } from './composables/useMarkdownParser';
|
|
5
|
+
export { useStreamingText } from './composables/useStreamingText';
|
|
6
|
+
export { autoCloseContainers } from './utils/autoClose';
|
|
7
|
+
export { htmlToVnodes } from './utils/htmlToVnodes';
|
|
8
|
+
export type { ComponentMap } from './utils/htmlToVnodes';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流式输出场景下自动补全未闭合的 ::: 容器块
|
|
3
|
+
* markdown-it-container 需要完整的开闭标记才能正确解析
|
|
4
|
+
*
|
|
5
|
+
* 例如流式输出到一半:
|
|
6
|
+
* ::: alert warning
|
|
7
|
+
* 这是一条
|
|
8
|
+
*
|
|
9
|
+
* 会被补全为:
|
|
10
|
+
* ::: alert warning
|
|
11
|
+
* 这是一条
|
|
12
|
+
* :::
|
|
13
|
+
*/
|
|
14
|
+
export declare function autoCloseContainers(raw: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Component, VNode } from 'vue';
|
|
2
|
+
export type ComponentMap = Record<string, Component>;
|
|
3
|
+
/**
|
|
4
|
+
* 将 markdown-it 输出的 HTML 字符串转换为 VNode 数组
|
|
5
|
+
* 其中 <vue-block> 自定义标签会被替换为真实 Vue 组件
|
|
6
|
+
*/
|
|
7
|
+
export declare function htmlToVnodes(html: string, componentMap: ComponentMap): (VNode | string)[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=require("vue"),C=require("markdown-it"),p=require("markdown-it-container");function k(e){const r=e.split(`
|
|
2
|
+
`),t=[];for(const o of r){const a=o.trim();if(/^:::\s+\S/.test(a)){t.push(a);continue}a===":::"&&t.pop()}return t.length===0?e:e+`
|
|
3
|
+
`+t.map(()=>":::").join(`
|
|
4
|
+
`)}const u=new C({html:!0,linkify:!0,typographer:!0});u.use(p,"alert",{validate(e){return/^alert(\s+(info|success|warning|error))?/.test(e.trim())},render(e,r){const t=e[r];return t.nesting===1?`<vue-block data-component="AlertBlock" data-type="${t.info.trim().match(/^alert\s*(\S*)/)?.[1]||"info"}">
|
|
5
|
+
`:`</vue-block>
|
|
6
|
+
`}});u.use(p,"card",{validate(e){return/^card/.test(e.trim())},render(e,r){const t=e[r];return t.nesting===1?`<vue-block data-component="DataCard" data-title="${(t.info.trim().match(/^card\s*(.*)/)?.[1]?.trim()||"").replace(/"/g,""")}">
|
|
7
|
+
`:`</vue-block>
|
|
8
|
+
`}});function h(e){const r=k(e);return u.render(r)}function w(e,r){if(e.nodeType===Node.TEXT_NODE)return e.textContent||"";if(e.nodeType===Node.COMMENT_NODE)return null;const t=e,o=t.tagName?.toLowerCase();if(o==="vue-block"){const c=t.getAttribute("data-component");if(!c||!r[c])return n.h("div",{innerHTML:t.innerHTML});const l=r[c],m={};for(const i of Array.from(t.attributes))if(i.name!=="data-component"&&i.name.startsWith("data-")){const N=i.name.slice(5);m[N]=i.value}const M=d(t.childNodes,r);return n.h(l,m,{default:()=>M})}const a=d(t.childNodes,r),s={};for(const c of Array.from(t.attributes))s[c.name]=c.value;return n.h(o,s,a)}function d(e,r){const t=[];for(const o of Array.from(e)){const a=w(o,r);a!==null&&t.push(a)}return t}function v(e,r){if(!e)return[];const a=new DOMParser().parseFromString(`<div id="__root">${e}</div>`,"text/html").getElementById("__root");return a?d(a.childNodes,r):[]}const b={class:"alert-header"},S={class:"alert-icon"},T={class:"alert-label"},V={class:"alert-body"},D=n.defineComponent({__name:"AlertBlock",props:{type:{default:"info"}},setup(e){const r=e,t={info:"ℹ️",success:"✅",warning:"⚠️",error:"❌"},o={info:"提示",success:"成功",warning:"注意",error:"错误"};return(a,s)=>(n.openBlock(),n.createElementBlock("div",{class:n.normalizeClass(["alert-block",`alert-${r.type}`])},[n.createElementVNode("div",b,[n.createElementVNode("span",S,n.toDisplayString(t[r.type]),1),n.createElementVNode("span",T,n.toDisplayString(o[r.type]),1)]),n.createElementVNode("div",V,[n.renderSlot(a.$slots,"default",{},void 0,!0)])],2))}}),_=(e,r)=>{const t=e.__vccOpts||e;for(const[o,a]of r)t[o]=a;return t},y=_(D,[["__scopeId","data-v-ebc7ae3e"]]),E={class:"data-card"},B={key:0,class:"card-header"},O={class:"card-title"},$={class:"card-body"},A=n.defineComponent({__name:"DataCard",props:{title:{}},setup(e){return(r,t)=>(n.openBlock(),n.createElementBlock("div",E,[e.title?(n.openBlock(),n.createElementBlock("div",B,[t[0]||(t[0]=n.createElementVNode("span",{class:"card-icon"},"📋",-1)),n.createElementVNode("span",O,n.toDisplayString(e.title),1)])):n.createCommentVNode("",!0),n.createElementVNode("div",$,[n.renderSlot(r.$slots,"default",{},void 0,!0)])]))}}),g=_(A,[["__scopeId","data-v-ece81c58"]]),x={AlertBlock:y,DataCard:g},I=n.defineComponent({name:"MarkdownRenderer",props:{content:{type:String,default:""}},setup(e){return()=>{const r=h(e.content),t=v(r,x);return n.h("div",{class:"markdown-body"},t)}}}),f=`# 流式 Markdown 渲染演示
|
|
9
|
+
|
|
10
|
+
这是一个演示**流式输出 Markdown 内容**并将特定块渲染为 Vue 组件的示例。
|
|
11
|
+
|
|
12
|
+
## 功能特性
|
|
13
|
+
|
|
14
|
+
- ✅ 流式打字机渲染
|
|
15
|
+
- ✅ 普通 Markdown 语法(标题、粗体、代码等)
|
|
16
|
+
- ✅ \`:::alert\` 块 → AlertBlock 组件
|
|
17
|
+
- ✅ \`:::card\` 块 → DataCard 组件
|
|
18
|
+
- ✅ 流式未闭合时自动补全容器
|
|
19
|
+
|
|
20
|
+
## 代码示例
|
|
21
|
+
|
|
22
|
+
\`\`\`typescript
|
|
23
|
+
const md = new MarkdownIt()
|
|
24
|
+
md.use(container, 'alert', {
|
|
25
|
+
render(tokens, idx) {
|
|
26
|
+
return '<vue-block data-component="AlertBlock">'
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
\`\`\`
|
|
30
|
+
|
|
31
|
+
::: alert info
|
|
32
|
+
**渲染原理**:markdown-it-container 的 \`render\` 回调输出自定义 \`<vue-block>\` 占位元素,再由 \`DOMParser\` 递归转为 \`h()\` VNode 树。
|
|
33
|
+
:::
|
|
34
|
+
|
|
35
|
+
## 数据展示
|
|
36
|
+
|
|
37
|
+
::: card 技术栈对比
|
|
38
|
+
|
|
39
|
+
| 方案 | 性能 | 体积 |
|
|
40
|
+
|------|------|------|
|
|
41
|
+
| h() + DOMParser | ✅ 优秀 | ✅ 轻量 |
|
|
42
|
+
| compile() | ❌ 每次重编译 | ❌ +14KB |
|
|
43
|
+
|
|
44
|
+
:::
|
|
45
|
+
|
|
46
|
+
::: alert warning
|
|
47
|
+
未闭合的容器块在流式输出过程中会由 \`autoCloseContainers()\` 自动补全,确保 markdown-it 始终解析到**合法输入**。
|
|
48
|
+
:::
|
|
49
|
+
|
|
50
|
+
::: alert success
|
|
51
|
+
✨ 所有功能均已实现!当前页面正是流式渲染的实时效果,块组件完整挂载了 Vue 响应式系统。
|
|
52
|
+
:::
|
|
53
|
+
|
|
54
|
+
::: card 实现步骤
|
|
55
|
+
|
|
56
|
+
1. **预处理**:\`autoCloseContainers()\` 补全未闭合 \`:::\`
|
|
57
|
+
2. **解析**:\`markdown-it\` + \`markdown-it-container\` 输出含 \`<vue-block>\` 的 HTML
|
|
58
|
+
3. **转换**:\`DOMParser\` 遍历 DOM,将 \`<vue-block>\` 节点替换为 \`h(Vue组件)\`
|
|
59
|
+
4. **渲染**:\`MarkdownRenderer\` 组件通过 render 函数返回 VNode 树
|
|
60
|
+
|
|
61
|
+
:::
|
|
62
|
+
|
|
63
|
+
::: alert error
|
|
64
|
+
注意:此方案仅适用于**客户端渲染**场景,SSR 环境下需将 \`DOMParser\` 替换为 \`parse5\`。
|
|
65
|
+
:::
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
🎉 流式输出完成!
|
|
70
|
+
`;function P(){const e=n.ref(""),r=n.ref(!1);let t=null,o=0;function a(){r.value||(r.value=!0,o=e.value.length,t=setInterval(()=>{if(o>=f.length){s();return}const l=Math.floor(Math.random()*3)+1;e.value+=f.slice(o,o+l),o+=l},30))}function s(){t!==null&&(clearInterval(t),t=null),r.value=!1}function c(){s(),e.value="",o=0}return{text:e,isStreaming:r,startStream:a,stopStream:s,resetStream:c}}exports.AlertBlock=y;exports.DataCard=g;exports.MarkdownRenderer=I;exports.autoCloseContainers=k;exports.htmlToVnodes=v;exports.renderMarkdown=h;exports.useStreamingText=P;
|
|
71
|
+
//# sourceMappingURL=vue-markdown-stream.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vue-markdown-stream.cjs.js","sources":["../src/utils/autoClose.ts","../src/composables/useMarkdownParser.ts","../src/utils/htmlToVnodes.ts","../src/components/blocks/AlertBlock.vue","../src/components/blocks/DataCard.vue","../src/components/MarkdownRenderer.ts","../src/composables/useStreamingText.ts"],"sourcesContent":["/**\r\n * 流式输出场景下自动补全未闭合的 ::: 容器块\r\n * markdown-it-container 需要完整的开闭标记才能正确解析\r\n *\r\n * 例如流式输出到一半:\r\n * ::: alert warning\r\n * 这是一条\r\n *\r\n * 会被补全为:\r\n * ::: alert warning\r\n * 这是一条\r\n * :::\r\n */\r\nexport function autoCloseContainers(raw: string): string {\r\n const lines = raw.split('\\n')\r\n const stack: string[] = []\r\n\r\n for (const line of lines) {\r\n const trimmed = line.trim()\r\n\r\n // 匹配开标记 ::: xxx(后面有内容)\r\n if (/^:::\\s+\\S/.test(trimmed)) {\r\n stack.push(trimmed)\r\n continue\r\n }\r\n\r\n // 匹配闭标记 ::: (仅三冒号)\r\n if (trimmed === ':::') {\r\n stack.pop()\r\n }\r\n }\r\n\r\n // 补全所有未闭合的块\r\n if (stack.length === 0) return raw\r\n\r\n return raw + '\\n' + stack.map(() => ':::').join('\\n')\r\n}\r\n","import MarkdownIt from 'markdown-it'\r\nimport container from 'markdown-it-container'\r\nimport { autoCloseContainers } from '../utils/autoClose'\r\n\r\nconst md = new MarkdownIt({\r\n html: true,\r\n linkify: true,\r\n typographer: true,\r\n})\r\n\r\n// ::: alert [type]\r\n// type: info | success | warning | error\r\nmd.use(container, 'alert', {\r\n validate(params: string) {\r\n return /^alert(\\s+(info|success|warning|error))?/.test(params.trim())\r\n },\r\n render(tokens: any[], idx: number) {\r\n const token = tokens[idx]\r\n if (token.nesting === 1) {\r\n const match = token.info.trim().match(/^alert\\s*(\\S*)/)\r\n const type = match?.[1] || 'info'\r\n return `<vue-block data-component=\"AlertBlock\" data-type=\"${type}\">\\n`\r\n }\r\n return '</vue-block>\\n'\r\n },\r\n})\r\n\r\n// ::: card [title]\r\nmd.use(container, 'card', {\r\n validate(params: string) {\r\n return /^card/.test(params.trim())\r\n },\r\n render(tokens: any[], idx: number) {\r\n const token = tokens[idx]\r\n if (token.nesting === 1) {\r\n const match = token.info.trim().match(/^card\\s*(.*)/)\r\n const title = match?.[1]?.trim() || ''\r\n const safeTitle = title.replace(/\"/g, '"')\r\n return `<vue-block data-component=\"DataCard\" data-title=\"${safeTitle}\">\\n`\r\n }\r\n return '</vue-block>\\n'\r\n },\r\n})\r\n\r\n/**\r\n * 将 markdown 字符串渲染为 HTML\r\n * 流式场景下会自动补全未闭合的容器块\r\n */\r\nexport function renderMarkdown(raw: string): string {\r\n const completed = autoCloseContainers(raw)\r\n return md.render(completed)\r\n}\r\n","import { h, type Component, type VNode } from 'vue'\r\n\r\nexport type ComponentMap = Record<string, Component>\r\n\r\n/**\r\n * 递归将 DOM 节点转换为 VNode\r\n * 遇到 <vue-block data-component=\"Xxx\"> 时挂载对应 Vue 组件\r\n */\r\nfunction domNodeToVNode(node: Node, componentMap: ComponentMap): VNode | string | null {\r\n // 文本节点\r\n if (node.nodeType === Node.TEXT_NODE) {\r\n return node.textContent || ''\r\n }\r\n\r\n // 注释节点忽略\r\n if (node.nodeType === Node.COMMENT_NODE) {\r\n return null\r\n }\r\n\r\n const el = node as Element\r\n const tag = el.tagName?.toLowerCase()\r\n\r\n // Vue 组件占位块\r\n if (tag === 'vue-block') {\r\n const compName = el.getAttribute('data-component')\r\n if (!compName || !componentMap[compName]) {\r\n // 找不到组件时降级渲染内部 HTML\r\n return h('div', { innerHTML: el.innerHTML })\r\n }\r\n\r\n const Comp = componentMap[compName]\r\n\r\n // 收集 data-* 作为 props(排除 data-component)\r\n const props: Record<string, string> = {}\r\n for (const attr of Array.from(el.attributes)) {\r\n if (attr.name !== 'data-component' && attr.name.startsWith('data-')) {\r\n const key = attr.name.slice(5) // 去掉 'data-'\r\n props[key] = attr.value\r\n }\r\n }\r\n\r\n // 递归转换子节点作为 default slot\r\n const children = domNodesToVNodes(el.childNodes, componentMap)\r\n return h(Comp, props, { default: () => children })\r\n }\r\n\r\n // 普通 HTML 元素:递归处理子节点\r\n const children = domNodesToVNodes(el.childNodes, componentMap)\r\n\r\n // 收集标准属性\r\n const attrs: Record<string, string> = {}\r\n for (const attr of Array.from(el.attributes)) {\r\n attrs[attr.name] = attr.value\r\n }\r\n\r\n return h(tag, attrs, children)\r\n}\r\n\r\nfunction domNodesToVNodes(nodes: NodeList, componentMap: ComponentMap): (VNode | string)[] {\r\n const result: (VNode | string)[] = []\r\n for (const node of Array.from(nodes)) {\r\n const vnode = domNodeToVNode(node, componentMap)\r\n if (vnode !== null) {\r\n result.push(vnode)\r\n }\r\n }\r\n return result\r\n}\r\n\r\n/**\r\n * 将 markdown-it 输出的 HTML 字符串转换为 VNode 数组\r\n * 其中 <vue-block> 自定义标签会被替换为真实 Vue 组件\r\n */\r\nexport function htmlToVnodes(html: string, componentMap: ComponentMap): (VNode | string)[] {\r\n if (!html) return []\r\n\r\n const parser = new DOMParser()\r\n // 包裹在 div 中确保能解析 fragment\r\n const doc = parser.parseFromString(`<div id=\"__root\">${html}</div>`, 'text/html')\r\n const root = doc.getElementById('__root')\r\n if (!root) return []\r\n\r\n return domNodesToVNodes(root.childNodes, componentMap)\r\n}\r\n","<script setup lang=\"ts\">\r\ntype AlertType = 'info' | 'success' | 'warning' | 'error'\r\n\r\nconst props = withDefaults(\r\n defineProps<{ type?: AlertType }>(),\r\n { type: 'info' }\r\n)\r\n\r\nconst iconMap: Record<AlertType, string> = {\r\n info: 'ℹ️',\r\n success: '✅',\r\n warning: '⚠️',\r\n error: '❌',\r\n}\r\n\r\nconst labelMap: Record<AlertType, string> = {\r\n info: '提示',\r\n success: '成功',\r\n warning: '注意',\r\n error: '错误',\r\n}\r\n</script>\r\n\r\n<template>\r\n <div class=\"alert-block\" :class=\"`alert-${props.type}`\">\r\n <div class=\"alert-header\">\r\n <span class=\"alert-icon\">{{ iconMap[props.type] }}</span>\r\n <span class=\"alert-label\">{{ labelMap[props.type] }}</span>\r\n </div>\r\n <div class=\"alert-body\">\r\n <slot />\r\n </div>\r\n </div>\r\n</template>\r\n\r\n<style scoped>\r\n.alert-block {\r\n border-left: 4px solid;\r\n border-radius: 6px;\r\n padding: 12px 16px;\r\n margin: 16px 0;\r\n background: var(--alert-bg);\r\n border-color: var(--alert-border);\r\n}\r\n\r\n.alert-info { --alert-bg: #eff6ff; --alert-border: #3b82f6; --alert-label-color: #1d4ed8; }\r\n.alert-success { --alert-bg: #f0fdf4; --alert-border: #22c55e; --alert-label-color: #15803d; }\r\n.alert-warning { --alert-bg: #fffbeb; --alert-border: #f59e0b; --alert-label-color: #b45309; }\r\n.alert-error { --alert-bg: #fef2f2; --alert-border: #ef4444; --alert-label-color: #b91c1c; }\r\n\r\n.alert-header {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n margin-bottom: 8px;\r\n font-weight: 600;\r\n font-size: 0.9em;\r\n color: var(--alert-label-color);\r\n}\r\n\r\n.alert-icon { font-size: 1em; }\r\n\r\n.alert-body {\r\n font-size: 0.92em;\r\n line-height: 1.7;\r\n color: #374151;\r\n}\r\n\r\n.alert-body :deep(p) {\r\n margin: 4px 0;\r\n}\r\n\r\n.alert-body :deep(code) {\r\n background: rgba(0, 0, 0, 0.07);\r\n padding: 1px 5px;\r\n border-radius: 3px;\r\n font-size: 0.88em;\r\n}\r\n</style>\r\n","<script setup lang=\"ts\">\r\ndefineProps<{\r\n title?: string\r\n}>()\r\n</script>\r\n\r\n<template>\r\n <div class=\"data-card\">\r\n <div v-if=\"title\" class=\"card-header\">\r\n <span class=\"card-icon\">📋</span>\r\n <span class=\"card-title\">{{ title }}</span>\r\n </div>\r\n <div class=\"card-body\">\r\n <slot />\r\n </div>\r\n </div>\r\n</template>\r\n\r\n<style scoped>\r\n.data-card {\r\n border: 1px solid #e5e7eb;\r\n border-radius: 8px;\r\n overflow: hidden;\r\n margin: 16px 0;\r\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);\r\n background: #fff;\r\n}\r\n\r\n.card-header {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n padding: 10px 16px;\r\n background: #f8fafc;\r\n border-bottom: 1px solid #e5e7eb;\r\n font-weight: 600;\r\n font-size: 0.9em;\r\n color: #374151;\r\n}\r\n\r\n.card-icon { font-size: 1em; }\r\n\r\n.card-body {\r\n padding: 14px 16px;\r\n font-size: 0.93em;\r\n line-height: 1.7;\r\n color: #374151;\r\n}\r\n\r\n.card-body :deep(p) {\r\n margin: 4px 0;\r\n}\r\n\r\n.card-body :deep(ul),\r\n.card-body :deep(ol) {\r\n padding-left: 20px;\r\n margin: 6px 0;\r\n}\r\n\r\n.card-body :deep(table) {\r\n width: 100%;\r\n border-collapse: collapse;\r\n font-size: 0.9em;\r\n}\r\n\r\n.card-body :deep(th),\r\n.card-body :deep(td) {\r\n border: 1px solid #e5e7eb;\r\n padding: 6px 10px;\r\n text-align: left;\r\n}\r\n\r\n.card-body :deep(th) {\r\n background: #f3f4f6;\r\n font-weight: 600;\r\n}\r\n\r\n.card-body :deep(code) {\r\n background: rgba(0, 0, 0, 0.07);\r\n padding: 1px 5px;\r\n border-radius: 3px;\r\n font-size: 0.88em;\r\n}\r\n</style>\r\n","import { defineComponent, h, type PropType, type VNode } from 'vue'\r\nimport { renderMarkdown } from '../composables/useMarkdownParser'\r\nimport { htmlToVnodes, type ComponentMap } from '../utils/htmlToVnodes'\r\nimport AlertBlock from './blocks/AlertBlock.vue'\r\nimport DataCard from './blocks/DataCard.vue'\r\n\r\nconst componentMap: ComponentMap = {\r\n AlertBlock,\r\n DataCard,\r\n}\r\n\r\nexport default defineComponent({\r\n name: 'MarkdownRenderer',\r\n props: {\r\n content: {\r\n type: String as PropType<string>,\r\n default: '',\r\n },\r\n },\r\n setup(props) {\r\n return () => {\r\n const html = renderMarkdown(props.content)\r\n const vnodes = htmlToVnodes(html, componentMap)\r\n return h('div', { class: 'markdown-body' }, vnodes as VNode[])\r\n }\r\n },\r\n})\r\n","import { ref } from 'vue'\r\n\r\nconst MOCK_TEXT = `# 流式 Markdown 渲染演示\r\n\r\n这是一个演示**流式输出 Markdown 内容**并将特定块渲染为 Vue 组件的示例。\r\n\r\n## 功能特性\r\n\r\n- ✅ 流式打字机渲染\r\n- ✅ 普通 Markdown 语法(标题、粗体、代码等)\r\n- ✅ \\`:::alert\\` 块 → AlertBlock 组件\r\n- ✅ \\`:::card\\` 块 → DataCard 组件\r\n- ✅ 流式未闭合时自动补全容器\r\n\r\n## 代码示例\r\n\r\n\\`\\`\\`typescript\r\nconst md = new MarkdownIt()\r\nmd.use(container, 'alert', {\r\n render(tokens, idx) {\r\n return '<vue-block data-component=\"AlertBlock\">'\r\n }\r\n})\r\n\\`\\`\\`\r\n\r\n::: alert info\r\n**渲染原理**:markdown-it-container 的 \\`render\\` 回调输出自定义 \\`<vue-block>\\` 占位元素,再由 \\`DOMParser\\` 递归转为 \\`h()\\` VNode 树。\r\n:::\r\n\r\n## 数据展示\r\n\r\n::: card 技术栈对比\r\n\r\n| 方案 | 性能 | 体积 |\r\n|------|------|------|\r\n| h() + DOMParser | ✅ 优秀 | ✅ 轻量 |\r\n| compile() | ❌ 每次重编译 | ❌ +14KB |\r\n\r\n:::\r\n\r\n::: alert warning\r\n未闭合的容器块在流式输出过程中会由 \\`autoCloseContainers()\\` 自动补全,确保 markdown-it 始终解析到**合法输入**。\r\n:::\r\n\r\n::: alert success\r\n✨ 所有功能均已实现!当前页面正是流式渲染的实时效果,块组件完整挂载了 Vue 响应式系统。\r\n:::\r\n\r\n::: card 实现步骤\r\n\r\n1. **预处理**:\\`autoCloseContainers()\\` 补全未闭合 \\`:::\\`\r\n2. **解析**:\\`markdown-it\\` + \\`markdown-it-container\\` 输出含 \\`<vue-block>\\` 的 HTML\r\n3. **转换**:\\`DOMParser\\` 遍历 DOM,将 \\`<vue-block>\\` 节点替换为 \\`h(Vue组件)\\`\r\n4. **渲染**:\\`MarkdownRenderer\\` 组件通过 render 函数返回 VNode 树\r\n\r\n:::\r\n\r\n::: alert error\r\n注意:此方案仅适用于**客户端渲染**场景,SSR 环境下需将 \\`DOMParser\\` 替换为 \\`parse5\\`。\r\n:::\r\n\r\n---\r\n\r\n🎉 流式输出完成!\r\n`\r\n\r\n/**\r\n * Mock 流式文本输出\r\n * 模拟 AI 逐字输出场景\r\n */\r\nexport function useStreamingText() {\r\n const text = ref('')\r\n const isStreaming = ref(false)\r\n let timer: ReturnType<typeof setInterval> | null = null\r\n let position = 0\r\n\r\n function startStream() {\r\n if (isStreaming.value) return\r\n\r\n isStreaming.value = true\r\n position = text.value.length // 支持续播\r\n\r\n timer = setInterval(() => {\r\n if (position >= MOCK_TEXT.length) {\r\n stopStream()\r\n return\r\n }\r\n // 每次追加 1~3 个字符,模拟真实流式速度\r\n const chunkSize = Math.floor(Math.random() * 3) + 1\r\n text.value += MOCK_TEXT.slice(position, position + chunkSize)\r\n position += chunkSize\r\n }, 30)\r\n }\r\n\r\n function stopStream() {\r\n if (timer !== null) {\r\n clearInterval(timer)\r\n timer = null\r\n }\r\n isStreaming.value = false\r\n }\r\n\r\n function resetStream() {\r\n stopStream()\r\n text.value = ''\r\n position = 0\r\n }\r\n\r\n return {\r\n text,\r\n isStreaming,\r\n startStream,\r\n stopStream,\r\n resetStream,\r\n }\r\n}\r\n"],"names":["autoCloseContainers","raw","lines","stack","line","trimmed","md","MarkdownIt","container","params","tokens","idx","token","renderMarkdown","completed","domNodeToVNode","node","componentMap","el","tag","compName","h","Comp","props","attr","key","children","domNodesToVNodes","attrs","nodes","result","vnode","htmlToVnodes","html","root","__props","iconMap","labelMap","_createElementBlock","_normalizeClass","_createElementVNode","_hoisted_1","_hoisted_2","_toDisplayString","_hoisted_3","_hoisted_4","_renderSlot","_ctx","_openBlock","_cache","AlertBlock","DataCard","MarkdownRenderer","defineComponent","vnodes","MOCK_TEXT","useStreamingText","text","ref","isStreaming","timer","position","startStream","stopStream","chunkSize","resetStream"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.alert-block[data-v-ebc7ae3e]{border-left:4px solid;border-radius:6px;padding:12px 16px;margin:16px 0;background:var(--alert-bg);border-color:var(--alert-border)}.alert-info[data-v-ebc7ae3e]{--alert-bg: #eff6ff;--alert-border: #3b82f6;--alert-label-color: #1d4ed8}.alert-success[data-v-ebc7ae3e]{--alert-bg: #f0fdf4;--alert-border: #22c55e;--alert-label-color: #15803d}.alert-warning[data-v-ebc7ae3e]{--alert-bg: #fffbeb;--alert-border: #f59e0b;--alert-label-color: #b45309}.alert-error[data-v-ebc7ae3e]{--alert-bg: #fef2f2;--alert-border: #ef4444;--alert-label-color: #b91c1c}.alert-header[data-v-ebc7ae3e]{display:flex;align-items:center;gap:6px;margin-bottom:8px;font-weight:600;font-size:.9em;color:var(--alert-label-color)}.alert-icon[data-v-ebc7ae3e]{font-size:1em}.alert-body[data-v-ebc7ae3e]{font-size:.92em;line-height:1.7;color:#374151}.alert-body[data-v-ebc7ae3e] p{margin:4px 0}.alert-body[data-v-ebc7ae3e] code{background:#00000012;padding:1px 5px;border-radius:3px;font-size:.88em}.data-card[data-v-ece81c58]{border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin:16px 0;box-shadow:0 1px 3px #00000014;background:#fff}.card-header[data-v-ece81c58]{display:flex;align-items:center;gap:8px;padding:10px 16px;background:#f8fafc;border-bottom:1px solid #e5e7eb;font-weight:600;font-size:.9em;color:#374151}.card-icon[data-v-ece81c58]{font-size:1em}.card-body[data-v-ece81c58]{padding:14px 16px;font-size:.93em;line-height:1.7;color:#374151}.card-body[data-v-ece81c58] p{margin:4px 0}.card-body[data-v-ece81c58] ul,.card-body[data-v-ece81c58] ol{padding-left:20px;margin:6px 0}.card-body[data-v-ece81c58] table{width:100%;border-collapse:collapse;font-size:.9em}.card-body[data-v-ece81c58] th,.card-body[data-v-ece81c58] td{border:1px solid #e5e7eb;padding:6px 10px;text-align:left}.card-body[data-v-ece81c58] th{background:#f3f4f6;font-weight:600}.card-body[data-v-ece81c58] code{background:#00000012;padding:1px 5px;border-radius:3px;font-size:.88em}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { h as d, defineComponent as k, openBlock as u, createElementBlock as m, normalizeClass as N, createElementVNode as c, toDisplayString as f, renderSlot as M, createCommentVNode as T, ref as _ } from "vue";
|
|
2
|
+
import S from "markdown-it";
|
|
3
|
+
import g from "markdown-it-container";
|
|
4
|
+
function D(t) {
|
|
5
|
+
const r = t.split(`
|
|
6
|
+
`), e = [];
|
|
7
|
+
for (const n of r) {
|
|
8
|
+
const o = n.trim();
|
|
9
|
+
if (/^:::\s+\S/.test(o)) {
|
|
10
|
+
e.push(o);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
o === ":::" && e.pop();
|
|
14
|
+
}
|
|
15
|
+
return e.length === 0 ? t : t + `
|
|
16
|
+
` + e.map(() => ":::").join(`
|
|
17
|
+
`);
|
|
18
|
+
}
|
|
19
|
+
const v = new S({
|
|
20
|
+
html: !0,
|
|
21
|
+
linkify: !0,
|
|
22
|
+
typographer: !0
|
|
23
|
+
});
|
|
24
|
+
v.use(g, "alert", {
|
|
25
|
+
validate(t) {
|
|
26
|
+
return /^alert(\s+(info|success|warning|error))?/.test(t.trim());
|
|
27
|
+
},
|
|
28
|
+
render(t, r) {
|
|
29
|
+
const e = t[r];
|
|
30
|
+
return e.nesting === 1 ? `<vue-block data-component="AlertBlock" data-type="${e.info.trim().match(/^alert\s*(\S*)/)?.[1] || "info"}">
|
|
31
|
+
` : `</vue-block>
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
v.use(g, "card", {
|
|
36
|
+
validate(t) {
|
|
37
|
+
return /^card/.test(t.trim());
|
|
38
|
+
},
|
|
39
|
+
render(t, r) {
|
|
40
|
+
const e = t[r];
|
|
41
|
+
return e.nesting === 1 ? `<vue-block data-component="DataCard" data-title="${(e.info.trim().match(/^card\s*(.*)/)?.[1]?.trim() || "").replace(/"/g, """)}">
|
|
42
|
+
` : `</vue-block>
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
function O(t) {
|
|
47
|
+
const r = D(t);
|
|
48
|
+
return v.render(r);
|
|
49
|
+
}
|
|
50
|
+
function $(t, r) {
|
|
51
|
+
if (t.nodeType === Node.TEXT_NODE)
|
|
52
|
+
return t.textContent || "";
|
|
53
|
+
if (t.nodeType === Node.COMMENT_NODE)
|
|
54
|
+
return null;
|
|
55
|
+
const e = t, n = e.tagName?.toLowerCase();
|
|
56
|
+
if (n === "vue-block") {
|
|
57
|
+
const s = e.getAttribute("data-component");
|
|
58
|
+
if (!s || !r[s])
|
|
59
|
+
return d("div", { innerHTML: e.innerHTML });
|
|
60
|
+
const i = r[s], h = {};
|
|
61
|
+
for (const l of Array.from(e.attributes))
|
|
62
|
+
if (l.name !== "data-component" && l.name.startsWith("data-")) {
|
|
63
|
+
const C = l.name.slice(5);
|
|
64
|
+
h[C] = l.value;
|
|
65
|
+
}
|
|
66
|
+
const b = p(e.childNodes, r);
|
|
67
|
+
return d(i, h, { default: () => b });
|
|
68
|
+
}
|
|
69
|
+
const o = p(e.childNodes, r), a = {};
|
|
70
|
+
for (const s of Array.from(e.attributes))
|
|
71
|
+
a[s.name] = s.value;
|
|
72
|
+
return d(n, a, o);
|
|
73
|
+
}
|
|
74
|
+
function p(t, r) {
|
|
75
|
+
const e = [];
|
|
76
|
+
for (const n of Array.from(t)) {
|
|
77
|
+
const o = $(n, r);
|
|
78
|
+
o !== null && e.push(o);
|
|
79
|
+
}
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
82
|
+
function V(t, r) {
|
|
83
|
+
if (!t) return [];
|
|
84
|
+
const o = new DOMParser().parseFromString(`<div id="__root">${t}</div>`, "text/html").getElementById("__root");
|
|
85
|
+
return o ? p(o.childNodes, r) : [];
|
|
86
|
+
}
|
|
87
|
+
const A = { class: "alert-header" }, B = { class: "alert-icon" }, E = { class: "alert-label" }, x = { class: "alert-body" }, I = /* @__PURE__ */ k({
|
|
88
|
+
__name: "AlertBlock",
|
|
89
|
+
props: {
|
|
90
|
+
type: { default: "info" }
|
|
91
|
+
},
|
|
92
|
+
setup(t) {
|
|
93
|
+
const r = t, e = {
|
|
94
|
+
info: "ℹ️",
|
|
95
|
+
success: "✅",
|
|
96
|
+
warning: "⚠️",
|
|
97
|
+
error: "❌"
|
|
98
|
+
}, n = {
|
|
99
|
+
info: "提示",
|
|
100
|
+
success: "成功",
|
|
101
|
+
warning: "注意",
|
|
102
|
+
error: "错误"
|
|
103
|
+
};
|
|
104
|
+
return (o, a) => (u(), m("div", {
|
|
105
|
+
class: N(["alert-block", `alert-${r.type}`])
|
|
106
|
+
}, [
|
|
107
|
+
c("div", A, [
|
|
108
|
+
c("span", B, f(e[r.type]), 1),
|
|
109
|
+
c("span", E, f(n[r.type]), 1)
|
|
110
|
+
]),
|
|
111
|
+
c("div", x, [
|
|
112
|
+
M(o.$slots, "default", {}, void 0, !0)
|
|
113
|
+
])
|
|
114
|
+
], 2));
|
|
115
|
+
}
|
|
116
|
+
}), w = (t, r) => {
|
|
117
|
+
const e = t.__vccOpts || t;
|
|
118
|
+
for (const [n, o] of r)
|
|
119
|
+
e[n] = o;
|
|
120
|
+
return e;
|
|
121
|
+
}, P = /* @__PURE__ */ w(I, [["__scopeId", "data-v-ebc7ae3e"]]), L = { class: "data-card" }, R = {
|
|
122
|
+
key: 0,
|
|
123
|
+
class: "card-header"
|
|
124
|
+
}, H = { class: "card-title" }, z = { class: "card-body" }, K = /* @__PURE__ */ k({
|
|
125
|
+
__name: "DataCard",
|
|
126
|
+
props: {
|
|
127
|
+
title: {}
|
|
128
|
+
},
|
|
129
|
+
setup(t) {
|
|
130
|
+
return (r, e) => (u(), m("div", L, [
|
|
131
|
+
t.title ? (u(), m("div", R, [
|
|
132
|
+
e[0] || (e[0] = c("span", { class: "card-icon" }, "📋", -1)),
|
|
133
|
+
c("span", H, f(t.title), 1)
|
|
134
|
+
])) : T("", !0),
|
|
135
|
+
c("div", z, [
|
|
136
|
+
M(r.$slots, "default", {}, void 0, !0)
|
|
137
|
+
])
|
|
138
|
+
]));
|
|
139
|
+
}
|
|
140
|
+
}), X = /* @__PURE__ */ w(K, [["__scopeId", "data-v-ece81c58"]]), j = {
|
|
141
|
+
AlertBlock: P,
|
|
142
|
+
DataCard: X
|
|
143
|
+
}, G = k({
|
|
144
|
+
name: "MarkdownRenderer",
|
|
145
|
+
props: {
|
|
146
|
+
content: {
|
|
147
|
+
type: String,
|
|
148
|
+
default: ""
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
setup(t) {
|
|
152
|
+
return () => {
|
|
153
|
+
const r = O(t.content), e = V(r, j);
|
|
154
|
+
return d("div", { class: "markdown-body" }, e);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}), y = `# 流式 Markdown 渲染演示
|
|
158
|
+
|
|
159
|
+
这是一个演示**流式输出 Markdown 内容**并将特定块渲染为 Vue 组件的示例。
|
|
160
|
+
|
|
161
|
+
## 功能特性
|
|
162
|
+
|
|
163
|
+
- ✅ 流式打字机渲染
|
|
164
|
+
- ✅ 普通 Markdown 语法(标题、粗体、代码等)
|
|
165
|
+
- ✅ \`:::alert\` 块 → AlertBlock 组件
|
|
166
|
+
- ✅ \`:::card\` 块 → DataCard 组件
|
|
167
|
+
- ✅ 流式未闭合时自动补全容器
|
|
168
|
+
|
|
169
|
+
## 代码示例
|
|
170
|
+
|
|
171
|
+
\`\`\`typescript
|
|
172
|
+
const md = new MarkdownIt()
|
|
173
|
+
md.use(container, 'alert', {
|
|
174
|
+
render(tokens, idx) {
|
|
175
|
+
return '<vue-block data-component="AlertBlock">'
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
\`\`\`
|
|
179
|
+
|
|
180
|
+
::: alert info
|
|
181
|
+
**渲染原理**:markdown-it-container 的 \`render\` 回调输出自定义 \`<vue-block>\` 占位元素,再由 \`DOMParser\` 递归转为 \`h()\` VNode 树。
|
|
182
|
+
:::
|
|
183
|
+
|
|
184
|
+
## 数据展示
|
|
185
|
+
|
|
186
|
+
::: card 技术栈对比
|
|
187
|
+
|
|
188
|
+
| 方案 | 性能 | 体积 |
|
|
189
|
+
|------|------|------|
|
|
190
|
+
| h() + DOMParser | ✅ 优秀 | ✅ 轻量 |
|
|
191
|
+
| compile() | ❌ 每次重编译 | ❌ +14KB |
|
|
192
|
+
|
|
193
|
+
:::
|
|
194
|
+
|
|
195
|
+
::: alert warning
|
|
196
|
+
未闭合的容器块在流式输出过程中会由 \`autoCloseContainers()\` 自动补全,确保 markdown-it 始终解析到**合法输入**。
|
|
197
|
+
:::
|
|
198
|
+
|
|
199
|
+
::: alert success
|
|
200
|
+
✨ 所有功能均已实现!当前页面正是流式渲染的实时效果,块组件完整挂载了 Vue 响应式系统。
|
|
201
|
+
:::
|
|
202
|
+
|
|
203
|
+
::: card 实现步骤
|
|
204
|
+
|
|
205
|
+
1. **预处理**:\`autoCloseContainers()\` 补全未闭合 \`:::\`
|
|
206
|
+
2. **解析**:\`markdown-it\` + \`markdown-it-container\` 输出含 \`<vue-block>\` 的 HTML
|
|
207
|
+
3. **转换**:\`DOMParser\` 遍历 DOM,将 \`<vue-block>\` 节点替换为 \`h(Vue组件)\`
|
|
208
|
+
4. **渲染**:\`MarkdownRenderer\` 组件通过 render 函数返回 VNode 树
|
|
209
|
+
|
|
210
|
+
:::
|
|
211
|
+
|
|
212
|
+
::: alert error
|
|
213
|
+
注意:此方案仅适用于**客户端渲染**场景,SSR 环境下需将 \`DOMParser\` 替换为 \`parse5\`。
|
|
214
|
+
:::
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
🎉 流式输出完成!
|
|
219
|
+
`;
|
|
220
|
+
function J() {
|
|
221
|
+
const t = _(""), r = _(!1);
|
|
222
|
+
let e = null, n = 0;
|
|
223
|
+
function o() {
|
|
224
|
+
r.value || (r.value = !0, n = t.value.length, e = setInterval(() => {
|
|
225
|
+
if (n >= y.length) {
|
|
226
|
+
a();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const i = Math.floor(Math.random() * 3) + 1;
|
|
230
|
+
t.value += y.slice(n, n + i), n += i;
|
|
231
|
+
}, 30));
|
|
232
|
+
}
|
|
233
|
+
function a() {
|
|
234
|
+
e !== null && (clearInterval(e), e = null), r.value = !1;
|
|
235
|
+
}
|
|
236
|
+
function s() {
|
|
237
|
+
a(), t.value = "", n = 0;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
text: t,
|
|
241
|
+
isStreaming: r,
|
|
242
|
+
startStream: o,
|
|
243
|
+
stopStream: a,
|
|
244
|
+
resetStream: s
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export {
|
|
248
|
+
P as AlertBlock,
|
|
249
|
+
X as DataCard,
|
|
250
|
+
G as MarkdownRenderer,
|
|
251
|
+
D as autoCloseContainers,
|
|
252
|
+
V as htmlToVnodes,
|
|
253
|
+
O as renderMarkdown,
|
|
254
|
+
J as useStreamingText
|
|
255
|
+
};
|
|
256
|
+
//# sourceMappingURL=vue-markdown-stream.es.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vue-markdown-stream.es.js","sources":["../src/utils/autoClose.ts","../src/composables/useMarkdownParser.ts","../src/utils/htmlToVnodes.ts","../src/components/blocks/AlertBlock.vue","../src/components/blocks/DataCard.vue","../src/components/MarkdownRenderer.ts","../src/composables/useStreamingText.ts"],"sourcesContent":["/**\r\n * 流式输出场景下自动补全未闭合的 ::: 容器块\r\n * markdown-it-container 需要完整的开闭标记才能正确解析\r\n *\r\n * 例如流式输出到一半:\r\n * ::: alert warning\r\n * 这是一条\r\n *\r\n * 会被补全为:\r\n * ::: alert warning\r\n * 这是一条\r\n * :::\r\n */\r\nexport function autoCloseContainers(raw: string): string {\r\n const lines = raw.split('\\n')\r\n const stack: string[] = []\r\n\r\n for (const line of lines) {\r\n const trimmed = line.trim()\r\n\r\n // 匹配开标记 ::: xxx(后面有内容)\r\n if (/^:::\\s+\\S/.test(trimmed)) {\r\n stack.push(trimmed)\r\n continue\r\n }\r\n\r\n // 匹配闭标记 ::: (仅三冒号)\r\n if (trimmed === ':::') {\r\n stack.pop()\r\n }\r\n }\r\n\r\n // 补全所有未闭合的块\r\n if (stack.length === 0) return raw\r\n\r\n return raw + '\\n' + stack.map(() => ':::').join('\\n')\r\n}\r\n","import MarkdownIt from 'markdown-it'\r\nimport container from 'markdown-it-container'\r\nimport { autoCloseContainers } from '../utils/autoClose'\r\n\r\nconst md = new MarkdownIt({\r\n html: true,\r\n linkify: true,\r\n typographer: true,\r\n})\r\n\r\n// ::: alert [type]\r\n// type: info | success | warning | error\r\nmd.use(container, 'alert', {\r\n validate(params: string) {\r\n return /^alert(\\s+(info|success|warning|error))?/.test(params.trim())\r\n },\r\n render(tokens: any[], idx: number) {\r\n const token = tokens[idx]\r\n if (token.nesting === 1) {\r\n const match = token.info.trim().match(/^alert\\s*(\\S*)/)\r\n const type = match?.[1] || 'info'\r\n return `<vue-block data-component=\"AlertBlock\" data-type=\"${type}\">\\n`\r\n }\r\n return '</vue-block>\\n'\r\n },\r\n})\r\n\r\n// ::: card [title]\r\nmd.use(container, 'card', {\r\n validate(params: string) {\r\n return /^card/.test(params.trim())\r\n },\r\n render(tokens: any[], idx: number) {\r\n const token = tokens[idx]\r\n if (token.nesting === 1) {\r\n const match = token.info.trim().match(/^card\\s*(.*)/)\r\n const title = match?.[1]?.trim() || ''\r\n const safeTitle = title.replace(/\"/g, '"')\r\n return `<vue-block data-component=\"DataCard\" data-title=\"${safeTitle}\">\\n`\r\n }\r\n return '</vue-block>\\n'\r\n },\r\n})\r\n\r\n/**\r\n * 将 markdown 字符串渲染为 HTML\r\n * 流式场景下会自动补全未闭合的容器块\r\n */\r\nexport function renderMarkdown(raw: string): string {\r\n const completed = autoCloseContainers(raw)\r\n return md.render(completed)\r\n}\r\n","import { h, type Component, type VNode } from 'vue'\r\n\r\nexport type ComponentMap = Record<string, Component>\r\n\r\n/**\r\n * 递归将 DOM 节点转换为 VNode\r\n * 遇到 <vue-block data-component=\"Xxx\"> 时挂载对应 Vue 组件\r\n */\r\nfunction domNodeToVNode(node: Node, componentMap: ComponentMap): VNode | string | null {\r\n // 文本节点\r\n if (node.nodeType === Node.TEXT_NODE) {\r\n return node.textContent || ''\r\n }\r\n\r\n // 注释节点忽略\r\n if (node.nodeType === Node.COMMENT_NODE) {\r\n return null\r\n }\r\n\r\n const el = node as Element\r\n const tag = el.tagName?.toLowerCase()\r\n\r\n // Vue 组件占位块\r\n if (tag === 'vue-block') {\r\n const compName = el.getAttribute('data-component')\r\n if (!compName || !componentMap[compName]) {\r\n // 找不到组件时降级渲染内部 HTML\r\n return h('div', { innerHTML: el.innerHTML })\r\n }\r\n\r\n const Comp = componentMap[compName]\r\n\r\n // 收集 data-* 作为 props(排除 data-component)\r\n const props: Record<string, string> = {}\r\n for (const attr of Array.from(el.attributes)) {\r\n if (attr.name !== 'data-component' && attr.name.startsWith('data-')) {\r\n const key = attr.name.slice(5) // 去掉 'data-'\r\n props[key] = attr.value\r\n }\r\n }\r\n\r\n // 递归转换子节点作为 default slot\r\n const children = domNodesToVNodes(el.childNodes, componentMap)\r\n return h(Comp, props, { default: () => children })\r\n }\r\n\r\n // 普通 HTML 元素:递归处理子节点\r\n const children = domNodesToVNodes(el.childNodes, componentMap)\r\n\r\n // 收集标准属性\r\n const attrs: Record<string, string> = {}\r\n for (const attr of Array.from(el.attributes)) {\r\n attrs[attr.name] = attr.value\r\n }\r\n\r\n return h(tag, attrs, children)\r\n}\r\n\r\nfunction domNodesToVNodes(nodes: NodeList, componentMap: ComponentMap): (VNode | string)[] {\r\n const result: (VNode | string)[] = []\r\n for (const node of Array.from(nodes)) {\r\n const vnode = domNodeToVNode(node, componentMap)\r\n if (vnode !== null) {\r\n result.push(vnode)\r\n }\r\n }\r\n return result\r\n}\r\n\r\n/**\r\n * 将 markdown-it 输出的 HTML 字符串转换为 VNode 数组\r\n * 其中 <vue-block> 自定义标签会被替换为真实 Vue 组件\r\n */\r\nexport function htmlToVnodes(html: string, componentMap: ComponentMap): (VNode | string)[] {\r\n if (!html) return []\r\n\r\n const parser = new DOMParser()\r\n // 包裹在 div 中确保能解析 fragment\r\n const doc = parser.parseFromString(`<div id=\"__root\">${html}</div>`, 'text/html')\r\n const root = doc.getElementById('__root')\r\n if (!root) return []\r\n\r\n return domNodesToVNodes(root.childNodes, componentMap)\r\n}\r\n","<script setup lang=\"ts\">\r\ntype AlertType = 'info' | 'success' | 'warning' | 'error'\r\n\r\nconst props = withDefaults(\r\n defineProps<{ type?: AlertType }>(),\r\n { type: 'info' }\r\n)\r\n\r\nconst iconMap: Record<AlertType, string> = {\r\n info: 'ℹ️',\r\n success: '✅',\r\n warning: '⚠️',\r\n error: '❌',\r\n}\r\n\r\nconst labelMap: Record<AlertType, string> = {\r\n info: '提示',\r\n success: '成功',\r\n warning: '注意',\r\n error: '错误',\r\n}\r\n</script>\r\n\r\n<template>\r\n <div class=\"alert-block\" :class=\"`alert-${props.type}`\">\r\n <div class=\"alert-header\">\r\n <span class=\"alert-icon\">{{ iconMap[props.type] }}</span>\r\n <span class=\"alert-label\">{{ labelMap[props.type] }}</span>\r\n </div>\r\n <div class=\"alert-body\">\r\n <slot />\r\n </div>\r\n </div>\r\n</template>\r\n\r\n<style scoped>\r\n.alert-block {\r\n border-left: 4px solid;\r\n border-radius: 6px;\r\n padding: 12px 16px;\r\n margin: 16px 0;\r\n background: var(--alert-bg);\r\n border-color: var(--alert-border);\r\n}\r\n\r\n.alert-info { --alert-bg: #eff6ff; --alert-border: #3b82f6; --alert-label-color: #1d4ed8; }\r\n.alert-success { --alert-bg: #f0fdf4; --alert-border: #22c55e; --alert-label-color: #15803d; }\r\n.alert-warning { --alert-bg: #fffbeb; --alert-border: #f59e0b; --alert-label-color: #b45309; }\r\n.alert-error { --alert-bg: #fef2f2; --alert-border: #ef4444; --alert-label-color: #b91c1c; }\r\n\r\n.alert-header {\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n margin-bottom: 8px;\r\n font-weight: 600;\r\n font-size: 0.9em;\r\n color: var(--alert-label-color);\r\n}\r\n\r\n.alert-icon { font-size: 1em; }\r\n\r\n.alert-body {\r\n font-size: 0.92em;\r\n line-height: 1.7;\r\n color: #374151;\r\n}\r\n\r\n.alert-body :deep(p) {\r\n margin: 4px 0;\r\n}\r\n\r\n.alert-body :deep(code) {\r\n background: rgba(0, 0, 0, 0.07);\r\n padding: 1px 5px;\r\n border-radius: 3px;\r\n font-size: 0.88em;\r\n}\r\n</style>\r\n","<script setup lang=\"ts\">\r\ndefineProps<{\r\n title?: string\r\n}>()\r\n</script>\r\n\r\n<template>\r\n <div class=\"data-card\">\r\n <div v-if=\"title\" class=\"card-header\">\r\n <span class=\"card-icon\">📋</span>\r\n <span class=\"card-title\">{{ title }}</span>\r\n </div>\r\n <div class=\"card-body\">\r\n <slot />\r\n </div>\r\n </div>\r\n</template>\r\n\r\n<style scoped>\r\n.data-card {\r\n border: 1px solid #e5e7eb;\r\n border-radius: 8px;\r\n overflow: hidden;\r\n margin: 16px 0;\r\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);\r\n background: #fff;\r\n}\r\n\r\n.card-header {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n padding: 10px 16px;\r\n background: #f8fafc;\r\n border-bottom: 1px solid #e5e7eb;\r\n font-weight: 600;\r\n font-size: 0.9em;\r\n color: #374151;\r\n}\r\n\r\n.card-icon { font-size: 1em; }\r\n\r\n.card-body {\r\n padding: 14px 16px;\r\n font-size: 0.93em;\r\n line-height: 1.7;\r\n color: #374151;\r\n}\r\n\r\n.card-body :deep(p) {\r\n margin: 4px 0;\r\n}\r\n\r\n.card-body :deep(ul),\r\n.card-body :deep(ol) {\r\n padding-left: 20px;\r\n margin: 6px 0;\r\n}\r\n\r\n.card-body :deep(table) {\r\n width: 100%;\r\n border-collapse: collapse;\r\n font-size: 0.9em;\r\n}\r\n\r\n.card-body :deep(th),\r\n.card-body :deep(td) {\r\n border: 1px solid #e5e7eb;\r\n padding: 6px 10px;\r\n text-align: left;\r\n}\r\n\r\n.card-body :deep(th) {\r\n background: #f3f4f6;\r\n font-weight: 600;\r\n}\r\n\r\n.card-body :deep(code) {\r\n background: rgba(0, 0, 0, 0.07);\r\n padding: 1px 5px;\r\n border-radius: 3px;\r\n font-size: 0.88em;\r\n}\r\n</style>\r\n","import { defineComponent, h, type PropType, type VNode } from 'vue'\r\nimport { renderMarkdown } from '../composables/useMarkdownParser'\r\nimport { htmlToVnodes, type ComponentMap } from '../utils/htmlToVnodes'\r\nimport AlertBlock from './blocks/AlertBlock.vue'\r\nimport DataCard from './blocks/DataCard.vue'\r\n\r\nconst componentMap: ComponentMap = {\r\n AlertBlock,\r\n DataCard,\r\n}\r\n\r\nexport default defineComponent({\r\n name: 'MarkdownRenderer',\r\n props: {\r\n content: {\r\n type: String as PropType<string>,\r\n default: '',\r\n },\r\n },\r\n setup(props) {\r\n return () => {\r\n const html = renderMarkdown(props.content)\r\n const vnodes = htmlToVnodes(html, componentMap)\r\n return h('div', { class: 'markdown-body' }, vnodes as VNode[])\r\n }\r\n },\r\n})\r\n","import { ref } from 'vue'\r\n\r\nconst MOCK_TEXT = `# 流式 Markdown 渲染演示\r\n\r\n这是一个演示**流式输出 Markdown 内容**并将特定块渲染为 Vue 组件的示例。\r\n\r\n## 功能特性\r\n\r\n- ✅ 流式打字机渲染\r\n- ✅ 普通 Markdown 语法(标题、粗体、代码等)\r\n- ✅ \\`:::alert\\` 块 → AlertBlock 组件\r\n- ✅ \\`:::card\\` 块 → DataCard 组件\r\n- ✅ 流式未闭合时自动补全容器\r\n\r\n## 代码示例\r\n\r\n\\`\\`\\`typescript\r\nconst md = new MarkdownIt()\r\nmd.use(container, 'alert', {\r\n render(tokens, idx) {\r\n return '<vue-block data-component=\"AlertBlock\">'\r\n }\r\n})\r\n\\`\\`\\`\r\n\r\n::: alert info\r\n**渲染原理**:markdown-it-container 的 \\`render\\` 回调输出自定义 \\`<vue-block>\\` 占位元素,再由 \\`DOMParser\\` 递归转为 \\`h()\\` VNode 树。\r\n:::\r\n\r\n## 数据展示\r\n\r\n::: card 技术栈对比\r\n\r\n| 方案 | 性能 | 体积 |\r\n|------|------|------|\r\n| h() + DOMParser | ✅ 优秀 | ✅ 轻量 |\r\n| compile() | ❌ 每次重编译 | ❌ +14KB |\r\n\r\n:::\r\n\r\n::: alert warning\r\n未闭合的容器块在流式输出过程中会由 \\`autoCloseContainers()\\` 自动补全,确保 markdown-it 始终解析到**合法输入**。\r\n:::\r\n\r\n::: alert success\r\n✨ 所有功能均已实现!当前页面正是流式渲染的实时效果,块组件完整挂载了 Vue 响应式系统。\r\n:::\r\n\r\n::: card 实现步骤\r\n\r\n1. **预处理**:\\`autoCloseContainers()\\` 补全未闭合 \\`:::\\`\r\n2. **解析**:\\`markdown-it\\` + \\`markdown-it-container\\` 输出含 \\`<vue-block>\\` 的 HTML\r\n3. **转换**:\\`DOMParser\\` 遍历 DOM,将 \\`<vue-block>\\` 节点替换为 \\`h(Vue组件)\\`\r\n4. **渲染**:\\`MarkdownRenderer\\` 组件通过 render 函数返回 VNode 树\r\n\r\n:::\r\n\r\n::: alert error\r\n注意:此方案仅适用于**客户端渲染**场景,SSR 环境下需将 \\`DOMParser\\` 替换为 \\`parse5\\`。\r\n:::\r\n\r\n---\r\n\r\n🎉 流式输出完成!\r\n`\r\n\r\n/**\r\n * Mock 流式文本输出\r\n * 模拟 AI 逐字输出场景\r\n */\r\nexport function useStreamingText() {\r\n const text = ref('')\r\n const isStreaming = ref(false)\r\n let timer: ReturnType<typeof setInterval> | null = null\r\n let position = 0\r\n\r\n function startStream() {\r\n if (isStreaming.value) return\r\n\r\n isStreaming.value = true\r\n position = text.value.length // 支持续播\r\n\r\n timer = setInterval(() => {\r\n if (position >= MOCK_TEXT.length) {\r\n stopStream()\r\n return\r\n }\r\n // 每次追加 1~3 个字符,模拟真实流式速度\r\n const chunkSize = Math.floor(Math.random() * 3) + 1\r\n text.value += MOCK_TEXT.slice(position, position + chunkSize)\r\n position += chunkSize\r\n }, 30)\r\n }\r\n\r\n function stopStream() {\r\n if (timer !== null) {\r\n clearInterval(timer)\r\n timer = null\r\n }\r\n isStreaming.value = false\r\n }\r\n\r\n function resetStream() {\r\n stopStream()\r\n text.value = ''\r\n position = 0\r\n }\r\n\r\n return {\r\n text,\r\n isStreaming,\r\n startStream,\r\n stopStream,\r\n resetStream,\r\n }\r\n}\r\n"],"names":["autoCloseContainers","raw","lines","stack","line","trimmed","md","MarkdownIt","container","params","tokens","idx","token","renderMarkdown","completed","domNodeToVNode","node","componentMap","el","tag","compName","h","Comp","props","attr","key","children","domNodesToVNodes","attrs","nodes","result","vnode","htmlToVnodes","html","root","__props","iconMap","labelMap","_createElementBlock","_normalizeClass","_createElementVNode","_hoisted_1","_hoisted_2","_toDisplayString","_hoisted_3","_hoisted_4","_renderSlot","_ctx","_openBlock","_cache","AlertBlock","DataCard","MarkdownRenderer","defineComponent","vnodes","MOCK_TEXT","useStreamingText","text","ref","isStreaming","timer","position","startStream","stopStream","chunkSize","resetStream"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@krishanjinbo/vue-markdown-stream",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Stream markdown content with Vue 3 component blocks — powered by markdown-it-container",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vue3",
|
|
8
|
+
"markdown",
|
|
9
|
+
"streaming",
|
|
10
|
+
"markdown-it",
|
|
11
|
+
"markdown-it-container",
|
|
12
|
+
"vue-component",
|
|
13
|
+
"typewriter",
|
|
14
|
+
"ai-streaming"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "hanlang123",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/hanlang123/vue-markdown-stream.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://hanlang123.github.io/vue-markdown-stream/",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/hanlang123/vue-markdown-stream/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/vue-markdown-stream.cjs.js",
|
|
28
|
+
"module": "./dist/vue-markdown-stream.es.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/vue-markdown-stream.es.js",
|
|
34
|
+
"require": "./dist/vue-markdown-stream.cjs.js"
|
|
35
|
+
},
|
|
36
|
+
"./style.css": "./dist/style.css"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"dev": "vite",
|
|
45
|
+
"build": "vue-tsc -b && vite build",
|
|
46
|
+
"build:lib": "vue-tsc -b && cross-env BUILD_MODE=lib vite build",
|
|
47
|
+
"preview": "vite preview",
|
|
48
|
+
"docs:dev": "vitepress dev docs",
|
|
49
|
+
"docs:build": "vitepress build docs",
|
|
50
|
+
"docs:preview": "vitepress preview docs"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"markdown-it": ">=14.0.0",
|
|
54
|
+
"markdown-it-container": ">=4.0.0",
|
|
55
|
+
"vue": ">=3.3.0"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"markdown-it": "^14.1.1",
|
|
59
|
+
"markdown-it-container": "^4.0.0",
|
|
60
|
+
"vue": "^3.5.25"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/markdown-it": "^14.1.2",
|
|
64
|
+
"@types/markdown-it-container": "^4.0.0",
|
|
65
|
+
"@types/node": "^24.10.1",
|
|
66
|
+
"@vitejs/plugin-vue": "^6.0.2",
|
|
67
|
+
"@vue/tsconfig": "^0.8.1",
|
|
68
|
+
"cross-env": "^10.1.0",
|
|
69
|
+
"typescript": "~5.9.3",
|
|
70
|
+
"vite": "^7.3.1",
|
|
71
|
+
"vite-plugin-dts": "^4.5.4",
|
|
72
|
+
"vitepress": "^1.6.4",
|
|
73
|
+
"vue-tsc": "^3.1.5"
|
|
74
|
+
}
|
|
75
|
+
}
|