svelte-streamdown 4.0.1 → 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 (40) hide show
  1. package/README.md +206 -41
  2. package/dist/Block.svelte +5 -2
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +7 -0
  6. package/dist/Elements/Code.svelte +55 -19
  7. package/dist/Elements/Code.svelte.d.ts +2 -0
  8. package/dist/Elements/Element.svelte +28 -6
  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 +2 -2
  13. package/dist/Elements/Mermaid.svelte +62 -12
  14. package/dist/Elements/Mermaid.svelte.d.ts +2 -0
  15. package/dist/Elements/MermaidDownload.svelte +29 -8
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +57 -83
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +21 -2
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +5 -2
  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 +2 -1
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.js +25 -10
  31. package/dist/marked/marked-math.js +40 -1
  32. package/dist/utils/fence.d.ts +16 -0
  33. package/dist/utils/fence.js +39 -0
  34. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  35. package/dist/utils/parse-incomplete-markdown.js +281 -138
  36. package/dist/utils/table-export.d.ts +14 -0
  37. package/dist/utils/table-export.js +82 -0
  38. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  39. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  40. package/package.json +3 -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
@@ -118,8 +118,9 @@ export class IncompleteMarkdownParser {
118
118
  preprocess: ({ text }) => {
119
119
  // Pre-scan the entire text to establish blocking contexts
120
120
  const lines = text.split('\n');
121
- let inCodeBlock = false;
121
+ let fence = null;
122
122
  let inMathBlock = false;
123
+ let mathCloser = '$$';
123
124
  let inCenterBlock = false;
124
125
  let inRightBlock = false;
125
126
  let centerOpenLine = -1;
@@ -128,13 +129,23 @@ export class IncompleteMarkdownParser {
128
129
  const lineContexts = [];
129
130
  for (let i = 0; i < lines.length; i++) {
130
131
  const line = lines[i];
131
- // Check for block boundaries (fences may be quoted inside blockquotes/alerts: "> ```")
132
- const fenceLine = line.replace(/^[ \t]*(?:>[ \t]*)*/, '');
133
- if (fenceLine.startsWith('```') || fenceLine.startsWith('~~~')) {
134
- 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
+ }
135
146
  }
136
- if (line.trim().startsWith('$$') && !line.trim().includes('$$', 2)) {
137
- inMathBlock = !inMathBlock;
147
+ else if (mathCloser === '$$' ? isDollarFence : trimmed === '\\]') {
148
+ inMathBlock = false;
138
149
  }
139
150
  if (line.trim() === '[center]') {
140
151
  inCenterBlock = true;
@@ -159,7 +170,7 @@ export class IncompleteMarkdownParser {
159
170
  }
160
171
  // Set the final blocking contexts (for postprocessing)
161
172
  const finalContexts = new Set();
162
- if (inCodeBlock)
173
+ if (fence)
163
174
  finalContexts.add('code');
164
175
  if (inMathBlock)
165
176
  finalContexts.add('math');
@@ -174,7 +185,9 @@ export class IncompleteMarkdownParser {
174
185
  text: text, // Don't modify text in preprocess
175
186
  state: {
176
187
  blockingContexts: finalContexts,
177
- lineContexts
188
+ lineContexts,
189
+ mathCloser,
190
+ openFence: fence
178
191
  }
179
192
  };
180
193
  },
@@ -183,12 +196,20 @@ export class IncompleteMarkdownParser {
183
196
  // Close inner blocks (code/math) before alignment wrappers.
184
197
  let result = text;
185
198
  if (state.blockingContexts.has('code')) {
186
- 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) : '```');
187
203
  }
188
204
  if (state.blockingContexts.has('math')) {
189
- // The first half of the closing '$$' may already have arrived: adding a
190
- // whole '\n$$' would leave a stray '$' inside the math and '$$' after it.
191
- result += result.endsWith('$') && !result.endsWith('$$') ? '$' : '\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
+ }
192
213
  }
193
214
  if (state.blockingContexts.has('center')) {
194
215
  result += '\n[/center]';
@@ -312,12 +333,14 @@ export class IncompleteMarkdownParser {
312
333
  },
313
334
  {
314
335
  name: 'singleAsteriskItalic',
315
- pattern: /[\s\S]*/,
336
+ pattern: /\*/,
316
337
  skipInBlockTypes: ['code', 'math'],
317
338
  handler: ({ line }) => {
318
339
  if (line.trim() === '***') {
319
340
  return line;
320
341
  }
342
+ // '\(w^{*}\)' and '$w^{*}$' are formulas, not half-open emphasis (8093f2a).
343
+ const mathy = mathDelimiter.test(line);
321
344
  // Inline countSingleAsterisks logic
322
345
  let singleAsterisks = 0;
323
346
  let lastSingleAsterisk = -1;
@@ -330,6 +353,9 @@ export class IncompleteMarkdownParser {
330
353
  if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar)) {
331
354
  continue;
332
355
  }
356
+ if (mathy && isWithinMathBlock(line, i)) {
357
+ continue;
358
+ }
333
359
  if (isWithinCompleteInlineCode(line, i)) {
334
360
  continue;
335
361
  }
@@ -355,6 +381,8 @@ export class IncompleteMarkdownParser {
355
381
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
356
382
  if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar))
357
383
  continue;
384
+ if (mathy && isWithinMathBlock(line, i))
385
+ continue;
358
386
  if (isWithinCompleteInlineCode(line, i))
359
387
  continue;
360
388
  if (/\w/.test(prevChar) && /\w/.test(nextChar))
@@ -408,11 +436,14 @@ export class IncompleteMarkdownParser {
408
436
  },
409
437
  {
410
438
  name: 'singleUnderscoreItalic',
411
- pattern: /[\s\S]*/,
439
+ pattern: /_/,
412
440
  skipInBlockTypes: ['code', 'math'],
413
441
  handler: ({ line }) => {
414
- // 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.
415
445
  let singleUnderscores = 0;
446
+ let firstSingleUnderscoreIndex = -1;
416
447
  for (let i = 0; i < line.length; i++) {
417
448
  if (line[i] === '_') {
418
449
  const prevChar = i > 0 ? line[i - 1] : '';
@@ -431,35 +462,14 @@ export class IncompleteMarkdownParser {
431
462
  }
432
463
  if (prevChar !== '_' && nextChar !== '_') {
433
464
  singleUnderscores++;
465
+ if (firstSingleUnderscoreIndex === -1)
466
+ firstSingleUnderscoreIndex = i;
434
467
  }
435
468
  }
436
469
  }
437
470
  if (singleUnderscores % 2 === 1) {
438
- // Inline findFirstSingleUnderscore logic
439
- let firstSingleUnderscoreIndex = -1;
440
- for (let i = 0; i < line.length; i++) {
441
- if (line[i] === '_' &&
442
- line[i - 1] !== '_' &&
443
- line[i + 1] !== '_' &&
444
- line[i - 1] !== '\\' &&
445
- !isWithinMathBlock(line, i) &&
446
- !isWithinCompleteInlineCode(line, i)) {
447
- const prevChar = i > 0 ? line[i - 1] : '';
448
- const nextChar = i < line.length - 1 ? line[i + 1] : '';
449
- if (prevChar &&
450
- nextChar &&
451
- /[\p{L}\p{N}_]/u.test(prevChar) &&
452
- /[\p{L}\p{N}_]/u.test(nextChar)) {
453
- continue;
454
- }
455
- firstSingleUnderscoreIndex = i;
456
- break;
457
- }
458
- }
459
- if (firstSingleUnderscoreIndex !== -1) {
460
- const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleUnderscoreIndex);
461
- return line.substring(0, endOfCellOrLine) + '_' + line.substring(endOfCellOrLine);
462
- }
471
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleUnderscoreIndex);
472
+ return line.substring(0, endOfCellOrLine) + '_' + line.substring(endOfCellOrLine);
463
473
  }
464
474
  return line;
465
475
  }
@@ -524,50 +534,86 @@ export class IncompleteMarkdownParser {
524
534
  if (line.includes('](')) {
525
535
  return line;
526
536
  }
527
- // Collect unescaped opening brackets without a matching closing bracket
528
- const unclosedPositions = [];
529
- for (let i = 0; i < line.length; i++) {
530
- if (line[i] === '[' && (i === 0 || line[i - 1] !== '\\')) {
531
- // Check if this bracket has a matching closing bracket later in the line
532
- if (line.indexOf(']', i + 1) === -1) {
533
- unclosedPositions.push(i);
534
- }
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);
535
546
  }
536
547
  }
537
- // Close every unclosed citation bracket (right to left so indices stay
538
- // valid). Brackets that look like incomplete images (`![`), footnotes
539
- // (`[^`), link text containing markdown formatting, table-cell content,
540
- // or a trailing bracket preceded by a completed `[...]` pair (evidence
541
- // of an in-progress link) are left for the dedicated plugins
542
- // (footnoteRef, linksAndImages).
543
- let result = line;
544
- for (let k = unclosedPositions.length - 1; k >= 0; k--) {
545
- const pos = unclosedPositions[k];
546
- const endOfCellOrLine = findEndOfCellOrLineContaining(result, pos);
547
- const content = result.substring(pos + 1, endOfCellOrLine);
548
- const isImage = pos > 0 && result[pos - 1] === '!';
549
- const isFootnote = content.startsWith('^');
550
- const hasFormatting = /[*~`_]/.test(content);
551
- const isTableCell = endOfCellOrLine < result.length && result[endOfCellOrLine] === '|';
552
- const hasPriorCompletedPair = /\[[^\]]*\]/.test(line.substring(0, pos));
553
- 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) {
554
584
  continue;
555
585
  }
556
- if (k === unclosedPositions.length - 1) {
586
+ if (k === unclosed.length - 1) {
557
587
  // Last bracket: close at end of cell/line (keeps multi-key citations together)
558
- result =
559
- result.substring(0, endOfCellOrLine) + ']' + result.substring(endOfCellOrLine);
588
+ insertions.push(cellEnd);
560
589
  }
561
590
  else {
562
591
  // Earlier brackets: close right after the citation key (first word)
563
- const keyMatch = content.match(/^\s*\S+/);
564
- if (keyMatch) {
565
- const insertAt = pos + 1 + keyMatch[0].length;
566
- result = result.substring(0, insertAt) + ']' + result.substring(insertAt);
567
- }
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);
568
602
  }
569
603
  }
570
- 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('');
571
617
  }
572
618
  },
573
619
  {
@@ -575,6 +621,8 @@ export class IncompleteMarkdownParser {
575
621
  pattern: /\^/,
576
622
  skipInBlockTypes: ['code', 'math'],
577
623
  handler: ({ line }) => {
624
+ // An exponent ('\(w^{*}\)', '$E = mc^2$') is not half-open superscript.
625
+ const mathy = mathDelimiter.test(line);
578
626
  // Inline countSingleCarets logic
579
627
  let singleCarets = 0;
580
628
  for (let i = 0; i < line.length; i++) {
@@ -582,6 +630,8 @@ export class IncompleteMarkdownParser {
582
630
  const prevChar = i > 0 ? line[i - 1] : '';
583
631
  if (prevChar === '\\')
584
632
  continue;
633
+ if (mathy && isWithinMathBlock(line, i))
634
+ continue;
585
635
  if (!isWithinFootnoteRef(line, i))
586
636
  singleCarets++;
587
637
  }
@@ -589,7 +639,7 @@ export class IncompleteMarkdownParser {
589
639
  if (singleCarets % 2 === 1) {
590
640
  const lastCaretIndex = line.lastIndexOf('^');
591
641
  if (lastCaretIndex !== -1 &&
592
- !isWithinMathBlock(line, lastCaretIndex) &&
642
+ !(mathy && isWithinMathBlock(line, lastCaretIndex)) &&
593
643
  !isWithinFootnoteRef(line, lastCaretIndex)) {
594
644
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastCaretIndex);
595
645
  // Only complete if there's content after the caret
@@ -672,6 +722,27 @@ export class IncompleteMarkdownParser {
672
722
  return line;
673
723
  }
674
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
+ },
675
746
  {
676
747
  name: 'descriptionList',
677
748
  pattern: /^(\s*):/,
@@ -697,7 +768,11 @@ export class IncompleteMarkdownParser {
697
768
  handler: ({ line }) => {
698
769
  // Check for incomplete links with URLs: [text](url
699
770
  const urlMatch = line.match(/(!?\[[^\]]*\]\()([^)]*?)$/);
700
- 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)) {
701
776
  const url = urlMatch[2];
702
777
  if (url.length > 0) {
703
778
  // Inline isUrlIncomplete logic
@@ -739,7 +814,7 @@ export class IncompleteMarkdownParser {
739
814
  }
740
815
  // Check for incomplete links without URLs: [text
741
816
  const linkMatch = line.match(/(!?\[)([^\]]*?)$/);
742
- if (linkMatch && !line.includes('](')) {
817
+ if (linkMatch && !isEscapedBracket(line, linkMatch) && !line.includes('](')) {
743
818
  const [, openBracket, linkTextWithPossibleBoundary] = linkMatch;
744
819
  // Position of the matched opening bracket (the regex matches the first
745
820
  // bracket that stays unclosed through the end of the line). Using the
@@ -911,6 +986,9 @@ export const parseIncompleteMarkdown = (text) => {
911
986
  // Utility functions
912
987
  // Full test for the comparisonOperator plugin, whose `pattern` only gates it.
913
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(['*', '~', '`', '_']);
914
992
  // The char accessors in the plugins return '' past either end of the line, so an
915
993
  // empty string here means "edge of line".
916
994
  const isSpaceOrEdge = (char) => !char || /\s/.test(char);
@@ -921,81 +999,146 @@ const findEndOfCellOrLineContaining = (text, position) => {
921
999
  }
922
1000
  return endPos;
923
1001
  };
924
- // Scanned on demand, against the line as it stands now, and allocating nothing:
925
- // the completer runs over every changed block of every streamed chunk, so garbage
926
- // here surfaces as GC pauses in the stages around it.
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;
927
1032
  const isWithinCompleteInlineCode = (line, position) => {
928
- let open = line.indexOf('`');
929
- while (open !== -1 && open <= position) {
930
- let openEnd = open;
931
- while (line.charCodeAt(openEnd) === 96)
932
- openEnd++;
933
- const runLength = openEnd - open;
934
- let closeStart = -1;
935
- for (let j = openEnd; j < line.length; j++) {
936
- if (line.charCodeAt(j) !== 96)
937
- continue;
938
- let closeEnd = j;
939
- while (line.charCodeAt(closeEnd) === 96)
940
- closeEnd++;
941
- if (closeEnd - j === runLength) {
942
- closeStart = j;
943
- break;
944
- }
945
- j = closeEnd - 1;
946
- }
1033
+ if (line !== codeLine || position < codePosition) {
1034
+ codeLine = line;
1035
+ codeOpen = line.indexOf('`');
1036
+ codeEnd = codeOpen === -1 ? -1 : endOfCodeSpan(line, codeOpen);
947
1037
  // An unterminated run closes nothing, so neither it nor anything after it
948
1038
  // is code: completing emphasis inside it is what streaming needs.
949
- if (closeStart === -1)
950
- return false;
951
- const spanEnd = closeStart + runLength;
952
- if (position < spanEnd)
953
- return true;
954
- open = line.indexOf('`', spanEnd);
1039
+ codeUnclosed = codeOpen !== -1 && codeEnd === -1;
955
1040
  }
956
- return false;
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;
957
1051
  };
958
- const isWithinMathBlock = (text, position) => {
959
- let inInlineMath = false;
960
- let inBlockMath = false;
961
- for (let i = 0; i < text.length && i < position; i++) {
962
- if (text[i] === '\\' && text[i + 1] === '$') {
963
- i++;
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
+ }
964
1101
  continue;
965
1102
  }
966
- if (text[i] === '$') {
1103
+ if (text[i] === '$' && mathState !== 'inlineLatex' && mathState !== 'blockLatex') {
967
1104
  if (text[i + 1] === '$') {
968
- inBlockMath = !inBlockMath;
1105
+ mathState = mathState === 'blockDollar' ? 'none' : 'blockDollar';
969
1106
  i++;
970
- inInlineMath = false;
971
1107
  }
972
- else if (!inBlockMath) {
973
- 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';
974
1115
  }
975
1116
  }
976
1117
  }
977
- return inInlineMath || inBlockMath;
1118
+ mathNext = i;
1119
+ return mathState;
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';
978
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.)
979
1134
  const isWithinFootnoteRef = (text, position) => {
980
- let openBracketPos = -1;
981
- let caretPos = -1;
982
- 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++) {
983
1138
  if (text[i] === ']')
984
- return false;
985
- if (text[i] === '^' && caretPos === -1)
986
- caretPos = i;
987
- if (text[i] === '[') {
988
- openBracketPos = i;
1139
+ return true;
1140
+ if (text[i] === '[' || text[i] === '\n')
989
1141
  break;
990
- }
991
- }
992
- if (openBracketPos !== -1 && caretPos === openBracketPos + 1 && position >= caretPos) {
993
- for (let i = position + 1; i < text.length; i++) {
994
- if (text[i] === ']')
995
- return true;
996
- if (text[i] === '[' || text[i] === '\n')
997
- break;
998
- }
999
1142
  }
1000
1143
  return false;
1001
1144
  };
@@ -0,0 +1,14 @@
1
+ export type TableData = {
2
+ headers: string[];
3
+ rows: string[][];
4
+ };
5
+ export type CsvSeparator = ',' | ';' | '\t' | 'auto';
6
+ /**
7
+ * Read a rendered table (or any element containing one) into a plain matrix.
8
+ * `colspan`/`rowspan` are expanded into empty cells so every row lines up.
9
+ */
10
+ export declare const extractTableData: (element: Element) => TableData;
11
+ export declare const tableDataToCSV: (data: TableData, separator?: CsvSeparator) => string;
12
+ export declare const tableDataToTSV: (data: TableData) => string;
13
+ export declare const tableDataToMarkdown: (data: TableData) => string;
14
+ export declare const tableDataToHTML: (data: TableData) => string;