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.
- package/README.md +206 -41
- package/dist/Block.svelte +5 -2
- 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 +55 -19
- package/dist/Elements/Code.svelte.d.ts +2 -0
- package/dist/Elements/Element.svelte +28 -6
- package/dist/Elements/Element.svelte.d.ts +1 -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 +62 -12
- package/dist/Elements/Mermaid.svelte.d.ts +2 -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 +21 -2
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -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 +68 -13
- package/dist/context.svelte.d.ts +98 -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.js +25 -10
- package/dist/marked/marked-math.js +40 -1
- package/dist/utils/fence.d.ts +16 -0
- package/dist/utils/fence.js +39 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
- package/dist/utils/parse-incomplete-markdown.js +281 -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 { 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,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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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:
|
|
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: /
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
let
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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 ===
|
|
586
|
+
if (k === unclosed.length - 1) {
|
|
557
587
|
// Last bracket: close at end of cell/line (keeps multi-key citations together)
|
|
558
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
1105
|
+
mathState = mathState === 'blockDollar' ? 'none' : 'blockDollar';
|
|
969
1106
|
i++;
|
|
970
|
-
inInlineMath = false;
|
|
971
1107
|
}
|
|
972
|
-
else if (
|
|
973
|
-
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
for (let i = position; 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
|
|
985
|
-
if (text[i] === '
|
|
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;
|