svelte-streamdown 4.0.0 → 4.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.
Files changed (45) hide show
  1. package/README.md +211 -38
  2. package/dist/Block.svelte +10 -4
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +9 -2
  6. package/dist/Elements/Code.svelte +65 -24
  7. package/dist/Elements/Code.svelte.d.ts +4 -2
  8. package/dist/Elements/Element.svelte +36 -11
  9. package/dist/Elements/Element.svelte.d.ts +1 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +3 -2
  13. package/dist/Elements/Mermaid.svelte +69 -14
  14. package/dist/Elements/Mermaid.svelte.d.ts +4 -2
  15. package/dist/Elements/MermaidDownload.svelte +30 -9
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +60 -78
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +28 -3
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +12 -3
  21. package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
  22. package/dist/Elements/icons.js +10 -1
  23. package/dist/Elements/srOnly.d.ts +1 -0
  24. package/dist/Elements/srOnly.js +3 -0
  25. package/dist/Streamdown.svelte +68 -13
  26. package/dist/context.svelte.d.ts +98 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +8 -1
  31. package/dist/marked/index.js +65 -14
  32. package/dist/marked/marked-footnotes.js +6 -2
  33. package/dist/marked/marked-math.js +40 -1
  34. package/dist/marked/marked-subsup.js +16 -3
  35. package/dist/utils/fence.d.ts +16 -0
  36. package/dist/utils/fence.js +39 -0
  37. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  38. package/dist/utils/parse-incomplete-markdown.js +347 -122
  39. package/dist/utils/save.js +4 -1
  40. package/dist/utils/table-export.d.ts +14 -0
  41. package/dist/utils/table-export.js +82 -0
  42. package/dist/utils/url.js +6 -2
  43. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  44. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  45. package/package.json +4 -2
