svelte-streamdown 0.0.1 → 1.0.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.
@@ -41,6 +41,10 @@
41
41
  import Em from './Em.svelte';
42
42
  import Del from './Del.svelte';
43
43
  import Alert from './Alert.svelte';
44
+ import { useStreamdown } from '../Streamdown.svelte';
45
+ import { type ElementProps } from './element.js';
46
+
47
+ const streamdown = useStreamdown();
44
48
  </script>
45
49
 
46
50
  {#if type === 'a'}
@@ -110,13 +114,17 @@
110
114
  <br />
111
115
  {:else if type === 'em'}
112
116
  <Em {props} {className} {children} {node} />
113
- {:else if type === 'ins'}
114
- <!-- <Ins {props} {children} {node} /> -->
115
117
  {:else if type === 'del'}
116
118
  <Del {props} {className} {children} {node} />
117
119
  {:else if type === 'alert'}
118
120
  <Alert {props} {className} {children} {node} />
119
121
  {:else}
122
+ {@const snippet = streamdown.customElements?.[type] as Snippet<[ElementProps]> | undefined}
123
+ {#if snippet}
124
+ {@render snippet({ props, className, children, node })}
125
+ {:else}
126
+ {@render children?.()}
127
+ {/if}
128
+
120
129
  <!-- Fallback for unsupported elements -->
121
- {@render children?.()}
122
130
  {/if}
@@ -27,13 +27,8 @@
27
27
  }}
28
28
  render={streamdown.snippets.img}
29
29
  >
30
- <div class={streamdown.theme.img.container}>
31
- <img
32
- src={transformedUrl}
33
- {alt}
34
- {...props}
35
- class={clsx(streamdown.theme.img.base, className)}
36
- />
30
+ <div class={clsx(streamdown.theme.img.base, className)} {...props}>
31
+ <img class={streamdown.theme.img.image} src={transformedUrl} {alt} />
37
32
  </div>
38
33
  </Slot>
39
34
  {:else}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
2
+ import { untrack } from 'svelte';
3
3
  import { useStreamdown } from '../Streamdown.svelte';
4
4
  import { clsx } from 'clsx';
5
5
  import type { ElementProps } from './element.js';
@@ -8,35 +8,27 @@
8
8
  import 'katex/dist/katex.min.css';
9
9
  const streamdown = useStreamdown();
10
10
  const { children, node, className, props }: ElementProps = $props();
11
- const code = $derived((node.children[0] as any).value as string);
12
- const isInline = className?.includes('inline') ?? false;
13
- let katex = $state<typeof import('katex').renderToString | null>(null);
14
- onMount(async () => {
15
- katex = (await import('katex')).renderToString;
16
- });
17
-
18
- const renderMath = async (code: string, element: HTMLElement) => {
19
- console.log({ code, node });
11
+ import katex from 'katex';
12
+ const isInline = className?.some?.((c: string) => c.includes('inline')) ?? false;
20
13
 
21
- if (!katex || !code) return;
14
+ let inner = $state<HTMLElement | null>(null);
15
+ const html = $derived.by(() => {
16
+ const config: KatexOptions = {
17
+ output: 'html',
18
+ displayMode: !isInline,
19
+ ...(typeof streamdown.katexConfig === 'function'
20
+ ? streamdown.katexConfig(isInline)
21
+ : streamdown.katexConfig || {})
22
+ };
23
+ const code = ((node.children[0] as any)?.value as string) ?? '';
22
24
  try {
23
- const customConfig =
24
- typeof streamdown.katexConfig === 'function'
25
- ? streamdown.katexConfig(isInline)
26
- : streamdown.katexConfig;
27
- // Default configuration
28
- const config: KatexOptions = {
29
- output: 'html',
30
- displayMode: !isInline,
31
- ...(customConfig || {})
32
- };
33
-
34
- const html = katex(code, config);
35
- element.innerHTML = html.toString();
36
- } catch (err) {
37
- // Do nothing
25
+ return katex.renderToString(code, config);
26
+ } catch (error) {
27
+ return untrack(() => {
28
+ return inner?.innerHTML || '';
29
+ });
38
30
  }
39
- };
31
+ });
40
32
  </script>
