svelte-streamdown 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -512,7 +512,7 @@ Prefixes can also be **protocol-only**, which allows any URL using that protocol
512
512
  ```
513
513
 
514
514
  > [!NOTE]
515
- > `'*'` allows all `http://` and `https://` URLs. A protocol-only prefix only allows that exact protocol, so list each one you want to permit. Only add a protocol you trust — e.g. do not add `'javascript:'`.
515
+ > `'*'` allows every `http:`, `https:`, `mailto:` and `tel:` URL — the protocols a document can legitimately link to. `javascript:`, `data:` and `vbscript:` stay blocked under the wildcard because they execute in the page's origin. A protocol-only prefix only allows that exact protocol, so list each one you want to permit. Only add a protocol you trust — e.g. do not add `'javascript:'`.
516
516
 
517
517
  ## 📦 Bundle Optimization
518
518
 
@@ -707,9 +707,13 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
707
707
  | `defaultOrigin` | `string` | - | Default origin for relative URLs |
708
708
  | `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
709
709
  | `allowedImagePrefixes` | `string[]` | `['*']` | Allowed URL prefixes for images |
710
- | `skipHtml` | `boolean` | - | Skip HTML parsing entirely |
711
- | `unwrapDisallowed` | `boolean` | - | Unwrap instead of removing disallowed elements |
712
- | `urlTransform` | `UrlTransform \| null` | - | Custom URL transformation function |
710
+ | `renderHtml` | `boolean \| ((token) => string)` | `false` | Render raw HTML blocks and inline tags. When off, the HTML source is shown as literal text instead of being dropped. Pass a function to sanitize and return the HTML string yourself. |
711
+ | `inlineCitationsMode` | `'list' \| 'carousel'` | `'carousel'` | How an inline citation popover presents its sources |
712
+ | `translations` | `{ alert?: { note?, tip?, warning?, caution?, important? } }` | - | Override the built-in alert titles |
713
+ | `icons` | `Partial<Record<IconName, Snippet>>` | - | Replace any built-in icon (`copy`, `check`, `download`, `fullscreen`, `zoomIn`, `zoomOut`, `fitView`, `chevronLeft`, `chevronRight`, `note`, `tip`, `warning`, `caution`, `important`) with your own snippet |
714
+ | `static` | `boolean` | `false` | Render finished content: skips the incomplete-markdown pass and the streaming animation |
715
+ | `element` | `HTMLElement` | - | `bind:element` to get the wrapper node |
716
+ | `streamdown` | `StreamdownContext` | - | `bind:streamdown` to read the resolved context (theme, controls, footnotes, sources) |
713
717
  | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
714
718
  | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
715
719
  | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
@@ -720,7 +724,7 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
720
724
  | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
721
725
  | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
722
726
  | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
723
- | `animation.type` | `'fade' \| 'blur' \| 'typewriter' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
727
+ | `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
724
728
  | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
725
729
  | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
726
730
  | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
@@ -739,7 +743,7 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
739
743
 
740
744
  **Lists**: `ul`, `ol`, `li`
741
745
 
742
- **Code**: `code`, `codeSpan`
746
+ **Code**: `code`, `codespan`
743
747
 
744
748
  **Tables**: `table`, `thead`, `tbody`, `tr`, `th`, `td`, `tfoot`
745
749
 
@@ -1103,6 +1107,10 @@ pnpm dev
1103
1107
  # Run tests
1104
1108
  pnpm test
1105
1109
 
1110
+ # Run the browser (component) tests — needs a Chromium binary:
1111
+ # pnpm exec playwright install chromium
1112
+ pnpm test:browser
1113
+
1106
1114
  # Build for production
1107
1115
  pnpm build
1108
1116
  ```
package/dist/Block.svelte CHANGED
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { parseIncompleteMarkdown } from './utils/parse-incomplete-markdown.js';
2
+ import { parseIncompleteMarkdown as completeMarkdown } from './utils/parse-incomplete-markdown.js';
3
3
  import Element from './Elements/Element.svelte';
4
4
  import { lex, type StreamdownToken } from './marked/index.js';
5
5
  import AnimatedText from './AnimatedText.svelte';
@@ -15,8 +15,11 @@
15
15
  } = $props();
16
16
 
17
17
  const streamdown = useStreamdown();
18
+ // The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
19
+ // is aliased so the context flag and the helper cannot be confused.
20
+ const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
18
21
  const tokens = $derived(
19
- lex(isStatic ? block : parseIncompleteMarkdown(block.trim()), streamdown.extensions)
22
+ lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
20
23
  );
21
24
  const insidePopover = getContext('POPOVER');
22
25
  </script>
