svelte-streamdown 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +211 -38
  2. package/dist/Block.svelte +10 -4
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +9 -2
  6. package/dist/Elements/Code.svelte +65 -24
  7. package/dist/Elements/Code.svelte.d.ts +4 -2
  8. package/dist/Elements/Element.svelte +36 -11
  9. package/dist/Elements/Element.svelte.d.ts +1 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +3 -2
  13. package/dist/Elements/Mermaid.svelte +69 -14
  14. package/dist/Elements/Mermaid.svelte.d.ts +4 -2
  15. package/dist/Elements/MermaidDownload.svelte +30 -9
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +60 -78
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +28 -3
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +12 -3
  21. package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
  22. package/dist/Elements/icons.js +10 -1
  23. package/dist/Elements/srOnly.d.ts +1 -0
  24. package/dist/Elements/srOnly.js +3 -0
  25. package/dist/Streamdown.svelte +68 -13
  26. package/dist/context.svelte.d.ts +98 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +8 -1
  31. package/dist/marked/index.js +65 -14
  32. package/dist/marked/marked-footnotes.js +6 -2
  33. package/dist/marked/marked-math.js +40 -1
  34. package/dist/marked/marked-subsup.js +16 -3
  35. package/dist/utils/fence.d.ts +16 -0
  36. package/dist/utils/fence.js +39 -0
  37. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  38. package/dist/utils/parse-incomplete-markdown.js +347 -122
  39. package/dist/utils/save.js +4 -1
  40. package/dist/utils/table-export.d.ts +14 -0
  41. package/dist/utils/table-export.js +82 -0
  42. package/dist/utils/url.js +6 -2
  43. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  44. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  45. package/package.json +4 -2
package/README.md CHANGED
@@ -26,6 +26,10 @@ Perfect for AI-powered applications that need to stream and render markdown cont
26
26
  - **Progressive Rendering**: Perfect for streaming AI responses
27
27
  - **Real-time Updates**: Optimized for dynamic content
28
28
  - **Smooth Animations**: Animate tokens and blocks as they are streamed.
