svelte-streamdown 4.0.1 → 4.1.1

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 (41) hide show
  1. package/README.md +209 -41
  2. package/dist/Block.svelte +15 -8
  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 +60 -21
  7. package/dist/Elements/Code.svelte.d.ts +4 -0
  8. package/dist/Elements/Element.svelte +36 -8
  9. package/dist/Elements/Element.svelte.d.ts +2 -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 +65 -13
  14. package/dist/Elements/Mermaid.svelte.d.ts +3 -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 +25 -4
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -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 +78 -16
  26. package/dist/context.svelte.d.ts +103 -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.d.ts +6 -0
  31. package/dist/marked/index.js +46 -11
  32. package/dist/marked/marked-math.js +40 -1
  33. package/dist/utils/fence.d.ts +27 -0
  34. package/dist/utils/fence.js +56 -0
  35. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  36. package/dist/utils/parse-incomplete-markdown.js +283 -138
  37. package/dist/utils/table-export.d.ts +14 -0
  38. package/dist/utils/table-export.js +82 -0
  39. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  40. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  41. package/package.json +3 -2
@@ -1,3 +1,4 @@
1
+ import { closingFence, 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,22 @@ 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, at the depth it was opened: a
200
+ // '~~~' block is not closed by '```', a longer run needs a closer at
201
+ // least as long, and a fence inside a list item needs its closer inside
202
+ // the item — one at column 0 leaves the block open and starts a new one.
203
+ const fence = state.openFence;
204
+ result += '\n' + (fence ? closingFence(fence) : '```');
187
205
  }
188
206
  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$$';
207
+ if (state.mathCloser === '\\]') {
208
+ result += '\n\\]';
209
+ }
210
+ else {
211
+ // The first half of the closing '$$' may already have arrived: adding a
212
+ // whole '\n$$' would leave a stray '$' inside the math and '$$' after it.
213
+ result += result.endsWith('$') && !result.endsWith('$$') ? '$' : '\n$$';
214
+ }
192
215
  }
193
216
  if (state.blockingContexts.has('center')) {
194
217
  result += '\n[/center]';
@@ -312,12 +335,14 @@ export class IncompleteMarkdownParser {
312
335
  },
313
336
  {
314
337
  name: 'singleAsteriskItalic',
315
- pattern: /[\s\S]*/,
338
+ pattern: /\*/,
316
339
  skipInBlockTypes: ['code', 'math'],
317
340
  handler: ({ line }) => {
318
341
  if (line.trim() === '***') {
319
342
  return line;
320
343
  }
344
+ // '\(w^{*}\)' and '$w^{*}$' are formulas, not half-open emphasis (8093f2a).
345
+ const mathy = mathDelimiter.test(line);
321
346
  // Inline countSingleAsterisks logic
322
347
  let singleAsterisks = 0;
323
348
  let lastSingleAsterisk = -1;
@@ -330,6 +355,9 @@ export class IncompleteMarkdownParser {
330
355
  if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar)) {
331
356
  continue;
332
357
  }
358
+ if (mathy && isWithinMathBlock(line, i)) {
359
+ continue;
360
+ }
333
361
  if (isWithinCompleteInlineCode(line, i)) {
334
362
  continue;
335
363
  }
@@ -355,6 +383,8 @@ export class IncompleteMarkdownParser {
355
383
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
356
384
  if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar))
357
385
  continue;
386
+ if (mathy && isWithinMathBlock(line, i))
387
+ continue;
358
388
  if (isWithinCompleteInlineCode(line, i))
359
389
  continue;
360
390
  if (/\w/.test(prevChar) && /\w/.test(nextChar))
@@ -408,11 +438,14 @@ export class IncompleteMarkdownParser {
408
438
  },
409
439
  {
410
440
  name: 'singleUnderscoreItalic',
411
- pattern: /[\s\S]*/,
441
+ pattern: /_/,
412
442
  skipInBlockTypes: ['code', 'math'],
413
443
  handler: ({ line }) => {
414
- // Inline countSingleUnderscores logic
444
+ // Inline countSingleUnderscores logic. The first counted underscore is
445
+ // also the one findFirstSingleUnderscore used to look for in a second
446
+ // identical pass, so it is picked up here.
415
447
  let singleUnderscores = 0;
448
+ let firstSingleUnderscoreIndex = -1;
416
449
  for (let i = 0; i < line.length; i++) {
417
450
  if (line[i] === '_') {
418
451
  const prevChar = i > 0 ? line[i - 1] : '';
@@ -431,35 +464,14 @@ export class IncompleteMarkdownParser {
431
464
  }
432
465
  if (prevChar !== '_' && nextChar !== '_') {
433
466
  singleUnderscores++;
467
+ if (firstSingleUnderscoreIndex === -1)
468
+ firstSingleUnderscoreIndex = i;
434
469
  }
435
470
  }
436
471
  }
437
472
  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
- }
473
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, firstSingleUnderscoreIndex);
474
+ return line.substring(0, endOfCellOrLine) + '_' + line.substring(endOfCellOrLine);
463
475
  }
