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
@@ -11,6 +11,12 @@
11
11
  } = $props();
12
12
 
13
13
  const streamdown = useStreamdown();
14
+
15
+ // Same trim as Code.svelte/Mermaid.svelte: marked keeps the fence's trailing
16
+ // blank lines in `text`, so they render as empty lines and flicker in and out
17
+ // on nearly every streamed chunk. This is the default renderer, so it needs it
18
+ // too.
19
+ const code = $derived(token.text.replace(/\n+$/, ''));
14
20
  </script>
15
21
 
16
22
  <div
@@ -23,7 +29,7 @@
23
29
  </div>
24
30
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
25
31
  <pre class={streamdown.theme.code.pre}><code
26
- >{#each token.text.split('\n') as line}<span class={streamdown.theme.code.line}
32
+ >{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
27
33
  ><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
28
34
  >{line.trim().length > 0 ? line : '\u200B'}</span
29
35
  ></span
@@ -11,6 +11,12 @@
11
11
  } = $props();
12
12
 
13
13
  const streamdown = useStreamdown();
14
+
15
+ // Same trim as Code.svelte/Mermaid.svelte: marked keeps the fence's trailing
16
+ // blank lines in `text`, so they render as empty lines and flicker in and out
17
+ // on nearly every streamed chunk. This is the default renderer, so it needs it
18
+ // too.
19
+ const chart = $derived(token.text.replace(/\n+$/, ''));
14
20
  </script>
15
21
 
16
22
  <div data-streamdown-mermaid={id}>
@@ -23,7 +29,7 @@
23
29
  </div>
24
30
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
25
31
  <pre class={streamdown.theme.code.pre}><code
26
- >{#each token.text.split('\n') as line}<span class={streamdown.theme.code.line}
32
+ >{#each chart.split('\n') as line}<span class={streamdown.theme.code.line}
27
33
  ><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
28
34
  >{line.trim().length > 0 ? line : '\u200B'}</span
29
35
  ></span
@@ -7,9 +7,9 @@
7
7
  let {
8
8
  content = '',
9
9
  class: className,
10
- shikiTheme,
11
- shikiLanguages,
12
- shikiThemes,
10
+ highlightTheme,
11
+ highlightLanguages,
12
+ highlightThemes,
13
13
  parseIncompleteMarkdown,
14
14
  defaultOrigin,
15
15
  allowedLinkPrefixes = ['*'],
@@ -36,15 +36,12 @@
36
36
  ...snippets
37
37
  }: StreamdownProps<Source> = $props();
38
38
  import { useDarkMode } from './utils/darkMode.svelte.js';
39
+ import { resolveHighlightTheme } from './utils/highlightThemes.js';
39
40
 
40
41
  const darkMode = useDarkMode();
41
42
 
42
- const shikiThemedTheme = $derived(
43
- shikiThemes
44
- ? Object.keys(shikiThemes)[0] || 'github-light'
45
- : darkMode.current
46
- ? 'github-dark'
47
- : 'github-light'
43
+ const resolvedHighlightTheme = $derived(
44
+ resolveHighlightTheme(highlightTheme, highlightThemes, darkMode.current)
48
45
  );
49
46
 
50
47
  const mermaidThemedTheme = $derived(
@@ -70,8 +67,8 @@
70
67
  get allowedImagePrefixes() {
71
68
  return allowedImagePrefixes;
72
69
  },
73
- get shikiTheme() {
74
- return shikiTheme || shikiThemedTheme;
70
+ get highlightTheme() {
71
+ return resolvedHighlightTheme;
75
72
  },
76
73
  get snippets() {
77
74
  return snippets;
@@ -99,11 +96,11 @@
99
96
  get translations() {
100
97
  return translations;
101
98
  },
102
- get shikiLanguages() {
103
- return shikiLanguages;
99
+ get highlightLanguages() {
100
+ return highlightLanguages;
104
101
  },
105
- get shikiThemes() {
106
- return shikiThemes;
102
+ get highlightThemes() {
103
+ return highlightThemes;
107
104
  },
108
105
  get sources() {
109
106
  return sources;
@@ -2,11 +2,11 @@ import type { Component, Snippet } from 'svelte';
2
2
  import type { DeepPartialTheme, Theme } from './theme.js';
3
3
  import type { MermaidConfig } from 'mermaid';
4
4
  import type { KatexOptions } from 'katex';
5
- import type { LanguageInfo } from './utils/bundledLanguages.js';
6
- import type { ThemeRegistration } from 'shiki';
7
- export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'shikiTheme' | 'inlineCitationsMode'> {
5
+ import type { HighlightTheme } from '@tanstack/highlight/theme';
6
+ import type { LanguageDefinition } from '@tanstack/highlight';
7
+ export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'highlightTheme' | 'inlineCitationsMode'> {
8
8
  snippets: Snippets;
9
- shikiTheme: string;
9
+ highlightTheme: HighlightTheme;
10
10
  theme: Theme;
11
11
  controls: {
12
12
  code: boolean;
@@ -27,8 +27,9 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
27
27
  isMounted: boolean;
28
28
  get animationTextStyle(): string | undefined;
29
29
  get animationBlockStyle(): string | undefined;
30
- constructor(props: Omit<StreamdownProps, keyof Snippets | 'class'> & {
30
+ constructor(props: Omit<StreamdownProps, keyof Snippets | 'class' | 'highlightTheme'> & {
31
31
  snippets: Snippets<Source>;
32
+ highlightTheme: HighlightTheme;
32
33
  });
33
34
  }
34
35
  export declare const useStreamdown: () => StreamdownContext<Record<string, any>>;
@@ -108,9 +109,9 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
108
109
  theme?: DeepPartialTheme;
109
110
  baseTheme?: 'tailwind' | 'shadcn';
110
111
  mergeTheme?: boolean;
111
- shikiTheme?: string;
112
- shikiLanguages?: LanguageInfo[];
113
- shikiThemes?: Record<string, ThemeRegistration>;
112
+ highlightTheme?: string;
113
+ highlightLanguages?: LanguageDefinition[];
114
+ highlightThemes?: Record<string, HighlightTheme>;
114
115
  mermaidConfig?: MermaidConfig;
115
116
  katexConfig?: KatexOptions | ((inline: boolean) => KatexOptions);
116
117
  translations?: {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { default as Streamdown } from './Streamdown.svelte';
2
2
  export { useStreamdown, type StreamdownProps } from './context.svelte.js';
3
3
  export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
4
- export { type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
4
+ export { type CodeToken, type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
5
5
  export { parseIncompleteMarkdown, type Plugin, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
6
- export { bundledLanguagesInfo, createLanguageSet, type LanguageInfo } from './utils/bundledLanguages.js';
6
+ export { defineLanguage, type LanguageDefinition } from '@tanstack/highlight';
7
+ export type { HighlightTheme } from '@tanstack/highlight/theme';
package/dist/index.js CHANGED
@@ -3,4 +3,4 @@ export { useStreamdown } from './context.svelte.js';
3
3
  export { theme, shadcnTheme, mergeTheme } from './theme.js';
4
4
  export { lex, parseBlocks } from './marked/index.js';
5
5
  export { parseIncompleteMarkdown, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
6
- export { bundledLanguagesInfo, createLanguageSet } from './utils/bundledLanguages.js';
6
+ export { defineLanguage } from '@tanstack/highlight';
@@ -23,7 +23,14 @@ export type Extension = {
23
23
  start?: TokenizerStartFunction;
24
24
  applyInBlockParsing?: boolean;
25
25
  };
26
- export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table> | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
26
+ /**
27
+ * A fenced code block. `lang` is the first word of the info string; everything
28
+ * after it is `meta` (`undefined` when there is none).
29
+ */
30
+ export type CodeToken = Tokens.Code & {
31
+ meta?: string;
32
+ };
33
+ export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table | Tokens.Code> | CodeToken | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
27
34
  export type { TableToken, THead, TBody, TFoot, THeadRow, TRow, TH, TD } from './marked-table.js';
28
35
  export declare const lex: (markdown: string, extensions?: Extension[]) => StreamdownToken[];
29
36
  /**
@@ -31,13 +38,25 @@ export declare const lex: (markdown: string, extensions?: Extension[]) => Stream
31
38
  * instance (or per simulated stream) and pass it on every call: append-only
32
39
  * content updates then re-lex only the last couple of blocks instead of the
33
40
  * whole document. Any non-append update falls back to a full parse.
41
+ *
42
+ * Everything here is sized so the append path costs O(live tail), never
43
+ * O(document): `offsets`/`keptBefore` are prefix sums so the seal point is an
44
+ * array lookup, and `blocks` is a persistent array whose tail is truncated and
45
+ * re-pushed instead of being rebuilt with a `filter` callback every chunk.
34
46
  */
35
47
  export type ParseBlocksCache = {
48
+ /** the content the cache describes (held by reference, never copied); '' = cold */
36
49
  content: string;
37
50
  /** every block token's raw (including space/footnote tokens) in document order */
38
51
  raws: string[];
39
52
  /** parallel to raws: whether the token is part of the rendered block list */
40
53
  keep: boolean[];
54
+ /** prefix sums: offsets[i] = start index of raws[i]; offsets[raws.length] = content.length */
55
+ offsets: number[];
56
+ /** prefix sums: keptBefore[i] = number of kept tokens among raws[0..i) */
57
+ keptBefore: number[];
58
+ /** persistent list of kept raws; only its tail is rewritten on append */
59
+ blocks: string[];
41
60
  };
42
61
  export declare const createParseBlocksCache: () => ParseBlocksCache;
43
62
  export declare const parseBlocks: (markdown: string, extensions?: Extension[], cache?: ParseBlocksCache) => string[];
@@ -1,4 +1,4 @@
1
- import { Lexer } from 'marked';
1
+ import { Lexer, Tokenizer } from 'marked';
2
2
  import { markedAlert } from './marked-alert.js';
3
3
  import { markedFootnote } from './marked-footnotes.js';
4
4
  import { markedMath } from './marked-math.js';
@@ -39,9 +39,42 @@ const DEFAULT_BLOCK_EXTENSIONS = [
39
39
  markedAlign,
40
40
  markedMdx
41
41
  ];
42
+ class StreamdownTokenizer extends Tokenizer {
43
+ /**
44
+ * marked keeps the whole fence info string in `lang`, so ```ts title="x" {1}
45
+ * highlighted as plaintext, labelled the header with the entire string, downloaded
46
+ * as `file.txt` and — worst — missed `token.lang === 'mermaid'` (upstream d4ec6c0).
47
+ * Splitting here rather than walking lex()'s output costs one search per code
48
+ * token instead of a per-block tree walk, and it also reaches fences nested in
49
+ * lists and blockquotes, which a pass over the top-level tokens would not.
50
+ */
51
+ fences(src) {
52
+ const token = super.fences(src);
53
+ if (token?.lang) {
54
+ const end = token.lang.search(/\s/);
55
+ if (end !== -1) {
56
+ token.meta = token.lang.slice(end + 1).trim() || undefined;
57
+ token.lang = token.lang.slice(0, end);
58
+ }
59
+ }
60
+ return token;
61
+ }
62
+ /**
63
+ * `~x~` is a subscript here, not GFM strikethrough (marked-subsup.ts). The old
64
+ * subscript rule shadowed single-tilde del by matching anything; now that it
65
+ * rejects whitespace, del would inherit exactly the sentences 716a5f0 is about
66
+ * (`20~25°C and 30~35°C` → del('~25°C and 30~')). Only `~~` opens a del.
67
+ */
68
+ del(src, maskedSrc, prevChar) {
69
+ if (src.charCodeAt(0) !== 126 /* ~ */ || src.charCodeAt(1) !== 126)
70
+ return;
71
+ return super.del(src, maskedSrc, prevChar);
72
+ }
73
+ }
42
74
  const parseExtensions = (...extensions) => {
43
75
  const options = {
44
76
  gfm: true,
77
+ tokenizer: new StreamdownTokenizer(),
45
78
  extensions: {
46
79
  block: [],
47
80
  inline: [],
@@ -71,9 +104,12 @@ const parseExtensions = (...extensions) => {
71
104
  });
72
105
  return options;
73
106
  };
74
- // Options objects are stateless and reusable across Lexer instances; cache them
75
- // per user-extension array (props are referentially stable across chunks) so the
76
- // hot path skips rebuilding ~20 tokenizer registrations on every streamed chunk.
107
+ // Options objects are reusable across Lexer instances; cache them per
108
+ // user-extension array (props are referentially stable across chunks) so the hot
109
+ // path skips rebuilding ~20 tokenizer registrations on every streamed chunk. The
110
+ // cached object carries one shared Tokenizer whose `lexer` back-pointer marked
111
+ // re-stamps per Lexer construction — safe only because lexing is synchronous and
112
+ // never re-entrant across documents.
77
113
  const DEFAULT_LEX_OPTIONS = parseExtensions(...DEFAULT_LEX_EXTENSIONS);
78
114
  const DEFAULT_BLOCK_OPTIONS = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS);
79
115
  const lexOptionsCache = new WeakMap();
@@ -106,21 +142,79 @@ export const lex = (markdown, extensions = []) => {
106
142
  export const createParseBlocksCache = () => ({
107
143
  content: '',
108
144
  raws: [],
109
- keep: []
145
+ keep: [],
146
+ offsets: [0],
147
+ keptBefore: [0],
148
+ blocks: []
110
149
  });
111
150
  // Number of trailing rendered blocks that stay "live" (re-lexed every chunk).
112
151
  // 2 covers constructs that merge backward as they stream in — e.g. a paragraph
113
152
  // line becoming a table once its delimiter row arrives, or a setext heading.
114
153
  const SEAL_SLACK = 2;
115
- const blockTokensOf = (markdown, extensions) => new Lexer(getBlockOptions(extensions)).blockTokens(markdown, []);
154
+ // Spot checks over the sealed prefix per append (see `appendable`). The sealed
155
+ // region was compared byte-for-byte while it was live, so this only has to catch
156
+ // a caller that swapped in a different, longer document.
157
+ const SEAL_PROBES = 16;
158
+ /**
159
+ * A Lexer used only to slice a document into top-level block raws.
160
+ *
161
+ * `Tokenizer.list()` recurses into every list item's text purely to decide
162
+ * whether the list is loose — `raw` is already final by then — and it marks that
163
+ * recursion by setting `state.top = false` first. `Tokenizer.blockquote()` also
164
+ * recurses, but its `raw` genuinely depends on the result (continuation lines are
165
+ * folded into a nested list/blockquote token), and it sets `state.top = true`.
166
+ * So dropping every nested call made with `top === false` removes the whole
167
+ * list-item subtree parse without changing a single raw.
168
+ */
169
+ class SplitLexer extends Lexer {
170
+ blockTokens(src, tokens = [], lastParagraphClipped = false) {
171
+ if (!this.state.top) {
172
+ // Lexer.blockTokens() sets state.top = true on exit; the skip must too.
173
+ this.state.top = true;
174
+ return tokens;
175
+ }
176
+ return super.blockTokens(src, tokens, lastParagraphClipped);
177
+ }
178
+ /** Extensions (dl, table) tokenize cell/term text inline; raws never depend on it. */
179
+ inlineTokens(src, tokens = []) {
180
+ return tokens;
181
+ }
182
+ }
183
+ const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(extensions)).blockTokens(markdown, []);
184
+ /**
185
+ * Is `markdown` an append to `cache.content`?
186
+ *
187
+ * The live region [offset, cache.content.length) is compared exactly — that is
188
+ * the only region whose segmentation can still change. The sealed prefix gets
189
+ * SEAL_PROBES sampled character comparisons instead of a full scan: a complete
190
+ * `markdown.startsWith(cache.content)` was 93% of parseBlocks' streaming cost
191
+ * (676 ms of 727 ms over a 100 KB / 5028-chunk stream) and is the single reason
192
+ * the append path was O(N) per chunk rather than O(tail).
193
+ *
194
+ * Note this compares source against source, not source against `raws`: marked's
195
+ * tokenizers normalize (e.g. the list tokenizer rewrites a trailing space as a
196
+ * newline), so raws are not always literal slices of the input.
197
+ */
198
+ const appendable = (markdown, content, offset) => {
199
+ for (let i = offset; i < content.length; i++) {
200
+ if (markdown.charCodeAt(i) !== content.charCodeAt(i))
201
+ return false;
202
+ }
203
+ if (offset === 0)
204
+ return true;
205
+ const step = offset > SEAL_PROBES ? offset / SEAL_PROBES : 1;
206
+ for (let f = 0; f < offset; f += step) {
207
+ const i = f | 0;
208
+ if (markdown.charCodeAt(i) !== content.charCodeAt(i))
209
+ return false;
210
+ }
211
+ return markdown.charCodeAt(offset - 1) === content.charCodeAt(offset - 1);
212
+ };
116
213
  export const parseBlocks = (markdown, extensions = [], cache) => {
117
- if (cache &&
118
- cache.content.length > 0 &&
119
- markdown.length > cache.content.length &&
120
- markdown.startsWith(cache.content)) {
214
+ if (cache && cache.content.length > 0 && markdown.length > cache.content.length) {
121
215
  // Append-only update: seal everything except the last SEAL_SLACK rendered
122
- // blocks and re-lex only the tail. cache.raws concatenates exactly to
123
- // cache.content (verified by length below), so summed lengths are offsets.
216
+ // blocks and re-lex only the tail. offsets[] are prefix sums over raws, so
217
+ // the seal point costs one array read instead of a running sum.
124
218
  let cut = cache.raws.length;
125
219
  let liveBlocks = 0;
126
220
  while (cut > 0 && liveBlocks < SEAL_SLACK) {
@@ -128,45 +222,72 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
128
222
  if (cache.keep[cut])
129
223
  liveBlocks++;
130
224
  }
131
- let offset = 0;
132
- for (let i = 0; i < cut; i++)
133
- offset += cache.raws[i].length;
134
- const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
135
- let tailLength = 0;
136
- for (const token of tailTokens)
137
- tailLength += token.raw.length;
138
- // Contiguity guard: if the lexer normalized the tail (so raws no longer
139
- // reconstruct the input), the offsets cannot be trusted — full reparse.
140
- if (offset + tailLength === markdown.length) {
141
- cache.raws.length = cut;
142
- cache.keep.length = cut;
143
- for (const token of tailTokens) {
144
- cache.raws.push(token.raw);
145
- cache.keep.push(token.type !== 'space' && token.type !== 'footnote');
225
+ const offset = cache.offsets[cut];
226
+ if (appendable(markdown, cache.content, offset)) {
227
+ const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
228
+ let tailLength = 0;
229
+ for (const token of tailTokens)
230
+ tailLength += token.raw.length;
231
+ // Contiguity guard: if the lexer normalized the tail (so raws no longer
232
+ // reconstruct the input), the offsets cannot be trusted full reparse.
233
+ if (offset + tailLength === markdown.length) {
234
+ const keptAtCut = cache.keptBefore[cut];
235
+ cache.raws.length = cut;
236
+ cache.keep.length = cut;
237
+ cache.offsets.length = cut + 1;
238
+ cache.keptBefore.length = cut + 1;
239
+ cache.blocks.length = keptAtCut;
240
+ let pos = offset;
241
+ let kept = keptAtCut;
242
+ for (const token of tailTokens) {
243
+ const raw = token.raw;
244
+ const keep = token.type !== 'space' && token.type !== 'footnote';
245
+ cache.raws.push(raw);
246
+ cache.keep.push(keep);
247
+ pos += raw.length;
248
+ cache.offsets.push(pos);
249
+ if (keep) {
250
+ cache.blocks.push(raw);
251
+ kept++;
252
+ }
253
+ cache.keptBefore.push(kept);
254
+ }
255
+ cache.content = markdown;
256
+ // Copy out: callers (Svelte `$derived`, the perf harness) diff block
257
+ // lists by identity, so handing back the persistent array would read as
258
+ // "nothing changed". slice() is a memcpy with no per-element callback.
259
+ return cache.blocks.slice();
146
260
  }
147
- cache.content = markdown;
148
- return cache.raws.filter((_, i) => cache.keep[i]);
149
261
  }
150
262
  }
151
263
  // Full parse (first call, non-append update, or contiguity fallback).
152
264
  const tokens = blockTokensOf(markdown, extensions);
153
- const blocks = [];
154
265
  if (cache) {
155
- cache.raws = [];
156
- cache.keep = [];
157
- let total = 0;
266
+ cache.raws.length = 0;
267
+ cache.keep.length = 0;
268
+ cache.offsets.length = 1;
269
+ cache.keptBefore.length = 1;
270
+ cache.blocks.length = 0;
271
+ let pos = 0;
272
+ let kept = 0;
158
273
  for (const token of tokens) {
274
+ const raw = token.raw;
159
275
  const keep = token.type !== 'space' && token.type !== 'footnote';
160
- cache.raws.push(token.raw);
276
+ cache.raws.push(raw);
161
277
  cache.keep.push(keep);
162
- total += token.raw.length;
163
- if (keep)
164
- blocks.push(token.raw);
278
+ pos += raw.length;
279
+ cache.offsets.push(pos);
280
+ if (keep) {
281
+ cache.blocks.push(raw);
282
+ kept++;
283
+ }
284
+ cache.keptBefore.push(kept);
165
285
  }
166
286
  // Only trust the cache for future appends if raws reconstruct the input.
167
- cache.content = total === markdown.length ? markdown : '';
168
- return blocks;
287
+ cache.content = pos === markdown.length ? markdown : '';
288
+ return cache.blocks.slice();
169
289
  }
290
+ const blocks = [];
170
291
  for (const token of tokens) {
171
292
  if (token.type !== 'space' && token.type !== 'footnote')
172
293
  blocks.push(token.raw);
@@ -2,6 +2,12 @@ export const markedBr = {
2
2
  name: 'br',
3
3
  level: 'inline',
4
4
  tokenizer(src) {
5
+ // marked dispatches every inline extension at every scan position; `start`
6
+ // only clips the text rule, it does not gate the tokenizer. This rule is
7
+ // anchored on a single literal character, so one charCodeAt rejects the
8
+ // ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
9
+ if (src.charCodeAt(0) !== 60 /* < */)
10
+ return undefined;
5
11
  // Match HTML <br> tags (with or without closing slash, case insensitive)
6
12
  const match = src.match(/^<br\s*\/?>/i);
7
13
  if (match) {
@@ -6,6 +6,12 @@ export const markedCitations = {
6
6
  return i === -1 ? -1 : i;
7
7
  },
8
8
  tokenizer(src) {
9
+ // marked dispatches every inline extension at every scan position; `start`
10
+ // only clips the text rule, it does not gate the tokenizer. This rule is
11
+ // anchored on a single literal character, so one charCodeAt rejects the
12
+ // ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
13
+ if (src.charCodeAt(0) !== 91 /* [ */)
14
+ return;
9
15
  // Match inline citations like [1], [ref], [1] [2], [ref] [ref2], etc.
10
16
  // Requires non-empty bracket contents and spaces between adjacent citation brackets
11
17
  const match = src.match(/^\[[^\][]+\](?:\s+\[[^\][]+\])*/);
@@ -1,8 +1,12 @@
1
1
  import {} from './index.js';
2
2
  import { StreamdownContext } from '../context.svelte.js';
3
3
  import { getContext } from 'svelte';
4
- const footnoteRegex = /^\[\^([^\]\n]+)\]:(?:[ \t]+|\n|$)([^\n]*(?:\n(?:[ \t]+[^\n]*)?)*)/;
5
- const footnoteRefRegex = /^\[\^([^\]\n]+)\]/;
4
+ // Footnote identifiers are word characters, `-` and `:` only. `[^\]\n]+` turned
5
+ // every regex character class written in prose (`[^\s]`, `[^,]`) into an empty
6
+ // footnote marker (upstream 9f72224). `:` is kept for the completer's
7
+ // `[^streamdown:footnote]` sentinel, which FootnoteRef.svelte renders as nothing.
8
+ const footnoteRegex = /^\[\^([\w:-]{1,200})\]:(?:[ \t]+|\n|$)([^\n]*(?:\n(?:[ \t]+[^\n]*)?)*)/;
9
+ const footnoteRefRegex = /^\[\^([\w:-]{1,200})\]/;
6
10
  const footNoteLastLineRegex = /^[ \t]*?[>\-*][ ]|[`]{3,}$|^[ \t]*?[|].+[|]$/;
7
11
  const safeGetContext = () => {
8
12
  try {
@@ -34,9 +38,14 @@ export function markedFootnote() {
34
38
  name: 'footnote',
35
39
  level: 'block',
36
40
  tokenizer(src) {
37
- const maps = ensureMaps(this);
41
+ // Match FIRST. ensureMaps() calls Svelte's getContext(), which throws
42
+ // (and is caught) whenever tokenizing happens outside component init —
43
+ // 3.3 us per call. Running it before the regex charged that to every
44
+ // block token in the document: 8.3 ms of the 13.0 ms it took to split a
45
+ // 100 KB document into blocks.
38
46
  const match = footnoteRegex.exec(src);
39
47
  if (match) {
48
+ const maps = ensureMaps(this);
40
49
  const [raw, label, text = ''] = match;
41
50
  let content = text.split('\n').reduce((acc, curr) => {
42
51
  return acc + '\n' + curr.replace(/^[ \t]+/, '');
@@ -66,9 +75,14 @@ export function markedFootnote() {
66
75
  name: 'footnoteRef',
67
76
  level: 'inline',
68
77
  tokenizer(src) {
69
- const maps = ensureMaps(this);
78
+ // Same ordering rule as the block tokenizer above: this one is an inline
79
+ // extension, so it is dispatched at every inline scan position — so it also
80
+ // gets a single-character guard before the regex.
81
+ if (src.charCodeAt(0) !== 91 /* [ */)
82
+ return;
70
83
  const match = footnoteRefRegex.exec(src);
71
84
  if (match) {
85
+ const maps = ensureMaps(this);
72
86
  const [raw, label] = match;
73
87
  const footnote = maps.footnotes.get(label);
74
88
  const token = {
@@ -20,6 +20,8 @@ export const markedMath = [
20
20
  name: 'math',
21
21
  level: 'block',
22
22
  tokenizer(src) {
23
+ if (src.charCodeAt(0) !== 36 /* $ */)
24
+ return;
23
25
  const match = src.match(blockRule);
24
26
  if (match) {
25
27
  // match[2] is multiline format, match[3] is single-line format
@@ -67,6 +69,9 @@ export const markedMath = [
67
69
  }
68
70
  },
69
71
  tokenizer(src) {
72
+ // inlineRule is anchored on `$`; skip the regex otherwise (see marked-br.ts).
73
+ if (src.charCodeAt(0) !== 36 /* $ */)
74
+ return;
70
75
  const match = src.match(inlineRule);
71
76
  if (match) {
72
77
  const content = match[2];
@@ -1,5 +1,8 @@
1
- const subRule = /^~([^~\s](?:[^~]*[^~\s])?)~/; // ~text~
2
- const supRule = /^\^([^\^\s](?:[^\^]*[^\^\s])?)\^/; // ^text^
1
+ // A sub/superscript is a single run, never a phrase: no whitespace anywhere in
2
+ // it. The old rules only forbade whitespace at the edges, so `20~25°C and 30~35°C`
3
+ // subscripted half the sentence (upstream 716a5f0).
4
+ const subRule = /^~([^~\s]+)~/; // ~text~
5
+ const supRule = /^\^([^\^\s]+)\^/; // ^text^
3
6
  export const markedSub = {
4
7
  name: 'sub',
5
8
  level: 'inline',
@@ -7,7 +10,23 @@ export const markedSub = {
7
10
  const i = src.indexOf('~');
8
11
  return i === -1 ? undefined : i;
9
12
  },
10
- tokenizer(src) {
13
+ tokenizer(src, tokens) {
14
+ // marked dispatches every inline extension at every scan position; `start`
15
+ // only clips the text rule, it does not gate the tokenizer. This rule is
16
+ // anchored on a single literal character, so one charCodeAt rejects the
17
+ // ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
18
+ if (src.charCodeAt(0) !== 126 /* ~ */)
19
+ return;
20
+ // A digit right before the opening `~` means a numeric range (`20~25°C`),
21
+ // not a subscript base — chemistry and indices always have a letter or a
22
+ // closing bracket there (`H~2~O`, `x~i+1~`). `src` starts at the marker, so
23
+ // the preceding character is the last one of the previous inline token.
24
+ const prevRaw = tokens[tokens.length - 1]?.raw;
25
+ if (prevRaw) {
26
+ const code = prevRaw.charCodeAt(prevRaw.length - 1);
27
+ if (code >= 48 && code <= 57)
28
+ return;
29
+ }
11
30
  const match = src.match(subRule);
12
31
  if (match) {
13
32
  return {
@@ -27,6 +46,8 @@ export const markedSup = {
27
46
  return i === -1 ? undefined : i;
28
47
  },
29
48
  tokenizer(src) {
49
+ if (src.charCodeAt(0) !== 94 /* ^ */)
50
+ return;
30
51
  const match = src.match(supRule);
31
52
  if (match) {
32
53
  return {
@@ -376,6 +376,19 @@ function processRows(headerRows, bodyRows, alignment, colCount, lexer, maxColspa
376
376
  }
377
377
  return tokens;
378
378
  }
379
+ // Hoisted: this was `new RegExp(...)` inside the tokenizer, so a ~1 KB pattern was
380
+ // concatenated and a RegExp constructed on every block-tokenizer dispatch — 3.3 ms
381
+ // of the 13.0 ms it took to split a 100 KB document into blocks.
382
+ const TABLE_WITH_ALIGN = new RegExp('^' +
383
+ '([^\\n ].*\\|.*\\n(?: *[^\\s].*\\n)*?)' + // Header
384
+ ' {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?' + // Header Align
385
+ '(?:\\n((?:(?! *\\n| {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})' + // Body Cells
386
+ '(?:\\n+|$)| {0,3}#{1,6} | {0,3}>| {4}[^\\n]| {0,3}(?:`{3,}' +
387
+ '(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n| {0,3}(?:[*+-]|1[.)]) |' +
388
+ '<\\/?(?:address|article|aside|base|basefont|blockquote|body|' +
389
+ 'caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?: +|\\n|\\/?>)|<(?:script|pre|style|textarea|!--)).*(?:\\n|$))*)\\n*|$)');
390
+ // Tables written without a header alignment row (`|a|b|` rows only).
391
+ const TABLE_NO_ALIGN = /^(\|.*\|(?:\n\|.*\|)*)/;
379
392
  const { detectFooter, maxColspan } = DEFAULT_OPTIONS;
380
393
  // Adds support for extended tables in marked with row spanning, column spanning,
381
394
  // multi-row headers, and column alignment
@@ -395,21 +408,11 @@ export const markedTable = {
395
408
  },
396
409
  tokenizer(src) {
397
410
  // Try to match table with header and alignment first
398
- let regex = new RegExp('^' +
399
- '([^\\n ].*\\|.*\\n(?: *[^\\s].*\\n)*?)' + // Header
400
- ' {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?' + // Header Align
401
- '(?:\\n((?:(?! *\\n| {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})' + // Body Cells
402
- '(?:\\n+|$)| {0,3}#{1,6} | {0,3}>| {4}[^\\n]| {0,3}(?:`{3,}' +
403
- '(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n| {0,3}(?:[*+-]|1[.)]) |' +
404
- '<\\/?(?:address|article|aside|base|basefont|blockquote|body|' +
405
- 'caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?: +|\\n|\\/?>)|<(?:script|pre|style|textarea|!--)).*(?:\\n|$))*)\\n*|$)');
406
- let cap = regex.exec(src);
411
+ let cap = TABLE_WITH_ALIGN.exec(src);
407
412
  let hasHeaderAlignment = true;
408
413
  // If no match with header alignment, try table without header alignment
409
414
  if (!cap) {
410
- // Simple regex for tables without header alignment
411
- regex = /^(\|.*\|(?:\n\|.*\|)*)/;
412
- cap = regex.exec(src);
415
+ cap = TABLE_NO_ALIGN.exec(src);
413
416
  hasHeaderAlignment = false;
414
417
  }
415
418
  if (!cap)