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 +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 +17 -16
- package/dist/Elements/Element.svelte +33 -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 +95 -98
- package/dist/Elements/Table.svelte +1 -1
- package/dist/Streamdown.svelte +90 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/marked/marked-footnotes.js +1 -1
- package/dist/{Streamdown.d.ts → streamdown.svelte.d.ts} +23 -0
- package/dist/streamdown.svelte.js +38 -0
- package/dist/theme.js +16 -10
- package/dist/utils/hightlighter.svelte.d.ts +0 -1
- package/dist/utils/hightlighter.svelte.js +7 -5
- package/dist/utils/parse-incomplete-markdown.js +54 -10
- 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
|
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 '../
|
|
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'}
|
|
@@ -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 '../
|
|
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
2
|
import { onMount } from 'svelte';
|
|
3
|
-
import { useStreamdown } from '../
|
|
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
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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}
|
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
|
|
|
@@ -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 './
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
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 './
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
2
|
+
export { useStreamdown } from './streamdown.svelte.js';
|
|
3
|
+
export { theme, shadcnTheme, mergeTheme } from './theme.js';
|
|
4
|
+
export { lex, parseBlocks } from './marked/index.js';
|
|
@@ -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
|
|
43
|
-
header: 'flex items-center justify-between bg-gray-100/80 p-2
|
|
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
|
|
170
|
-
header: 'flex items-center justify-between bg-muted/80
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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}*`;
|
|
@@ -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
|
|
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';
|