svelte-streamdown 2.2.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +47 -47
  2. package/dist/AnimatedText.svelte +6 -8
  3. package/dist/Block.svelte +2 -2
  4. package/dist/Elements/Alert.svelte +19 -18
  5. package/dist/Elements/Code.svelte +20 -17
  6. package/dist/Elements/Image.svelte +22 -20
  7. package/dist/Elements/Link.svelte +1 -1
  8. package/dist/Elements/Math.svelte +4 -7
  9. package/dist/Elements/Mermaid.svelte +76 -68
  10. package/dist/Streamdown.svelte +4 -0
  11. package/dist/context.svelte.d.ts +16 -3
  12. package/dist/context.svelte.js +1 -3
  13. package/dist/marked/index.d.ts +4 -2
  14. package/dist/marked/index.js +10 -6
  15. package/dist/marked/marked-alert.d.ts +1 -1
  16. package/dist/marked/marked-alert.js +14 -5
  17. package/dist/marked/marked-br.d.ts +12 -0
  18. package/dist/marked/marked-br.js +21 -0
  19. package/dist/marked/marked-footnotes.d.ts +1 -1
  20. package/dist/marked/marked-footnotes.js +1 -1
  21. package/dist/marked/marked-hr.d.ts +12 -0
  22. package/dist/marked/marked-hr.js +24 -0
  23. package/dist/marked/marked-list.d.ts +2 -1
  24. package/dist/marked/marked-list.js +9 -3
  25. package/dist/marked/marked-math.d.ts +3 -2
  26. package/dist/marked/marked-math.js +41 -21
  27. package/dist/marked/marked-subsup.d.ts +1 -1
  28. package/dist/marked/marked-subsup.js +1 -1
  29. package/dist/marked/marked-table.d.ts +1 -0
  30. package/dist/marked/marked-table.js +11 -4
  31. package/dist/theme.d.ts +9 -1
  32. package/dist/theme.js +2 -0
  33. package/dist/utils/hightlighter.svelte.d.ts +15 -13
  34. package/dist/utils/hightlighter.svelte.js +101 -57
  35. package/dist/utils/parse-incomplete-markdown.js +352 -157
  36. package/package.json +4 -2
  37. package/dist/Elements/Table.svelte +0 -30
  38. package/dist/Elements/Table.svelte.d.ts +0 -9
@@ -1,5 +1,5 @@
1
1
  const linkImagePattern = /(!?\[)([^\]]*?)$/;
2
- const boldPattern = /(\*\*)([^\n]*?)$/;
2
+ const boldPattern = /(\*\*)([^]*?)$/;
3
3
  const italicPattern = /(__)([^_]*?)$/;
4
4
  const boldItalicPattern = /(\*\*\*)([^*]*?)$/;
5
5
  const singleAsteriskPattern = /(\*)([^*]*?)$/;
@@ -8,6 +8,16 @@ const inlineCodePattern = /(`)([^`]*?)$/;
8
8
  const strikethroughPattern = /(~~)([^~]*?)$/;
9
9
  const subPattern = /(~)([^~]*?)$/;
10
10
  const supPattern = /(\^)([^\^]*?)$/;
11
+ const incompleteLinkUrlPattern = /(!?\[[^\]]*\]\()([^)]*?)$/;
12
+ // Helper function to find the end of the line containing a specific position
13
+ const findEndOfLineContaining = (text, position) => {
14
+ let endPos = position;
15
+ // Move forward to find the end of the line
16
+ while (endPos < text.length && text[endPos] !== '\n') {
17
+ endPos++;
18
+ }
19
+ return endPos;
20
+ };
11
21
  // Helper function to check if we have a complete code block