@@ -174,7 +174,7 @@
174
174
  style:position="relative"
175
175
  style:transition-duration="200ms"
176
176
  style:transition-timing-function="ease-in-out"
177
- aria-label="Citations-${id}"
177
+ aria-label={'Citations-' + id}
178
178
  >
179
179
  <div
180
180
  bind:this={stepper.stepContainer}
@@ -193,7 +193,7 @@
193
193
  style:height="fit-content"
194
194
  style:width="100%"
195
195
  style:flex-grow="1"
196
- aria-label="Citation-${id}"
196
+ aria-label={'Citation-' + id}
197
197
  >
198
198
  <Slot render={streamdown.snippets.inlineCitationContent} props={{ source, key, token }}>
199
199
  {#if url || title}
@@ -3,22 +3,27 @@
3
3
  import { save } from '../utils/save.js';
4
4
  import { useCopy } from '../utils/copy.svelte.js';
5
5
  import { highlightLines, languageExtensionMap } from '../utils/hightlighter.svelte.js';
6
- import type { Tokens } from 'marked';
6
+ import type { CodeToken } from '../marked/index.js';
7
7
  import { checkIcon, copyIcon, downloadIcon } from './icons.js';
8
8
 
9
9
  const {
10
10
  token,
11
11
  id
12
12
  }: {
13
- token: Tokens.Code;
13
+ token: CodeToken;
14
14
  id: string;
15
15
  } = $props();
16
16
 
17
17
  const streamdown = useStreamdown();
18
18
 
19
+ // marked keeps the fence's trailing blank lines in `text`; they render as empty
20
+ // lines and, while streaming, flicker in and out on nearly every chunk. Render,
21
+ // copy and download all read this so they can never disagree.
22
+ const code = $derived(token.text.replace(/\n+$/, ''));
23
+
19
24
  const copy = useCopy({
20
25
  get content() {
21
- return token.text;
26
+ return code;
22
27
  }
23
28
  });
24
29
 
@@ -32,13 +37,13 @@
32
37
  : 'txt';
33
38
  const filename = `file.${extension}`;
34
39
  const mimeType = 'text/plain';
35
- save(filename, token.text, mimeType);
40
+ save(filename, code, mimeType);
36
41
  } catch (error) {
37
42
  console.error('Failed to download file:', error);
38
43
  }
39
44
  };
40
45
 
41
- const lines = $derived(highlightLines(token.text, token.lang, streamdown.highlightLanguages));
46
+ const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
42
47
  </script>
43
48
 
44
49
  <div
@@ -1,6 +1,6 @@
1
- import type { Tokens } from 'marked';
1
+ import type { CodeToken } from '../marked/index.js';
2
2
  type $$ComponentProps = {
3
- token: Tokens.Code;
3
+ token: CodeToken;
4
4
  id: string;
5
5
  };
6
6
  declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -288,12 +288,11 @@
288
288
  {@render children()}
289
289
  </dd>
290
290
  </Slot>
291
- {:else if token.type === 'def'}
292
- <!-- TODO This does not seems to be tokenized for now -->
291
+ {:else if token.type === 'def' || token.type === 'space'}
292
+ <!-- Link reference definitions and blank lines produce no output (CommonMark) -->
293
293
  {:else if token.type === 'escape'}
294
- <!-- TODO This does not seems to be tokenized for now -->
295
- {:else if token.type === 'space'}
296
- <!-- TODO This does not seems to be tokenized for now -->
294
+ <!-- `children` renders token.text, i.e. the escaped character itself -->
295
+ {@render children()}
297
296
  {:else if token.type === 'text'}
298
297
  {@render children()}
299
298
  {:else if token.type === 'html'}
@@ -301,6 +300,10 @@
301
300
  {@const content =
302
301
  typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
303
302
  {@html content}
303
+ {:else}
304
+ <!-- Without renderHtml the source is shown literally instead of being dropped;
305
+ `children` interpolates it as text, so Svelte escapes it — no XSS surface -->
306
+ {@render children()}
304
307
  {/if}
305
308
  {:else if token.type === 'mdx'}
306
309
  {@const Component = streamdown.mdxComponents?.[token.tagName]}
@@ -39,6 +39,7 @@
39
39
  <a
40
40
  data-streamdown-link={id}
41
41
  class={streamdown.theme.link.base}
42
+ title={token.title}
42
43
  {...isRelativeUrl
43
44
  ? { href: token.href }
44
45
  : { href: transformedUrl, target: '_blank', rel: 'noopener noreferrer' }}
@@ -50,7 +51,7 @@
50
51
  <span
51
52
  data-streamdown-link-blocked={id}
52
53
  class={streamdown.theme.link.blocked}
53
- title={token.title ? `Blocked URL: ${token.href}` : undefined}
54
+ title={`Blocked URL: ${token.href}`}
54
55
  >
55
56
  {@render children()} [blocked]
56
57
  </span>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
3
  import { useStreamdown } from '../context.svelte.js';
4
- import type { Tokens } from 'marked';
4
+ import type { CodeToken } from '../marked/index.js';
5
5
  import type { MermaidConfig } from 'mermaid';
6
6
  import { on } from 'svelte/events';
7
7
  import { usePanzoom } from '../utils/panzoom.svelte';
@@ -14,10 +14,15 @@
14
14
  token,
15
15
  id
16
16
  }: {
17
- token: Tokens.Code;
17
+ token: CodeToken;
18
18
  id: string;
19
19
  } = $props();
