@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.
Files changed (28) hide show
  1. package/dist/XMarkdown/components/AnimationText.vue.d.ts +6 -0
  2. package/dist/XMarkdown/components/AnimationText.vue_vue_type_script_setup_true_lang.js +58 -2
  3. package/dist/XMarkdown/components/Section.js +5 -0
  4. package/dist/XMarkdown/components/Section.vue.d.ts +76 -0
  5. package/dist/XMarkdown/components/Section.vue_vue_type_script_lang.js +54 -0
  6. package/dist/XMarkdown/composables/index.d.ts +2 -1
  7. package/dist/XMarkdown/composables/index.js +3 -2
  8. package/dist/XMarkdown/composables/useStreaming.d.ts +37 -1
  9. package/dist/XMarkdown/composables/useStreaming.js +449 -34
  10. package/dist/XMarkdown/composables/useTypewriter.d.ts +11 -0
  11. package/dist/XMarkdown/composables/useTypewriter.js +378 -0
  12. package/dist/XMarkdown/core/Parser.d.ts +15 -0
  13. package/dist/XMarkdown/core/Parser.js +22 -3
  14. package/dist/XMarkdown/core/VueRenderer.js +16 -9
  15. package/dist/XMarkdown/core/detectUnclosedComponentTags.d.ts +12 -0
  16. package/dist/XMarkdown/core/detectUnclosedComponentTags.js +240 -6
  17. package/dist/XMarkdown/core/index.js +1 -1
  18. package/dist/XMarkdown/index.d.ts +6 -2
  19. package/dist/XMarkdown/index.js +5 -3
  20. package/dist/XMarkdown/index.vue.d.ts +1 -1
  21. package/dist/XMarkdown/index.vue_vue_type_script_setup_true_lang.js +64 -30
  22. package/dist/XMarkdown/interface.d.ts +241 -14
  23. package/dist/XMarkdown/utils/streaming.d.ts +6 -0
  24. package/dist/XMarkdown/utils/streaming.js +32 -0
  25. package/dist/index.d.ts +6 -2
  26. package/dist/index.js +5 -3
  27. package/package.json +1 -1
  28. 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 { ref, watch } from "vue";
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
- var isTableInComplete = (markdown) => {
13
- if (markdown.includes("\n\n")) return false;
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 && (char === "`" || char === "~")) {
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
- function useStreaming(content, streaming, components) {
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
- if (token === StreamCacheTokenType.Table && pending.split("\n").length > 2) return pending;
173
- const componentName = opts?.incompleteMarkdownComponentMap?.[token] ?? `incomplete-${token}`;
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
- if (!chunk) {
188
- const incompletePlaceholder = handleIncompleteMarkdown(cache, opts);
189
- processedContent.value = cache.completeMarkdown + (incompletePlaceholder || "");
190
- return;
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
- processedContent.value = cache.completeMarkdown + (incompletePlaceholder || "");
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([content, () => streaming.value?.hasNextChunk], ([newContent, hasNextChunk]) => {
216
- const opts = streaming.value;
217
- if (!Boolean(hasNextChunk)) {
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
- streamCache = {
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;