464
476
  return line;
465
477
  }
@@ -524,50 +536,86 @@ export class IncompleteMarkdownParser {
524
536
  if (line.includes('](')) {
525
537
  return line;
526
538
  }
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
- }
539
+ // A '[' is unclosed exactly when no ']' follows it, so nothing before the
540
+ // last ']' can be: one lastIndexOf replaces an indexOf per bracket.
541
+ const lastClose = line.lastIndexOf(']');
542
+ const unclosed = [];
543
+ for (let i = lastClose + 1; i < line.length; i++) {
544
+ // Inside a math span a bracket is notation, not a citation opener:
545
+ // '\\(a[b\\)' must not gain a ']' at the end of the line.
546
+ if (line[i] === '[' && line[i - 1] !== '\\' && mathContextAt(line, i) === 'none') {
547
+ unclosed.push(i);
535
548
  }
536
549
  }
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) {
550
+ if (unclosed.length === 0)
551
+ return line;
552
+ // A completed `[...]` pair in front of them is evidence of an in-progress
553
+ // link, left to linksAndImages. No ']' can follow the first unclosed
554
+ // bracket, so that answer is the same for all of them: decided once.
555
+ let sawOpen = false;
556
+ for (let i = 0; i < unclosed[0]; i++) {
557
+ if (line[i] === ']' && sawOpen)
558
+ return line;
559
+ sawOpen ||= line[i] === '[';
560
+ }
561
+ // Close every unclosed citation bracket. Brackets that look like
562
+ // incomplete images (`![`), footnotes (`[^`), link text containing
563
+ // markdown formatting, or table-cell content are left for the dedicated
564
+ // plugins (footnoteRef, linksAndImages). The cell boundary, the
565
+ // formatting scan and the citation key all advance monotonically with
566
+ // the brackets, so the line is walked a constant number of times.
567
+ const insertions = [];
568
+ let cellEnd = 0;
569
+ let lastFormatting = -1;
570
+ let keyStart = 0;
571
+ let keyEnd = 0;
572
+ for (let k = 0; k < unclosed.length; k++) {
573
+ const pos = unclosed[k];
574
+ if (cellEnd <= pos) {
575
+ cellEnd = findEndOfCellOrLineContaining(line, pos);
576
+ lastFormatting = -1;
577
+ for (let i = pos + 1; i < cellEnd; i++) {
578
+ if (formattingChars.has(line[i]))
579
+ lastFormatting = i;
580
+ }
581
+ }
582
+ const isImage = pos > 0 && line[pos - 1] === '!';
583
+ const isFootnote = line[pos + 1] === '^';
584
+ const isTableCell = cellEnd < line.length && line[cellEnd] === '|';
585
+ if (isImage || isFootnote || lastFormatting > pos || isTableCell) {
554
586
  continue;
555
587
  }
556
- if (k === unclosedPositions.length - 1) {
588
+ if (k === unclosed.length - 1) {
557
589
  // Last bracket: close at end of cell/line (keeps multi-key citations together)
558
- result =
559
- result.substring(0, endOfCellOrLine) + ']' + result.substring(endOfCellOrLine);
590
+ insertions.push(cellEnd);
560
591
  }
561
592
  else {
562
593
  // 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
- }
594
+ if (keyStart < pos + 1)
595
+ keyStart = pos + 1;
596
+ while (keyStart < cellEnd && isSpaceOrEdge(line[keyStart]))
597
+ keyStart++;
598
+ if (keyEnd < keyStart)
599
+ keyEnd = keyStart;
600
+ while (keyEnd < cellEnd && !isSpaceOrEdge(line[keyEnd]))
601
+ keyEnd++;
602
+ if (keyEnd > keyStart)
603
+ insertions.push(keyEnd);
568
604
  }
569
605
  }
570
- return result;
606
+ if (insertions.length === 0)
607
+ return line;
608
+ // Offsets are all against the original line and ascending, so the
609
+ // closers are spliced in with one join instead of rebuilding the line
610
+ // per bracket.
611
+ const out = [];
612
+ let from = 0;
613
+ for (const at of insertions) {
614
+ out.push(line.slice(from, at), ']');
615
+ from = at;
616
+ }
617
+ out.push(line.slice(from));
618
+ return out.join('');
571
619
  }
