@preferred-markdown-stream/core 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 +28 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/streamingText.d.ts +21 -0
- package/dist/streamingText.js +247 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @preferred-markdown-stream/core
|
|
2
|
+
|
|
3
|
+
Framework-agnostic utilities for streaming Markdown UIs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @preferred-markdown-stream/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { splitContent } from '@preferred-markdown-stream/core'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Public API
|
|
18
|
+
|
|
19
|
+
- `splitContent(message)`
|
|
20
|
+
- `addFadeInClassToTreeNodes(children, loading, fadeInClass?)`
|
|
21
|
+
- `streamingTextStyles`
|
|
22
|
+
- `StreamingTextNode`
|
|
23
|
+
|
|
24
|
+
## Notes
|
|
25
|
+
|
|
26
|
+
- The current implementation keeps the existing behavior from the app.
|
|
27
|
+
- The core package no longer depends on Vue types.
|
|
28
|
+
- Vue-specific node adaptation lives in `@preferred-markdown-stream/vue`.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { addFadeInClassToTreeNodes, splitContent, streamingTextStyles, } from './streamingText.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface StreamingTextNode {
|
|
2
|
+
children?: string | StreamingTextNode[] | unknown;
|
|
3
|
+
props?: {
|
|
4
|
+
class?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
} | null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Smart content splitting to avoid displaying incomplete sentences
|
|
10
|
+
* Supports both English and Chinese punctuation
|
|
11
|
+
*/
|
|
12
|
+
export declare function splitContent(msg: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Add a fade-in class to string-backed nodes in a generic tree structure.
|
|
15
|
+
*/
|
|
16
|
+
export declare function addFadeInClassToTreeNodes<T extends StreamingTextNode>(childrenRaw: T[], loading: boolean, fadeInClass?: string): T[];
|
|
17
|
+
/**
|
|
18
|
+
* CSS styles for streaming text animation
|
|
19
|
+
* Can be used in your global styles or component styles
|
|
20
|
+
*/
|
|
21
|
+
export declare const streamingTextStyles = "\n.fade-in {\n opacity: 0;\n animation: fadeIn 1s forwards;\n}\n\n@keyframes fadeIn {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n.streaming-text-wrapper {\n position: relative;\n}\n\n.streaming-text-wrapper.streaming {\n /* Additional styling for streaming state */\n}\n";
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const SENTENCE_SPLIT_REGEXP = /(?<=[。?!;、,,;\n])|(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!`])/g;
|
|
2
|
+
const SENTENCE_END_REGEXP = /[.?!。?!;,、,;`\n]$/;
|
|
3
|
+
const ORDERED_LIST_PREFIX_REGEXP = /^\d+\./;
|
|
4
|
+
const TABLE_ROW_REGEXP = /^\|.+\|$/;
|
|
5
|
+
const TABLE_SEPARATOR_REGEXP = /^\|[\s:|-]+\|$/;
|
|
6
|
+
const FENCE_DELIMITER_REGEXP = /^(`{3,}|~{3,})/gm;
|
|
7
|
+
const INLINE_CODE_BACKTICK_REGEXP = /(?<!`)`(?!`)/g;
|
|
8
|
+
const COMPLETE_INLINE_MARKUP_REGEXP = /^!?\[[^\]]*\]\([^)]*\)/;
|
|
9
|
+
/**
|
|
10
|
+
* Strip trailing unclosed display-math blocks ($$…) that would render
|
|
11
|
+
* as plain text because the closing $$ has not arrived yet.
|
|
12
|
+
* Fenced code blocks are NOT stripped — markdown-it renders unclosed
|
|
13
|
+
* fences as code, allowing progressive streaming inside code blocks.
|
|
14
|
+
*/
|
|
15
|
+
function stripUnclosedBlock(content) {
|
|
16
|
+
const lastDoubleDollar = content.lastIndexOf('$$');
|
|
17
|
+
if (lastDoubleDollar !== -1) {
|
|
18
|
+
let count = 0;
|
|
19
|
+
let pos = -1;
|
|
20
|
+
// eslint-disable-next-line no-cond-assign
|
|
21
|
+
while ((pos = content.indexOf('$$', pos + 1)) !== -1) {
|
|
22
|
+
count++;
|
|
23
|
+
}
|
|
24
|
+
if (count % 2 === 1) {
|
|
25
|
+
return `${content.slice(0, lastDoubleDollar).trimEnd()}\n`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Strip trailing incomplete inline markup such as links `[text](url)` and
|
|
32
|
+
* images ``. If the content ends mid-link/image, truncate back
|
|
33
|
+
* to just before the opening `[` (or `![`).
|
|
34
|
+
*
|
|
35
|
+
* Recognised incomplete patterns (from the last unmatched `[`):
|
|
36
|
+
* [text – unclosed bracket
|
|
37
|
+
* [text] – no (url) part yet
|
|
38
|
+
* [text](url – unclosed parenthesis
|
|
39
|
+
*/
|
|
40
|
+
function stripIncompleteInlineMarkup(content) {
|
|
41
|
+
// Find the last `[` that could start a link/image.
|
|
42
|
+
// Walk backwards so we handle the trailing-most incomplete markup.
|
|
43
|
+
let i = content.length - 1;
|
|
44
|
+
while (i >= 0) {
|
|
45
|
+
if (content[i] === '[') {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
i--;
|
|
49
|
+
}
|
|
50
|
+
if (i < 0) {
|
|
51
|
+
return content; // no `[` at all
|
|
52
|
+
}
|
|
53
|
+
// Check whether the `[` is inside a fenced code block — if so, skip.
|
|
54
|
+
const beforeBracket = content.slice(0, i);
|
|
55
|
+
const fencesBefore = beforeBracket.match(FENCE_DELIMITER_REGEXP)?.length ?? 0;
|
|
56
|
+
if (fencesBefore % 2 === 1) {
|
|
57
|
+
return content; // inside a code fence, leave alone
|
|
58
|
+
}
|
|
59
|
+
// Also skip if it's inside an inline code span (backticks).
|
|
60
|
+
const backticksBefore = beforeBracket.match(INLINE_CODE_BACKTICK_REGEXP)?.length ?? 0;
|
|
61
|
+
if (backticksBefore % 2 === 1) {
|
|
62
|
+
return content; // inside inline code
|
|
63
|
+
}
|
|
64
|
+
const tail = content.slice(i);
|
|
65
|
+
// Complete link/image: [text](url) — possibly followed by more text
|
|
66
|
+
if (COMPLETE_INLINE_MARKUP_REGEXP.test(tail)) {
|
|
67
|
+
return content; // fully formed, nothing to strip
|
|
68
|
+
}
|
|
69
|
+
// The `[` starts an incomplete link/image — truncate.
|
|
70
|
+
// Also strip a preceding `!` for image syntax.
|
|
71
|
+
const cutAt = (i > 0 && content[i - 1] === '!') ? i - 1 : i;
|
|
72
|
+
return content.slice(0, cutAt);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Strip a trailing unclosed inline code span (single backtick).
|
|
76
|
+
* If the content ends with an odd number of unmatched backticks,
|
|
77
|
+
* the last one opens a code span that never closes — strip from there.
|
|
78
|
+
*/
|
|
79
|
+
function stripUnclosedInlineCode(content) {
|
|
80
|
+
// Find the last backtick that is NOT part of a fenced code block delimiter.
|
|
81
|
+
// We only care about single backticks (inline code), not ``` fences.
|
|
82
|
+
// Work on individual lines to avoid false positives from fenced blocks.
|
|
83
|
+
// First, remove fenced code block regions so they don't interfere.
|
|
84
|
+
// We only need to check the *last line fragment* outside fences.
|
|
85
|
+
let inFence = false;
|
|
86
|
+
let fenceChar = null;
|
|
87
|
+
let lastFenceEnd = 0;
|
|
88
|
+
let match = null;
|
|
89
|
+
FENCE_DELIMITER_REGEXP.lastIndex = 0;
|
|
90
|
+
// eslint-disable-next-line no-cond-assign
|
|
91
|
+
while ((match = FENCE_DELIMITER_REGEXP.exec(content)) !== null) {
|
|
92
|
+
if (!inFence) {
|
|
93
|
+
inFence = true;
|
|
94
|
+
fenceChar = match[1][0];
|
|
95
|
+
}
|
|
96
|
+
else if (match[1][0] === fenceChar) {
|
|
97
|
+
inFence = false;
|
|
98
|
+
fenceChar = null;
|
|
99
|
+
lastFenceEnd = match.index + match[0].length;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// If we're inside an open fence, the backticks are code content — skip.
|
|
103
|
+
if (inFence) {
|
|
104
|
+
return content;
|
|
105
|
+
}
|
|
106
|
+
// Only inspect the portion after the last closed fence.
|
|
107
|
+
const region = content.slice(lastFenceEnd);
|
|
108
|
+
// Count single backticks (not part of ```` sequences).
|
|
109
|
+
// A simple approach: split by backtick and check odd/even.
|
|
110
|
+
let count = 0;
|
|
111
|
+
let lastBacktickPos = -1;
|
|
112
|
+
let index = 0;
|
|
113
|
+
for (const character of region) {
|
|
114
|
+
if (character === '`') {
|
|
115
|
+
count++;
|
|
116
|
+
lastBacktickPos = index;
|
|
117
|
+
}
|
|
118
|
+
index++;
|
|
119
|
+
}
|
|
120
|
+
if (count % 2 === 1 && lastBacktickPos !== -1) {
|
|
121
|
+
// Odd count — the last backtick is an unclosed opener.
|
|
122
|
+
// Find its absolute position and truncate.
|
|
123
|
+
const absPos = lastFenceEnd + lastBacktickPos;
|
|
124
|
+
return content.slice(0, absPos);
|
|
125
|
+
}
|
|
126
|
+
return content;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Strip trailing table rows that lack a separator line.
|
|
130
|
+
* A markdown table requires at least a header row + separator row
|
|
131
|
+
* (e.g. `| --- |`) to be recognised by the parser.
|
|
132
|
+
*/
|
|
133
|
+
function stripIncompleteTable(content) {
|
|
134
|
+
const lines = content.split('\n');
|
|
135
|
+
// Walk backwards to find a contiguous block of table-like lines at the end.
|
|
136
|
+
let tableStart = -1;
|
|
137
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
138
|
+
const trimmed = lines[i].trim();
|
|
139
|
+
if (trimmed === '') {
|
|
140
|
+
if (tableStart !== -1) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (TABLE_ROW_REGEXP.test(trimmed)) {
|
|
146
|
+
tableStart = i;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (tableStart === -1) {
|
|
153
|
+
return content;
|
|
154
|
+
}
|
|
155
|
+
// Check whether the trailing table block contains a separator line
|
|
156
|
+
const hasSeparator = lines
|
|
157
|
+
.slice(tableStart)
|
|
158
|
+
.some(line => TABLE_SEPARATOR_REGEXP.test(line.trim()));
|
|
159
|
+
if (hasSeparator) {
|
|
160
|
+
return content;
|
|
161
|
+
}
|
|
162
|
+
// Compute the character offset of the first table row and truncate there.
|
|
163
|
+
let offset = 0;
|
|
164
|
+
for (let i = 0; i < tableStart; i++) {
|
|
165
|
+
offset += lines[i].length + 1; // +1 for the '\n'
|
|
166
|
+
}
|
|
167
|
+
return content.slice(0, offset);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Smart content splitting to avoid displaying incomplete sentences
|
|
171
|
+
* Supports both English and Chinese punctuation
|
|
172
|
+
*/
|
|
173
|
+
export function splitContent(msg) {
|
|
174
|
+
const sentences = msg.split(SENTENCE_SPLIT_REGEXP);
|
|
175
|
+
if (sentences.length > 0
|
|
176
|
+
&& !SENTENCE_END_REGEXP.test(sentences.at(-1))) {
|
|
177
|
+
sentences.pop();
|
|
178
|
+
}
|
|
179
|
+
if (sentences.length > 0 && ORDERED_LIST_PREFIX_REGEXP.test(sentences.at(-1))) {
|
|
180
|
+
sentences.pop();
|
|
181
|
+
}
|
|
182
|
+
let result = sentences.join('');
|
|
183
|
+
result = stripIncompleteTable(result);
|
|
184
|
+
result = stripUnclosedBlock(result);
|
|
185
|
+
result = stripIncompleteInlineMarkup(result);
|
|
186
|
+
result = stripUnclosedInlineCode(result);
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Add a fade-in class to string-backed nodes in a generic tree structure.
|
|
191
|
+
*/
|
|
192
|
+
export function addFadeInClassToTreeNodes(childrenRaw, loading, fadeInClass = 'fade-in') {
|
|
193
|
+
// eslint-disable-next-line unicorn/no-magic-array-flat-depth
|
|
194
|
+
const children = childrenRaw.flat(20);
|
|
195
|
+
for (const child of children) {
|
|
196
|
+
// Leaf node with text content (normal markdown text)
|
|
197
|
+
const isTextLeaf = typeof child.children === 'string';
|
|
198
|
+
// Leaf node rendered via innerHTML (e.g. KaTeX math, raw HTML blocks)
|
|
199
|
+
const isHtmlLeaf = !isTextLeaf
|
|
200
|
+
&& child.props != null
|
|
201
|
+
&& 'innerHTML' in child.props;
|
|
202
|
+
if (isTextLeaf || isHtmlLeaf) {
|
|
203
|
+
child.props = {
|
|
204
|
+
...child.props,
|
|
205
|
+
};
|
|
206
|
+
if (loading) {
|
|
207
|
+
const existingClass = typeof child.props.class === 'string'
|
|
208
|
+
? child.props.class
|
|
209
|
+
: '';
|
|
210
|
+
child.props.class = `${existingClass} ${fadeInClass}`.trim();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (child.children
|
|
214
|
+
&& Array.isArray(child.children)
|
|
215
|
+
&& child.children.length > 0) {
|
|
216
|
+
addFadeInClassToTreeNodes(child.children, loading, fadeInClass);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return children;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* CSS styles for streaming text animation
|
|
223
|
+
* Can be used in your global styles or component styles
|
|
224
|
+
*/
|
|
225
|
+
export const streamingTextStyles = `
|
|
226
|
+
.fade-in {
|
|
227
|
+
opacity: 0;
|
|
228
|
+
animation: fadeIn 1s forwards;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@keyframes fadeIn {
|
|
232
|
+
from {
|
|
233
|
+
opacity: 0;
|
|
234
|
+
}
|
|
235
|
+
to {
|
|
236
|
+
opacity: 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.streaming-text-wrapper {
|
|
241
|
+
position: relative;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.streaming-text-wrapper.streaming {
|
|
245
|
+
/* Additional styling for streaming state */
|
|
246
|
+
}
|
|
247
|
+
`;
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@preferred-markdown-stream/core",
|
|
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
|
+
"./streaming-text": {
|
|
13
|
+
"types": "./dist/streamingText.d.ts",
|
|
14
|
+
"import": "./dist/streamingText.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"files": [
|
|
22
|
+
"README.md",
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p ./tsconfig.build.json",
|
|
30
|
+
"test": "vitest run --config ./vitest.config.ts",
|
|
31
|
+
"typecheck": "tsc -p ./tsconfig.json"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^6.0.2",
|
|
35
|
+
"vitest": "^4.1.1"
|
|
36
|
+
}
|
|
37
|
+
}
|