41
33
 
42
34
  <Slot
@@ -47,27 +39,17 @@
47
39
  }}
48
40
  render={streamdown.snippets.math}
49
41
  >
50
- {#if katex}
51
- {#if isInline}
52
- <span
53
- {...props}
54
- class={clsx(streamdown.theme.math.base, className)}
55
- {@attach (node) => renderMath(code, node)}
56
- >
57
- <!-- -->
58
- </span>
59
- {:else}
60
- <div class="h-fit w-full">
61
- <div class="overflow-x-auto">
62
- <div
63
- {...props}
64
- class={clsx(streamdown.theme.math.base, className)}
65
- {@attach (node) => renderMath(code, node)}
66
- ></div>
42
+ {#if isInline}
43
+ <span bind:this={inner} {...props} class={clsx(streamdown.theme.inlineMath.base, className)}>
44
+ {@html html}
45
+ </span>
46
+ {:else}
47
+ <div class="h-fit w-full">
48
+ <div class="overflow-x-auto">
49
+ <div bind:this={inner} {...props} class={clsx(streamdown.theme.math.base, className)}>
50
+ {@html html}
67
51
  </div>
68
52
  </div>
69
- {/if}
70
- {:else}
71
- <div {...props} class={clsx(streamdown.theme.math.base, className)}></div>
53
+ </div>
72
54
  {/if}
73
55
  </Slot>
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
2
+ import { onMount } from 'svelte';
3
3
  import { useStreamdown } from '../Streamdown.svelte';
4
4
  import { clsx } from 'clsx';
5
5
  import type { ElementProps } from './element.js';
6
6
  import Slot from './Slot.svelte';
7
7
  import type { MermaidConfig } from 'mermaid';
8
+ import { on } from 'svelte/events';
9
+ import { usePanzoom } from '../utils/panzoom.svelte';
8
10
 
9
11
  const streamdown = useStreamdown();
10
12
 
@@ -16,6 +18,45 @@
16
18
  mermaid = (await import('mermaid')).default;
17
19
  });
18
20
 
21
+ const useIsInsideForMoreThanAQuarterSecond = () => {
22
+ let isInside = $state(false);
23
+ let timeout: number | undefined = undefined;
24
+
25
+ return {
26
+ get isInside() {
27
+ return isInside;
28
+ },
29
+ attach: (node: HTMLElement) => {
30
+ const off1 = on(node, 'mouseenter', () => {
31
+ timeout = setTimeout(() => {
32
+ isInside = true;
33
+ }, 800);
34
+ });
35
+
36
+ const off2 = on(node, 'mouseleave', () => {
37
+ isInside = false;
38
+ clearTimeout(timeout);
39
+ });
40
+
41
+ return () => {
42
+ off1();
43
+ off2();
44
+ };
45
+ }
46
+ };
47
+ };
48
+
49
+ const insider = useIsInsideForMoreThanAQuarterSecond();
50
+
51
+ const panzoom = usePanzoom({
52
+ minZoom: 0.5,
53
+ maxZoom: 4,
54
+ zoomSpeed: 1,
55
+ get activateMouseWheel() {
56
+ return insider.isInside;
57
+ }
58
+ });
59
+
19
60
  const renderMermaid = async (code: string, element: HTMLElement) => {
20
61
  try {
21
62
  // Default configuration
@@ -25,6 +66,7 @@
25
66
  securityLevel: 'strict',
26
67
  fontFamily: 'monospace',
27
68
  suppressErrorRendering: true,
69
+
28
70
  flowchart: {
29
71
  useMaxWidth: true,
30
72
  htmlLabels: true,
@@ -50,10 +92,19 @@
50
92
  const uniqueId = `mermaid-${Math.abs(chartHash)}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
51
93
 
52
94
  // Render the diagram
53
- const { svg } = await mermaid.render(uniqueId, code);
95
+ const { svg: svgString } = await mermaid.render(uniqueId, code);
96
+ const svg = new DOMParser().parseFromString(svgString, 'image/svg+xml').documentElement;
97
+
98
+ const svgTarget = element.querySelector('[data-mermaid-svg]')!;
99
+ svg.id = uniqueId;
100
+ Array.from(svg.attributes).forEach((attribute) => {
101
+ svgTarget.setAttribute(attribute.name, attribute.value);
102
+ });
103
+ svgTarget.innerHTML = svg.innerHTML;
104
+ // After rendering, fit the SVG within its parent container
54
105
 
55
- // Insert the SVG into the container
56
- element.innerHTML = svg;
106
+ panzoom.zoomToFit();
107
+ panzoom.zoomToFit();
57
108
  } catch (err) {
58
109
  // Do nothing
59
110
  }
@@ -73,8 +124,159 @@
73
124
  {...props}
74
125
  class={clsx(streamdown.theme.mermaid.base, className)}
75
126
  {@attach (node) => renderMermaid(code, node)}
76
- ></div>
127
+ {@attach insider.attach}
128
+ data-expanded={'false'}
129
+ >
130
+ <div class={streamdown.theme.mermaid.buttons}>
131
+ <button
132
+ class={streamdown.theme.mermaid.button}
133
+ aria-label="Zoom to fit"
134
+ onclick={() => panzoom.zoomToFit()}
135
+ data-panzoom-ignore
136
+ >
137
+ <svg
138
+ class={streamdown.theme.mermaid.icon}
139
+ xmlns="http://www.w3.org/2000/svg"
140
+ viewBox="0 0 24 24"
141
+ fill="none"
142
+ stroke="currentColor"
143
+ stroke-width="2"
144
+ stroke-linecap="round"
145
+ stroke-linejoin="round"
146
+ ><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
147
+ d="M21 17v2a2 2 0 0 1-2 2h-2"
148
+ /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
149
+ width="10"
150
+ height="8"
151
+ x="7"
152
+ y="8"
153
+ rx="1"
154
+ /></svg
155
+ >
156
+ </button>
157
+ <button
158
+ class={streamdown.theme.mermaid.button}
159
+ aria-label="Zoom in"
160
+ onclick={() => panzoom.zoomIn()}
161
+ data-panzoom-ignore
162
+ >
163
+ <svg
164
+ class={streamdown.theme.mermaid.icon}
165
+ xmlns="http://www.w3.org/2000/svg"
166
+ viewBox="0 0 24 24"
167
+ fill="none"
168
+ stroke="currentColor"
169
+ stroke-width="2"
170
+ stroke-linecap="round"
171
+ stroke-linejoin="round"
172
+ ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
173
+ x1="11"
174
+ x2="11"
175
+ y1="8"
176
+ y2="14"
177
+ /><line x1="8" x2="14" y1="11" y2="11" /></svg
178
+ >
179
+ </button>
180
+ <button
181
+ class={streamdown.theme.mermaid.button}
182
+ aria-label="Zoom out"
183
+ onclick={() => panzoom.zoomOut()}
184
+ data-panzoom-ignore
185
+ ><svg
186
+ class={streamdown.theme.mermaid.icon}
187
+ xmlns="http://www.w3.org/2000/svg"
188
+ viewBox="0 0 24 24"
189
+ fill="none"
190
+ stroke="currentColor"
191
+ stroke-width="2"
192
+ stroke-linecap="round"
193
+ stroke-linejoin="round"
194
+ ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
195
+ x1="8"
196
+ x2="14"
197
+ y1="11"
198
+ y2="11"
199
+ /></svg
200
+ ></button
201
+ >
202
+ <button
203
+ class={streamdown.theme.mermaid.button}
204
+ aria-label="Toggle expand"
205
+ onclick={() => panzoom.toggleExpand()}
206
+ data-panzoom-ignore
207
+ >
208
+ <svg
209
+ class={streamdown.theme.mermaid.icon}
210
+ xmlns="http://www.w3.org/2000/svg"
211
+ viewBox="0 0 24 24"
212
+ fill="none"
213
+ stroke="currentColor"
214
+ stroke-width="2"
215
+ stroke-linecap="round"
216
+ stroke-linejoin="round"
217
+ ><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
218
+ d="M21 8V3h-5"
219
+ /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
220
+ d="M9 9 3 3"
221
+ /></svg
222
+ >
223
+ </button>
224
+ </div>
225
+ <svg {@attach panzoom.attach} data-mermaid-svg></svg>
226
+ </div>
77
227
  {:else}
78
228
  <div {...props} class={clsx(streamdown.theme.mermaid.base, className)}></div>
79
229
  {/if}
80
230
  </Slot>
231
+
232
+ <style>
233
+ /* View Transition styles for zoom effect */
234
+ ::view-transition-old(panzoom-element),
235
+ ::view-transition-new(panzoom-element) {
236
+ /* Animate both scale and opacity for smooth zoom */
237
+ animation-duration: 0.3s;
238
+ animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
239
+ }
240
+
241
+ /* Zoom in animation (expand) */
242
+ ::view-transition-old(panzoom-element) {
243
+ animation-name: zoom-out;
244
+ }
245
+
246
+ ::view-transition-new(panzoom-element) {
247
+ animation-name: zoom-in;
248
+ }
249
+
250
+ @keyframes zoom-out {
251
+ from {
252
+ transform: scale(1);
253
+ }
254
+ to {
255
+ transform: scale(0.8);
256
+ }
257
+ }
258
+
259
+ @keyframes zoom-in {
260
+ from {
261
+ transform: scale(1.2);
262
+ }
263
+ to {
264
+ transform: scale(1);
265
+ }
266
+ }
267
+
268
+ [data-expanded='true'] {
269
+ ::view-transition-old(panzoom-element),
270
+ ::view-transition-new(panzoom-element) {
271
+ animation-duration: 0.3s;
272
+ animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
273
+ }
274
+ position: fixed;
275
+ top: 16px;
276
+ left: 16px;
277
+ width: calc(100vw - 32px);
278
+ height: calc(100vh - 32px);
279
+ z-index: 2147483647;
280
+ margin: 0px;
281
+ }
282
+ </style>
@@ -17,8 +17,8 @@
17
17
  }}
18
18
  render={streamdown.snippets.table}
19
19
  >
20
- <div class={clsx(streamdown.theme.table.container)}>
21
- <table {...props} class={clsx(streamdown.theme.table.base, className)}>
20
+ <div class={clsx(streamdown.theme.table.base, className)}>
21
+ <table class={streamdown.theme.table.table} {...props}>
22
22
  {@render children()}
23
23
  </table>
24
24
  </div>
@@ -2,7 +2,7 @@ import type { Snippet } from 'svelte';
2
2
  import type { Element as HastElement } from 'hast';
3
3
  export type ElementProps<T = {}> = {
4
4
  children: Snippet;
5
- className?: string;
5
+ className?: string[];
6
6
  node: HastElement;
7
7
  props: Record<string, any>;
8
8
  } & T & Record<string, any>;
@@ -7,41 +7,9 @@ import type { Theme } from './theme.js';
7
7
  import type { MermaidConfig } from 'mermaid';
8
8
  import type { KatexOptions } from 'katex';
9
9
  import type { ElementProps } from './Elements/element.js';
10
+ type PredefinedElements = 'a' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'code' | 'inlineCode' | 'img' | 'ul' | 'ol' | 'li' | 'hr' | 'alert' | 'strong' | 'blockquote' | 'table' | 'thead' | 'tbody' | 'tr' | 'th' | 'td' | 'sup' | 'sub' | 'pre' | 'mermaid' | 'math' | 'inlineMath' | 'em' | 'del';
10
11
  export type Snippets = {
11
- a?: Snippet<[ElementProps]>;
12
- h1?: Snippet<[ElementProps]>;
13
- h2?: Snippet<[ElementProps]>;
14
- h3?: Snippet<[ElementProps]>;
15
- h4?: Snippet<[ElementProps]>;
16
- h5?: Snippet<[ElementProps]>;
17
- h6?: Snippet<[ElementProps]>;
18
- p?: Snippet<[ElementProps]>;
19
- code?: Snippet<[ElementProps]>;
20
- inlineCode?: Snippet<[ElementProps]>;
21
- img?: Snippet<[ElementProps]>;
22
- ul?: Snippet<[ElementProps]>;
23
- ol?: Snippet<[ElementProps]>;
24
- li?: Snippet<[ElementProps]>;
25
- hr?: Snippet<[ElementProps]>;
26
- alert?: Snippet<[ElementProps]>;
27
- strong?: Snippet<[ElementProps]>;
28
- blockquote?: Snippet<[ElementProps]>;
29
- table?: Snippet<[ElementProps]>;
30
- thead?: Snippet<[ElementProps]>;
31
- tbody?: Snippet<[ElementProps]>;
32
- tr?: Snippet<[ElementProps]>;
33
- th?: Snippet<[ElementProps]>;
34
- td?: Snippet<[ElementProps]>;
35
- sup?: Snippet<[ElementProps]>;
36
- sub?: Snippet<[ElementProps]>;
37
- pre?: Snippet<[ElementProps]>;
38
- mermaid?: Snippet<[ElementProps]>;
39
- math?: Snippet<[ElementProps]>;
40
- inlineMath?: Snippet<[ElementProps]>;
41
- em?: Snippet<[ElementProps]>;
42
- ins?: Snippet<[ElementProps]>;
43
- del?: Snippet<[ElementProps]>;
44
- small?: Snippet<[ElementProps]>;
12
+ [K in PredefinedElements]?: Snippet<[ElementProps]>;
45
13
  };
46
14
  /**
47
15
  * Filter elements callback type
@@ -68,7 +36,10 @@ export type StreamdownProps = {
68
36
  unwrapDisallowed?: boolean;
69
37
  urlTransform?: UrlTransform | null;
70
38
  theme?: Partial<Theme>;
39
+ baseTheme?: 'tailwind' | 'shadcn';
40
+ mergeTheme?: boolean;
71
41
  shikiTheme?: BundledTheme;
42
+ shikiPreloadThemes?: BundledTheme[];
72
43
  mermaidConfig?: MermaidConfig;
73
44
  katexConfig?: KatexOptions | ((inline: boolean) => KatexOptions);
74
45
  translations?: {
@@ -80,4 +51,6 @@ export type StreamdownProps = {
80
51
  important?: string;
81
52
  };
82
53
  };
83
- } & Snippets;
54
+ customElements?: Record<string, Snippet<[ElementProps]>>;
55
+ } & Partial<Snippets>;
56
+ export {};
@@ -5,7 +5,6 @@
5
5
  extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'shikiTheme'> {
6
6
  snippets: Snippets;
7
7
  shikiTheme: BundledTheme;
8
- // make it non optional
9
8
  theme: Theme;
10
9
  }
11
10
  export class StreamdownContext {
@@ -29,13 +28,15 @@
29
28
  import { bind } from './utils/bind.js';
30
29
  import { parseMarkdownIntoBlocks } from './utils/parse-blocks.js';
31
30
  import 'katex/dist/katex.min.css';
32
- import { mergeTheme, type Theme } from './theme.js';
31
+ import { mergeTheme, type Theme, shadcnTheme } from './theme.js';
33
32
  import type { BundledTheme } from 'shiki';
33
+ import { highlighter } from './hightlighter.svelte.js';
34
34
 
35
35
  let {
36
36
  content,
37
37
  class: className,
38
38
  shikiTheme,
39
+ shikiPreloadThemes,
39
40
  parseIncompleteMarkdown,
40
41
  defaultOrigin,
41
42
  allowedLinkPrefixes = ['*'],
@@ -53,6 +54,9 @@
53
54
  mermaidConfig,
54
55
  katexConfig,
55
56
  translations,
57
+ baseTheme,
58
+ mergeTheme: shouldMergeTheme = true,
59
+ customElements,
56
60
  ...snippets
57
61
  }: StreamdownProps = $props();
58
62
 
@@ -106,7 +110,12 @@
106
110
  return snippets;
107
111
  },
108
112
  get theme() {
109
- return mergeTheme(theme);
113
+ return shouldMergeTheme
114
+ ? mergeTheme(theme, baseTheme)
115
+ : theme || (baseTheme === 'shadcn' ? shadcnTheme : theme);
116
+ },
117
+ get baseTheme() {
118
+ return baseTheme;
110
119
  },
111
120
  get mermaidConfig() {
112
121
  return mermaidConfig;
@@ -116,11 +125,21 @@
116
125
  },
117
126
  get translations() {
118
127
  return translations;
128
+ },
129
+ get customElements() {
130
+ return customElements;
119
131
  }
120
132
  });
121
133
  const id = $props.id();
122
134
 
123
135
  const blocks = $derived(parseMarkdownIntoBlocks(content));
136
+
137
+ // Preload themes if specified to avoid flickering
138
+ $effect(() => {
139
+ if (shikiPreloadThemes && shikiPreloadThemes.length > 0) {
140
+ highlighter.preloadThemes(shikiPreloadThemes);
141
+ }
142
+ });
124
143
  </script>
125
144
 
126
145
  {#each blocks as block, index (`${id}-block-${index}`)}
@@ -1,24 +1,30 @@
1
1
  import { type BundledLanguage, type BundledTheme } from 'shiki';
2
2
  declare class HighlighterManager {
3
3
  initialized: boolean;
4
- initializedPromise: Promise<void> | null;
5
- private highlighter;
6
- private theme;
7
- private readonly loadedLanguages;
8
- private initializationPromise;
9
- private readyStates;
10
- private getReadyKey;
4
+ private state;
5
+ private readonly preloadedThemes;
6
+ private readonly readyCache;
7
+ initPromise: Promise<void> | null;
8
+ private getCacheKey;
9
+ isLoaded(theme: BundledTheme, language: BundledLanguage): boolean;
11
10
  /**
12
- * Prepares the highlighter for the given themes and language.
13
- * This method must be called and awaited before using highlightCode.
11
+ * Preloads themes by creating minimal highlighter instances.
12
+ * This reduces flickering when switching themes.
13
+ */
14
+ preloadThemes(themes: BundledTheme[]): Promise<void>;
15
+ /**
16
+ * Ensures the highlighter is ready for the given theme and language.
14
17
  */
15
18
  isReady(theme: BundledTheme, language: BundledLanguage): Promise<void>;
16
- private ensureHighlightersInitialized;
19
+ private initialize;
17
20
  /**
18
- * Synchronously highlights code. Must call isReady() first for the same themes and language.
19
- * @throws {Error} If isReady() hasn't been called for this combination of themes and language
21
+ * Highlights code synchronously. Must call isReady() first.
20
22
  */
21
23
  highlightCode(code: string, language: BundledLanguage, theme: BundledTheme, preClassName?: string): string;
24
+ /**
25
+ * Clean up resources
26
+ */
27
+ dispose(): void;
22
28
  }
23
29
  export { HighlighterManager };
24
30
  export declare const highlighter: HighlighterManager;