12
22
  const hasCompleteCodeBlock = (text) => {
13
23
  const tripleBackticks = (text.match(/```/g) || []).length;
@@ -15,26 +25,85 @@ const hasCompleteCodeBlock = (text) => {
15
25
  };
16
26
  // Handles incomplete links and images by preserving them with a special marker
17
27
  const handleIncompleteLinksAndImages = (text) => {
28
+ // Check for incomplete link URLs like "[text](incomplete-url"
29
+ const urlMatch = text.match(incompleteLinkUrlPattern);
30
+ if (urlMatch) {
31
+ const isImage = urlMatch[1].startsWith('!');
32
+ const url = urlMatch[2];
33
+ // Only handle if there's actually some URL content
34
+ if (url.length > 0) {
35
+ // Check if the URL is actually incomplete
36
+ const isIncomplete = isUrlIncomplete(url);
37
+ if (isIncomplete) {
38
+ // For incomplete URLs, replace with incomplete marker
39
+ const marker = isImage ? 'streamdown:incomplete-image' : 'streamdown:incomplete-link';
40
+ return text.replace(urlMatch[2], marker) + ')';
41
+ }
42
+ else {
43
+ // URL is complete, just add closing parenthesis
44
+ return `${text})`;
45
+ }
46
+ }
47
+ else {
48
+ // If URL is empty (just "[text]("), complete it with incomplete marker
49
+ const marker = isImage ? 'streamdown:incomplete-image' : 'streamdown:incomplete-link';
50
+ return `${text}${marker})`;
51
+ }
52
+ }
53
+ // Check for incomplete link text like "[incomplete-text" (but not "[text](")
18
54
  const linkMatch = text.match(linkImagePattern);
19
- if (linkMatch) {
55
+ if (linkMatch && !text.includes('](')) {
20
56
  const isImage = linkMatch[1].startsWith('!');
21
- // For images, we still remove them as they can't show skeleton
22
- if (isImage) {
23
- const startIndex = text.lastIndexOf(linkMatch[1]);
24
- return text.substring(0, startIndex);
25
- }
26
- // For links, preserve the text and close the link with a
27
- // special placeholder URL that indicates it's incomplete
28
- return `${text}](streamdown:incomplete-link)`;
57
+ // For incomplete link/image text, complete with incomplete marker
58
+ const marker = isImage ? 'streamdown:incomplete-image' : 'streamdown:incomplete-link';
59
+ return `${text}](${marker})`;
29
60
  }
30
61
  return text;
31
62
  };
63
+ // Helper function to determine if a URL is incomplete or lacks proper domain extension
64
+ const isUrlIncomplete = (url) => {
65
+ // If URL is clearly incomplete (too short, no protocol, etc.)
66
+ if (!url || url.length < 4) {
67
+ return true;
68
+ }
69
+ // If URL starts with protocol but is very short
70
+ if ((url.startsWith('http://') && url.length < 12) ||
71
+ (url.startsWith('https://') && url.length < 13)) {
72
+ return true;
73
+ }
74
+ // If URL has protocol, extract the domain part
75
+ let domain = url;
76
+ if (url.startsWith('http://')) {
77
+ domain = url.substring(7);
78
+ }
79
+ else if (url.startsWith('https://')) {
80
+ domain = url.substring(8);
81
+ }
82
+ // Remove path, query, and fragment parts to get just the domain
83
+ domain = domain.split('/')[0].split('?')[0].split('#')[0];
84
+ // Check if domain has a proper extension
85
+ const domainParts = domain.split('.');
86
+ if (domainParts.length < 2) {
87
+ return true; // No extension at all
88
+ }
89
+ const extension = domainParts[domainParts.length - 1];
90
+ // Check if extension looks valid (at least 2 characters, only letters)
91
+ if (extension.length < 2 || !/^[a-zA-Z]+$/.test(extension)) {
92
+ return true;
93
+ }
94
+ // If we get here, the URL looks reasonably complete
95
+ return false;
96
+ };
32
97
  // Completes incomplete bold formatting (**)
33
98
  const handleIncompleteBold = (text) => {
34
99
  // Don't process if inside a complete code block
35
100
  if (hasCompleteCodeBlock(text)) {
36
101
  return text;
37
102
  }
103
+ // IMPORTANT SAFEGUARD: Don't modify text that contains complete italic patterns
104
+ // This prevents accidentally converting *italic* to something else
105
+ const completeItalicPattern = /\*[^*\n]+\*/g;
106
+ const completeItalicMatches = text.match(completeItalicPattern);
38
107
  const boldMatch = text.match(boldPattern);
39
108
  if (boldMatch) {
40
109
  // Find the position of the last ** marker
@@ -45,23 +114,26 @@ const handleIncompleteBold = (text) => {
45
114
  if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
46
115
  return text;
47
116
  }
48
- // Check if the bold marker is in a list item context
49
- const beforeMarker = text.substring(0, lastDoubleAsteriskIndex);
50
- const lastNewlineBeforeMarker = beforeMarker.lastIndexOf('\n');
51
- const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;
52
- const lineBeforeMarker = text.substring(lineStart, lastDoubleAsteriskIndex);
53
- // Check if this line is a list item with just the bold marker
54
- if (/^[\s]*[-*+][\s]+$/.test(lineBeforeMarker)) {
55
- // This is a list item with just emphasis markers
56
- // Check if content after marker spans multiple lines
57
- const hasNewlineInContent = contentAfterMarker.includes('\n');
58
- if (hasNewlineInContent) {
59
- // Don't complete if the content spans to another line
60
- return text;
61
- }
117
+ // Check if this is a standalone horizontal rule (asterisks on a line by themselves)
118
+ const matchIndex = lastDoubleAsteriskIndex;
119
+ const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
120
+ const lineEnd = text.indexOf('\n', matchIndex);
121
+ const lineContent = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
122
+ const trimmedLine = lineContent.trim();
123
+ // If the line contains only asterisks (2 or more), it's a horizontal rule
124
+ if (/^\*+$/.test(trimmedLine) && trimmedLine.length >= 2) {
125
+ return text;
62
126
  }
63
- // Check if the content after ** ends with a single * (incomplete closing marker)
127
+ // For streaming context, we allow multi-line completion
128
+ // Remove the conservative multi-line restriction for better UX
129
+ // ADDITIONAL SAFEGUARD: Check if the content after ** would interfere with existing italic
64
130
  if (contentAfterMarker.endsWith('*') && !contentAfterMarker.endsWith('**')) {
131
+ // Before treating as incomplete closing marker, check if this would break italic formatting
132
+ const potentiallyAffectedText = contentAfterMarker.slice(0, -1);
133
+ if (completeItalicMatches && potentiallyAffectedText.includes('*')) {
134
+ // Don't modify if it would interfere with existing italic patterns
135
+ return text;
136
+ }
65
137
  // The content ends with a single *, treat it as an incomplete closing marker
66
138
  // Remove the trailing * and add complete closing **
67
139
  const contentWithoutTrailingAsterisk = contentAfterMarker.slice(0, -1);
@@ -71,7 +143,9 @@ const handleIncompleteBold = (text) => {
71
143
  const doubleAsteriskMatches = text.match(/\*\*/g) || [];
72
144
  const doubleAsteriskCount = doubleAsteriskMatches.length;
73
145
  if (doubleAsteriskCount % 2 === 1) {
74
- return `${text}**`;
146
+ // Find the end of the line containing the incomplete bold marker
147
+ const endOfLine = findEndOfLineContaining(text, lastDoubleAsteriskIndex);
148
+ return text.substring(0, endOfLine) + '**' + text.substring(endOfLine);
75
149
  }
76
150
  }
77
151
  return text;
@@ -87,13 +161,23 @@ const handleIncompleteDoubleUnderscoreItalic = (text) => {
87
161
  if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
88
162
  return text;
89
163
  }
164
+ // Check if this is a standalone horizontal rule (underscores on a line by themselves)
165
+ const matchIndex = text.lastIndexOf('__');
166
+ const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
167
+ const lineEnd = text.indexOf('\n', matchIndex);
168
+ const lineContent = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
169
+ const trimmedLine = lineContent.trim();
170
+ // If the line contains only underscores (3 or more), it's a horizontal rule
171
+ if (/^_+$/.test(trimmedLine) && trimmedLine.length >= 3) {
172
+ return text;
173
+ }
90
174
  // Check if the underscore marker is in a list item context
91
175
  // Find the position of the matched underscore marker
92
176
  const markerIndex = text.lastIndexOf(italicMatch[1]);
93
177
  const beforeMarker = text.substring(0, markerIndex);
94
178
  const lastNewlineBeforeMarker = beforeMarker.lastIndexOf('\n');
95
- const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;
96
- const lineBeforeMarker = text.substring(lineStart, markerIndex);
179
+ const lineStart2 = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;
180
+ const lineBeforeMarker = text.substring(lineStart2, markerIndex);
97
181
  // Check if this line is a list item with just the underscore marker
98
182
  if (/^[\s]*[-*+][\s]+$/.test(lineBeforeMarker)) {
99
183
  // This is a list item with just emphasis markers
@@ -106,46 +190,51 @@ const handleIncompleteDoubleUnderscoreItalic = (text) => {
106
190
  }
107
191
  const underscorePairs = (text.match(/__/g) || []).length;
108
192
  if (underscorePairs % 2 === 1) {
109
- return `${text}__`;
193
+ // Find the position of the last __ marker
194
+ const lastDoubleUnderscoreIndex = text.lastIndexOf('__');
195
+ // Find the end of the line containing the incomplete italic marker
196
+ const endOfLine = findEndOfLineContaining(text, lastDoubleUnderscoreIndex);
197
+ return text.substring(0, endOfLine) + '__' + text.substring(endOfLine);
110
198
  }
111
199
  }
112
200
  return text;
113
201
  };
114
202
  // Counts single asterisks that are not part of double asterisks, not escaped, and not list markers
115
203
  const countSingleAsterisks = (text) => {
116
- return text.split('').reduce((acc, char, index) => {
117
- if (char === '*') {
118
- const prevChar = text[index - 1];
119
- const nextChar = text[index + 1];
204
+ let count = 0;
205
+ for (let i = 0; i < text.length; i++) {
206
+ if (text[i] === '*') {
207
+ const prevChar = i > 0 ? text[i - 1] : '';
208
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
120
209
  // Skip if escaped with backslash
121
210
  if (prevChar === '\\') {
122
- return acc;
211
+ continue;
123
212
  }
124
213
  // Check if this is a list marker (asterisk at start of line followed by space)
125
214
  // Look backwards to find the start of the current line
126
- let lineStartIndex = index;
127
- for (let i = index - 1; i >= 0; i--) {
128
- if (text[i] === '\n') {
129
- lineStartIndex = i + 1;
215
+ let lineStartIndex = i;
216
+ for (let j = i - 1; j >= 0; j--) {
217
+ if (text[j] === '\n') {
218
+ lineStartIndex = j + 1;
130
219
  break;
131
220
  }
132
- if (i === 0) {
221
+ if (j === 0) {
133
222
  lineStartIndex = 0;
134
223
  break;
135
224
  }
136
225
  }
137
226
  // Check if this asterisk is at the beginning of a line (with optional whitespace)
138
- const beforeAsterisk = text.substring(lineStartIndex, index);
227
+ const beforeAsterisk = text.substring(lineStartIndex, i);
139
228
  if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
140
229
  // This is likely a list marker, don't count it
141
- return acc;
230
+ continue;
142
231
  }
143
232
  if (prevChar !== '*' && nextChar !== '*') {
144
- return acc + 1;
233
+ count++;
145
234
  }
146
235
  }
147
- return acc;
148
- }, 0);
236
+ }
237
+ return count;
149
238
  };
150
239
  // Completes incomplete italic formatting with single asterisks (*)
151
240
  const handleIncompleteSingleAsteriskItalic = (text) => {
@@ -153,6 +242,19 @@ const handleIncompleteSingleAsteriskItalic = (text) => {
153
242
  if (hasCompleteCodeBlock(text)) {
154
243
  return text;
155
244
  }
245
+ // IMPORTANT SAFEGUARD: Check if we already have complete italic formatting patterns
246
+ // If text contains complete *word* patterns, don't modify them
247
+ const completeItalicPattern = /\*[^*\n]+\*/g;
248
+ const completeMatches = text.match(completeItalicPattern);
249
+ if (completeMatches) {
250
+ // Count asterisks in complete matches
251
+ const asterisksInCompleteMatches = completeMatches.join('').split('*').length - 1;
252
+ const totalAsterisks = (text.match(/\*/g) || []).length;
253
+ // If most asterisks are already in complete italic patterns, don't process
254
+ if (asterisksInCompleteMatches >= totalAsterisks) {
255
+ return text;
256
+ }
257
+ }
156
258
  const singleAsteriskMatch = text.match(singleAsteriskPattern);
157
259
  if (singleAsteriskMatch) {
158
260
  // Find the first single asterisk position (not part of **)
@@ -188,7 +290,9 @@ const handleIncompleteSingleAsteriskItalic = (text) => {
188
290
  }
189
291
  const singleAsterisks = countSingleAsterisks(text);
190
292
  if (singleAsterisks % 2 === 1) {
191
- return `${text}*`;
293
+ // Find the end of the line containing the incomplete italic marker
294
+ const endOfLine = findEndOfLineContaining(text, firstSingleAsteriskIndex);
295
+ return text.substring(0, endOfLine) + '*' + text.substring(endOfLine);
192
296
  }
193
297
  }
194
298
  return text;
@@ -253,31 +357,32 @@ const isWithinFootnoteRef = (text, position) => {
253
357
  };
254
358
  // Counts single underscores that are not part of double underscores, not escaped, and not in math blocks
255
359
  const countSingleUnderscores = (text) => {
256
- return text.split('').reduce((acc, char, index) => {
257
- if (char === '_') {
258
- const prevChar = text[index - 1];
259
- const nextChar = text[index + 1];
360
+ let count = 0;
361
+ for (let i = 0; i < text.length; i++) {
362
+ if (text[i] === '_') {
363
+ const prevChar = i > 0 ? text[i - 1] : '';
364
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
260
365
  // Skip if escaped with backslash
261
366
  if (prevChar === '\\') {
262
- return acc;
367
+ continue;
263
368
  }
264
369
  // Skip if within math block
265
- if (isWithinMathBlock(text, index)) {
266
- return acc;
370
+ if (isWithinMathBlock(text, i)) {
371
+ continue;
267
372
  }
268
373
  // Skip if underscore is word-internal (between word characters)
269
374
  if (prevChar &&
270
375
  nextChar &&
271
376
  /[\p{L}\p{N}_]/u.test(prevChar) &&
272
377
  /[\p{L}\p{N}_]/u.test(nextChar)) {
273
- return acc;
378
+ continue;
274
379
  }
275
380
  if (prevChar !== '_' && nextChar !== '_') {
276
- return acc + 1;
381
+ count++;
277
382
  }
278
383
  }
279
- return acc;
280
- }, 0);
384
+ }
385
+ return count;
281
386
  };
282
387
  // Completes incomplete italic formatting with single underscores (_)
283
388
  const handleIncompleteSingleUnderscoreItalic = (text) => {
@@ -320,13 +425,9 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
320
425
  }
321
426
  const singleUnderscores = countSingleUnderscores(text);
322
427
  if (singleUnderscores % 2 === 1) {
323
- // If text ends with newline(s), insert underscore before them
324
- const trailingNewlineMatch = text.match(/\n+$/);
325
- if (trailingNewlineMatch) {
326
- const textBeforeNewlines = text.slice(0, -trailingNewlineMatch[0].length);
327
- return `${textBeforeNewlines}_${trailingNewlineMatch[0]}`;
328
- }
329
- return `${text}_`;
428
+ // Find the end of the line containing the incomplete underscore italic marker
429
+ const endOfLine = findEndOfLineContaining(text, firstSingleUnderscoreIndex);
430
+ return text.substring(0, endOfLine) + '_' + text.substring(endOfLine);
330
431
  }
331
432
  }
332
433
  return text;
@@ -390,7 +491,11 @@ const handleIncompleteInlineCode = (text) => {
390
491
  }
391
492
  const singleBacktickCount = countSingleBackticks(text);
392
493
  if (singleBacktickCount % 2 === 1) {
393
- return `${text}\``;
494
+ // Find the position of the last backtick
495
+ const lastBacktickIndex = text.lastIndexOf('`');
496
+ // Find the end of the line containing the incomplete code marker
497
+ const endOfLine = findEndOfLineContaining(text, lastBacktickIndex);
498
+ return text.substring(0, endOfLine) + '`' + text.substring(endOfLine);
394
499
  }
