svelte-streamdown 4.0.1 → 4.1.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.
Files changed (41) hide show
  1. package/README.md +209 -41
  2. package/dist/Block.svelte +15 -8
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +7 -0
  6. package/dist/Elements/Code.svelte +60 -21
  7. package/dist/Elements/Code.svelte.d.ts +4 -0
  8. package/dist/Elements/Element.svelte +36 -8
  9. package/dist/Elements/Element.svelte.d.ts +2 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +2 -2
  13. package/dist/Elements/Mermaid.svelte +65 -13
  14. package/dist/Elements/Mermaid.svelte.d.ts +3 -0
  15. package/dist/Elements/MermaidDownload.svelte +29 -8
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +57 -83
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +25 -4
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +5 -2
  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 +78 -16
  26. package/dist/context.svelte.d.ts +103 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +6 -0
  31. package/dist/marked/index.js +46 -11
  32. package/dist/marked/marked-math.js +40 -1
  33. package/dist/utils/fence.d.ts +27 -0
  34. package/dist/utils/fence.js +56 -0
  35. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  36. package/dist/utils/parse-incomplete-markdown.js +283 -138
  37. package/dist/utils/table-export.d.ts +14 -0
  38. package/dist/utils/table-export.js +82 -0
  39. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  40. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  41. package/package.json +3 -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.
@@ -415,6 +467,9 @@ The animation system works by:
415
467
  2. **Sequential Animation**: Each token animates as it is received
416
468
  3. **Block-level Animation**: Entire blocks (paragraphs, headings, code blocks) animate as units
417
469
 
470
+ > [!NOTE]
471
+ > Only text that arrives in **streamed-sized appends** to `content` is animated. A bulk update — `content` replaced by a different document, a jump back to an earlier prefix, or a single append of more than ~2 KB such as pasting a whole answer or a "show all" — renders without animation, and the next streamed append animates again. Animating a whole document at once would start thousands of CSS animations in a single frame and stall the page.
472
+
418
473
  ### Animation Types
419
474
 
420
475
  Choose from 4 distinct animation styles:
@@ -698,42 +753,44 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
698
753
 
699
754
  ## 📋 Props API
