svelte-streamdown 2.0.3 → 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';
@@ -61,8 +61,103 @@
61
61
  }
62
62
  });
63
63
 
64
+ const sanitizeMermaidCode = (code: string): string => {
65
+ try {
66
+ let sanitized = code;
67
+
68
+ // 1. Remove Byte Order Mark (BOM)
69
+ sanitized = sanitized.replace(/^\uFEFF/, '');
70
+
71
+ // 2. Normalize Unicode (NFC form for consistent rendering)
72
+ sanitized = sanitized.normalize('NFC');
73
+
74
+ // 3. Remove invisible/zero-width characters
75
+ sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\u205F-\u206F]/g, '');
76
+
77
+ // 4. Remove control characters (except tab, line feed, carriage return)
78
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
79
+
80
+ // 5. Normalize line endings to LF
81
+ sanitized = sanitized.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
82
+
83
+ // 6. Decode common HTML entities that might appear in Mermaid code
84
+ const htmlEntities: Record<string, string> = {
85
+ '&lt;': '<',
86
+ '&gt;': '>',
87
+ '&amp;': '&',
88
+ '&quot;': '"',
89
+ '&#39;': "'",
90
+ '&apos;': "'",
91
+ '&nbsp;': ' ',
92
+ '&hellip;': '...',
93
+ '&mdash;': '--',
94
+ '&ndash;': '-',
95
+ '&lsquo;': "'",
96
+ '&rsquo;': "'",
97
+ '&ldquo;': '"',
98
+ '&rdquo;': '"'
99
+ };
100
+
101
+ for (const [entity, replacement] of Object.entries(htmlEntities)) {
102
+ sanitized = sanitized.replace(new RegExp(entity, 'g'), replacement);
103
+ }
104
+
105
+ // 7. Convert smart quotes and other quote variants to standard quotes
106
+ sanitized = sanitized
107
+ .replace(/[\u2018\u2019]/g, "'") // Smart single quotes
108
+ .replace(/[\u201C\u201D]/g, '"') // Smart double quotes
109
+ .replace(/[\u2013\u2014]/g, '-') // Em/en dashes
110
+ .replace(/\u2026/g, '...'); // Horizontal ellipsis
111
+
112
+ // 8. Trim leading/trailing whitespace from each line and remove empty lines
113
+ sanitized = sanitized
114
+ .split('\n')
115
+ .map((line) => line.trim())
116
+ .filter((line) => line.length > 0)
117
+ .join('\n');
118
+
119
+ // 9. Normalize multiple spaces/tabs to single space (but preserve indentation in code blocks)
120
+ sanitized = sanitized.replace(/[ \t]+/g, ' ');
121
+
122
+ // 10. Handle over-escaped characters (common in copied code)
123
+ // Convert double backslashes to single (except in JSON strings)
124
+ sanitized = sanitized.replace(/\\\\(?![\\"])/g, '\\');
125
+
126
+ // 11. Remove non-breaking spaces and other special spaces
127
+ sanitized = sanitized.replace(/[\u00A0\u1680\u180E\u2000-\u200A\u202F\u205F\u3000]/g, ' ');
128
+
129
+ // 12. Ensure proper spacing around operators and keywords
130
+ // Add space after commas if missing (common in CSV-like data)
131
+ sanitized = sanitized.replace(/,([^\s])/g, ', $1');
132
+
133
+ // 13. Clean up Mermaid-specific issues
134
+ // Remove trailing semicolons that might break parsing
135
+ sanitized = sanitized.replace(/;+\s*$/gm, '');
136
+
137
+ // Ensure proper spacing in flowchart syntax
138
+ sanitized = sanitized.replace(
139
+ /([A-Za-z0-9_]+)(\-\-|\-\-\>|\-\.\-|\-\.\-\>|\=\=|\=\=\>|\=\.\=\>|\=\.\-\>)/g,
140
+ '$1 $2'
141
+ );
142
+
143
+ // 14. Final cleanup: trim and ensure single trailing newline
144
+ sanitized = sanitized.trim();
145
+ if (sanitized && !sanitized.endsWith('\n')) {
146
+ sanitized += '\n';
147
+ }
148
+
149
+ return sanitized;
150
+ } catch (error) {
151
+ console.warn('Error during Mermaid code sanitization:', error);
152
+ // Return original code if sanitization fails
153
+ return code;
154
+ }
155
+ };
156
+
64
157
  const renderMermaid = async (code: string, element: HTMLElement) => {
65
158
  try {
159
+ // Sanitize the code first
160
+
66
161
  // Default configuration
67
162
  const defaultConfig: MermaidConfig = {
68
163
  theme: 'base',
@@ -83,20 +178,14 @@
83
178
  const mergedConfig = { ...defaultConfig };
84
179
  mermaid.initialize(mergedConfig);
85
180
 
86
- // Validate and render the diagram
87
- const isValidDiagram = await mermaid.parse(code);
88
- if (!isValidDiagram) {
89
- throw new Error('Invalid mermaid diagram syntax');
90
- }
91
- // Use a stable ID based on chart content hash and timestamp to ensure uniqueness
92
181
  const chartHash = code.split('').reduce((acc, char) => {
93
- // biome-ignore lint/suspicious/noBitwiseOperators: "Required for Mermaid"
94
182
  return ((acc << 5) - acc + char.charCodeAt(0)) | 0;
95
183
  }, 0);
184
+
96
185
  const uniqueId = `mermaid-${Math.abs(chartHash)}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
97
186
 
98
187
  // Render the diagram
99
- const { svg: svgString } = await mermaid.render(uniqueId, code);
188
+ const { svg: svgString } = await mermaid.render(uniqueId, sanitizeMermaidCode(code));
100
189
  const svg = new DOMParser().parseFromString(svgString, 'image/svg+xml').documentElement;
101
190
 
102
191
  const svgTarget = element.querySelector('[data-mermaid-svg]')!;
@@ -105,12 +194,14 @@
105
194
  svgTarget.setAttribute(attribute.name, attribute.value);
106
195
  });
107
196
  svgTarget.innerHTML = svg.innerHTML;
108
- // After rendering, fit the SVG within its parent container
109
197
 
110
198
  panzoom.zoomToFit();
111
199
  panzoom.zoomToFit();
112
200
  } catch (err) {
113
- // Do nothing
201
+ const sanitizedCode = sanitizeMermaidCode(code);
202
+ console.error('Mermaid rendering error:', err);
203
+ console.error('Original code:', code);
204
+ console.error('Sanitized code:', sanitizedCode);
114
205
  }
115
206
  };
116
207
  </script>
@@ -122,101 +213,103 @@
122
213
  {@attach insider.attach}
123
214
  data-expanded={'false'}
124
215
  >
125
- <div class={streamdown.theme.mermaid.buttons}>
126
- <button
127
- class={streamdown.theme.mermaid.button}
128
- aria-label="Zoom to fit"
129
- onclick={() => panzoom.zoomToFit()}
130
- data-panzoom-ignore
131
- >
132
- <svg
133
- class={streamdown.theme.mermaid.icon}
134
- xmlns="http://www.w3.org/2000/svg"
135
- viewBox="0 0 24 24"
136
- fill="none"
137
- stroke="currentColor"
138
- stroke-width="2"
139
- stroke-linecap="round"
140
- stroke-linejoin="round"
141
- ><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path
142
- d="M21 17v2a2 2 0 0 1-2 2h-2"
143
- /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><rect
144
- width="10"
145
- height="8"
146
- x="7"
147
- y="8"
148
- rx="1"
149
- /></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
223
+ >
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
150
249
  >
151
- </button>
152
- <button
153
- class={streamdown.theme.mermaid.button}
154
- aria-label="Zoom in"
155
- onclick={() => panzoom.zoomIn()}
156
- data-panzoom-ignore
157
- >
158
- <svg
159
- class={streamdown.theme.mermaid.icon}
160
- xmlns="http://www.w3.org/2000/svg"
161
- viewBox="0 0 24 24"
162
- fill="none"
163
- stroke="currentColor"
164
- stroke-width="2"
165
- stroke-linecap="round"
166
- stroke-linejoin="round"
167
- ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
168
- x1="11"
169
- x2="11"
170
- y1="8"
171
- y2="14"
172
- /><line x1="8" x2="14" y1="11" y2="11" /></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
173
288
  >
174
- </button>
175
- <button
176
- class={streamdown.theme.mermaid.button}
177
- aria-label="Zoom out"
178
- onclick={() => panzoom.zoomOut()}
179
- data-panzoom-ignore
180
- ><svg
181
- class={streamdown.theme.mermaid.icon}
182
- xmlns="http://www.w3.org/2000/svg"
183
- viewBox="0 0 24 24"
184
- fill="none"
185
- stroke="currentColor"
186
- stroke-width="2"
187
- stroke-linecap="round"
188
- stroke-linejoin="round"
189
- ><circle cx="11" cy="11" r="8" /><line x1="21" x2="16.65" y1="21" y2="16.65" /><line
190
- x1="8"
191
- x2="14"
192
- y1="11"
193
- y2="11"
194
- /></svg
195
- ></button
196
- >
197
- <button
198
- class={streamdown.theme.mermaid.button}
199
- aria-label="Toggle expand"
200
- onclick={() => panzoom.toggleExpand()}
201
- data-panzoom-ignore
202
- >
203
- <svg
204
- class={streamdown.theme.mermaid.icon}
205
- xmlns="http://www.w3.org/2000/svg"
206
- viewBox="0 0 24 24"
207
- fill="none"
208
- stroke="currentColor"
209
- stroke-width="2"
210
- stroke-linecap="round"
211
- stroke-linejoin="round"
212
- ><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path
213
- d="M21 8V3h-5"
214
- /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path
215
- d="M9 9 3 3"
216
- /></svg
289
+ <button
290
+ class={streamdown.theme.mermaid.button}
291
+ aria-label="Toggle expand"
292
+ onclick={() => panzoom.toggleExpand()}
293
+ data-panzoom-ignore
217
294
  >
218
- </button>
219
- </div>
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}
220
313
  <svg {@attach panzoom.attach} data-mermaid-svg></svg>
221
314
  </div>
222
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';
@@ -1,7 +1,7 @@
1
1
  import { Lexer } from 'marked';
2
2
  const variants = ['note', 'tip', 'important', 'warning', 'caution'];
3
3
  export function createSyntaxPattern(type) {
4
- return `^\\s*\\[!${type.toUpperCase()}\\]\\s+`;
4
+ return `^\\s*[\\*_]*\\[!${type.toUpperCase()}\\][\\*_]*\\s*`;
5
5
  }
6
6
  export default function markedAlert() {
7
7
  const defaultLexer = new Lexer({ gfm: true });
@@ -29,7 +29,11 @@ export function processAlertToken(token, tokenizer) {
29
29
  return;
30
30
  const tokens = token.tokens
31
31
  .map((token) => {
32
- return tokenizer.lexer.blockTokens(token.raw.replaceAll(`[!${matchedVariant.toUpperCase()}]`, '').trim(), [])[0];
32
+ let cleanedRaw = token.raw;
33
+ // Remove alert markers with any markdown formatting (asterisks/underscores)
34
+ const alertPattern = new RegExp(`[\\*_]*\\[!${matchedVariant.toUpperCase()}\\][\\*_]*`, 'g');
35
+ cleanedRaw = cleanedRaw.replaceAll(alertPattern, '').trim();
36
+ return tokenizer.lexer.blockTokens(cleanedRaw, [])[0];
33
37
  })
34
38
  .filter(Boolean);
35
39
  Object.assign(token, {
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.3",
3
+ "version": "2.1.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",