395
500
  }
396
501
  return text;
@@ -408,27 +513,33 @@ const handleIncompleteStrikethrough = (text) => {
408
513
  }
409
514
  const tildePairs = (text.match(/~~/g) || []).length;
410
515
  if (tildePairs % 2 === 1) {
411
- return `${text}~~`;
516
+ // Find the position of the last ~~ marker
517
+ const lastDoubleTildeIndex = text.lastIndexOf('~~');
518
+ // Find the end of the line containing the incomplete strikethrough marker
519
+ const endOfLine = findEndOfLineContaining(text, lastDoubleTildeIndex);
520
+ return text.substring(0, endOfLine) + '~~' + text.substring(endOfLine);
412
521
  }
413
522
  }
414
523
  return text;
415
524
  };
416
525
  // Counts single tildes that are not part of double tildes and not escaped
417
526
  const countSingleTildes = (text) => {
418
- return text.split('').reduce((acc, char, index) => {
419
- if (char === '~') {
420
- const prevChar = text[index - 1];
421
- const nextChar = text[index + 1];
527
+ let count = 0;
528
+ for (let i = 0; i < text.length; i++) {
529
+ if (text[i] === '~') {
530
+ const prevChar = i > 0 ? text[i - 1] : '';
531
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
422
532
  // Skip if escaped with backslash
423
533
  if (prevChar === '\\') {
424
- return acc;
534
+ continue;
425
535
  }
536
+ // Skip if part of double tilde
426
537
  if (prevChar !== '~' && nextChar !== '~') {
427
- return acc + 1;
538
+ count++;
428
539
  }
429
540
  }
430
- return acc;
431
- }, 0);
541
+ }
542
+ return count;
432
543
  };
433
544
  // Completes incomplete subscript formatting (~)
434
545
  const handleIncompleteSub = (text) => {
@@ -438,7 +549,7 @@ const handleIncompleteSub = (text) => {
438
549
  }
439
550
  const singleTildes = countSingleTildes(text);
440
551
  if (singleTildes % 2 === 1) {
441
- // Find the last unmatched tilde and add content until we find a good place to close
552
+ // Find the last unmatched tilde
442
553
  const lastTildeIndex = text.lastIndexOf('~');
443
554
  if (lastTildeIndex !== -1) {
444
555
  // Check if the tilde is within a math block - if so, don't process
@@ -450,44 +561,31 @@ const handleIncompleteSub = (text) => {
450
561
  if (!afterTilde || /^[\s_~*`^]*$/.test(afterTilde)) {
451
562
  return text;
452
563
  }
453
- // Find the end of the subscript content
454
- // For typical sub/sup patterns, look for single numbers, letters, or simple expressions
455
- const endMatch = afterTilde.match(/^([0-9]+|[a-zA-Z]|[a-zA-Z0-9]{1,3})(?=\s|[^a-zA-Z0-9]|$)/);
456
- if (endMatch) {
457
- const contentLength = endMatch[1].length;
458
- const insertPosition = lastTildeIndex + 1 + contentLength;
459
- return text.substring(0, insertPosition) + '~' + text.substring(insertPosition);
460
- }
461
- else {
462
- // Fallback: if no clear boundary, treat everything until space as content
463
- const spaceMatch = afterTilde.match(/^([^\s]+)/);
464
- if (spaceMatch) {
465
- const contentLength = spaceMatch[1].length;
466
- const insertPosition = lastTildeIndex + 1 + contentLength;
467
- return text.substring(0, insertPosition) + '~' + text.substring(insertPosition);
468
- }
469
- }
564
+ // Find the end of the line containing the incomplete subscript marker
565
+ const endOfLine = findEndOfLineContaining(text, lastTildeIndex);
566
+ return text.substring(0, endOfLine) + '~' + text.substring(endOfLine);
470
567
  }
471
568
  }
472
569
  return text;
473
570
  };
474
571
  // Counts single carets that are not escaped and not in footnote references
475
572
  const countSingleCarets = (text) => {
476
- return text.split('').reduce((acc, char, index) => {
477
- if (char === '^') {
478
- const prevChar = text[index - 1];
573
+ let count = 0;
574
+ for (let i = 0; i < text.length; i++) {
575
+ if (text[i] === '^') {
576
+ const prevChar = i > 0 ? text[i - 1] : '';
479
577
  // Skip if escaped with backslash
480
578
  if (prevChar === '\\') {
481
- return acc;
579
+ continue;
482
580
  }
483
581
  // Skip if within footnote reference
484
- if (isWithinFootnoteRef(text, index)) {
485
- return acc;
582
+ if (isWithinFootnoteRef(text, i)) {
583
+ continue;
486
584
  }
487
- return acc + 1;
585
+ count++;
488
586
  }
489
- return acc;
490
- }, 0);
587
+ }
588
+ return count;
491
589
  };
492
590
  // Completes incomplete superscript formatting (^)
493
591
  const handleIncompleteSup = (text) => {
@@ -497,7 +595,7 @@ const handleIncompleteSup = (text) => {
497
595
  }
498
596
  const singleCarets = countSingleCarets(text);
499
597
  if (singleCarets % 2 === 1) {
500
- // Find the last unmatched caret and add content until we find a good place to close
598
+ // Find the last unmatched caret
501
599
  const lastCaretIndex = text.lastIndexOf('^');
502
600
  if (lastCaretIndex !== -1) {
503
601
  // Check if the caret is within a math block - if so, don't process
@@ -513,43 +611,83 @@ const handleIncompleteSup = (text) => {
513
611
  if (!afterCaret || /^[\s_~*`^]*$/.test(afterCaret)) {
514
612
  return text;
515
613
  }
516
- // Find the end of the superscript content
517
- // For typical sub/sup patterns, look for single numbers, letters, or simple expressions
518
- const endMatch = afterCaret.match(/^([0-9]+|[a-zA-Z]|[a-zA-Z0-9]{1,3})(?=\s|[^a-zA-Z0-9]|$)/);
519
- if (endMatch) {
520
- const contentLength = endMatch[1].length;
521
- const insertPosition = lastCaretIndex + 1 + contentLength;
522
- return text.substring(0, insertPosition) + '^' + text.substring(insertPosition);
523
- }
524
- else {
525
- // Fallback: if no clear boundary, treat everything until space as content
526
- const spaceMatch = afterCaret.match(/^([^\s]+)/);
527
- if (spaceMatch) {
528
- const contentLength = spaceMatch[1].length;
529
- const insertPosition = lastCaretIndex + 1 + contentLength;
530
- return text.substring(0, insertPosition) + '^' + text.substring(insertPosition);
531
- }
532
- }
614
+ // Find the end of the line containing the incomplete superscript marker
615
+ const endOfLine = findEndOfLineContaining(text, lastCaretIndex);
616
+ return text.substring(0, endOfLine) + '^' + text.substring(endOfLine);
533
617
  }
534
618
  }
535
619
  return text;
536
620
  };
537
- // Counts single dollar signs that are not part of double dollar signs and not escaped
538
- const _countSingleDollarSigns = (text) => {
539
- return text.split('').reduce((acc, char, index) => {
540
- if (char === '$') {
541
- const prevChar = text[index - 1];
542
- const nextChar = text[index + 1];
621
+ // Counts single dollar signs that are not part of double dollar signs, not escaped, and not currency
622
+ const countSingleDollarSigns = (text) => {
623
+ let count = 0;
624
+ for (let i = 0; i < text.length; i++) {
625
+ if (text[i] === '$') {
626
+ const prevChar = i > 0 ? text[i - 1] : '';
627
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
543
628
  // Skip if escaped with backslash
544
629
  if (prevChar === '\\') {
545
- return acc;
630
+ continue;
631
+ }
632
+ // Skip if part of double dollar
633
+ if (prevChar === '$' || nextChar === '$') {
634
+ continue;
546
635
  }
547
- if (prevChar !== '$' && nextChar !== '$') {
548
- return acc + 1;
636
+ // Skip if this looks like currency (digit immediately after $)
637
+ if (nextChar && /\d/.test(nextChar)) {
638
+ continue;
549
639
  }
640
+ count++;
550
641
  }
551
- return acc;
552
- }, 0);
642
+ }
643
+ return count;
644
+ };
645
+ // Completes incomplete inline math formatting ($)
646
+ const handleIncompleteInlineMath = (text) => {
647
+ // Don't process if inside a complete code block
648
+ if (hasCompleteCodeBlock(text)) {
649
+ return text;
650
+ }
651
+ // Count single dollar signs (excluding currency patterns)
652
+ const singleDollars = countSingleDollarSigns(text);
653
+ // If we have an odd number of single dollars, we need to complete
654
+ if (singleDollars % 2 === 1) {
655
+ // Find the last unmatched dollar sign
656
+ let lastDollarIndex = -1;
657
+ for (let i = text.length - 1; i >= 0; i--) {
658
+ if (text[i] === '$') {
659
+ const prevChar = i > 0 ? text[i - 1] : '';
660
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
661
+ // Skip if escaped or part of double dollar
662
+ if (prevChar === '\\' || prevChar === '$' || nextChar === '$') {
663
+ continue;
664
+ }
665
+ // Skip if this looks like currency
666
+ if (nextChar && /\d/.test(nextChar)) {
667
+ continue;
668
+ }
669
+ lastDollarIndex = i;
670
+ break;
671
+ }
672
+ }
673
+ if (lastDollarIndex !== -1) {
674
+ const afterDollar = text.substring(lastDollarIndex + 1);
675
+ // Don't complete if there's no meaningful content after the dollar
676
+ if (!afterDollar || /^[\s$]*$/.test(afterDollar)) {
677
+ return text;
678
+ }
679
+ // Check if content after dollar looks like math (contains letters, spaces, symbols)
680
+ // but not pure numbers (which would be currency)
681
+ if (/^\d+(\.\d{2})?\s*$/.test(afterDollar.trim())) {
682
+ // This looks like currency, don't complete
683
+ return text;
684
+ }
685
+ // Find the end of the line containing the incomplete inline math marker
686
+ const endOfLine = findEndOfLineContaining(text, lastDollarIndex);
687
+ return text.substring(0, endOfLine) + '$' + text.substring(endOfLine);
688
+ }
689
+ }
690
+ return text;
553
691
  };
554
692
  // Completes incomplete block KaTeX formatting ($$)
555
693
  const handleIncompleteBlockKatex = (text) => {
@@ -573,13 +711,19 @@ const handleIncompleteBlockKatex = (text) => {
573
711
  // Counts triple asterisks that are not part of quadruple or more asterisks
574
712
  const countTripleAsterisks = (text) => {
575
713
  let count = 0;
576
- const matches = text.match(/\*+/g) || [];
577
- for (const match of matches) {
578
- // Count how many complete triple asterisks are in this sequence
579
- const asteriskCount = match.length;
580
- if (asteriskCount >= 3) {
714
+ for (let i = 0; i < text.length; i++) {
715
+ if (text[i] === '*') {
716
+ // Count consecutive asterisks
717
+ let asteriskCount = 0;
718
+ while (i < text.length && text[i] === '*') {
719
+ asteriskCount++;
720
+ i++;
721
+ }
722
+ i--; // Adjust back one position since the outer loop will increment
581
723
  // Each group of exactly 3 asterisks counts as one triple asterisk marker
582
- count += Math.floor(asteriskCount / 3);
724
+ if (asteriskCount >= 3) {
725
+ count += Math.floor(asteriskCount / 3);
726
+ }
583
727
  }
584
728
  }
585
729
  return count;
@@ -604,40 +748,91 @@ const handleIncompleteBoldItalic = (text) => {
604
748
  if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
605
749
  return text;
606
750
  }
751
+ // Check if this is a standalone horizontal rule (*** on a line by itself)
752
+ const matchIndex = text.lastIndexOf('***');
753
+ const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
754
+ const lineEnd = text.indexOf('\n', matchIndex);
755
+ const lineContent = text.substring(lineStart, lineEnd === -1 ? text.length : lineEnd);
756
+ const trimmedLine = lineContent.trim();
757
+ // If the line contains only *** (possibly with whitespace), it's a horizontal rule
758
+ if (trimmedLine === '***') {
759
+ return text;
760
+ }
607
761
  const tripleAsteriskCount = countTripleAsterisks(text);
608
762
  if (tripleAsteriskCount % 2 === 1) {
609
- return `${text}***`;
763
+ // Find the position of the last *** marker
764
+ const lastTripleAsteriskIndex = text.lastIndexOf('***');
765
+ // Find the end of the line containing the incomplete bold-italic marker
766
+ const endOfLine = findEndOfLineContaining(text, lastTripleAsteriskIndex);
767
+ return text.substring(0, endOfLine) + '***' + text.substring(endOfLine);
610
768
  }
611
769
  }
612
770
  return text;
613
771
  };
772
+ // Handles incomplete code blocks by leaving them unchanged
773
+ const handleIncompleteCodeBlock = (text) => {
774
+ // Check if we have an incomplete code block (odd number of triple backticks with newlines)
775
+ const tripleBackticks = (text.match(/```/g) || []).length;
776
+ // If we have an odd number of triple backticks and the text contains newlines,
777
+ // this is likely an incomplete code block - leave it unchanged
778
+ if (tripleBackticks % 2 === 1 && text.includes('\n')) {
779
+ return text;
780
+ }
781
+ return text;
782
+ };
783
+ // Handles incomplete footnote references
784
+ const handleIncompleteFootnotes = (text) => {
785
+ // Check for incomplete footnote references like [^ or [^label
786
+ // Match [^ followed by optional simple label, but ensure no ] follows immediately
787
+ // and no complex characters that would indicate this isn't a footnote
788
+ const footnotePattern = /\[\^([a-zA-Z0-9_-]*)(?![^\]\s,])/g;
789
+ let result = text;
790
+ let match;
791
+ while ((match = footnotePattern.exec(text)) !== null) {
792
+ const fullMatch = match[0];
793
+ const label = match[1];
794
+ // Check if this is at the end of a line or followed by simple separators
795
+ const matchIndex = match.index;
796
+ const afterMatch = text.substring(matchIndex + fullMatch.length);
797
+ // If followed by end of string, newline, space, or comma, it's likely a footnote
798
+ if (afterMatch === '' ||
799
+ /^\s/.test(afterMatch) ||
800
+ afterMatch.startsWith('\n') ||
801
+ afterMatch.startsWith(',')) {
802
+ const marker = 'streamdown-footnote';
803
+ const replacement = `[^${marker}]`;
804
+ result = result.replace(fullMatch, replacement);
805
+ break; // Only process the first match to avoid conflicts
806
+ }
807
+ }
808
+ return result;
809
+ };
614
810
  // Parses markdown text and removes incomplete tokens to prevent partial rendering
615
811
  export const parseIncompleteMarkdown = (text) => {
616
812
  if (!text || typeof text !== 'string') {
617
813
  return text;
618
814
  }
619
815
  let result = text;
620
- // Handle incomplete links and images first
621
- const processedResult = handleIncompleteLinksAndImages(result);
622
- // If we added an incomplete link marker, don't process other formatting
623
- // as the content inside the link should be preserved as-is
624
- if (processedResult.endsWith('](streamdown:incomplete-link)')) {
625
- return processedResult;
626
- }
627
- result = processedResult;
628
- // Handle various formatting completions
629
- // Handle triple asterisks first (most specific)
630
- result = handleIncompleteBoldItalic(result);
631
- result = handleIncompleteBold(result);
632
- result = handleIncompleteDoubleUnderscoreItalic(result);
633
- result = handleIncompleteSingleAsteriskItalic(result);
634
- result = handleIncompleteSingleUnderscoreItalic(result);
635
- result = handleIncompleteInlineCode(result);
636
- result = handleIncompleteStrikethrough(result);
637
- result = handleIncompleteSub(result);
638
- result = handleIncompleteSup(result);
639
- // Handle KaTeX formatting (only block math with $$)
816
+ // Handle incomplete code blocks FIRST - this prevents other formatters
817
+ // from processing content that should be treated as literal code
818
+ result = handleIncompleteCodeBlock(result);
819
+ // Handle incomplete footnotes FIRST (before any other processing to avoid conflicts)
820
+ result = handleIncompleteFootnotes(result);
821
+ // Handle various formatting completions ONLY if not inside a code block
822
+ // Handle double patterns before single patterns for proper priority
823
+ result = handleIncompleteBoldItalic(result); // ***
824
+ result = handleIncompleteBold(result); // **
825
+ result = handleIncompleteDoubleUnderscoreItalic(result); // __
826
+ result = handleIncompleteStrikethrough(result); // ~~
827
+ result = handleIncompleteInlineCode(result); // `
828
+ result = handleIncompleteSingleAsteriskItalic(result); // *
829
+ result = handleIncompleteSingleUnderscoreItalic(result); // _
830
+ result = handleIncompleteSub(result); // ~
831
+ result = handleIncompleteSup(result); // ^
832
+ // Handle KaTeX formatting
640
833
  result = handleIncompleteBlockKatex(result);
641
- // Note: We don't handle inline KaTeX with single $ as they're likely currency symbols
834
+ result = handleIncompleteInlineMath(result);
835
+ // Handle incomplete links and images LAST
836
+ result = handleIncompleteLinksAndImages(result);
642
837
  return result;
643
838
  };