svelte-streamdown 4.1.0 → 4.1.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 +3 -0
- package/dist/Block.svelte +11 -7
- package/dist/Elements/Code.svelte +6 -3
- package/dist/Elements/Code.svelte.d.ts +2 -0
- package/dist/Elements/Element.svelte +12 -6
- package/dist/Elements/Element.svelte.d.ts +1 -0
- package/dist/Elements/Mermaid.svelte +4 -2
- package/dist/Elements/Mermaid.svelte.d.ts +1 -0
- package/dist/Elements/fallbacks/CodeFallback.svelte +5 -3
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +1 -0
- package/dist/Streamdown.svelte +10 -3
- package/dist/context.svelte.d.ts +5 -0
- package/dist/marked/index.d.ts +6 -0
- package/dist/marked/index.js +21 -1
- package/dist/utils/fence.d.ts +12 -1
- package/dist/utils/fence.js +34 -17
- package/dist/utils/parse-incomplete-markdown.js +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -467,6 +467,9 @@ The animation system works by:
|
|
|
467
467
|
2. **Sequential Animation**: Each token animates as it is received
|
|
468
468
|
3. **Block-level Animation**: Entire blocks (paragraphs, headings, code blocks) animate as units
|
|
469
469
|
|
|
470
|
+
> [!NOTE]
|
|
471
|
+
> Only text that arrives in **streamed-sized appends** to `content` is animated. A bulk update — `content` replaced by a different document, a jump back to an earlier prefix, or a single append of more than ~2 KB such as pasting a whole answer or a "show all" — renders without animation, and the next streamed append animates again. Animating a whole document at once would start thousands of CSS animations in a single frame and stall the page.
|
|
472
|
+
|
|
470
473
|
### Animation Types
|
|
471
474
|
|
|
472
475
|
Choose from 4 distinct animation styles:
|
package/dist/Block.svelte
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { lex, type StreamdownToken } from './marked/index.js';
|
|
5
5
|
import AnimatedText from './AnimatedText.svelte';
|
|
6
6
|
import { useStreamdown } from './context.svelte.js';
|
|
7
|
-
import { getContext } from 'svelte';
|
|
7
|
+
import { getContext, untrack } from 'svelte';
|
|
8
8
|
|
|
9
9
|
let {
|
|
10
10
|
block,
|
|
@@ -21,9 +21,13 @@
|
|
|
21
21
|
// The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
|
|
22
22
|
// is aliased so the context flag and the helper cannot be confused.
|
|
23
23
|
const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
|
|
24
|
-
const
|
|
25
|
-
lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
|
|
26
|
-
|
|
24
|
+
const view = $derived.by(() => {
|
|
25
|
+
const tokens = lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions);
|
|
26
|
+
// Decided when this block's text changes and deliberately not tracked: a
|
|
27
|
+
// bulk update renders plain, and the next streamed chunk must not
|
|
28
|
+
// retroactively animate the blocks it left untouched.
|
|
29
|
+
return { tokens, animate: untrack(() => streamdown.animateUpdate) };
|
|
30
|
+
});
|
|
27
31
|
const insidePopover = getContext('POPOVER');
|
|
28
32
|
</script>
|
|
29
33
|
|
|
@@ -32,9 +36,9 @@
|
|
|
32
36
|
{#if token}
|
|
33
37
|
{@const children = (token as any)?.tokens || []}
|
|
34
38
|
{@const isTextOnlyNode = children.length === 0}
|
|
35
|
-
<Element {token} {incomplete}>
|
|
39
|
+
<Element {token} {incomplete} animate={view.animate}>
|
|
36
40
|
{#if isTextOnlyNode}
|
|
37
|
-
{#if streamdown.animation.enabled && !insidePopover && !isStatic}
|
|
41
|
+
{#if streamdown.animation.enabled && view.animate && !insidePopover && !isStatic}
|
|
38
42
|
<AnimatedText text={'text' in token ? token.text || '' : ''} />
|
|
39
43
|
{:else}
|
|
40
44
|
{'text' in token ? token.text : ''}
|
|
@@ -47,4 +51,4 @@
|
|
|
47
51
|
{/each}
|
|
48
52
|
{/snippet}
|
|
49
53
|
|
|
50
|
-
{@render renderChildren(tokens)}
|
|
54
|
+
{@render renderChildren(view.tokens)}
|
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
const {
|
|
12
12
|
token,
|
|
13
13
|
id,
|
|
14
|
-
incomplete = false
|
|
14
|
+
incomplete = false,
|
|
15
|
+
animate = true
|
|
15
16
|
}: {
|
|
16
17
|
token: CodeToken;
|
|
17
18
|
id: string;
|
|
18
19
|
/** The fence is still being streamed; nothing below it is final yet. */
|
|
19
20
|
incomplete?: boolean;
|
|
21
|
+
/** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
|
|
22
|
+
animate?: boolean;
|
|
20
23
|
} = $props();
|
|
21
24
|
|
|
22
25
|
const streamdown = useStreamdown();
|
|
@@ -66,7 +69,7 @@
|
|
|
66
69
|
<div
|
|
67
70
|
data-streamdown-code={id}
|
|
68
71
|
data-incomplete={incomplete || undefined}
|
|
69
|
-
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
72
|
+
style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
70
73
|
class={streamdown.theme.code.base}
|
|
71
74
|
>
|
|
72
75
|
<div class={streamdown.theme.code.header}>
|
|
@@ -115,7 +118,7 @@
|
|
|
115
118
|
>{#each lines as line}<span class={streamdown.theme.code.line}
|
|
116
119
|
>{#if line.length === 0}​{/if}{#each line as t}<span
|
|
117
120
|
class="th-token{t.className ? ` th-${t.className}` : ''}"
|
|
118
|
-
style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
|
|
121
|
+
style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
|
|
119
122
|
style:color={streamdown.highlightTheme.tokens[t.className ?? 'token']}
|
|
120
123
|
>{t.value}</span
|
|
121
124
|
>{/each}</span
|
|
@@ -4,6 +4,8 @@ type $$ComponentProps = {
|
|
|
4
4
|
id: string;
|
|
5
5
|
/** The fence is still being streamed; nothing below it is final yet. */
|
|
6
6
|
incomplete?: boolean;
|
|
7
|
+
/** False for the render of a bulk update — a replacement or a paste-sized append (see Block). */
|
|
8
|
+
animate?: boolean;
|
|
7
9
|
};
|
|
8
10
|
declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
11
|
type Code = ReturnType<typeof Code>;
|
|
@@ -15,8 +15,14 @@
|
|
|
15
15
|
let {
|
|
16
16
|
token,
|
|
17
17
|
children,
|
|
18
|
-
incomplete = false
|
|
19
|
-
|
|
18
|
+
incomplete = false,
|
|
19
|
+
animate = true
|
|
20
|
+
}: {
|
|
21
|
+
token: StreamdownToken;
|
|
22
|
+
children: Snippet;
|
|
23
|
+
incomplete?: boolean;
|
|
24
|
+
animate?: boolean;
|
|
25
|
+
} = $props();
|
|
20
26
|
const streamdown = useStreamdown();
|
|
21
27
|
|
|
22
28
|
// Use provided components or fallback to lightweight versions
|
|
@@ -25,7 +31,7 @@
|
|
|
25
31
|
const MathComponent = $derived(streamdown.components?.math ?? MathFallback);
|
|
26
32
|
|
|
27
33
|
// Only apply animation on block level elements. Leaves text elements to be animated by their text children.
|
|
28
|
-
const style = $derived(streamdown.isMounted ? streamdown.animationBlockStyle : '');
|
|
34
|
+
const style = $derived(animate && streamdown.isMounted ? streamdown.animationBlockStyle : '');
|
|
29
35
|
const id = $props.id();
|
|
30
36
|
|
|
31
37
|
// Only ever attached to the table wrapper, which is the element that already
|
|
@@ -91,11 +97,11 @@
|
|
|
91
97
|
props={{ children, token, incomplete }}
|
|
92
98
|
render={streamdown.snippets.mermaid ?? streamdown.snippets.code}
|
|
93
99
|
>
|
|
94
|
-
<MermaidComponent {id} {token} {incomplete} />
|
|
100
|
+
<MermaidComponent {id} {token} {incomplete} {animate} />
|
|
95
101
|
</Slot>
|
|
96
102
|
{:else if token.type === 'code'}
|
|
97
103
|
<Slot props={{ children, token, incomplete }} render={streamdown.snippets.code}>
|
|
98
|
-
<CodeComponent {id} {token} {incomplete} />
|
|
104
|
+
<CodeComponent {id} {token} {incomplete} {animate} />
|
|
99
105
|
</Slot>
|
|
100
106
|
{:else if token.type === 'codespan'}
|
|
101
107
|
<Slot props={{ children, token }} render={streamdown.snippets.codespan}>
|
|
@@ -284,7 +290,7 @@
|
|
|
284
290
|
<Slot props={{ children, token }} render={streamdown.snippets.descriptionList}>
|
|
285
291
|
<dl
|
|
286
292
|
data-streamdown-description-list={id}
|
|
287
|
-
style={streamdown.animationBlockStyle}
|
|
293
|
+
style={animate ? streamdown.animationBlockStyle : ''}
|
|
288
294
|
class={streamdown.theme.descriptionList.base}
|
|
289
295
|
>
|
|
290
296
|
{@render children()}
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
const {
|
|
14
14
|
token,
|
|
15
15
|
id,
|
|
16
|
-
incomplete = false
|
|
16
|
+
incomplete = false,
|
|
17
|
+
animate = true
|
|
17
18
|
}: {
|
|
18
19
|
token: CodeToken;
|
|
19
20
|
id: string;
|
|
20
21
|
/** The fence is still being streamed; nothing below it is final yet. */
|
|
21
22
|
incomplete?: boolean;
|
|
23
|
+
animate?: boolean;
|
|
22
24
|
} = $props();
|
|
23
25
|
|
|
24
26
|
// Trailing blank lines are noise for mermaid but they still changed `token.text`
|
|
@@ -251,7 +253,7 @@
|
|
|
251
253
|
{#if mermaid}
|
|
252
254
|
<div
|
|
253
255
|
bind:this={container}
|
|
254
|
-
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
256
|
+
style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
255
257
|
class={streamdown.theme.mermaid.base}
|
|
256
258
|
{@attach (node) => {
|
|
257
259
|
// A half-written diagram makes mermaid throw and log on every chunk, so
|
|
@@ -4,6 +4,7 @@ type $$ComponentProps = {
|
|
|
4
4
|
id: string;
|
|
5
5
|
/** The fence is still being streamed; nothing below it is final yet. */
|
|
6
6
|
incomplete?: boolean;
|
|
7
|
+
animate?: boolean;
|
|
7
8
|
};
|
|
8
9
|
declare const Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
10
|
type Mermaid = ReturnType<typeof Mermaid>;
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
const {
|
|
7
7
|
token,
|
|
8
8
|
id,
|
|
9
|
-
incomplete = false
|
|
9
|
+
incomplete = false,
|
|
10
|
+
animate = true
|
|
10
11
|
}: {
|
|
11
12
|
token: Tokens.Code;
|
|
12
13
|
id: string;
|
|
13
14
|
/** The fence is still being streamed; nothing below it is final yet. */
|
|
14
15
|
incomplete?: boolean;
|
|
16
|
+
animate?: boolean;
|
|
15
17
|
} = $props();
|
|
16
18
|
|
|
17
19
|
const streamdown = useStreamdown();
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
<div
|
|
37
39
|
data-streamdown-code={id}
|
|
38
40
|
data-incomplete={incomplete || undefined}
|
|
39
|
-
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
41
|
+
style={animate && streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
40
42
|
class={streamdown.theme.code.base}
|
|
41
43
|
>
|
|
42
44
|
<div class={streamdown.theme.code.header}>
|
|
@@ -49,7 +51,7 @@
|
|
|
49
51
|
style:overflow-y={streamdown.codeBlockMaxHeight ? 'auto' : undefined}
|
|
50
52
|
{@attach pinnedScroll}><code
|
|
51
53
|
>{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
|
|
52
|
-
><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
|
|
54
|
+
><span style={animate && streamdown.isMounted ? streamdown.animationTextStyle : ''}
|
|
53
55
|
>{line.trim().length > 0 ? line : '\u200B'}</span
|
|
54
56
|
></span
|
|
55
57
|
>{/each}</code
|
|
@@ -4,6 +4,7 @@ type $$ComponentProps = {
|
|
|
4
4
|
id: string;
|
|
5
5
|
/** The fence is still being streamed; nothing below it is final yet. */
|
|
6
6
|
incomplete?: boolean;
|
|
7
|
+
animate?: boolean;
|
|
7
8
|
};
|
|
8
9
|
declare const CodeFallback: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
10
|
type CodeFallback = ReturnType<typeof CodeFallback>;
|
package/dist/Streamdown.svelte
CHANGED
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
mermaidConfig?.theme ? mermaidConfig.theme : darkMode.current ? 'dark' : 'default'
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
// Per-instance incremental state: append-only content updates re-lex only
|
|
59
|
+
// the last couple of blocks instead of the whole document.
|
|
60
|
+
const blocksCache = createParseBlocksCache();
|
|
61
|
+
|
|
58
62
|
streamdown = new StreamdownContext({
|
|
59
63
|
get element() {
|
|
60
64
|
return element;
|
|
@@ -77,6 +81,12 @@
|
|
|
77
81
|
get highlightTheme() {
|
|
78
82
|
return resolvedHighlightTheme;
|
|
79
83
|
},
|
|
84
|
+
get animateUpdate() {
|
|
85
|
+
// A streamed chunk keeps animating; a replacement or a bulk append (the
|
|
86
|
+
// demo's "Show All" is an append of 95% of the document) renders plain.
|
|
87
|
+
// The first parse stays animatable so `animateOnMount` keeps its meaning.
|
|
88
|
+
return blocksCache.lastUpdate !== 'bulk';
|
|
89
|
+
},
|
|
80
90
|
get snippets() {
|
|
81
91
|
return snippets;
|
|
82
92
|
},
|
|
@@ -200,9 +210,6 @@
|
|
|
200
210
|
|
|
201
211
|
const id = $props.id();
|
|
202
212
|
|
|
203
|
-
// Per-instance incremental state: append-only content updates re-lex only
|
|
204
|
-
// the last couple of blocks instead of the whole document.
|
|
205
|
-
const blocksCache = createParseBlocksCache();
|
|
206
213
|
const blocks = $derived(
|
|
207
214
|
isStatic ? content : parseBlocks(content, streamdown.extensions, blocksCache)
|
|
208
215
|
);
|
package/dist/context.svelte.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export declare const defaultTranslations: Translations;
|
|
|
50
50
|
export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'highlightTheme' | 'inlineCitationsMode'> {
|
|
51
51
|
snippets: Snippets;
|
|
52
52
|
highlightTheme: HighlightTheme;
|
|
53
|
+
/** False while rendering a bulk update (a replacement or a paste-sized append); Block captures it per update. */
|
|
54
|
+
animateUpdate: boolean;
|
|
53
55
|
theme: Theme;
|
|
54
56
|
translations: Translations;
|
|
55
57
|
controls: ResolvedControls;
|
|
@@ -69,6 +71,7 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
|
|
|
69
71
|
constructor(props: Omit<StreamdownProps, keyof Snippets | 'class' | 'highlightTheme'> & {
|
|
70
72
|
snippets: Snippets<Source>;
|
|
71
73
|
highlightTheme: HighlightTheme;
|
|
74
|
+
animateUpdate: boolean;
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
77
|
export declare const useStreamdown: () => StreamdownContext<Record<string, any>>;
|
|
@@ -246,11 +249,13 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
246
249
|
token: Tokens.Code;
|
|
247
250
|
id: string;
|
|
248
251
|
incomplete: boolean;
|
|
252
|
+
animate: boolean;
|
|
249
253
|
}, any, any>;
|
|
250
254
|
mermaid?: Component<{
|
|
251
255
|
token: Tokens.Code;
|
|
252
256
|
id: string;
|
|
253
257
|
incomplete: boolean;
|
|
258
|
+
animate: boolean;
|
|
254
259
|
}, any, any>;
|
|
255
260
|
math?: Component<{
|
|
256
261
|
token: MathToken;
|
package/dist/marked/index.d.ts
CHANGED
|
@@ -57,6 +57,12 @@ export type ParseBlocksCache = {
|
|
|
57
57
|
keptBefore: number[];
|
|
58
58
|
/** persistent list of kept raws; only its tail is rewritten on append */
|
|
59
59
|
blocks: string[];
|
|
60
|
+
/**
|
|
61
|
+
* What kind of update the last call was. Rendering skips the streaming
|
|
62
|
+
* animation on a `bulk` one — a replacement, or an append far larger than any
|
|
63
|
+
* streamed chunk — where ~10k spans would otherwise all animate in one frame.
|
|
64
|
+
*/
|
|
65
|
+
lastUpdate: 'first' | 'stream' | 'bulk';
|
|
60
66
|
};
|
|
61
67
|
export declare const createParseBlocksCache: () => ParseBlocksCache;
|
|
62
68
|
export declare const parseBlocks: (markdown: string, extensions?: Extension[], cache?: ParseBlocksCache) => string[];
|
package/dist/marked/index.js
CHANGED
|
@@ -145,7 +145,8 @@ export const createParseBlocksCache = () => ({
|
|
|
145
145
|
keep: [],
|
|
146
146
|
offsets: [0],
|
|
147
147
|
keptBefore: [0],
|
|
148
|
-
blocks: []
|
|
148
|
+
blocks: [],
|
|
149
|
+
lastUpdate: 'first'
|
|
149
150
|
});
|
|
150
151
|
// Number of trailing rendered blocks that stay "live" (re-lexed every chunk).
|
|
151
152
|
// 2 covers constructs that merge backward as they stream in — e.g. a paragraph
|
|
@@ -225,7 +226,23 @@ const appendable = (markdown, cache, cut, offset) => {
|
|
|
225
226
|
}
|
|
226
227
|
return markdown.charCodeAt(offset - 1) === content.charCodeAt(offset - 1);
|
|
227
228
|
};
|
|
229
|
+
// An append this large in a single update is a paste or a "show all", not a
|
|
230
|
+
// streamed chunk. Real streams arrive in tens of characters, and even a client
|
|
231
|
+
// that batches renders to one frame at ~1000 tokens/s adds a few hundred; the
|
|
232
|
+
// harm — thousands of spans starting a CSS animation in the same frame — only
|
|
233
|
+
// begins well past this. ponytail: heuristic; a prop if anyone needs to tune it.
|
|
234
|
+
const BULK_APPEND_CHARS = 2048;
|
|
235
|
+
const updateKind = (isAppend, previousLength, length) => {
|
|
236
|
+
if (!isAppend)
|
|
237
|
+
return previousLength === 0 ? 'first' : 'bulk';
|
|
238
|
+
return length - previousLength > BULK_APPEND_CHARS ? 'bulk' : 'stream';
|
|
239
|
+
};
|
|
228
240
|
export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
241
|
+
// Whether this call extends the content the cache already described — decided
|
|
242
|
+
// by the same probe the fast path uses, so the contiguity fallback below still
|
|
243
|
+
// counts as an append for the animation's purposes.
|
|
244
|
+
let isAppend = false;
|
|
245
|
+
const previousLength = cache?.content.length ?? 0;
|
|
229
246
|
if (cache && cache.content.length > 0 && markdown.length > cache.content.length) {
|
|
230
247
|
// Append-only update: seal everything except the last SEAL_SLACK rendered
|
|
231
248
|
// blocks and re-lex only the tail. offsets[] are prefix sums over raws, so
|
|
@@ -239,6 +256,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
239
256
|
}
|
|
240
257
|
const offset = cache.offsets[cut];
|
|
241
258
|
if (appendable(markdown, cache, cut, offset)) {
|
|
259
|
+
isAppend = true;
|
|
242
260
|
const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
|
|
243
261
|
let tailLength = 0;
|
|
244
262
|
for (const token of tailTokens)
|
|
@@ -268,6 +286,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
268
286
|
cache.keptBefore.push(kept);
|
|
269
287
|
}
|
|
270
288
|
cache.content = markdown;
|
|
289
|
+
cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
|
|
271
290
|
// Copy out: callers (Svelte `$derived`, the perf harness) diff block
|
|
272
291
|
// lists by identity, so handing back the persistent array would read as
|
|
273
292
|
// "nothing changed". slice() is a memcpy with no per-element callback.
|
|
@@ -298,6 +317,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
298
317
|
}
|
|
299
318
|
cache.keptBefore.push(kept);
|
|
300
319
|
}
|
|
320
|
+
cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
|
|
301
321
|
// Only trust the cache for future appends if raws reconstruct the input.
|
|
302
322
|
cache.content = pos === markdown.length ? markdown : '';
|
|
303
323
|
return cache.blocks.slice();
|
package/dist/utils/fence.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* An open code fence: the character it was opened with, its run length, and
|
|
3
|
+
* the prefix the opener sat behind (indentation, blockquote markers, a list
|
|
4
|
+
* marker). The prefix is what a closer's indentation is measured against.
|
|
5
|
+
*/
|
|
2
6
|
export type OpenFence = {
|
|
3
7
|
char: string;
|
|
4
8
|
length: number;
|
|
9
|
+
prefix: string;
|
|
5
10
|
} | null;
|
|
6
11
|
/**
|
|
7
12
|
* Fence tracking, one line at a time: returns the fence state after `line`.
|
|
@@ -9,6 +14,12 @@ export type OpenFence = {
|
|
|
9
14
|
* "am I inside a fence" answer can never drift between them.
|
|
10
15
|
*/
|
|
11
16
|
export declare const trackFence: (line: string, open: OpenFence) => OpenFence;
|
|
17
|
+
/**
|
|
18
|
+
* The line that closes `open`, placed at the opener's depth so it closes the
|
|
19
|
+
* fence where it lives. A list marker in the prefix is blanked to spaces: a
|
|
20
|
+
* closer must sit inside the item, and a repeated marker would start a new one.
|
|
21
|
+
*/
|
|
22
|
+
export declare const closingFence: (open: NonNullable<OpenFence>) => string;
|
|
12
23
|
/**
|
|
13
24
|
* True when `raw` ends inside a fenced block — i.e. the fence is still being
|
|
14
25
|
* streamed. Only the last block of a live document can be in this state.
|
package/dist/utils/fence.js
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
const
|
|
1
|
+
// An opener may sit behind up to 3 spaces of indentation per CommonMark, behind
|
|
2
|
+
// blockquote markers ("> ```" inside a blockquote or an alert), and behind a
|
|
3
|
+
// list marker, because "- ```js" opens a fence in the item.
|
|
4
|
+
const OPENER_LINE = /^([ \t]{0,3}(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?)(`{3,}|~{3,})(.*)$/;
|
|
5
|
+
// A closer is measured against the opener, not against column 0: it may be
|
|
6
|
+
// indented up to 3 spaces more than the opener was. That is what makes a fence
|
|
7
|
+
// inside a list item close — "10. ```js" opens at column 4, so its " ```"
|
|
8
|
+
// closer is at 4, which a fixed 0–3 limit rejected.
|
|
9
|
+
const CLOSER_LINE = /^([ \t]*(?:>[ \t]*)*)(`{3,}|~{3,})[ \t]*$/;
|
|
5
10
|
/**
|
|
6
11
|
* Fence tracking, one line at a time: returns the fence state after `line`.
|
|
7
12
|
* The completer's block scan and `hasUnclosedFence` both walk with this, so the
|
|
8
13
|
* "am I inside a fence" answer can never drift between them.
|
|
9
14
|
*/
|
|
10
15
|
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
16
|
if (open) {
|
|
18
|
-
// A closer uses the opener's character, is at least as long,
|
|
19
|
-
// info string
|
|
20
|
-
|
|
17
|
+
// A closer uses the opener's character, is at least as long, carries no
|
|
18
|
+
// info string, and is not indented deeper than the opener plus 3 — so a
|
|
19
|
+
// shorter or deeper fence line inside the block is content.
|
|
20
|
+
const match = CLOSER_LINE.exec(line);
|
|
21
|
+
if (!match)
|
|
22
|
+
return open;
|
|
23
|
+
const run = match[2];
|
|
24
|
+
const closes = run[0] === open.char &&
|
|
25
|
+
run.length >= open.length &&
|
|
26
|
+
match[1].length <= open.prefix.length + 3;
|
|
27
|
+
return closes ? null : open;
|
|
21
28
|
}
|
|
29
|
+
const match = OPENER_LINE.exec(line);
|
|
30
|
+
if (!match)
|
|
31
|
+
return null;
|
|
32
|
+
const run = match[2];
|
|
22
33
|
// A backtick fence's info string cannot contain a backtick (marked's own rule),
|
|
23
34
|
// so such a line opens nothing.
|
|
24
|
-
if (run[0] === '`' &&
|
|
25
|
-
return
|
|
26
|
-
}
|
|
27
|
-
return { char: run[0], length: run.length };
|
|
35
|
+
if (run[0] === '`' && match[3].includes('`'))
|
|
36
|
+
return null;
|
|
37
|
+
return { char: run[0], length: run.length, prefix: match[1] };
|
|
28
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* The line that closes `open`, placed at the opener's depth so it closes the
|
|
41
|
+
* fence where it lives. A list marker in the prefix is blanked to spaces: a
|
|
42
|
+
* closer must sit inside the item, and a repeated marker would start a new one.
|
|
43
|
+
*/
|
|
44
|
+
export const closingFence = (open) => open.prefix.replace(/[-*+]|\d{1,9}[.)]/g, (marker) => ' '.repeat(marker.length)) +
|
|
45
|
+
open.char.repeat(open.length);
|
|
29
46
|
/**
|
|
30
47
|
* True when `raw` ends inside a fenced block — i.e. the fence is still being
|
|
31
48
|
* streamed. Only the last block of a live document can be in this state.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { trackFence } from './fence.js';
|
|
1
|
+
import { closingFence, trackFence } from './fence.js';
|
|
2
2
|
export class IncompleteMarkdownParser {
|
|
3
3
|
plugins = [];
|
|
4
4
|
state = {
|
|
@@ -196,10 +196,12 @@ export class IncompleteMarkdownParser {
|
|
|
196
196
|
// Close inner blocks (code/math) before alignment wrappers.
|
|
197
197
|
let result = text;
|
|
198
198
|
if (state.blockingContexts.has('code')) {
|
|
199
|
-
// Close with the fence that was opened
|
|
200
|
-
// '```',
|
|
199
|
+
// Close with the fence that was opened, at the depth it was opened: a
|
|
200
|
+
// '~~~' block is not closed by '```', a longer run needs a closer at
|
|
201
|
+
// least as long, and a fence inside a list item needs its closer inside
|
|
202
|
+
// the item — one at column 0 leaves the block open and starts a new one.
|
|
201
203
|
const fence = state.openFence;
|
|
202
|
-
result += '\n' + (fence ?
|
|
204
|
+
result += '\n' + (fence ? closingFence(fence) : '```');
|
|
203
205
|
}
|
|
204
206
|
if (state.blockingContexts.has('math')) {
|
|
205
207
|
if (state.mathCloser === '\\]') {
|