svelte-streamdown 2.0.4 → 2.1.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.
@@ -47,21 +47,23 @@
47
47
  <div class={streamdown.theme.code.base} data-language={language}>
48
48
  <div class={streamdown.theme.code.header} data-code-block-header data-language={language}>
49
49
  <span class={streamdown.theme.code.language}>{language}</span>
50
- <div class="flex items-center gap-2">
51
- <!-- Download button snippet -->
52
- <button
53
- class={streamdown.theme.code.button}
54
- onclick={downloadCode}
55
- title="Download file"
56
- type="button"
57
- >
58
- {@render downloadIcon()}
59
- </button>
50
+ {#if streamdown.controls.code}
51
+ <div class="flex items-center gap-2">
52
+ <!-- Download button snippet -->
53
+ <button
54
+ class={streamdown.theme.code.button}
55
+ onclick={downloadCode}
56
+ title="Download file"
57
+ type="button"
58
+ >
59
+ {@render downloadIcon()}
60
+ </button>
60
61
 
61
- <button class={streamdown.theme.code.button} onclick={copy.copy} type="button">
62
- {@render copyIcon()}
63
- </button>
64
- </div>
62
+ <button class={streamdown.theme.code.button} onclick={copy.copy} type="button">
63
+ {@render copyIcon()}
64
+ </button>
65
+ </div>
66
+ {/if}
65
67
  </div>
66
68
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
67
69
  <div>
@@ -203,6 +203,12 @@
203
203
  <Alert {token} {children} />
204
204
  {:else if token.type === 'footnoteRef'}
205
205
  <FootnoteRef {token} />
206
+ {:else if token.type === 'html'}
207
+ {#if streamdown.renderHtml}
208
+ {@const content =
209
+ typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
210
+ {@html content}
211
+ {/if}
206
212
  {:else}
207
213
  <!-- For tokens we don't handle specifically, render children or fallback -->
208
214
  {@render children?.()}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
2
+ import { flushSync, onMount, tick } from 'svelte';
3
3
  import { useStreamdown } from '../Streamdown.js';
4
4
  import Slot from './Slot.svelte';
5
5
  import type { Tokens } from 'marked';
@@ -178,9 +178,7 @@
178
178
  const mergedConfig = { ...defaultConfig };
179
179
  mermaid.initialize(mergedConfig);
180
180
 
181
- // Use a stable ID based on chart content hash and timestamp to ensure uniqueness
182
181
  const chartHash = code.split('').reduce((acc, char) => {
183
- // biome-ignore lint/suspicious/noBitwiseOperators: "Required for Mermaid"
184
182
  return ((acc << 5) - acc + char.charCodeAt(0)) | 0;
185
183
  }, 0);
186
184
 
@@ -196,7 +194,6 @@
196
194
  svgTarget.setAttribute(attribute.name, attribute.value);
197
195
  });
198
196
  svgTarget.innerHTML = svg.innerHTML;
199
- // After rendering, fit the SVG within its parent container
200
197
 
201
198
  panzoom.zoomToFit();
202
199
  panzoom.zoomToFit();
@@ -216,101 +213,103 @@
216
213
  {@attach insider.attach}
217
214
  data-expanded={'false'}
218
215
  >
219
- <div class={streamdown.theme.mermaid.buttons}>
220
- <button
221
- class={streamdown.theme.mermaid.button}
222
- aria-label="Zoom to fit"
223
- onclick={() => panzoom.zoomToFit()}
224
- data-panzoom-ignore
225
- >
226
- <svg
227
- class={streamdown.theme.mermaid.icon}
228
- xmlns="http://www.w3.org/2000/svg"
229
- viewBox="0 0 24 24"
230
- fill="none"
231
- stroke="currentColor"
232
- stroke-width="2"
233
- stroke-linecap="round"
234
- stroke-linejoin="round"
235
- ><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
236
- d="M21 17v2a2 2 0 0 1-2 2h-2"
237
- /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
238
- width="10"
239
- height="8"
240
- x="7"
241
- y="8"
242
- rx="1"
243
- /></svg
216
+ {#if streamdown.controls.mermaid}
217
+ <div class={streamdown.theme.mermaid.buttons}>
218
+ <button
219
+ class={streamdown.theme.mermaid.button}
220
+ aria-label="Zoom to fit"
221
+ onclick={() => panzoom.zoomToFit()}
222
+ data-panzoom-ignore
244
223
  >
245
- </button>
246
- <button
247
- class={streamdown.theme.mermaid.button}
248
- aria-label="Zoom in"
249
- onclick={() => panzoom.zoomIn()}
250
- data-panzoom-ignore
251
- >
252
- <svg
253
- class={streamdown.theme.mermaid.icon}
254
- xmlns="http://www.w3.org/2000/svg"
255
- viewBox="0 0 24 24"
256
- fill="none"
257
- stroke="currentColor"
258
- stroke-width="2"
259
- stroke-linecap="round"
260
- stroke-linejoin="round"
261
- ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
262
- x1="11"
263
- x2="11"
264
- y1="8"
265
- y2="14"
266
- /><line x1="8" x2="14" y1="11" y2="11" /></svg
224
+ <svg
225
+ class={streamdown.theme.mermaid.icon}
226
+ xmlns="http://www.w3.org/2000/svg"
227
+ viewBox="0 0 24 24"
228
+ fill="none"
229
+ stroke="currentColor"
230
+ stroke-width="2"
231
+ stroke-linecap="round"
232
+ stroke-linejoin="round"
233
+ ><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
234
+ d="M21 17v2a2 2 0 0 1-2 2h-2"
235
+ /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
236
+ width="10"
237
+ height="8"
238
+ x="7"
239
+ y="8"
240
+ rx="1"
241
+ /></svg
242
+ >
243
+ </button>
244
+ <button
245
+ class={streamdown.theme.mermaid.button}
246
+ aria-label="Zoom in"
247
+ onclick={() => panzoom.zoomIn()}
248
+ data-panzoom-ignore
267
249
  >
268
- </button>
269
- <button
270
- class={streamdown.theme.mermaid.button}
271
- aria-label="Zoom out"
272
- onclick={() => panzoom.zoomOut()}
273
- data-panzoom-ignore
274
- ><svg
275
- class={streamdown.theme.mermaid.icon}
276
- xmlns="http://www.w3.org/2000/svg"
277
- viewBox="0 0 24 24"
278
- fill="none"
279
- stroke="currentColor"
280
- stroke-width="2"
281
- stroke-linecap="round"
282
- stroke-linejoin="round"
283
- ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
284
- x1="8"
285
- x2="14"
286
- y1="11"
287
- y2="11"
288
- /></svg
289
- ></button
290
- >
291
- <button
292
- class={streamdown.theme.mermaid.button}
293
- aria-label="Toggle expand"
294
- onclick={() => panzoom.toggleExpand()}
295
- data-panzoom-ignore
296
- >
297
- <svg
298
- class={streamdown.theme.mermaid.icon}
299
- xmlns="http://www.w3.org/2000/svg"
300
- viewBox="0 0 24 24"
301
- fill="none"
302
- stroke="currentColor"
303
- stroke-width="2"
304
- stroke-linecap="round"
305
- stroke-linejoin="round"
306
- ><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
307
- d="M21 8V3h-5"
308
- /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
309
- d="M9 9 3 3"
310
- /></svg
250
+ <svg
251
+ class={streamdown.theme.mermaid.icon}
252
+ xmlns="http://www.w3.org/2000/svg"
253
+ viewBox="0 0 24 24"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ stroke-width="2"
257
+ stroke-linecap="round"
258
+ stroke-linejoin="round"
259
+ ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
260
+ x1="11"
261
+ x2="11"
262
+ y1="8"
263
+ y2="14"
264
+ /><line x1="8" x2="14" y1="11" y2="11" /></svg
265
+ >
266
+ </button>
267
+ <button
268
+ class={streamdown.theme.mermaid.button}
269
+ aria-label="Zoom out"
270
+ onclick={() => panzoom.zoomOut()}
271
+ data-panzoom-ignore
272
+ ><svg
273
+ class={streamdown.theme.mermaid.icon}
274
+ xmlns="http://www.w3.org/2000/svg"
275
+ viewBox="0 0 24 24"
276
+ fill="none"
277
+ stroke="currentColor"
278
+ stroke-width="2"
279
+ stroke-linecap="round"
280
+ stroke-linejoin="round"
281
+ ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
282
+ x1="8"
283
+ x2="14"
284
+ y1="11"
285
+ y2="11"
286
+ /></svg
287
+ ></button
311
288
  >
312
- </button>
313
- </div>
289
+ <button
290
+ class={streamdown.theme.mermaid.button}
291
+ aria-label="Toggle expand"
292
+ onclick={() => panzoom.toggleExpand()}
293
+ data-panzoom-ignore
294
+ >
295
+ <svg
296
+ class={streamdown.theme.mermaid.icon}
297
+ xmlns="http://www.w3.org/2000/svg"
298
+ viewBox="0 0 24 24"
299
+ fill="none"
300
+ stroke="currentColor"
301
+ stroke-width="2"
302
+ stroke-linecap="round"
303
+ stroke-linejoin="round"
304
+ ><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
305
+ d="M21 8V3h-5"
306
+ /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
307
+ d="M9 9 3 3"
308
+ /></svg
309
+ >
310
+ </button>
311
+ </div>
312
+ {/if}
314
313
  <svg {@attach panzoom.attach} data-mermaid-svg></svg>
315
314
  </div>
316
315
  {:else}
@@ -7,6 +7,10 @@ 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
+ };
10
14
  }
