svelte-streamdown 4.1.0 → 4.2.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 (37) hide show
  1. package/README.md +149 -47
  2. package/dist/Block.svelte +19 -8
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Code.svelte +15 -4
  5. package/dist/Elements/Code.svelte.d.ts +2 -0
  6. package/dist/Elements/Element.svelte +32 -11
  7. package/dist/Elements/Element.svelte.d.ts +1 -0
  8. package/dist/Elements/Mermaid.svelte +5 -11
  9. package/dist/Elements/Mermaid.svelte.d.ts +1 -0
  10. package/dist/Elements/TableDownload.svelte +44 -3
  11. package/dist/Elements/TableDownload.svelte.d.ts +3 -1
  12. package/dist/Elements/fallbacks/CodeFallback.svelte +16 -6
  13. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -2
  14. package/dist/Elements/icons.d.ts +1 -0
  15. package/dist/Elements/icons.js +8 -0
  16. package/dist/Streamdown.svelte +68 -6
  17. package/dist/context.svelte.d.ts +37 -1
  18. package/dist/context.svelte.js +3 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +2 -1
  21. package/dist/marked/index.d.ts +11 -4
  22. package/dist/marked/index.js +54 -45
  23. package/dist/marked/marked-mdx.d.ts +27 -1
  24. package/dist/marked/marked-mdx.js +37 -8
  25. package/dist/theme.d.ts +6 -0
  26. package/dist/theme.js +12 -4
  27. package/dist/utils/expand.svelte.d.ts +21 -0
  28. package/dist/utils/expand.svelte.js +46 -0
  29. package/dist/utils/fence.d.ts +12 -1
  30. package/dist/utils/fence.js +34 -17
  31. package/dist/utils/line-numbers.d.ts +23 -0
  32. package/dist/utils/line-numbers.js +30 -0
  33. package/dist/utils/normalize-html-indentation.d.ts +10 -0
  34. package/dist/utils/normalize-html-indentation.js +52 -0
  35. package/dist/utils/parse-incomplete-markdown.d.ts +20 -6
  36. package/dist/utils/parse-incomplete-markdown.js +173 -69
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -77,12 +77,63 @@ Full support for
77
77
  > [!NOTE]
78
78
  > 🧠 **AI Prompting Tip:** For best results, use our [comprehensive prompt](/prompting) covering all supported markdown features.
79
79
 
80
+ ### 🏷️ Raw HTML
81
+
82
+ `renderHtml` decides what happens to HTML in the markdown: off (the default) shows the source as
83
+ literal text, `true` renders it, and a function lets you sanitize it yourself and return the string.
84
+
85
+ Pretty-printed HTML has one markdown trap: four leading spaces after a blank line is an indented
86
+ code block, tag or not, so a nested `<div>` shows up in a code box instead of rendering.
87
+ `normalizeHtmlIndentation` dedents tag lines before parsing and leaves `<pre>` and `<code>` bodies
88
+ byte-for-byte:
89
+
90
+ ```svelte
91
+ <Streamdown {content} renderHtml normalizeHtmlIndentation />
92
+ ```
93
+
94
+ The trap only bites where blocks are not trimmed: `static`, `parseIncompleteMarkdown={false}`,
95
+ or calling the exported function on raw text. The default streaming path trims each block, which
96
+ strips the leading four spaces, so it never sees the code box in the first place.
97
+
98
+ It is off by default because dedenting is lossy, and it is exported as a plain function
99
+ (`import { normalizeHtmlIndentation } from 'svelte-streamdown'`) if you would rather pre-process
100
+ the string yourself.
101
+
80
102
  ### 💻 Interactive Code Blocks
81
103
 
