@antdv-next/x-markdown 0.2.0 → 0.3.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/dist/XMarkdown/components/AnimationText.vue.d.ts +6 -0
- package/dist/XMarkdown/components/AnimationText.vue_vue_type_script_setup_true_lang.js +58 -2
- package/dist/XMarkdown/components/Section.js +5 -0
- package/dist/XMarkdown/components/Section.vue.d.ts +76 -0
- package/dist/XMarkdown/components/Section.vue_vue_type_script_lang.js +54 -0
- package/dist/XMarkdown/composables/index.d.ts +2 -1
- package/dist/XMarkdown/composables/index.js +3 -2
- package/dist/XMarkdown/composables/useStreaming.d.ts +37 -1
- package/dist/XMarkdown/composables/useStreaming.js +449 -34
- package/dist/XMarkdown/composables/useTypewriter.d.ts +11 -0
- package/dist/XMarkdown/composables/useTypewriter.js +378 -0
- package/dist/XMarkdown/core/Parser.d.ts +15 -0
- package/dist/XMarkdown/core/Parser.js +22 -3
- package/dist/XMarkdown/core/VueRenderer.js +16 -9
- package/dist/XMarkdown/core/detectUnclosedComponentTags.d.ts +12 -0
- package/dist/XMarkdown/core/detectUnclosedComponentTags.js +240 -6
- package/dist/XMarkdown/core/index.js +1 -1
- package/dist/XMarkdown/index.d.ts +6 -2
- package/dist/XMarkdown/index.js +5 -3
- package/dist/XMarkdown/index.vue.d.ts +1 -1
- package/dist/XMarkdown/index.vue_vue_type_script_setup_true_lang.js +64 -30
- package/dist/XMarkdown/interface.d.ts +241 -14
- package/dist/XMarkdown/utils/streaming.d.ts +6 -0
- package/dist/XMarkdown/utils/streaming.js +32 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -3
- package/package.json +1 -1
- package/plugins/Latex/index.js +8 -2
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { detectUnclosedComponentTags, hasUnclosedRawTags } from "../core/detectUnclosedComponentTags.js";
|
|
1
2
|
import { StreamCacheTokenType } from "../interface.js";
|
|
2
|
-
import {
|
|
3
|
+
import { resolveStreaming } from "../utils/streaming.js";
|
|
4
|
+
import { computed, ref, watch } from "vue";
|
|
5
|
+
import { Marked, walkTokens } from "marked";
|
|
3
6
|
//#region src/XMarkdown/composables/useStreaming.ts
|
|
7
|
+
var HEADING_LINE = /^#{1,6}(?:[ \t]|$)/;
|
|
4
8
|
var STREAM_INCOMPLETE_REGEX = {
|
|
5
9
|
image: [/^!\[[^\]\r\n]{0,1000}$/, /^!\[[^\r\n]{0,1000}\]\(*[^)\r\n]{0,1000}$/],
|
|
6
10
|
link: [/^\[[^\]\r\n]{0,1000}$/, /^\[[^\r\n]{0,1000}\]\(*[^)\r\n]{0,1000}$/],
|
|
@@ -9,17 +13,25 @@ var STREAM_INCOMPLETE_REGEX = {
|
|
|
9
13
|
list: [/^[-+*]\s{0,3}$/, /^[-+*]\s{1,3}(\*{1,3}|_{1,3})(?!\s)(?!.*\1$)[^\r\n]{0,1000}$/],
|
|
10
14
|
"inline-code": [/^`[^`\r\n]{0,300}$/]
|
|
11
15
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const lines = markdown.split("\n");
|
|
15
|
-
if (lines.length <= 1) return true;
|
|
16
|
-
const [header, separator] = lines;
|
|
16
|
+
/** Header + delimiter row shape check. Cost is bounded by those two rows, not by pending. */
|
|
17
|
+
var isTableShapeValid = (header, separator) => {
|
|
17
18
|
const trimmedHeader = header.trim();
|
|
18
19
|
if (!/^\|.*\|$/.test(trimmedHeader)) return false;
|
|
19
20
|
const columns = separator.trim().split("|").map((col) => col.trim()).filter(Boolean);
|
|
20
21
|
const separatorRegex = /^:?-+:?$/;
|
|
21
22
|
return columns.every((col, index) => index === columns.length - 1 ? col === ":" || separatorRegex.test(col) : separatorRegex.test(col));
|
|
22
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Same verdict as scanning the whole pending buffer, but read off the
|
|
26
|
+
* incrementally maintained state instead — O(1) once the delimiter row is
|
|
27
|
+
* terminated, which is where a long table spends all of its characters.
|
|
28
|
+
*/
|
|
29
|
+
var isTableInComplete = (table) => {
|
|
30
|
+
if (table.hasBlankLine) return false;
|
|
31
|
+
if (table.newlines === 0) return true;
|
|
32
|
+
if (table.shape !== null) return table.shape;
|
|
33
|
+
return isTableShapeValid(table.firstLine, table.secondLine);
|
|
34
|
+
};
|
|
23
35
|
var tokenRecognizerMap = {
|
|
24
36
|
[StreamCacheTokenType.Link]: {
|
|
25
37
|
tokenType: StreamCacheTokenType.Link,
|
|
@@ -54,7 +66,7 @@ var tokenRecognizerMap = {
|
|
|
54
66
|
[StreamCacheTokenType.Table]: {
|
|
55
67
|
tokenType: StreamCacheTokenType.Table,
|
|
56
68
|
isStartOfToken: (markdown) => markdown.startsWith("|"),
|
|
57
|
-
isStreamingValid: isTableInComplete
|
|
69
|
+
isStreamingValid: (_markdown, cache) => isTableInComplete(cache.table)
|
|
58
70
|
},
|
|
59
71
|
[StreamCacheTokenType.InlineCode]: {
|
|
60
72
|
tokenType: StreamCacheTokenType.InlineCode,
|
|
@@ -70,11 +82,12 @@ var recognize = (cache, tokenType) => {
|
|
|
70
82
|
cache.token = tokenType;
|
|
71
83
|
return;
|
|
72
84
|
}
|
|
73
|
-
if (token === tokenType && !recognizer.isStreamingValid(pending)) {
|
|
85
|
+
if (token === tokenType && !recognizer.isStreamingValid(pending, cache)) {
|
|
74
86
|
const prefix = recognizer.getCommitPrefix?.(pending);
|
|
75
87
|
if (prefix) {
|
|
76
88
|
cache.completeMarkdown += prefix;
|
|
77
89
|
cache.pending = pending.slice(prefix.length);
|
|
90
|
+
rebuildTableState(cache.table, cache.pending);
|
|
78
91
|
cache.token = StreamCacheTokenType.Text;
|
|
79
92
|
return;
|
|
80
93
|
}
|
|
@@ -86,23 +99,70 @@ var getInitialFenceState = () => ({
|
|
|
86
99
|
inFenced: false,
|
|
87
100
|
fenceChar: "",
|
|
88
101
|
fenceLen: 0,
|
|
102
|
+
lineIndent: 0,
|
|
89
103
|
lineFenceChar: "",
|
|
90
104
|
lineFenceLen: 0,
|
|
91
105
|
lineFenceRunEnded: false,
|
|
92
106
|
lineTailBlank: true
|
|
93
107
|
});
|
|
108
|
+
var getInitialTableState = () => ({
|
|
109
|
+
newlines: 0,
|
|
110
|
+
lastWasNewline: false,
|
|
111
|
+
hasBlankLine: false,
|
|
112
|
+
firstLine: "",
|
|
113
|
+
secondLine: "",
|
|
114
|
+
shape: null
|
|
115
|
+
});
|
|
116
|
+
var resetTableState = (table) => {
|
|
117
|
+
table.newlines = 0;
|
|
118
|
+
table.lastWasNewline = false;
|
|
119
|
+
table.hasBlankLine = false;
|
|
120
|
+
table.firstLine = "";
|
|
121
|
+
table.secondLine = "";
|
|
122
|
+
table.shape = null;
|
|
123
|
+
};
|
|
124
|
+
/** Advance the table state by one appended character. O(1). */
|
|
125
|
+
var feedTableState = (table, char) => {
|
|
126
|
+
if (char === "\n") {
|
|
127
|
+
if (table.lastWasNewline) table.hasBlankLine = true;
|
|
128
|
+
table.lastWasNewline = true;
|
|
129
|
+
table.newlines += 1;
|
|
130
|
+
if (table.newlines === 2) table.shape = isTableShapeValid(table.firstLine, table.secondLine);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
table.lastWasNewline = false;
|
|
134
|
+
if (table.newlines === 0) table.firstLine += char;
|
|
135
|
+
else if (table.newlines === 1) table.secondLine += char;
|
|
136
|
+
};
|
|
137
|
+
/** Rebuild from scratch. Only used when pending is replaced instead of appended to. */
|
|
138
|
+
var rebuildTableState = (table, pending) => {
|
|
139
|
+
resetTableState(table);
|
|
140
|
+
for (const char of pending) feedTableState(table, char);
|
|
141
|
+
};
|
|
142
|
+
var getInitialSectionState = () => ({
|
|
143
|
+
offsets: [],
|
|
144
|
+
lineStart: 0,
|
|
145
|
+
noSplit: false,
|
|
146
|
+
sawDefinitionTrigger: false,
|
|
147
|
+
definitionCheckedLength: -1,
|
|
148
|
+
rawBlock: null,
|
|
149
|
+
openBlock: null
|
|
150
|
+
});
|
|
94
151
|
var getInitialCache = () => ({
|
|
95
152
|
pending: "",
|
|
96
153
|
token: StreamCacheTokenType.Text,
|
|
97
154
|
processedLength: 0,
|
|
98
155
|
completeMarkdown: "",
|
|
99
|
-
fence: getInitialFenceState()
|
|
156
|
+
fence: getInitialFenceState(),
|
|
157
|
+
table: getInitialTableState(),
|
|
158
|
+
sections: getInitialSectionState()
|
|
100
159
|
});
|
|
101
160
|
var commitCache = (cache) => {
|
|
102
161
|
if (cache.pending) {
|
|
103
162
|
cache.completeMarkdown += cache.pending;
|
|
104
163
|
cache.pending = "";
|
|
105
164
|
}
|
|
165
|
+
resetTableState(cache.table);
|
|
106
166
|
cache.token = StreamCacheTokenType.Text;
|
|
107
167
|
};
|
|
108
168
|
/**
|
|
@@ -124,13 +184,15 @@ var feedFenceState = (fence, char) => {
|
|
|
124
184
|
fence.fenceLen = 0;
|
|
125
185
|
}
|
|
126
186
|
}
|
|
187
|
+
fence.lineIndent = 0;
|
|
127
188
|
fence.lineFenceChar = "";
|
|
128
189
|
fence.lineFenceLen = 0;
|
|
129
190
|
fence.lineFenceRunEnded = false;
|
|
130
191
|
fence.lineTailBlank = true;
|
|
131
192
|
return;
|
|
132
193
|
}
|
|
133
|
-
if (!fence.lineFenceRunEnded) if (fence.lineFenceLen === 0 &&
|
|
194
|
+
if (!fence.lineFenceRunEnded) if (fence.lineFenceLen === 0 && char === " " && fence.lineIndent < 3) fence.lineIndent += 1;
|
|
195
|
+
else if (fence.lineFenceLen === 0 && (char === "`" || char === "~")) {
|
|
134
196
|
fence.lineFenceChar = char;
|
|
135
197
|
fence.lineFenceLen = 1;
|
|
136
198
|
} else if (fence.lineFenceLen > 0 && char === fence.lineFenceChar) fence.lineFenceLen += 1;
|
|
@@ -141,6 +203,304 @@ var feedFenceState = (fence, char) => {
|
|
|
141
203
|
else fence.lineTailBlank = fence.lineTailBlank && /\s/.test(char);
|
|
142
204
|
};
|
|
143
205
|
var isInCodeBlock = (fence) => fence.inFenced || fence.lineFenceLen >= 3;
|
|
206
|
+
/** Sections shorter than this are merged into the next one. */
|
|
207
|
+
var DEFAULT_MIN_SECTION_CHARS = 200;
|
|
208
|
+
var DEFINITION_TRIGGER = /\]:/;
|
|
209
|
+
/** Raw HTML blocks whose end condition may span blank lines (CommonMark 1–2/3–5). */
|
|
210
|
+
var RAW_HTML_BLOCK_OPEN = /^ {0,3}<(pre|script|style|textarea)(?=[\s>]|$)/i;
|
|
211
|
+
var HTML_COMMENT_OPEN = /^ {0,3}<!--/;
|
|
212
|
+
var HTML_PI_OPEN = /^ {0,3}<\?/;
|
|
213
|
+
var HTML_DECLARATION_OPEN = /^ {0,3}<![a-zA-Z]/;
|
|
214
|
+
var HTML_CDATA_OPEN = /^ {0,3}<!\[CDATA\[/;
|
|
215
|
+
var MATH_DOLLAR_LINE = /^(\${1,2})$/;
|
|
216
|
+
var MATH_BRACKET_OPEN = /^\\\[/;
|
|
217
|
+
/**
|
|
218
|
+
* The close condition of a raw block opened by `line`, or null. A block that
|
|
219
|
+
* already closed on its own line (`<pre>x</pre>`, `<!-- x -->`) is not open.
|
|
220
|
+
*/
|
|
221
|
+
var detectRawBlockOpen = (line) => {
|
|
222
|
+
const html = line.match(RAW_HTML_BLOCK_OPEN);
|
|
223
|
+
if (html) {
|
|
224
|
+
const close = `</${html[1].toLowerCase()}`;
|
|
225
|
+
return line.toLowerCase().includes(close) ? null : {
|
|
226
|
+
close,
|
|
227
|
+
exact: false
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (HTML_COMMENT_OPEN.test(line)) return line.includes("-->") ? null : {
|
|
231
|
+
close: "-->",
|
|
232
|
+
exact: false
|
|
233
|
+
};
|
|
234
|
+
if (HTML_PI_OPEN.test(line)) return line.includes("?>") ? null : {
|
|
235
|
+
close: "?>",
|
|
236
|
+
exact: false
|
|
237
|
+
};
|
|
238
|
+
if (HTML_CDATA_OPEN.test(line)) return line.includes("]]>") ? null : {
|
|
239
|
+
close: "]]>",
|
|
240
|
+
exact: false
|
|
241
|
+
};
|
|
242
|
+
if (HTML_DECLARATION_OPEN.test(line)) return line.includes(">") ? null : {
|
|
243
|
+
close: ">",
|
|
244
|
+
exact: false
|
|
245
|
+
};
|
|
246
|
+
const dollar = line.match(MATH_DOLLAR_LINE);
|
|
247
|
+
if (dollar) return {
|
|
248
|
+
close: dollar[1],
|
|
249
|
+
exact: true,
|
|
250
|
+
bodyChars: 0
|
|
251
|
+
};
|
|
252
|
+
if (MATH_BRACKET_OPEN.test(line)) return line.includes("\\]") ? null : {
|
|
253
|
+
close: "\\]",
|
|
254
|
+
exact: false
|
|
255
|
+
};
|
|
256
|
+
return null;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Whether `line` closes `rawBlock`. `$$` math (exact) closes only at a
|
|
260
|
+
* column-0 run of the opening delimiter, matching the bundled Latex plugin.
|
|
261
|
+
*/
|
|
262
|
+
var closesRawBlock = (line, rawBlock) => rawBlock.exact ? line.startsWith(rawBlock.close) && !line.startsWith(`${rawBlock.close}$`) && /^[ \t]*$/.test(line.slice(rawBlock.close.length)) : line.toLowerCase().includes(rawBlock.close);
|
|
263
|
+
/** How far a memoised "still inside an open block" verdict may be trusted. */
|
|
264
|
+
var OPEN_BLOCK_MEMO_CHARS = 4096;
|
|
265
|
+
/** The line appended to probe whether a heading starts a new top-level block. */
|
|
266
|
+
var PROBE_HEADING = "# x\n";
|
|
267
|
+
/**
|
|
268
|
+
* Ask marked whether `prefix` plus a column-0 ATX heading parses into strictly
|
|
269
|
+
* more top-level blocks than `prefix` alone — i.e. whether the heading starts a
|
|
270
|
+
* new top-level block instead of being swallowed by one the prefix left open —
|
|
271
|
+
* and, when it does not, whether the swallowing block is an *unclosed fenced
|
|
272
|
+
* code block* (marked's last top-level token is a fenced `code` token).
|
|
273
|
+
*
|
|
274
|
+
* Only the second fact needs interpreting, and only to decide how long a
|
|
275
|
+
* refusal may be memoised: a fence's body may contain blank lines, so a refusal
|
|
276
|
+
* a fence caused survives them, while an HTML block and a table end at a blank
|
|
277
|
+
* line and the refusal they caused must not. Both facts are read off marked's
|
|
278
|
+
* own answers, never re-derived.
|
|
279
|
+
*/
|
|
280
|
+
var probeBoundary = (lex, prefix) => {
|
|
281
|
+
const blocks = lex(prefix);
|
|
282
|
+
const starts = lex(prefix + PROBE_HEADING).length > blocks.length;
|
|
283
|
+
const last = blocks[blocks.length - 1];
|
|
284
|
+
return {
|
|
285
|
+
starts,
|
|
286
|
+
fenceCause: last?.type === "code" && last.codeBlockStyle !== "indented"
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
/**
|
|
290
|
+
* Whether `markdown` contains at least one link reference / footnote
|
|
291
|
+
* definition, per the renderer's own marked. Its `links` map collects
|
|
292
|
+
* definitions from any depth (`> [a]: /x`, `- [a]: /x`) and stays empty for a
|
|
293
|
+
* definition-shaped line inside fence, comment, `<pre>` or math body — which
|
|
294
|
+
* is exactly the distinction the tracker must make.
|
|
295
|
+
*/
|
|
296
|
+
var hasDefinition = (lex, markdown) => Object.keys(lex(markdown).links ?? {}).length > 0;
|
|
297
|
+
/**
|
|
298
|
+
* Arm the document-global "must refuse" rule's cheap gate. Called for every
|
|
299
|
+
* completed line whatever block state the line is in, so a raw block or a fence
|
|
300
|
+
* body can hide neither a real definition nor a definition-shaped line that is
|
|
301
|
+
* really body text — the gate only decides whether to ask marked, never the
|
|
302
|
+
* answer.
|
|
303
|
+
*/
|
|
304
|
+
var noteDefinitionTrigger = (state, line) => {
|
|
305
|
+
if (DEFINITION_TRIGGER.test(line)) state.sawDefinitionTrigger = true;
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* The only document-global "must refuse" rule, settled once per pass: a link
|
|
309
|
+
* reference / footnote definition can be referenced from any section, so once
|
|
310
|
+
* the document contains one no boundary may be recorded and the offsets already
|
|
311
|
+
* recorded are discarded.
|
|
312
|
+
*
|
|
313
|
+
* The question — "does the text so far contain a definition" — is asked of the
|
|
314
|
+
* renderer's marked (`hasDefinition`) on the whole text. It deliberately does
|
|
315
|
+
* not try to predict *where* a definition ends: marked accepts a title spanning
|
|
316
|
+
* any number of lines (blank lines included), so a definition can complete
|
|
317
|
+
* arbitrarily far from the `]:` line that started it, and any line count after
|
|
318
|
+
* which the probe stops is a wrong boundary waiting to happen. Asking late is
|
|
319
|
+
* safe: the offsets recorded before the answer arrived are discarded here and
|
|
320
|
+
* `noSplit` is one-way, so a late yes still collapses the sections.
|
|
321
|
+
*
|
|
322
|
+
* It is also asked only when the answer can change the output: with no recorded
|
|
323
|
+
* boundary `sections` is null whatever the answer, and by the end of the pass
|
|
324
|
+
* the offsets include every boundary recorded during it, so a boundary that is
|
|
325
|
+
* about to be used is always covered by a probe on the text that contains it.
|
|
326
|
+
* (Deferring cannot miss a definition: the probe reads the whole current text,
|
|
327
|
+
* not a window.) This is what keeps a long `]:`-bearing but definition-free
|
|
328
|
+
* stream — a TypeScript block full of `[key: string]: T`, say — from paying one
|
|
329
|
+
* whole-text lex per pass; and once a definition is found, `noSplit` closes the
|
|
330
|
+
* gate for good.
|
|
331
|
+
*/
|
|
332
|
+
var settleDefinition = (state, text, lex) => {
|
|
333
|
+
if (state.noSplit) return;
|
|
334
|
+
if (!state.sawDefinitionTrigger || state.offsets.length === 0) return;
|
|
335
|
+
if (state.definitionCheckedLength === text.length) return;
|
|
336
|
+
state.definitionCheckedLength = text.length;
|
|
337
|
+
if (hasDefinition(lex, text)) {
|
|
338
|
+
state.noSplit = true;
|
|
339
|
+
state.offsets = [];
|
|
340
|
+
state.openBlock = null;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Total length of the content of every code token (fenced or indented), at any
|
|
345
|
+
* depth. Used only to compare the document before and after appending one line:
|
|
346
|
+
* a code token that grows did not exist for that content before, so the line
|
|
347
|
+
* became code body rather than a new block. Marked's own walker is used rather
|
|
348
|
+
* than a hand-rolled model of the token shapes: it already knows every place a
|
|
349
|
+
* child token can live (list items, table cells, extension `childTokens`).
|
|
350
|
+
*/
|
|
351
|
+
var codeContentLength = (tokens) => {
|
|
352
|
+
let total = 0;
|
|
353
|
+
walkTokens(tokens, (token) => {
|
|
354
|
+
if (token.type === "code") total += (token.text ?? "").length;
|
|
355
|
+
});
|
|
356
|
+
return total;
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Whether `line` (ending just past `lineEnd`) is code body per the renderer's
|
|
360
|
+
* own marked. A raw-opener-shaped line that is inside a code token must not
|
|
361
|
+
* open a raw block; see the call site. Appending the line can only grow code
|
|
362
|
+
* content by becoming the body of an already-open fence - none of the raw
|
|
363
|
+
* openers (column-0 `$`/`$$`/`\[`, up to three leading spaces before `<`) is
|
|
364
|
+
* itself a fence line, so a growth here is never a new fence.
|
|
365
|
+
*
|
|
366
|
+
* The two lexes are whole-prefix and run for every raw-opener-shaped line, so a
|
|
367
|
+
* document whose code blocks are full of column-0 `$`/`$$`/`<pre` lines pays
|
|
368
|
+
* O(prefix) per such line. It is kept because the cheap fence scanner cannot
|
|
369
|
+
* see a fence indented into a list item's content, and guessing wrong there
|
|
370
|
+
* splits inside a real math/raw block. The fix for the cost is to derive "is
|
|
371
|
+
* this line code body" from one token walk of the current prefix (marked's
|
|
372
|
+
* tokens carry `raw`), not to relax the question.
|
|
373
|
+
*/
|
|
374
|
+
var lineIsCodeContent = (lex, text, lineStart, lineEnd) => codeContentLength(lex(text.slice(0, lineEnd))) > codeContentLength(lex(text.slice(0, lineStart)));
|
|
375
|
+
/** Fallback lexer for the sections-blind `useStreaming` wrapper. */
|
|
376
|
+
var fallbackLexer = (() => {
|
|
377
|
+
const marked = new Marked({ gfm: true });
|
|
378
|
+
return (markdown) => marked.lexer(markdown);
|
|
379
|
+
})();
|
|
380
|
+
/**
|
|
381
|
+
* Called once per completed line, right after the fence state has consumed it.
|
|
382
|
+
* Decides whether the line that just ended starts a new section.
|
|
383
|
+
*
|
|
384
|
+
* The per-line work here is O(line) — a `]:` test, a heading test and cheap
|
|
385
|
+
* string guards. The marked lexes it can trigger are not, and one of them is
|
|
386
|
+
* paid with `incremental` off too: a raw-opener-shaped line costs two
|
|
387
|
+
* whole-prefix lexes (`lineIsCodeContent`) before the `record` gate, because
|
|
388
|
+
* its answer also shapes the raw-block state a later switch-on sees. The other
|
|
389
|
+
* two are gated on `record`: a candidate heading that passed every guard costs
|
|
390
|
+
* O(section) (`probeBoundary`), and the document-global definition question —
|
|
391
|
+
* the one probe whose cost cannot be bounded by a line count — is settled once
|
|
392
|
+
* per boundary-bearing pass rather than here (`settleDefinition`).
|
|
393
|
+
*/
|
|
394
|
+
var trackSectionBoundary = (cache, text, newlineIndex, componentNames, minSectionChars, record, lex) => {
|
|
395
|
+
const state = cache.sections;
|
|
396
|
+
const lineStart = state.lineStart;
|
|
397
|
+
const raw = text.slice(lineStart, newlineIndex);
|
|
398
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
399
|
+
const blank = line.trim() === "";
|
|
400
|
+
state.lineStart = newlineIndex + 1;
|
|
401
|
+
if (!state.openBlock?.fenceCause && (blank || line.includes("</"))) state.openBlock = null;
|
|
402
|
+
noteDefinitionTrigger(state, line);
|
|
403
|
+
if (state.noSplit) return;
|
|
404
|
+
if (state.rawBlock) {
|
|
405
|
+
if (state.rawBlock.exact && (state.rawBlock.bodyChars ?? 0) < 2) {
|
|
406
|
+
state.rawBlock.bodyChars = (state.rawBlock.bodyChars ?? 0) + line.length + 1;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (closesRawBlock(line, state.rawBlock)) state.rawBlock = null;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const rawBlock = detectRawBlockOpen(line);
|
|
413
|
+
if (rawBlock) {
|
|
414
|
+
if (lineIsCodeContent(lex, text, lineStart, newlineIndex + 1)) return;
|
|
415
|
+
state.rawBlock = rawBlock;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (!HEADING_LINE.test(line)) return;
|
|
419
|
+
if (!record) return;
|
|
420
|
+
const sectionStart = state.offsets.length ? state.offsets[state.offsets.length - 1] : 0;
|
|
421
|
+
if (lineStart <= sectionStart || lineStart - sectionStart < minSectionChars) return;
|
|
422
|
+
const sectionText = text.slice(sectionStart, lineStart);
|
|
423
|
+
if (componentNames.length > 0 && detectUnclosedComponentTags(sectionText, componentNames).size > 0) return;
|
|
424
|
+
if (sectionText.includes("<") && hasUnclosedRawTags(sectionText)) return;
|
|
425
|
+
const fenceOpen = cache.fence.inFenced;
|
|
426
|
+
const memo = state.openBlock;
|
|
427
|
+
if (memo !== null && memo.fenceOpen === fenceOpen && lineStart - memo.end < OPEN_BLOCK_MEMO_CHARS) return;
|
|
428
|
+
const { starts, fenceCause } = probeBoundary(lex, sectionText);
|
|
429
|
+
if (!starts) {
|
|
430
|
+
state.openBlock = {
|
|
431
|
+
end: lineStart,
|
|
432
|
+
fenceOpen,
|
|
433
|
+
fenceCause
|
|
434
|
+
};
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
state.openBlock = null;
|
|
438
|
+
state.offsets.push(lineStart);
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* Slice `output` at the recorded boundaries. A boundary past `limit` falls
|
|
442
|
+
* inside the pending token — a heading right after a table row is held in the
|
|
443
|
+
* table's pending text until the table's terminating blank line — and is left
|
|
444
|
+
* for a later chunk.
|
|
445
|
+
*/
|
|
446
|
+
var buildSections = (cache, output, limit) => {
|
|
447
|
+
const { offsets } = cache.sections;
|
|
448
|
+
if (cache.sections.noSplit || offsets.length === 0) return null;
|
|
449
|
+
const sections = [];
|
|
450
|
+
let start = 0;
|
|
451
|
+
for (const offset of offsets) {
|
|
452
|
+
if (offset > limit) break;
|
|
453
|
+
sections.push(output.slice(start, offset));
|
|
454
|
+
start = offset;
|
|
455
|
+
}
|
|
456
|
+
if (sections.length === 0) return null;
|
|
457
|
+
sections.push(output.slice(start));
|
|
458
|
+
return sections;
|
|
459
|
+
};
|
|
460
|
+
var EMPHASIS_MARKER = /^(\*{1,3}|_{1,3})/;
|
|
461
|
+
var INLINE_CODE_MARKER = /^`+/;
|
|
462
|
+
var LIST_PREFIX = /^([-+*]\s{0,3})([\s\S]*)$/;
|
|
463
|
+
var TRAILING_WHITESPACE = /\s+$/;
|
|
464
|
+
/**
|
|
465
|
+
* Close an emphasis run that is still open. The closing delimiter has to be
|
|
466
|
+
* right-flanking (not preceded by whitespace), so trailing whitespace is moved
|
|
467
|
+
* after it: `**bold and ` → `**bold and** `.
|
|
468
|
+
*/
|
|
469
|
+
var completeEmphasis = (pending) => {
|
|
470
|
+
const marker = pending.match(EMPHASIS_MARKER)?.[0] ?? "";
|
|
471
|
+
const body = pending.slice(marker.length);
|
|
472
|
+
const trimmed = body.replace(TRAILING_WHITESPACE, "");
|
|
473
|
+
if (!trimmed) return void 0;
|
|
474
|
+
return `${marker}${trimmed}${marker}${body.slice(trimmed.length)}`;
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* Render the pending token as finished text instead of a placeholder, for the
|
|
478
|
+
* `incompleteMarkdown: 'complete'` mode. Works on the pending token only, so
|
|
479
|
+
* it costs O(pending) and never touches the committed text or the cache.
|
|
480
|
+
* Returns undefined to keep the token hidden, exactly like placeholder mode.
|
|
481
|
+
*/
|
|
482
|
+
var completePending = (token, pending) => {
|
|
483
|
+
switch (token) {
|
|
484
|
+
case StreamCacheTokenType.Emphasis: return completeEmphasis(pending);
|
|
485
|
+
case StreamCacheTokenType.InlineCode: {
|
|
486
|
+
const marker = pending.match(INLINE_CODE_MARKER)?.[0] ?? "";
|
|
487
|
+
return pending.length > marker.length ? `${pending}${marker}` : void 0;
|
|
488
|
+
}
|
|
489
|
+
case StreamCacheTokenType.Link: {
|
|
490
|
+
const close = pending.indexOf("]");
|
|
491
|
+
return (close === -1 ? pending.slice(1) : pending.slice(1, close)) || void 0;
|
|
492
|
+
}
|
|
493
|
+
case StreamCacheTokenType.List: {
|
|
494
|
+
const match = pending.match(LIST_PREFIX);
|
|
495
|
+
if (!match) return void 0;
|
|
496
|
+
const [, prefix, rest] = match;
|
|
497
|
+
if (!rest) return void 0;
|
|
498
|
+
const completed = EMPHASIS_MARKER.test(rest) ? completeEmphasis(rest) : rest;
|
|
499
|
+
return completed ? `${prefix}${completed}` : void 0;
|
|
500
|
+
}
|
|
501
|
+
default: return;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
144
504
|
var sanitizeForURIComponent = (input) => {
|
|
145
505
|
let result = "";
|
|
146
506
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -162,37 +522,65 @@ var safeEncodeURIComponent = (str) => {
|
|
|
162
522
|
return "";
|
|
163
523
|
}
|
|
164
524
|
};
|
|
165
|
-
|
|
525
|
+
var resolveMinSectionChars = (incremental) => typeof incremental === "object" && typeof incremental.minSectionChars === "number" ? incremental.minSectionChars : 200;
|
|
526
|
+
var resolveKeepSectionsOnEnd = (incremental) => typeof incremental === "object" ? incremental.keepSectionsOnEnd !== false : true;
|
|
527
|
+
/**
|
|
528
|
+
* Streaming state machine plus, when `streaming.incremental` is on, the
|
|
529
|
+
* section boundaries the renderer can memoise on. `useStreaming` is the
|
|
530
|
+
* backwards-compatible view of this composable.
|
|
531
|
+
*/
|
|
532
|
+
function useStreamingCore(content, streaming, components, lex) {
|
|
533
|
+
const resolvedStreaming = computed(() => resolveStreaming(streaming.value));
|
|
166
534
|
let streamCache = getInitialCache();
|
|
167
535
|
const processedContent = ref("");
|
|
536
|
+
const sections = ref(null);
|
|
168
537
|
function handleIncompleteMarkdown(cache, opts) {
|
|
169
538
|
const { token, pending } = cache;
|
|
170
539
|
if (token === StreamCacheTokenType.Text) return void 0;
|
|
540
|
+
/**
|
|
541
|
+
* An image tag starts with '!', if it's the only character, it's incomplete and should be stripped.
|
|
542
|
+
* !
|
|
543
|
+
* ^
|
|
544
|
+
*/
|
|
171
545
|
if (token === StreamCacheTokenType.Image && pending === "!") return;
|
|
172
|
-
|
|
173
|
-
|
|
546
|
+
/**
|
|
547
|
+
* If a table has more than two lines (header, separator, and at least one row),
|
|
548
|
+
* it's considered complete enough to not be replaced by a placeholder.
|
|
549
|
+
* | column1 | column2 |\n| -- | --|\n
|
|
550
|
+
* ^
|
|
551
|
+
*/
|
|
552
|
+
if (token === StreamCacheTokenType.Table && cache.table.newlines > 1) return pending;
|
|
553
|
+
const componentMap = opts?.incompleteMarkdownComponentMap || {};
|
|
554
|
+
if (opts?.incompleteMarkdown === "complete" && !componentMap[token]) return completePending(token, pending);
|
|
555
|
+
const componentName = componentMap[token] ?? `incomplete-${token}`;
|
|
174
556
|
if (!components?.value?.[componentName]) return;
|
|
175
557
|
return `<${componentName} data-raw="${safeEncodeURIComponent(pending)}" />`;
|
|
176
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* Advance the cache to `text` and return the streaming output. It is
|
|
561
|
+
* idempotent: re-running it for the same `text` finds an empty chunk and
|
|
562
|
+
* only re-derives the output.
|
|
563
|
+
*/
|
|
177
564
|
function processStreaming(text, opts) {
|
|
178
565
|
if (!text) {
|
|
179
|
-
processedContent.value = "";
|
|
180
566
|
streamCache = getInitialCache();
|
|
181
|
-
return;
|
|
567
|
+
return "";
|
|
182
568
|
}
|
|
183
569
|
const expectedPrefix = streamCache.completeMarkdown + streamCache.pending;
|
|
184
570
|
if (!text.startsWith(expectedPrefix)) streamCache = getInitialCache();
|
|
185
571
|
const cache = streamCache;
|
|
186
572
|
const chunk = text.slice(cache.processedLength);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
573
|
+
const trackSections = !!opts?.incremental;
|
|
574
|
+
const minSectionChars = resolveMinSectionChars(opts?.incremental);
|
|
575
|
+
const componentNames = Object.keys(components?.value ?? {});
|
|
576
|
+
let offset = cache.processedLength;
|
|
192
577
|
cache.processedLength += chunk.length;
|
|
193
578
|
for (const char of chunk) {
|
|
194
579
|
cache.pending += char;
|
|
195
580
|
feedFenceState(cache.fence, char);
|
|
581
|
+
feedTableState(cache.table, char);
|
|
582
|
+
if (char === "\n") trackSectionBoundary(cache, text, offset, componentNames, minSectionChars, trackSections, lex);
|
|
583
|
+
offset += char.length;
|
|
196
584
|
if (isInCodeBlock(cache.fence)) {
|
|
197
585
|
commitCache(cache);
|
|
198
586
|
continue;
|
|
@@ -205,32 +593,59 @@ function useStreaming(content, streaming, components) {
|
|
|
205
593
|
}
|
|
206
594
|
if (cache.token === StreamCacheTokenType.Text) commitCache(cache);
|
|
207
595
|
}
|
|
596
|
+
if (cache.sections.lineStart < text.length) noteDefinitionTrigger(cache.sections, text.slice(cache.sections.lineStart));
|
|
597
|
+
settleDefinition(cache.sections, text, lex);
|
|
208
598
|
const incompletePlaceholder = handleIncompleteMarkdown(cache, opts);
|
|
209
|
-
|
|
599
|
+
return cache.completeMarkdown + (incompletePlaceholder || "");
|
|
210
600
|
}
|
|
211
601
|
function reset() {
|
|
212
602
|
streamCache = getInitialCache();
|
|
213
603
|
processedContent.value = "";
|
|
604
|
+
sections.value = null;
|
|
214
605
|
}
|
|
215
|
-
watch([
|
|
216
|
-
|
|
217
|
-
|
|
606
|
+
watch([
|
|
607
|
+
content,
|
|
608
|
+
() => resolvedStreaming.value?.hasNextChunk,
|
|
609
|
+
() => !!resolvedStreaming.value?.incremental,
|
|
610
|
+
() => resolveMinSectionChars(resolvedStreaming.value?.incremental),
|
|
611
|
+
() => resolveKeepSectionsOnEnd(resolvedStreaming.value?.incremental),
|
|
612
|
+
() => resolvedStreaming.value?.incompleteMarkdown,
|
|
613
|
+
() => resolvedStreaming.value?.incompleteMarkdownComponentMap,
|
|
614
|
+
() => components?.value
|
|
615
|
+
], () => {
|
|
616
|
+
const newContent = content.value;
|
|
617
|
+
const opts = resolvedStreaming.value;
|
|
618
|
+
const enableCache = Boolean(opts?.hasNextChunk);
|
|
619
|
+
const trackSections = !!opts?.incremental;
|
|
620
|
+
if (!enableCache) {
|
|
621
|
+
const cache = streamCache;
|
|
622
|
+
if (!(trackSections && !resolveKeepSectionsOnEnd(opts?.incremental)) && cache.processedLength > 0 && newContent.startsWith(cache.completeMarkdown + cache.pending)) {
|
|
623
|
+
processStreaming(newContent, opts);
|
|
624
|
+
processedContent.value = newContent;
|
|
625
|
+
sections.value = trackSections ? buildSections(cache, newContent, newContent.length) : null;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
streamCache = getInitialCache();
|
|
218
629
|
processedContent.value = newContent;
|
|
219
|
-
|
|
220
|
-
pending: "",
|
|
221
|
-
token: StreamCacheTokenType.Text,
|
|
222
|
-
processedLength: newContent.length,
|
|
223
|
-
completeMarkdown: newContent,
|
|
224
|
-
fence: getInitialFenceState()
|
|
225
|
-
};
|
|
630
|
+
sections.value = null;
|
|
226
631
|
return;
|
|
227
632
|
}
|
|
228
|
-
processStreaming(newContent, opts);
|
|
633
|
+
const output = processStreaming(newContent, opts);
|
|
634
|
+
processedContent.value = output;
|
|
635
|
+
sections.value = trackSections ? buildSections(streamCache, output, streamCache.completeMarkdown.length) : null;
|
|
229
636
|
}, { immediate: true });
|
|
230
637
|
return {
|
|
231
|
-
processedContent,
|
|
638
|
+
output: processedContent,
|
|
639
|
+
sections,
|
|
640
|
+
reset
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function useStreaming(content, streaming, components) {
|
|
644
|
+
const { output, reset } = useStreamingCore(content, streaming, components, fallbackLexer);
|
|
645
|
+
return {
|
|
646
|
+
processedContent: output,
|
|
232
647
|
reset
|
|
233
648
|
};
|
|
234
649
|
}
|
|
235
650
|
//#endregion
|
|
236
|
-
export { useStreaming };
|
|
651
|
+
export { DEFAULT_MIN_SECTION_CHARS, useStreaming, useStreamingCore };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
import { TypewriterConfig } from '../interface';
|
|
3
|
+
export declare const alignToClusterFallback: (text: string, length: number) => number;
|
|
4
|
+
/**
|
|
5
|
+
* Never cut inside a user-perceived character: a cut is moved forward to the
|
|
6
|
+
* next grapheme-cluster boundary so an emoji, a flag or an accented letter
|
|
7
|
+
* is never shown half-built (or as a replacement glyph) for a frame.
|
|
8
|
+
*/
|
|
9
|
+
export declare const alignToGrapheme: (text: string, length: number) => number;
|
|
10
|
+
export declare function useTypewriter(input: Ref<string>, typewriter: Ref<boolean | TypewriterConfig | undefined>, active: Ref<boolean>): Ref<string>;
|
|
11
|
+
export default useTypewriter;
|