svelte-streamdown 4.1.1 → 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.
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 |
@@ -753,44 +816,48 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
753
816
 
754
817
  ## 📋 Props API
755
818
 
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 |
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 |
794
861
 
795
862
  #### All Available Customizable Elements:
796
863
 
@@ -832,10 +899,10 @@ Every string the components render themselves — alert titles, button labels, d
832
899
  />
833
900
  ```
834
901
 
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` |
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` |
839
906
 
840
907
  The alert defaults are lowercase because the theme capitalizes them with CSS; if you drop that class, capitalize them here instead.
841
908
 
@@ -864,6 +931,7 @@ type Controls =
864
931
  enabled?: boolean;
865
932
  copy?: boolean;
866
933
  download?: boolean | { filename?: string | ((token: TableToken) => string) };
934
+ fullscreen?: boolean;
867
935
  csvSeparator?: ',' | ';' | '\t' | 'auto';
868
936
  };
869
937
  mermaid?:
@@ -895,6 +963,9 @@ type Controls =
895
963
  text opens correctly in Excel.
896
964
  - `mouseWheelZoom` is a gesture rather than a button: it stays on unless you set it to `false` or
897
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.
898
969
 
899
970
  ### Table export utilities
900
971
 
@@ -1004,13 +1075,13 @@ Each component supports multiple themeable parts:
1004
1075
 
1005
1076
  **Links (`a`)**: `base`, `blocked` (for blocked/unsafe links)
1006
1077
 
1007
- **Code (`code`)**: `base`, `container`, `header`, `buttons`, `language`, `line`, `pre`
1078
+ **Code (`code`)**: `base`, `container`, `header`, `buttons`, `language`, `line`, `lineNumber`, `pre`
1008
1079
 
1009
1080
  **Inline Code (`inlineCode`)**: `base`
1010
1081
 
1011
1082
  **Images (`img`)**: `container`, `base`, `downloadButton`
1012
1083
 
1013
- **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)
1014
1085
 
1015
1086
  **Blockquotes (`blockquote`)**: `base`
1016
1087
 
@@ -1150,9 +1221,36 @@ MDX components support three attribute value types:
1150
1221
 
1151
1222
  ### Component Naming
1152
1223
 
1153
- - Component names **must start with a capital letter** (PascalCase)
1154
- - Valid: `<Card />`, `<MyComponent />`, `<Component123 />`
1155
- - 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.
1156
1254
 
1157
1255
  ### Streaming Safety
1158
1256
 
@@ -1161,6 +1259,7 @@ MDX components are streaming-safe. Incomplete components are automatically handl
1161
1259
  - Incomplete tags like `<Component attr` not rendered to prevent runtime errors
1162
1260
  - Unclosed components like `<Card>content` are auto-closed with `</Card>`
1163
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`.
1164
1263
 
1165
1264
  This ensures your UI remains stable even when receiving partial markdown from streaming AI responses.
1166
1265
 
package/dist/Block.svelte CHANGED
@@ -9,12 +9,15 @@
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();
@@ -22,7 +25,11 @@
22
25
  // is aliased so the context flag and the helper cannot be confused.
23
26
  const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
