svelte-streamdown 2.0.4 → 2.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
@@ -1,6 +1,6 @@
1
1
  # Svelte Streamdown
2
2
 
3
- [![npm version](https://badge.fury.io/js/svelte-streamdown.svg)](https://badge.fury.io/js/svelte-streamdown)
3
+ ![npm version](https://badge.fury.io/js/svelte-streamdown.svg)
4
4
 
5
5
  A **Svelte port** of [Streamdown](https://streamdown.ai/) by Vercel - an all in one markdown renderer, designed specifically for AI-powered streaming applications.
6
6
 
@@ -26,6 +26,8 @@ Perfect for AI-powered applications that need to stream and render markdown cont
26
26
  - **Incomplete Markdown Parsing**: Handles unterminated blocks gracefully
27
27
  - **Progressive Rendering**: Perfect for streaming AI responses
28
28
  - **Real-time Updates**: Optimized for dynamic content
29
+ - **Smooth Animations**: Animate tokens and blocks as they are streamed.
30
+
29
31
 
30
32
  ### 🔒 Security Hardening
31
33
 
@@ -259,6 +261,46 @@ This Svelte port maintains feature parity with the original [Streamdown](https:/
259
261
  @source "../node_modules/svelte-streamdown/**/*";
260
262
  ```
261
263
 
264
+
265
+
266
+ ## 🎭 Animation System
267
+
268
+ Streamdown includes a sophisticated animation system designed specifically for streaming AI content, providing smooth and engaging visual feedback as text appears on screen.
269
+
270
+ ### How It Works
271
+
272
+ The animation system works by:
273
+
274
+ 1. **Tokenization**: Text is broken down into tokens (words or characters) based on your configuration
275
+ 2. **Sequential Animation**: Each token animates in sequence with configurable timing
276
+ 3. **Block-level Animation**: Entire blocks (paragraphs, headings, code blocks) animate as units
277
+
278
+ ### Animation Types
279
+
280
+ Choose from five distinct animation styles:
281
+
282
+ #### `fade`
283
+ A clean fade-in effect where text smoothly appears from transparent to opaque.
284
+
285
+ #### `blur`
286
+ Text starts slightly blurred and comes into focus while fading in, creating a smooth reveal effect.
287
+
288
+ #### `typewriter`
289
+ A typewriter effect where text appears character by character, mimicking the look of someone typing.
290
+
291
+ #### `slideUp`
292
+ Text slides up from below while fading in, creating a dynamic upward motion.
293
+
294
+ #### `slideDown`
295
+ Text slides down from above while fading in, creating a dynamic downward motion.
296
+
297
+ > [!TIP]
298
+ > For production applications where the LLM is not streaming (static content), disable animations entirely by setting `animation.enabled = false` to minimize DOM elements and improve performance.
299
+
300
+ > [!WARNING]
301
+ > Character-level tokenization (`tokenize: 'char'`) creates significantly more DOM elements than word-level tokenization. Use character tokenization sparingly and only when the typewriter effect is essential for your user experience.
302
+
303
+
262
304
  ## 🚀 Quick Start
263
305
 
264
306
  ### Basic Usage
@@ -338,6 +380,12 @@ This heading will use a custom component!`;
338
380
  | `shikiTheme` | `BundledTheme` | `'github-light'` | Code highlighting theme |
339
381
  | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
340
382
  | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
383
+ | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
384
+ | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
385
+ | `animation.type` | `'fade' \| 'blur' \| 'typewriter' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
386
+ | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
387
+ | `animation.timingFunction`| `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
388
+ | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
341
389
 
342
390
  ### Custom Component Props
343
391
 
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { useStreamdown } from './streamdown.svelte.js';
4
+
5
+ let { children }: { children: Snippet } = $props();
6
+
7
+ const streamdown = useStreamdown();
8
+
9
+ const isMounted = streamdown.isMounted;
10
+ </script>
11
+
12
+ {#if isMounted}
13
+ <div style={streamdown.animationBlockStyle}>
14
+ {@render children()}
15
+ </div>
16
+ {:else}
17
+ {@render children()}
18
+ {/if}
@@ -0,0 +1,7 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ children: Snippet;
4
+ };
5
+ declare const AnimatedBlock: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type AnimatedBlock = ReturnType<typeof AnimatedBlock>;
7
+ export default AnimatedBlock;
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import { useStreamdown } from './streamdown.svelte.js';
3
+
4
+ let { text }: { text: string } = $props();
5
+
6
+ const streamdown = useStreamdown();
7
+
8
+ const tokenizeNewContent = (text: string) => {
9
+ if (!text) return [];
10
+
11
+ let splitRegex;
12
+ if (streamdown.animation.tokenize === 'word') {
13
+ splitRegex = /(\s+)/;
14
+ } else {
15
+ splitRegex = /(.)/;
16
+ }
17
+
18
+ return text.split(splitRegex).filter((token) => token.length > 0);
19
+ };
20
+
21
+ let tokens = $derived.by(() => {
22
+ return tokenizeNewContent(text);
23
+ });
24
+
25
+ const isMounted = streamdown.isMounted;
26
+ </script>
27
+
28
+ {#if isMounted}
29
+ <span>
30
+ {#each tokens as token}
31
+ <span style={streamdown.animationTextStyle}>
32
+ {token}
33
+ </span>
34
+ {/each}
35
+ </span>
36
+ {:else}
37
+ {text}
38
+ {/if}
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ text: string;
3
+ };
4
+ declare const AnimatedText: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type AnimatedText = ReturnType<typeof AnimatedText>;
6
+ export default AnimatedText;
package/dist/Block.svelte CHANGED
@@ -1,28 +1,33 @@
1
- <script lang="ts" module>
2
- </script>
3
-
4
1
  <script lang="ts">
5
2
  import { parseIncompleteMarkdown } from './utils/parse-incomplete-markdown.js';
6
3
  import Element from './Elements/Element.svelte';
7
4
  import { lex, type StreamdownToken } from './marked/index.js';
5
+ import AnimatedText from './AnimatedText.svelte';
6
+ import { useStreamdown } from './streamdown.svelte.js';
7
+ import AnimatedBlock from './AnimatedBlock.svelte';
8
8
 
9
9
  let {
10
10
  block
11
11
  }: {
12
12
  block: string;
13
13
  } = $props();
14
-
14
+ const id = $props.id();
15
15
  const tokens = $derived(lex(parseIncompleteMarkdown(block.trim())));
16
+ const streamdown = useStreamdown();
16
17
  </script>
17
18
 
18
19
  {#snippet renderChildren(tokens: StreamdownToken[])}
19
- {#each tokens as token}
20
+ {#each tokens as token, i (`${id}-block-${i}`)}
20
21
  {#if token}
21
22
  {@const children = (token as any)?.tokens || []}
22
23
  {@const isTextOnlyNode = children.length === 0}
23
24
  <Element {token}>
24
25
  {#if isTextOnlyNode}
25
- {'text' in token ? token.text : ''}
26
+ {#if streamdown.animation.enabled}
27
+ <AnimatedText text={'text' in token ? token.text : ''} />
28
+ {:else}
29
+ {'text' in token ? token.text : ''}
30
+ {/if}
26
31
  {:else}
27
32
  {@render renderChildren(children)}
28
33
  {/if}
@@ -31,4 +36,10 @@
31
36
  {/each}
32
37
  {/snippet}
33
38
 
34
- {@render renderChildren(tokens)}
39
+ {#if streamdown.animation.enabled}
40
+ <AnimatedBlock>
41
+ {@render renderChildren(tokens)}
42
+ </AnimatedBlock>
43
+ {:else}
44
+ {@render renderChildren(tokens)}
45
+ {/if}
@@ -13,7 +13,7 @@
13
13
  /><path d="M12 17h.01" />`
14
14
  };
15
15
 
16
- import { useStreamdown } from '../Streamdown.js';
16
+ import { useStreamdown } from '../streamdown.svelte.js';
17
17
  import Slot from './Slot.svelte';
18
18
  import type { AlertToken } from '../marked/index.js';
19
19
  import type { Snippet } from 'svelte';
@@ -1,10 +1,9 @@
1
1
  <script lang="ts">
2
- import { useStreamdown } from '../Streamdown.js';
2
+ import { useStreamdown } from '../streamdown.svelte.js';
3
3
  import { save } from '../utils/save.js';
4
4
  import { useCopy } from '../utils/copy.svelte.js';
5
5
  import { HighlighterManager, languageExtensionMap } from '../utils/hightlighter.svelte.js';
6
6
  import type { Tokens } from 'marked';
7
- import type { Snippet } from 'svelte';
8
7
 
9
8
  const {
10
9
  token
@@ -47,21 +46,23 @@
47
46
  <div class={streamdown.theme.code.base} data-language={language}>
48
47
  <div class={streamdown.theme.code.header} data-code-block-header data-language={language}>
49
48
  <span class={streamdown.theme.code.language}>{language}</span>
50
- <div class="flex items-center gap-2">
51
- <!-- Download button snippet -->
52
- <button
53
- class={streamdown.theme.code.button}
54
- onclick={downloadCode}
55
- title="Download file"
56
- type="button"
57
- >
58
- {@render downloadIcon()}
59
- </button>
49
+ {#if streamdown.controls.code}
50
+ <div class="flex items-center gap-2">
51
+ <!-- Download button snippet -->
52
+ <button
53
+ class={streamdown.theme.code.button}
54
+ onclick={downloadCode}
55
+ title="Download file"
56
+ type="button"
57
+ >
58
+ {@render downloadIcon()}
59
+ </button>
60
60
 
61
- <button class={streamdown.theme.code.button} onclick={copy.copy} type="button">
62
- {@render copyIcon()}
63
- </button>
64
- </div>
61
+ <button class={streamdown.theme.code.button} onclick={copy.copy} type="button">
62
+ {@render copyIcon()}
63
+ </button>
64
+ </div>
65
+ {/if}
65
66
  </div>
66
67
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
67
68
  <div>
@@ -8,16 +8,13 @@
8
8
  import Alert from './Alert.svelte';
9
9
  import type { StreamdownToken } from '../marked/index.js';
10
10
  import Slot from './Slot.svelte';
11
- import { useStreamdown } from '../Streamdown.js';
11
+ import { useStreamdown } from '../streamdown.svelte.js';
12
12
  import FootnoteRef from './FootnoteRef.svelte';
13
13
  let { token, children }: { token: StreamdownToken; children: Snippet } = $props();
14
- const id = $props.id();
15
14
  const streamdown = useStreamdown();
16
15
  </script>
17
16
 
18
17
  {#if token.type === 'heading'}
19
- {@const level = `h${token.depth}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'}
20
- {@const className = streamdown.theme[level].base}
21
18
  <Slot
22
19
  props={{
23
20
  children,
@@ -25,9 +22,31 @@
25
22
  }}
26
23
  render={streamdown.snippets.heading}
27
24
  >
28
- <svelte:element this={level} class={className}>
29
- {@render children()}
30
- </svelte:element>
25
+ {#if token.depth === 1}
26
+ <h1 class={streamdown.theme[`h${token.depth}`].base}>
27
+ {@render children()}
28
+ </h1>
29
+ {:else if token.depth === 2}
30
+ <h2 class={streamdown.theme[`h${token.depth}`].base}>
31
+ {@render children()}
32
+ </h2>
33
+ {:else if token.depth === 3}
34
+ <h3 class={streamdown.theme[`h${token.depth}`].base}>
35
+ {@render children()}
36
+ </h3>
37
+ {:else if token.depth === 4}
38
+ <h4 class={streamdown.theme[`h${token.depth}`].base}>
39
+ {@render children()}
40
+ </h4>
41
+ {:else if token.depth === 5}
42
+ <h5 class={streamdown.theme[`h${token.depth}`].base}>
43
+ {@render children()}
44
+ </h5>
45
+ {:else if token.depth === 6}
46
+ <h6 class={streamdown.theme[`h${token.depth}`].base}>
47
+ {@render children()}
48
+ </h6>
49
+ {/if}
31
50
  </Slot>
32
51
  {:else if token.type === 'paragraph'}
33
52
  <Slot props={{ children, token }} render={streamdown.snippets.paragraph}>
@@ -52,7 +71,7 @@
52
71
  {:else if token.type === 'codespan'}
53
72
  <Slot props={{ children, token }} render={streamdown.snippets.codespan}>
54
73
  <code class={streamdown.theme.codespan.base}>
55
- {token.text}
74
+ {@render children()}
56
75
  </code>
57
76
  </Slot>
58
77
  {:else if token.type === 'list'}
@@ -203,6 +222,12 @@
203
222
  <Alert {token} {children} />
204
223
  {:else if token.type === 'footnoteRef'}
205
224
  <FootnoteRef {token} />
225
+ {:else if token.type === 'html'}
226
+ {#if streamdown.renderHtml}
227
+ {@const content =
228
+ typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
229
+ {@html content}
230
+ {/if}
206
231
  {:else}
207
232
  <!-- For tokens we don't handle specifically, render children or fallback -->
208
233
  {@render children?.()}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { useStreamdown } from '../Streamdown.js';
2
+ import { useStreamdown } from '../streamdown.svelte.js';
3
3
  import Slot from './Slot.svelte';
4
4
  import type { FootnoteRef } from '../marked/marked-footnotes.js';
5
5
  import {
@@ -52,7 +52,6 @@
52
52
  const middleware = [
53
53
  hide(),
54
54
  offset(0),
55
- ,
56
55
  shift({
57
56
  mainAxis: true
58
57
  }),
@@ -80,6 +79,7 @@
80
79
  off();
81
80
  };
82
81
  };
82
+ const isMounted = streamdown.isMounted;
83
83
  </script>
84
84
 
85
85
  {#if isOpen}
@@ -113,6 +113,7 @@
113
113
  render={streamdown.snippets.footnoteRef}
114
114
  >
115
115
  <button
116
+ style={isMounted ? streamdown.animationBlockStyle : ''}
116
117
  bind:this={reference}
117
118
  class={streamdown.theme.footnoteRef.base}
118
119
  onclick={() => (isOpen = !isOpen)}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { useStreamdown } from '../Streamdown.js';
2
+ import { useStreamdown } from '../streamdown.svelte.js';
3
3
  import { transformUrl } from '../utils/url.js';
4
4
  import Slot from './Slot.svelte';
5
5
  import type { Tokens } from 'marked';
@@ -30,9 +30,9 @@
30
30
  }}
31
31
  render={streamdown.snippets.image}
32
32
  >
33
- <div class={streamdown.theme.image.base}>
33
+ <span class={streamdown.theme.image.base}>
34
34
  <img class={streamdown.theme.image.image} src={transformedUrl} alt={token.text} />
35
- </div>
35
+ </span>
36
36
  </Slot>
37
37
  {:else}
38
38
  <span
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { useStreamdown } from '../Streamdown.js';
2
+ import { useStreamdown } from '../streamdown.svelte.js';
3
3
  import { transformUrl } from '../utils/url.js';
4
4
  import Slot from './Slot.svelte';
5
5
  import type { Tokens } from 'marked';
@@ -1,9 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onMount, untrack } from 'svelte';
3
- import { useStreamdown } from '../Streamdown.js';
4
- import Slot from './Slot.svelte';
3
+ import { useStreamdown } from '../streamdown.svelte.js';
5
4
  import type { MathToken } from '../marked/index.js';
6
- import type { Snippet } from 'svelte';
7
5
  import type { KatexOptions } from 'katex';
8
6
  import 'katex/dist/katex.min.css';
9
7
 
@@ -47,10 +45,16 @@
47
45
  });
48
46
  }
49
47
  });
48
+
49
+ const isMounted = streamdown.isMounted;
50
50
  </script>
51
51
 
52
52
  {#if isInline}
53
- <span bind:this={inner} class={streamdown.theme.math.inline}>
53
+ <span
54
+ style={isMounted ? streamdown.animationBlockStyle : ''}
55
+ bind:this={inner}
56
+ class={streamdown.theme.math.inline}
57
+ >
54
58
  {@html html}
55
59
  </span>
56
60
  {:else}
@@ -1,9 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
- import { useStreamdown } from '../Streamdown.js';
4
- import Slot from './Slot.svelte';
3
+ import { useStreamdown } from '../streamdown.svelte.js';
5
4
  import type { Tokens } from 'marked';
6
- import type { Snippet } from 'svelte';
7
5
  import type { MermaidConfig } from 'mermaid';
8
6
  import { on } from 'svelte/events';
9
7
  import { usePanzoom } from '../utils/panzoom.svelte';
@@ -178,9 +176,7 @@
178
176
  const mergedConfig = { ...defaultConfig };
179
177
  mermaid.initialize(mergedConfig);
180
178
 
181
- // Use a stable ID based on chart content hash and timestamp to ensure uniqueness
182
179
  const chartHash = code.split('').reduce((acc, char) => {
183
- // biome-ignore lint/suspicious/noBitwiseOperators: "Required for Mermaid"
184
180
  return ((acc << 5) - acc + char.charCodeAt(0)) | 0;
185
181
  }, 0);
186
182
 
@@ -196,7 +192,6 @@
196
192
  svgTarget.setAttribute(attribute.name, attribute.value);
197
193
  });
198
194
  svgTarget.innerHTML = svg.innerHTML;
199
- // After rendering, fit the SVG within its parent container
200
195
 
201
196
  panzoom.zoomToFit();
202
197
  panzoom.zoomToFit();
@@ -216,101 +211,103 @@
216
211
  {@attach insider.attach}
217
212
  data-expanded={'false'}
218
213
  >
219
- <div class={streamdown.theme.mermaid.buttons}>
220
- <button
221
- class={streamdown.theme.mermaid.button}
222
- aria-label="Zoom to fit"
223
- onclick={() => panzoom.zoomToFit()}
224
- data-panzoom-ignore
225
- >
226
- <svg
227
- class={streamdown.theme.mermaid.icon}
228
- xmlns="http://www.w3.org/2000/svg"
229
- viewBox="0 0 24 24"
230
- fill="none"
231
- stroke="currentColor"
232
- stroke-width="2"
233
- stroke-linecap="round"
234
- stroke-linejoin="round"
235
- ><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
236
- d="M21 17v2a2 2 0 0 1-2 2h-2"
237
- /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
238
- width="10"
239
- height="8"
240
- x="7"
241
- y="8"
242
- rx="1"
243
- /></svg
214
+ {#if streamdown.controls.mermaid}
215
+ <div class={streamdown.theme.mermaid.buttons}>
216
+ <button
217
+ class={streamdown.theme.mermaid.button}
218
+ aria-label="Zoom to fit"
219
+ onclick={() => panzoom.zoomToFit()}
220
+ data-panzoom-ignore
244
221
  >
245
- </button>
246
- <button
247
- class={streamdown.theme.mermaid.button}
248
- aria-label="Zoom in"
249
- onclick={() => panzoom.zoomIn()}
250
- data-panzoom-ignore
251
- >
252
- <svg
253
- class={streamdown.theme.mermaid.icon}
254
- xmlns="http://www.w3.org/2000/svg"
255
- viewBox="0 0 24 24"
256
- fill="none"
257
- stroke="currentColor"
258
- stroke-width="2"
259
- stroke-linecap="round"
260
- stroke-linejoin="round"
261
- ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
262
- x1="11"
263
- x2="11"
264
- y1="8"
265
- y2="14"
266
- /><line x1="8" x2="14" y1="11" y2="11" /></svg
222
+ <svg
223
+ class={streamdown.theme.mermaid.icon}
224
+ xmlns="http://www.w3.org/2000/svg"
225
+ viewBox="0 0 24 24"
226
+ fill="none"
227
+ stroke="currentColor"
228
+ stroke-width="2"
229
+ stroke-linecap="round"
230
+ stroke-linejoin="round"
231
+ ><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
232
+ d="M21 17v2a2 2 0 0 1-2 2h-2"
233
+ /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
234
+ width="10"
235
+ height="8"
236
+ x="7"
237
+ y="8"
238
+ rx="1"
239
+ /></svg
240
+ >
241
+ </button>
242
+ <button
243
+ class={streamdown.theme.mermaid.button}
244
+ aria-label="Zoom in"
245
+ onclick={() => panzoom.zoomIn()}
246
+ data-panzoom-ignore
267
247
  >
268
- </button>
269
- <button
270
- class={streamdown.theme.mermaid.button}
271
- aria-label="Zoom out"
272
- onclick={() => panzoom.zoomOut()}
273
- data-panzoom-ignore
274
- ><svg
275
- class={streamdown.theme.mermaid.icon}
276
- xmlns="http://www.w3.org/2000/svg"
277
- viewBox="0 0 24 24"
278
- fill="none"
279
- stroke="currentColor"
280
- stroke-width="2"
281
- stroke-linecap="round"
282
- stroke-linejoin="round"
283
- ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
284
- x1="8"
285
- x2="14"
286
- y1="11"
287
- y2="11"
288
- /></svg
289
- ></button
290
- >
291
- <button
292
- class={streamdown.theme.mermaid.button}
293
- aria-label="Toggle expand"
294
- onclick={() => panzoom.toggleExpand()}
295
- data-panzoom-ignore
296
- >
297
- <svg
298
- class={streamdown.theme.mermaid.icon}
299
- xmlns="http://www.w3.org/2000/svg"
300
- viewBox="0 0 24 24"
301
- fill="none"
302
- stroke="currentColor"
303
- stroke-width="2"
304
- stroke-linecap="round"
305
- stroke-linejoin="round"
306
- ><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
307
- d="M21 8V3h-5"
308
- /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
309
- d="M9 9 3 3"
310
- /></svg
248
+ <svg
249
+ class={streamdown.theme.mermaid.icon}
250
+ xmlns="http://www.w3.org/2000/svg"
251
+ viewBox="0 0 24 24"
252
+ fill="none"
253
+ stroke="currentColor"
254
+ stroke-width="2"
255
+ stroke-linecap="round"
256
+ stroke-linejoin="round"
257
+ ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
258
+ x1="11"
259
+ x2="11"
260
+ y1="8"
261
+ y2="14"
262
+ /><line x1="8" x2="14" y1="11" y2="11" /></svg
263
+ >
264
+ </button>
265
+ <button
266
+ class={streamdown.theme.mermaid.button}
267
+ aria-label="Zoom out"
268
+ onclick={() => panzoom.zoomOut()}
269
+ data-panzoom-ignore
270
+ ><svg
271
+ class={streamdown.theme.mermaid.icon}
272
+ xmlns="http://www.w3.org/2000/svg"
273
+ viewBox="0 0 24 24"
274
+ fill="none"
275
+ stroke="currentColor"
276
+ stroke-width="2"
277
+ stroke-linecap="round"
278
+ stroke-linejoin="round"
279
+ ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
280
+ x1="8"
281
+ x2="14"
282
+ y1="11"
283
+ y2="11"
284
+ /></svg
285
+ ></button
311
286
  >
312
- </button>
313
- </div>
287
+ <button
288
+ class={streamdown.theme.mermaid.button}
289
+ aria-label="Toggle expand"
290
+ onclick={() => panzoom.toggleExpand()}
291
+ data-panzoom-ignore
292
+ >
293
+ <svg
294
+ class={streamdown.theme.mermaid.icon}
295
+ xmlns="http://www.w3.org/2000/svg"
296
+ viewBox="0 0 24 24"
297
+ fill="none"
298
+ stroke="currentColor"
299
+ stroke-width="2"
300
+ stroke-linecap="round"
301
+ stroke-linejoin="round"
302
+ ><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
303
+ d="M21 8V3h-5"
304
+ /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
305
+ d="M9 9 3 3"
306
+ /></svg
307
+ >
308
+ </button>
309
+ </div>
310
+ {/if}
314
311
  <svg {@attach panzoom.attach} data-mermaid-svg></svg>
315
312
  </div>
316
313
  {:else}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { useStreamdown } from '../Streamdown.js';
2
+ import { useStreamdown } from '../streamdown.svelte.js';
3
3
  import Slot from './Slot.svelte';
4
4
  import type { Tokens } from 'marked';
5
5
  import type { Snippet } from 'svelte';
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import Block from './Block.svelte';
3
- import { StreamdownContext, type StreamdownProps } from './Streamdown.js';
4
- import 'katex/dist/katex.min.css';
3
+ import { StreamdownContext, type StreamdownProps } from './streamdown.svelte.js';
5
4
  import { mergeTheme, shadcnTheme } from './theme.js';
6
5
  import { parseBlocks } from './marked/index.js';
7
6
 
@@ -21,10 +20,17 @@
21
20
  baseTheme,
22
21
  mergeTheme: shouldMergeTheme = true,
23
22
  streamdown = $bindable(),
23
+ renderHtml,
24
+ controls,
25
+ animation,
26
+ element = $bindable(),
24
27
  ...snippets
25
28
  }: StreamdownProps = $props();
26
29
 
27
30
  streamdown = new StreamdownContext({
31
+ get element() {
32
+ return element;
33
+ },
28
34
  get content() {
29
35
  return content;
30
36
  },
@@ -60,11 +66,36 @@
60
66
  get katexConfig() {
61
67
  return katexConfig;
62
68
  },
69
+ get renderHtml() {
70
+ return renderHtml;
71
+ },
63
72
  get translations() {
64
73
  return translations;
65
74
  },
66
75
  get shikiPreloadThemes() {
67
76
  return shikiPreloadThemes;
77
+ },
78
+
79
+ get animation() {
80
+ if (!animation?.enabled)
81
+ return {
82
+ enabled: false
83
+ };
84
+ return {
85
+ enabled: true,
86
+ type: animation.type || 'blur',
87
+ duration: animation.duration || 500,
88
+ timingFunction: animation.timingFunction || 'ease-in',
89
+ tokenize: animation.tokenize || 'word'
90
+ };
91
+ },
92
+ get controls() {
93
+ const codeControls = controls?.code ?? true;
94
+ const mermaidControls = controls?.mermaid ?? true;
95
+ return {
96
+ code: codeControls,
97
+ mermaid: mermaidControls
98
+ };
68
99
  }
69
100
  });
70
101
 
@@ -73,8 +104,64 @@
73
104
  const blocks = $derived(parseBlocks(content));
74
105
  </script>
75
106
 
76
- <div class={className}>
107
+ <div bind:this={element} class={className}>
77
108
  {#each blocks as block, index (`${id}-block-${index}`)}
78
109
  <Block {block} />
79
110
  {/each}
80
111
  </div>
112
+
113
+ <style global>
114
+ :global {
115
+ @keyframes sd-fade {
116
+ from {
117
+ opacity: 0;
118
+ }
119
+ to {
120
+ opacity: 1;
121
+ }
122
+ }
123
+
124
+ @keyframes sd-blur {
125
+ from {
126
+ opacity: 0;
127
+ filter: blur(5px);
128
+ }
129
+ to {
130
+ opacity: 1;
131
+ filter: blur(0px);
132
+ }
133
+ }
134
+
135
+ @keyframes sd-typewriter {
136
+ from {
137
+ width: 0;
138
+ overflow: hidden;
139
+ }
140
+ to {
141
+ width: fit-content;
142
+ }
143
+ }
144
+
145
+ @keyframes sd-slideUp {
146
+ from {
147
+ transform: translateY(10%);
148
+ opacity: 0;
149
+ }
150
+ to {
151
+ transform: translateY(0);
152
+ opacity: 1;
153
+ }
154
+ }
155
+
156
+ @keyframes sd-slideDown {
157
+ from {
158
+ transform: translateY(-10%);
159
+ opacity: 0;
160
+ }
161
+ to {
162
+ transform: translateY(0);
163
+ opacity: 1;
164
+ }
165
+ }
166
+ }
167
+ </style>
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { default as Streamdown } from './Streamdown.svelte';
2
- export { useStreamdown } from './Streamdown.js';
3
- export * from './Elements/index.js';
4
- export { theme, shadcnTheme, mergeTheme, cn, type Theme } from './theme.js';
2
+ export { useStreamdown, type StreamdownProps } from './streamdown.svelte.js';
3
+ export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
4
+ export { lex, parseBlocks, type StreamdownToken } from './marked/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { default as Streamdown } from './Streamdown.svelte';
2
- export { useStreamdown } from './Streamdown.js';
3
- export * from './Elements/index.js';
4
- export { theme, shadcnTheme, mergeTheme, cn } from './theme.js';
2
+ export { useStreamdown } from './streamdown.svelte.js';
3
+ export { theme, shadcnTheme, mergeTheme } from './theme.js';
4
+ export { lex, parseBlocks } from './marked/index.js';
@@ -1,5 +1,5 @@
1
1
  import {} from './index.js';
2
- import { StreamdownContext } from '../Streamdown.js';
2
+ import { StreamdownContext } from '../streamdown.svelte.js';
3
3
  import { getContext } from 'svelte';
4
4
  const safeGetContext = () => {
5
5
  try {
@@ -7,12 +7,22 @@ export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets
7
7
  snippets: Snippets;
8
8
  shikiTheme: BundledTheme;
9
9
  theme: Theme;
10
+ controls: {
11
+ code: boolean;
12
+ mermaid: boolean;
13
+ };
14
+ animation: {
15
+ enabled: boolean;
16
+ } & StreamdownProps['animation'];
10
17
  }
11
18
  export declare class StreamdownContext {
12
19
  footnotes: {
13
20
  refs: Map<string, FootnoteRef>;
14
21
  footnotes: Map<string, Footnote>;
15
22
  };
23
+ isMounted: boolean;
24
+ animationTextStyle: string;
25
+ animationBlockStyle: string;
16
26
  constructor(props: Omit<StreamdownProps, keyof Snippets | 'class'> & {
17
27
  snippets: Snippets;
18
28
  });
@@ -64,6 +74,7 @@ export type Snippets = {
64
74
  };
65
75
  export type StreamdownProps = {
66
76
  streamdown?: StreamdownContext;
77
+ element?: HTMLElement;
67
78
  content: string;
68
79
  class?: string;
69
80
  parseIncompleteMarkdown?: boolean;
@@ -86,5 +97,17 @@ export type StreamdownProps = {
86
97
  important?: string;
87
98
  };
88
99
  };
100
+ controls?: {
101
+ code?: boolean;
102
+ mermaid?: boolean;
103
+ };
104
+ renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
105
+ animation?: {
106
+ enabled?: boolean;
107
+ type?: 'fade' | 'blur' | 'typewriter' | 'slideUp' | 'slideDown';
108
+ duration?: number;
109
+ timingFunction?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear';
110
+ tokenize?: 'word' | 'char';
111
+ };
89
112
  } & Partial<Snippets>;
90
113
  export {};
@@ -0,0 +1,38 @@
1
+ import { getContext, onMount, setContext } from 'svelte';
2
+ export class StreamdownContext {
3
+ footnotes = {
4
+ refs: new Map(),
5
+ footnotes: new Map()
6
+ };
7
+ isMounted = $state(false);
8
+ animationTextStyle = $derived(`animation-name: sd-${this.animation.type};
9
+ animation-duration: ${this.animation.duration}ms;
10
+ animation-timing-function: ${this.animation.timingFunction};
11
+ animation-iteration-count: 1;
12
+ animation-fill-mode: forwards;
13
+ white-space: pre-wrap;
14
+ display: inline-block;`);
15
+ animationBlockStyle = $derived(`animation-name: sd-${this.animation.type};
16
+ animation-duration: ${this.animation.duration}ms;
17
+ animation-timing-function: ${this.animation.timingFunction};
18
+ animation-iteration-count: 1;
19
+ animation-fill-mode: forwards;`);
20
+ constructor(props) {
21
+ bind(this, props);
22
+ setContext('streamdown', this);
23
+ onMount(() => {
24
+ this.isMounted = true;
25
+ });
26
+ $effect(() => {
27
+ this.isMounted = this.animation.enabled;
28
+ });
29
+ }
30
+ }
31
+ export const useStreamdown = () => {
32
+ const context = getContext('streamdown');
33
+ if (!context) {
34
+ throw new Error('Streamdown context not found');
35
+ }
36
+ return context;
37
+ };
38
+ import { bind } from './utils/bind.js';
package/dist/theme.js CHANGED
@@ -3,7 +3,7 @@ import { twMerge } from 'tailwind-merge';
3
3
  export const cn = (...inputs) => twMerge(clsx(inputs));
4
4
  export const theme = {
5
5
  link: {
6
- base: 'text-blue-600 font-medium underline',
6
+ base: 'text-blue-600 font-medium underline wrap-anywhere hover:text-blue-600/80',
7
7
  blocked: 'text-gray-500'
8
8
  },
9
9
  h1: {
@@ -39,8 +39,8 @@ export const theme = {
39
39
  },
40
40
  code: {
41
41
  base: 'my-4 w-full overflow-hidden rounded-xl border border-gray-200 flex flex-col',
42
- container: ' relative overflow-visible bg-gray-100 rounded p-2 font-mono text-sm ',
43
- header: 'flex items-center justify-between bg-gray-100/80 p-2 pb-0 text-gray-600 text-xs',
42
+ container: ' relative overflow-visible bg-gray-100 p-2 font-mono text-sm ',
43
+ header: 'flex items-center justify-between bg-gray-100/80 p-2 text-gray-600 text-xs',
44
44
  button: 'cursor-pointer size-6 p-1 text-gray-600 transition-all hover:text-gray-900 rounded hover:bg-gray-100',
45
45
  language: 'ml-1 font-mono lowercase',
46
46
  skeleton: 'rounded-md font-mono text-transparent bg-gray-200 scale-y-90 animate-pulse whitespace-nowrap inline-block',
@@ -130,7 +130,7 @@ export const theme = {
130
130
  };
131
131
  export const shadcnTheme = {
132
132
  link: {
133
- base: 'text-primary font-medium underline hover:text-primary/80',
133
+ base: 'text-primary wrap-anywhere font-medium underline hover:text-primary/80',
134
134
  blocked: 'text-muted-foreground'
135
135
  },
136
136
  h1: {
@@ -166,8 +166,8 @@ export const shadcnTheme = {
166
166
  },
167
167
  code: {
168
168
  base: 'my-4 w-full overflow-hidden rounded-lg border border-border flex flex-col',
169
- container: 'relative overflow-visible bg-muted rounded p-2 font-mono text-sm',
170
- header: 'flex items-center justify-between bg-muted/80 p-2 pb-0 text-muted-foreground text-xs',
169
+ container: 'relative overflow-visible bg-muted p-2 font-mono text-sm',
170
+ header: 'flex items-center justify-between bg-muted/80 px-2 py-1 text-muted-foreground text-xs',
171
171
  button: 'cursor-pointer size-6 p-1 text-muted-foreground transition-all hover:text-foreground rounded hover:bg-muted',
172
172
  language: 'ml-1 font-mono lowercase',
173
173
  skeleton: 'rounded-md font-mono text-transparent bg-border/80 scale-y-90 w-fit animate-pulse whitespace-nowrap inline-block',
@@ -261,11 +261,17 @@ export const mergeTheme = (customTheme, baseTheme) => {
261
261
  return base;
262
262
  const mergedTheme = { ...base };
263
263
  for (const key in customTheme) {
264
- for (const subKey in customTheme[key]) {
265
- Object.assign(mergedTheme[key], {
266
- [subKey]: cn(mergedTheme[key], customTheme[key])
267
- });
264
+ const origGroup = mergedTheme[key];
265
+ const customGroup = customTheme[key];
266
+ if (!origGroup || !customGroup)
267
+ continue;
268
+ const mergedGroup = { ...origGroup };
269
+ for (const subKey of Object.keys(customGroup)) {
270
+ const baseVal = origGroup[subKey];
271
+ const customVal = customGroup[subKey];
272
+ mergedGroup[subKey] = cn(baseVal, customVal);
268
273
  }
274
+ mergedTheme[key] = mergedGroup;
269
275
  }
270
276
  return mergedTheme;
271
277
  };
@@ -3,7 +3,6 @@ import { SvelteSet } from 'svelte/reactivity';
3
3
  export declare const loadShiki: () => Promise<[any, import("shiki").CreateHighlighterFactory<BundledLanguage, BundledTheme>]>;
4
4
  declare class HighlighterManager {
5
5
  initialized: boolean;
6
- private highlighter;
7
6
  private highlighters;
8
7
  private createHighlighter;
9
8
  private engine;
@@ -1,6 +1,9 @@
1
- import {} from 'shiki';
1
+ import { bundledLanguages } from 'shiki';
2
2
  import { untrack } from 'svelte';
3
3
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
4
+ const isLanguageSupported = (language) => {
5
+ return Object.hasOwn(bundledLanguages, language);
6
+ };
4
7
  // Remove background styles from <pre> tags (inline style)
5
8
  const removePreBackground = (html) => {
6
9
  return html.replace(/<pre[^>]*style="[^"]*background[^";]*;?[^"]*"[^>]*>/g, (match) => match.replace(/style="[^"]*background[^";]*;?[^"]*"/, ''));
@@ -13,7 +16,6 @@ export const loadShiki = async () => {
13
16
  };
14
17
  class HighlighterManager {
15
18
  initialized = $state(false);
16
- highlighter = null;
17
19
  highlighters = new SvelteMap();
18
20
  createHighlighter = null;
19
21
  engine = null;
@@ -47,7 +49,7 @@ class HighlighterManager {
47
49
  }
48
50
  const highlighter = await this.createHighlighter?.({
49
51
  themes: [theme],
50
- langs: [language],
52
+ langs: isLanguageSupported(language) ? [language] : ['text'],
51
53
  engine: this.engine
52
54
  });
53
55
  this.highlighters.set(`${theme}:${language}`, highlighter);
@@ -66,7 +68,7 @@ class HighlighterManager {
66
68
  if (!this.highlighters.has(`${theme}:${language}`)) {
67
69
  const highlighter = await this.createHighlighter({
68
70
  themes: [theme],
69
- langs: [language],
71
+ langs: isLanguageSupported(language) ? [language] : ['text'],
70
72
  engine: this.engine
71
73
  });
72
74
  this.highlighters.set(`${theme}:${language}`, highlighter);
@@ -83,7 +85,7 @@ class HighlighterManager {
83
85
  return '';
84
86
  }
85
87
  let html = highlighter.codeToHtml(code, {
86
- lang: language,
88
+ lang: isLanguageSupported(language) ? language : 'text',
87
89
  theme: theme
88
90
  });
89
91
  // Remove background and add custom class if needed
@@ -1,5 +1,5 @@
1
1
  const linkImagePattern = /(!?\[)([^\]]*?)$/;
2
- const boldPattern = /(\*\*)([^*]*?)$/;
2
+ const boldPattern = /(\*\*)([^\n]*?)$/;
3
3
  const italicPattern = /(__)([^_]*?)$/;
4
4
  const boldItalicPattern = /(\*\*\*)([^*]*?)$/;
5
5
  const singleAsteriskPattern = /(\*)([^*]*?)$/;
@@ -37,20 +37,19 @@ const handleIncompleteBold = (text) => {
37
37
  }
38
38
  const boldMatch = text.match(boldPattern);
39
39
  if (boldMatch) {
40
+ // Find the position of the last ** marker
41
+ const lastDoubleAsteriskIndex = text.lastIndexOf('**');
42
+ const contentAfterMarker = text.substring(lastDoubleAsteriskIndex + 2);
40
43
  // Don't close if there's no meaningful content after the opening markers
41
- // boldMatch[2] contains the content after **
42
44
  // Check if content is only whitespace or other emphasis markers
43
- const contentAfterMarker = boldMatch[2];
44
45
  if (!contentAfterMarker || /^[\s_~*`]*$/.test(contentAfterMarker)) {
45
46
  return text;
46
47
  }
47
48
  // Check if the bold marker is in a list item context
48
- // Find the position of the matched bold marker
49
- const markerIndex = text.lastIndexOf(boldMatch[1]);
50
- const beforeMarker = text.substring(0, markerIndex);
49
+ const beforeMarker = text.substring(0, lastDoubleAsteriskIndex);
51
50
  const lastNewlineBeforeMarker = beforeMarker.lastIndexOf('\n');
52
51
  const lineStart = lastNewlineBeforeMarker === -1 ? 0 : lastNewlineBeforeMarker + 1;
53
- const lineBeforeMarker = text.substring(lineStart, markerIndex);
52
+ const lineBeforeMarker = text.substring(lineStart, lastDoubleAsteriskIndex);
54
53
  // Check if this line is a list item with just the bold marker
55
54
  if (/^[\s]*[-*+][\s]+$/.test(lineBeforeMarker)) {
56
55
  // This is a list item with just emphasis markers
@@ -61,8 +60,17 @@ const handleIncompleteBold = (text) => {
61
60
  return text;
62
61
  }
63
62
  }
64
- const asteriskPairs = (text.match(/\*\*/g) || []).length;
65
- if (asteriskPairs % 2 === 1) {
63
+ // Check if the content after ** ends with a single * (incomplete closing marker)
64
+ if (contentAfterMarker.endsWith('*') && !contentAfterMarker.endsWith('**')) {
65
+ // The content ends with a single *, treat it as an incomplete closing marker
66
+ // Remove the trailing * and add complete closing **
67
+ const contentWithoutTrailingAsterisk = contentAfterMarker.slice(0, -1);
68
+ return text.substring(0, lastDoubleAsteriskIndex + 2) + contentWithoutTrailingAsterisk + '**';
69
+ }
70
+ // Count all ** sequences - if odd, we have an unmatched opening **
71
+ const doubleAsteriskMatches = text.match(/\*\*/g) || [];
72
+ const doubleAsteriskCount = doubleAsteriskMatches.length;
73
+ if (doubleAsteriskCount % 2 === 1) {
66
74
  return `${text}**`;
67
75
  }
68
76
  }
@@ -165,6 +173,19 @@ const handleIncompleteSingleAsteriskItalic = (text) => {
165
173
  if (!contentAfterFirstAsterisk || /^[\s_~*`]*$/.test(contentAfterFirstAsterisk)) {
166
174
  return text;
167
175
  }
176
+ // Additional check: be more conservative about single asterisks
177
+ // Only complete if the asterisk appears to be intended for formatting
178
+ const prevChar = firstSingleAsteriskIndex > 0 ? text[firstSingleAsteriskIndex - 1] : '';
179
+ const nextChar = firstSingleAsteriskIndex < text.length - 1 ? text[firstSingleAsteriskIndex + 1] : '';
180
+ // If asterisk is surrounded by word characters, it's likely literal (e.g., test*var)
181
+ if (/\w/.test(prevChar) && /\w/.test(nextChar)) {
182
+ return text;
183
+ }
184
+ // If asterisk is at the end of a word/phrase, be more cautious
185
+ // Only complete if there's clear whitespace before it (typical italic pattern)
186
+ if (/\w/.test(prevChar) && !/\s/.test(prevChar)) {
187
+ return text;
188
+ }
168
189
  const singleAsterisks = countSingleAsterisks(text);
169
190
  if (singleAsterisks % 2 === 1) {
170
191
  return `${text}*`;
@@ -244,6 +265,13 @@ const countSingleUnderscores = (text) => {
244
265
  if (isWithinMathBlock(text, index)) {
245
266
  return acc;
246
267
  }
268
+ // Skip if underscore is word-internal (between word characters)
269
+ if (prevChar &&
270
+ nextChar &&
271
+ /[\p{L}\p{N}_]/u.test(prevChar) &&
272
+ /[\p{L}\p{N}_]/u.test(nextChar)) {
273
+ return acc;
274
+ }
247
275
  if (prevChar !== '_' && nextChar !== '_') {
248
276
  return acc + 1;
249
277
  }
@@ -259,13 +287,23 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
259
287
  }
260
288
  const singleUnderscoreMatch = text.match(singleUnderscorePattern);
261
289
  if (singleUnderscoreMatch) {
262
- // Find the first single underscore position (not part of __)
290
+ // Find the first single underscore position (not part of __ and not word-internal)
263
291
  let firstSingleUnderscoreIndex = -1;
264
292
  for (let i = 0; i < text.length; i++) {
265
293
  if (text[i] === '_' &&
266
294
  text[i - 1] !== '_' &&
267
295
  text[i + 1] !== '_' &&
296
+ text[i - 1] !== '\\' &&
268
297
  !isWithinMathBlock(text, i)) {
298
+ // Check if underscore is word-internal (between word characters)
299
+ const prevChar = i > 0 ? text[i - 1] : '';
300
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
301
+ if (prevChar &&
302
+ nextChar &&
303
+ /[\p{L}\p{N}_]/u.test(prevChar) &&
304
+ /[\p{L}\p{N}_]/u.test(nextChar)) {
305
+ continue;
306
+ }
269
307
  firstSingleUnderscoreIndex = i;
270
308
  break;
271
309
  }
@@ -282,6 +320,12 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
282
320
  }
283
321
  const singleUnderscores = countSingleUnderscores(text);
284
322
  if (singleUnderscores % 2 === 1) {
323
+ // If text ends with newline(s), insert underscore before them
324
+ const trailingNewlineMatch = text.match(/\n+$/);
325
+ if (trailingNewlineMatch) {
326
+ const textBeforeNewlines = text.slice(0, -trailingNewlineMatch[0].length);
327
+ return `${textBeforeNewlines}_${trailingNewlineMatch[0]}`;
328
+ }
285
329
  return `${text}_`;
286
330
  }
287
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "2.0.4",
3
+ "version": "2.2.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -47,6 +47,7 @@
47
47
  "prettier-plugin-svelte": "^3.3.3",
48
48
  "prettier-plugin-tailwindcss": "^0.6.11",
49
49
  "publint": "^0.3.2",
50
+ "stick-to-bottom-svelte": "^1.0.1",
50
51
  "svelte": "^5.0.0",
51
52
  "svelte-check": "^4.0.0",
52
53
  "svelte-themes": "^2.0.8",
@@ -1,19 +0,0 @@
1
- import { getContext, setContext } from 'svelte';
2
- export class StreamdownContext {
3
- footnotes = {
4
- refs: new Map(),
5
- footnotes: new Map()
6
- };
7
- constructor(props) {
8
- bind(this, props);
9
- setContext('streamdown', this);
10
- }
11
- }
12
- export const useStreamdown = () => {
13
- const context = getContext('streamdown');
14
- if (!context) {
15
- throw new Error('Streamdown context not found');
16
- }
17
- return context;
18
- };
19
- import { bind } from './utils/bind.js';
@@ -1,5 +0,0 @@
1
- import { type StreamdownProps } from './Streamdown.js';
2
- import 'katex/dist/katex.min.css';
3
- declare const Streamdown: import("svelte").Component<StreamdownProps, {}, "streamdown">;
4
- type Streamdown = ReturnType<typeof Streamdown>;
5
- export default Streamdown;