svelte-streamdown 3.0.0 → 3.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.
@@ -90,12 +90,9 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
90
90
  const processedCells = [];
91
91
  // Track colspan cells that need rowspan
92
92
  const colspanCells = new Map();
93
- // First pass: Process each cell's colspan and merge consecutive empty cells
93
+ // First pass: Process each cell's colspan
94
94
  let cellIndex = 0;
95
- const mergedIndices = new Set();
96
95
  for (i = 0; i < cells.length; i++) {
97
- if (mergedIndices.has(i))
98
- continue;
99
96
  trimmedCell = cells[i];
100
97
  let colspan = 1;
101
98
  // Check for colspan marker from consecutive pipes
@@ -104,24 +101,6 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
104
101
  trimmedCell = parts[0];
105
102
  colspan = parseInt(parts[1], 10);
106
103
  }
107
- else if (!trimmedCell.trim()) {
108
- // Fallback: merge empty run into previous cell (backward compatibility)
109
- let run = 1, k = i + 1;
110
- while (k < cells.length && !cells[k].trim()) {
111
- run++;
112
- mergedIndices.add(k++);
113
- }
114
- if (processedCells.length) {
115
- const target = processedCells[processedCells.length - 1];
116
- const allowed = maxColspan != null ? Math.min(run, Math.max(0, maxColspan - target.colspan)) : run;
117
- target.colspan += allowed;
118
- numCols += allowed;
119
- continue;
120
- }
121
- else {
122
- colspan = maxColspan != null ? Math.min(run, maxColspan) : run;
123
- }
124
- }
125
104
  if (maxColspan !== null && colspan > maxColspan)
126
105
  colspan = maxColspan;
