svelte-streamdown 3.1.2 → 4.0.1

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.
Files changed (37) hide show
  1. package/README.md +130 -43
  2. package/dist/Block.svelte +5 -2
  3. package/dist/Elements/Citation.svelte +2 -2
  4. package/dist/Elements/Code.svelte +21 -54
  5. package/dist/Elements/Code.svelte.d.ts +2 -2
  6. package/dist/Elements/Element.svelte +8 -5
  7. package/dist/Elements/Link.svelte +2 -1
  8. package/dist/Elements/Mermaid.svelte +8 -3
  9. package/dist/Elements/Mermaid.svelte.d.ts +2 -2
  10. package/dist/Elements/MermaidDownload.svelte +1 -1
  11. package/dist/Elements/TableDownload.svelte +13 -5
  12. package/dist/Elements/fallbacks/CodeFallback.svelte +7 -1
  13. package/dist/Elements/fallbacks/MermaidFallback.svelte +7 -1
  14. package/dist/Streamdown.svelte +12 -15
  15. package/dist/context.svelte.d.ts +9 -8
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1 -1
  18. package/dist/marked/index.d.ts +20 -1
  19. package/dist/marked/index.js +160 -39
  20. package/dist/marked/marked-br.js +6 -0
  21. package/dist/marked/marked-citations.js +6 -0
  22. package/dist/marked/marked-footnotes.js +18 -4
  23. package/dist/marked/marked-math.js +5 -0
  24. package/dist/marked/marked-subsup.js +24 -3
  25. package/dist/marked/marked-table.js +15 -12
  26. package/dist/theme.d.ts +0 -3
  27. package/dist/theme.js +0 -2
  28. package/dist/utils/highlightThemes.d.ts +4 -0
  29. package/dist/utils/highlightThemes.js +14 -0
  30. package/dist/utils/hightlighter.svelte.d.ts +6 -29
  31. package/dist/utils/hightlighter.svelte.js +27 -212
  32. package/dist/utils/parse-incomplete-markdown.js +100 -18
  33. package/dist/utils/save.js +4 -1
  34. package/dist/utils/url.js +6 -2
  35. package/package.json +5 -6
  36. package/dist/utils/bundledLanguages.d.ts +0 -8
  37. package/dist/utils/bundledLanguages.js +0 -143
package/dist/theme.d.ts CHANGED
@@ -42,7 +42,6 @@ export declare const theme: {
42
42
  header: string;
43
43
  buttons: string;
44
44
  language: string;
45
- skeleton: string;
46
45
  pre: string;
47
46
  line: string;
48
47
  };
@@ -195,7 +194,6 @@ export declare const shadcnTheme: {
195
194
  header: string;
196
195
  buttons: string;
197
196
  language: string;
198
- skeleton: string;
199
197
  pre: string;
200
198
  line: string;
201
199
  };
@@ -353,7 +351,6 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
353
351
  header: string;
354
352
  buttons: string;
355
353
  language: string;
356
- skeleton: string;
357
354
  pre: string;
358
355
  line: string;
359
356
  };
package/dist/theme.js CHANGED
@@ -43,7 +43,6 @@ export const theme = {
43
43
  header: 'flex items-center justify-between bg-gray-100/80 p-2 text-gray-600 text-xs',
44
44
  buttons: 'flex items-center gap-2',
45
45
  language: 'ml-1 font-mono lowercase',
46
- skeleton: 'block rounded-md font-mono text-transparent bg-gray-200 scale-y-90 animate-pulse whitespace-nowrap',
47
46
  pre: 'overflow-x-auto font-mono p-0 bg-gray-100/40',
48
47
  line: 'block'
49
48
  },
@@ -196,7 +195,6 @@ export const shadcnTheme = {
196
195
  header: 'flex items-center justify-between bg-muted/80 px-2 py-1 text-muted-foreground text-xs',
197
196
  buttons: 'flex items-center gap-2',
198
197
  language: 'ml-1 font-mono lowercase',
199
- skeleton: 'block rounded-md font-mono text-transparent bg-border/80 scale-y-90 w-fit animate-pulse whitespace-nowrap',
200
198
  pre: 'overflow-x-auto font-mono p-0 bg-muted/40',
201
199
  line: 'block '
202
200
  },
