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.
- package/README.md +209 -41
- package/dist/Block.svelte +15 -8
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Alert.svelte +2 -1
- package/dist/Elements/Citation.svelte +7 -0
- package/dist/Elements/Code.svelte +60 -21
- package/dist/Elements/Code.svelte.d.ts +4 -0
- package/dist/Elements/Element.svelte +36 -8
- package/dist/Elements/Element.svelte.d.ts +2 -0
- package/dist/Elements/FootnoteRef.svelte +1 -0
- package/dist/Elements/Image.svelte +3 -2
- package/dist/Elements/Link.svelte +2 -2
- package/dist/Elements/Mermaid.svelte +65 -13
- package/dist/Elements/Mermaid.svelte.d.ts +3 -0
- package/dist/Elements/MermaidDownload.svelte +29 -8
- package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
- package/dist/Elements/TableDownload.svelte +57 -83
- package/dist/Elements/fallbacks/CodeFallback.svelte +25 -4
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -0
- package/dist/Elements/fallbacks/MermaidFallback.svelte +5 -2
- package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
- package/dist/Elements/icons.js +10 -1
- package/dist/Elements/srOnly.d.ts +1 -0
- package/dist/Elements/srOnly.js +3 -0
- package/dist/Streamdown.svelte +78 -16
- package/dist/context.svelte.d.ts +103 -22
- package/dist/context.svelte.js +38 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/marked/index.d.ts +6 -0
- package/dist/marked/index.js +46 -11
- package/dist/marked/marked-math.js +40 -1
- package/dist/utils/fence.d.ts +27 -0
- package/dist/utils/fence.js +56 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
- package/dist/utils/parse-incomplete-markdown.js +283 -138
- package/dist/utils/table-export.d.ts +14 -0
- package/dist/utils/table-export.js +82 -0
- package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
- package/dist/utils/usePinnedScroll.svelte.js +36 -0
- 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
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
137
|
-
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 (
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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:
|
|
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: /
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
let
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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 ===
|
|
588
|
+
if (k === unclosed.length - 1) {
|
|
557
589
|
// Last bracket: close at end of cell/line (keeps multi-key citations together)
|
|
558
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
1107
|
+
mathState = mathState === 'blockDollar' ? 'none' : 'blockDollar';
|
|
969
1108
|
i++;
|
|
970
|
-
inInlineMath = false;
|
|
971
1109
|
}
|
|
972
|
-
else if (
|
|
973
|
-
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
for (let i = position; 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
|
|
985
|
-
if (text[i] === '
|
|
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;
|