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