svelte-streamdown 4.0.0 → 4.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.
Files changed (45) hide show
  1. package/README.md +211 -38
  2. package/dist/Block.svelte +10 -4
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +9 -2
  6. package/dist/Elements/Code.svelte +65 -24
  7. package/dist/Elements/Code.svelte.d.ts +4 -2
  8. package/dist/Elements/Element.svelte +36 -11
  9. package/dist/Elements/Element.svelte.d.ts +1 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +3 -2
  13. package/dist/Elements/Mermaid.svelte +69 -14
  14. package/dist/Elements/Mermaid.svelte.d.ts +4 -2
  15. package/dist/Elements/MermaidDownload.svelte +30 -9
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +60 -78
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +28 -3
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +12 -3
  21. package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
  22. package/dist/Elements/icons.js +10 -1
  23. package/dist/Elements/srOnly.d.ts +1 -0
  24. package/dist/Elements/srOnly.js +3 -0
  25. package/dist/Streamdown.svelte +68 -13
  26. package/dist/context.svelte.d.ts +98 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +8 -1
  31. package/dist/marked/index.js +65 -14
  32. package/dist/marked/marked-footnotes.js +6 -2
  33. package/dist/marked/marked-math.js +40 -1
  34. package/dist/marked/marked-subsup.js +16 -3
  35. package/dist/utils/fence.d.ts +16 -0
  36. package/dist/utils/fence.js +39 -0
  37. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  38. package/dist/utils/parse-incomplete-markdown.js +347 -122
  39. package/dist/utils/save.js +4 -1
  40. package/dist/utils/table-export.d.ts +14 -0
  41. package/dist/utils/table-export.js +82 -0
  42. package/dist/utils/url.js +6 -2
  43. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  44. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  45. package/package.json +4 -2