@@ -0,0 +1,4 @@
1
+ import type { HighlightTheme } from '@tanstack/highlight/theme';
2
+ export declare const DEFAULT_THEMES: Record<string, HighlightTheme>;
3
+ /** Resolves the active theme: explicit key, else the first user theme, else the dark-mode default. */
4
+ export declare function resolveHighlightTheme(key: string | undefined, extra: Record<string, HighlightTheme> | undefined, isDark: boolean): HighlightTheme;
@@ -0,0 +1,14 @@
1
+ import githubDark from '@tanstack/highlight/themes/github-dark';
2
+ import githubLight from '@tanstack/highlight/themes/github-light';
3
+ // ponytail: themes live apart from the highlighter so <Streamdown> never pulls in the
4
+ // language scanners — those are only reachable from the opt-in <Code> component.
5
+ export const DEFAULT_THEMES = {
6
+ 'github-dark': githubDark,
7
+ 'github-light': githubLight
8
+ };
9
+ /** Resolves the active theme: explicit key, else the first user theme, else the dark-mode default. */
10
+ export function resolveHighlightTheme(key, extra, isDark) {
11
+ const themes = { ...DEFAULT_THEMES, ...extra };
12
+ const fallback = DEFAULT_THEMES[isDark ? 'github-dark' : 'github-light'];
13
+ return themes[key ?? (extra ? Object.keys(extra)[0] : fallback.name)] ?? fallback;
14
+ }
@@ -1,30 +1,7 @@
1
- import type { ThemedToken, ThemeRegistration } from 'shiki';
2
- import type { HighlighterCore } from 'shiki/core';
3
- import { SvelteMap } from 'svelte/reactivity';
4
- import { type LanguageInfo } from './bundledLanguages.js';
5
- export type Highlighter = HighlighterCore;
6
- declare class HighlighterManager {
7
- loadedLanguages: SvelteMap<string, boolean | Promise<void>>;
8
- highlighter: any;
9
- customLanguages: Set<string>;
10
- languageLoaders: Map<string, () => Promise<any>>;
11
- additionalThemes: Record<string, ThemeRegistration>;
12
- constructor(languages: LanguageInfo[], additionalThemes?: Record<string, ThemeRegistration>, additionalLanguages?: LanguageInfo[]);
13
- private loadHighlighter;
14
- private isThemeAvailable;
15
- private loadLanguage;
16
- private isLanguageSupported;
17
- isReady(theme: string, language: string | undefined): boolean;
18
- /**
19
- * Ensures the highlighter is ready for the given theme and language.
20
- */
21
- load(theme: string, language: string | undefined): Promise<void>;
22
- /**
23
- * Highlights code synchronously. Must call isReady() first.
24
- * Returns plaintext tokens for unsupported languages.
25
- */
26
- highlightCode(code: string, language: string | undefined, theme: string): ThemedToken[][];
27
- static create(languages?: LanguageInfo[], additionalThemes?: Record<string, ThemeRegistration>, additionalLanguages?: LanguageInfo[]): HighlighterManager;
28
- }
29
- export { HighlighterManager };
1
+ import { type HighlightToken, type LanguageDefinition } from '@tanstack/highlight';
2
+ /**
3
+ * Tokenizes code into lines of tokens. Synchronous and isomorphic: works during SSR.
4
+ * Unknown languages fall back to plaintext.
5
+ */
6
+ export declare function highlightLines(code: string, lang: string | undefined, extra?: LanguageDefinition[]): HighlightToken[][];
30
7
  export declare const languageExtensionMap: Record<string, string>;