29
+ - **An [`incomplete` signal](#the-incomplete-signal)** on the block still being streamed, so
30
+ expensive renderers can wait and loading states need no JavaScript
31
+ - **Capped, self-scrolling blocks**: `codeBlockMaxHeight` / `tableMaxHeight` keep a long snippet or
32
+ table pinned to its newest line while it streams
29
33
 
30
34
  ### 🔒 Security Hardening
31
35
 
@@ -58,7 +62,7 @@ Full support for
58
62
  - Task lists ([ ] and [x])
59
63
  - Code blocks
60
64
  - Mermaid diagrams
61
- - Math $expressions$
65
+ - Math $expressions$, in `$…$` / `$$…$$` or the LaTeX `\(…\)` / `\[…\]` delimiters
62
66
  - Escaping currency symbols ($140)
63
67
  - Complex tables
64
68
  - Footnotes [^1]
@@ -77,14 +81,17 @@ Full support for
77
81
 
78
82
  - Syntax highlighting powered by [@tanstack/highlight](https://github.com/TanStack/highlight) (synchronous, SSR-friendly, ~31KB min / ~11KB gzip for every language)
79
83
  - Copy-to-clipboard functionality
84
+ - Download the snippet with the extension of its language
80
85
  - Support any `@tanstack/highlight` theme, or your own
81
86
 
82
87
  ### 🔢 Mathematical Expressions
83
88
 
84
- LaTeX math support through KaTeX. Use single dollars for **inline** math and double dollars for **block** (display) math:
89
+ LaTeX math support through KaTeX. Both delimiter styles are supported dollars and the LaTeX delimiters LLMs usually emit:
85
90
 
86
- - Inline math: `$E = mc^2$` renders inline as $E = mc^2$
87
- - Block math:
91
+ - Inline math: `$E = mc^2$` or `\(E = mc^2\)` renders inline as $E = mc^2$
92
+ - Block (display) math: `$$ … $$` or `\[ … \]`
93
+
94
+ > `\(` and `\[` are always read as math delimiters — in static rendering as well as while streaming. Prose that uses them as literal-bracket escapes renders as math: `\[optional\]` is a display-math token, and an unterminated `Use \[ to open a bracket` is auto-closed into one by the completer (`parseIncompleteMarkdown={false}` stops that half). Escape the backslash — `\\[` — to keep the sequence literal. See the [4.1.0 behaviour changes](CHANGELOG.md#410).
88
95
 
89
96
  $$
90
97
  f(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}
@@ -109,6 +116,7 @@ Pass KaTeX options through the [`katexConfig`](#-props-api) prop (e.g. to set `t
109
116
  - **Incremental rendering** during streaming content
110
117
  - Pan and Zoom
111
118
  - Full screen mode
119
+ - Download as PNG, SVG or `.mmd` source
112
120
 
113
121
  # **Example:**
114
122
 
@@ -146,6 +154,9 @@ pie title Project Time Allocation
146
154
 
147
155
  ### Complex table support
148
156
 
157
+ Tables copy and download as Markdown, HTML, CSV or TSV — see [Controls](#-controls) for the
158
+ separator and filename options, and for the exported table utilities.
159
+
149
160
  #### Colspan
150
161
 
151
162
  | H1 | H2 | H3 |
@@ -403,6 +414,47 @@ Code highlighting is incremental as well: a code block is only re-highlighted wh
403
414
  > [!NOTE]
404
415
  > There is intentionally no separate block-level parse cache (e.g. an LRU keyed by block content). For the common append-only streaming case the reactivity-based approach above already avoids redundant work, and a standalone cache would add memory usage and invalidation complexity without a measurable benefit. If you have a workload where this matters, please [open an issue](https://github.com/beynar/svelte-streamdown/issues) with a repro — we're happy to revisit.
405
416
 
417
+ ### The incremental block cache contract
418
+
419
+ Step 1 above keeps a small per-instance cache so that block splitting costs O(new text) rather than O(document) on each update. It seals every block except the last two and re-splits only the live tail.
420
+
421
+ Deciding whether an update is an append has to be cheap, so the sealed prefix is **sampled, not rescanned**: the first character of every sealed block, plus a fixed number of evenly spaced characters. Anything that fails a sample falls back to a full parse.
422
+
423
+ What that means in practice:
424
+
425
+ - **Append-only updates are exact.** This is what an LLM stream does, and what the component does with its own `content` prop.
426
+ - **Replacing, shortening or restructuring the content is detected** — a different length, a moved block boundary, or a changed block start all fail the checks and trigger a full reparse.
427
+ - **A same-length edit in the middle of a sealed block can be missed**, and that block will keep rendering its old text. If you bind `content` to an editor, or regenerate a block in the middle of a finished document, either pass `static` (which skips the streaming path) or force a fresh parse by re-keying the component:
428
+
429
+ ```svelte
430
+ {#key documentVersion}
431
+ <Streamdown content={editorValue} />
432
+ {/key}
433
+ ```
434
+
435
+ ### The `incomplete` signal
436
+
437
+ While a fence is still streaming, the block it produces is a guess: the closing ``` has not arrived,
438
+ so the language, the last line and even whether it is a diagram at all can still change. Streamdown
439
+ now says so out loud.
440
+
441
+ - The `code` and `mermaid` snippets receive an extra `incomplete: boolean` prop, as do custom
442
+ `components.code` / `components.mermaid` components.
443
+ - The rendered container carries `data-incomplete="true"` while the fence is open, so a loading
444
+ style needs no JavaScript: `[data-streamdown-code][data-incomplete] { opacity: 0.7 }`.
445
+ - Only the **last** block of a streaming document can be incomplete, and `static` never marks
446
+ anything.
447
+
448
+ ```svelte
449
+ {#snippet code({ token, children, incomplete })}
450
+ <pre class:animate-pulse={incomplete}>{@render children()}</pre>
451
+ {/snippet}
452
+ ```
453
+
454
+ The built-in Mermaid component already acts on it: it skips `mermaid.render` while the fence is
455
+ open and keeps the last good diagram on screen, instead of re-parsing a half-written graph on every
456
+ chunk and flashing an error.
457
+
406
458
  ## 🎭 Animation System
407
459
 
408
460
  Streamdown includes an animation system designed specifically for streaming AI content, providing smooth and engaging visual feedback as text appears on screen.
@@ -512,7 +564,7 @@ Prefixes can also be **protocol-only**, which allows any URL using that protocol
512
564
  ```
513
565
 
514
566
  > [!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:'`.
567
+ > `'*'` 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
568
 
517
569
  ## 📦 Bundle Optimization
518
570
 
@@ -698,38 +750,44 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
698
750
 
699
751
  ## 📋 Props API
700
752
 
701
- | Prop | Type | Default | Description |
702
- | -------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
703
- | `content` | `string` | - | **Required.** The markdown content to render |
704
- | `sources` | `Record<string, any>` | - | Citation data object for inline citations |
705
- | `class` | `string` | - | CSS class names for the wrapper element |
706
- | `parseIncompleteMarkdown` | `boolean` | `true` | Parse and fix incomplete markdown syntax |
707
- | `defaultOrigin` | `string` | - | Default origin for relative URLs |
708
- | `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
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 |
713
- | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
714
- | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
715
- | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
716
- | `highlightTheme` | `string` | auto (dark-mode aware) | Code highlighting theme. Defaults to `github-dark` in dark mode / `github-light` otherwise. Any other value must be a key registered via `highlightThemes`. See [Highlight themes](#highlight-themes). |
717
- | `highlightThemes` | `Record<string, HighlightTheme>` | - | Register additional pre-imported themes (e.g. `{ dracula }`) so they can be selected via `highlightTheme`, including dynamic light/dark switching. |
718
- | `highlightLanguages` | `LanguageDefinition[]` | - | Additional languages built with `defineLanguage` (merged with the 30 built-in ones) |
719
- | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
720
- | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
721
- | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
722
- | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
723
- | `animation.type` | `'fade' \| 'blur' \| 'typewriter' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
724
- | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
725
- | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
726
- | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
727
- | `animation.animateOnMount` | `boolean` | `false` | Run the token animation on mount or not, useful if you render the Streamdown component in the same time as the first token is receive from the LLM |
728
- | `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
729
- | `mdxComponents` | `Record<string, Component>` | `{}` | Map of MDX component names to Svelte components (e.g., `{ Card, Button }`) |
730
- | `components` | `{ code?, mermaid?, math? }` | - | Optional heavy components for syntax highlighting, diagrams, and math rendering |
731
- | `controls` | `{ code?: boolean, mermaid?: boolean \| { enabled?: boolean, mouseWheelZoom?: boolean }, table?: boolean }` | all `true` | Toggle the action toolbars for code blocks, mermaid diagrams, and tables. For mermaid, pass an object to disable only mouse-wheel zoom while keeping pan and the zoom buttons, e.g. `{ mermaid: { mouseWheelZoom: false } }` |
732
- | `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
753
+ | Prop | Type | Default | Description |
754
+ | -------------------------- | -------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
755
+ | `content` | `string` | - | **Required.** The markdown content to render |
756
+ | `sources` | `Record<string, any>` | - | Citation data object for inline citations |
757
+ | `class` | `string` | - | CSS class names for the wrapper element |
758
+ | `parseIncompleteMarkdown` | `boolean` | `true` | Parse and fix incomplete markdown syntax |
759
+ | `defaultOrigin` | `string` | - | Default origin for relative URLs |
760
+ | `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
761
+ | `allowedImagePrefixes` | `string[]` | `['*']` | Allowed URL prefixes for images |
762
+ | `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. |
763
+ | `inlineCitationsMode` | `'list' \| 'carousel'` | `'carousel'` | How an inline citation popover presents its sources |
764
+ | `translations` | `{ alert?: {...}, controls?: {...} }` | `defaultTranslations` | Override the built-in alert titles and control labels — see [Translations](#-translations) |
765
+ | `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 |
766
+ | `static` | `boolean` | `false` | Render finished content: skips the incomplete-markdown pass and the streaming animation |
767
+ | `element` | `HTMLElement` | - | `bind:element` to get the wrapper node |
768
+ | `streamdown` | `StreamdownContext` | - | `bind:streamdown` to read the resolved context (theme, controls, footnotes, sources) |
769
+ | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
770
+ | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
771
+ | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
772
+ | `highlightTheme` | `string` | auto (dark-mode aware) | Code highlighting theme. Defaults to `github-dark` in dark mode / `github-light` otherwise. Any other value must be a key registered via `highlightThemes`. See [Highlight themes](#highlight-themes). |
773
+ | `highlightThemes` | `Record<string, HighlightTheme>` | - | Register additional pre-imported themes (e.g. `{ dracula }`) so they can be selected via `highlightTheme`, including dynamic light/dark switching. |
774
+ | `highlightLanguages` | `LanguageDefinition[]` | - | Additional languages built with `defineLanguage` (merged with the 30 built-in ones) |
775
+ | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
776
+ | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
777
+ | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
778
+ | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
779
+ | `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
780
+ | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
781
+ | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
782
+ | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
783
+ | `animation.animateOnMount` | `boolean` | `false` | Run the token animation on mount or not, useful if you render the Streamdown component in the same time as the first token is receive from the LLM |
784
+ | `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
785
+ | `mdxComponents` | `Record<string, Component>` | `{}` | Map of MDX component names to Svelte components (e.g., `{ Card, Button }`) |
786
+ | `components` | `{ code?, mermaid?, math? }` | - | Optional heavy components for syntax highlighting, diagrams, and math rendering |
787
+ | `controls` | `boolean \| { code?, table?, mermaid? }` | all `true` | Toggle and configure the action toolbars for code blocks, tables and mermaid diagrams — see [Controls](#-controls) |
788
+ | `codeBlockMaxHeight` | `string` | - | CSS length that caps the height of code blocks (e.g. `'24rem'`). While content streams in, the block stays scrolled to the bottom unless the reader has scrolled up. |
789
+ | `tableMaxHeight` | `string` | - | CSS length that caps the height of tables, with the same streaming auto-scroll as `codeBlockMaxHeight`. |
790
+ | `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
733
791
 
734
792
  #### All Available Customizable Elements:
735
793
 
@@ -739,7 +797,7 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
739
797
 
740
798
  **Lists**: `ul`, `ol`, `li`
741
799
 
742
- **Code**: `code`, `codeSpan`
800
+ **Code**: `code`, `codespan` — `code` and `mermaid` also receive [`incomplete`](#the-incomplete-signal)
743
801
 
744
802
  **Tables**: `table`, `thead`, `tbody`, `tr`, `th`, `td`, `tfoot`
745
803
 
@@ -749,6 +807,117 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
749
807
 
750
808
  **Note**: The above elements are **supported by Streamdown** and should be customized using individual props or the theme system. MDX components require the `mdx` snippet.
751
809
 
810
+ ## 🌍 Translations
811
+
812
+ Every string the components render themselves — alert titles, button labels, download menu entries, the copy announcements screen readers hear, the blocked-URL tooltips — comes from one nested `translations` object. Pass only the keys you want to change; the rest fall back to `defaultTranslations`, which is exported so you can read the shipped English values (or diff against them when a new key appears).
813
+
814
+ ```svelte
815
+ <script>
816
+ import { Streamdown, defaultTranslations } from 'svelte-streamdown';
817
+ </script>
818
+
819
+ <Streamdown
820
+ {content}
821
+ translations={{
822
+ alert: { note: 'remarque', warning: 'attention' },
823
+ controls: {
824
+ copyCode: 'Copier le code',
825
+ copiedCode: 'Code copié',
826
+ downloadCode: 'Télécharger le code'
827
+ }
828
+ }}
829
+ />
830
+ ```
831
+
832
+ | Namespace | Keys |
833
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
834
+ | `alert` | `note`, `tip`, `warning`, `caution`, `important` |
835
+ | `controls` | `copyCode`, `copiedCode`, `downloadCode`, `copyTable`, `copiedTable`, `downloadTable`, `tableFormatMarkdown`, `tableFormatHtml`, `tableFormatCsv`, `tableFormatTsv`, `downloadDiagram`, `downloadDiagramPng`, `downloadDiagramSvg`, `downloadDiagramMmd`, `zoomIn`, `zoomOut`, `resetView`, `fullscreen`, `exitFullscreen`, `diagram`, `previousCitation`, `nextCitation`, `blockedUrl`, `imageBlocked`, `imageNoDescription`, `linkBlocked` |
836
+
837
+ The alert defaults are lowercase because the theme capitalizes them with CSS; if you drop that class, capitalize them here instead.
838
+
839
+ Control labels are used as both the `title` and the `aria-label` of the matching icon-only button, so translating them translates the accessible name too. `copiedCode` / `copiedTable` are announced through a visually hidden `aria-live` region after a copy.
840
+
841
+ ## 🎛️ Controls
842
+
843
+ `controls` turns the code / table / mermaid toolbars on and off and configures what they do.
844
+ `controls={false}` turns every control off, `controls={true}` (the default) turns them all on,
845
+ and each section takes a boolean or an object:
846
+
847
+ ```ts
848
+ type Controls =
849
+ | boolean
850
+ | {
851
+ code?:
852
+ | boolean
853
+ | {
854
+ enabled?: boolean;
855
+ copy?: boolean;
856
+ download?: boolean | { filename?: string | ((token: CodeToken) => string) };
857
+ };
858
+ table?:
859
+ | boolean
860
+ | {
861
+ enabled?: boolean;
862
+ copy?: boolean;
863
+ download?: boolean | { filename?: string | ((token: TableToken) => string) };
864
+ csvSeparator?: ',' | ';' | '\t' | 'auto';
865
+ };
866
+ mermaid?:
867
+ | boolean
868
+ | {
869
+ enabled?: boolean;
870
+ download?: boolean | { filename?: string | ((token: CodeToken) => string) };
871
+ mouseWheelZoom?: boolean;
872
+ };
873
+ };
874
+ ```
875
+
876
+ ```svelte
877
+ <Streamdown
878
+ {content}
879
+ controls={{
880
+ code: { download: { filename: (token) => `snippet-${token.lang}` } },
881
+ table: { copy: false, csvSeparator: 'auto' },
882
+ mermaid: { mouseWheelZoom: false }
883
+ }}
884
+ />
885
+ ```
886
+
887
+ - `filename` is the **base name**; the extension still comes from the content — `languageExtensionMap`
888
+ for code (`.ts`, `.py`, … `.txt`), `.csv` / `.tsv` / `.md` / `.html` for tables, `.svg` / `.png` / `.mmd`
889
+ for diagrams. The defaults are `file`, `table` and `diagram`.
890
+ - `csvSeparator: 'auto'` picks `;` when the browser locale writes decimals with a comma (Excel reads
891
+ `,` as a decimal point there), otherwise `,`. CSV downloads carry a UTF-8 BOM so accented and CJK
892
+ text opens correctly in Excel.
893
+ - `mouseWheelZoom` is a gesture rather than a button: it stays on unless you set it to `false` or
894
+ turn every control off with `controls={false}`.
895
+
896
+ ### Table export utilities
897
+
898
+ The table toolbar's DOM walk is exported, so a custom `table` snippet can build its own copy or
899
+ download menu:
900
+
901
+ ```ts
902
+ import {
903
+ extractTableData,
904
+ tableDataToCSV,
905
+ tableDataToTSV,
906
+ tableDataToMarkdown,
907
+ tableDataToHTML,
908
+ type TableData
909
+ } from 'svelte-streamdown';
910
+
911
+ const data = extractTableData(document.querySelector('[data-streamdown-table="..."]')!);
912
+ // { headers: string[], rows: string[][] } — <br> becomes \n, colspan/rowspan become empty cells
913
+ tableDataToCSV(data, ';');
914
+ tableDataToTSV(data);
915
+ ```
916
+
917
+ `tableDataToMarkdown` and `tableDataToHTML` are there for callers that only hold a DOM table; the
918
+ built-in menu copies Markdown from `token.raw` instead, which keeps the author's original inline
919
+ formatting.
920
+
752
921
  ## 🎨 Theming System
753
922
 
754
923
  ### Built-in Themes
@@ -1103,6 +1272,10 @@ pnpm dev
1103
1272
  # Run tests
1104
1273
  pnpm test
1105
1274
 
1275
+ # Run the browser (component) tests — needs a Chromium binary:
1276
+ # pnpm exec playwright install chromium
1277
+ pnpm test:browser
1278
+
1106
1279
  # Build for production
1107
1280
  pnpm build
1108
1281
  ```
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';
@@ -8,15 +8,21 @@
8
8
 
9
9
  let {
10
10
  block,
11
- static: isStatic = false
11
+ static: isStatic = false,
12
+ incomplete = false
12
13
  }: {
13
14
  block: string;
14
15
  static?: boolean;
16
+ /** This block ends inside an unfinished code fence (streaming only). */
17
+ incomplete?: boolean;
15
18
  } = $props();
16
19
 
17
20
  const streamdown = useStreamdown();
21
+ // The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
22
+ // is aliased so the context flag and the helper cannot be confused.
23
+ const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
18
24
  const tokens = $derived(
19
- lex(isStatic ? block : parseIncompleteMarkdown(block.trim()), streamdown.extensions)
25
+ lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
20
26
  );
21
27
  const insidePopover = getContext('POPOVER');
22
28
  </script>
@@ -26,7 +32,7 @@
26
32
  {#if token}
27
33
  {@const children = (token as any)?.tokens || []}
28
34
  {@const isTextOnlyNode = children.length === 0}
29
- <Element {token}>
35
+ <Element {token} {incomplete}>
30
36
  {#if isTextOnlyNode}
31
37
  {#if streamdown.animation.enabled && !insidePopover && !isStatic}
32
38
  <AnimatedText text={'text' in token ? token.text || '' : ''} />
@@ -1,6 +1,8 @@
1
1
  type $$ComponentProps = {
2
2
  block: string;
3
3
  static?: boolean;
4
+ /** This block ends inside an unfinished code fence (streaming only). */
5
+ incomplete?: boolean;
4
6
  };
5
7
  declare const Block: import("svelte").Component<$$ComponentProps, {}, "">;
6
8
  type Block = ReturnType<typeof Block>;
@@ -45,7 +45,7 @@
45
45
  >
46
46
  <div data-alert-title class={streamdown.theme.alert.title}>
47
47
  {@render (streamdown.icons?.[token.variant] || icon)()}
48
- {streamdown.translations?.alert?.[token.variant] || token.variant}
48
+ {streamdown.translations.alert[token.variant]}
49
49
  </div>
50
50
  {@render children()}
51
51
  </div>
@@ -53,6 +53,7 @@
53
53
 
54
54
  {#snippet icon()}
55
55
  <svg
56
+ aria-hidden="true"
56
57
  xmlns="http://www.w3.org/2000/svg"
57
58
  width="24"
58
59
  height="24"
@@ -152,6 +152,9 @@
152
152
  disabled={!stepper.canGoPrevious}
153
153
  class={streamdown.theme.components.button}
154
154
  onclick={() => stepper.previous()}
155
+ type="button"
156
+ aria-label={streamdown.translations.controls.previousCitation}
157
+ title={streamdown.translations.controls.previousCitation}
155
158
  >
156
159
  {@render (streamdown.icons?.chevronLeft || chevronLeft)()}
157
160
  </button>
@@ -159,6 +162,9 @@
159
162
  disabled={!stepper.canGoNext}
160
163
  class={streamdown.theme.components.button}
161
164
  onclick={() => stepper.next()}
165
+ type="button"
166
+ aria-label={streamdown.translations.controls.nextCitation}
167
+ title={streamdown.translations.controls.nextCitation}
162
168
  >
163
169
  {@render (streamdown.icons?.chevronRight || chevronRight)()}
164
170
  </button>
@@ -174,7 +180,7 @@
174
180
  style:position="relative"
175
181
  style:transition-duration="200ms"
176
182
  style:transition-timing-function="ease-in-out"
177
- aria-label="Citations-${id}"
183
+ aria-label={'Citations-' + id}
178
184
  >
179
185
  <div
180
186
  bind:this={stepper.stepContainer}
@@ -193,7 +199,7 @@
193
199
  style:height="fit-content"
194
200
  style:width="100%"
195
201
  style:flex-grow="1"
196
- aria-label="Citation-${id}"
202
+ aria-label={'Citation-' + id}
197
203
  >
198
204
  <Slot render={streamdown.snippets.inlineCitationContent} props={{ source, key, token }}>
199
205
  {#if url || title}
@@ -243,6 +249,7 @@
243
249
  aria-expanded={popover.isOpen}
244
250
  aria-haspopup="dialog"
245
251
  aria-controls={'citation-popover-' + id}
252
+ type="button"
246
253
  {@attach clickOutside.attachment}
247
254
  >
248
255
  <Slot
@@ -3,22 +3,32 @@
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
+ import { usePinnedScroll } from '../utils/usePinnedScroll.svelte.js';
7
8
  import { checkIcon, copyIcon, downloadIcon } from './icons.js';
9
+ import { srOnly } from './srOnly.js';
8
10
 
9
11
  const {
10
12
  token,
11
- id
13
+ id,
14
+ incomplete = false
12
15
  }: {
13
- token: Tokens.Code;
16
+ token: CodeToken;
14
17
  id: string;
18
+ /** The fence is still being streamed; nothing below it is final yet. */
19
+ incomplete?: boolean;
15
20
  } = $props();
16
21
 
17
22
  const streamdown = useStreamdown();
18
23
 
24
+ // marked keeps the fence's trailing blank lines in `text`; they render as empty
25
+ // lines and, while streaming, flicker in and out on nearly every chunk. Render,
26
+ // copy and download all read this so they can never disagree.
27
+ const code = $derived(token.text.replace(/\n+$/, ''));
28
+
19
29
  const copy = useCopy({
20
30
  get content() {
21
- return token.text;
31
+ return code;
22
32
  }
23
33
  });
24
34
 
@@ -30,47 +40,78 @@
30
40
  token.lang && token.lang in languageExtensionMap
31
41
  ? languageExtensionMap[token.lang as keyof typeof languageExtensionMap]
32
42
  : 'txt';
33
- const filename = `file.${extension}`;
43
+ const base = streamdown.controls.codeDownloadFilename;
44
+ const filename = `${typeof base === 'function' ? base(token) : base}.${extension}`;
34
45
  const mimeType = 'text/plain';
35
- save(filename, token.text, mimeType);
46
+ save(filename, code, mimeType);
36
47
  } catch (error) {
37
48
  console.error('Failed to download file:', error);
38
49
  }
39
50
  };
40
51
 
41
- const lines = $derived(highlightLines(token.text, token.lang, streamdown.highlightLanguages));
52
+ const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
53
+
54
+ // `pre` is already the horizontal scroll container, so capping it there keeps
55
+ // one scrollable box for both axes (and the horizontal scrollbar visible).
56
+ const pinnedScroll = usePinnedScroll({
57
+ get maxHeight() {
58
+ return streamdown.codeBlockMaxHeight;
59
+ },
60
+ get content() {
61
+ return code;
62
+ }
63
+ });
42
64
  </script>
43
65
 
44
66
  <div
45
67
  data-streamdown-code={id}
68
+ data-incomplete={incomplete || undefined}
46
69
  style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
47
70
  class={streamdown.theme.code.base}
48
71
  >
49
72
  <div class={streamdown.theme.code.header}>
50
73
  <span class={streamdown.theme.code.language}>{token.lang}</span>
51
- {#if streamdown.controls.code}
74
+ {#if streamdown.controls.codeCopy || streamdown.controls.codeDownload}
52
75
  <div class={streamdown.theme.code.buttons}>
53
- <button
54
- class={streamdown.theme.components.button}
55
- onclick={downloadCode}
56
- title="Download code"
57
- type="button"
58
- >
59
- {@render (streamdown.icons?.download || downloadIcon)()}
60
- </button>
76
+ {#if streamdown.controls.codeDownload}
77
+ <button
78
+ class={streamdown.theme.components.button}
79
+ onclick={downloadCode}
80
+ title={streamdown.translations.controls.downloadCode}
81
+ aria-label={streamdown.translations.controls.downloadCode}
82
+ type="button"
83
+ >
84
+ {@render (streamdown.icons?.download || downloadIcon)()}
85
+ </button>
86
+ {/if}
61
87
 
62
- <button class={streamdown.theme.components.button} onclick={copy.copy} type="button">
63
- {#if copy.isCopied}
64
- {@render (streamdown.icons?.check || checkIcon)()}
65
- {:else}
66
- {@render (streamdown.icons?.copy || copyIcon)()}
67
- {/if}
68
- </button>
88
+ {#if streamdown.controls.codeCopy}
89
+ <button
90
+ class={streamdown.theme.components.button}
91
+ onclick={copy.copy}
92
+ title={streamdown.translations.controls.copyCode}
93
+ aria-label={streamdown.translations.controls.copyCode}
94
+ type="button"
95
+ >
96
+ {#if copy.isCopied}
97
+ {@render (streamdown.icons?.check || checkIcon)()}
98
+ {:else}
99
+ {@render (streamdown.icons?.copy || copyIcon)()}
100
+ {/if}
101
+ </button>
102
+ {/if}
103
+ <span aria-live="polite" style={srOnly}
104
+ >{copy.isCopied ? streamdown.translations.controls.copiedCode : ''}</span
105
+ >
69
106
  </div>
70
107
  {/if}
71
108
  </div>
72
109
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
73
- <pre class={streamdown.theme.code.pre}><code
110
+ <pre
111
+ class={streamdown.theme.code.pre}
112
+ style:max-height={streamdown.codeBlockMaxHeight}
113
+ style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
114
+ {@attach pinnedScroll}><code
74
115
  >{#each lines as line}<span class={streamdown.theme.code.line}
75
116
  >{#if line.length === 0}&#8203;{/if}{#each line as t}<span
76
117
  class="th-token{t.className ? ` th-${t.className}` : ''}"
@@ -1,7 +1,9 @@
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
+ /** The fence is still being streamed; nothing below it is final yet. */
6
+ incomplete?: boolean;
5
7
  };
6
8
  declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
7
9
  type Code = ReturnType<typeof Code>;