127
106
  processedCells[cellIndex] = {
@@ -141,8 +120,11 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
141
120
  // Check if it's a rowspan indicator (single ^ at end) vs superscript (^text^)
142
121
  const isRowspanIndicator = cellText.slice(-1) === '^' && !cellText.match(/\^[^^\n\r]+\^$/); // Not a superscript pattern ^text^
143
122
  if (isRowspanIndicator && prevRow.length > 0) {
144
- // Clean the ^ indicator from the cell text
123
+ // Clean the ^ indicator from the cell text. A cell that is nothing but
124
+ // carets (the usual `^^` continuation marker) carries no content.
145
125
  cell.text = cellText.slice(0, -1).trim();
126
+ if (/^\^*$/.test(cell.text))
127
+ cell.text = '';
146
128
  cellText = cell.text;
147
129
  let targetFound = false;
148
130
  const startPosition = cell.position || 0;
@@ -161,8 +143,10 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
161
143
  // If the cell spans exactly match, simple case
162
144
  if (cell.colspan === prevCell.colspan && cell.position === prevCell.position) {
163
145
  cell.rowSpanTarget = prevCell.rowSpanTarget ?? prevCell;
164
- // Only append text if it's different from the target cell
165
- const textToAppend = cell.text.slice(0, -1).trim();
146
+ // Only append text if it's different from the target cell.
147
+ // cell.text was already cleaned of its ^ indicator above —
148
+ // slicing again here used to drop the last real character.
149
+ const textToAppend = cell.text.trim();
166
150
  const targetText = cell.rowSpanTarget.text.trim();
167
151
  // Don't append if the text is the same or already contained (common case for rowspan indicators)
168
152
  if (textToAppend &&
@@ -188,8 +172,9 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
188
172
  else {
189
173
  // Standard case of single column cell with rowspan
190
174
  cell.rowSpanTarget = prevCell.rowSpanTarget ?? prevCell;
191
- // Only append text if it's different from the target cell
192
- const textToAppend = cell.text.slice(0, -1).trim();
175
+ // Only append text if it's different from the target cell.
176
+ // cell.text was already cleaned of its ^ indicator above.
177
+ const textToAppend = cell.text.trim();
193
178
  const targetText = cell.rowSpanTarget.text.trim();
194
179
  // Don't append if the text is the same or already contained (common case for rowspan indicators)
195
180
  if (textToAppend && textToAppend !== targetText && !targetText.includes(textToAppend)) {
@@ -202,13 +187,8 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
202
187
  }
203
188
  }
204
189
  }
205
- // If no target was found but it's a rowspan cell, clean the ^ indicator
206
- if (!targetFound && cell.rowspan > 0) {
207
- // Only clean if it was actually a rowspan indicator, not superscript
208
- if (isRowspanIndicator) {
209
- cell.text = cell.text.slice(0, -1);
210
- }
211
- }
190
+ // No target found: cell.text was already cleaned of its ^ indicator
191
+ // above, so the cell simply renders as a normal cell.
212
192
  }
213
193
  }
214
194
  // Process any complex colspan+rowspan combinations we tracked
@@ -108,12 +108,15 @@ export class IncompleteMarkdownParser {
108
108
  let inMathBlock = false;
109
109
  let inCenterBlock = false;
110
110
  let inRightBlock = false;
111
+ let centerOpenLine = -1;
112
+ let rightOpenLine = -1;
111
113
  // Track which lines are in which contexts for state management
112
114
  const lineContexts = [];
113
115
  for (let i = 0; i < lines.length; i++) {
114
116
  const line = lines[i];
115
- // Check for block boundaries
116
- if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
117
+ // Check for block boundaries (fences may be quoted inside blockquotes/alerts: "> ```")
118
+ const fenceLine = line.replace(/^[ \t]*(?:>[ \t]*)*/, '');
119
+ if (fenceLine.startsWith('```') || fenceLine.startsWith('~~~')) {
117
120
  inCodeBlock = !inCodeBlock;
118
121
  }
119
122
  if (line.trim().startsWith('$$') && !line.trim().includes('$$', 2)) {
@@ -121,12 +124,14 @@ export class IncompleteMarkdownParser {
121
124
  }
122
125
  if (line.trim() === '[center]') {
123
126
  inCenterBlock = true;
127
+ centerOpenLine = i;
124
128
  }
125
129
  if (line.trim() === '[/center]') {
126
130
  inCenterBlock = false;
127
131
  }
128
132
  if (line.trim() === '[right]') {
129
133
  inRightBlock = true;
134
+ rightOpenLine = i;
130
135
  }
131
136
  if (line.trim() === '[/right]') {
132
137
  inRightBlock = false;
@@ -144,9 +149,11 @@ export class IncompleteMarkdownParser {
144
149
  finalContexts.add('code');
145
150
  if (inMathBlock)
146
151
  finalContexts.add('math');
147
- if (inCenterBlock)
152
+ // Only auto-close center/right when content follows the opening tag;
153
+ // a bare trailing '[center]'/'[right]' line is left untouched.
154
+ if (inCenterBlock && centerOpenLine < lines.length - 1)
148
155
  finalContexts.add('center');
149
- if (inRightBlock)
156
+ if (inRightBlock && rightOpenLine < lines.length - 1)
150
157
  finalContexts.add('right');
151
158
  // Return both the text and the updated state
152
159
  return {
@@ -158,20 +165,22 @@ export class IncompleteMarkdownParser {
158
165
  };
159
166
  },
160
167
  postprocess: ({ text, state }) => {
161
- // Complete incomplete blocks at end of input
168
+ // Complete incomplete blocks at end of input.
169
+ // Close inner blocks (code/math) before alignment wrappers.
170
+ let result = text;
162
171
  if (state.blockingContexts.has('code')) {
163
- return text + '\n```';
172
+ result += '\n```';
164
173
  }
165
174
  if (state.blockingContexts.has('math')) {
166
- return text + '\n$$';
175
+ result += '\n$$';
167
176
  }
168
177
  if (state.blockingContexts.has('center')) {
169
- return text + '\n[/center]';
178
+ result += '\n[/center]';
170
179
  }
171
180
  if (state.blockingContexts.has('right')) {
172
- return text + '\n[/right]';
181
+ result += '\n[/right]';
173
182
  }
174
- return text;
183
+ return result;
175
184
  }
176
185
  },
177
186
  {
@@ -190,7 +199,13 @@ export class IncompleteMarkdownParser {
190
199
  if (isEndingWithTripleAsterisk) {
191
200
  return line.substring(0, lastTripleAsteriskIndex);
192
201
  }
193
- return line.substring(0, endOfCellOrLine) + '***' + line.substring(endOfCellOrLine);
202
+ const before = line.substring(0, endOfCellOrLine);
203
+ // Part of the closing '***' may have already arrived (a trailing '*' or
204
+ // '**'); only add the missing asterisks so we complete to exactly '***'
205
+ // instead of leaving stray asterisks after the text.
206
+ const trailing = before.match(/\*+$/)?.[0].length ?? 0;
207
+ const missing = trailing >= 1 && trailing <= 2 ? 3 - trailing : 3;
208
+ return before + '*'.repeat(missing) + line.substring(endOfCellOrLine);
194
209
  }
195
210
  return line;
196
211
  }
@@ -211,7 +226,12 @@ export class IncompleteMarkdownParser {
211
226
  if (isEndingWithDoubleAsterisk) {
212
227
  return line.substring(0, lastDoubleAsteriskIndex);
213
228
  }
214
- return line.substring(0, endOfCellOrLine) + '**' + line.substring(endOfCellOrLine);
229
+ const before = line.substring(0, endOfCellOrLine);
230
+ // If the content already ends with a single '*' — the first half of the
231
+ // closing '**' arriving mid-stream — only add one more '*' to complete it.
232
+ // Otherwise we'd emit '***' and leave a stray '*' after the bold text.
233
+ const closing = before.endsWith('*') && !before.endsWith('**') ? '*' : '**';
234
+ return before + closing + line.substring(endOfCellOrLine);
215
235
  }
216
236
  return line;
217
237
  }
@@ -232,7 +252,11 @@ export class IncompleteMarkdownParser {
232
252
  if (isEndingWithDoubleUnderscore) {
233
253
  return line.substring(0, lastDoubleUnderscoreIndex);
234
254
  }
235
- return line.substring(0, endOfCellOrLine) + '__' + line.substring(endOfCellOrLine);
255
+ const before = line.substring(0, endOfCellOrLine);
256
+ // A half-typed closing '__' (a lone trailing '_') only needs one more '_',
257
+ // not a full '__' that would leave a stray underscore after the text.
258
+ const closing = before.endsWith('_') && !before.endsWith('__') ? '_' : '__';
259
+ return before + closing + line.substring(endOfCellOrLine);
236
260
  }
237
261
  return line;
238
262
  }
@@ -253,7 +277,10 @@ export class IncompleteMarkdownParser {
253
277
  if (isEndingWithDoubleTilde) {
254
278
  return line.substring(0, lastDoubleTildeIndex);
255
279
  }
256
- return line.substring(0, endOfCellOrLine) + '~~' + line.substring(endOfCellOrLine);
280
+ const before = line.substring(0, endOfCellOrLine);
281
+ // A half-typed closing '~~' (a lone trailing '~') only needs one more '~'.
282
+ const closing = before.endsWith('~') && !before.endsWith('~~') ? '~' : '~~';
283
+ return before + closing + line.substring(endOfCellOrLine);
257
284
  }
258
285
  }
259
286
  return line;
@@ -435,40 +462,73 @@ export class IncompleteMarkdownParser {
435
462
  return line;
436
463
  }
437
464
  },
465
+ {
466
+ // Must run before inlineCitation: otherwise a trailing `[^label` gets
467
+ // closed with a plain `]`, defeating the streamdown:footnote marker.
468
+ name: 'footnoteRef',
469
+ pattern: /\[\^[^\]\s,]*/,
470
+ skipInBlockTypes: ['code', 'math'],
471
+ handler: ({ line }) => {
472
+ if (!line.includes(']')) {
473
+ return line.replace(/\[\^[^\]\s,]*/, '[^streamdown:footnote]');
474
+ }
475
+ return line;
476
+ }
477
+ },
438
478
  {
439
479
  name: 'inlineCitation',
440
480
  pattern: /\[/,
441
481
  skipInBlockTypes: ['code', 'math'],
442
482
  handler: ({ line }) => {
443
- // Count unescaped opening brackets without matching closing brackets
444
- let unclosedBrackets = 0;
483
+ // Lines that already contain link/image "](" syntax belong to the
484
+ // linksAndImages plugin: completing or preserving them is its job.
485
+ if (line.includes('](')) {
486
+ return line;
487
+ }
488
+ // Collect unescaped opening brackets without a matching closing bracket
489
+ const unclosedPositions = [];
445
490
  for (let i = 0; i < line.length; i++) {
446
491
  if (line[i] === '[' && (i === 0 || line[i - 1] !== '\\')) {
447
492
  // Check if this bracket has a matching closing bracket later in the line
448
- const restOfLine = line.substring(i + 1);
449
- const closingIndex = restOfLine.indexOf(']');
450
- if (closingIndex === -1) {
451
- unclosedBrackets++;
493
+ if (line.indexOf(']', i + 1) === -1) {
494
+ unclosedPositions.push(i);
452
495
  }
453
496
  }
454
497
  }
455
- // If there's an odd number of unclosed brackets, add closing bracket
456
- if (unclosedBrackets % 2 === 1) {
457
- const endOfCellOrLine = findEndOfCellOrLineContaining(line, line.length - 1);
458
- return line.substring(0, endOfCellOrLine) + ']' + line.substring(endOfCellOrLine);
459
- }
460
- return line;
461
- }
462
- },
463
- {
464
- name: 'footnoteRef',
465
- pattern: /\[\^[^\]\s,]*/,
466
- skipInBlockTypes: ['code', 'math'],
467
- handler: ({ line }) => {
468
- if (!line.includes(']')) {
469
- return line.replace(/\[\^[^\]\s,]*/, '[^streamdown:footnote]');
498
+ // Close every unclosed citation bracket (right to left so indices stay
499
+ // valid). Brackets that look like incomplete images (`![`), footnotes
500
+ // (`[^`), link text containing markdown formatting, table-cell content,
501
+ // or a trailing bracket preceded by a completed `[...]` pair (evidence
502
+ // of an in-progress link) are left for the dedicated plugins
503
+ // (footnoteRef, linksAndImages).
504
+ let result = line;
505
+ for (let k = unclosedPositions.length - 1; k >= 0; k--) {
506
+ const pos = unclosedPositions[k];
507
+ const endOfCellOrLine = findEndOfCellOrLineContaining(result, pos);
508
+ const content = result.substring(pos + 1, endOfCellOrLine);
509
+ const isImage = pos > 0 && result[pos - 1] === '!';
510
+ const isFootnote = content.startsWith('^');
511
+ const hasFormatting = /[*~`_]/.test(content);
512
+ const isTableCell = endOfCellOrLine < result.length && result[endOfCellOrLine] === '|';
513
+ const hasPriorCompletedPair = /\[[^\]]*\]/.test(line.substring(0, pos));
514
+ if (isImage || isFootnote || hasFormatting || isTableCell || hasPriorCompletedPair) {
515
+ continue;
516
+ }
517
+ if (k === unclosedPositions.length - 1) {
518
+ // Last bracket: close at end of cell/line (keeps multi-key citations together)
519
+ result =
520
+ result.substring(0, endOfCellOrLine) + ']' + result.substring(endOfCellOrLine);
521
+ }
522
+ else {
523
+ // Earlier brackets: close right after the citation key (first word)
524
+ const keyMatch = content.match(/^\s*\S+/);
525
+ if (keyMatch) {
526
+ const insertAt = pos + 1 + keyMatch[0].length;
527
+ result = result.substring(0, insertAt) + ']' + result.substring(insertAt);
528
+ }
529
+ }
470
530
  }
471
- return line;
531
+ return result;
472
532
  }
473
533
  },
474
534
  {
@@ -638,8 +698,11 @@ export class IncompleteMarkdownParser {
638
698
  const linkMatch = line.match(/(!?\[)([^\]]*?)$/);
639
699
  if (linkMatch && !line.includes('](')) {
640
700
  const [, openBracket, linkTextWithPossibleBoundary] = linkMatch;
641
- // Find the position of the opening bracket
642
- const bracketIndex = line.lastIndexOf(openBracket);
701
+ // Position of the matched opening bracket (the regex matches the first
702
+ // bracket that stays unclosed through the end of the line). Using the
703
+ // match index keeps the replacement aligned with the captured link text;
704
+ // `lastIndexOf` could point at a different bracket and duplicate text.
705
+ const bracketIndex = linkMatch.index ?? 0;
643
706
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, bracketIndex);
644
707
  // Extract the clean link text (remove any trailing | or whitespace)
645
708
  const linkText = linkTextWithPossibleBoundary.replace(/[\s|]+$/, '');
@@ -649,24 +712,8 @@ export class IncompleteMarkdownParser {
649
712
  // Replace from bracket to end of cell/line, including boundary if it's |
650
713
  const includeBoundary = endOfCellOrLine < line.length && line[endOfCellOrLine] === '|';
651
714
  const incompleteEnd = includeBoundary ? endOfCellOrLine + 1 : endOfCellOrLine;
652
- const incompletePart = line.substring(bracketIndex, incompleteEnd);
653
715
  const completedPart = openBracket + linkText + '](' + marker + ')' + (includeBoundary ? '|' : '');
654
- return line.replace(incompletePart, completedPart);
655
- }
656
- return line;
657
- }
658
- },
659
- {
660
- name: 'alignmentBlocks',
661
- pattern: /^(\s*\[(center|right)\])$/,
662
- skipInBlockTypes: ['code', 'math'],
663
- handler: ({ line, state }) => {
664
- // Check if this is an opening alignment tag without content or closing tag
665
- const alignMatch = line.match(/^(\s*\[(center|right)\])$/);
666
- if (alignMatch) {
667
- const indent = alignMatch[1].length - alignMatch[1].trim().length;
668
- const alignType = alignMatch[2];
669
- return line + '\n' + ' '.repeat(indent) + '[/' + alignType + ']';
716
+ return line.substring(0, bracketIndex) + completedPart + line.substring(incompleteEnd);
670
717
  }
671
718
  return line;
672
719
  }
@@ -674,12 +721,19 @@ export class IncompleteMarkdownParser {
674
721
  {
675
722
  name: 'mdx',
676
723
  skipInBlockTypes: ['code', 'math', 'center', 'right'],
677
- preprocess: ({ text }) => {
724
+ preprocess: ({ text, state }) => {
678
725
  // Track MDX component states across the entire text
679
726
  const lines = text.split('\n');
680
727
  const openTags = [];
681
728
  let mdxLineStates = [];
682
729
  for (let i = 0; i < lines.length; i++) {
730
+ // Lines inside code fences or math blocks are opaque content: MDX-looking
731
+ // tags there must not open/close/track components.
732
+ const lineCtx = state.lineContexts?.[i];
733
+ if (lineCtx?.code || lineCtx?.math) {
734
+ mdxLineStates[i] = { inMdx: false, incompletePositions: [] };
735
+ continue;
736
+ }
683
737
  const line = lines[i];
684
738
  let inMdx = false;
685
739
  let incompletePositions = [];
@@ -691,6 +745,23 @@ export class IncompleteMarkdownParser {
691
745
  if (tagStart === -1 || tagStart >= line.length - 1)
692
746
  break;
693
747
  const nextChar = line[tagStart + 1];
748
+ // Closing tag for a component opened on an earlier line. Handled
749
+ // inside the scan so a close that is part of a same-line complete
750
+ // pair (consumed below) is never double-counted against the stack.
751
+ const closeTagMatch = line.substring(tagStart).match(/^<\/([A-Z][a-zA-Z0-9]*)>/);
752
+ if (closeTagMatch) {
753
+ const tagName = closeTagMatch[1];
754
+ // Pop the innermost same-name open (LIFO) so the auto-appended
755
+ // closers keep the right nesting order.
756
+ for (let openIndex = openTags.length - 1; openIndex >= 0; openIndex--) {
757
+ if (openTags[openIndex].tagName === tagName) {
758
+ openTags.splice(openIndex, 1);
759
+ break;
760
+ }
761
+ }
762
+ searchPos = tagStart + closeTagMatch[0].length;
763
+ continue;
764
+ }
694
765
  // Only match if starts with capital letter (MDX component)
695
766
  if (!/[A-Z]/.test(nextChar)) {
696
767
  searchPos = tagStart + 1;
@@ -741,16 +812,6 @@ export class IncompleteMarkdownParser {
741
812
  }
742
813
  searchPos = tagStart + 1;
743
814
  }
744
- // Check for closing tags
745
- const closeTagMatches = line.matchAll(/<\/([A-Z][a-zA-Z0-9]*)>/g);
746
- for (const closeMatch of closeTagMatches) {
747
- const tagName = closeMatch[1];
748
- // Find and remove the matching open tag
749
- const openIndex = openTags.findIndex((t) => t.tagName === tagName);
750
- if (openIndex !== -1) {
751
- openTags.splice(openIndex, 1);
752
- }
753
- }
754
815
  mdxLineStates[i] = { inMdx, incompletePositions };
755
816
  }
756
817
  return {
package/dist/utils/url.js CHANGED
@@ -39,6 +39,12 @@ export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
39
39
  const urlString = parseUrl(url);
40
40
  if (urlString &&
41
41
  allowedPrefixes.some((prefix) => {
42
+ // Protocol-only prefixes (e.g. 'https://', 'http://', 'mailto:') allow any
43
+ // URL using that protocol. They are not valid absolute URLs on their own
44
+ // (new URL('https://') throws), so we match them with a simple prefix check.
45
+ if (prefix.endsWith('://') || (prefix.endsWith(':') && !prefix.includes('//'))) {
46
+ return urlString.href.startsWith(prefix);
47
+ }
42
48
  const parsedPrefix = parseUrl(prefix);
43
49
  if (!parsedPrefix) {
44
50
  return false;
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
+ "packageManager": "pnpm@10.32.1",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/beynar/svelte-streamdown.git"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "provenance": true
12
+ },
4
13
  "scripts": {
5
14
  "dev": "vite dev",
6
15
  "build": "vite build && npm run prepack",
@@ -54,6 +63,7 @@
54
63
  "@sveltejs/package": "^2.0.0",
55
64
  "@sveltejs/vite-plugin-svelte": "^6.0.0",
56
65
  "@tailwindcss/vite": "^4.0.0",
66
+ "@types/node": "^25.9.2",
57
67
  "@vitest/browser": "^3.2.3",
58
68
  "@vitest/ui": "^3.2.4",
59
69
  "playwright": "^1.53.0",