@@ -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();
@@ -115,9 +151,10 @@ export const createParseBlocksCache = () => ({
115
151
  // 2 covers constructs that merge backward as they stream in — e.g. a paragraph
116
152
  // line becoming a table once its delimiter row arrives, or a setext heading.
117
153
  const SEAL_SLACK = 2;
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.
154
+ // Stride spot checks over the sealed prefix per append (see `appendable`), on
155
+ // top of the per-block-start probes. The sealed region was compared
156
+ // byte-for-byte while it was live, so this only has to catch a caller that
157
+ // swapped in a different, longer document.
121
158
  const SEAL_PROBES = 16;
122
159
  /**
123
160
  * A Lexer used only to slice a document into top-level block raws.
@@ -149,23 +186,37 @@ const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(e
149
186
  * Is `markdown` an append to `cache.content`?
150
187
  *
151
188
  * 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).
189
+ * the only region whose segmentation can still change. The sealed prefix is only
190
+ * sampled, never scanned: a complete `markdown.startsWith(cache.content)` was
191
+ * 93% of parseBlocks' streaming cost (676 ms of 727 ms over a 100 KB /
192
+ * 5028-chunk stream) and is the single reason the append path was O(N) per chunk
193
+ * rather than O(tail).
194
+ *
195
+ * Two families of samples, both O(sealed blocks) and not O(sealed characters):
196
+ * the first character of every sealed block (`cache.offsets` already holds those
197
+ * positions), which catches any edit that moves a block boundary or rewrites a
198
+ * block's first character — the shape a real out-of-band edit takes; plus
199
+ * SEAL_PROBES evenly spaced characters, which catch a wholesale document swap.
200
+ * A same-length edit in the middle of a sealed block can still slip through; see
201
+ * the streaming contract in the README.
157
202
  *
158
203
  * Note this compares source against source, not source against `raws`: marked's
159
204
  * tokenizers normalize (e.g. the list tokenizer rewrites a trailing space as a
160
205
  * newline), so raws are not always literal slices of the input.
161
206
  */
162
- const appendable = (markdown, content, offset) => {
207
+ const appendable = (markdown, cache, cut, offset) => {
208
+ const content = cache.content;
163
209
  for (let i = offset; i < content.length; i++) {
164
210
  if (markdown.charCodeAt(i) !== content.charCodeAt(i))
165
211
  return false;
166
212
  }
167
213
  if (offset === 0)
168
214
  return true;
215
+ for (let b = 0; b < cut; b++) {
216
+ const i = cache.offsets[b];
217
+ if (markdown.charCodeAt(i) !== content.charCodeAt(i))
218
+ return false;
219
+ }
169
220
  const step = offset > SEAL_PROBES ? offset / SEAL_PROBES : 1;
170
221
  for (let f = 0; f < offset; f += step) {
171
222
  const i = f | 0;
@@ -187,7 +238,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
187
238
  liveBlocks++;
188
239
  }
189
240
  const offset = cache.offsets[cut];
190
- if (appendable(markdown, cache.content, offset)) {
241
+ if (appendable(markdown, cache, cut, offset)) {
191
242
  const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
192
243
  let tailLength = 0;
193
244
  for (const token of tailTokens)
@@ -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 {
@@ -4,6 +4,13 @@ const blockRule = /^(\$\$)(?:\n((?:\\[\s\S]|[^\\])+?)\n\1(?:\n|$)|([^$\n]+?)\1(?
4
4
  // Inline math: handles both single ($) and double ($$) dollar delimiters
5
5
  // Avoids matching currency by checking context and requiring proper content
6
6
  const inlineRule = /^(\${1,2})(?!\$)((?:[^$\n]|\\\$)*?)\1(?!\d)/;
7
+ // LaTeX delimiters. LLMs emit these as often as dollars, so they produce the
8
+ // same `math` token: `\(`/`\)` inline, `\[`/`\]` display. Only the block form
9
+ // may span lines; an escaped backslash (`\\(`) is not a delimiter, which the
10
+ // charCode dispatch below rejects before any regex runs.
11
+ const inlineParenRule = /^\\\(([^\n]*?)\\\)/;
12
+ const inlineBracketRule = /^\\\[([^\n]*?)\\\]/;
13
+ const blockBracketRule = /^\\\[([\s\S]+?)\\\](?:\n|$)/;
7
14
  // Enhanced currency detection patterns
8
15
  const currencyPatterns = {
9
16
  // Simple price patterns: $123, $123.45, $1,234.56
@@ -20,6 +27,20 @@ export const markedMath = [
20
27
  name: 'math',
21
28
  level: 'block',
22
29
  tokenizer(src) {
30
+ if (src.charCodeAt(0) === 92 /* \ */) {
31
+ if (src.charCodeAt(1) !== 91 /* [ */)
32
+ return;
33
+ const latex = src.match(blockBracketRule);
34
+ if (!latex)
35
+ return;
36
+ return {
37
+ type: 'math',
38
+ isInline: false,
39
+ displayMode: true,
40
+ raw: latex[0],
41
+ text: latex[1].trim()
42
+ };
43
+ }
23
44
  if (src.charCodeAt(0) !== 36 /* $ */)
24
45
  return;
25
46
  const match = src.match(blockRule);
@@ -69,7 +90,25 @@ export const markedMath = [
69
90
  }
70
91
  },
71
92
  tokenizer(src) {
72
- // inlineRule is anchored on `$`; skip the regex otherwise (see marked-br.ts).
93
+ // The rules are anchored on their opening delimiter; skip the regex
94
+ // otherwise (see marked-br.ts) — this runs at every inline scan position.
95
+ if (src.charCodeAt(0) === 92 /* \ */) {
96
+ const open = src.charCodeAt(1);
97
+ // `\\(` is an escaped backslash followed by a paren, never math.
98
+ const displayMode = open === 91; /* [ */
99
+ if (!displayMode && open !== 40 /* ( */)
100
+ return;
101
+ const latex = src.match(displayMode ? inlineBracketRule : inlineParenRule);
102
+ if (!latex)
103
+ return;
104
+ return {
105
+ type: 'math',
106
+ isInline: true,
107
+ displayMode,
108
+ raw: latex[0],
109
+ text: latex[1].trim()
110
+ };
111
+ }
73
112
  if (src.charCodeAt(0) !== 36 /* $ */)
74
113
  return;
75
114
  const match = src.match(inlineRule);
@@ -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,13 +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) {
11
14
  // marked dispatches every inline extension at every scan position; `start`
12
15
  // only clips the text rule, it does not gate the tokenizer. This rule is
13
16
  // anchored on a single literal character, so one charCodeAt rejects the
14
17
  // ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
15
18
  if (src.charCodeAt(0) !== 126 /* ~ */)
16
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
+ }
17
30
  const match = src.match(subRule);
18
31
  if (match) {
19
32
  return {
@@ -0,0 +1,16 @@
1
+ /** An open code fence: the character it was opened with, and its run length. */
2
+ export type OpenFence = {
3
+ char: string;
4
+ length: number;
5
+ } | null;
6
+ /**
7
+ * Fence tracking, one line at a time: returns the fence state after `line`.
8
+ * The completer's block scan and `hasUnclosedFence` both walk with this, so the
9
+ * "am I inside a fence" answer can never drift between them.
10
+ */
11
+ export declare const trackFence: (line: string, open: OpenFence) => OpenFence;
12
+ /**
13
+ * True when `raw` ends inside a fenced block — i.e. the fence is still being
14
+ * streamed. Only the last block of a live document can be in this state.
15
+ */
16
+ export declare const hasUnclosedFence: (raw: string) => boolean;
@@ -0,0 +1,39 @@
1
+ // Up to 3 spaces of indentation per CommonMark; blockquote markers are stripped
2
+ // too because a fence can be quoted ("> ```") inside a blockquote or an alert,
3
+ // and so is a leading list marker, because "- ```js" opens a fence in the item.
4
+ const FENCE_LINE = /^[ \t]{0,3}(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/;
5
+ /**
6
+ * Fence tracking, one line at a time: returns the fence state after `line`.
7
+ * The completer's block scan and `hasUnclosedFence` both walk with this, so the
8
+ * "am I inside a fence" answer can never drift between them.
9
+ */
10
+ export const trackFence = (line, open) => {
11
+ const match = FENCE_LINE.exec(line);
12
+ if (!match) {
13
+ return open;
14
+ }
15
+ const run = match[1];
16
+ const info = match[2];
17
+ if (open) {
18
+ // A closer uses the opener's character, is at least as long, and carries no
19
+ // info string — so a shorter fence line inside a longer block is content.
20
+ return run[0] === open.char && run.length >= open.length && info.trim() === '' ? null : open;
21
+ }
22
+ // A backtick fence's info string cannot contain a backtick (marked's own rule),
23
+ // so such a line opens nothing.
24
+ if (run[0] === '`' && info.includes('`')) {
25
+ return open;
26
+ }
27
+ return { char: run[0], length: run.length };
28
+ };
29
+ /**
30
+ * True when `raw` ends inside a fenced block — i.e. the fence is still being
31
+ * streamed. Only the last block of a live document can be in this state.
32
+ */
33
+ export const hasUnclosedFence = (raw) => {
34
+ let open = null;
35
+ for (const line of raw.split('\n')) {
36
+ open = trackFence(line, open);
37
+ }
38
+ return open !== null;
39
+ };
@@ -1,3 +1,4 @@
1
+ import { type OpenFence } from './fence.js';
1
2
  export interface Plugin {
2
3
  name: string;
3
4
  pattern?: RegExp;
@@ -25,13 +26,16 @@ interface ParseState {
25
26
  currentLine: number;
26
27
  context: 'normal' | 'list' | 'blockquote' | 'descriptionList';
27
28
  blockingContexts: Set<'code' | 'math' | 'center' | 'right'>;
29
+ /** Delimiter that closes the open math block: '$$' or '\]'. */
30
+ mathCloser?: '$$' | '\\]';
31
+ /** The fence still open at the end of the input, if any — what closes it. */
32
+ openFence?: OpenFence;
28
33
  lineContexts?: Array<{
29
34
  code: boolean;
30
35
  math: boolean;
31
36
  center: boolean;
32
37
  right: boolean;
33
38
  }>;
34
- fenceInfo?: string;
35
39
  mdxUnclosedTags?: Array<{
36
40
  tagName: string;
37
41
  lineIndex: number;