@@ -1,219 +1,34 @@
1
- import { SvelteMap } from 'svelte/reactivity';
2
- import { supportedLanguages, createLanguageSet, bundledLanguagesInfo } from './bundledLanguages.js';
3
- import { createHighlighterCore } from 'shiki/core';
4
- import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
5
- // Import default themes directly
6
- import githubDark from '@shikijs/themes/github-dark';
7
- import githubLight from '@shikijs/themes/github-light';
8
- // Default themes that are always loaded
9
- const DEFAULT_THEMES = {
10
- 'github-dark': githubDark,
11
- 'github-light': githubLight
12
- };
13
- class HighlighterManager {
14
- loadedLanguages = new SvelteMap();
15
- highlighter = $state(null);
16
- customLanguages;
17
- languageLoaders;
18
- additionalThemes;
19
- constructor(languages, additionalThemes, additionalLanguages) {
20
- // Store additional themes (will be loaded with highlighter)
21
- this.additionalThemes = additionalThemes || {};
22
- // Merge languages: default + additional
23
- const allLanguages = additionalLanguages ? [...languages, ...additionalLanguages] : languages;
24
- this.languageLoaders = new Map();
25
- allLanguages.forEach((l) => {
26
- this.languageLoaders.set(l.id, l.import);
27
- if (l.aliases) {
28
- l.aliases.forEach((alias) => this.languageLoaders.set(alias, l.import));
29
- }
30
- });
31
- // Build custom languages set for validation
32
- if (additionalLanguages) {
33
- const additionalSet = createLanguageSet(additionalLanguages);
34
- this.customLanguages = new Set([...supportedLanguages, ...additionalSet]);
35
- }
36
- else {
37
- this.customLanguages = supportedLanguages;
38
- }
39
- if (typeof window !== 'undefined') {
40
- Object.assign(window, {
41
- STREAMDOWN_HIGHLIGHTER: this
42
- });
43
- }
44
- }
45
- async loadHighlighter() {
46
- if (this.highlighter instanceof Promise) {
47
- return this.highlighter;
48
- }
49
- else if (!this.highlighter) {
50
- this.highlighter = new Promise(async (resolve, reject) => {
51
- try {
52
- const engine = createJavaScriptRegexEngine({ forgiving: true });
53
- // Load default themes + any additional themes immediately
54
- const allThemes = [
55
- ...Object.values(DEFAULT_THEMES),
56
- ...Object.values(this.additionalThemes)
57
- ];
58
- const highlighter = await createHighlighterCore({
59
- themes: allThemes,
60
- langs: [],
61
- engine
62
- });
63
- this.highlighter = highlighter;
64
- resolve(highlighter);
65
- }
66
- catch (error) {
67
- reject(error);
68
- }
69
- });
70
- return this.highlighter;
71
- }
72
- else {
73
- return this.highlighter;
74
- }
75
- }
76
- isThemeAvailable(theme) {
77
- return theme in DEFAULT_THEMES || theme in this.additionalThemes;
1
+ import { createHighlighter, defaultHighlighter, allLanguages } from '@tanstack/highlight';
2
+ const highlighters = new WeakMap();
3
+ const getHighlighter = (extra) => {
4
+ if (!extra?.length)
5
+ return defaultHighlighter;
6
+ let highlighter = highlighters.get(extra);
7
+ if (!highlighter) {
8
+ highlighter = createHighlighter({ languages: [...allLanguages, ...extra] });
9
+ highlighters.set(extra, highlighter);
78
10
  }
79
- async loadLanguage(language, highlighter) {
80
- // Skip loading if language is not supported
81
- if (!this.isLanguageSupported(language)) {
82
- return;
83
- }
84
- const languageLoader = this.loadedLanguages.get(language);
85
- if (languageLoader instanceof Promise) {
86
- await languageLoader;
87
- }
88
- else if (!languageLoader) {
89
- const loader = this.languageLoaders.get(language);
90
- if (!loader) {
91
- // Language not available, mark as failed
92
- this.loadedLanguages.set(language, false);
93
- return;
94
- }
95
- const languageLoaderPromise = loader()
96
- .then((langModule) => {
97
- const langObj = langModule.default || langModule;
98
- return highlighter.loadLanguage(langObj);
99
- })
100
- .then(() => {
101
- this.loadedLanguages.set(language, true);
102
- })
103
- .catch((err) => {
104
- this.loadedLanguages.set(language, false);
105
- throw err;
106
- });
107
- this.loadedLanguages.set(language, languageLoaderPromise);
108
- await languageLoaderPromise;
109
- }
110
- }
111
- isLanguageSupported = (language) => {
112
- return this.customLanguages.has(language);
113
- };
114
- isReady(theme, language) {
115
- // Check if theme is available
116
- if (!this.isThemeAvailable(theme)) {
117
- return false;
118
- }
119
- // For unsupported languages, we don't need to load anything (will use plaintext)
120
- if (!language || !this.isLanguageSupported(language)) {
121
- return !!this.highlighter && !(this.highlighter instanceof Promise);
122
- }
123
- return (!!this.highlighter &&
124
- !(this.highlighter instanceof Promise) &&
125
- this.loadedLanguages.get(language) === true);
126
- }
127
- /**
128
- * Ensures the highlighter is ready for the given theme and language.
129
- */
130
- async load(theme, language) {
131
- // For unsupported languages, only need to load highlighter (themes are pre-loaded)
132
- if (!language || !this.isLanguageSupported(language)) {
133
- if (this.highlighter !== null && !(this.highlighter instanceof Promise)) {
134
- return;
135
- }
136
- await this.loadHighlighter();
137
- return;
138
- }
139
- const languageLoader = this.loadedLanguages.get(language);
140
- if (this.highlighter !== null &&
141
- !(this.highlighter instanceof Promise) &&
142
- languageLoader === true) {
143
- return;
144
- }
145
- const highlighter = await this.loadHighlighter();
146
- await this.loadLanguage(language, highlighter);
147
- }
148
- /**
149
- * Highlights code synchronously. Must call isReady() first.
150
- * Returns plaintext tokens for unsupported languages.
151
- */
152
- highlightCode(code, language, theme) {
153
- try {
154
- const highlighter = this.highlighter;
155
- if (!highlighter || highlighter instanceof Promise) {
156
- // Return plaintext tokens when highlighter is not ready
157
- return code.split('\n').map((line) => [
158
- {
159
- content: line,
160
- color: undefined,
161
- bgColor: undefined
162
- }
163
- ]);
164
- }
165
- // For unsupported languages, return plaintext tokens
166
- if (!language || !this.isLanguageSupported(language)) {
167
- return code.split('\n').map((line) => [
168
- {
169
- content: line,
170
- color: undefined,
171
- bgColor: undefined
172
- }
173
- ]);
174
- }
175
- const tokens = highlighter.codeToTokensBase(code, {
176
- lang: language,
177
- theme
178
- });
179
- return tokens;
180
- }
181
- catch (error) {
182
- // Return plaintext tokens on error
183
- return code.split('\n').map((line) => [
184
- {
185
- content: line,
186
- color: undefined,
187
- bgColor: undefined
188
- }
189
- ]);
190
- }
191
- }
192
- static create(languages = bundledLanguagesInfo, additionalThemes, additionalLanguages) {
193
- if (typeof window !== 'undefined' && 'STREAMDOWN_HIGHLIGHTER' in window) {
194
- const previousHighlighter = window.STREAMDOWN_HIGHLIGHTER;
195
- // Merge additional themes
196
- if (additionalThemes) {
197
- Object.assign(previousHighlighter.additionalThemes, additionalThemes);
198
- }
199
- // Merge additional languages with existing set
200
- if (additionalLanguages) {
201
- additionalLanguages.forEach((lang) => {
202
- previousHighlighter.languageLoaders.set(lang.id, lang.import);
203
- if (lang.aliases) {
204
- lang.aliases.forEach((alias) => previousHighlighter.languageLoaders.set(alias, lang.import));
205
- }
206
- });
207
- const additionalSet = createLanguageSet(additionalLanguages);
208
- additionalSet.forEach((lang) => previousHighlighter.customLanguages.add(lang));
209
- }
210
- return previousHighlighter;
11
+ return highlighter;
12
+ };
13
+ /**
14
+ * Tokenizes code into lines of tokens. Synchronous and isomorphic: works during SSR.
15
+ * Unknown languages fall back to plaintext.
16
+ */
17
+ export function highlightLines(code, lang, extra) {
18
+ const tokens = getHighlighter(extra).tokenize(code, { lang }).tokens;
19
+ const lines = [[]];
20
+ for (const token of tokens) {
21
+ const parts = token.value.split('\n');
22
+ for (let i = 0; i < parts.length; i++) {
23
+ if (i > 0)
24
+ lines.push([]);
25
+ // ponytail: empty segments carry no text, so skip them instead of emitting empty spans
26
+ if (parts[i])
27
+ lines[lines.length - 1].push({ ...token, value: parts[i] });
211
28
  }
212
- return new HighlighterManager(languages, additionalThemes, additionalLanguages);
213
29
  }
30
+ return lines;
214
31
  }
215
- // Export the class for those who want to create their own instances
216
- export { HighlighterManager };
217
32
  export const languageExtensionMap = {
218
33
  '1c': '1c',
219
34
  '1c-query': '1cq',
@@ -98,6 +98,20 @@ export class IncompleteMarkdownParser {
98
98
  // Create default plugins that replicate the original handler functions
99
99
  static createDefaultPlugins() {
100
100
  return [
101
+ {
102
+ // Runs first: in a list item a '>' before a number is a comparison
103
+ // ('- > 25: rich'), but marked reads it as a nested blockquote. Escaping it
104
+ // keeps the text, and the escape renders as a plain '>' (4fffb9f).
105
+ // `pattern` is only a cheap gate — a line whose first non-space character is
106
+ // not a list marker can never match, and bails on that one character.
107
+ name: 'comparisonOperator',
108
+ pattern: /^\s*[-*+\d]/,
109
+ skipInBlockTypes: ['code', 'math'],
110
+ handler: ({ line }) => {
111
+ const match = listItemComparison.exec(line);
112
+ return match ? `${match[1]}\\>${line.slice(match[0].length)}` : line;
113
+ }
114
+ },
101
115
  // Block-level plugin that manages blocking contexts
102
116
  {
103
117
  name: 'contextManager',
@@ -172,7 +186,9 @@ export class IncompleteMarkdownParser {
172
186
  result += '\n```';
173
187
  }
174
188
  if (state.blockingContexts.has('math')) {
175
- result += '\n$$';
189
+ // The first half of the closing '$$' may already have arrived: adding a
190
+ // whole '\n$$' would leave a stray '$' inside the math and '$$' after it.
191
+ result += result.endsWith('$') && !result.endsWith('$$') ? '$' : '\n$$';
176
192
  }
177
193
  if (state.blockingContexts.has('center')) {
178
194
  result += '\n[/center]';
@@ -195,6 +211,8 @@ export class IncompleteMarkdownParser {
195
211
  const tripleAsterisks = (line.match(/\*\*\*/g) || []).length;
196
212
  if (tripleAsterisks % 2 === 1) {
197
213
  const lastTripleAsteriskIndex = line.lastIndexOf('***');
214
+ if (isWithinCompleteInlineCode(line, lastTripleAsteriskIndex))
215
+ return line;
198
216
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTripleAsteriskIndex);
199
217
  if (isEndingWithTripleAsterisk) {
200
218
  return line.substring(0, lastTripleAsteriskIndex);
@@ -222,6 +240,8 @@ export class IncompleteMarkdownParser {
222
240
  if (doubleAsteriskMatches % 2 === 1) {
223
241
  const isEndingWithDoubleAsterisk = line.endsWith('**');
224
242
  const lastDoubleAsteriskIndex = line.lastIndexOf('**');
243
+ if (isWithinCompleteInlineCode(line, lastDoubleAsteriskIndex))
244
+ return line;
225
245
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleAsteriskIndex);
226
246
  if (isEndingWithDoubleAsterisk) {
227
247
  return line.substring(0, lastDoubleAsteriskIndex);
@@ -248,6 +268,8 @@ export class IncompleteMarkdownParser {
248
268
  if (underscorePairs % 2 === 1) {
249
269
  const isEndingWithDoubleUnderscore = line.endsWith('__');
250
270
  const lastDoubleUnderscoreIndex = line.lastIndexOf('__');
271
+ if (isWithinCompleteInlineCode(line, lastDoubleUnderscoreIndex))
272
+ return line;
251
273
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleUnderscoreIndex);
252
274
  if (isEndingWithDoubleUnderscore) {
253
275
  return line.substring(0, lastDoubleUnderscoreIndex);
@@ -270,6 +292,8 @@ export class IncompleteMarkdownParser {
270
292
  if (tildePairs % 2 === 1) {
271
293
  const isEndingWithDoubleTilde = line.endsWith('~~');
272
294
  const lastDoubleTildeIndex = line.lastIndexOf('~~');
295
+ if (isWithinCompleteInlineCode(line, lastDoubleTildeIndex))
296
+ return line;
273
297
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleTildeIndex);
274
298
  // Only complete if there's content after the tildes
275
299
  const contentAfterTildes = line.substring(lastDoubleTildeIndex + 2, endOfCellOrLine);
@@ -296,37 +320,43 @@ export class IncompleteMarkdownParser {
296
320
  }
297
321
  // Inline countSingleAsterisks logic
298
322
  let singleAsterisks = 0;
323
+ let lastSingleAsterisk = -1;
299
324
  for (let i = 0; i < line.length; i++) {
300
325
  if (line[i] === '*') {
301
326
  const prevChar = i > 0 ? line[i - 1] : '';
302
327
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
303
- let lineStartIndex = i;
304
- for (let j = i - 1; j >= 0; j--) {
305
- if (line[j] === '\n') {
306
- lineStartIndex = j + 1;
307
- break;
308
- }
309
- if (j === 0) {
310
- lineStartIndex = 0;
311
- break;
312
- }
328
+ // Whitespace on both sides means arithmetic ('5 * 0') or a bare list
329
+ // marker, never an emphasis delimiter (c347b53).
330
+ if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar)) {
331
+ continue;
313
332
  }
314
- const beforeAsterisk = line.substring(lineStartIndex, i);
315
- if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
333
+ if (isWithinCompleteInlineCode(line, i)) {
316
334
  continue;
317
335
  }
318
336
  if (prevChar !== '*' && nextChar !== '*') {
319
337
  singleAsterisks++;
338
+ lastSingleAsterisk = i;
320
339
  }
321
340
  }
322
341
  }
323
342
  if (singleAsterisks % 2 === 1) {
343
+ // The dangling asterisk is the last counted one. If it cannot OPEN
344
+ // emphasis (nothing or whitespace after it) it is an intraword or
345
+ // trailing closer — '*foo*bar*' is literal, not half of '*foo*bar**'
346
+ // (9f96409).
347
+ if (isSpaceOrEdge(line[lastSingleAsterisk + 1] ?? '')) {
348
+ return line;
349
+ }
324
350
  // Inline findFirstSingleAsterisk logic
325
351
  let firstSingleAsteriskIndex = -1;
326
352
  for (let i = 0; i < line.length; i++) {
327
353
  if (line[i] === '*' && line[i - 1] !== '*' && line[i + 1] !== '*') {
328
354
  const prevChar = i > 0 ? line[i - 1] : '';
329
355
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
356
+ if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar))
357
+ continue;
358
+ if (isWithinCompleteInlineCode(line, i))
359
+ continue;
330
360
  if (/\w/.test(prevChar) && /\w/.test(nextChar))
331
361
  continue;
332
362
  if (/\w/.test(prevChar) && !/\s/.test(prevChar))
@@ -391,6 +421,8 @@ export class IncompleteMarkdownParser {
391
421
  continue;
392
422
  if (isWithinMathBlock(line, i))
393
423
  continue;
424
+ if (isWithinCompleteInlineCode(line, i))
425
+ continue;
394
426
  if (prevChar &&
395
427
  nextChar &&
396
428
  /[\p{L}\p{N}_]/u.test(prevChar) &&
@@ -410,7 +442,8 @@ export class IncompleteMarkdownParser {
410
442
  line[i - 1] !== '_' &&
411
443
  line[i + 1] !== '_' &&
412
444
  line[i - 1] !== '\\' &&
413
- !isWithinMathBlock(line, i)) {
445
+ !isWithinMathBlock(line, i) &&
446
+ !isWithinCompleteInlineCode(line, i)) {
414
447
  const prevChar = i > 0 ? line[i - 1] : '';
415
448
  const nextChar = i < line.length - 1 ? line[i + 1] : '';
416
449
  if (prevChar &&
@@ -450,11 +483,17 @@ export class IncompleteMarkdownParser {
450
483
  }
451
484
  if (singleTildes % 2 === 1) {
452
485
  const lastTildeIndex = line.lastIndexOf('~');
453
- if (lastTildeIndex !== -1 && !isWithinMathBlock(line, lastTildeIndex)) {
486
+ if (lastTildeIndex !== -1 &&
487
+ !isWithinMathBlock(line, lastTildeIndex) &&
488
+ !isWithinCompleteInlineCode(line, lastTildeIndex)) {
454
489
  const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTildeIndex);
455
- // Only complete if there's content after the tilde
456
490
  const contentAfterTilde = line.substring(lastTildeIndex + 1, endOfCellOrLine);
457
- if (contentAfterTilde.trim().length > 0) {
491
+ // A subscript is '~text~' with no whitespace inside (same rule as the
492
+ // lexer), and a digit before the tilde means a range like '20~25°C':
493
+ // closing those would manufacture a subscript nobody typed (716a5f0).
494
+ if (contentAfterTilde.length > 0 &&
495
+ !/\s/.test(contentAfterTilde) &&
496
+ !/\d/.test(line[lastTildeIndex - 1] ?? '')) {
458
497
  return line.substring(0, endOfCellOrLine) + '~' + line.substring(endOfCellOrLine);
459
498
  }
460
499
  }
@@ -580,6 +619,9 @@ export class IncompleteMarkdownParser {
580
619
  continue;
581
620
  if (nextChar && /\d/.test(nextChar))
582
621
  continue;
622
+ // A '$' shown as code ('`$var`') must not flip the parity (e50b0c4)
623
+ if (isWithinCompleteInlineCode(line, i))
624
+ continue;
583
625
  singleDollars++;
584
626
  }
585
627
  }
@@ -593,7 +635,8 @@ export class IncompleteMarkdownParser {
593
635
  prevChar !== '$' &&
594
636
  nextChar !== '$' &&
595
637
  nextChar !== '' &&
596
- !/\d/.test(nextChar)) {
638
+ !/\d/.test(nextChar) &&
639
+ !isWithinCompleteInlineCode(line, i)) {
597
640
  lastDollarIndex = i;
598
641
  break;
599
642
  }
@@ -866,6 +909,11 @@ export const parseIncompleteMarkdown = (text) => {
866
909
  return defaultParser.parse(text);
867
910
  };
868
911
  // Utility functions
912
+ // Full test for the comparisonOperator plugin, whose `pattern` only gates it.
913
+ const listItemComparison = /^(\s*(?:[-*+]|\d+[.)]) +)>(?==?\s*\$?\d)/;
914
+ // The char accessors in the plugins return '' past either end of the line, so an
915
+ // empty string here means "edge of line".
916
+ const isSpaceOrEdge = (char) => !char || /\s/.test(char);
869
917
  const findEndOfCellOrLineContaining = (text, position) => {
870
918
  let endPos = position;
871
919
  while (endPos < text.length && text[endPos] !== '\n' && text[endPos] !== '|') {
@@ -873,6 +921,40 @@ const findEndOfCellOrLineContaining = (text, position) => {
873
921
  }
874
922
  return endPos;
875
923
  };
924
+ // Scanned on demand, against the line as it stands now, and allocating nothing:
925
+ // the completer runs over every changed block of every streamed chunk, so garbage
926
+ // here surfaces as GC pauses in the stages around it.
927
+ const isWithinCompleteInlineCode = (line, position) => {
928
+ let open = line.indexOf('`');
929
+ while (open !== -1 && open <= position) {
930
+ let openEnd = open;
931
+ while (line.charCodeAt(openEnd) === 96)
932
+ openEnd++;
933
+ const runLength = openEnd - open;
934
+ let closeStart = -1;
935
+ for (let j = openEnd; j < line.length; j++) {
936
+ if (line.charCodeAt(j) !== 96)
937
+ continue;
938
+ let closeEnd = j;
939
+ while (line.charCodeAt(closeEnd) === 96)
940
+ closeEnd++;
941
+ if (closeEnd - j === runLength) {
942
+ closeStart = j;
943
+ break;
944
+ }
945
+ j = closeEnd - 1;
946
+ }
947
+ // An unterminated run closes nothing, so neither it nor anything after it
948
+ // is code: completing emphasis inside it is what streaming needs.
949
+ if (closeStart === -1)
950
+ return false;
951
+ const spanEnd = closeStart + runLength;
952
+ if (position < spanEnd)
953
+ return true;
954
+ open = line.indexOf('`', spanEnd);
955
+ }
956
+ return false;
957
+ };
876
958
  const isWithinMathBlock = (text, position) => {
877
959
  let inInlineMath = false;
878
960
  let inBlockMath = false;
@@ -1,5 +1,8 @@
1
1
  export const save = (filename, content, mimeType) => {
2
- const blob = new Blob([content], { type: mimeType });
2
+ // Excel on Windows assumes the system codepage without a BOM, so accented
3
+ // and CJK text in a downloaded CSV opens as mojibake.
4
+ const body = mimeType.startsWith('text/csv') ? '\uFEFF' + content : content;
5
+ const blob = new Blob([body], { type: mimeType });
3
6
  const url = URL.createObjectURL(blob);
4
7
  const link = document.createElement('a');
5
8
  link.href = url;
package/dist/utils/url.js CHANGED
@@ -25,6 +25,7 @@ export const isPathRelativeUrl = (url) => {
25
25
  return false;
26
26
  return url.startsWith('/');
27
27
  };
28
+ const WILDCARD_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
28
29
  export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
29
30
  if (!url)
30
31
  return null;
@@ -61,8 +62,11 @@ export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
61
62
  }
62
63
  // Check for wildcard - allow all URLs
63
64
  if (allowedPrefixes.includes('*')) {
64
- // Wildcard only allows http and https URLs
65
- if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
65
+ // The wildcard allows the protocols a document can legitimately link to.
66
+ // javascript:, data: and vbscript: stay blocked - they execute in the
67
+ // page's origin. mailto:/tel: were missing, so the default ['*'] config
68
+ // rendered a [blocked] badge on every phone and email link.
69
+ if (!WILDCARD_PROTOCOLS.has(parsedUrl.protocol)) {
66
70
  return null;
67
71
  }
68
72
  const inputWasRelative = isPathRelativeUrl(url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "3.1.2",
3
+ "version": "4.0.1",
4
4
  "packageManager": "pnpm@10.32.1",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,7 +22,8 @@
22
22
  "lint": "prettier --check .",
23
23
  "test:ui": "vitest --ui",
24
24
  "test:unit": "vitest",
25
- "test": "npm run test:unit -- --run"
25
+ "test:browser": "vitest --run --project client",
26
+ "test": "npm run test:unit -- --run --project server"
26
27
  },
27
28
  "files": [
28
29
  "dist",
@@ -94,13 +95,11 @@
94
95
  },
95
96
  "dependencies": {
96
97
  "@floating-ui/dom": "^1.7.4",
97
- "@shikijs/langs": "^3.17.1",
98
- "@shikijs/themes": "^3.17.1",
98
+ "@tanstack/highlight": "^0.1.0",
99
99
  "clsx": "^2.1.1",
100
100
  "katex": "^0.16.22",
101
- "marked": "^16.2.1",
101
+ "marked": "18.0.12",
102
102
  "mermaid": "^11.15.0",
103
- "shiki": "^3.13.0",
104
103
  "tailwind-merge": "^3.3.1"
105
104
  }
106
105
  }
@@ -1,8 +0,0 @@
1
- export type LanguageInfo = {
2
- id: string;
3
- aliases?: string[];
4
- import: () => Promise<any>;
5
- };
6
- export declare const bundledLanguagesInfo: LanguageInfo[];
7
- export declare function createLanguageSet(languages: LanguageInfo[]): Set<string>;
8
- export declare const supportedLanguages: Set<string>;