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.
- package/README.md +211 -38
- package/dist/Block.svelte +10 -4
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Alert.svelte +2 -1
- package/dist/Elements/Citation.svelte +9 -2
- package/dist/Elements/Code.svelte +65 -24
- package/dist/Elements/Code.svelte.d.ts +4 -2
- package/dist/Elements/Element.svelte +36 -11
- package/dist/Elements/Element.svelte.d.ts +1 -0
- package/dist/Elements/FootnoteRef.svelte +1 -0
- package/dist/Elements/Image.svelte +3 -2
- package/dist/Elements/Link.svelte +3 -2
- package/dist/Elements/Mermaid.svelte +69 -14
- package/dist/Elements/Mermaid.svelte.d.ts +4 -2
- package/dist/Elements/MermaidDownload.svelte +30 -9
- package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
- package/dist/Elements/TableDownload.svelte +60 -78
- package/dist/Elements/fallbacks/CodeFallback.svelte +28 -3
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
- package/dist/Elements/fallbacks/MermaidFallback.svelte +12 -3
- package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
- package/dist/Elements/icons.js +10 -1
- package/dist/Elements/srOnly.d.ts +1 -0
- package/dist/Elements/srOnly.js +3 -0
- package/dist/Streamdown.svelte +68 -13
- package/dist/context.svelte.d.ts +98 -22
- package/dist/context.svelte.js +38 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/marked/index.d.ts +8 -1
- package/dist/marked/index.js +65 -14
- package/dist/marked/marked-footnotes.js +6 -2
- package/dist/marked/marked-math.js +40 -1
- package/dist/marked/marked-subsup.js +16 -3
- package/dist/utils/fence.d.ts +16 -0
- package/dist/utils/fence.js +39 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
- package/dist/utils/parse-incomplete-markdown.js +347 -122
- package/dist/utils/save.js +4 -1
- package/dist/utils/table-export.d.ts +14 -0
- package/dist/utils/table-export.js +82 -0
- package/dist/utils/url.js +6 -2
- package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
- package/dist/utils/usePinnedScroll.svelte.js +36 -0
- package/package.json +4 -2
package/dist/marked/index.js
CHANGED
|
@@ -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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2
|
-
|
|
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;
|