20
20
 
21
+ // Trailing blank lines are noise for mermaid but they still changed `token.text`
22
+ // on nearly every streamed chunk, which re-ran the whole render. Same trim as
23
+ // Code.svelte.
24
+ const chart = $derived(token.text.replace(/\n+$/, ''));
25
+
21
26
  let mermaid = $state<any>(null);
22
27
  onMount(async () => {
23
28
  mermaid = (await import('mermaid')).default;
@@ -217,7 +222,7 @@
217
222
  <div
218
223
  style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
219
224
  class={streamdown.theme.mermaid.base}
220
- {@attach (node) => renderMermaid(token.text, node)}
225
+ {@attach (node) => renderMermaid(chart, node)}
221
226
  {@attach insider.attach}
222
227
  data-expanded={'false'}
223
228
  >
@@ -1,6 +1,6 @@
1
- import type { Tokens } from 'marked';
1
+ import type { CodeToken } from '../marked/index.js';
2
2
  type $$ComponentProps = {
3
- token: Tokens.Code;
3
+ token: CodeToken;
4
4
  id: string;
5
5
  };
6
6
  declare const Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -156,7 +156,7 @@
156
156
 
157
157
  {#if popover.isOpen}
158
158
  <dialog
159
- id={'mermaid-download-popover'}
159
+ id={'mermaid-download-popover-' + id}
160
160
  aria-modal="false"
161
161
  transition:scale|global={{ start: 0.95, duration: 100 }}
162
162
  {@attach clickOutside.attachment}
@@ -46,6 +46,15 @@
46
46
  }
47
47
  });
48
48
 
49
+ // textContent flattens a real <br> element, so a multiline cell collapses to
50
+ // 'Paragraph one.Paragraph two.'. Walk the cell instead and keep the breaks;
51
+ // the quoting below then quotes the newline for us.
52
+ const extractCellText = (node: Node): string => {
53
+ if (node.nodeType === 3) return node.textContent || '';
54
+ if ((node as Element).tagName === 'BR') return '\n';
55
+ return Array.from(node.childNodes).map(extractCellText).join('');
56
+ };
57
+
49
58
  const copyOrDownload = (type: 'Markdown' | 'HTML' | 'CSV') => {
50
59
  if (type === 'Markdown') {
51
60
  copyValue = token.raw;
@@ -55,7 +64,7 @@
55
64
  save('table.md', copyValue, 'text/markdown');
56
65
  }
57
66
  } else if (type === 'HTML') {
58
- const table = document.querySelector(`[data-streamdown-table=${id}]`);
67
+ const table = document.querySelector(`[data-streamdown-table="${id}"]`);
59
68
 
60
69
  if (table) {
61
70
  let html = (table.cloneNode(true) as HTMLElement).outerHTML;
@@ -83,7 +92,7 @@
83
92
  }
84
93
  }
