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.
- package/README.md +130 -43
- package/dist/Block.svelte +5 -2
- package/dist/Elements/Citation.svelte +2 -2
- package/dist/Elements/Code.svelte +21 -54
- package/dist/Elements/Code.svelte.d.ts +2 -2
- package/dist/Elements/Element.svelte +8 -5
- package/dist/Elements/Link.svelte +2 -1
- package/dist/Elements/Mermaid.svelte +8 -3
- package/dist/Elements/Mermaid.svelte.d.ts +2 -2
- package/dist/Elements/MermaidDownload.svelte +1 -1
- package/dist/Elements/TableDownload.svelte +13 -5
- package/dist/Elements/fallbacks/CodeFallback.svelte +7 -1
- package/dist/Elements/fallbacks/MermaidFallback.svelte +7 -1
- package/dist/Streamdown.svelte +12 -15
- package/dist/context.svelte.d.ts +9 -8
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/marked/index.d.ts +20 -1
- package/dist/marked/index.js +160 -39
- package/dist/marked/marked-br.js +6 -0
- package/dist/marked/marked-citations.js +6 -0
- package/dist/marked/marked-footnotes.js +18 -4
- package/dist/marked/marked-math.js +5 -0
- package/dist/marked/marked-subsup.js +24 -3
- package/dist/marked/marked-table.js +15 -12
- package/dist/theme.d.ts +0 -3
- package/dist/theme.js +0 -2
- package/dist/utils/highlightThemes.d.ts +4 -0
- package/dist/utils/highlightThemes.js +14 -0
- package/dist/utils/hightlighter.svelte.d.ts +6 -29
- package/dist/utils/hightlighter.svelte.js +27 -212
- package/dist/utils/parse-incomplete-markdown.js +100 -18
- package/dist/utils/save.js +4 -1
- package/dist/utils/url.js +6 -2
- package/package.json +5 -6
- package/dist/utils/bundledLanguages.d.ts +0 -8
- 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
|
|
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
|
|
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
|
package/dist/Streamdown.svelte
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
let {
|
|
8
8
|
content = '',
|
|
9
9
|
class: className,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
43
|
-
|
|
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
|
|
74
|
-
return
|
|
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
|
|
103
|
-
return
|
|
99
|
+
get highlightLanguages() {
|
|
100
|
+
return highlightLanguages;
|
|
104
101
|
},
|
|
105
|
-
get
|
|
106
|
-
return
|
|
102
|
+
get highlightThemes() {
|
|
103
|
+
return highlightThemes;
|
|
107
104
|
},
|
|
108
105
|
get sources() {
|
|
109
106
|
return sources;
|
package/dist/context.svelte.d.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
import type {
|
|
7
|
-
export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | '
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 {
|
|
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 {
|
|
6
|
+
export { defineLanguage } from '@tanstack/highlight';
|
package/dist/marked/index.d.ts
CHANGED
|
@@ -23,7 +23,14 @@ export type Extension = {
|
|
|
23
23
|
start?: TokenizerStartFunction;
|
|
24
24
|
applyInBlockParsing?: boolean;
|
|
25
25
|
};
|
|
26
|
-
|
|
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[];
|
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();
|
|
@@ -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
|
-
|
|
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.
|
|
123
|
-
//
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
cache.
|
|
145
|
-
cache.
|
|
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
|
-
|
|
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(
|
|
276
|
+
cache.raws.push(raw);
|
|
161
277
|
cache.keep.push(keep);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 =
|
|
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);
|
package/dist/marked/marked-br.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
@@ -34,9 +38,14 @@ export function markedFootnote() {
|
|
|
34
38
|
name: 'footnote',
|
|
35
39
|
level: 'block',
|
|
36
40
|
tokenizer(src) {
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
|
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
|
-
|
|
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)
|