svelte-streamdown 3.1.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -106,21 +106,79 @@ export const lex = (markdown, extensions = []) => {
106
106
  export const createParseBlocksCache = () => ({
107
107
  content: '',
108
108
  raws: [],
109
- keep: []
109
+ keep: [],
110
+ offsets: [0],
111
+ keptBefore: [0],
112
+ blocks: []
110
113
  });
111
114
  // Number of trailing rendered blocks that stay "live" (re-lexed every chunk).
112
115
  // 2 covers constructs that merge backward as they stream in — e.g. a paragraph
113
116
  // line becoming a table once its delimiter row arrives, or a setext heading.
114
117
  const SEAL_SLACK = 2;
115
- const blockTokensOf = (markdown, extensions) => new Lexer(getBlockOptions(extensions)).blockTokens(markdown, []);
118
+ // Spot checks over the sealed prefix per append (see `appendable`). The sealed
119
+ // region was compared byte-for-byte while it was live, so this only has to catch
120
+ // a caller that swapped in a different, longer document.
121
+ const SEAL_PROBES = 16;
122
+ /**
123
+ * A Lexer used only to slice a document into top-level block raws.
124
+ *
125
+ * `Tokenizer.list()` recurses into every list item's text purely to decide
126
+ * whether the list is loose — `raw` is already final by then — and it marks that
127
+ * recursion by setting `state.top = false` first. `Tokenizer.blockquote()` also
128
+ * recurses, but its `raw` genuinely depends on the result (continuation lines are
129
+ * folded into a nested list/blockquote token), and it sets `state.top = true`.
130
+ * So dropping every nested call made with `top === false` removes the whole
131
+ * list-item subtree parse without changing a single raw.
132
+ */
133
+ class SplitLexer extends Lexer {
134
+ blockTokens(src, tokens = [], lastParagraphClipped = false) {
135
+ if (!this.state.top) {
136
+ // Lexer.blockTokens() sets state.top = true on exit; the skip must too.
137
+ this.state.top = true;
138
+ return tokens;
139
+ }
140
+ return super.blockTokens(src, tokens, lastParagraphClipped);
141
+ }
142
+ /** Extensions (dl, table) tokenize cell/term text inline; raws never depend on it. */
143
+ inlineTokens(src, tokens = []) {
144
+ return tokens;
145
+ }
146
+ }
147
+ const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(extensions)).blockTokens(markdown, []);
148
+ /**
149
+ * Is `markdown` an append to `cache.content`?
150
+ *
151
+ * The live region [offset, cache.content.length) is compared exactly — that is
152
+ * the only region whose segmentation can still change. The sealed prefix gets
153
+ * SEAL_PROBES sampled character comparisons instead of a full scan: a complete
154
+ * `markdown.startsWith(cache.content)` was 93% of parseBlocks' streaming cost
155
+ * (676 ms of 727 ms over a 100 KB / 5028-chunk stream) and is the single reason
156
+ * the append path was O(N) per chunk rather than O(tail).
157
+ *
158
+ * Note this compares source against source, not source against `raws`: marked's
159
+ * tokenizers normalize (e.g. the list tokenizer rewrites a trailing space as a
160
+ * newline), so raws are not always literal slices of the input.
161
+ */
162
+ const appendable = (markdown, content, offset) => {
163
+ for (let i = offset; i < content.length; i++) {
164
+ if (markdown.charCodeAt(i) !== content.charCodeAt(i))
165
+ return false;
166
+ }
167
+ if (offset === 0)
168
+ return true;
169
+ const step = offset > SEAL_PROBES ? offset / SEAL_PROBES : 1;
170
+ for (let f = 0; f < offset; f += step) {
171
+ const i = f | 0;
172
+ if (markdown.charCodeAt(i) !== content.charCodeAt(i))
173
+ return false;
174
+ }
175
+ return markdown.charCodeAt(offset - 1) === content.charCodeAt(offset - 1);
176
+ };
116
177
  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)) {
178
+ if (cache && cache.content.length > 0 && markdown.length > cache.content.length) {
121
179
  // 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.
180
+ // blocks and re-lex only the tail. offsets[] are prefix sums over raws, so
181
+ // the seal point costs one array read instead of a running sum.
124
182
  let cut = cache.raws.length;
125
183
  let liveBlocks = 0;
126
184
  while (cut > 0 && liveBlocks < SEAL_SLACK) {
@@ -128,45 +186,72 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
128
186
  if (cache.keep[cut])
129
187
  liveBlocks++;
130
188
  }
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');
189
+ const offset = cache.offsets[cut];
190
+ if (appendable(markdown, cache.content, offset)) {
191
+ const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
192
+ let tailLength = 0;
193
+ for (const token of tailTokens)
194
+ tailLength += token.raw.length;
195
+ // Contiguity guard: if the lexer normalized the tail (so raws no longer
196
+ // reconstruct the input), the offsets cannot be trusted full reparse.
197
+ if (offset + tailLength === markdown.length) {
198
+ const keptAtCut = cache.keptBefore[cut];
199
+ cache.raws.length = cut;
200
+ cache.keep.length = cut;
201
+ cache.offsets.length = cut + 1;
202
+ cache.keptBefore.length = cut + 1;
203
+ cache.blocks.length = keptAtCut;
204
+ let pos = offset;
205
+ let kept = keptAtCut;
206
+ for (const token of tailTokens) {
207
+ const raw = token.raw;
208
+ const keep = token.type !== 'space' && token.type !== 'footnote';
209
+ cache.raws.push(raw);
210
+ cache.keep.push(keep);
211
+ pos += raw.length;
212
+ cache.offsets.push(pos);
213
+ if (keep) {
214
+ cache.blocks.push(raw);
215
+ kept++;
216
+ }
217
+ cache.keptBefore.push(kept);
218
+ }
219
+ cache.content = markdown;
220
+ // Copy out: callers (Svelte `$derived`, the perf harness) diff block
221
+ // lists by identity, so handing back the persistent array would read as
222
+ // "nothing changed". slice() is a memcpy with no per-element callback.
223
+ return cache.blocks.slice();
146
224
  }
147
- cache.content = markdown;
148
- return cache.raws.filter((_, i) => cache.keep[i]);
149
225
  }
150
226
  }
151
227
  // Full parse (first call, non-append update, or contiguity fallback).
152
228
  const tokens = blockTokensOf(markdown, extensions);
153
- const blocks = [];
154
229
  if (cache) {
155
- cache.raws = [];
156
- cache.keep = [];
157
- let total = 0;
230
+ cache.raws.length = 0;
231
+ cache.keep.length = 0;
232
+ cache.offsets.length = 1;
233
+ cache.keptBefore.length = 1;
234
+ cache.blocks.length = 0;
235
+ let pos = 0;
236
+ let kept = 0;
158
237
  for (const token of tokens) {
238
+ const raw = token.raw;
159
239
  const keep = token.type !== 'space' && token.type !== 'footnote';
160
- cache.raws.push(token.raw);
240
+ cache.raws.push(raw);
161
241
  cache.keep.push(keep);
162
- total += token.raw.length;
163
- if (keep)
164
- blocks.push(token.raw);
242
+ pos += raw.length;
243
+ cache.offsets.push(pos);
244
+ if (keep) {
245
+ cache.blocks.push(raw);
246
+ kept++;
247
+ }
248
+ cache.keptBefore.push(kept);
165
249
  }
166
250
  // Only trust the cache for future appends if raws reconstruct the input.
167
- cache.content = total === markdown.length ? markdown : '';
168
- return blocks;
251
+ cache.content = pos === markdown.length ? markdown : '';
252
+ return cache.blocks.slice();
169
253
  }
254
+ const blocks = [];
170
255
  for (const token of tokens) {
171
256
  if (token.type !== 'space' && token.type !== 'footnote')
172
257
  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+\[[^\][]+\])*/);
