svelte-streamdown 4.0.1 → 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 +206 -41
- package/dist/Block.svelte +5 -2
- 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 +55 -19
- package/dist/Elements/Code.svelte.d.ts +2 -0
- package/dist/Elements/Element.svelte +28 -6
- 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 +2 -2
- package/dist/Elements/Mermaid.svelte +62 -12
- package/dist/Elements/Mermaid.svelte.d.ts +2 -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 +21 -2
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -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 +68 -13
- package/dist/context.svelte.d.ts +98 -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.js +25 -10
- package/dist/marked/marked-math.js +40 -1
- 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 +281 -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
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,
|
|
@@ -94,7 +101,11 @@
|
|
|
94
101
|
return renderHtml;
|
|
95
102
|
},
|
|
96
103
|
get translations() {
|
|
97
|
-
|
|
104
|
+
// Resolved once, like the theme, so components never fall back themselves.
|
|
105
|
+
return {
|
|
106
|
+
alert: { ...defaultTranslations.alert, ...translations?.alert },
|
|
107
|
+
controls: { ...defaultTranslations.controls, ...translations?.controls }
|
|
108
|
+
};
|
|
98
109
|
},
|
|
99
110
|
get highlightLanguages() {
|
|
100
111
|
return highlightLanguages;
|
|
@@ -123,19 +134,53 @@
|
|
|
123
134
|
};
|
|
124
135
|
},
|
|
125
136
|
get controls() {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
137
|
+
// `controls` is sugar: `false` turns every control off, `true`/undefined
|
|
138
|
+
// turns them all on, and each section is a boolean or an object of the
|
|
139
|
+
// same shape. Flatten it once here so components stay boolean lookups.
|
|
140
|
+
const sections = typeof controls === 'object' && controls !== null ? controls : {};
|
|
141
|
+
const resolve = (section: boolean | Record<string, any> | undefined) => {
|
|
142
|
+
const config = typeof section === 'object' && section !== null ? section : {};
|
|
143
|
+
const enabled = controls === false || section === false ? false : (config.enabled ?? true);
|
|
144
|
+
const download = config.download;
|
|
145
|
+
return {
|
|
146
|
+
enabled,
|
|
147
|
+
copy: enabled && config.copy !== false,
|
|
148
|
+
download: enabled && download !== false,
|
|
149
|
+
filename:
|
|
150
|
+
typeof download === 'object' && download !== null ? download.filename : undefined
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
const code = resolve(sections.code);
|
|
154
|
+
const table = resolve(sections.table);
|
|
155
|
+
const mermaid = resolve(sections.mermaid);
|
|
156
|
+
const tableSection =
|
|
157
|
+
typeof sections.table === 'object' && sections.table ? sections.table : {};
|
|
158
|
+
const mermaidSection =
|
|
159
|
+
typeof sections.mermaid === 'object' && sections.mermaid ? sections.mermaid : {};
|
|
132
160
|
return {
|
|
133
|
-
code:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
161
|
+
code: code.enabled,
|
|
162
|
+
codeCopy: code.copy,
|
|
163
|
+
codeDownload: code.download,
|
|
164
|
+
codeDownloadFilename: code.filename ?? 'file',
|
|
165
|
+
table: table.enabled,
|
|
166
|
+
tableCopy: table.copy,
|
|
167
|
+
tableDownload: table.download,
|
|
168
|
+
tableDownloadFilename: table.filename ?? 'table',
|
|
169
|
+
tableCsvSeparator: tableSection.csvSeparator ?? ',',
|
|
170
|
+
mermaid: mermaid.enabled,
|
|
171
|
+
mermaidDownload: mermaid.download,
|
|
172
|
+
mermaidDownloadFilename: mermaid.filename ?? 'diagram',
|
|
173
|
+
// Wheel zoom is a gesture, not a button: it stays on unless it is
|
|
174
|
+
// turned off explicitly or every control is.
|
|
175
|
+
mermaidMouseWheelZoom: controls !== false && mermaidSection.mouseWheelZoom !== false
|
|
137
176
|
};
|
|
138
177
|
},
|
|
178
|
+
get codeBlockMaxHeight() {
|
|
179
|
+
return codeBlockMaxHeight;
|
|
180
|
+
},
|
|
181
|
+
get tableMaxHeight() {
|
|
182
|
+
return tableMaxHeight;
|
|
183
|
+
},
|
|
139
184
|
get children() {
|
|
140
185
|
return children;
|
|
141
186
|
},
|
|
@@ -161,6 +206,12 @@
|
|
|
161
206
|
const blocks = $derived(
|
|
162
207
|
isStatic ? content : parseBlocks(content, streamdown.extensions, blocksCache)
|
|
163
208
|
);
|
|
209
|
+
|
|
210
|
+
// Only the tail of a live stream can be mid-fence — a static render is finished
|
|
211
|
+
// by definition. Computed once here, not per block or per token.
|
|
212
|
+
const lastBlockIncomplete = $derived(
|
|
213
|
+
!isStatic && blocks.length > 0 && hasUnclosedFence(blocks[blocks.length - 1])
|
|
214
|
+
);
|
|
164
215
|
</script>
|
|
165
216
|
|
|
166
217
|
<div bind:this={element} class={className}>
|
|
@@ -168,7 +219,11 @@
|
|
|
168
219
|
<Block static={isStatic} block={content} />
|
|
169
220
|
{:else}
|
|
170
221
|
{#each blocks as block, index (`${id}-block-${index}`)}
|
|
171
|
-
<Block
|
|
222
|
+
<Block
|
|
223
|
+
static={isStatic}
|
|
224
|
+
{block}
|
|
225
|
+
incomplete={lastBlockIncomplete && index === blocks.length - 1}
|
|
226
|
+
/>
|
|
172
227
|
{/each}
|
|
173
228
|
{/if}
|
|
174
229
|
</div>
|
package/dist/context.svelte.d.ts
CHANGED
|
@@ -4,16 +4,55 @@ 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;
|
|
10
53
|
theme: Theme;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
mermaid: boolean;
|
|
14
|
-
mermaidMouseWheelZoom: boolean;
|
|
15
|
-
table: boolean;
|
|
16
|
-
};
|
|
54
|
+
translations: Translations;
|
|
55
|
+
controls: ResolvedControls;
|
|
17
56
|
inlineCitationsMode: 'list' | 'carousel';
|
|
18
57
|
animation: {
|
|
19
58
|
enabled: boolean;
|
|
@@ -33,8 +72,9 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
|
|
|
33
72
|
});
|
|
34
73
|
}
|
|
35
74
|
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';
|
|
75
|
+
import type { AlertToken, CodeToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken, MdxToken } from './marked/index.js';
|
|
37
76
|
import type { Tokens } from 'marked';
|
|
77
|
+
import type { CsvSeparator } from './utils/table-export.js';
|
|
38
78
|
import type { ListItemToken, ListToken } from './marked/marked-list.js';
|
|
39
79
|
import type { Footnote, FootnoteRef, FootnoteToken } from './marked/marked-footnotes.js';
|
|
40
80
|
import type { DescriptionDetailToken, DescriptionListToken, DescriptionTermToken, DescriptionToken } from './marked/marked-dl.js';
|
|
@@ -89,9 +129,53 @@ export type Snippets<Source extends Record<string, any> = Record<string, any>> =
|
|
|
89
129
|
key: string;
|
|
90
130
|
} : K extends 'mdx' ? {
|
|
91
131
|
props: Record<string, number | string | boolean | null | undefined>;
|
|
132
|
+
} : K extends 'code' | 'mermaid' ? {
|
|
133
|
+
/** The fence is still streaming: defer expensive work. */
|
|
134
|
+
incomplete: boolean;
|
|
92
135
|
} : {})
|
|
93
136
|
]>;
|
|
94
137
|
};
|
|
138
|
+
/** `filename` is the base name; the extension comes from the format. */
|
|
139
|
+
type DownloadControl<Token> = boolean | {
|
|
140
|
+
filename?: string | ((token: Token) => string);
|
|
141
|
+
};
|
|
142
|
+
export type CodeControls = boolean | {
|
|
143
|
+
enabled?: boolean;
|
|
144
|
+
copy?: boolean;
|
|
145
|
+
download?: DownloadControl<CodeToken>;
|
|
146
|
+
};
|
|
147
|
+
export type TableControls = boolean | {
|
|
148
|
+
enabled?: boolean;
|
|
149
|
+
copy?: boolean;
|
|
150
|
+
download?: DownloadControl<TableToken>;
|
|
151
|
+
csvSeparator?: CsvSeparator;
|
|
152
|
+
};
|
|
153
|
+
export type MermaidControls = boolean | {
|
|
154
|
+
enabled?: boolean;
|
|
155
|
+
download?: DownloadControl<CodeToken>;
|
|
156
|
+
mouseWheelZoom?: boolean;
|
|
157
|
+
};
|
|
158
|
+
export type Controls = boolean | {
|
|
159
|
+
code?: CodeControls;
|
|
160
|
+
table?: TableControls;
|
|
161
|
+
mermaid?: MermaidControls;
|
|
162
|
+
};
|
|
163
|
+
/** What `Streamdown.svelte` flattens `controls` into, so components stay dumb. */
|
|
164
|
+
export type ResolvedControls = {
|
|
165
|
+
code: boolean;
|
|
166
|
+
codeCopy: boolean;
|
|
167
|
+
codeDownload: boolean;
|
|
168
|
+
codeDownloadFilename: string | ((token: CodeToken) => string);
|
|
169
|
+
table: boolean;
|
|
170
|
+
tableCopy: boolean;
|
|
171
|
+
tableDownload: boolean;
|
|
172
|
+
tableDownloadFilename: string | ((token: TableToken) => string);
|
|
173
|
+
tableCsvSeparator: CsvSeparator;
|
|
174
|
+
mermaid: boolean;
|
|
175
|
+
mermaidDownload: boolean;
|
|
176
|
+
mermaidDownloadFilename: string | ((token: CodeToken) => string);
|
|
177
|
+
mermaidMouseWheelZoom: boolean;
|
|
178
|
+
};
|
|
95
179
|
export type StreamdownProps<Source extends Record<string, any> = Record<string, any>> = {
|
|
96
180
|
streamdown?: StreamdownContext;
|
|
97
181
|
static?: boolean;
|
|
@@ -114,23 +198,13 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
114
198
|
highlightThemes?: Record<string, HighlightTheme>;
|
|
115
199
|
mermaidConfig?: MermaidConfig;
|
|
116
200
|
katexConfig?: KatexOptions | ((inline: boolean) => KatexOptions);
|
|
201
|
+
/** Partial overrides; unset keys fall back to `defaultTranslations`. */
|
|
117
202
|
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;
|
|
203
|
+
[K in keyof Translations]?: Partial<Translations[K]>;
|
|
133
204
|
};
|
|
205
|
+
controls?: Controls;
|
|
206
|
+
codeBlockMaxHeight?: string;
|
|
207
|
+
tableMaxHeight?: string;
|
|
134
208
|
renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
|
|
135
209
|
animation?: {
|
|
136
210
|
animateOnMount?: boolean;
|
|
@@ -171,10 +245,12 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
171
245
|
code?: Component<{
|
|
172
246
|
token: Tokens.Code;
|
|
173
247
|
id: string;
|
|
248
|
+
incomplete: boolean;
|
|
174
249
|
}, any, any>;
|
|
175
250
|
mermaid?: Component<{
|
|
176
251
|
token: Tokens.Code;
|
|
177
252
|
id: string;
|
|
253
|
+
incomplete: boolean;
|
|
178
254
|
}, any, any>;
|
|
179
255
|
math?: Component<{
|
|
180
256
|
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.js
CHANGED
|
@@ -151,9 +151,10 @@ export const createParseBlocksCache = () => ({
|
|
|
151
151
|
// 2 covers constructs that merge backward as they stream in — e.g. a paragraph
|
|
152
152
|
// line becoming a table once its delimiter row arrives, or a setext heading.
|
|
153
153
|
const SEAL_SLACK = 2;
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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.
|
|
157
158
|
const SEAL_PROBES = 16;
|
|
158
159
|
/**
|
|
159
160
|
* A Lexer used only to slice a document into top-level block raws.
|
|
@@ -185,23 +186,37 @@ const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(e
|
|
|
185
186
|
* Is `markdown` an append to `cache.content`?
|
|
186
187
|
*
|
|
187
188
|
* 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
|
-
*
|
|
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.
|
|
193
202
|
*
|
|
194
203
|
* Note this compares source against source, not source against `raws`: marked's
|
|
195
204
|
* tokenizers normalize (e.g. the list tokenizer rewrites a trailing space as a
|
|
196
205
|
* newline), so raws are not always literal slices of the input.
|
|
197
206
|
*/
|
|
198
|
-
const appendable = (markdown,
|
|
207
|
+
const appendable = (markdown, cache, cut, offset) => {
|
|
208
|
+
const content = cache.content;
|
|
199
209
|
for (let i = offset; i < content.length; i++) {
|
|
200
210
|
if (markdown.charCodeAt(i) !== content.charCodeAt(i))
|
|
201
211
|
return false;
|
|
202
212
|
}
|
|
203
213
|
if (offset === 0)
|
|
204
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
|
+
}
|
|
205
220
|
const step = offset > SEAL_PROBES ? offset / SEAL_PROBES : 1;
|
|
206
221
|
for (let f = 0; f < offset; f += step) {
|
|
207
222
|
const i = f | 0;
|
|
@@ -223,7 +238,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
|
223
238
|
liveBlocks++;
|
|
224
239
|
}
|
|
225
240
|
const offset = cache.offsets[cut];
|
|
226
|
-
if (appendable(markdown, cache
|
|
241
|
+
if (appendable(markdown, cache, cut, offset)) {
|
|
227
242
|
const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
|
|
228
243
|
let tailLength = 0;
|
|
229
244
|
for (const token of tailTokens)
|
|
@@ -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);
|
|
@@ -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;
|