700
755
 
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
- | `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) |
717
- | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
718
- | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
719
- | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
720
- | `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). |
721
- | `highlightThemes` | `Record<string, HighlightTheme>` | - | Register additional pre-imported themes (e.g. `{ dracula }`) so they can be selected via `highlightTheme`, including dynamic light/dark switching. |
722
- | `highlightLanguages` | `LanguageDefinition[]` | - | Additional languages built with `defineLanguage` (merged with the 30 built-in ones) |
723
- | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
724
- | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
725
- | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
726
- | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
727
- | `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
728
- | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
729
- | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
730
- | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
731
- | `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 |
732
- | `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
733
- | `mdxComponents` | `Record<string, Component>` | `{}` | Map of MDX component names to Svelte components (e.g., `{ Card, Button }`) |
734
- | `components` | `{ code?, mermaid?, math? }` | - | Optional heavy components for syntax highlighting, diagrams, and math rendering |
735
- | `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 } }` |
736
- | `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
756
+ | Prop | Type | Default | Description |
757
+ | -------------------------- | -------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
758
+ | `content` | `string` | - | **Required.** The markdown content to render |
759
+ | `sources` | `Record<string, any>` | - | Citation data object for inline citations |
760
+ | `class` | `string` | - | CSS class names for the wrapper element |
761
+ | `parseIncompleteMarkdown` | `boolean` | `true` | Parse and fix incomplete markdown syntax |
762
+ | `defaultOrigin` | `string` | - | Default origin for relative URLs |
763
+ | `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
764
+ | `allowedImagePrefixes` | `string[]` | `['*']` | Allowed URL prefixes for images |
765
+ | `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. |
766
+ | `inlineCitationsMode` | `'list' \| 'carousel'` | `'carousel'` | How an inline citation popover presents its sources |
767
+ | `translations` | `{ alert?: {...}, controls?: {...} }` | `defaultTranslations` | Override the built-in alert titles and control labels — see [Translations](#-translations) |
768
+ | `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 |
769
+ | `static` | `boolean` | `false` | Render finished content: skips the incomplete-markdown pass and the streaming animation |
770
+ | `element` | `HTMLElement` | - | `bind:element` to get the wrapper node |
771
+ | `streamdown` | `StreamdownContext` | - | `bind:streamdown` to read the resolved context (theme, controls, footnotes, sources) |
772
+ | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
773
+ | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
774
+ | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
775
+ | `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). |
776
+ | `highlightThemes` | `Record<string, HighlightTheme>` | - | Register additional pre-imported themes (e.g. `{ dracula }`) so they can be selected via `highlightTheme`, including dynamic light/dark switching. |
777
+ | `highlightLanguages` | `LanguageDefinition[]` | - | Additional languages built with `defineLanguage` (merged with the 30 built-in ones) |
778
+ | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
779
+ | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
780
+ | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
781
+ | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
782
+ | `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
783
+ | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
784
+ | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
785
+ | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
786
+ | `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 |
787
+ | `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
788
+ | `mdxComponents` | `Record<string, Component>` | `{}` | Map of MDX component names to Svelte components (e.g., `{ Card, Button }`) |
789
+ | `components` | `{ code?, mermaid?, math? }` | - | Optional heavy components for syntax highlighting, diagrams, and math rendering |
790
+ | `controls` | `boolean \| { code?, table?, mermaid? }` | all `true` | Toggle and configure the action toolbars for code blocks, tables and mermaid diagrams see [Controls](#-controls) |
791
+ | `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. |
792
+ | `tableMaxHeight` | `string` | - | CSS length that caps the height of tables, with the same streaming auto-scroll as `codeBlockMaxHeight`. |
793
+ | `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
737
794
 
738
795
  #### All Available Customizable Elements:
739
796
 
@@ -743,7 +800,7 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
743
800
 
744
801
  **Lists**: `ul`, `ol`, `li`
745
802
 
746
- **Code**: `code`, `codespan`
803
+ **Code**: `code`, `codespan` — `code` and `mermaid` also receive [`incomplete`](#the-incomplete-signal)
747
804
 
748
805
  **Tables**: `table`, `thead`, `tbody`, `tr`, `th`, `td`, `tfoot`
749
806
 
@@ -753,6 +810,117 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
753
810
 
754
811
  **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.
755
812
 
813
+ ## 🌍 Translations
814
+
815
+ 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).
816
+
817
+ ```svelte
818
+ <script>
819
+ import { Streamdown, defaultTranslations } from 'svelte-streamdown';
820
+ </script>
821
+
822
+ <Streamdown
823
+ {content}
824
+ translations={{
825
+ alert: { note: 'remarque', warning: 'attention' },
826
+ controls: {
827
+ copyCode: 'Copier le code',
828
+ copiedCode: 'Code copié',
829
+ downloadCode: 'Télécharger le code'
830
+ }
831
+ }}
832
+ />
833
+ ```
834
+
835
+ | Namespace | Keys |
836
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
837
+ | `alert` | `note`, `tip`, `warning`, `caution`, `important` |
838
+ | `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` |
839
+
840
+ The alert defaults are lowercase because the theme capitalizes them with CSS; if you drop that class, capitalize them here instead.
841
+
842
+ 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.
843
+
844
+ ## 🎛️ Controls
845
+
846
+ `controls` turns the code / table / mermaid toolbars on and off and configures what they do.
847
+ `controls={false}` turns every control off, `controls={true}` (the default) turns them all on,
848
+ and each section takes a boolean or an object:
849
+
850
+ ```ts
851
+ type Controls =
852
+ | boolean
853
+ | {
854
+ code?:
855
+ | boolean
856
+ | {
857
+ enabled?: boolean;
858
+ copy?: boolean;
859
+ download?: boolean | { filename?: string | ((token: CodeToken) => string) };
860
+ };
861
+ table?:
862
+ | boolean
863
+ | {
864
+ enabled?: boolean;
865
+ copy?: boolean;
866
+ download?: boolean | { filename?: string | ((token: TableToken) => string) };
867
+ csvSeparator?: ',' | ';' | '\t' | 'auto';
868
+ };
869
+ mermaid?:
870
+ | boolean
871
+ | {
872
+ enabled?: boolean;
873
+ download?: boolean | { filename?: string | ((token: CodeToken) => string) };
874
+ mouseWheelZoom?: boolean;
875
+ };
876
+ };
877
+ ```
878
+
879
+ ```svelte
880
+ <Streamdown
881
+ {content}
882
+ controls={{
883
+ code: { download: { filename: (token) => `snippet-${token.lang}` } },
884
+ table: { copy: false, csvSeparator: 'auto' },
885
+ mermaid: { mouseWheelZoom: false }
886
+ }}
887
+ />
888
+ ```
889
+
890
+ - `filename` is the **base name**; the extension still comes from the content — `languageExtensionMap`
891
+ for code (`.ts`, `.py`, … `.txt`), `.csv` / `.tsv` / `.md` / `.html` for tables, `.svg` / `.png` / `.mmd`
892
+ for diagrams. The defaults are `file`, `table` and `diagram`.
893
+ - `csvSeparator: 'auto'` picks `;` when the browser locale writes decimals with a comma (Excel reads
894
+ `,` as a decimal point there), otherwise `,`. CSV downloads carry a UTF-8 BOM so accented and CJK
895
+ text opens correctly in Excel.
896
+ - `mouseWheelZoom` is a gesture rather than a button: it stays on unless you set it to `false` or
897
+ turn every control off with `controls={false}`.
898
+
899
+ ### Table export utilities
900
+
901
+ The table toolbar's DOM walk is exported, so a custom `table` snippet can build its own copy or
902
+ download menu:
903
+
904
+ ```ts
905
+ import {
906
+ extractTableData,
907
+ tableDataToCSV,
908
+ tableDataToTSV,
909
+ tableDataToMarkdown,
910
+ tableDataToHTML,
911
+ type TableData
912
+ } from 'svelte-streamdown';
913
+
914
+ const data = extractTableData(document.querySelector('[data-streamdown-table="..."]')!);
915
+ // { headers: string[], rows: string[][] } — <br> becomes \n, colspan/rowspan become empty cells
916
+ tableDataToCSV(data, ';');
917
+ tableDataToTSV(data);
918
+ ```
919
+
920
+ `tableDataToMarkdown` and `tableDataToHTML` are there for callers that only hold a DOM table; the
921
+ built-in menu copies Markdown from `token.raw` instead, which keeps the author's original inline
922
+ formatting.
923
+
756
924
  ## 🎨 Theming System
757
925
 
758
926
  ### Built-in Themes
package/dist/Block.svelte CHANGED
@@ -4,23 +4,30 @@
4
4
  import { lex, type StreamdownToken } from './marked/index.js';
5
5
  import AnimatedText from './AnimatedText.svelte';
6
6
  import { useStreamdown } from './context.svelte.js';
7
- import { getContext } from 'svelte';
7
+ import { getContext, untrack } from 'svelte';
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();
18
21
  // The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
19
22
  // is aliased so the context flag and the helper cannot be confused.
20
23
  const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
21
- const tokens = $derived(
22
- lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
23
- );
24
+ const view = $derived.by(() => {
25
+ const tokens = lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions);
26
+ // Decided when this block's text changes and deliberately not tracked: a
27
+ // bulk update renders plain, and the next streamed chunk must not
28
+ // retroactively animate the blocks it left untouched.
29
+ return { tokens, animate: untrack(() => streamdown.animateUpdate) };
30
+ });
24
31
  const insidePopover = getContext('POPOVER');
25
32
  </script>
26
33
 
@@ -29,9 +36,9 @@
29
36
  {#if token}
30
37
  {@const children = (token as any)?.tokens || []}
31
38
  {@const isTextOnlyNode = children.length === 0}
32
- <Element {token}>
39
+ <Element {token} {incomplete} animate={view.animate}>
33
40
  {#if isTextOnlyNode}
34
- {#if streamdown.animation.enabled && !insidePopover && !isStatic}
41
+ {#if streamdown.animation.enabled && view.animate && !insidePopover && !isStatic}
35
42
  <AnimatedText text={'text' in token ? token.text || '' : ''} />
36
43
  {:else}
37
44
  {'text' in token ? token.text : ''}
@@ -44,4 +51,4 @@
44
51
  {/each}
45
52
  {/snippet}
46
53
 
47
- {@render renderChildren(tokens)}
54
+ {@render renderChildren(view.tokens)}
@@ -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>
@@ -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
@@ -4,14 +4,22 @@
4
4
  import { useCopy } from '../utils/copy.svelte.js';
5
5
  import { highlightLines, languageExtensionMap } from '../utils/hightlighter.svelte.js';
6
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,
15
+ animate = true
12
16
  }: {
13
17
  token: CodeToken;
14
18
  id: string;
19
+ /** The fence is still being streamed; nothing below it is final yet. */
20
+ incomplete?: boolean;
21
+ /** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
22
+ animate?: boolean;
15
23
  } = $props();
16
24
 
17
25
  const streamdown = useStreamdown();
@@ -35,7 +43,8 @@
35
43
  token.lang && token.lang in languageExtensionMap
36
44
  ? languageExtensionMap[token.lang as keyof typeof languageExtensionMap]
37
45
  : 'txt';
38
- const filename = `file.${extension}`;
46
+ const base = streamdown.controls.codeDownloadFilename;
47
+ const filename = `${typeof base === 'function' ? base(token) : base}.${extension}`;
39
48
  const mimeType = 'text/plain';
40
49
  save(filename, code, mimeType);
41
50
  } catch (error) {
@@ -44,42 +53,72 @@
44
53
  };
45
54
 
46
55
  const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
56
+
57
+ // `pre` is already the horizontal scroll container, so capping it there keeps
58
+ // one scrollable box for both axes (and the horizontal scrollbar visible).
59
+ const pinnedScroll = usePinnedScroll({
60
+ get maxHeight() {
61
+ return streamdown.codeBlockMaxHeight;
62
+ },
63
+ get content() {
64
+ return code;
65
+ }
66
+ });
47
67
  </script>
48
68
 
49
69
  <div
50
70
  data-streamdown-code={id}
51
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
71
+ data-incomplete={incomplete || undefined}
72
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
52
73
  class={streamdown.theme.code.base}
53
74
  >
54
75
  <div class={streamdown.theme.code.header}>
55
76
  <span class={streamdown.theme.code.language}>{token.lang}</span>
56
- {#if streamdown.controls.code}
77
+ {#if streamdown.controls.codeCopy || streamdown.controls.codeDownload}
57
78
  <div class={streamdown.theme.code.buttons}>
58
- <button
59
- class={streamdown.theme.components.button}
60
- onclick={downloadCode}
61
- title="Download code"
62
- type="button"
63
- >
64
- {@render (streamdown.icons?.download || downloadIcon)()}
65
- </button>
79
+ {#if streamdown.controls.codeDownload}
80
+ <button
81
+ class={streamdown.theme.components.button}
82
+ onclick={downloadCode}
83
+ title={streamdown.translations.controls.downloadCode}
84
+ aria-label={streamdown.translations.controls.downloadCode}
85
+ type="button"
86
+ >
87
+ {@render (streamdown.icons?.download || downloadIcon)()}
88
+ </button>
89
+ {/if}
66
90
 
67
- <button class={streamdown.theme.components.button} onclick={copy.copy} type="button">
68
- {#if copy.isCopied}
69
- {@render (streamdown.icons?.check || checkIcon)()}
70
- {:else}
71
- {@render (streamdown.icons?.copy || copyIcon)()}
72
- {/if}
73
- </button>
91
+ {#if streamdown.controls.codeCopy}
92
+ <button
93
+ class={streamdown.theme.components.button}
94
+ onclick={copy.copy}
95
+ title={streamdown.translations.controls.copyCode}
96
+ aria-label={streamdown.translations.controls.copyCode}
97
+ type="button"
98
+ >
99
+ {#if copy.isCopied}
100
+ {@render (streamdown.icons?.check || checkIcon)()}
101
+ {:else}
102
+ {@render (streamdown.icons?.copy || copyIcon)()}
103
+ {/if}
104
+ </button>
105
+ {/if}
106
+ <span aria-live="polite" style={srOnly}
107
+ >{copy.isCopied ? streamdown.translations.controls.copiedCode : ''}</span
108
+ >
74
109
  </div>
75
110
  {/if}
76
111
  </div>
77
112
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
78
- <pre class={streamdown.theme.code.pre}><code
113
+ <pre
114
+ class={streamdown.theme.code.pre}
115
+ style:max-height={streamdown.codeBlockMaxHeight}
116
+ style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
117
+ {@attach pinnedScroll}><code
79
118
  >{#each lines as line}<span class={streamdown.theme.code.line}
80
119
  >{#if line.length === 0}&#8203;{/if}{#each line as t}<span
81
120
  class="th-token{t.className ? ` th-${t.className}` : ''}"
82
- style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
121
+ style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
83
122
  style:color={streamdown.highlightTheme.tokens[t.className ?? 'token']}
84
123
  >{t.value}</span
85
124
  >{/each}</span
@@ -2,6 +2,10 @@ import type { CodeToken } from '../marked/index.js';
2
2
  type $$ComponentProps = {
3
3
  token: CodeToken;
4
4
  id: string;
5
+ /** The fence is still being streamed; nothing below it is final yet. */
6
+ incomplete?: boolean;
7
+ /** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
8
+ animate?: boolean;
5
9
  };
6
10
  declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
7
11
  type Code = ReturnType<typeof Code>;