82
104
  - Syntax highlighting powered by [@tanstack/highlight](https://github.com/TanStack/highlight) (synchronous, SSR-friendly, ~31KB min / ~11KB gzip for every language)
83
105
  - Copy-to-clipboard functionality
84
106
  - Download the snippet with the extension of its language
85
107
  - Support any `@tanstack/highlight` theme, or your own
108
+ - Optional line numbers (`lineNumbers`, off by default)
109
+
110
+ #### Line numbers
111
+
112
+ `lineNumbers` numbers every code block. It is a CSS counter on the line that is already
113
+ rendered — no extra element per line, and the numbers are pseudo-content, so copy,
114
+ download and text selection only ever give you the code.
115
+
116
+ ```svelte
117
+ <Streamdown {content} lineNumbers />
118
+ ```
119
+
120
+ Three words in the fence's info string, after the language, override the prop for
121
+ that block: `lineNumbers` numbers it even when the prop is off, `noLineNumbers` leaves
122
+ it unnumbered even when the prop is on, and `startLine=N` starts the count at N instead
123
+ of 1 (anything non-numeric is ignored).
124
+
125
+ ````markdown
126
+ ```ts startLine=10
127
+ const a = 1;
128
+ ```
129
+
130
+ ```ts noLineNumbers
131
+ const b = 2;
132
+ ```
133
+ ````
134
+
135
+ The gutter's width and colour are theme data (`theme.code.lineNumber`), so they follow
136
+ your theme like every other class.
86
137
 
87
138
  ### 🔢 Mathematical Expressions
88
139
 
@@ -157,6 +208,18 @@ pie title Project Time Allocation
157
208
  Tables copy and download as Markdown, HTML, CSV or TSV — see [Controls](#-controls) for the
158
209
  separator and filename options, and for the exported table utilities.
159
210
 
211
+ #### Fullscreen
212
+
213
+ Wide tables get an expand toggle next to copy and download: it lifts the table out of the flow
214
+ (`position: fixed`, the whole viewport) so all of its columns are reachable, and the toolbar is
215
+ repositioned to the top right so copy and download stay usable. `Escape` or the same button — now a
216
+ close icon — collapses it and puts focus back on the toggle. While expanded the wrapper is a
217
+ `role="dialog"` container labelled with `translations.controls.table`, and it carries
218
+ `data-expanded="true"` plus the `theme.table.expanded` classes. It is deliberately not
219
+ `aria-modal`: the toolbar is a sibling of the wrapper, not a child, so marking the wrapper modal
220
+ would hide those buttons from assistive tech — that comes with a focus trap in a later release.
221
+ Turn it off with `controls={{ table: { fullscreen: false } }}`.
222
+
160
223
  #### Colspan
161
224
 
162
225
  | H1 | H2 | H3 |
@@ -467,6 +530,9 @@ The animation system works by:
467
530
  2. **Sequential Animation**: Each token animates as it is received
468
531
  3. **Block-level Animation**: Entire blocks (paragraphs, headings, code blocks) animate as units
469
532
 
533
+ > [!NOTE]
534
+ > 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.
535
+
470
536
  ### Animation Types
471
537
 
472
538
  Choose from 4 distinct animation styles:
@@ -750,44 +816,48 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
750
816
 
751
817
  ## 📋 Props API
752
818
 
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 |
819
+ | Prop | Type | Default | Description |
820
+ | -------------------------- | -------------------------------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
821
+ | `content` | `string` | - | **Required.** The markdown content to render |
822
+ | `sources` | `Record<string, any>` | - | Citation data object for inline citations |
823
+ | `class` | `string` | - | CSS class names for the wrapper element |
824
+ | `parseIncompleteMarkdown` | `boolean` | `true` | Parse and fix incomplete markdown syntax |
825
+ | `defaultOrigin` | `string` | - | Default origin for relative URLs |
826
+ | `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
827
+ | `allowedImagePrefixes` | `string[]` | `['*']` | Allowed URL prefixes for images |
828
+ | `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. |
829
+ | `inlineCitationsMode` | `'list' \| 'carousel'` | `'carousel'` | How an inline citation popover presents its sources |
830
+ | `translations` | `{ alert?: {...}, controls?: {...} }` | `defaultTranslations` | Override the built-in alert titles and control labels — see [Translations](#-translations) |
831
+ | `icons` | `Partial<Record<IconName, Snippet>>` | - | Replace any built-in icon (`copy`, `check`, `download`, `fullscreen`, `close`, `zoomIn`, `zoomOut`, `fitView`, `chevronLeft`, `chevronRight`, `note`, `tip`, `warning`, `caution`, `important`) with your own snippet |
832
+ | `static` | `boolean` | `false` | Render finished content: skips the incomplete-markdown pass and the streaming animation |
833
+ | `element` | `HTMLElement` | - | `bind:element` to get the wrapper node |
834
+ | `streamdown` | `StreamdownContext` | - | `bind:streamdown` to read the resolved context (theme, controls, footnotes, sources) |
835
+ | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
836
+ | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
837
+ | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
838
+ | `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). |
839
+ | `highlightThemes` | `Record<string, HighlightTheme>` | - | Register additional pre-imported themes (e.g. `{ dracula }`) so they can be selected via `highlightTheme`, including dynamic light/dark switching. |
840
+ | `highlightLanguages` | `LanguageDefinition[]` | - | Additional languages built with `defineLanguage` (merged with the 30 built-in ones) |
841
+ | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
842
+ | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
843
+ | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
844
+ | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
845
+ | `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
846
+ | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
847
+ | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
848
+ | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
849
+ | `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 |
850
+ | `extensions` | `Array<Extension>` | `[]` | Custom marked tokenizers to render special markdown blocks or inline tokens |
851
+ | `mdxComponents` | `Record<string, Component>` | `{}` | Map of MDX component names to Svelte components (e.g., `{ Card, Button }`) |
852
+ | `customTags` | `string[]` | `[]` | Extra tag names the MDX tokenizer accepts on top of PascalCase, so `<ai-thinking>` becomes a component instead of a raw HTML block. Keys of `mdxComponents` are allowed automatically — see [Component Naming](#component-naming) |
853
+ | `literalTagContent` | `string[]` | `[]` | Tags whose children render verbatim: no markdown parsing, `**` and `_` left exactly as written. Listing a tag here also allows it, like `customTags` |
854
+ | `normalizeHtmlIndentation` | `boolean` | `false` | Dedent pretty-printed HTML before parsing, so a nested tag indented four spaces after a blank line is not read as an indented code block. `<pre>` and `<code>` bodies are never touched |
855
+ | `components` | `{ code?, mermaid?, math? }` | - | Optional heavy components for syntax highlighting, diagrams, and math rendering |
856
+ | `controls` | `boolean \| { code?, table?, mermaid? }` | all `true` | Toggle and configure the action toolbars for code blocks, tables and mermaid diagrams — see [Controls](#-controls) |
857
+ | `lineNumbers` | `boolean` | `false` | Number the lines of every code block. A fence can override it with a `lineNumbers` / `noLineNumbers` meta word, and start the count at N with `startLine=N`. |
858
+ | `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. |
859
+ | `tableMaxHeight` | `string` | - | CSS length that caps the height of tables, with the same streaming auto-scroll as `codeBlockMaxHeight`. |
860
+ | `children` | `Snippet<[{token:GenericToken, streamdown: StreamdownContext, children: Snippet` | `undefined` | Snippet used to render elements not supported by Streamdown, custom extensions, and MDX components |
791
861
 
792
862
  #### All Available Customizable Elements:
793
863
 
@@ -829,10 +899,10 @@ Every string the components render themselves — alert titles, button labels, d
829
899
  />
830
900
  ```
831
901
 
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` |
902
+ | Namespace | Keys |
903
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
904
+ | `alert` | `note`, `tip`, `warning`, `caution`, `important` |
905
+ | `controls` | `copyCode`, `copiedCode`, `downloadCode`, `copyTable`, `copiedTable`, `downloadTable`, `tableFormatMarkdown`, `tableFormatHtml`, `tableFormatCsv`, `tableFormatTsv`, `tableFullscreen`, `exitTableFullscreen`, `table`, `downloadDiagram`, `downloadDiagramPng`, `downloadDiagramSvg`, `downloadDiagramMmd`, `zoomIn`, `zoomOut`, `resetView`, `fullscreen`, `exitFullscreen`, `diagram`, `previousCitation`, `nextCitation`, `blockedUrl`, `imageBlocked`, `imageNoDescription`, `linkBlocked` |
836
906
 
837
907
  The alert defaults are lowercase because the theme capitalizes them with CSS; if you drop that class, capitalize them here instead.
838
908
 
@@ -861,6 +931,7 @@ type Controls =
861
931
  enabled?: boolean;
862
932
  copy?: boolean;
863
933
  download?: boolean | { filename?: string | ((token: TableToken) => string) };
934
+ fullscreen?: boolean;
864
935
  csvSeparator?: ',' | ';' | '\t' | 'auto';
865
936
  };
866
937
  mermaid?:
@@ -892,6 +963,9 @@ type Controls =
892
963
  text opens correctly in Excel.
893
964
  - `mouseWheelZoom` is a gesture rather than a button: it stays on unless you set it to `false` or
894
965
  turn every control off with `controls={false}`.
966
+ - `table.fullscreen` is the expand toggle in the table toolbar (on by default). It is a third
967
+ action, so the toolbar survives `copy: false, download: false`; set all three to `false` to get
968
+ rid of it.
895
969
 
896
970
  ### Table export utilities
897
971
 
@@ -1001,13 +1075,13 @@ Each component supports multiple themeable parts:
1001
1075
 
1002
1076
  **Links (`a`)**: `base`, `blocked` (for blocked/unsafe links)
1003
1077
 
1004
- **Code (`code`)**: `base`, `container`, `header`, `buttons`, `language`, `line`, `pre`
1078
+ **Code (`code`)**: `base`, `container`, `header`, `buttons`, `language`, `line`, `lineNumber`, `pre`
1005
1079
 
1006
1080
  **Inline Code (`inlineCode`)**: `base`
1007
1081
 
1008
1082
  **Images (`img`)**: `container`, `base`, `downloadButton`
1009
1083
 
1010
- **Tables (`table`, `thead`, `tbody`, `tr`, `th`, `td`)**: `base`, `container` (table only)
1084
+ **Tables (`table`, `thead`, `tbody`, `tr`, `th`, `td`)**: `base`, plus `table` and `expanded` (table only)
1011
1085
 
1012
1086
  **Blockquotes (`blockquote`)**: `base`
1013
1087
 
@@ -1147,9 +1221,36 @@ MDX components support three attribute value types:
1147
1221
 
1148
1222
  ### Component Naming
1149
1223
 
1150
- - Component names **must start with a capital letter** (PascalCase)
1151
- - Valid: `<Card />`, `<MyComponent />`, `<Component123 />`
1152
- - Invalid: `<card />`, `<myComponent />` (these are treated as HTML)
1224
+ - PascalCase names are **always** components: `<Card />`, `<MyComponent />`, `<Component123 />`
1225
+ - Lowercase and hyphenated names are **opt-in**. `<card />`, `<ai-thinking>` and `<mention>` are plain HTML unless you list them, so nothing you write today changes meaning.
1226
+ - A name is listed by putting it in `customTags`, or simply by registering it in `mdxComponents` — its keys are allowlisted for you.
1227
+
1228
+ ```svelte
1229
+ <Streamdown
1230
+ content={`<ai-thinking>\nLet me **check** that.\n</ai-thinking>`}
1231
+ customTags={['ai-thinking']}
1232
+ >
1233
+ {#snippet mdx({ token, children })}
1234
+ {#if token.tagName === 'ai-thinking'}
1235
+ <aside class="text-sm text-gray-500">{@render children()}</aside>
1236
+ {/if}
1237
+ {/snippet}
1238
+ </Streamdown>
1239
+ ```
1240
+
1241
+ The allowlist is compiled once and shared by the tokenizer and the streaming completer, so a half-typed `<ai-think` is hidden while it streams rather than flashing as text.
1242
+
1243
+ ### Literal Tag Content
1244
+
1245
+ Some tags carry data, not markdown. List them in `literalTagContent` and their children become a single text token — underscores, asterisks and backticks survive untouched:
1246
+
1247
+ ```svelte
1248
+ <!-- Markdown: <mention user_id="1">@_john_doe_</mention> -->
1249
+ <Streamdown {content} literalTagContent={['mention']} />
1250
+ <!-- renders @_john_doe_, not @john_doe with an italic run -->
1251
+ ```
1252
+
1253
+ Attribute names may contain hyphens (`<mention data-id="7">`), and values use the same `attr="string"` / `attr={expression}` forms as PascalCase components.
1153
1254
 
1154
1255
  ### Streaming Safety
1155
1256
 
@@ -1158,6 +1259,7 @@ MDX components are streaming-safe. Incomplete components are automatically handl
1158
1259
  - Incomplete tags like `<Component attr` not rendered to prevent runtime errors
1159
1260
  - Unclosed components like `<Card>content` are auto-closed with `</Card>`
1160
1261
  - Malformed attributes are escaped to prevent rendering errors
1262
+ - Half-typed **HTML** tags are hidden too: `Hello <div cla` renders as `Hello` until the `>` arrives. This only ever applies to the very end of the block still streaming, and only to a name that could still grow into a common HTML element, a PascalCase component or a `customTags` entry, followed by nothing but attributes. A paragraph reading `if a <b then c` keeps its text wherever it sits, and so do `Use the <div element to wrap it.` and `3 < 5`.
1161
1263
 
1162
1264
  This ensures your UI remains stable even when receiving partial markdown from streaming AI responses.
1163
1265
 
package/dist/Block.svelte CHANGED
@@ -4,26 +4,37 @@
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
11
  static: isStatic = false,
12
- incomplete = false
12
+ incomplete = false,
13
+ live = false
13
14
  }: {
14
15
  block: string;
15
16
  static?: boolean;
16
17
  /** This block ends inside an unfinished code fence (streaming only). */
17
18
  incomplete?: boolean;
19
+ /** This is the block still being streamed into — the only one whose tail may be cut. */
20
+ live?: boolean;
18
21
  } = $props();
19
22
 
20
23
  const streamdown = useStreamdown();
21
24
  // The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
22
25
  // is aliased so the context flag and the helper cannot be confused.
23
26
  const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
24
- const tokens = $derived(
25
- lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
26
- );
27
+ const view = $derived.by(() => {
28
+ const tokens = lex(
29
+ complete ? completeMarkdown(block.trim(), { tags: streamdown.tags, live }) : block,
30
+ streamdown.extensions,
31
+ streamdown.tags
32
+ );
33
+ // Decided when this block's text changes and deliberately not tracked: a
34
+ // bulk update renders plain, and the next streamed chunk must not
35
+ // retroactively animate the blocks it left untouched.
36
+ return { tokens, animate: untrack(() => streamdown.animateUpdate) };
37
+ });
27
38
  const insidePopover = getContext('POPOVER');
28
39
  </script>
29
40
 
@@ -32,9 +43,9 @@
32
43
  {#if token}
33
44
  {@const children = (token as any)?.tokens || []}
34
45
  {@const isTextOnlyNode = children.length === 0}
35
- <Element {token} {incomplete}>
46
+ <Element {token} {incomplete} animate={view.animate}>
36
47
  {#if isTextOnlyNode}
37
- {#if streamdown.animation.enabled && !insidePopover && !isStatic}
48
+ {#if streamdown.animation.enabled && view.animate && !insidePopover && !isStatic}
38
49
  <AnimatedText text={'text' in token ? token.text || '' : ''} />
39
50
  {:else}
40
51
  {'text' in token ? token.text : ''}
@@ -47,4 +58,4 @@
47
58
  {/each}
48
59
  {/snippet}
49
60
 
50
- {@render renderChildren(tokens)}
61
+ {@render renderChildren(view.tokens)}
@@ -3,6 +3,8 @@ type $$ComponentProps = {
3
3
  static?: boolean;
4
4
  /** This block ends inside an unfinished code fence (streaming only). */
5
5
  incomplete?: boolean;
6
+ /** This is the block still being streamed into — the only one whose tail may be cut. */
7
+ live?: boolean;
6
8
  };
7
9
  declare const Block: import("svelte").Component<$$ComponentProps, {}, "">;
8
10
  type Block = ReturnType<typeof Block>;
@@ -5,18 +5,22 @@
5
5
  import { highlightLines, languageExtensionMap } from '../utils/hightlighter.svelte.js';
6
6
  import type { CodeToken } from '../marked/index.js';
7
7
  import { usePinnedScroll } from '../utils/usePinnedScroll.svelte.js';
8
+ import { resolveLineNumbers } from '../utils/line-numbers.js';
8
9
  import { checkIcon, copyIcon, downloadIcon } from './icons.js';
9
10
  import { srOnly } from './srOnly.js';
10
11
 
11
12
  const {
12
13
  token,
13
14
  id,
14
- incomplete = false
15
+ incomplete = false,
16
+ animate = true
15
17
  }: {
16
18
  token: CodeToken;
17
19
  id: string;
18
20
  /** The fence is still being streamed; nothing below it is final yet. */
19
21
  incomplete?: boolean;
22
+ /** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
23
+ animate?: boolean;
20
24
  } = $props();
21
25
 
22
26
  const streamdown = useStreamdown();
@@ -51,6 +55,8 @@
51
55
 
52
56
  const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
53
57
 
58
+ const lineNumbers = $derived(resolveLineNumbers(token.meta, streamdown.lineNumbers ?? false));
59
+
54
60
  // `pre` is already the horizontal scroll container, so capping it there keeps
55
61
  // one scrollable box for both axes (and the horizontal scrollbar visible).
56
62
  const pinnedScroll = usePinnedScroll({
@@ -66,7 +72,7 @@
66
72
  <div
67
73
  data-streamdown-code={id}
68
74
  data-incomplete={incomplete || undefined}
69
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
75
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
70
76
  class={streamdown.theme.code.base}
71
77
  >
72
78
  <div class={streamdown.theme.code.header}>
@@ -109,13 +115,18 @@
109
115
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
110
116
  <pre
111
117
  class={streamdown.theme.code.pre}
118
+ data-line-numbers={lineNumbers.enabled || undefined}
119
+ style:counter-reset={lineNumbers.enabled ? `sd-line ${lineNumbers.start - 1}` : undefined}
112
120
  style:max-height={streamdown.codeBlockMaxHeight}
113
121
  style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
114
122
  {@attach pinnedScroll}><code
115
- >{#each lines as line}<span class={streamdown.theme.code.line}
123
+ >{#each lines as line}<span
124
+ class="{streamdown.theme.code.line}{lineNumbers.enabled
125
+ ? ` ${streamdown.theme.code.lineNumber}`
126
+ : ''}"
116
127
  >{#if line.length === 0}&#8203;{/if}{#each line as t}<span
117
128
  class="th-token{t.className ? ` th-${t.className}` : ''}"
118
- style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
129
+ style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
119
130
  style:color={streamdown.highlightTheme.tokens[t.className ?? 'token']}
120
131
  >{t.value}</span
121
132
  >{/each}</span
@@ -4,6 +4,8 @@ type $$ComponentProps = {
4
4
  id: string;
5
5
  /** The fence is still being streamed; nothing below it is final yet. */
6
6
  incomplete?: boolean;
7
+ /** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
8
+ animate?: boolean;
7
9
  };
8
10
  declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
9
11
  type Code = ReturnType<typeof Code>;
@@ -15,8 +15,14 @@
15
15
  let {
16
16
  token,
17
17
  children,
18
- incomplete = false
19
- }: { token: StreamdownToken; children: Snippet; incomplete?: boolean } = $props();
18
+ incomplete = false,
19
+ animate = true
20
+ }: {
21
+ token: StreamdownToken;
22
+ children: Snippet;
23
+ incomplete?: boolean;
24
+ animate?: boolean;
25
+ } = $props();
20
26
  const streamdown = useStreamdown();
21
27
 
22
28
  // Use provided components or fallback to lightweight versions
@@ -25,9 +31,16 @@
25
31
  const MathComponent = $derived(streamdown.components?.math ?? MathFallback);
26
32
 
27
33
  // Only apply animation on block level elements. Leaves text elements to be animated by their text children.
28
- const style = $derived(streamdown.isMounted ? streamdown.animationBlockStyle : '');
34
+ const style = $derived(animate && streamdown.isMounted ? streamdown.animationBlockStyle : '');
29
35
  const id = $props.id();
30
36
 
37
+ // Owned here because the table wrapper below is what goes fullscreen, while
38
+ // TableDownload holds the toggle.
39
+ let tableExpanded = $state(false);
40
+ // One below the global [data-expanded='true'] rule, so the download toolbar —
41
+ // which precedes this wrapper in the DOM — can still paint over the overlay.
42
+ const expandedZIndex = 2147483646;
43
+
31
44
  // Only ever attached to the table wrapper, which is the element that already
32
45
  // owns the horizontal scroll.
33
46
  const tableScroll = usePinnedScroll({
@@ -91,11 +104,11 @@
91
104
  props={{ children, token, incomplete }}
92
105
  render={streamdown.snippets.mermaid ?? streamdown.snippets.code}
93
106
  >
94
- <MermaidComponent {id} {token} {incomplete} />
107
+ <MermaidComponent {id} {token} {incomplete} {animate} />
95
108
  </Slot>
96
109
  {:else if token.type === 'code'}
97
110
  <Slot props={{ children, token, incomplete }} render={streamdown.snippets.code}>
98
- <CodeComponent {id} {token} {incomplete} />
111
+ <CodeComponent {id} {token} {incomplete} {animate} />
99
112
  </Slot>
100
113
  {:else if token.type === 'codespan'}
101
114
  <Slot props={{ children, token }} render={streamdown.snippets.codespan}>
@@ -143,17 +156,25 @@
143
156
  </Slot>
144
157
  {:else if token.type === 'table'}
145
158
  <Slot props={{ token, children }} render={streamdown.snippets.table}>
146
- {#if streamdown.controls.tableCopy || streamdown.controls.tableDownload}
147
- <TableDownload {id} {token} />
159
+ <!-- The toolbar stays a sibling of the wrapper and is only repositioned
160
+ while expanded, so the wrapper is a `role="dialog"` but deliberately
161
+ NOT `aria-modal`: that would mark the toolbar's own buttons inert for
162
+ any AT that honours it. Add it together with a focus trap. -->
163
+ {#if streamdown.controls.tableCopy || streamdown.controls.tableDownload || streamdown.controls.tableFullscreen}
164
+ <TableDownload {id} {token} bind:expanded={tableExpanded} />
148
165
  {/if}
149
166
  <div
150
167
  data-streamdown-table={id}
151
168
  {style}
152
- class={`${streamdown.theme.table.base} group`}
169
+ class={`${streamdown.theme.table.base} group ${tableExpanded ? streamdown.theme.table.expanded : ''}`}
153
170
  style:overscroll-behavior-x="none"
154
- style:max-height={streamdown.tableMaxHeight}
155
- style:overflow-y={streamdown.tableMaxHeight ? 'auto' : undefined}
171
+ style:max-height={tableExpanded ? undefined : streamdown.tableMaxHeight}
172
+ style:overflow-y={streamdown.tableMaxHeight && !tableExpanded ? 'auto' : undefined}
173
+ style:z-index={tableExpanded ? expandedZIndex : undefined}
156
174
  {@attach tableScroll}
175
+ tabindex="-1"
176
+ role={tableExpanded ? 'dialog' : undefined}
177
+ aria-label={tableExpanded ? streamdown.translations.controls.table : undefined}
157
178
  >
158
179
  <table class={streamdown.theme.table.table}>
159
180
  {@render children()}
@@ -284,7 +305,7 @@
284
305
  <Slot props={{ children, token }} render={streamdown.snippets.descriptionList}>
285
306
  <dl
286
307
  data-streamdown-description-list={id}
287
- style={streamdown.animationBlockStyle}
308
+ style={animate ? streamdown.animationBlockStyle : ''}
288
309
  class={streamdown.theme.descriptionList.base}
289
310
  >
290
311
  {@render children()}
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  token: StreamdownToken;
5
5
  children: Snippet;
6
6
  incomplete?: boolean;
7
+ animate?: boolean;
7
8
  };
8
9
  declare const Element: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type Element = ReturnType<typeof Element>;
@@ -13,12 +13,14 @@
13
13
  const {
14
14
  token,
15
15
  id,
16
- incomplete = false
16
+ incomplete = false,
17
+ animate = true
17
18
  }: {
18
19
  token: CodeToken;
19
20
  id: string;
20
21
  /** The fence is still being streamed; nothing below it is final yet. */
21
22
  incomplete?: boolean;
23
+ animate?: boolean;
22
24
  } = $props();
23
25
 
24
26
  // Trailing blank lines are noise for mermaid but they still changed `token.text`
@@ -251,7 +253,7 @@
251
253
  {#if mermaid}
252
254
  <div
253
255
  bind:this={container}
254
- style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
256
+ style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
255
257
  class={streamdown.theme.mermaid.base}
256
258
  {@attach (node) => {
257
259
  // A half-written diagram makes mermaid throw and log on every chunk, so
@@ -321,15 +323,7 @@
321
323
  </div>
322
324
 
323
325
  <style>
324
- :global([data-expanded='true']) {
325
- position: fixed;
326
- top: 16px;
327
- left: 16px;
328
- width: calc(100vw - 32px);
329
- height: calc(100vh - 32px);
330
- z-index: 2147483647;
331
- margin: 0px;
332
- }
326
+ /* [data-expanded='true'] now lives in Streamdown.svelte's global block. */
333
327
 
334
328
  /* Hide Mermaid's temporary rendering containers */
335
329
  :global(div[id^='dmermaid-']) {
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  id: string;
5
5
  /** The fence is still being streamed; nothing below it is final yet. */
6
6
  incomplete?: boolean;
7
+ animate?: boolean;
7
8
  };
8
9
  declare const Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
9
10
  type Mermaid = ReturnType<typeof Mermaid>;