svelte-streamdown 2.1.0 → 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
@@ -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'}
@@ -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
- import { flushSync, onMount, tick } from 'svelte';
3
- import { useStreamdown } from '../Streamdown.js';
4
- import Slot from './Slot.svelte';
2
+ import { onMount } from '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';
@@ -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
 
@@ -23,10 +22,15 @@
23
22
  streamdown = $bindable(),
24
23
  renderHtml,
25
24
  controls,
25
+ animation,
26
+ element = $bindable(),
26
27
  ...snippets
27
28
  }: StreamdownProps = $props();
28
29
 
29
30
  streamdown = new StreamdownContext({
31
+ get element() {
32
+ return element;
33
+ },
30
34
  get content() {
31
35
  return content;
32
36
  },
@@ -71,6 +75,20 @@
71
75
  get shikiPreloadThemes() {
72
76
  return shikiPreloadThemes;
73
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
+ },
74
92
  get controls() {
75
93
  const codeControls = controls?.code ?? true;
76
94
  const mermaidControls = controls?.mermaid ?? true;
@@ -86,8 +104,64 @@
86
104
  const blocks = $derived(parseBlocks(content));
87
105
  </script>
88
106
 
89
- <div class={className}>
107
+ <div bind:this={element} class={className}>
90
108
  {#each blocks as block, index (`${id}-block-${index}`)}
91
109
  <Block {block} />
92
110
  {/each}
93
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, type StreamdownProps } from './Streamdown.js';
2
+ export { useStreamdown, type StreamdownProps } from './streamdown.svelte.js';
3
3
  export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
4
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';
2
+ export { useStreamdown } from './streamdown.svelte.js';
3
3
  export { theme, shadcnTheme, mergeTheme } from './theme.js';
4
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 {
@@ -11,12 +11,18 @@ export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets
11
11
  code: boolean;
12
12
  mermaid: boolean;
13
13
  };
14
+ animation: {
15
+ enabled: boolean;
16
+ } & StreamdownProps['animation'];
14
17
  }
15
18
  export declare class StreamdownContext {
16
19
  footnotes: {
17
20
  refs: Map<string, FootnoteRef>;
18
21
  footnotes: Map<string, Footnote>;
19
22
  };
23
+ isMounted: boolean;
24
+ animationTextStyle: string;
25
+ animationBlockStyle: string;
20
26
  constructor(props: Omit<StreamdownProps, keyof Snippets | 'class'> & {
21
27
  snippets: Snippets;
22
28
  });
@@ -68,6 +74,7 @@ export type Snippets = {
68
74
  };
69
75
  export type StreamdownProps = {
70
76
  streamdown?: StreamdownContext;
77
+ element?: HTMLElement;
71
78
  content: string;
72
79
  class?: string;
73
80
  parseIncompleteMarkdown?: boolean;
@@ -95,5 +102,12 @@ export type StreamdownProps = {
95
102
  mermaid?: boolean;
96
103
  };
97
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
+ };
98
112
  } & Partial<Snippets>;
99
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
@@ -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
  };
@@ -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}*`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "2.1.0",
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;