svelte-streamdown 4.0.1 → 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 +209 -41
- package/dist/Block.svelte +15 -8
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Alert.svelte +2 -1
- package/dist/Elements/Citation.svelte +7 -0
- package/dist/Elements/Code.svelte +60 -21
- package/dist/Elements/Code.svelte.d.ts +4 -0
- package/dist/Elements/Element.svelte +36 -8
- package/dist/Elements/Element.svelte.d.ts +2 -0
- package/dist/Elements/FootnoteRef.svelte +1 -0
- package/dist/Elements/Image.svelte +3 -2
- package/dist/Elements/Link.svelte +2 -2
- package/dist/Elements/Mermaid.svelte +65 -13
- package/dist/Elements/Mermaid.svelte.d.ts +3 -0
- package/dist/Elements/MermaidDownload.svelte +29 -8
- package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
- package/dist/Elements/TableDownload.svelte +57 -83
- package/dist/Elements/fallbacks/CodeFallback.svelte +25 -4
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -0
- package/dist/Elements/fallbacks/MermaidFallback.svelte +5 -2
- 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 +78 -16
- package/dist/context.svelte.d.ts +103 -22
- package/dist/context.svelte.js +38 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/marked/index.d.ts +6 -0
- package/dist/marked/index.js +46 -11
- package/dist/marked/marked-math.js +40 -1
- package/dist/utils/fence.d.ts +27 -0
- package/dist/utils/fence.js +56 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
- package/dist/utils/parse-incomplete-markdown.js +283 -138
- package/dist/utils/table-export.d.ts +14 -0
- package/dist/utils/table-export.js +82 -0
- package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
- package/dist/utils/usePinnedScroll.svelte.js +36 -0
- package/package.json +3 -2
|
@@ -2,6 +2,9 @@ import type { Tokens } from 'marked';
|
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
token: Tokens.Code;
|
|
4
4
|
id: string;
|
|
5
|
+
/** The fence is still being streamed; nothing below it is final yet. */
|
|
6
|
+
incomplete?: boolean;
|
|
7
|
+
animate?: boolean;
|
|
5
8
|
};
|
|
6
9
|
declare const CodeFallback: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
10
|
type CodeFallback = ReturnType<typeof CodeFallback>;
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
const {
|
|
6
6
|
token,
|
|
7
|
-
id
|
|
7
|
+
id,
|
|
8
|
+
incomplete = false
|
|
8
9
|
}: {
|
|
9
10
|
token: Tokens.Code;
|
|
10
11
|
id: string;
|
|
12
|
+
/** The fence is still being streamed; nothing below it is final yet. */
|
|
13
|
+
incomplete?: boolean;
|
|
11
14
|
} = $props();
|
|
12
15
|
|
|
13
16
|
const streamdown = useStreamdown();
|
|
@@ -19,7 +22,7 @@
|
|
|
19
22
|
const chart = $derived(token.text.replace(/\n+$/, ''));
|
|
20
23
|
</script>
|
|
21
24
|
|
|
22
|
-
<div data-streamdown-mermaid={id}>
|
|
25
|
+
<div data-streamdown-mermaid={id} data-incomplete={incomplete || undefined}>
|
|
23
26
|
<div
|
|
24
27
|
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
25
28
|
class={streamdown.theme.code.base}
|
|
@@ -2,6 +2,8 @@ import type { Tokens } from 'marked';
|
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
token: Tokens.Code;
|
|
4
4
|
id: string;
|
|
5
|
+
/** The fence is still being streamed; nothing below it is final yet. */
|
|
6
|
+
incomplete?: boolean;
|
|
5
7
|
};
|
|
6
8
|
declare const MermaidFallback: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
9
|
type MermaidFallback = ReturnType<typeof MermaidFallback>;
|
package/dist/Elements/icons.js
CHANGED
|
@@ -4,6 +4,7 @@ export const copyIcon = createRawSnippet(() => {
|
|
|
4
4
|
render: () => {
|
|
5
5
|
return `
|
|
6
6
|
<svg
|
|
7
|
+
aria-hidden="true"
|
|
7
8
|
xmlns="http://www.w3.org/2000/svg"
|
|
8
9
|
width="100%"
|
|
9
10
|
height="100%"
|
|
@@ -24,6 +25,7 @@ export const downloadIcon = createRawSnippet(() => {
|
|
|
24
25
|
return {
|
|
25
26
|
render: () => {
|
|
26
27
|
return `<svg
|
|
28
|
+
aria-hidden="true"
|
|
27
29
|
xmlns="http://www.w3.org/2000/svg"
|
|
28
30
|
width="100%"
|
|
29
31
|
height="100%"
|
|
@@ -44,7 +46,8 @@ export const checkIcon = createRawSnippet(() => {
|
|
|
44
46
|
return {
|
|
45
47
|
render: () => {
|
|
46
48
|
return `
|
|
47
|
-
<svg
|
|
49
|
+
<svg
|
|
50
|
+
aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
|
48
51
|
`;
|
|
49
52
|
}
|
|
50
53
|
};
|
|
@@ -53,6 +56,7 @@ export const zoomInIcon = createRawSnippet(() => {
|
|
|
53
56
|
return {
|
|
54
57
|
render: () => `
|
|
55
58
|
<svg
|
|
59
|
+
aria-hidden="true"
|
|
56
60
|
width="100%"
|
|
57
61
|
height="100%"
|
|
58
62
|
viewBox="0 0 24 24"
|
|
@@ -75,6 +79,7 @@ export const zoomOutIcon = createRawSnippet(() => {
|
|
|
75
79
|
return {
|
|
76
80
|
render: () => `
|
|
77
81
|
<svg
|
|
82
|
+
aria-hidden="true"
|
|
78
83
|
width="100%"
|
|
79
84
|
height="100%"
|
|
80
85
|
viewBox="0 0 24 24"
|
|
@@ -96,6 +101,7 @@ export const fitViewIcon = createRawSnippet(() => {
|
|
|
96
101
|
return {
|
|
97
102
|
render: () => `
|
|
98
103
|
<svg
|
|
104
|
+
aria-hidden="true"
|
|
99
105
|
width="100%"
|
|
100
106
|
height="100%"
|
|
101
107
|
viewBox="0 0 24 24"
|
|
@@ -119,6 +125,7 @@ export const fullscreenIcon = createRawSnippet(() => {
|
|
|
119
125
|
return {
|
|
120
126
|
render: () => `
|
|
121
127
|
<svg
|
|
128
|
+
aria-hidden="true"
|
|
122
129
|
width="100%"
|
|
123
130
|
height="100%"
|
|
124
131
|
viewBox="0 0 24 24"
|
|
@@ -139,6 +146,7 @@ export const chevronRight = createRawSnippet(() => {
|
|
|
139
146
|
return {
|
|
140
147
|
render: () => `
|
|
141
148
|
<svg
|
|
149
|
+
aria-hidden="true"
|
|
142
150
|
xmlns="http://www.w3.org/2000/svg"
|
|
143
151
|
width="100%"
|
|
144
152
|
height="100%"
|
|
@@ -158,6 +166,7 @@ export const chevronLeft = createRawSnippet(() => {
|
|
|
158
166
|
return {
|
|
159
167
|
render: () => `
|
|
160
168
|
<svg
|
|
169
|
+
aria-hidden="true"
|
|
161
170
|
xmlns="http://www.w3.org/2000/svg"
|
|
162
171
|
width="100%"
|
|
163
172
|
height="100%"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const srOnly = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Visually hidden but announced. Inline rather than a theme class: a live region
|
|
2
|
+
// must keep working in apps that do not ship Tailwind's `sr-only`.
|
|
3
|
+
export const srOnly = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
|
package/dist/Streamdown.svelte
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
<script lang="ts" generics="Source extends Record<string, any> = Record<string, any>">
|
|
2
2
|
import Block from './Block.svelte';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
StreamdownContext,
|
|
5
|
+
defaultTranslations,
|
|
6
|
+
type StreamdownProps
|
|
7
|
+
} from './context.svelte.js';
|
|
4
8
|
import { mergeTheme, shadcnTheme } from './theme.js';
|
|
5
9
|
import { parseBlocks, createParseBlocksCache } from './marked/index.js';
|
|
10
|
+
import { hasUnclosedFence } from './utils/fence.js';
|
|
6
11
|
|
|
7
12
|
let {
|
|
8
13
|
content = '',
|
|
@@ -23,6 +28,8 @@
|
|
|
23
28
|
streamdown = $bindable(),
|
|
24
29
|
renderHtml,
|
|
25
30
|
controls,
|
|
31
|
+
codeBlockMaxHeight,
|
|
32
|
+
tableMaxHeight,
|
|
26
33
|
animation,
|
|
27
34
|
element = $bindable(),
|
|
28
35
|
icons,
|
|
@@ -48,6 +55,10 @@
|
|
|
48
55
|
mermaidConfig?.theme ? mermaidConfig.theme : darkMode.current ? 'dark' : 'default'
|
|
49
56
|
);
|
|
50
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
|
+
|
|
51
62
|
streamdown = new StreamdownContext({
|
|
52
63
|
get element() {
|
|
53
64
|
return element;
|
|
@@ -70,6 +81,12 @@
|
|
|
70
81
|
get highlightTheme() {
|
|
71
82
|
return resolvedHighlightTheme;
|
|
72
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
|
+
},
|
|
73
90
|
get snippets() {
|
|
74
91
|
return snippets;
|
|
75
92
|
},
|
|
@@ -94,7 +111,11 @@
|
|
|
94
111
|
return renderHtml;
|
|
95
112
|
},
|
|
96
113
|
get translations() {
|
|
97
|
-
|
|
114
|
+
// Resolved once, like the theme, so components never fall back themselves.
|
|
115
|
+
return {
|
|
116
|
+
alert: { ...defaultTranslations.alert, ...translations?.alert },
|
|
117
|
+
controls: { ...defaultTranslations.controls, ...translations?.controls }
|
|
118
|
+
};
|
|
98
119
|
},
|
|
99
120
|
get highlightLanguages() {
|
|
100
121
|
return highlightLanguages;
|
|
@@ -123,19 +144,53 @@
|
|
|
123
144
|
};
|
|
124
145
|
},
|
|
125
146
|
get controls() {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
147
|
+
// `controls` is sugar: `false` turns every control off, `true`/undefined
|
|
148
|
+
// turns them all on, and each section is a boolean or an object of the
|
|
149
|
+
// same shape. Flatten it once here so components stay boolean lookups.
|
|
150
|
+
const sections = typeof controls === 'object' && controls !== null ? controls : {};
|
|
151
|
+
const resolve = (section: boolean | Record<string, any> | undefined) => {
|
|
152
|
+
const config = typeof section === 'object' && section !== null ? section : {};
|
|
153
|
+
const enabled = controls === false || section === false ? false : (config.enabled ?? true);
|
|
154
|
+
const download = config.download;
|
|
155
|
+
return {
|
|
156
|
+
enabled,
|
|
157
|
+
copy: enabled && config.copy !== false,
|
|
158
|
+
download: enabled && download !== false,
|
|
159
|
+
filename:
|
|
160
|
+
typeof download === 'object' && download !== null ? download.filename : undefined
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
const code = resolve(sections.code);
|
|
164
|
+
const table = resolve(sections.table);
|
|
165
|
+
const mermaid = resolve(sections.mermaid);
|
|
166
|
+
const tableSection =
|
|
167
|
+
typeof sections.table === 'object' && sections.table ? sections.table : {};
|
|
168
|
+
const mermaidSection =
|
|
169
|
+
typeof sections.mermaid === 'object' && sections.mermaid ? sections.mermaid : {};
|
|
132
170
|
return {
|
|
133
|
-
code:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
171
|
+
code: code.enabled,
|
|
172
|
+
codeCopy: code.copy,
|
|
173
|
+
codeDownload: code.download,
|
|
174
|
+
codeDownloadFilename: code.filename ?? 'file',
|
|
175
|
+
table: table.enabled,
|
|
176
|
+
tableCopy: table.copy,
|
|
177
|
+
tableDownload: table.download,
|
|
178
|
+
tableDownloadFilename: table.filename ?? 'table',
|
|
179
|
+
tableCsvSeparator: tableSection.csvSeparator ?? ',',
|
|
180
|
+
mermaid: mermaid.enabled,
|
|
181
|
+
mermaidDownload: mermaid.download,
|
|
182
|
+
mermaidDownloadFilename: mermaid.filename ?? 'diagram',
|
|
183
|
+
// Wheel zoom is a gesture, not a button: it stays on unless it is
|
|
184
|
+
// turned off explicitly or every control is.
|
|
185
|
+
mermaidMouseWheelZoom: controls !== false && mermaidSection.mouseWheelZoom !== false
|
|
137
186
|
};
|
|
138
187
|
},
|
|
188
|
+
get codeBlockMaxHeight() {
|
|
189
|
+
return codeBlockMaxHeight;
|
|
190
|
+
},
|
|
191
|
+
get tableMaxHeight() {
|
|
192
|
+
return tableMaxHeight;
|
|
193
|
+
},
|
|
139
194
|
get children() {
|
|
140
195
|
return children;
|
|
141
196
|
},
|
|
@@ -155,12 +210,15 @@
|
|
|
155
210
|
|
|
156
211
|
const id = $props.id();
|
|
157
212
|
|
|
158
|
-
// Per-instance incremental state: append-only content updates re-lex only
|
|
159
|
-
// the last couple of blocks instead of the whole document.
|
|
160
|
-
const blocksCache = createParseBlocksCache();
|
|
161
213
|
const blocks = $derived(
|
|
162
214
|
isStatic ? content : parseBlocks(content, streamdown.extensions, blocksCache)
|
|
163
215
|
);
|
|
216
|
+
|
|
217
|
+
// Only the tail of a live stream can be mid-fence — a static render is finished
|
|
218
|
+
// by definition. Computed once here, not per block or per token.
|
|
219
|
+
const lastBlockIncomplete = $derived(
|
|
220
|
+
!isStatic && blocks.length > 0 && hasUnclosedFence(blocks[blocks.length - 1])
|
|
221
|
+
);
|
|
164
222
|
</script>
|
|
165
223
|
|
|
166
224
|
<div bind:this={element} class={className}>
|
|
@@ -168,7 +226,11 @@
|
|
|
168
226
|
<Block static={isStatic} block={content} />
|
|
169
227
|
{:else}
|
|
170
228
|
{#each blocks as block, index (`${id}-block-${index}`)}
|
|
171
|
-
<Block
|
|
229
|
+
<Block
|
|
230
|
+
static={isStatic}
|
|
231
|
+
{block}
|
|
232
|
+
incomplete={lastBlockIncomplete && index === blocks.length - 1}
|
|
233
|
+
/>
|
|
172
234
|
{/each}
|
|
173
235
|
{/if}
|
|
174
236
|
</div>
|
package/dist/context.svelte.d.ts
CHANGED
|
@@ -4,16 +4,57 @@ import type { MermaidConfig } from 'mermaid';
|
|
|
4
4
|
import type { KatexOptions } from 'katex';
|
|
5
5
|
import type { HighlightTheme } from '@tanstack/highlight/theme';
|
|
6
6
|
import type { LanguageDefinition } from '@tanstack/highlight';
|
|
7
|
+
/**
|
|
8
|
+
* Every user-visible string the components render themselves. Nested by area,
|
|
9
|
+
* like the theme; `Streamdown` merges the `translations` prop over
|
|
10
|
+
* `defaultTranslations` once so components always read a complete object.
|
|
11
|
+
*/
|
|
12
|
+
export type Translations = {
|
|
13
|
+
alert: {
|
|
14
|
+
note: string;
|
|
15
|
+
tip: string;
|
|
16
|
+
warning: string;
|
|
17
|
+
caution: string;
|
|
18
|
+
important: string;
|
|
19
|
+
};
|
|
20
|
+
controls: {
|
|
21
|
+
copyCode: string;
|
|
22
|
+
copiedCode: string;
|
|
23
|
+
downloadCode: string;
|
|
24
|
+
copyTable: string;
|
|
25
|
+
copiedTable: string;
|
|
26
|
+
downloadTable: string;
|
|
27
|
+
tableFormatMarkdown: string;
|
|
28
|
+
tableFormatHtml: string;
|
|
29
|
+
tableFormatCsv: string;
|
|
30
|
+
tableFormatTsv: string;
|
|
31
|
+
downloadDiagram: string;
|
|
32
|
+
downloadDiagramPng: string;
|
|
33
|
+
downloadDiagramSvg: string;
|
|
34
|
+
downloadDiagramMmd: string;
|
|
35
|
+
zoomIn: string;
|
|
36
|
+
zoomOut: string;
|
|
37
|
+
resetView: string;
|
|
38
|
+
fullscreen: string;
|
|
39
|
+
exitFullscreen: string;
|
|
40
|
+
diagram: string;
|
|
41
|
+
previousCitation: string;
|
|
42
|
+
nextCitation: string;
|
|
43
|
+
blockedUrl: string;
|
|
44
|
+
imageBlocked: string;
|
|
45
|
+
imageNoDescription: string;
|
|
46
|
+
linkBlocked: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export declare const defaultTranslations: Translations;
|
|
7
50
|
export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'highlightTheme' | 'inlineCitationsMode'> {
|
|
8
51
|
snippets: Snippets;
|
|
9
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;
|
|
10
55
|
theme: Theme;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
mermaid: boolean;
|
|
14
|
-
mermaidMouseWheelZoom: boolean;
|
|
15
|
-
table: boolean;
|
|
16
|
-
};
|
|
56
|
+
translations: Translations;
|
|
57
|
+
controls: ResolvedControls;
|
|
17
58
|
inlineCitationsMode: 'list' | 'carousel';
|
|
18
59
|
animation: {
|
|
19
60
|
enabled: boolean;
|
|
@@ -30,11 +71,13 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
|
|
|
30
71
|
constructor(props: Omit<StreamdownProps, keyof Snippets | 'class' | 'highlightTheme'> & {
|
|
31
72
|
snippets: Snippets<Source>;
|
|
32
73
|
highlightTheme: HighlightTheme;
|
|
74
|
+
animateUpdate: boolean;
|
|
33
75
|
});
|
|
34
76
|
}
|
|
35
77
|
export declare const useStreamdown: () => StreamdownContext<Record<string, any>>;
|
|
36
|
-
import type { AlertToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken, MdxToken } from './marked/index.js';
|
|
78
|
+
import type { AlertToken, CodeToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken, MdxToken } from './marked/index.js';
|
|
37
79
|
import type { Tokens } from 'marked';
|
|
80
|
+
import type { CsvSeparator } from './utils/table-export.js';
|
|
38
81
|
import type { ListItemToken, ListToken } from './marked/marked-list.js';
|
|
39
82
|
import type { Footnote, FootnoteRef, FootnoteToken } from './marked/marked-footnotes.js';
|
|
40
83
|
import type { DescriptionDetailToken, DescriptionListToken, DescriptionTermToken, DescriptionToken } from './marked/marked-dl.js';
|
|
@@ -89,9 +132,53 @@ export type Snippets<Source extends Record<string, any> = Record<string, any>> =
|
|
|
89
132
|
key: string;
|
|
90
133
|
} : K extends 'mdx' ? {
|
|
91
134
|
props: Record<string, number | string | boolean | null | undefined>;
|
|
135
|
+
} : K extends 'code' | 'mermaid' ? {
|
|
136
|
+
/** The fence is still streaming: defer expensive work. */
|
|
137
|
+
incomplete: boolean;
|
|
92
138
|
} : {})
|
|
93
139
|
]>;
|
|
94
140
|
};
|
|
141
|
+
/** `filename` is the base name; the extension comes from the format. */
|
|
142
|
+
type DownloadControl<Token> = boolean | {
|
|
143
|
+
filename?: string | ((token: Token) => string);
|
|
144
|
+
};
|
|
145
|
+
export type CodeControls = boolean | {
|
|
146
|
+
enabled?: boolean;
|
|
147
|
+
copy?: boolean;
|
|
148
|
+
download?: DownloadControl<CodeToken>;
|
|
149
|
+
};
|
|
150
|
+
export type TableControls = boolean | {
|
|
151
|
+
enabled?: boolean;
|
|
152
|
+
copy?: boolean;
|
|
153
|
+
download?: DownloadControl<TableToken>;
|
|
154
|
+
csvSeparator?: CsvSeparator;
|
|
155
|
+
};
|
|
156
|
+
export type MermaidControls = boolean | {
|
|
157
|
+
enabled?: boolean;
|
|
158
|
+
download?: DownloadControl<CodeToken>;
|
|
159
|
+
mouseWheelZoom?: boolean;
|
|
160
|
+
};
|
|
161
|
+
export type Controls = boolean | {
|
|
162
|
+
code?: CodeControls;
|
|
163
|
+
table?: TableControls;
|
|
164
|
+
mermaid?: MermaidControls;
|
|
165
|
+
};
|
|
166
|
+
/** What `Streamdown.svelte` flattens `controls` into, so components stay dumb. */
|
|
167
|
+
export type ResolvedControls = {
|
|
168
|
+
code: boolean;
|
|
169
|
+
codeCopy: boolean;
|
|
170
|
+
codeDownload: boolean;
|
|
171
|
+
codeDownloadFilename: string | ((token: CodeToken) => string);
|
|
172
|
+
table: boolean;
|
|
173
|
+
tableCopy: boolean;
|
|
174
|
+
tableDownload: boolean;
|
|
175
|
+
tableDownloadFilename: string | ((token: TableToken) => string);
|
|
176
|
+
tableCsvSeparator: CsvSeparator;
|
|
177
|
+
mermaid: boolean;
|
|
178
|
+
mermaidDownload: boolean;
|
|
179
|
+
mermaidDownloadFilename: string | ((token: CodeToken) => string);
|
|
180
|
+
mermaidMouseWheelZoom: boolean;
|
|
181
|
+
};
|
|
95
182
|
export type StreamdownProps<Source extends Record<string, any> = Record<string, any>> = {
|
|
96
183
|
streamdown?: StreamdownContext;
|
|
97
184
|
static?: boolean;
|
|
@@ -114,23 +201,13 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
114
201
|
highlightThemes?: Record<string, HighlightTheme>;
|
|
115
202
|
mermaidConfig?: MermaidConfig;
|
|
116
203
|
katexConfig?: KatexOptions | ((inline: boolean) => KatexOptions);
|
|
204
|
+
/** Partial overrides; unset keys fall back to `defaultTranslations`. */
|
|
117
205
|
translations?: {
|
|
118
|
-
|
|
119
|
-
note?: string;
|
|
120
|
-
tip?: string;
|
|
121
|
-
warning?: string;
|
|
122
|
-
caution?: string;
|
|
123
|
-
important?: string;
|
|
124
|
-
};
|
|
125
|
-
};
|
|
126
|
-
controls?: {
|
|
127
|
-
code?: boolean;
|
|
128
|
-
mermaid?: boolean | {
|
|
129
|
-
enabled?: boolean;
|
|
130
|
-
mouseWheelZoom?: boolean;
|
|
131
|
-
};
|
|
132
|
-
table?: boolean;
|
|
206
|
+
[K in keyof Translations]?: Partial<Translations[K]>;
|
|
133
207
|
};
|
|
208
|
+
controls?: Controls;
|
|
209
|
+
codeBlockMaxHeight?: string;
|
|
210
|
+
tableMaxHeight?: string;
|
|
134
211
|
renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
|
|
135
212
|
animation?: {
|
|
136
213
|
animateOnMount?: boolean;
|
|
@@ -171,10 +248,14 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
171
248
|
code?: Component<{
|
|
172
249
|
token: Tokens.Code;
|
|
173
250
|
id: string;
|
|
251
|
+
incomplete: boolean;
|
|
252
|
+
animate: boolean;
|
|
174
253
|
}, any, any>;
|
|
175
254
|
mermaid?: Component<{
|
|
176
255
|
token: Tokens.Code;
|
|
177
256
|
id: string;
|
|
257
|
+
incomplete: boolean;
|
|
258
|
+
animate: boolean;
|
|
178
259
|
}, any, any>;
|
|
179
260
|
math?: Component<{
|
|
180
261
|
token: MathToken;
|
package/dist/context.svelte.js
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
import { getContext, onMount, setContext } from 'svelte';
|
|
2
|
+
// Alert titles stay lowercase: the theme capitalizes them with CSS.
|
|
3
|
+
export const defaultTranslations = {
|
|
4
|
+
alert: {
|
|
5
|
+
note: 'note',
|
|
6
|
+
tip: 'tip',
|
|
7
|
+
warning: 'warning',
|
|
8
|
+
caution: 'caution',
|
|
9
|
+
important: 'important'
|
|
10
|
+
},
|
|
11
|
+
controls: {
|
|
12
|
+
copyCode: 'Copy code',
|
|
13
|
+
copiedCode: 'Code copied',
|
|
14
|
+
downloadCode: 'Download code',
|
|
15
|
+
copyTable: 'Copy table',
|
|
16
|
+
copiedTable: 'Table copied',
|
|
17
|
+
downloadTable: 'Download table',
|
|
18
|
+
tableFormatMarkdown: 'Markdown',
|
|
19
|
+
tableFormatHtml: 'HTML',
|
|
20
|
+
tableFormatCsv: 'CSV',
|
|
21
|
+
tableFormatTsv: 'TSV',
|
|
22
|
+
downloadDiagram: 'Download diagram',
|
|
23
|
+
downloadDiagramPng: 'PNG',
|
|
24
|
+
downloadDiagramSvg: 'SVG',
|
|
25
|
+
downloadDiagramMmd: 'MMD',
|
|
26
|
+
zoomIn: 'Zoom in',
|
|
27
|
+
zoomOut: 'Zoom out',
|
|
28
|
+
resetView: 'Zoom to fit',
|
|
29
|
+
fullscreen: 'Expand diagram',
|
|
30
|
+
exitFullscreen: 'Collapse diagram',
|
|
31
|
+
diagram: 'Diagram',
|
|
32
|
+
previousCitation: 'Previous citation',
|
|
33
|
+
nextCitation: 'Next citation',
|
|
34
|
+
blockedUrl: 'Blocked URL',
|
|
35
|
+
imageBlocked: 'Image blocked',
|
|
36
|
+
imageNoDescription: 'No description',
|
|
37
|
+
linkBlocked: 'blocked'
|
|
38
|
+
}
|
|
39
|
+
};
|
|
2
40
|
export class StreamdownContext {
|
|
3
41
|
footnotes = {
|
|
4
42
|
refs: new Map(),
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { default as Streamdown } from './Streamdown.svelte';
|
|
2
|
-
export { useStreamdown, type StreamdownProps } from './context.svelte.js';
|
|
2
|
+
export { useStreamdown, defaultTranslations, type StreamdownProps, type Translations, type Controls, type CodeControls, type TableControls, type MermaidControls, type ResolvedControls } from './context.svelte.js';
|
|
3
|
+
export { extractTableData, tableDataToCSV, tableDataToTSV, tableDataToMarkdown, tableDataToHTML, type TableData, type CsvSeparator } from './utils/table-export.js';
|
|
3
4
|
export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
|
|
4
5
|
export { type CodeToken, type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
|
|
5
6
|
export { parseIncompleteMarkdown, type Plugin, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { default as Streamdown } from './Streamdown.svelte';
|
|
2
|
-
export { useStreamdown } from './context.svelte.js';
|
|
2
|
+
export { useStreamdown, defaultTranslations } from './context.svelte.js';
|
|
3
|
+
export { extractTableData, tableDataToCSV, tableDataToTSV, tableDataToMarkdown, tableDataToHTML } from './utils/table-export.js';
|
|
3
4
|
export { theme, shadcnTheme, mergeTheme } from './theme.js';
|
|
4
5
|
export { lex, parseBlocks } from './marked/index.js';
|
|
5
6
|
export { parseIncompleteMarkdown, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
|
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,15 +145,17 @@ 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
|
|
152
153
|
// line becoming a table once its delimiter row arrives, or a setext heading.
|
|
153
154
|
const SEAL_SLACK = 2;
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
155
|
+
// Stride spot checks over the sealed prefix per append (see `appendable`), on
|
|
156
|
+
// top of the per-block-start probes. The sealed region was compared
|
|
157
|
+
// byte-for-byte while it was live, so this only has to catch a caller that
|
|
158
|
+
// swapped in a different, longer document.
|
|
157
159
|
const SEAL_PROBES = 16;
|
|
158
160
|
/**
|
|
159
161
|
* A Lexer used only to slice a document into top-level block raws.
|
|
@@ -185,23 +187,37 @@ const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(e
|
|
|
185
187
|
* Is `markdown` an append to `cache.content`?
|
|
186
188
|
*
|
|
187
189
|
* The live region [offset, cache.content.length) is compared exactly — that is
|
|
188
|
-
* the only region whose segmentation can still change. The sealed prefix
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
190
|
+
* the only region whose segmentation can still change. The sealed prefix is only
|
|
191
|
+
* sampled, never scanned: a complete `markdown.startsWith(cache.content)` was
|
|
192
|
+
* 93% of parseBlocks' streaming cost (676 ms of 727 ms over a 100 KB /
|
|
193
|
+
* 5028-chunk stream) and is the single reason the append path was O(N) per chunk
|
|
194
|
+
* rather than O(tail).
|
|
195
|
+
*
|
|
196
|
+
* Two families of samples, both O(sealed blocks) and not O(sealed characters):
|
|
197
|
+
* the first character of every sealed block (`cache.offsets` already holds those
|
|
198
|
+
* positions), which catches any edit that moves a block boundary or rewrites a
|
|
199
|
+
* block's first character — the shape a real out-of-band edit takes; plus
|
|
200
|
+
* SEAL_PROBES evenly spaced characters, which catch a wholesale document swap.
|
|
201
|
+
* A same-length edit in the middle of a sealed block can still slip through; see
|
|
202
|
+
* the streaming contract in the README.
|
|
193
203
|
*
|
|
194
204
|
* Note this compares source against source, not source against `raws`: marked's
|
|
195
205
|
* tokenizers normalize (e.g. the list tokenizer rewrites a trailing space as a
|
|
196
206
|
* newline), so raws are not always literal slices of the input.
|
|
197
207
|
*/
|
|
198
|
-
const appendable = (markdown,
|
|
208
|
+
const appendable = (markdown, cache, cut, offset) => {
|
|
209
|
+
const content = cache.content;
|
|
199
210
|
for (let i = offset; i < content.length; i++) {
|
|
200
211
|
if (markdown.charCodeAt(i) !== content.charCodeAt(i))
|
|
201
212
|
return false;
|
|
202
213
|
}
|
|
203
214
|
if (offset === 0)
|
|
204
215
|
return true;
|
|
216
|
+
for (let b = 0; b < cut; b++) {
|
|
217
|
+
const i = cache.offsets[b];
|
|
218
|
+
if (markdown.charCodeAt(i) !== content.charCodeAt(i))
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
205
221
|
const step = offset > SEAL_PROBES ? offset / SEAL_PROBES : 1;
|
|
206
222
|
for (let f = 0; f < offset; f += step) {
|
|
207
223
|
const i = f | 0;
|
|
@@ -210,7 +226,23 @@ const appendable = (markdown, content, offset) => {
|
|
|
210
226
|
}
|
|
211
227
|
return markdown.charCodeAt(offset - 1) === content.charCodeAt(offset - 1);
|
|
212
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
|
+
};
|
|
213
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;
|
|
214
246
|
if (cache && cache.content.length > 0 && markdown.length > cache.content.length) {
|
|
215
247
|
// Append-only update: seal everything except the last SEAL_SLACK rendered
|
|
216
248
|
// blocks and re-lex only the tail. offsets[] are prefix sums over raws, so
|
|
@@ -223,7 +255,8 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
223
255
|
liveBlocks++;
|
|
224
256
|
}
|
|
225
257
|
const offset = cache.offsets[cut];
|
|
226
|
-
if (appendable(markdown, cache
|
|
258
|
+
if (appendable(markdown, cache, cut, offset)) {
|
|
259
|
+
isAppend = true;
|
|
227
260
|
const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
|
|
228
261
|
let tailLength = 0;
|
|
229
262
|
for (const token of tailTokens)
|
|
@@ -253,6 +286,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
253
286
|
cache.keptBefore.push(kept);
|
|
254
287
|
}
|
|
255
288
|
cache.content = markdown;
|
|
289
|
+
cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
|
|
256
290
|
// Copy out: callers (Svelte `$derived`, the perf harness) diff block
|
|
257
291
|
// lists by identity, so handing back the persistent array would read as
|
|
258
292
|
// "nothing changed". slice() is a memcpy with no per-element callback.
|
|
@@ -283,6 +317,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
283
317
|
}
|
|
284
318
|
cache.keptBefore.push(kept);
|
|
285
319
|
}
|
|
320
|
+
cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
|
|
286
321
|
// Only trust the cache for future appends if raws reconstruct the input.
|
|
287
322
|
cache.content = pos === markdown.length ? markdown : '';
|
|
288
323
|
return cache.blocks.slice();
|