85
94
  } else if (type === 'CSV') {
86
- const table = document.querySelector(`[data-streamdown-table=${id}]`);
95
+ const table = document.querySelector(`[data-streamdown-table="${id}"]`);
87
96
 
88
97
  if (table) {
89
98
  const rows = table.querySelectorAll('tr');
@@ -98,9 +107,8 @@
98
107
  const colSpan = parseInt(cell.getAttribute('colspan') || '1');
99
108
  const rowSpan = parseInt(cell.getAttribute('rowspan') || '1');
100
109
 
101
- // Add the cell content
102
110
  // Add the cell content, quoting if it contains commas, quotes, or newlines
103
- const content = cell.textContent || '';
111
+ const content = extractCellText(cell);
104
112
  const needsQuoting = /[,"\n]/.test(content);
105
113
  const escapedContent = content.replace(/"/g, '""');
106
114
  rowData.push(needsQuoting ? `"${escapedContent}"` : content);
@@ -150,7 +158,7 @@
150
158
 
151
159
  {#if popover.isOpen}
152
160
  <dialog
153
- id={'table-download-popover'}
161
+ id={'table-download-popover-' + id}
154
162
  aria-modal="false"
155
163
  transition:scale|global={{ start: 0.95, duration: 100 }}
156
164
  {@attach clickOutside.attachment}
@@ -11,6 +11,12 @@
11
11
  } = $props();
12
12
 
13
13
  const streamdown = useStreamdown();
14
+
15
+ // Same trim as Code.svelte/Mermaid.svelte: marked keeps the fence's trailing
16
+ // blank lines in `text`, so they render as empty lines and flicker in and out
17
+ // on nearly every streamed chunk. This is the default renderer, so it needs it
18
+ // too.
19
+ const code = $derived(token.text.replace(/\n+$/, ''));
14
20
  </script>
15
21
 
16
22
  <div
@@ -23,7 +29,7 @@
23
29
  </div>
24
30
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
25
31
  <pre class={streamdown.theme.code.pre}><code
26
- >{#each token.text.split('\n') as line}<span class={streamdown.theme.code.line}
32
+ >{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
27
33
  ><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
28
34
  >{line.trim().length > 0 ? line : '\u200B'}</span
29
35
  ></span
@@ -11,6 +11,12 @@
11
11
  } = $props();
12
12
 
13
13
  const streamdown = useStreamdown();
14
+
15
+ // Same trim as Code.svelte/Mermaid.svelte: marked keeps the fence's trailing
16
+ // blank lines in `text`, so they render as empty lines and flicker in and out
17
+ // on nearly every streamed chunk. This is the default renderer, so it needs it
18
+ // too.
19
+ const chart = $derived(token.text.replace(/\n+$/, ''));
14
20
  </script>
15
21
 
16
22
  <div data-streamdown-mermaid={id}>
@@ -23,7 +29,7 @@
23
29
  </div>
24
30
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
25
31
  <pre class={streamdown.theme.code.pre}><code
26
- >{#each token.text.split('\n') as line}<span class={streamdown.theme.code.line}
32
+ >{#each chart.split('\n') as line}<span class={streamdown.theme.code.line}
27
33
  ><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
28
34
  >{line.trim().length > 0 ? line : '\u200B'}</span
29
35
  ></span
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { default as Streamdown } from './Streamdown.svelte';
2
2
  export { useStreamdown, type StreamdownProps } from './context.svelte.js';
3
3
  export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
4
- export { type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
4
+ export { type CodeToken, type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
5
5
  export { parseIncompleteMarkdown, type Plugin, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
6
6
  export { defineLanguage, type LanguageDefinition } from '@tanstack/highlight';
7
7
  export type { HighlightTheme } from '@tanstack/highlight/theme';
@@ -23,7 +23,14 @@ export type Extension = {
23
23
  start?: TokenizerStartFunction;
24
24
  applyInBlockParsing?: boolean;
25
25
  };
26
- export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table> | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
26
+ /**
27
+ * A fenced code block. `lang` is the first word of the info string; everything
28
+ * after it is `meta` (`undefined` when there is none).
29
+ */
30
+ export type CodeToken = Tokens.Code & {
31
+ meta?: string;
32
+ };
33
+ export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table | Tokens.Code> | CodeToken | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
27
34
  export type { TableToken, THead, TBody, TFoot, THeadRow, TRow, TH, TD } from './marked-table.js';
28
35
  export declare const lex: (markdown: string, extensions?: Extension[]) => StreamdownToken[];
29
36
  /**
@@ -1,4 +1,4 @@
1
- import { Lexer } from 'marked';
1
+ import { Lexer, Tokenizer } from 'marked';
2
2
  import { markedAlert } from './marked-alert.js';
3
3
  import { markedFootnote } from './marked-footnotes.js';
4
4
  import { markedMath } from './marked-math.js';
@@ -39,9 +39,42 @@ const DEFAULT_BLOCK_EXTENSIONS = [
39
39
  markedAlign,
40
40
  markedMdx
41
41
  ];
42
+ class StreamdownTokenizer extends Tokenizer {
43
+ /**
44
+ * marked keeps the whole fence info string in `lang`, so ```ts title="x" {1}
45
+ * highlighted as plaintext, labelled the header with the entire string, downloaded
46
+ * as `file.txt` and — worst — missed `token.lang === 'mermaid'` (upstream d4ec6c0).
47
+ * Splitting here rather than walking lex()'s output costs one search per code
48
+ * token instead of a per-block tree walk, and it also reaches fences nested in
49
+ * lists and blockquotes, which a pass over the top-level tokens would not.
50
+ */
51
+ fences(src) {
52
+ const token = super.fences(src);
53
+ if (token?.lang) {
54
+ const end = token.lang.search(/\s/);
55
+ if (end !== -1) {
56
+ token.meta = token.lang.slice(end + 1).trim() || undefined;
57
+ token.lang = token.lang.slice(0, end);
58
+ }
59
+ }
60
+ return token;
61
+ }
62
+ /**
63
+ * `~x~` is a subscript here, not GFM strikethrough (marked-subsup.ts). The old
64
+ * subscript rule shadowed single-tilde del by matching anything; now that it
65
+ * rejects whitespace, del would inherit exactly the sentences 716a5f0 is about
66
+ * (`20~25°C and 30~35°C` → del('~25°C and 30~')). Only `~~` opens a del.
67
+ */
68
+ del(src, maskedSrc, prevChar) {
69
+ if (src.charCodeAt(0) !== 126 /* ~ */ || src.charCodeAt(1) !== 126)
70
+ return;
71
+ return super.del(src, maskedSrc, prevChar);
72
+ }
73
+ }
42
74
  const parseExtensions = (...extensions) => {
43
75
  const options = {
44
76
  gfm: true,
77
+ tokenizer: new StreamdownTokenizer(),
45
78
  extensions: {
46
79
  block: [],
47
80
  inline: [],
@@ -71,9 +104,12 @@ const parseExtensions = (...extensions) => {
71
104
  });
72
105
  return options;
73
106
  };
74
- // Options objects are stateless and reusable across Lexer instances; cache them
75
- // per user-extension array (props are referentially stable across chunks) so the
76
- // hot path skips rebuilding ~20 tokenizer registrations on every streamed chunk.
107
+ // Options objects are reusable across Lexer instances; cache them per
108
+ // user-extension array (props are referentially stable across chunks) so the hot
109
+ // path skips rebuilding ~20 tokenizer registrations on every streamed chunk. The
110
+ // cached object carries one shared Tokenizer whose `lexer` back-pointer marked
111
+ // re-stamps per Lexer construction — safe only because lexing is synchronous and
112
+ // never re-entrant across documents.
77
113
  const DEFAULT_LEX_OPTIONS = parseExtensions(...DEFAULT_LEX_EXTENSIONS);
78
114
  const DEFAULT_BLOCK_OPTIONS = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS);
79
115
  const lexOptionsCache = new WeakMap();
@@ -1,8 +1,12 @@
1
1
  import {} from './index.js';
2
2
  import { StreamdownContext } from '../context.svelte.js';
3
3
  import { getContext } from 'svelte';
4
- const footnoteRegex = /^\[\^([^\]\n]+)\]:(?:[ \t]+|\n|$)([^\n]*(?:\n(?:[ \t]+[^\n]*)?)*)/;
5
- const footnoteRefRegex = /^\[\^([^\]\n]+)\]/;
4
+ // Footnote identifiers are word characters, `-` and `:` only. `[^\]\n]+` turned
5
+ // every regex character class written in prose (`[^\s]`, `[^,]`) into an empty
6
+ // footnote marker (upstream 9f72224). `:` is kept for the completer's
7
+ // `[^streamdown:footnote]` sentinel, which FootnoteRef.svelte renders as nothing.
8
+ const footnoteRegex = /^\[\^([\w:-]{1,200})\]:(?:[ \t]+|\n|$)([^\n]*(?:\n(?:[ \t]+[^\n]*)?)*)/;
9
+ const footnoteRefRegex = /^\[\^([\w:-]{1,200})\]/;
6
10
  const footNoteLastLineRegex = /^[ \t]*?[>\-*][ ]|[`]{3,}$|^[ \t]*?[|].+[|]$/;
7
11
  const safeGetContext = () => {
8
12
  try {
@@ -1,5 +1,8 @@
1
- const subRule = /^~([^~\s](?:[^~]*[^~\s])?)~/; // ~text~
2
- const supRule = /^\^([^\^\s](?:[^\^]*[^\^\s])?)\^/; // ^text^
1
+ // A sub/superscript is a single run, never a phrase: no whitespace anywhere in
2
+ // it. The old rules only forbade whitespace at the edges, so `20~25°C and 30~35°C`
3
+ // subscripted half the sentence (upstream 716a5f0).
4
+ const subRule = /^~([^~\s]+)~/; // ~text~
5
+ const supRule = /^\^([^\^\s]+)\^/; // ^text^
3
6
  export const markedSub = {
4
7
  name: 'sub',
5
8
  level: 'inline',
@@ -7,13 +10,23 @@ export const markedSub = {
7
10
  const i = src.indexOf('~');
8
11
  return i === -1 ? undefined : i;
9
12
  },
10
- tokenizer(src) {
13
+ tokenizer(src, tokens) {
11
14
  // marked dispatches every inline extension at every scan position; `start`
12
15
  // only clips the text rule, it does not gate the tokenizer. This rule is
13
16
  // anchored on a single literal character, so one charCodeAt rejects the
14
17
  // ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
15
18
  if (src.charCodeAt(0) !== 126 /* ~ */)
16
19
  return;
20
+ // A digit right before the opening `~` means a numeric range (`20~25°C`),
21
+ // not a subscript base — chemistry and indices always have a letter or a
22
+ // closing bracket there (`H~2~O`, `x~i+1~`). `src` starts at the marker, so
23
+ // the preceding character is the last one of the previous inline token.
24
+ const prevRaw = tokens[tokens.length - 1]?.raw;
25
+ if (prevRaw) {
26
+ const code = prevRaw.charCodeAt(prevRaw.length - 1);
27
+ if (code >= 48 && code <= 57)
28
+ return;
29
+ }
17
30
  const match = src.match(subRule);
18
31
  if (match) {
19
32
  return {
@@ -98,6 +98,20 @@ 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',
@@ -172,7 +186,9 @@ export class IncompleteMarkdownParser {
172
186
  result += '\n```';
173
187
  }
174
188
  if (state.blockingContexts.has('math')) {
175
- result += '\n$$';
189
+ // The first half of the closing '$$' may already have arrived: adding a
190
+ // whole '\n$$' would leave a stray '$' inside the math and '$$' after it.
191
+ result += result.endsWith('$') && !result.endsWith('$$') ? '$' : '\n$$';
176
192
  }
177
193
  if (state.blockingContexts.has('center')) {
178
194
  result += '\n[/center]';
@@ -195,6 +211,8 @@ export class IncompleteMarkdownParser {
195
211
  const tripleAsterisks = (line.match(/\*\*\*/g) || []).length;
196
212
  if (tripleAsterisks % 2 === 1) {
197
213
  const lastTripleAsteriskIndex = line.lastIndexOf('***');
214
+ if (isWithinCompleteInlineCode(line, lastTripleAsteriskIndex))
215
+ return line;
198
216
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTripleAsteriskIndex);
199
217
  if (isEndingWithTripleAsterisk) {
200
218
  return line.substring(0, lastTripleAsteriskIndex);
@@ -222,6 +240,8 @@ export class IncompleteMarkdownParser {
222
240
  if (doubleAsteriskMatches % 2 === 1) {
223
241
  const isEndingWithDoubleAsterisk = line.endsWith('**');
224
242
  const lastDoubleAsteriskIndex = line.lastIndexOf('**');
243
+ if (isWithinCompleteInlineCode(line, lastDoubleAsteriskIndex))
244
+ return line;
225
245
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleAsteriskIndex);
226
246
  if (isEndingWithDoubleAsterisk) {
227
247
  return line.substring(0, lastDoubleAsteriskIndex);
@@ -248,6 +268,8 @@ export class IncompleteMarkdownParser {
248
268
  if (underscorePairs % 2 === 1) {
249
269
  const isEndingWithDoubleUnderscore = line.endsWith('__');
250
270
  const lastDoubleUnderscoreIndex = line.lastIndexOf('__');
271
+ if (isWithinCompleteInlineCode(line, lastDoubleUnderscoreIndex))
272
+ return line;
251
273
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleUnderscoreIndex);
252
274
  if (isEndingWithDoubleUnderscore) {
253
275
  return line.substring(0, lastDoubleUnderscoreIndex);
@@ -270,6 +292,8 @@ export class IncompleteMarkdownParser {
270
292
  if (tildePairs % 2 === 1) {
271
293
  const isEndingWithDoubleTilde = line.endsWith('~~');
272
294
  const lastDoubleTildeIndex = line.lastIndexOf('~~');
295
+ if (isWithinCompleteInlineCode(line, lastDoubleTildeIndex))
296
+ return line;
273
297
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleTildeIndex);
274
298
  // Only complete if there's content after the tildes
275
299
  const contentAfterTildes = line.substring(lastDoubleTildeIndex + 2, endOfCellOrLine);
@@ -296,37 +320,43 @@ export class IncompleteMarkdownParser {
296
320
  }
297
321
  // Inline countSingleAsterisks logic
298
322
  let singleAsterisks = 0;
323
+ let lastSingleAsterisk = -1;
299
324
  for (let i = 0; i < line.length; i++) {
300
325
  if (line[i] === '*') {
301
326
  const prevChar = i > 0 ? line[i - 1] : '';
302
327
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
303
- let lineStartIndex = i;
304
- for (let j = i - 1; j >= 0; j--) {
305
- if (line[j] === '\n') {
306
- lineStartIndex = j + 1;
307
- break;
308
- }
309
- if (j === 0) {
310
- lineStartIndex = 0;
311
- break;
312
- }
328
+ // Whitespace on both sides means arithmetic ('5 * 0') or a bare list
329
+ // marker, never an emphasis delimiter (c347b53).
330
+ if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar)) {
331
+ continue;
313
332
  }
314
- const beforeAsterisk = line.substring(lineStartIndex, i);
315
- if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
333
+ if (isWithinCompleteInlineCode(line, i)) {
316
334
  continue;
317
335
  }
318
336
  if (prevChar !== '*' && nextChar !== '*') {
319
337
  singleAsterisks++;
338
+ lastSingleAsterisk = i;
320
339
  }
321
340
  }
322
341
  }
323
342
  if (singleAsterisks % 2 === 1) {
343
+ // The dangling asterisk is the last counted one. If it cannot OPEN
344
+ // emphasis (nothing or whitespace after it) it is an intraword or
345
+ // trailing closer — '*foo*bar*' is literal, not half of '*foo*bar**'
346
+ // (9f96409).
347
+ if (isSpaceOrEdge(line[lastSingleAsterisk + 1] ?? '')) {
348
+ return line;
349
+ }
324
350
  // Inline findFirstSingleAsterisk logic
325
351
  let firstSingleAsteriskIndex = -1;
326
352
  for (let i = 0; i < line.length; i++) {
327
353
  if (line[i] === '*' && line[i - 1] !== '*' && line[i + 1] !== '*') {
328
354
  const prevChar = i > 0 ? line[i - 1] : '';
329
355
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
356
+ if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar))
357
+ continue;
358
+ if (isWithinCompleteInlineCode(line, i))
359
+ continue;
330
360
  if (/\w/.test(prevChar) && /\w/.test(nextChar))
331
361
  continue;
332
362
  if (/\w/.test(prevChar) && !/\s/.test(prevChar))
@@ -391,6 +421,8 @@ export class IncompleteMarkdownParser {
391
421
  continue;
392
422
  if (isWithinMathBlock(line, i))
393
423
  continue;
424
+ if (isWithinCompleteInlineCode(line, i))
425
+ continue;
394
426
  if (prevChar &&
395
427
  nextChar &&
396
428
  /[\p{L}\p{N}_]/u.test(prevChar) &&
@@ -410,7 +442,8 @@ export class IncompleteMarkdownParser {
410
442
  line[i - 1] !== '_' &&
411
443
  line[i + 1] !== '_' &&
412
444
  line[i - 1] !== '\\' &&
413
- !isWithinMathBlock(line, i)) {
445
+ !isWithinMathBlock(line, i) &&
446
+ !isWithinCompleteInlineCode(line, i)) {
414
447
  const prevChar = i > 0 ? line[i - 1] : '';
415
448
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
416
449
  if (prevChar &&
@@ -450,11 +483,17 @@ export class IncompleteMarkdownParser {
450
483
  }
451
484
  if (singleTildes % 2 === 1) {
452
485
  const lastTildeIndex = line.lastIndexOf('~');
453
- if (lastTildeIndex !== -1 && !isWithinMathBlock(line, lastTildeIndex)) {
486
+ if (lastTildeIndex !== -1 &&
487
+ !isWithinMathBlock(line, lastTildeIndex) &&
488
+ !isWithinCompleteInlineCode(line, lastTildeIndex)) {
454
489
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTildeIndex);
455
- // Only complete if there's content after the tilde
456
490
  const contentAfterTilde = line.substring(lastTildeIndex + 1, endOfCellOrLine);
457
- if (contentAfterTilde.trim().length > 0) {
491
+ // A subscript is '~text~' with no whitespace inside (same rule as the
492
+ // lexer), and a digit before the tilde means a range like '20~25°C':
493
+ // closing those would manufacture a subscript nobody typed (716a5f0).
494
+ if (contentAfterTilde.length > 0 &&
495
+ !/\s/.test(contentAfterTilde) &&
496
+ !/\d/.test(line[lastTildeIndex - 1] ?? '')) {
458
497
  return line.substring(0, endOfCellOrLine) + '~' + line.substring(endOfCellOrLine);
459
498
  }
460
499
  }
@@ -580,6 +619,9 @@ export class IncompleteMarkdownParser {
580
619
  continue;
581
620
  if (nextChar && /\d/.test(nextChar))
582
621
  continue;
622
+ // A '$' shown as code ('`$var`') must not flip the parity (e50b0c4)
623
+ if (isWithinCompleteInlineCode(line, i))
624
+ continue;
583
625
  singleDollars++;
584
626
  }
585
627
  }
@@ -593,7 +635,8 @@ export class IncompleteMarkdownParser {
593
635
  prevChar !== '$' &&
594
636
  nextChar !== '$' &&
595
637
  nextChar !== '' &&
596
- !/\d/.test(nextChar)) {
638
+ !/\d/.test(nextChar) &&
639
+ !isWithinCompleteInlineCode(line, i)) {
597
640
  lastDollarIndex = i;
598
641
  break;
599
642
  }
@@ -866,6 +909,11 @@ export const parseIncompleteMarkdown = (text) => {
866
909
  return defaultParser.parse(text);
867
910
  };
868
911
  // Utility functions
912
+ // Full test for the comparisonOperator plugin, whose `pattern` only gates it.
913
+ const listItemComparison = /^(\s*(?:[-*+]|\d+[.)]) +)>(?==?\s*\$?\d)/;
914
+ // The char accessors in the plugins return '' past either end of the line, so an
915
+ // empty string here means "edge of line".
916
+ const isSpaceOrEdge = (char) => !char || /\s/.test(char);
869
917
  const findEndOfCellOrLineContaining = (text, position) => {
870
918
  let endPos = position;
871
919
  while (endPos < text.length && text[endPos] !== '\n' && text[endPos] !== '|') {
@@ -873,6 +921,40 @@ const findEndOfCellOrLineContaining = (text, position) => {
873
921
  }
874
922
  return endPos;
875
923
  };
924
+ // Scanned on demand, against the line as it stands now, and allocating nothing:
925
+ // the completer runs over every changed block of every streamed chunk, so garbage
926
+ // here surfaces as GC pauses in the stages around it.
927
+ const isWithinCompleteInlineCode = (line, position) => {
928
+ let open = line.indexOf('`');
929
+ while (open !== -1 && open <= position) {
930
+ let openEnd = open;
931
+ while (line.charCodeAt(openEnd) === 96)
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
+ }
947
+ // An unterminated run closes nothing, so neither it nor anything after it
948
+ // is code: completing emphasis inside it is what streaming needs.
949
+ if (closeStart === -1)
950
+ return false;
951
+ const spanEnd = closeStart + runLength;
952
+ if (position < spanEnd)
953
+ return true;
954
+ open = line.indexOf('`', spanEnd);
955
+ }
956
+ return false;
957
+ };
876
958
  const isWithinMathBlock = (text, position) => {
877
959
  let inInlineMath = false;
878
960
  let inBlockMath = false;
@@ -1,5 +1,8 @@
1
1
  export const save = (filename, content, mimeType) => {
2
- const blob = new Blob([content], { type: mimeType });
2
+ // Excel on Windows assumes the system codepage without a BOM, so accented
3
+ // and CJK text in a downloaded CSV opens as mojibake.
4
+ const body = mimeType.startsWith('text/csv') ? '\uFEFF' + content : content;
5
+ const blob = new Blob([body], { type: mimeType });
3
6
  const url = URL.createObjectURL(blob);
4
7
  const link = document.createElement('a');
5
8
  link.href = url;
package/dist/utils/url.js CHANGED
@@ -25,6 +25,7 @@ export const isPathRelativeUrl = (url) => {
25
25
  return false;
26
26
  return url.startsWith('/');
27
27
  };
28
+ const WILDCARD_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
28
29
  export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
29
30
  if (!url)
30
31
  return null;
@@ -61,8 +62,11 @@ export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
61
62
  }
62
63
  // Check for wildcard - allow all URLs
63
64
  if (allowedPrefixes.includes('*')) {
64
- // Wildcard only allows http and https URLs
65
- if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
65
+ // The wildcard allows the protocols a document can legitimately link to.
66
+ // javascript:, data: and vbscript: stay blocked - they execute in the
67
+ // page's origin. mailto:/tel: were missing, so the default ['*'] config
68
+ // rendered a [blocked] badge on every phone and email link.
69
+ if (!WILDCARD_PROTOCOLS.has(parsedUrl.protocol)) {
66
70
  return null;
67
71
  }
68
72
  const inputWasRelative = isPathRelativeUrl(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "packageManager": "pnpm@10.32.1",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,7 +22,8 @@
22
22
  "lint": "prettier --check .",
23
23
  "test:ui": "vitest --ui",
24
24
  "test:unit": "vitest",
25
- "test": "npm run test:unit -- --run"
25
+ "test:browser": "vitest --run --project client",
26
+ "test": "npm run test:unit -- --run --project server"
26
27
  },
27
28
  "files": [
28
29
  "dist",