24
27
  const view = $derived.by(() => {
25
- const tokens = lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions);
28
+ const tokens = lex(
29
+ complete ? completeMarkdown(block.trim(), { tags: streamdown.tags, live }) : block,
30
+ streamdown.extensions,
31
+ streamdown.tags
32
+ );
26
33
  // Decided when this block's text changes and deliberately not tracked: a
27
34
  // bulk update renders plain, and the next streamed chunk must not
28
35
  // retroactively animate the blocks it left untouched.
@@ -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,6 +5,7 @@
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
 
@@ -54,6 +55,8 @@
54
55
 
55
56
  const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
56
57
 
58
+ const lineNumbers = $derived(resolveLineNumbers(token.meta, streamdown.lineNumbers ?? false));
59
+
57
60
  // `pre` is already the horizontal scroll container, so capping it there keeps
58
61
  // one scrollable box for both axes (and the horizontal scrollbar visible).
59
62
  const pinnedScroll = usePinnedScroll({
@@ -112,10 +115,15 @@
112
115
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
113
116
  <pre
114
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}
115
120
  style:max-height={streamdown.codeBlockMaxHeight}
116
121
  style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
117
122
  {@attach pinnedScroll}><code
118
- >{#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
+ : ''}"
119
127
  >{#if line.length === 0}&#8203;{/if}{#each line as t}<span
120
128
  class="th-token{t.className ? ` th-${t.className}` : ''}"
121
129
  style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
@@ -34,6 +34,13 @@
34
34
  const style = $derived(animate && streamdown.isMounted ? streamdown.animationBlockStyle : '');
35
35
  const id = $props.id();
36
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
+
37
44
  // Only ever attached to the table wrapper, which is the element that already
38
45
  // owns the horizontal scroll.
39
46
  const tableScroll = usePinnedScroll({
@@ -149,17 +156,25 @@
149
156
  </Slot>
150
157
  {:else if token.type === 'table'}
151
158
  <Slot props={{ token, children }} render={streamdown.snippets.table}>
152
- {#if streamdown.controls.tableCopy || streamdown.controls.tableDownload}
153
- <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} />
154
165
  {/if}
155
166
  <div
156
167
  data-streamdown-table={id}
157
168
  {style}
158
- class={`${streamdown.theme.table.base} group`}
169
+ class={`${streamdown.theme.table.base} group ${tableExpanded ? streamdown.theme.table.expanded : ''}`}
159
170
  style:overscroll-behavior-x="none"
160
- style:max-height={streamdown.tableMaxHeight}
161
- 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}
162
174
  {@attach tableScroll}
175
+ tabindex="-1"
176
+ role={tableExpanded ? 'dialog' : undefined}
177
+ aria-label={tableExpanded ? streamdown.translations.controls.table : undefined}
163
178
  >
164
179
  <table class={streamdown.theme.table.table}>
165
180
  {@render children()}
@@ -323,15 +323,7 @@
323
323
  </div>
324
324
 
325
325
  <style>
326
- :global([data-expanded='true']) {
327
- position: fixed;
328
- top: 16px;
329
- left: 16px;
330
- width: calc(100vw - 32px);
331
- height: calc(100vh - 32px);
332
- z-index: 2147483647;
333
- margin: 0px;
334
- }
326
+ /* [data-expanded='true'] now lives in Streamdown.svelte's global block. */
335
327
 
336
328
  /* Hide Mermaid's temporary rendering containers */
337
329
  :global(div[id^='dmermaid-']) {
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { useStreamdown } from '../context.svelte.js';
3
3
  import { scale } from 'svelte/transition';
4
- import { checkIcon, copyIcon, downloadIcon } from './icons.js';
4
+ import { checkIcon, closeIcon, copyIcon, downloadIcon, fullscreenIcon } from './icons.js';
5
5
  import { Popover } from './popover.svelte.js';
6
6
  import { useClickOutside } from '../utils/useClickOutside.svelte.js';
7
7
  import { useKeyDown } from '../utils/useKeyDown.svelte.js';
8
+ import { useExpand } from '../utils/expand.svelte.js';
8
9
  import type { TableToken } from '../marked/marked-table.js';
9
10
  import { useCopy } from '../utils/copy.svelte.js';
10
11
  import { save } from '../utils/save.js';
@@ -15,10 +16,13 @@
15
16
 
16
17
  let {
17
18
  token,
18
- id
19
+ id,
20
+ expanded = $bindable(false)
19
21
  }: {
20
22
  token: TableToken;
21
23
  id: string;
24
+ /** Owned by the caller: the wrapper it renders is what expands. */
25
+ expanded?: boolean;
22
26
  } = $props();
23
27
  const streamdown = useStreamdown();
24
28
  const popover = new Popover();
@@ -76,7 +80,22 @@
76
80
  }
77
81
  };
78
82
 
79
- const tableElement = () => document.querySelector(`[data-streamdown-table="${id}"]`);
83
+ const tableElement = () => document.querySelector<HTMLElement>(`[data-streamdown-table="${id}"]`);
84
+
85
+ const expand = useExpand({
86
+ get expanded() {
87
+ return expanded;
88
+ },
89
+ set expanded(value: boolean) {
90
+ expanded = value;
91
+ },
92
+ getTarget: tableElement
93
+ });
94
+ const expandLabel = $derived(
95
+ expanded
96
+ ? streamdown.translations.controls.exitTableFullscreen
97
+ : streamdown.translations.controls.tableFullscreen
98
+ );
80
99
 
81
100
  const copyOrDownload = (type: Format) => {
82
101
  if (type === 'Markdown') {
@@ -145,9 +164,15 @@
145
164
  </dialog>
146
165
  {/if}
147
166
 
167
+ <!-- The wrapper this toolbar controls becomes `position: fixed` when expanded,
168
+ so the buttons have to leave the flow with it or they end up underneath. -->
148
169
  <div
149
170
  data-streamdown-table-download
150
171
  class=" right-0 ml-auto flex items-center justify-end gap-2 p-1"
172
+ style:position={expanded ? 'fixed' : undefined}
173
+ style:top={expanded ? '24px' : undefined}
174
+ style:right={expanded ? '32px' : undefined}
175
+ style:z-index={expanded ? 2147483647 : undefined}
151
176
  >
152
177
  {#each modes as mode (mode)}
153
178
  <button
@@ -186,6 +211,22 @@
186
211
  {/if}
187
212
  </button>
188
213
  {/each}
214
+ {#if streamdown.controls.tableFullscreen}
215
+ <button
216
+ class={streamdown.theme.components.button}
217
+ onclick={(e) => expand.toggle(e.currentTarget)}
218
+ type="button"
219
+ title={expandLabel}
220
+ aria-label={expandLabel}
221
+ aria-pressed={expanded}
222
+ >
223
+ {#if expanded}
224
+ {@render (streamdown.icons?.close || closeIcon)()}
225
+ {:else}
226
+ {@render (streamdown.icons?.fullscreen || fullscreenIcon)()}
227
+ {/if}
228
+ </button>
229
+ {/if}
189
230
  <span aria-live="polite" style={srOnly}
190
231
  >{copy.isCopied ? streamdown.translations.controls.copiedTable : ''}</span
191
232
  >
@@ -2,7 +2,9 @@ import type { TableToken } from '../marked/marked-table.js';
2
2
  type $$ComponentProps = {
3
3
  token: TableToken;
4
4
  id: string;
5
+ /** Owned by the caller: the wrapper it renders is what expands. */
6
+ expanded?: boolean;
5
7
  };
6
- declare const TableDownload: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ declare const TableDownload: import("svelte").Component<$$ComponentProps, {}, "expanded">;
7
9
  type TableDownload = ReturnType<typeof TableDownload>;
8
10
  export default TableDownload;
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { useStreamdown } from '../../context.svelte.js';
3
3
  import { usePinnedScroll } from '../../utils/usePinnedScroll.svelte.js';
4
- import type { Tokens } from 'marked';
4
+ import { resolveLineNumbers } from '../../utils/line-numbers.js';
5
+ import type { CodeToken } from '../../marked/index.js';
5
6
 
6
7
  const {
7
8
  token,
@@ -9,7 +10,7 @@
9
10
  incomplete = false,
10
11
  animate = true
11
12
  }: {
12
- token: Tokens.Code;
13
+ token: CodeToken;
13
14
  id: string;
14
15
  /** The fence is still being streamed; nothing below it is final yet. */
15
16
  incomplete?: boolean;
@@ -24,6 +25,8 @@
24
25
  // too.
25
26
  const code = $derived(token.text.replace(/\n+$/, ''));
26
27
 
28
+ const lineNumbers = $derived(resolveLineNumbers(token.meta, streamdown.lineNumbers ?? false));
29
+
27
30
  // Same scroll container as Code.svelte: `pre` already scrolls horizontally.
28
31
  const pinnedScroll = usePinnedScroll({
29
32
  get maxHeight() {
@@ -47,10 +50,15 @@
47
50
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
48
51
  <pre
49
52
  class={streamdown.theme.code.pre}
53
+ data-line-numbers={lineNumbers.enabled || undefined}
54
+ style:counter-reset={lineNumbers.enabled ? `sd-line ${lineNumbers.start - 1}` : undefined}
50
55
  style:max-height={streamdown.codeBlockMaxHeight}
51
56
  style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
52
57
  {@attach pinnedScroll}><code
53
- >{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
58
+ >{#each code.split('\n') as line}<span
59
+ class="{streamdown.theme.code.line}{lineNumbers.enabled
60
+ ? ` ${streamdown.theme.code.lineNumber}`
61
+ : ''}"
54
62
  ><span style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
55
63
  >{line.trim().length > 0 ? line : '\u200B'}</span
56
64
  ></span
@@ -1,6 +1,6 @@
1
- import type { Tokens } from 'marked';
1
+ import type { CodeToken } from '../../marked/index.js';
2
2
  type $$ComponentProps = {
3
- token: Tokens.Code;
3
+ token: CodeToken;
4
4
  id: string;
5
5
  /** The fence is still being streamed; nothing below it is final yet. */
6
6
  incomplete?: boolean;
@@ -5,5 +5,6 @@ export declare const zoomInIcon: import("svelte").Snippet<[]>;
5
5
  export declare const zoomOutIcon: import("svelte").Snippet<[]>;
6
6
  export declare const fitViewIcon: import("svelte").Snippet<[]>;
7
7
  export declare const fullscreenIcon: import("svelte").Snippet<[]>;
8
+ export declare const closeIcon: import("svelte").Snippet<[]>;
8
9
  export declare const chevronRight: import("svelte").Snippet<[]>;
9
10
  export declare const chevronLeft: import("svelte").Snippet<[]>;
@@ -142,6 +142,14 @@ export const fullscreenIcon = createRawSnippet(() => {
142
142
  `
143
143
  };
144
144
  });
145
+ export const closeIcon = createRawSnippet(() => {
146
+ return {
147
+ render: () => `
148
+ <svg
149
+ aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
150
+ `
151
+ };
152
+ });
145
153
  export const chevronRight = createRawSnippet(() => {
146
154
  return {
147
155
  render: () => `