@@ -1,3 +1,4 @@
1
+ import { trackFence } from './fence.js';
1
2
  export class IncompleteMarkdownParser {
2
3
  plugins = [];
3
4
  state = {
@@ -21,8 +22,7 @@ export class IncompleteMarkdownParser {
21
22
  currentLine: 0,
22
23
  context: 'normal',
23
24
  blockingContexts: new Set(),
24
- lineContexts: [],
25
- fenceInfo: undefined
25
+ lineContexts: []
26
26
  };
27
27
  let result = text;
28
28
  // Execute preprocess hooks for all plugins
@@ -98,14 +98,29 @@ export class IncompleteMarkdownParser {
98
98
  // Create default plugins that replicate the original handler functions
99
99
  static createDefaultPlugins() {
100
100
  return [
101
+ {
102
+ // Runs first: in a list item a '>' before a number is a comparison
103
+ // ('- > 25: rich'), but marked reads it as a nested blockquote. Escaping it
104
+ // keeps the text, and the escape renders as a plain '>' (4fffb9f).
105
+ // `pattern` is only a cheap gate — a line whose first non-space character is
106
+ // not a list marker can never match, and bails on that one character.
107
+ name: 'comparisonOperator',
108
+ pattern: /^\s*[-*+\d]/,
109
+ skipInBlockTypes: ['code', 'math'],
110
+ handler: ({ line }) => {
111
+ const match = listItemComparison.exec(line);
112
+ return match ? `${match[1]}\\>${line.slice(match[0].length)}` : line;
113
+ }
114
+ },
101
115
  // Block-level plugin that manages blocking contexts
102
116
  {
103
117
  name: 'contextManager',
104
118
  preprocess: ({ text }) => {
105
119
  // Pre-scan the entire text to establish blocking contexts
106
120
  const lines = text.split('\n');
107
- let inCodeBlock = false;
121
+ let fence = null;
108
122
  let inMathBlock = false;
123
+ let mathCloser = '$$';
109
124
  let inCenterBlock = false;
110
125
  let inRightBlock = false;
111
126
  let centerOpenLine = -1;
@@ -114,13 +129,23 @@ export class IncompleteMarkdownParser {
114
129
  const lineContexts = [];
115
130
  for (let i = 0; i < lines.length; i++) {
116
131
  const line = lines[i];
117
- // Check for block boundaries (fences may be quoted inside blockquotes/alerts: "> ```")
118
- const fenceLine = line.replace(/^[ \t]*(?:>[ \t]*)*/, '');
119
- if (fenceLine.startsWith('```') || fenceLine.startsWith('~~~')) {
120
- inCodeBlock = !inCodeBlock;
132
+ // Check for block boundaries. Fences may be quoted inside
133
+ // blockquotes/alerts ("> ```"); trackFence is the same CommonMark
134
+ // scanner the `incomplete` signal uses, so the two cannot drift.
135
+ fence = trackFence(line, fence);
136
+ const inCodeBlock = fence !== null;
137
+ // A math block opens with '$$' or a lone '\\[' and closes with the
138
+ // same delimiter it opened with, so '$' inside '\\[ … \\]' is literal.
139
+ const trimmed = line.trim();
140
+ const isDollarFence = trimmed.startsWith('$$') && !trimmed.includes('$$', 2);
141
+ if (!inMathBlock) {
142
+ if (isDollarFence || trimmed === '\\[') {
143
+ inMathBlock = true;
144
+ mathCloser = isDollarFence ? '$$' : '\\]';
145
+ }
121
146
  }
122
- if (line.trim().startsWith('$$') && !line.trim().includes('$$', 2)) {
123
- inMathBlock = !inMathBlock;
147
+ else if (mathCloser === '$$' ? isDollarFence : trimmed === '\\]') {
148
+ inMathBlock = false;
124
149
  }
125
150
  if (line.trim() === '[center]') {
126
151
  inCenterBlock = true;
@@ -145,7 +170,7 @@ export class IncompleteMarkdownParser {
145
170
  }
146
171
  // Set the final blocking contexts (for postprocessing)
147
172
  const finalContexts = new Set();
148
- if (inCodeBlock)
173
+ if (fence)
149
174
  finalContexts.add('code');
150
175
  if (inMathBlock)
151
176
  finalContexts.add('math');
@@ -160,7 +185,9 @@ export class IncompleteMarkdownParser {
160
185
  text: text, // Don't modify text in preprocess
161
186
  state: {
162
187
  blockingContexts: finalContexts,
163
- lineContexts
188
+ lineContexts,
189
+ mathCloser,
190
+ openFence: fence
164
191
  }
165
192
  };
166
193
  },
@@ -169,10 +196,20 @@ export class IncompleteMarkdownParser {
169
196
  // Close inner blocks (code/math) before alignment wrappers.
170
197
  let result = text;
171
198
  if (state.blockingContexts.has('code')) {
172
- result += '\n```';
199
+ // Close with the fence that was opened: a '~~~' block is not closed by
200
+ // '```', and a longer run needs a closer at least as long.
201
+ const fence = state.openFence;
202
+ result += '\n' + (fence ? fence.char.repeat(fence.length) : '```');
173
203
  }
174
204
  if (state.blockingContexts.has('math')) {
175
- result += '\n$$';
205
+ if (state.mathCloser === '\\]') {
206
+ result += '\n\\]';
207
+ }
208
+ else {
209
+ // The first half of the closing '$$' may already have arrived: adding a
210
+ // whole '\n$$' would leave a stray '$' inside the math and '$$' after it.
211
+ result += result.endsWith('$') && !result.endsWith('$$') ? '$' : '\n$$';
212
+ }
176
213
  }
177
214
  if (state.blockingContexts.has('center')) {
178
215
  result += '\n[/center]';
@@ -195,6 +232,8 @@ export class IncompleteMarkdownParser {
195
232
  const tripleAsterisks = (line.match(/\*\*\*/g) || []).length;
196
233
  if (tripleAsterisks % 2 === 1) {
197
234
  const lastTripleAsteriskIndex = line.lastIndexOf('***');
235
+ if (isWithinCompleteInlineCode(line, lastTripleAsteriskIndex))
236
+ return line;
198
237
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTripleAsteriskIndex);
199
238
  if (isEndingWithTripleAsterisk) {
200
239
  return line.substring(0, lastTripleAsteriskIndex);
@@ -222,6 +261,8 @@ export class IncompleteMarkdownParser {
222
261
  if (doubleAsteriskMatches % 2 === 1) {
223
262
  const isEndingWithDoubleAsterisk = line.endsWith('**');
224
263
  const lastDoubleAsteriskIndex = line.lastIndexOf('**');
264
+ if (isWithinCompleteInlineCode(line, lastDoubleAsteriskIndex))
265
+ return line;
225
266
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleAsteriskIndex);
226
267
  if (isEndingWithDoubleAsterisk) {
227
268
  return line.substring(0, lastDoubleAsteriskIndex);
@@ -248,6 +289,8 @@ export class IncompleteMarkdownParser {
248
289
  if (underscorePairs % 2 === 1) {
249
290
  const isEndingWithDoubleUnderscore = line.endsWith('__');
250
291
  const lastDoubleUnderscoreIndex = line.lastIndexOf('__');
292
+ if (isWithinCompleteInlineCode(line, lastDoubleUnderscoreIndex))
293
+ return line;
251
294
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleUnderscoreIndex);
252
295
  if (isEndingWithDoubleUnderscore) {
253
296
  return line.substring(0, lastDoubleUnderscoreIndex);
@@ -270,6 +313,8 @@ export class IncompleteMarkdownParser {
270
313
  if (tildePairs % 2 === 1) {
271
314
  const isEndingWithDoubleTilde = line.endsWith('~~');
272
315
  const lastDoubleTildeIndex = line.lastIndexOf('~~');
316
+ if (isWithinCompleteInlineCode(line, lastDoubleTildeIndex))
317
+ return line;
273
318
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleTildeIndex);
274
319
  // Only complete if there's content after the tildes
275
320
  const contentAfterTildes = line.substring(lastDoubleTildeIndex + 2, endOfCellOrLine);
@@ -288,45 +333,58 @@ export class IncompleteMarkdownParser {
288
333
  },
289
334
  {
290
335
  name: 'singleAsteriskItalic',
291
- pattern: /[\s\S]*/,
336
+ pattern: /\*/,
292
337
  skipInBlockTypes: ['code', 'math'],
293
338
  handler: ({ line }) => {
294
339
  if (line.trim() === '***') {
295
340
  return line;
296
341
  }
342
+ // '\(w^{*}\)' and '$w^{*}$' are formulas, not half-open emphasis (8093f2a).
343
+ const mathy = mathDelimiter.test(line);
297
344
  // Inline countSingleAsterisks logic
298
345
  let singleAsterisks = 0;
346
+ let lastSingleAsterisk = -1;
299
347
  for (let i = 0; i < line.length; i++) {
300
348
  if (line[i] === '*') {
301
349
  const prevChar = i > 0 ? line[i - 1] : '';
302
350
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
303
- let lineStartIndex = i;
304
- for (let j = i - 1; j >= 0; j--) {
305
- if (line[j] === '\n') {
306
- lineStartIndex = j + 1;
307
- break;
308
- }
309
- if (j === 0) {
310
- lineStartIndex = 0;
311
- break;
312
- }
351
+ // Whitespace on both sides means arithmetic ('5 * 0') or a bare list
352
+ // marker, never an emphasis delimiter (c347b53).
353
+ if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar)) {
354
+ continue;
313
355
  }
314
- const beforeAsterisk = line.substring(lineStartIndex, i);
315
- if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
356
+ if (mathy && isWithinMathBlock(line, i)) {
357
+ continue;
358
+ }
359
+ if (isWithinCompleteInlineCode(line, i)) {
316
360
  continue;
317
361
  }
318
362
  if (prevChar !== '*' && nextChar !== '*') {
319
363
  singleAsterisks++;
364
+ lastSingleAsterisk = i;
320
365
  }
321
366
  }
322
367
  }
323
368
  if (singleAsterisks % 2 === 1) {
369
+ // The dangling asterisk is the last counted one. If it cannot OPEN
370
+ // emphasis (nothing or whitespace after it) it is an intraword or
371
+ // trailing closer — '*foo*bar*' is literal, not half of '*foo*bar**'
372
+ // (9f96409).
373
+ if (isSpaceOrEdge(line[lastSingleAsterisk + 1] ?? '')) {
374
+ return line;
375
+ }
324
376
  // Inline findFirstSingleAsterisk logic
325
377
  let firstSingleAsteriskIndex = -1;
326
378
  for (let i = 0; i < line.length; i++) {
327
379
  if (line[i] === '*' && line[i - 1] !== '*' && line[i + 1] !== '*') {
328
380
  const prevChar = i > 0 ? line[i - 1] : '';
329
381
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
382
+ if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar))
383
+ continue;
384
+ if (mathy && isWithinMathBlock(line, i))
385
+ continue;
386
+ if (isWithinCompleteInlineCode(line, i))
387
+ continue;
330
388
  if (/\w/.test(prevChar) && /\w/.test(nextChar))
331
389
  continue;
332
390
  if (/\w/.test(prevChar) && !/\s/.test(prevChar))
@@ -378,11 +436,14 @@ export class IncompleteMarkdownParser {
378
436
  },
379
437
  {
380
438
  name: 'singleUnderscoreItalic',
381
- pattern: /[\s\S]*/,
439
+ pattern: /_/,
382
440
  skipInBlockTypes: ['code', 'math'],
383
441
  handler: ({ line }) => {
384
- // Inline countSingleUnderscores logic
442
+ // Inline countSingleUnderscores logic. The first counted underscore is
443
+ // also the one findFirstSingleUnderscore used to look for in a second
444
+ // identical pass, so it is picked up here.
385
445
  let singleUnderscores = 0;
446
+ let firstSingleUnderscoreIndex = -1;
386
447
  for (let i = 0; i < line.length; i++) {
387
448
  if (line[i] === '_') {
388
449
  const prevChar = i > 0 ? line[i - 1] : '';
@@ -391,6 +452,8 @@ export class IncompleteMarkdownParser {
391
452
  continue;
392
453
  if (isWithinMathBlock(line, i))
393
454
  continue;
455
+ if (isWithinCompleteInlineCode(line, i))
456
+ continue;
394
457
  if (prevChar &&
395
458
  nextChar &&
396
459
  /[\p{L}\p{N}_]/u.test(prevChar) &&
@@ -399,34 +462,14 @@ export class IncompleteMarkdownParser {
399
462
  }
400
463
  if (prevChar !== '_' && nextChar !== '_') {
401
464
  singleUnderscores++;
465
+ if (firstSingleUnderscoreIndex === -1)
466
+ firstSingleUnderscoreIndex = i;
402
467
  }
403
468
  }
404
469
  }
405
470
  if (singleUnderscores % 2 === 1) {
406
- // Inline findFirstSingleUnderscore logic
407
- let firstSingleUnderscoreIndex = -1;
408
- for (let i = 0; i < line.length; i++) {
409
- if (line[i] === '_' &&
410
- line[i - 1] !== '_' &&
411
- line[i + 1] !== '_' &&
412
- line[i - 1] !== '\\' &&
413
- !isWithinMathBlock(line, i)) {
414
- const prevChar = i > 0 ? line[i - 1] : '';
415
- const nextChar = i < line.length - 1 ? line[i + 1] : '';
416
- if (prevChar &&
417
- nextChar &&
418
- /[\p{L}\p{N}_]/u.test(prevChar) &&
419
- /[\p{L}\p{N}_]/u.test(nextChar)) {
420
- continue;
421
- }
422
- firstSingleUnderscoreIndex = i;
423
- break;
424
- }
425
- }
426
- if (firstSingleUnderscoreIndex !== -1) {
427
- const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleUnderscoreIndex);
428
- return line.substring(0, endOfCellOrLine) + '_' + line.substring(endOfCellOrLine);
429
- }
471
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleUnderscoreIndex);
472
+ return line.substring(0, endOfCellOrLine) + '_' + line.substring(endOfCellOrLine);
430
473
  }
431
474
  return line;
432
475
  }
@@ -450,11 +493,17 @@ export class IncompleteMarkdownParser {
450
493
  }
451
494
  if (singleTildes % 2 === 1) {
452
495
  const lastTildeIndex = line.lastIndexOf('~');
453
- if (lastTildeIndex !== -1 && !isWithinMathBlock(line, lastTildeIndex)) {
496
+ if (lastTildeIndex !== -1 &&
497
+ !isWithinMathBlock(line, lastTildeIndex) &&
498
+ !isWithinCompleteInlineCode(line, lastTildeIndex)) {
454
499
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTildeIndex);
455
- // Only complete if there's content after the tilde
456
500
  const contentAfterTilde = line.substring(lastTildeIndex + 1, endOfCellOrLine);
457
- if (contentAfterTilde.trim().length > 0) {
501
+ // A subscript is '~text~' with no whitespace inside (same rule as the
502
+ // lexer), and a digit before the tilde means a range like '20~25°C':
503
+ // closing those would manufacture a subscript nobody typed (716a5f0).
504
+ if (contentAfterTilde.length > 0 &&
505
+ !/\s/.test(contentAfterTilde) &&
506
+ !/\d/.test(line[lastTildeIndex - 1] ?? '')) {
458
507
  return line.substring(0, endOfCellOrLine) + '~' + line.substring(endOfCellOrLine);
459
508
  }
460
509
  }
@@ -485,50 +534,86 @@ export class IncompleteMarkdownParser {
485
534
  if (line.includes('](')) {
486
535
  return line;
487
536
  }
488
- // Collect unescaped opening brackets without a matching closing bracket
489
- const unclosedPositions = [];
490
- for (let i = 0; i < line.length; i++) {
491
- if (line[i] === '[' && (i === 0 || line[i - 1] !== '\\')) {
492
- // Check if this bracket has a matching closing bracket later in the line
493
- if (line.indexOf(']', i + 1) === -1) {
494
- unclosedPositions.push(i);
495
- }
537
+ // A '[' is unclosed exactly when no ']' follows it, so nothing before the
538
+ // last ']' can be: one lastIndexOf replaces an indexOf per bracket.
539
+ const lastClose = line.lastIndexOf(']');
540
+ const unclosed = [];
541
+ for (let i = lastClose + 1; i < line.length; i++) {
542
+ // Inside a math span a bracket is notation, not a citation opener:
543
+ // '\\(a[b\\)' must not gain a ']' at the end of the line.
544
+ if (line[i] === '[' && line[i - 1] !== '\\' && mathContextAt(line, i) === 'none') {
545
+ unclosed.push(i);
496
546
  }
497
547
  }
498
- // Close every unclosed citation bracket (right to left so indices stay
499
- // valid). Brackets that look like incomplete images (`![`), footnotes
500
- // (`[^`), link text containing markdown formatting, table-cell content,
501
- // or a trailing bracket preceded by a completed `[...]` pair (evidence
502
- // of an in-progress link) are left for the dedicated plugins
503
- // (footnoteRef, linksAndImages).
504
- let result = line;
505
- for (let k = unclosedPositions.length - 1; k >= 0; k--) {
506
- const pos = unclosedPositions[k];
507
- const endOfCellOrLine = findEndOfCellOrLineContaining(result, pos);
508
- const content = result.substring(pos + 1, endOfCellOrLine);
509
- const isImage = pos > 0 && result[pos - 1] === '!';
510
- const isFootnote = content.startsWith('^');
511
- const hasFormatting = /[*~`_]/.test(content);
512
- const isTableCell = endOfCellOrLine < result.length && result[endOfCellOrLine] === '|';
513
- const hasPriorCompletedPair = /\[[^\]]*\]/.test(line.substring(0, pos));
514
- if (isImage || isFootnote || hasFormatting || isTableCell || hasPriorCompletedPair) {
548
+ if (unclosed.length === 0)
549
+ return line;
550
+ // A completed `[...]` pair in front of them is evidence of an in-progress
551
+ // link, left to linksAndImages. No ']' can follow the first unclosed
552
+ // bracket, so that answer is the same for all of them: decided once.
553
+ let sawOpen = false;
554
+ for (let i = 0; i < unclosed[0]; i++) {
555
+ if (line[i] === ']' && sawOpen)
556
+ return line;
557
+ sawOpen ||= line[i] === '[';
558
+ }
559
+ // Close every unclosed citation bracket. Brackets that look like
560
+ // incomplete images (`![`), footnotes (`[^`), link text containing
561
+ // markdown formatting, or table-cell content are left for the dedicated
562
+ // plugins (footnoteRef, linksAndImages). The cell boundary, the
563
+ // formatting scan and the citation key all advance monotonically with
564
+ // the brackets, so the line is walked a constant number of times.
565
+ const insertions = [];
566
+ let cellEnd = 0;
567
+ let lastFormatting = -1;
568
+ let keyStart = 0;
569
+ let keyEnd = 0;
570
+ for (let k = 0; k < unclosed.length; k++) {
571
+ const pos = unclosed[k];
572
+ if (cellEnd <= pos) {
573
+ cellEnd = findEndOfCellOrLineContaining(line, pos);
574
+ lastFormatting = -1;
575
+ for (let i = pos + 1; i < cellEnd; i++) {
576
+ if (formattingChars.has(line[i]))
577
+ lastFormatting = i;
578
+ }
579
+ }
580
+ const isImage = pos > 0 && line[pos - 1] === '!';
581
+ const isFootnote = line[pos + 1] === '^';
582
+ const isTableCell = cellEnd < line.length && line[cellEnd] === '|';
583
+ if (isImage || isFootnote || lastFormatting > pos || isTableCell) {
515
584
  continue;
516
585
  }
517
- if (k === unclosedPositions.length - 1) {
586
+ if (k === unclosed.length - 1) {
518
587
  // Last bracket: close at end of cell/line (keeps multi-key citations together)
519
- result =
520
- result.substring(0, endOfCellOrLine) + ']' + result.substring(endOfCellOrLine);
588
+ insertions.push(cellEnd);
521
589
  }
522
590
  else {
523
591
  // Earlier brackets: close right after the citation key (first word)
524
- const keyMatch = content.match(/^\s*\S+/);
525
- if (keyMatch) {
526
- const insertAt = pos + 1 + keyMatch[0].length;
527
- result = result.substring(0, insertAt) + ']' + result.substring(insertAt);
528
- }
592
+ if (keyStart < pos + 1)
593
+ keyStart = pos + 1;
594
+ while (keyStart < cellEnd && isSpaceOrEdge(line[keyStart]))
595
+ keyStart++;
596
+ if (keyEnd < keyStart)
597
+ keyEnd = keyStart;
598
+ while (keyEnd < cellEnd && !isSpaceOrEdge(line[keyEnd]))
599
+ keyEnd++;
600
+ if (keyEnd > keyStart)
601
+ insertions.push(keyEnd);
529
602
  }
530
603
  }
531
- return result;
604
+ if (insertions.length === 0)
605
+ return line;
606
+ // Offsets are all against the original line and ascending, so the
607
+ // closers are spliced in with one join instead of rebuilding the line
608
+ // per bracket.
609
+ const out = [];
610
+ let from = 0;
611
+ for (const at of insertions) {
612
+ out.push(line.slice(from, at), ']');
613
+ from = at;
614
+ }
615
+ out.push(line.slice(from));
616
+ return out.join('');
532
617
  }
533
618
  },
534
619
  {
@@ -536,6 +621,8 @@ export class IncompleteMarkdownParser {
536
621
  pattern: /\^/,
537
622
  skipInBlockTypes: ['code', 'math'],
538
623
  handler: ({ line }) => {
624
+ // An exponent ('\(w^{*}\)', '$E = mc^2$') is not half-open superscript.
625
+ const mathy = mathDelimiter.test(line);
539
626
  // Inline countSingleCarets logic
540
627
  let singleCarets = 0;
541
628
  for (let i = 0; i < line.length; i++) {
@@ -543,6 +630,8 @@ export class IncompleteMarkdownParser {
543
630
  const prevChar = i > 0 ? line[i - 1] : '';
544
631
  if (prevChar === '\\')
545
632
  continue;
633
+ if (mathy && isWithinMathBlock(line, i))
634
+ continue;
546
635
  if (!isWithinFootnoteRef(line, i))
547
636
  singleCarets++;
548
637
  }
@@ -550,7 +639,7 @@ export class IncompleteMarkdownParser {
550
639
  if (singleCarets % 2 === 1) {
551
640
  const lastCaretIndex = line.lastIndexOf('^');
552
641
  if (lastCaretIndex !== -1 &&
553
- !isWithinMathBlock(line, lastCaretIndex) &&
642
+ !(mathy && isWithinMathBlock(line, lastCaretIndex)) &&
554
643
  !isWithinFootnoteRef(line, lastCaretIndex)) {
555
644
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastCaretIndex);
556
645
  // Only complete if there's content after the caret
@@ -580,6 +669,9 @@ export class IncompleteMarkdownParser {
580
669
  continue;
581
670
  if (nextChar && /\d/.test(nextChar))
582
671
  continue;
672
+ // A '$' shown as code ('`$var`') must not flip the parity (e50b0c4)
673
+ if (isWithinCompleteInlineCode(line, i))
674
+ continue;
583
675
  singleDollars++;
584
676
  }
585
677
  }
@@ -593,7 +685,8 @@ export class IncompleteMarkdownParser {
593
685
  prevChar !== '$' &&
594
686
  nextChar !== '$' &&
595
687
  nextChar !== '' &&
596
- !/\d/.test(nextChar)) {
688
+ !/\d/.test(nextChar) &&
689
+ !isWithinCompleteInlineCode(line, i)) {
597
690
  lastDollarIndex = i;
598
691
  break;
599
692
  }
@@ -629,6 +722,27 @@ export class IncompleteMarkdownParser {
629
722
  return line;
630
723
  }
631
724
  },
725
+ {
726
+ // Same job inlineMath does for '$': a half-streamed '\(x^2' should render
727
+ // as math rather than flashing a literal escaped paren. A lone '\[' on its
728
+ // own line is a block opener and is closed by contextManager instead.
729
+ name: 'latexMath',
730
+ pattern: /\\[([]/,
731
+ skipInBlockTypes: ['code', 'math'],
732
+ handler: ({ line }) => {
733
+ const context = mathContextAt(line, line.length);
734
+ if (context !== 'inlineLatex' && context !== 'blockLatex')
735
+ return line;
736
+ const opener = context === 'inlineLatex' ? '\\(' : '\\[';
737
+ const openIndex = line.lastIndexOf(opener);
738
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, openIndex);
739
+ // Nothing to render yet: leave the bare delimiter for the next chunk.
740
+ if (!line.substring(openIndex + 2, endOfCellOrLine).trim())
741
+ return line;
742
+ const closer = context === 'inlineLatex' ? '\\)' : '\\]';
743
+ return line.substring(0, endOfCellOrLine) + closer + line.substring(endOfCellOrLine);
744
+ }
745
+ },
632
746
  {
633
747
  name: 'descriptionList',
634
748
  pattern: /^(\s*):/,
@@ -654,7 +768,11 @@ export class IncompleteMarkdownParser {
654
768
  handler: ({ line }) => {
655
769
  // Check for incomplete links with URLs: [text](url
656
770
  const urlMatch = line.match(/(!?\[[^\]]*\]\()([^)]*?)$/);
657
- if (urlMatch) {
771
+ // An escaped bracket opens nothing — '\[' is LaTeX display math or a
772
+ // literal '[', never an incomplete link (8093f2a). Both regexes here are
773
+ // anchored to the end of the line, so a guarded match means the line has
774
+ // no incomplete link at all.
775
+ if (urlMatch && !isEscapedBracket(line, urlMatch)) {
658
776
  const url = urlMatch[2];
659
777
  if (url.length > 0) {
660
778
  // Inline isUrlIncomplete logic
@@ -696,7 +814,7 @@ export class IncompleteMarkdownParser {
696
814
  }
697
815
  // Check for incomplete links without URLs: [text
698
816
  const linkMatch = line.match(/(!?\[)([^\]]*?)$/);
699
- if (linkMatch && !line.includes('](')) {
817
+ if (linkMatch && !isEscapedBracket(line, linkMatch) && !line.includes('](')) {
700
818
  const [, openBracket, linkTextWithPossibleBoundary] = linkMatch;
701
819
  // Position of the matched opening bracket (the regex matches the first
702
820
  // bracket that stays unclosed through the end of the line). Using the
@@ -866,6 +984,14 @@ export const parseIncompleteMarkdown = (text) => {
866
984
  return defaultParser.parse(text);
867
985
  };
868
986
  // Utility functions
987
+ // Full test for the comparisonOperator plugin, whose `pattern` only gates it.
988
+ const listItemComparison = /^(\s*(?:[-*+]|\d+[.)]) +)>(?==?\s*\$?\d)/;
989
+ // Emphasis/code markers, as a set so inlineCitation can scan a cell for them
990
+ // without building a substring per bracket.
991
+ const formattingChars = new Set(['*', '~', '`', '_']);
992
+ // The char accessors in the plugins return '' past either end of the line, so an
993
+ // empty string here means "edge of line".
994
+ const isSpaceOrEdge = (char) => !char || /\s/.test(char);
869
995
  const findEndOfCellOrLineContaining = (text, position) => {
870
996
  let endPos = position;
871
997
  while (endPos < text.length && text[endPos] !== '\n' && text[endPos] !== '|') {
@@ -873,47 +999,146 @@ const findEndOfCellOrLineContaining = (text, position) => {
873
999
  }
874
1000
  return endPos;
875
1001
  };
876
- const isWithinMathBlock = (text, position) => {
877
- let inInlineMath = false;
878
- let inBlockMath = false;
879
- for (let i = 0; i < text.length && i < position; i++) {
880
- if (text[i] === '\\' && text[i + 1] === '$') {
881
- i++;
1002
+ // End (exclusive) of the complete inline-code span opened by the backtick run at
1003
+ // `open`, or -1 when that run is never closed.
1004
+ const endOfCodeSpan = (line, open) => {
1005
+ let openEnd = open;
1006
+ while (line.charCodeAt(openEnd) === 96)
1007
+ openEnd++;
1008
+ const runLength = openEnd - open;
1009
+ for (let j = openEnd; j < line.length; j++) {
1010
+ if (line.charCodeAt(j) !== 96)
1011
+ continue;
1012
+ let closeEnd = j;
1013
+ while (line.charCodeAt(closeEnd) === 96)
1014
+ closeEnd++;
1015
+ if (closeEnd - j === runLength)
1016
+ return j + runLength;
1017
+ j = closeEnd - 1;
1018
+ }
1019
+ return -1;
1020
+ };
1021
+ // The spans are probed at ascending positions along one line (once per marker
1022
+ // character in the worst case), so the walk is resumed where it stopped instead
1023
+ // of restarted from the line's first backtick — the difference between linear
1024
+ // and quadratic on a long line. A query that moves backwards, or onto another
1025
+ // line, restarts it. Four scalars: allocating a mask per line instead costs more
1026
+ // in GC than the scan it saves.
1027
+ let codeLine = '';
1028
+ let codePosition = -1;
1029
+ let codeOpen = -1;
1030
+ let codeEnd = -1;
1031
+ let codeUnclosed = false;
1032
+ const isWithinCompleteInlineCode = (line, position) => {
1033
+ if (line !== codeLine || position < codePosition) {
1034
+ codeLine = line;
1035
+ codeOpen = line.indexOf('`');
1036
+ codeEnd = codeOpen === -1 ? -1 : endOfCodeSpan(line, codeOpen);
1037
+ // An unterminated run closes nothing, so neither it nor anything after it
1038
+ // is code: completing emphasis inside it is what streaming needs.
1039
+ codeUnclosed = codeOpen !== -1 && codeEnd === -1;
1040
+ }
1041
+ codePosition = position;
1042
+ while (!codeUnclosed && codeOpen !== -1 && position >= codeEnd) {
1043
+ codeOpen = line.indexOf('`', codeEnd);
1044
+ if (codeOpen === -1)
1045
+ break;
1046
+ codeEnd = endOfCodeSpan(line, codeOpen);
1047
+ if (codeEnd === -1)
1048
+ codeUnclosed = true;
1049
+ }
1050
+ return !codeUnclosed && codeOpen !== -1 && position >= codeOpen && position < codeEnd;
1051
+ };
1052
+ /**
1053
+ * Math delimiters `mathContextAt` reacts to. Testing this first keeps the
1054
+ * per-character scan off the lines — nearly all of them — that cannot be math.
1055
+ */
1056
+ const mathDelimiter = /\$|\\[([]/;
1057
+ // Same deal as the code-span walk above: one left-to-right fold over the line,
1058
+ // resumed rather than replayed from character 0 on every probe. The fold carries
1059
+ // the LaTeX states too (8093f2a), so the guards that consult it stay linear on a
1060
+ // long line instead of costing an O(n) probe per marker.
1061
+ let mathLine = '';
1062
+ let mathNext = 0;
1063
+ let mathState = 'none';
1064
+ /**
1065
+ * The math context a position sits in: `$`/`$$` as before, plus the LaTeX
1066
+ * delimiters the lexer now tokenizes. Inside `\(`/`\[` a `$` is literal, so
1067
+ * dollars are only read when no LaTeX span is open.
1068
+ */
1069
+ const mathContextAt = (text, position) => {
1070
+ if (text !== mathLine || position < mathNext) {
1071
+ mathLine = text;
1072
+ mathNext = 0;
1073
+ mathState = 'none';
1074
+ }
1075
+ let i = mathNext;
1076
+ for (; i < text.length && i < position; i++) {
1077
+ if (text[i] === '\\') {
1078
+ const next = text[i + 1];
1079
+ // An escaped backslash consumes both characters, so '\\[' in the source is a
1080
+ // literal backslash then a '[', not an opener. The lexer's tokenizer rejects
1081
+ // it the same way; skipping the pair keeps the two scanners in agreement.
1082
+ if (next === '\\') {
1083
+ i++;
1084
+ continue;
1085
+ }
1086
+ if (next === '$') {
1087
+ i++;
1088
+ continue;
1089
+ }
1090
+ if (mathState === 'none' && (next === '(' || next === '[')) {
1091
+ mathState = next === '(' ? 'inlineLatex' : 'blockLatex';
1092
+ i++;
1093
+ continue;
1094
+ }
1095
+ if ((mathState === 'inlineLatex' && next === ')') ||
1096
+ (mathState === 'blockLatex' && next === ']')) {
1097
+ mathState = 'none';
1098
+ i++;
1099
+ continue;
1100
+ }
882
1101
  continue;
883
1102
  }
884
- if (text[i] === '$') {
1103
+ if (text[i] === '$' && mathState !== 'inlineLatex' && mathState !== 'blockLatex') {
885
1104
  if (text[i + 1] === '$') {
886
- inBlockMath = !inBlockMath;
1105
+ mathState = mathState === 'blockDollar' ? 'none' : 'blockDollar';
887
1106
  i++;
888
- inInlineMath = false;
889
1107
  }
890
- else if (!inBlockMath) {
891
- inInlineMath = !inInlineMath;
1108
+ else if (mathState !== 'blockDollar') {
1109
+ // '$100' is a price, not an opening delimiter — the same currency rule the
1110
+ // inlineMath counter below and the lexer apply. Without it a single price
1111
+ // on the line would make everything after it look like math.
1112
+ if (mathState === 'none' && /\d/.test(text[i + 1] ?? ''))
1113
+ continue;
1114
+ mathState = mathState === 'inlineDollar' ? 'none' : 'inlineDollar';
892
1115
  }
893
1116
  }
894
1117
  }
895
- return inInlineMath || inBlockMath;
1118
+ mathNext = i;
1119
+ return mathState;
896
1120
  };
1121
+ const isWithinMathBlock = (text, position) => mathContextAt(text, position) !== 'none';
1122
+ /**
1123
+ * True when the '[' captured in group 1 (possibly behind a '!') cannot open a
1124
+ * link: it is backslash-escaped, or it sits inside a math span, where brackets
1125
+ * are notation ('\\(a[b\\)') rather than markup.
1126
+ */
1127
+ const isEscapedBracket = (line, match) => {
1128
+ const bracketIndex = (match.index ?? 0) + (match[1].startsWith('!') ? 1 : 0);
1129
+ return line[bracketIndex - 1] === '\\' || mathContextAt(line, bracketIndex) !== 'none';
1130
+ };
1131
+ // Only ever asked about a '^': that caret belongs to a footnote reference when a
1132
+ // '[' sits immediately before it and a ']' closes it before any other bracket.
1133
+ // (Walking back to the nearest bracket instead is O(line) per caret.)
897
1134
  const isWithinFootnoteRef = (text, position) => {
898
- let openBracketPos = -1;
899
- let caretPos = -1;
900
- for (let i = position; i >= 0; i--) {
1135
+ if (text[position - 1] !== '[')
1136
+ return false;
1137
+ for (let i = position + 1; i < text.length; i++) {
901
1138
  if (text[i] === ']')
902
- return false;
903
- if (text[i] === '^' && caretPos === -1)
904
- caretPos = i;
905
- if (text[i] === '[') {
906
- openBracketPos = i;
1139
+ return true;
1140
+ if (text[i] === '[' || text[i] === '\n')
907
1141
  break;
908
- }
909
- }
910
- if (openBracketPos !== -1 && caretPos === openBracketPos + 1 && position >= caretPos) {
911
- for (let i = position + 1; i < text.length; i++) {
912
- if (text[i] === ']')
913
- return true;
914
- if (text[i] === '[' || text[i] === '\n')
915
- break;
916
- }
917
1142
  }
918
1143
  return false;
919
1144
  };