11
15
  export declare class StreamdownContext {
12
16
  footnotes: {
@@ -86,5 +90,10 @@ export type StreamdownProps = {
86
90
  important?: string;
87
91
  };
88
92
  };
93
+ controls?: {
94
+ code?: boolean;
95
+ mermaid?: boolean;
96
+ };
97
+ renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
89
98
  } & Partial<Snippets>;
90
99
  export {};
@@ -21,6 +21,8 @@
21
21
  baseTheme,
22
22
  mergeTheme: shouldMergeTheme = true,
23
23
  streamdown = $bindable(),
24
+ renderHtml,
25
+ controls,
24
26
  ...snippets
25
27
  }: StreamdownProps = $props();
26
28
 
@@ -60,11 +62,22 @@
60
62
  get katexConfig() {
61
63
  return katexConfig;
62
64
  },
65
+ get renderHtml() {
66
+ return renderHtml;
67
+ },
63
68
  get translations() {
64
69
  return translations;
65
70
  },
66
71
  get shikiPreloadThemes() {
67
72
  return shikiPreloadThemes;
73
+ },
74
+ get controls() {
75
+ const codeControls = controls?.code ?? true;
76
+ const mermaidControls = controls?.mermaid ?? true;
77
+ return {
78
+ code: codeControls,
79
+ mermaid: mermaidControls
80
+ };
68
81
  }
