@preferred-markdown-stream/vue 0.1.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/README.md +32 -0
- package/dist/fadeIn.d.ts +2 -0
- package/dist/fadeIn.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/render.d.ts +17 -0
- package/dist/render.js +326 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +65 -0
- package/dist/useStreamingMarkdown.d.ts +14 -0
- package/dist/useStreamingMarkdown.js +62 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @preferred-markdown-stream/vue
|
|
2
|
+
|
|
3
|
+
Vue bindings and runtime helpers for streaming Markdown rendering.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @preferred-markdown-stream/vue vue
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
createStreamingMarkdownVNodes,
|
|
16
|
+
createVNodeRendererComponent,
|
|
17
|
+
} from '@preferred-markdown-stream/vue'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Public API
|
|
21
|
+
|
|
22
|
+
- `addFadeInToVNodes(children, loading, fadeInClass?)`
|
|
23
|
+
- `createStreamingMarkdownVNodes(content, loading)`
|
|
24
|
+
- `createVNodeRendererComponent(vnodes)`
|
|
25
|
+
- `setCodeBlockComponent(component)`
|
|
26
|
+
|
|
27
|
+
## Notes
|
|
28
|
+
|
|
29
|
+
- The package currently preserves the app's existing runtime behavior.
|
|
30
|
+
- `fadeIn.ts` adapts the framework-agnostic tree animation helper for Vue VNodes.
|
|
31
|
+
- `runtime.ts` stays internal and owns the Markdown renderer instance plus lazy capability loading.
|
|
32
|
+
- `useStreamingMarkdown.ts` contains the repeated Vue wiring that was previously duplicated inside app components.
|
package/dist/fadeIn.d.ts
ADDED
package/dist/fadeIn.js
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Plugin<Ctx = any> {
|
|
2
|
+
name: string;
|
|
3
|
+
register?: (ctx: Ctx) => any;
|
|
4
|
+
}
|
|
5
|
+
export declare const DOM_ATTR_NAME: {
|
|
6
|
+
SOURCE_LINE_START: string;
|
|
7
|
+
SOURCE_LINE_END: string;
|
|
8
|
+
ORIGIN_SRC: string;
|
|
9
|
+
ORIGIN_HREF: string;
|
|
10
|
+
LOCAL_IMAGE: string;
|
|
11
|
+
ONLY_CHILD: string;
|
|
12
|
+
TOKEN_IDX: string;
|
|
13
|
+
DISPLAY_NONE: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function setCodeBlockComponent(component: any): void;
|
|
16
|
+
declare function render_(md: any): void;
|
|
17
|
+
export default render_;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { escapeHtml, unescapeAll } from 'markdown-it/lib/common/utils.mjs';
|
|
2
|
+
import { Comment, createVNode, Fragment, h, Text, } from 'vue';
|
|
3
|
+
export const DOM_ATTR_NAME = {
|
|
4
|
+
SOURCE_LINE_START: 'data-source-line',
|
|
5
|
+
SOURCE_LINE_END: 'data-source-line-end',
|
|
6
|
+
ORIGIN_SRC: 'origin-src',
|
|
7
|
+
ORIGIN_HREF: 'origin-href',
|
|
8
|
+
LOCAL_IMAGE: 'local-image',
|
|
9
|
+
ONLY_CHILD: 'auto-center',
|
|
10
|
+
TOKEN_IDX: 'data-token-idx',
|
|
11
|
+
DISPLAY_NONE: 'display-none',
|
|
12
|
+
};
|
|
13
|
+
const sensitiveUrlReg = /^javascript:|vbscript:|file:/i;
|
|
14
|
+
const sensitiveAttrReg = /^href|src|xlink:href|poster/i;
|
|
15
|
+
const attrNameReg = /^[a-z_:][\w:.-]*$/i;
|
|
16
|
+
const attrEventReg = /^on/i;
|
|
17
|
+
const CODE_INFO_SPLIT_REGEXP = /(\s+)/g;
|
|
18
|
+
const STREAMING_TEXT_SPLIT_REGEXP = /(?<=[。?!;、,,;\n])|(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!`])/g;
|
|
19
|
+
const defaultRules = {};
|
|
20
|
+
function validateAttrName(name) {
|
|
21
|
+
return attrNameReg.test(name) && !attrEventReg.test(name);
|
|
22
|
+
}
|
|
23
|
+
function getLine(token, env) {
|
|
24
|
+
const [lineStart, lineEnd] = token.map || [0, 1];
|
|
25
|
+
// macro, calc line offset, see `markdown-macro` plugin.
|
|
26
|
+
let sOffset = 0;
|
|
27
|
+
if (env?.macroLines && env.bMarks && env.eMarks) {
|
|
28
|
+
const sPos = env.bMarks[lineStart];
|
|
29
|
+
for (let i = 0; i < env.macroLines.length; i++) {
|
|
30
|
+
const { matchPos, lineOffset, posOffset, currentPosOffset } = env.macroLines[i];
|
|
31
|
+
if (sPos + posOffset > matchPos
|
|
32
|
+
&& sPos + posOffset - currentPosOffset > matchPos) {
|
|
33
|
+
sOffset = lineOffset;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [lineStart + sOffset, lineEnd + sOffset];
|
|
41
|
+
}
|
|
42
|
+
function processToken(token, env) {
|
|
43
|
+
if (!token.meta) {
|
|
44
|
+
token.meta = {};
|
|
45
|
+
}
|
|
46
|
+
if (env?.safeMode && token.attrs) {
|
|
47
|
+
for (let [name, val] of token.attrs) {
|
|
48
|
+
name = name.toLowerCase();
|
|
49
|
+
if (sensitiveAttrReg.test(name) && sensitiveUrlReg.test(val)) {
|
|
50
|
+
token.attrSet(name, '');
|
|
51
|
+
}
|
|
52
|
+
if (name === 'href' && val.toLowerCase().startsWith('data:')) {
|
|
53
|
+
token.attrSet(name, '');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (token.block) {
|
|
58
|
+
const [lineStart, lineEnd] = getLine(token, env);
|
|
59
|
+
if (token.map) {
|
|
60
|
+
token.attrSet(DOM_ATTR_NAME.SOURCE_LINE_START, String(lineStart + 1));
|
|
61
|
+
token.attrSet(DOM_ATTR_NAME.SOURCE_LINE_END, String(lineEnd + 1));
|
|
62
|
+
if (!token.meta.attrs) {
|
|
63
|
+
token.meta.attrs = {};
|
|
64
|
+
}
|
|
65
|
+
// transform array to object
|
|
66
|
+
if (token.attrs) {
|
|
67
|
+
for (const [name, val] of token.attrs) {
|
|
68
|
+
token.meta.attrs[name] = val;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
defaultRules.code_inline = function (tokens, idx, _, __, slf) {
|
|
75
|
+
const token = tokens[idx];
|
|
76
|
+
return createVNode('code', { ...slf.renderAttrs(token), key: idx }, token.content);
|
|
77
|
+
};
|
|
78
|
+
defaultRules.code_block = function (tokens, idx, _, __, slf) {
|
|
79
|
+
const token = tokens[idx];
|
|
80
|
+
const attrs = slf.renderAttrs(token);
|
|
81
|
+
const preAttrs = {
|
|
82
|
+
[DOM_ATTR_NAME.SOURCE_LINE_START]: attrs[DOM_ATTR_NAME.SOURCE_LINE_START],
|
|
83
|
+
[DOM_ATTR_NAME.SOURCE_LINE_END]: attrs[DOM_ATTR_NAME.SOURCE_LINE_END],
|
|
84
|
+
};
|
|
85
|
+
delete attrs[DOM_ATTR_NAME.SOURCE_LINE_START];
|
|
86
|
+
delete attrs[DOM_ATTR_NAME.SOURCE_LINE_END];
|
|
87
|
+
return h('pre', preAttrs, [
|
|
88
|
+
h('code', attrs, [createVNode(Text, {}, token.content)]),
|
|
89
|
+
]);
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Custom code block component slot.
|
|
93
|
+
* When set via `setCodeBlockComponent()`, the fence rule delegates rendering
|
|
94
|
+
* to this component instead of the default `pre > code`.
|
|
95
|
+
*
|
|
96
|
+
* The component receives props: `{ language: string, content: string, preAttrs: Record<string, any> }`
|
|
97
|
+
* where `content` is the highlighted HTML string.
|
|
98
|
+
*/
|
|
99
|
+
let customCodeBlockComponent = null;
|
|
100
|
+
export function setCodeBlockComponent(component) {
|
|
101
|
+
customCodeBlockComponent = component;
|
|
102
|
+
}
|
|
103
|
+
// fence rule
|
|
104
|
+
defaultRules.fence = function (tokens, idx, options, _, slf) {
|
|
105
|
+
const token = tokens[idx];
|
|
106
|
+
const info = token.info ? unescapeAll(token.info).trim() : '';
|
|
107
|
+
let langName = '';
|
|
108
|
+
let langAttrs = '';
|
|
109
|
+
let i;
|
|
110
|
+
let arr;
|
|
111
|
+
let tmpAttrs;
|
|
112
|
+
let tmpToken;
|
|
113
|
+
if (info) {
|
|
114
|
+
arr = info.split(CODE_INFO_SPLIT_REGEXP);
|
|
115
|
+
langName = arr[0];
|
|
116
|
+
langAttrs = arr.slice(2).join('');
|
|
117
|
+
}
|
|
118
|
+
const highlighted = options.highlight
|
|
119
|
+
? options.highlight(token.content, langName, langAttrs)
|
|
120
|
+
|| escapeHtml(token.content)
|
|
121
|
+
: escapeHtml(token.content);
|
|
122
|
+
const buildVNode = (attrs) => {
|
|
123
|
+
const preAttrs = {
|
|
124
|
+
'data-info': info,
|
|
125
|
+
'data-lang': langName,
|
|
126
|
+
[DOM_ATTR_NAME.SOURCE_LINE_START]: attrs[DOM_ATTR_NAME.SOURCE_LINE_START],
|
|
127
|
+
[DOM_ATTR_NAME.SOURCE_LINE_END]: attrs[DOM_ATTR_NAME.SOURCE_LINE_END],
|
|
128
|
+
};
|
|
129
|
+
delete attrs[DOM_ATTR_NAME.SOURCE_LINE_START];
|
|
130
|
+
delete attrs[DOM_ATTR_NAME.SOURCE_LINE_END];
|
|
131
|
+
if (customCodeBlockComponent) {
|
|
132
|
+
return h(customCodeBlockComponent, {
|
|
133
|
+
language: langName,
|
|
134
|
+
content: highlighted,
|
|
135
|
+
preAttrs,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Default: simple pre > code
|
|
139
|
+
return h('pre', preAttrs, [
|
|
140
|
+
createVNode('code', { innerHTML: highlighted }, []),
|
|
141
|
+
]);
|
|
142
|
+
};
|
|
143
|
+
// If language exists, inject class gently, without modifying original token.
|
|
144
|
+
if (info) {
|
|
145
|
+
i = token.attrIndex('class');
|
|
146
|
+
tmpAttrs = token.attrs ? [...token.attrs] : [];
|
|
147
|
+
if (i < 0) {
|
|
148
|
+
tmpAttrs.push(['class', options.langPrefix + langName]);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
tmpAttrs[i] = [...tmpAttrs[i]];
|
|
152
|
+
tmpAttrs[i][1] += ` ${options.langPrefix}${langName}`;
|
|
153
|
+
}
|
|
154
|
+
// Fake token just to render attributes
|
|
155
|
+
tmpToken = {
|
|
156
|
+
attrs: tmpAttrs,
|
|
157
|
+
};
|
|
158
|
+
return buildVNode(slf.renderAttrs(tmpToken));
|
|
159
|
+
}
|
|
160
|
+
return buildVNode(slf.renderAttrs(token));
|
|
161
|
+
};
|
|
162
|
+
defaultRules.image = function (tokens, idx, options, env, slf) {
|
|
163
|
+
const token = tokens[idx];
|
|
164
|
+
return h('img', {
|
|
165
|
+
...slf.renderAttrs(token),
|
|
166
|
+
alt: slf.renderInlineAsText(token.children || [], options, env),
|
|
167
|
+
}, []);
|
|
168
|
+
};
|
|
169
|
+
defaultRules.hardbreak = function (_, idx) {
|
|
170
|
+
return h('br', { key: idx });
|
|
171
|
+
};
|
|
172
|
+
defaultRules.softbreak = function (_, idx, options) {
|
|
173
|
+
return options.breaks ? h('br', { key: idx }) : null;
|
|
174
|
+
};
|
|
175
|
+
defaultRules.list_item_open = function (tokens, idx, _, __, self) {
|
|
176
|
+
return h('li', { key: idx, ...self.renderAttrs(tokens[idx]) }, []);
|
|
177
|
+
};
|
|
178
|
+
defaultRules.paragraph_open = function (tokens, idx, _, __, self) {
|
|
179
|
+
// 强制添加 p 标签,即使单个列表项。从而防止列表项元素突变。
|
|
180
|
+
return h('p', { key: idx, ...self.renderAttrs(tokens[idx]) }, []);
|
|
181
|
+
};
|
|
182
|
+
defaultRules.text = function (tokens, idx, _, env) {
|
|
183
|
+
const token = tokens[idx];
|
|
184
|
+
if (env.sanitize) {
|
|
185
|
+
// 检查 content 是否包含是句尾符号,如 ',', '。', '?', '!', '. '
|
|
186
|
+
// 使用
|
|
187
|
+
// 分割英语 (?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)
|
|
188
|
+
// 分割中文 (?<=[。?!;])
|
|
189
|
+
const splited = token.content.split(STREAMING_TEXT_SPLIT_REGEXP);
|
|
190
|
+
return splited.map((content, i) => {
|
|
191
|
+
return createVNode('span', { key: i }, { default: () => content });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// console.log('text', token)
|
|
196
|
+
return createVNode(Text, { key: idx }, token.content);
|
|
197
|
+
// return createVNode(Text, { key: idx }, token.content)
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
defaultRules.html_block = function (tokens, idx) {
|
|
201
|
+
const token = tokens[idx];
|
|
202
|
+
if (token.contentVNode) {
|
|
203
|
+
return token.contentVNode;
|
|
204
|
+
}
|
|
205
|
+
return createHtmlVNode(token.content);
|
|
206
|
+
};
|
|
207
|
+
defaultRules.html_inline = function (tokens, idx) {
|
|
208
|
+
const token = tokens[idx];
|
|
209
|
+
if (token.contentVNode) {
|
|
210
|
+
return token.contentVNode;
|
|
211
|
+
}
|
|
212
|
+
return createHtmlVNode(token.content);
|
|
213
|
+
};
|
|
214
|
+
function createHtmlVNode(html) {
|
|
215
|
+
const div = document.createElement('template');
|
|
216
|
+
div.innerHTML = html;
|
|
217
|
+
const elements = div.content.children;
|
|
218
|
+
const children = [];
|
|
219
|
+
for (const element of elements) {
|
|
220
|
+
const tagName = element.tagName.toLowerCase();
|
|
221
|
+
const attrs = {
|
|
222
|
+
key: element.outerHTML,
|
|
223
|
+
};
|
|
224
|
+
for (let j = 0; j < element.attributes.length; j++) {
|
|
225
|
+
const attr = element.attributes[j];
|
|
226
|
+
attrs[attr.name] = attr.value;
|
|
227
|
+
}
|
|
228
|
+
attrs.innerHTML = element.innerHTML;
|
|
229
|
+
attrs.key = element.innerHTML;
|
|
230
|
+
children.push(h(tagName, attrs, []));
|
|
231
|
+
}
|
|
232
|
+
return h(Fragment, {}, children);
|
|
233
|
+
}
|
|
234
|
+
function renderToken(tokens, idx) {
|
|
235
|
+
const token = tokens[idx];
|
|
236
|
+
if (token.nesting === -1) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
// Tight list paragraphs
|
|
240
|
+
if (token.hidden) {
|
|
241
|
+
return createVNode(Fragment, {}, []);
|
|
242
|
+
}
|
|
243
|
+
if (token.tag === '--') {
|
|
244
|
+
return createVNode(Comment);
|
|
245
|
+
}
|
|
246
|
+
return createVNode(token.tag, this.renderAttrs(token), []);
|
|
247
|
+
}
|
|
248
|
+
function renderAttrs(token) {
|
|
249
|
+
if (!token.attrs) {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
const result = {};
|
|
253
|
+
// eslint-disable-next-line unicorn/no-array-for-each
|
|
254
|
+
token.attrs.forEach((token) => {
|
|
255
|
+
if (validateAttrName(token[0])) {
|
|
256
|
+
result[token[0]] = token[1];
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
function render(tokens, options, env) {
|
|
262
|
+
const rules = this.rules;
|
|
263
|
+
const vNodeParents = [];
|
|
264
|
+
return tokens
|
|
265
|
+
.map((token, i) => {
|
|
266
|
+
processToken(token, env);
|
|
267
|
+
if (token.block) {
|
|
268
|
+
token.attrSet(DOM_ATTR_NAME.TOKEN_IDX, i.toString());
|
|
269
|
+
}
|
|
270
|
+
const { type } = token;
|
|
271
|
+
let vnode = null;
|
|
272
|
+
let parent = null;
|
|
273
|
+
if (type === 'inline') {
|
|
274
|
+
vnode = this.render(token.children || [], options, env);
|
|
275
|
+
}
|
|
276
|
+
else if (rules[type]) {
|
|
277
|
+
const result = rules[type](tokens, i, options, env, this);
|
|
278
|
+
if (typeof result === 'string') {
|
|
279
|
+
vnode = createHtmlVNode(result);
|
|
280
|
+
}
|
|
281
|
+
else if (result && result.node && result.parent) {
|
|
282
|
+
parent = result.parent;
|
|
283
|
+
vnode = result.node;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
vnode = result;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
vnode = this.renderToken(tokens, i, options);
|
|
291
|
+
}
|
|
292
|
+
let isChild = false;
|
|
293
|
+
const parentNode = vNodeParents.length > 0 ? vNodeParents.at(-1) : null;
|
|
294
|
+
if (vnode && parentNode) {
|
|
295
|
+
if (typeof parentNode.type === 'string'
|
|
296
|
+
|| parentNode.type === Fragment) {
|
|
297
|
+
const children = Array.isArray(parentNode.children)
|
|
298
|
+
? parentNode.children
|
|
299
|
+
: [];
|
|
300
|
+
parentNode.children = [...children, vnode];
|
|
301
|
+
}
|
|
302
|
+
isChild = true;
|
|
303
|
+
}
|
|
304
|
+
if (token.nesting === 1) {
|
|
305
|
+
if (parent) {
|
|
306
|
+
vNodeParents.push(parent);
|
|
307
|
+
}
|
|
308
|
+
else if (vnode) {
|
|
309
|
+
vNodeParents.push(vnode);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (token.nesting === -1) {
|
|
313
|
+
vNodeParents.pop();
|
|
314
|
+
}
|
|
315
|
+
return isChild ? null : vnode;
|
|
316
|
+
})
|
|
317
|
+
.filter(node => !!node);
|
|
318
|
+
}
|
|
319
|
+
function render_(md) {
|
|
320
|
+
md.renderer.rules = { ...md.renderer.rules, ...defaultRules };
|
|
321
|
+
md.renderer.render = render;
|
|
322
|
+
md.renderer.renderInline = render;
|
|
323
|
+
md.renderer.renderAttrs = renderAttrs;
|
|
324
|
+
md.renderer.renderToken = renderToken;
|
|
325
|
+
}
|
|
326
|
+
export default render_;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import markdownit from 'markdown-it';
|
|
2
|
+
export declare const md: markdownit;
|
|
3
|
+
export declare const isKatexLoaded: import("vue").Ref<boolean, boolean>;
|
|
4
|
+
export declare const isShikiLoaded: import("vue").Ref<boolean, boolean>;
|
|
5
|
+
export declare function loadKatex(): Promise<void>;
|
|
6
|
+
export declare function loadShiki(): Promise<void>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import markdownit from 'markdown-it';
|
|
2
|
+
import todo from 'markdown-it-todo';
|
|
3
|
+
import { ref } from 'vue';
|
|
4
|
+
import VNodePlugin from './render.js';
|
|
5
|
+
export const md = markdownit({
|
|
6
|
+
linkify: true,
|
|
7
|
+
typographer: true,
|
|
8
|
+
breaks: true,
|
|
9
|
+
html: true,
|
|
10
|
+
});
|
|
11
|
+
md.use(VNodePlugin);
|
|
12
|
+
md.use(todo);
|
|
13
|
+
// Lazy load katex and shiki to reduce initial bundle size
|
|
14
|
+
export const isKatexLoaded = ref(false);
|
|
15
|
+
export const isShikiLoaded = ref(false);
|
|
16
|
+
export async function loadKatex() {
|
|
17
|
+
if (isKatexLoaded.value) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const [katex, texmath] = await Promise.all([
|
|
21
|
+
import('katex'),
|
|
22
|
+
import('markdown-it-texmath'),
|
|
23
|
+
import('katex/dist/katex.min.css'), // Lazy load CSS
|
|
24
|
+
]);
|
|
25
|
+
const tm = texmath.default.use(katex.default);
|
|
26
|
+
md.use(tm, { delimiters: ['brackets', 'dollars'] });
|
|
27
|
+
isKatexLoaded.value = true;
|
|
28
|
+
}
|
|
29
|
+
export async function loadShiki() {
|
|
30
|
+
if (isShikiLoaded.value) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const Shiki = await import('@shikijs/markdown-it');
|
|
35
|
+
const shiki = await Shiki.default({
|
|
36
|
+
theme: 'vitesse-dark',
|
|
37
|
+
// Only load most commonly used languages to reduce bundle size
|
|
38
|
+
langs: [
|
|
39
|
+
'javascript',
|
|
40
|
+
'typescript',
|
|
41
|
+
'python',
|
|
42
|
+
'bash',
|
|
43
|
+
'json',
|
|
44
|
+
'html',
|
|
45
|
+
'css',
|
|
46
|
+
'markdown',
|
|
47
|
+
'vue',
|
|
48
|
+
'rust',
|
|
49
|
+
'go',
|
|
50
|
+
'java',
|
|
51
|
+
'sql',
|
|
52
|
+
'jsx',
|
|
53
|
+
'tsx',
|
|
54
|
+
],
|
|
55
|
+
fallbackLanguage: 'markdown',
|
|
56
|
+
});
|
|
57
|
+
md.use(shiki);
|
|
58
|
+
isShikiLoaded.value = true;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error('Failed to load Shiki:', error);
|
|
62
|
+
// If Shiki fails to load, ensure repeated attempts are skipped.
|
|
63
|
+
isShikiLoaded.value = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Ref, VNode } from 'vue';
|
|
2
|
+
export declare function createStreamingMarkdownVNodes(content: Readonly<Ref<string>>, loading: Readonly<Ref<boolean>>): {
|
|
3
|
+
contentFinal: import("vue").ComputedRef<string>;
|
|
4
|
+
contentVNodes: import("@vueuse/core").ComputedRefWithControl<VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
5
|
+
[key: string]: any;
|
|
6
|
+
}>[]>;
|
|
7
|
+
debouncedLoading: Ref<boolean, boolean>;
|
|
8
|
+
formattedContent: import("vue").ComputedRef<string>;
|
|
9
|
+
};
|
|
10
|
+
export declare function createVNodeRendererComponent(vnodes: {
|
|
11
|
+
value: VNode[];
|
|
12
|
+
}): import("vue").DefineComponent<{}, () => VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
13
|
+
[key: string]: any;
|
|
14
|
+
}>[], {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { splitContent } from '@preferred-markdown-stream/core';
|
|
2
|
+
import { computedWithControl } from '@vueuse/core';
|
|
3
|
+
import { computed, defineComponent, ref, watch } from 'vue';
|
|
4
|
+
import { addFadeInToVNodes } from './fadeIn.js';
|
|
5
|
+
import { isKatexLoaded, isShikiLoaded, loadKatex, loadShiki, md, } from './runtime.js';
|
|
6
|
+
function ensureMarkdownCapabilities(content) {
|
|
7
|
+
if (content.includes('```') || content.includes('`')) {
|
|
8
|
+
loadShiki();
|
|
9
|
+
}
|
|
10
|
+
if (content.includes('$')
|
|
11
|
+
|| content.includes(String.raw `\(`)
|
|
12
|
+
|| content.includes(String.raw `\[`)) {
|
|
13
|
+
loadKatex();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function renderMarkdown(content) {
|
|
17
|
+
ensureMarkdownCapabilities(content);
|
|
18
|
+
return md.render(content, {
|
|
19
|
+
sanitize: true,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function createStreamingMarkdownVNodes(content, loading) {
|
|
23
|
+
// Trailing-only debounce: true takes effect immediately,
|
|
24
|
+
// false is delayed so the last batch of content still animates.
|
|
25
|
+
const debouncedLoading = ref(loading.value);
|
|
26
|
+
let offTimer;
|
|
27
|
+
watch(loading, (val) => {
|
|
28
|
+
if (offTimer !== undefined) {
|
|
29
|
+
clearTimeout(offTimer);
|
|
30
|
+
offTimer = undefined;
|
|
31
|
+
}
|
|
32
|
+
if (val) {
|
|
33
|
+
debouncedLoading.value = true;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
offTimer = setTimeout(() => {
|
|
37
|
+
debouncedLoading.value = false;
|
|
38
|
+
}, 1000);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const formattedContent = computed(() => splitContent(content.value));
|
|
42
|
+
const contentFinal = computed(() => loading.value ? formattedContent.value : content.value);
|
|
43
|
+
const contentVNodes = computedWithControl(() => [isShikiLoaded.value, isKatexLoaded.value, contentFinal.value], () => {
|
|
44
|
+
const currentContent = contentFinal.value ?? '';
|
|
45
|
+
const rendered = renderMarkdown(currentContent);
|
|
46
|
+
return addFadeInToVNodes(rendered, debouncedLoading.value);
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
contentFinal,
|
|
50
|
+
contentVNodes,
|
|
51
|
+
debouncedLoading,
|
|
52
|
+
formattedContent,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function createVNodeRendererComponent(vnodes) {
|
|
56
|
+
return defineComponent({
|
|
57
|
+
setup() {
|
|
58
|
+
// eslint-disable-next-line unicorn/consistent-function-scoping
|
|
59
|
+
return () => vnodes.value;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@preferred-markdown-stream/vue",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": false,
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"README.md",
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p ./tsconfig.build.json",
|
|
26
|
+
"test": "vitest run --config ./vitest.config.ts",
|
|
27
|
+
"typecheck": "tsc -p ./tsconfig.json"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"vue": "^3.5.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@preferred-markdown-stream/core": "workspace:*",
|
|
34
|
+
"@shikijs/markdown-it": "^4.0.2",
|
|
35
|
+
"@vueuse/core": "^14.2.1",
|
|
36
|
+
"katex": "^0.16.42",
|
|
37
|
+
"markdown-it": "^14.1.1",
|
|
38
|
+
"markdown-it-texmath": "^1.0.0",
|
|
39
|
+
"markdown-it-todo": "^1.0.5"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/markdown-it": "^14.1.2",
|
|
43
|
+
"typescript": "^6.0.2",
|
|
44
|
+
"vitest": "^4.1.1",
|
|
45
|
+
"vue": "^3.5.30"
|
|
46
|
+
}
|
|
47
|
+
}
|