572
620
  },
573
621
  {
@@ -575,6 +623,8 @@ export class IncompleteMarkdownParser {
575
623
  pattern: /\^/,
576
624
  skipInBlockTypes: ['code', 'math'],
577
625
  handler: ({ line }) => {
626
+ // An exponent ('\(w^{*}\)', '$E = mc^2$') is not half-open superscript.
627
+ const mathy = mathDelimiter.test(line);
578
628
  // Inline countSingleCarets logic
579
629
  let singleCarets = 0;
580
630
  for (let i = 0; i < line.length; i++) {
@@ -582,6 +632,8 @@ export class IncompleteMarkdownParser {
582
632
  const prevChar = i > 0 ? line[i - 1] : '';
583
633
  if (prevChar === '\\')
584
634
  continue;
635
+ if (mathy && isWithinMathBlock(line, i))
636
+ continue;
585
637
  if (!isWithinFootnoteRef(line, i))
586
638
  singleCarets++;
587
639
  }
@@ -589,7 +641,7 @@ export class IncompleteMarkdownParser {
589
641
  if (singleCarets % 2 === 1) {
590
642
  const lastCaretIndex = line.lastIndexOf('^');
591
643
  if (lastCaretIndex !== -1 &&
592
- !isWithinMathBlock(line, lastCaretIndex) &&
644
+ !(mathy && isWithinMathBlock(line, lastCaretIndex)) &&
593
645
  !isWithinFootnoteRef(line, lastCaretIndex)) {
594
646
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastCaretIndex);
595
647
  // Only complete if there's content after the caret
@@ -672,6 +724,27 @@ export class IncompleteMarkdownParser {
672
724
  return line;
673
725
  }
674
726
  },
727
+ {
728
+ // Same job inlineMath does for '$': a half-streamed '\(x^2' should render
729
+ // as math rather than flashing a literal escaped paren. A lone '\[' on its
730
+ // own line is a block opener and is closed by contextManager instead.
731
+ name: 'latexMath',
732
+ pattern: /\\[([]/,
733
+ skipInBlockTypes: ['code', 'math'],
734
+ handler: ({ line }) => {
735
+ const context = mathContextAt(line, line.length);
736
+ if (context !== 'inlineLatex' && context !== 'blockLatex')
737
+ return line;
738
+ const opener = context === 'inlineLatex' ? '\\(' : '\\[';
739
+ const openIndex = line.lastIndexOf(opener);
740
+ const endOfCellOrLine = findEndOfCellOrLineContaining(line, openIndex);
741
+ // Nothing to render yet: leave the bare delimiter for the next chunk.
742
+ if (!line.substring(openIndex + 2, endOfCellOrLine).trim())
743
+ return line;
744
+ const closer = context === 'inlineLatex' ? '\\)' : '\\]';
745
+ return line.substring(0, endOfCellOrLine) + closer + line.substring(endOfCellOrLine);
746
+ }
747
+ },
675
748
  {
676
749
  name: 'descriptionList',
677
750
  pattern: /^(\s*):/,
@@ -697,7 +770,11 @@ export class IncompleteMarkdownParser {
697
770
  handler: ({ line }) => {
698
771
  // Check for incomplete links with URLs: [text](url
699
772
  const urlMatch = line.match(/(!?\[[^\]]*\]\()([^)]*?)$/);
700
- if (urlMatch) {
773
+ // An escaped bracket opens nothing — '\[' is LaTeX display math or a
774
+ // literal '[', never an incomplete link (8093f2a). Both regexes here are
775
+ // anchored to the end of the line, so a guarded match means the line has
776
+ // no incomplete link at all.
777
+ if (urlMatch && !isEscapedBracket(line, urlMatch)) {
701
778
  const url = urlMatch[2];
702
779
  if (url.length > 0) {
703
780
  // Inline isUrlIncomplete logic
@@ -739,7 +816,7 @@ export class IncompleteMarkdownParser {
739
816
  }
740
817
  // Check for incomplete links without URLs: [text
741
818
  const linkMatch = line.match(/(!?\[)([^\]]*?)$/);
742
- if (linkMatch && !line.includes('](')) {
819
+ if (linkMatch && !isEscapedBracket(line, linkMatch) && !line.includes('](')) {
743
820
  const [, openBracket, linkTextWithPossibleBoundary] = linkMatch;
744
821
  // Position of the matched opening bracket (the regex matches the first
745
822
  // bracket that stays unclosed through the end of the line). Using the
@@ -911,6 +988,9 @@ export const parseIncompleteMarkdown = (text) => {
911
988
  // Utility functions
912
989
  // Full test for the comparisonOperator plugin, whose `pattern` only gates it.
913
990
  const listItemComparison = /^(\s*(?:[-*+]|\d+[.)]) +)>(?==?\s*\$?\d)/;
991
+ // Emphasis/code markers, as a set so inlineCitation can scan a cell for them
992
+ // without building a substring per bracket.
993
+ const formattingChars = new Set(['*', '~', '`', '_']);
914
994
  // The char accessors in the plugins return '' past either end of the line, so an
915
995
  // empty string here means "edge of line".
916
996
  const isSpaceOrEdge = (char) => !char || /\s/.test(char);
@@ -921,81 +1001,146 @@ const findEndOfCellOrLineContaining = (text, position) => {
921
1001
  }
922
1002
  return endPos;
923
1003
  };
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.
1004
+ // End (exclusive) of the complete inline-code span opened by the backtick run at
1005
+ // `open`, or -1 when that run is never closed.
1006
+ const endOfCodeSpan = (line, open) => {
1007
+ let openEnd = open;
1008
+ while (line.charCodeAt(openEnd) === 96)
1009
+ openEnd++;
1010
+ const runLength = openEnd - open;
1011
+ for (let j = openEnd; j < line.length; j++) {
1012
+ if (line.charCodeAt(j) !== 96)
1013
+ continue;
1014
+ let closeEnd = j;
1015
+ while (line.charCodeAt(closeEnd) === 96)
1016
+ closeEnd++;
1017
+ if (closeEnd - j === runLength)
1018
+ return j + runLength;
1019
+ j = closeEnd - 1;
1020
+ }
1021
+ return -1;
1022
+ };
1023
+ // The spans are probed at ascending positions along one line (once per marker
1024
+ // character in the worst case), so the walk is resumed where it stopped instead
1025
+ // of restarted from the line's first backtick — the difference between linear
1026
+ // and quadratic on a long line. A query that moves backwards, or onto another
1027
+ // line, restarts it. Four scalars: allocating a mask per line instead costs more
1028
+ // in GC than the scan it saves.
1029
+ let codeLine = '';
1030
+ let codePosition = -1;
1031
+ let codeOpen = -1;
1032
+ let codeEnd = -1;
1033
+ let codeUnclosed = false;
927
1034
  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
- }
1035
+ if (line !== codeLine || position < codePosition) {
1036
+ codeLine = line;
1037
+ codeOpen = line.indexOf('`');
1038
+ codeEnd = codeOpen === -1 ? -1 : endOfCodeSpan(line, codeOpen);
947
1039
  // An unterminated run closes nothing, so neither it nor anything after it
948
1040
  // 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);
1041
+ codeUnclosed = codeOpen !== -1 && codeEnd === -1;
955
1042
  }
956
- return false;
1043
+ codePosition = position;
1044
+ while (!codeUnclosed && codeOpen !== -1 && position >= codeEnd) {
1045
+ codeOpen = line.indexOf('`', codeEnd);
1046
+ if (codeOpen === -1)
1047
+ break;
1048
+ codeEnd = endOfCodeSpan(line, codeOpen);
1049
+ if (codeEnd === -1)
1050
+ codeUnclosed = true;
1051
+ }
1052
+ return !codeUnclosed && codeOpen !== -1 && position >= codeOpen && position < codeEnd;
957
1053
  };
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++;
1054
+ /**
1055
+ * Math delimiters `mathContextAt` reacts to. Testing this first keeps the
1056
+ * per-character scan off the lines — nearly all of them — that cannot be math.
1057
+ */
1058
+ const mathDelimiter = /\$|\\[([]/;
1059
+ // Same deal as the code-span walk above: one left-to-right fold over the line,
1060
+ // resumed rather than replayed from character 0 on every probe. The fold carries
1061
+ // the LaTeX states too (8093f2a), so the guards that consult it stay linear on a
1062
+ // long line instead of costing an O(n) probe per marker.
1063
+ let mathLine = '';
1064
+ let mathNext = 0;
1065
+ let mathState = 'none';
1066
+ /**
1067
+ * The math context a position sits in: `$`/`$$` as before, plus the LaTeX
1068
+ * delimiters the lexer now tokenizes. Inside `\(`/`\[` a `$` is literal, so
1069
+ * dollars are only read when no LaTeX span is open.
1070
+ */
1071
+ const mathContextAt = (text, position) => {
1072
+ if (text !== mathLine || position < mathNext) {
1073
+ mathLine = text;
1074
+ mathNext = 0;
1075
+ mathState = 'none';
1076
+ }
1077
+ let i = mathNext;
1078
+ for (; i < text.length && i < position; i++) {
1079
+ if (text[i] === '\\') {
1080
+ const next = text[i + 1];
1081
+ // An escaped backslash consumes both characters, so '\\[' in the source is a
1082
+ // literal backslash then a '[', not an opener. The lexer's tokenizer rejects
1083
+ // it the same way; skipping the pair keeps the two scanners in agreement.
1084
+ if (next === '\\') {
1085
+ i++;
1086
+ continue;
1087
+ }
1088
+ if (next === '$') {
1089
+ i++;
1090
+ continue;
1091
+ }
1092
+ if (mathState === 'none' && (next === '(' || next === '[')) {
1093
+ mathState = next === '(' ? 'inlineLatex' : 'blockLatex';
1094
+ i++;
1095
+ continue;
1096
+ }
1097
+ if ((mathState === 'inlineLatex' && next === ')') ||
1098
+ (mathState === 'blockLatex' && next === ']')) {
1099
+ mathState = 'none';
1100
+ i++;
1101
+ continue;
1102
+ }
964
1103
  continue;
965
1104
  }
966
- if (text[i] === '$') {
1105
+ if (text[i] === '$' && mathState !== 'inlineLatex' && mathState !== 'blockLatex') {
967
1106
  if (text[i + 1] === '$') {
968
- inBlockMath = !inBlockMath;
1107
+ mathState = mathState === 'blockDollar' ? 'none' : 'blockDollar';
969
1108
  i++;
970
- inInlineMath = false;
971
1109
  }
972
- else if (!inBlockMath) {
973
- inInlineMath = !inInlineMath;
1110
+ else if (mathState !== 'blockDollar') {
1111
+ // '$100' is a price, not an opening delimiter — the same currency rule the
1112
+ // inlineMath counter below and the lexer apply. Without it a single price
1113
+ // on the line would make everything after it look like math.
1114
+ if (mathState === 'none' && /\d/.test(text[i + 1] ?? ''))
1115
+ continue;
1116
+ mathState = mathState === 'inlineDollar' ? 'none' : 'inlineDollar';
974
1117
  }
975
1118
  }
976
1119
  }
977
- return inInlineMath || inBlockMath;
1120
+ mathNext = i;
1121
+ return mathState;
1122
+ };
1123
+ const isWithinMathBlock = (text, position) => mathContextAt(text, position) !== 'none';
1124
+ /**
1125
+ * True when the '[' captured in group 1 (possibly behind a '!') cannot open a
1126
+ * link: it is backslash-escaped, or it sits inside a math span, where brackets
1127
+ * are notation ('\\(a[b\\)') rather than markup.
1128
+ */
1129
+ const isEscapedBracket = (line, match) => {
1130
+ const bracketIndex = (match.index ?? 0) + (match[1].startsWith('!') ? 1 : 0);
1131
+ return line[bracketIndex - 1] === '\\' || mathContextAt(line, bracketIndex) !== 'none';
978
1132
  };
1133
+ // Only ever asked about a '^': that caret belongs to a footnote reference when a
1134
+ // '[' sits immediately before it and a ']' closes it before any other bracket.
1135
+ // (Walking back to the nearest bracket instead is O(line) per caret.)
979
1136
  const isWithinFootnoteRef = (text, position) => {
980
- let openBracketPos = -1;
981
- let caretPos = -1;
982
- for (let i = position; i >= 0; i--) {
1137
+ if (text[position - 1] !== '[')
1138
+ return false;
1139
+ for (let i = position + 1; i < text.length; i++) {
983
1140
  if (text[i] === ']')
984
- return false;
985
- if (text[i] === '^' && caretPos === -1)
986
- caretPos = i;
987
- if (text[i] === '[') {
988
- openBracketPos = i;
1141
+ return true;
1142
+ if (text[i] === '[' || text[i] === '\n')
989
1143
  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
1144
  }
1000
1145
  return false;
1001
1146
  };
@@ -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;