@@ -34,9 +34,14 @@ export function markedFootnote() {
34
34
  name: 'footnote',
35
35
  level: 'block',
36
36
  tokenizer(src) {
37
- const maps = ensureMaps(this);
37
+ // Match FIRST. ensureMaps() calls Svelte's getContext(), which throws
38
+ // (and is caught) whenever tokenizing happens outside component init —
39
+ // 3.3 us per call. Running it before the regex charged that to every
40
+ // block token in the document: 8.3 ms of the 13.0 ms it took to split a
41
+ // 100 KB document into blocks.
38
42
  const match = footnoteRegex.exec(src);
39
43
  if (match) {
44
+ const maps = ensureMaps(this);
40
45
  const [raw, label, text = ''] = match;
41
46
  let content = text.split('\n').reduce((acc, curr) => {
42
47
  return acc + '\n' + curr.replace(/^[ \t]+/, '');
@@ -66,9 +71,14 @@ export function markedFootnote() {
66
71
  name: 'footnoteRef',
67
72
  level: 'inline',
68
73
  tokenizer(src) {
69
- const maps = ensureMaps(this);
74
+ // Same ordering rule as the block tokenizer above: this one is an inline
75
+ // extension, so it is dispatched at every inline scan position — so it also
76
+ // gets a single-character guard before the regex.
77
+ if (src.charCodeAt(0) !== 91 /* [ */)
78
+ return;
70
79
  const match = footnoteRefRegex.exec(src);
71
80
  if (match) {
81
+ const maps = ensureMaps(this);
72
82
  const [raw, label] = match;
73
83
  const footnote = maps.footnotes.get(label);
74
84
  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];
@@ -8,6 +8,12 @@ export const markedSub = {
8
8
  return i === -1 ? undefined : i;
9
9
  },
10
10
  tokenizer(src) {
11
+ // marked dispatches every inline extension at every scan position; `start`
12
+ // only clips the text rule, it does not gate the tokenizer. This rule is
13
+ // anchored on a single literal character, so one charCodeAt rejects the
14
+ // ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
15
+ if (src.charCodeAt(0) !== 126 /* ~ */)
16
+ return;
11
17
  const match = src.match(subRule);
12
18
  if (match) {
13
19
  return {
@@ -27,6 +33,8 @@ export const markedSup = {
27
33
  return i === -1 ? undefined : i;
28
34
  },
29
35
  tokenizer(src) {
36
+ if (src.charCodeAt(0) !== 94 /* ^ */)
37
+ return;
30
38
  const match = src.match(supRule);
31
39
  if (match) {
32
40
  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)
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>;