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.
Files changed (41) hide show
  1. package/README.md +209 -41
  2. package/dist/Block.svelte +15 -8
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +7 -0
  6. package/dist/Elements/Code.svelte +60 -21
  7. package/dist/Elements/Code.svelte.d.ts +4 -0
  8. package/dist/Elements/Element.svelte +36 -8
  9. package/dist/Elements/Element.svelte.d.ts +2 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +2 -2
  13. package/dist/Elements/Mermaid.svelte +65 -13
  14. package/dist/Elements/Mermaid.svelte.d.ts +3 -0
  15. package/dist/Elements/MermaidDownload.svelte +29 -8
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +57 -83
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +25 -4
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +5 -2
  21. package/dist/Elements/fallbacks/MermaidFallback.svelte.d.ts +2 -0
  22. package/dist/Elements/icons.js +10 -1
  23. package/dist/Elements/srOnly.d.ts +1 -0
  24. package/dist/Elements/srOnly.js +3 -0
  25. package/dist/Streamdown.svelte +78 -16
  26. package/dist/context.svelte.d.ts +103 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +6 -0
  31. package/dist/marked/index.js +46 -11
  32. package/dist/marked/marked-math.js +40 -1
  33. package/dist/utils/fence.d.ts +27 -0
  34. package/dist/utils/fence.js +56 -0
  35. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  36. package/dist/utils/parse-incomplete-markdown.js +283 -138
  37. package/dist/utils/table-export.d.ts +14 -0
  38. package/dist/utils/table-export.js +82 -0
  39. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  40. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  41. 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>;
@@ -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 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>
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;';
@@ -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 { StreamdownContext, type StreamdownProps } from './context.svelte.js';
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
- return translations;
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
- const codeControls = controls?.code ?? true;
127
- const mermaid = controls?.mermaid;
128
- const isMermaidObject = typeof mermaid === 'object' && mermaid !== null;
129
- const mermaidControls = isMermaidObject ? (mermaid.enabled ?? true) : (mermaid ?? true);
130
- const mermaidMouseWheelZoom = isMermaidObject ? (mermaid.mouseWheelZoom ?? true) : true;
131
- const tableControls = controls?.table ?? true;
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: codeControls,
134
- mermaid: mermaidControls,
135
- mermaidMouseWheelZoom,
136
- table: tableControls
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 static={isStatic} {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>
@@ -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
- controls: {
12
- code: boolean;
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
- alert?: {
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;
@@ -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';
@@ -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[];
@@ -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
- // 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.
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 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).
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, content, offset) => {
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.content, offset)) {
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();