69
82
  });
70
83
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { default as Streamdown } from './Streamdown.svelte';
2
- export { useStreamdown } from './Streamdown.js';
3
- export * from './Elements/index.js';
4
- export { theme, shadcnTheme, mergeTheme, cn, type Theme } from './theme.js';
2
+ export { useStreamdown, type StreamdownProps } from './Streamdown.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
2
  export { useStreamdown } from './Streamdown.js';
3
- export * from './Elements/index.js';
4
- export { theme, shadcnTheme, mergeTheme, cn } from './theme.js';
3
+ export { theme, shadcnTheme, mergeTheme } from './theme.js';
4
+ export { lex, parseBlocks } from './marked/index.js';
package/dist/theme.js CHANGED
@@ -3,7 +3,7 @@ import { twMerge } from 'tailwind-merge';
3
3
  export const cn = (...inputs) => twMerge(clsx(inputs));
4
4
  export const theme = {
5
5
  link: {
6
- base: 'text-blue-600 font-medium underline',
6
+ base: 'text-blue-600 font-medium underline wrap-anywhere hover:text-blue-600/80',
7
7
  blocked: 'text-gray-500'
8
8
  },
9
9
  h1: {
@@ -39,8 +39,8 @@ export const theme = {
39
39
  },
40
40
  code: {
41
41
  base: 'my-4 w-full overflow-hidden rounded-xl border border-gray-200 flex flex-col',
42
- container: ' relative overflow-visible bg-gray-100 rounded p-2 font-mono text-sm ',
43
- header: 'flex items-center justify-between bg-gray-100/80 p-2 pb-0 text-gray-600 text-xs',
42
+ container: ' relative overflow-visible bg-gray-100 p-2 font-mono text-sm ',
43
+ header: 'flex items-center justify-between bg-gray-100/80 p-2 text-gray-600 text-xs',
44
44
  button: 'cursor-pointer size-6 p-1 text-gray-600 transition-all hover:text-gray-900 rounded hover:bg-gray-100',
45
45
  language: 'ml-1 font-mono lowercase',
46
46
  skeleton: 'rounded-md font-mono text-transparent bg-gray-200 scale-y-90 animate-pulse whitespace-nowrap inline-block',
@@ -130,7 +130,7 @@ export const theme = {
130
130
  };
131
131
  export const shadcnTheme = {
132
132
  link: {
133
- base: 'text-primary font-medium underline hover:text-primary/80',
133
+ base: 'text-primary wrap-anywhere font-medium underline hover:text-primary/80',
134
134
  blocked: 'text-muted-foreground'
135
135
  },
136
136
  h1: {
@@ -166,8 +166,8 @@ export const shadcnTheme = {
166
166
  },
167
167
  code: {
168
168
  base: 'my-4 w-full overflow-hidden rounded-lg border border-border flex flex-col',
169
- container: 'relative overflow-visible bg-muted rounded p-2 font-mono text-sm',
170
- header: 'flex items-center justify-between bg-muted/80 p-2 pb-0 text-muted-foreground text-xs',
169
+ container: 'relative overflow-visible bg-muted p-2 font-mono text-sm',
170
+ header: 'flex items-center justify-between bg-muted/80 px-2 py-1 text-muted-foreground text-xs',
171
171
  button: 'cursor-pointer size-6 p-1 text-muted-foreground transition-all hover:text-foreground rounded hover:bg-muted',
172
172
  language: 'ml-1 font-mono lowercase',
173
173
  skeleton: 'rounded-md font-mono text-transparent bg-border/80 scale-y-90 w-fit animate-pulse whitespace-nowrap inline-block',
@@ -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
@@ -244,6 +244,13 @@ const countSingleUnderscores = (text) => {
244
244
  if (isWithinMathBlock(text, index)) {
245
245
  return acc;
246
246
  }
247
+ // Skip if underscore is word-internal (between word characters)
248
+ if (prevChar &&
249
+ nextChar &&
250
+ /[\p{L}\p{N}_]/u.test(prevChar) &&
251
+ /[\p{L}\p{N}_]/u.test(nextChar)) {
252
+ return acc;
253
+ }
247
254
  if (prevChar !== '_' && nextChar !== '_') {
248
255
  return acc + 1;
249
256
  }
@@ -259,13 +266,23 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
259
266
  }
260
267
  const singleUnderscoreMatch = text.match(singleUnderscorePattern);
261
268
  if (singleUnderscoreMatch) {
262
- // Find the first single underscore position (not part of __)
269
+ // Find the first single underscore position (not part of __ and not word-internal)
263
270
  let firstSingleUnderscoreIndex = -1;
264
271
  for (let i = 0; i < text.length; i++) {
265
272
  if (text[i] === '_' &&
266
273
  text[i - 1] !== '_' &&
267
274
  text[i + 1] !== '_' &&
275
+ text[i - 1] !== '\\' &&
268
276
  !isWithinMathBlock(text, i)) {
277
+ // Check if underscore is word-internal (between word characters)
278
+ const prevChar = i > 0 ? text[i - 1] : '';
279
+ const nextChar = i < text.length - 1 ? text[i + 1] : '';
280
+ if (prevChar &&
281
+ nextChar &&
282
+ /[\p{L}\p{N}_]/u.test(prevChar) &&
283
+ /[\p{L}\p{N}_]/u.test(nextChar)) {
284
+ continue;
285
+ }
269
286
  firstSingleUnderscoreIndex = i;
270
287
  break;
271
288
  }
@@ -282,6 +299,12 @@ const handleIncompleteSingleUnderscoreItalic = (text) => {
282
299
  }
283
300
  const singleUnderscores = countSingleUnderscores(text);
284
301
  if (singleUnderscores % 2 === 1) {
302
+ // If text ends with newline(s), insert underscore before them
303
+ const trailingNewlineMatch = text.match(/\n+$/);
304
+ if (trailingNewlineMatch) {
305
+ const textBeforeNewlines = text.slice(0, -trailingNewlineMatch[0].length);
306
+ return `${textBeforeNewlines}_${trailingNewlineMatch[0]}`;
307
+ }
285
308
  return `${text}_`;
286
309
  }
287
310
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",