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 +49 -1
- package/dist/AnimatedBlock.svelte +18 -0
- package/dist/AnimatedBlock.svelte.d.ts +7 -0
- package/dist/AnimatedText.svelte +38 -0
- package/dist/AnimatedText.svelte.d.ts +6 -0
- package/dist/Block.svelte +18 -7
- package/dist/Elements/Alert.svelte +1 -1
- package/dist/Elements/Code.svelte +1 -2
- package/dist/Elements/Element.svelte +27 -8
- package/dist/Elements/FootnoteRef.svelte +3 -2
- package/dist/Elements/Image.svelte +3 -3
- package/dist/Elements/Link.svelte +1 -1
- package/dist/Elements/Math.svelte +8 -4
- package/dist/Elements/Mermaid.svelte +2 -4
- package/dist/Elements/Table.svelte +1 -1
- package/dist/Streamdown.svelte +77 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/marked/marked-footnotes.js +1 -1
- package/dist/{Streamdown.d.ts → streamdown.svelte.d.ts} +14 -0
- package/dist/streamdown.svelte.js +38 -0
- package/dist/theme.js +10 -4
- package/dist/utils/parse-incomplete-markdown.js +30 -9
- package/package.json +2 -1
- package/dist/Streamdown.js +0 -19
- package/dist/Streamdown.svelte.d.ts +0 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Svelte Streamdown
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
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,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}
|
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
|
-
{
|
|
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
|
-
{
|
|
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 '../
|
|
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 '../
|
|
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 '../
|
|
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
|
-
|
|
29
|
-
{
|
|
30
|
-
|
|
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
|
-
{
|
|
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 '../
|
|
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 '../
|
|
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
|
-
<
|
|
33
|
+
<span class={streamdown.theme.image.base}>
|
|
34
34
|
<img class={streamdown.theme.image.image} src={transformedUrl} alt={token.text} />
|
|
35
|
-
</
|
|
35
|
+
</span>
|
|
36
36
|
</Slot>
|
|
37
37
|
{:else}
|
|
38
38
|
<span
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, untrack } from 'svelte';
|
|
3
|
-
import { useStreamdown } from '../
|
|
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
|
|
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 {
|
|
3
|
-
import { useStreamdown } from '../
|
|
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';
|
package/dist/Streamdown.svelte
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Block from './Block.svelte';
|
|
3
|
-
import { StreamdownContext, type StreamdownProps } from './
|
|
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 './
|
|
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 './
|
|
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';
|
|
@@ -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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
65
|
-
if (
|
|
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.
|
|
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",
|
package/dist/Streamdown.js
DELETED
|
@@ -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';
|