svelte-streamdown 4.0.0 → 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.
Files changed (45) hide show
  1. package/README.md +211 -38
  2. package/dist/Block.svelte +10 -4
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Alert.svelte +2 -1
  5. package/dist/Elements/Citation.svelte +9 -2
  6. package/dist/Elements/Code.svelte +65 -24
  7. package/dist/Elements/Code.svelte.d.ts +4 -2
  8. package/dist/Elements/Element.svelte +36 -11
  9. package/dist/Elements/Element.svelte.d.ts +1 -0
  10. package/dist/Elements/FootnoteRef.svelte +1 -0
  11. package/dist/Elements/Image.svelte +3 -2
  12. package/dist/Elements/Link.svelte +3 -2
  13. package/dist/Elements/Mermaid.svelte +69 -14
  14. package/dist/Elements/Mermaid.svelte.d.ts +4 -2
  15. package/dist/Elements/MermaidDownload.svelte +30 -9
  16. package/dist/Elements/MermaidDownload.svelte.d.ts +2 -0
  17. package/dist/Elements/TableDownload.svelte +60 -78
  18. package/dist/Elements/fallbacks/CodeFallback.svelte +28 -3
  19. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -0
  20. package/dist/Elements/fallbacks/MermaidFallback.svelte +12 -3
  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 +68 -13
  26. package/dist/context.svelte.d.ts +98 -22
  27. package/dist/context.svelte.js +38 -0
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +2 -1
  30. package/dist/marked/index.d.ts +8 -1
  31. package/dist/marked/index.js +65 -14
  32. package/dist/marked/marked-footnotes.js +6 -2
  33. package/dist/marked/marked-math.js +40 -1
  34. package/dist/marked/marked-subsup.js +16 -3
  35. package/dist/utils/fence.d.ts +16 -0
  36. package/dist/utils/fence.js +39 -0
  37. package/dist/utils/parse-incomplete-markdown.d.ts +5 -1
  38. package/dist/utils/parse-incomplete-markdown.js +347 -122
  39. package/dist/utils/save.js +4 -1
  40. package/dist/utils/table-export.d.ts +14 -0
  41. package/dist/utils/table-export.js +82 -0
  42. package/dist/utils/url.js +6 -2
  43. package/dist/utils/usePinnedScroll.svelte.d.ts +22 -0
  44. package/dist/utils/usePinnedScroll.svelte.js +36 -0
  45. package/package.json +4 -2
@@ -9,9 +9,14 @@
9
9
  import FootnoteRef from './FootnoteRef.svelte';
10
10
  import Citation from './Citation.svelte';
11
11
  import TableDownload from './TableDownload.svelte';
12
+ import { usePinnedScroll } from '../utils/usePinnedScroll.svelte.js';
12
13
  // Import fallback components
13
14
  import { CodeFallback, MermaidFallback, MathFallback } from './fallbacks/index.js';
14
- let { token, children }: { token: StreamdownToken; children: Snippet } = $props();
15
+ let {
16
+ token,
17
+ children,
18
+ incomplete = false
19
+ }: { token: StreamdownToken; children: Snippet; incomplete?: boolean } = $props();
15
20
  const streamdown = useStreamdown();
16
21
 
17
22
  // Use provided components or fallback to lightweight versions
@@ -22,6 +27,17 @@
22
27
  // Only apply animation on block level elements. Leaves text elements to be animated by their text children.
23
28
  const style = $derived(streamdown.isMounted ? streamdown.animationBlockStyle : '');
24
29
  const id = $props.id();
30
+
31
+ // Only ever attached to the table wrapper, which is the element that already
32
+ // owns the horizontal scroll.
33
+ const tableScroll = usePinnedScroll({
34
+ get maxHeight() {
35
+ return streamdown.tableMaxHeight;
36
+ },
37
+ get content() {
38
+ return token.type === 'table' ? token.raw : undefined;
39
+ }
40
+ });
25
41
  </script>
26
42
 
27
43
  {#if token.type === 'heading'}
@@ -71,12 +87,15 @@
71
87
  </blockquote>
72
88
  </Slot>
73
89
  {:else if token.type === 'code' && token.lang === 'mermaid'}
74
- <Slot props={{ children, token }} render={streamdown.snippets.code}>
75
- <MermaidComponent {id} {token} />
90
+ <Slot
91
+ props={{ children, token, incomplete }}
92
+ render={streamdown.snippets.mermaid ?? streamdown.snippets.code}
93
+ >
94
+ <MermaidComponent {id} {token} {incomplete} />
76
95
  </Slot>
77
96
  {:else if token.type === 'code'}
78
- <Slot props={{ children, token }} render={streamdown.snippets.code}>
79
- <CodeComponent {id} {token} />
97
+ <Slot props={{ children, token, incomplete }} render={streamdown.snippets.code}>
98
+ <CodeComponent {id} {token} {incomplete} />
80
99
  </Slot>
81
100
  {:else if token.type === 'codespan'}
82
101
  <Slot props={{ children, token }} render={streamdown.snippets.codespan}>
@@ -124,7 +143,7 @@
124
143
  </Slot>
125
144
  {:else if token.type === 'table'}
126
145
  <Slot props={{ token, children }} render={streamdown.snippets.table}>
127
- {#if streamdown.controls.table}
146
+ {#if streamdown.controls.tableCopy || streamdown.controls.tableDownload}
128
147
  <TableDownload {id} {token} />
129
148
  {/if}
130
149
  <div
@@ -132,6 +151,9 @@
132
151
  {style}
133
152
  class={`${streamdown.theme.table.base} group`}
134
153
  style:overscroll-behavior-x="none"
154
+ style:max-height={streamdown.tableMaxHeight}
155
+ style:overflow-y={streamdown.tableMaxHeight ? 'auto' : undefined}
156
+ {@attach tableScroll}
135
157
  >
136
158
  <table class={streamdown.theme.table.table}>
137
159
  {@render children()}
@@ -288,12 +310,11 @@
288
310
  {@render children()}
289
311
  </dd>
290
312
  </Slot>
291
- {:else if token.type === 'def'}
292
- <!-- TODO This does not seems to be tokenized for now -->
313
+ {:else if token.type === 'def' || token.type === 'space'}
314
+ <!-- Link reference definitions and blank lines produce no output (CommonMark) -->
293
315
  {:else if token.type === 'escape'}
294
- <!-- TODO This does not seems to be tokenized for now -->
295
- {:else if token.type === 'space'}
296
- <!-- TODO This does not seems to be tokenized for now -->
316
+ <!-- `children` renders token.text, i.e. the escaped character itself -->
317
+ {@render children()}
297
318
  {:else if token.type === 'text'}
298
319
  {@render children()}
299
320
  {:else if token.type === 'html'}
@@ -301,6 +322,10 @@
301
322
  {@const content =
302
323
  typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
303
324
  {@html content}
325
+ {:else}
326
+ <!-- Without renderHtml the source is shown literally instead of being dropped;
327
+ `children` interpolates it as text, so Svelte escapes it — no XSS surface -->
328
+ {@render children()}
304
329
  {/if}
305
330
  {:else if token.type === 'mdx'}
306
331
  {@const Component = streamdown.mdxComponents?.[token.tagName]}
@@ -3,6 +3,7 @@ import type { StreamdownToken } from '../marked/index.js';
3
3
  type $$ComponentProps = {
4
4
  token: StreamdownToken;
5
5
  children: Snippet;
6
+ incomplete?: boolean;
6
7
  };
7
8
  declare const Element: import("svelte").Component<$$ComponentProps, {}, "">;
8
9
  type Element = ReturnType<typeof Element>;
@@ -74,6 +74,7 @@
74
74
  aria-expanded={popover.isOpen}
75
75
  aria-haspopup="dialog"
76
76
  aria-controls={'footnote-popover-' + id}
77
+ type="button"
77
78
  {@attach clickOutside.attachment}
78
79
  >
79
80
  {token.label.replace('^', '')}
@@ -51,9 +51,10 @@
51
51
  <span
52
52
  data-streamdown-image-blocked={id}
53
53
  class="inline-block rounded bg-gray-200 px-3 py-1 text-sm text-gray-600 dark:bg-gray-700 dark:text-gray-400"
54
- title={`Blocked URL: ${token.href}`}
54
+ title={`${streamdown.translations.controls.blockedUrl}: ${token.href}`}
55
55
  >
56
- [Image blocked: {token.text || 'No description'}]
56
+ [{streamdown.translations.controls.imageBlocked}: {token.text ||
57
+ streamdown.translations.controls.imageNoDescription}]
57
58
  </span>
58
59
  {/if}
59
60
  {/if}
@@ -39,6 +39,7 @@
39
39
  <a
40
40
  data-streamdown-link={id}
41
41
  class={streamdown.theme.link.base}
42
+ title={token.title}
42
43
  {...isRelativeUrl
43
44
  ? { href: token.href }
44
45
  : { href: transformedUrl, target: '_blank', rel: 'noopener noreferrer' }}
@@ -50,8 +51,8 @@
50
51
  <span
51
52
  data-streamdown-link-blocked={id}
52
53
  class={streamdown.theme.link.blocked}
53
- title={token.title ? `Blocked URL: ${token.href}` : undefined}
54
+ title={`${streamdown.translations.controls.blockedUrl}: ${token.href}`}
54
55
  >
55
- {@render children()} [blocked]
56
+ {@render children()} [{streamdown.translations.controls.linkBlocked}]
56
57
  </span>
57
58
  {/if}
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
3
  import { useStreamdown } from '../context.svelte.js';
4
- import type { Tokens } from 'marked';
4
+ import type { CodeToken } from '../marked/index.js';
5
5
  import type { MermaidConfig } from 'mermaid';
6
6
  import { on } from 'svelte/events';
7
7
  import { usePanzoom } from '../utils/panzoom.svelte';
@@ -12,12 +12,29 @@
12
12
 
13
13
  const {
14
14
  token,
15
- id
15
+ id,
16
+ incomplete = false
16
17
  }: {
17
- token: Tokens.Code;
18
+ token: CodeToken;
18
19
  id: string;
20
+ /** The fence is still being streamed; nothing below it is final yet. */
21
+ incomplete?: boolean;
19
22
  } = $props();
20
23
 
24
+ // Trailing blank lines are noise for mermaid but they still changed `token.text`
25
+ // on nearly every streamed chunk, which re-ran the whole render. Same trim as
26
+ // Code.svelte.
27
+ const chart = $derived(token.text.replace(/\n+$/, ''));
28
+
29
+ // Screen readers get the diagram's declaration line ('flowchart TD', 'sequenceDiagram'),
30
+ // which is the only human-readable summary the source offers.
31
+ const firstLine = $derived(chart.split('\n', 1)[0].trim());
32
+ const diagramLabel = $derived(
33
+ firstLine
34
+ ? `${streamdown.translations.controls.diagram}: ${firstLine}`
35
+ : streamdown.translations.controls.diagram
36
+ );
37
+
21
38
  let mermaid = $state<any>(null);
22
39
  onMount(async () => {
23
40
  mermaid = (await import('mermaid')).default;
@@ -25,7 +42,7 @@
25
42
 
26
43
  const useIsInsideForMoreThanAQuarterSecond = () => {
27
44
  let isInside = $state(false);
28
- let timeout: number | undefined = undefined;
45
+ let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
29
46
 
30
47
  return {
31
48
  get isInside() {
@@ -62,6 +79,19 @@
62
79
  }
63
80
  });
64
81
 
82
+ const expandLabel = $derived(
83
+ panzoom.expanded
84
+ ? streamdown.translations.controls.exitFullscreen
85
+ : streamdown.translations.controls.fullscreen
86
+ );
87
+
88
+ let container = $state<HTMLDivElement>();
89
+ $effect(() => {
90
+ // Expanding covers the page: move focus in so Escape (handled by panzoom)
91
+ // and the toolbar are reachable from the keyboard.
92
+ if (panzoom.expanded) container?.focus();
93
+ });
94
+
65
95
  const sanitizeMermaidCode = (code: string): string => {
66
96
  try {
67
97
  let sanitized = code;
@@ -194,10 +224,15 @@
194
224
  svgTarget.innerHTML = svgString;
195
225
  svgTarget.id = uniqueId;
196
226
 
197
- // Apply any additional attributes from the rendered SVG
227
+ // Apply any additional attributes from the rendered SVG. The accessibility
228
+ // attributes are ours: mermaid's root carries role="graphics-document" and
229
+ // an aria-roledescription, which would overwrite the label a screen reader
230
+ // needs (and did, non-deterministically, depending on when the lazy import
231
+ // resolved).
232
+ const keepOwn = new Set(['id', 'role', 'aria-label', 'aria-roledescription']);
198
233
  const tempSvg = new DOMParser().parseFromString(svgString, 'image/svg+xml').documentElement;
199
234
  Array.from(tempSvg.attributes).forEach((attribute) => {
200
- if (attribute.name !== 'id') {
235
+ if (!keepOwn.has(attribute.name)) {
201
236
  svgTarget.setAttribute(attribute.name, attribute.value);
202
237
  }
203
238
  });
@@ -212,20 +247,31 @@
212
247
  };
213
248
  </script>
214
249
 
215
- <div data-streamdown-mermaid={id}>
250
+ <div data-streamdown-mermaid={id} data-incomplete={incomplete || undefined}>
216
251
  {#if mermaid}
217
252
  <div
253
+ bind:this={container}
218
254
  style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
219
255
  class={streamdown.theme.mermaid.base}
220
- {@attach (node) => renderMermaid(token.text, node)}
256
+ {@attach (node) => {
257
+ // A half-written diagram makes mermaid throw and log on every chunk, so
258
+ // skip the render entirely and keep showing whatever rendered last.
259
+ if (!incomplete) renderMermaid(chart, node);
260
+ }}
221
261
  {@attach insider.attach}
222
262
  data-expanded={'false'}
263
+ role={panzoom.expanded ? 'dialog' : undefined}
264
+ aria-modal={panzoom.expanded ? 'true' : undefined}
265
+ aria-label={panzoom.expanded ? diagramLabel : undefined}
266
+ tabindex="-1"
223
267
  >
224
268
  {#if streamdown.controls.mermaid}
225
269
  <div class={streamdown.theme.mermaid.buttons}>
226
270
  <button
227
271
  class={streamdown.theme.components.button}
228
- aria-label="Zoom to fit"
272
+ aria-label={streamdown.translations.controls.resetView}
273
+ title={streamdown.translations.controls.resetView}
274
+ type="button"
229
275
  onclick={() => panzoom.zoomToFit()}
230
276
  data-panzoom-ignore
231
277
  >
@@ -233,7 +279,9 @@
233
279
  </button>
234
280
  <button
235
281
  class={streamdown.theme.components.button}
236
- aria-label="Zoom in"
282
+ aria-label={streamdown.translations.controls.zoomIn}
283
+ title={streamdown.translations.controls.zoomIn}
284
+ type="button"
237
285
  onclick={() => panzoom.zoomIn()}
238
286
  data-panzoom-ignore
239
287
  >
@@ -241,7 +289,9 @@
241
289
  </button>
242
290
  <button
243
291
  class={streamdown.theme.components.button}
244
- aria-label="Zoom out"
292
+ aria-label={streamdown.translations.controls.zoomOut}
293
+ title={streamdown.translations.controls.zoomOut}
294
+ type="button"
245
295
  onclick={() => panzoom.zoomOut()}
246
296
  data-panzoom-ignore
247
297
  >
@@ -249,16 +299,21 @@
249
299
  </button>
250
300
  <button
251
301
  class={streamdown.theme.components.button}
252
- aria-label="Toggle expand"
302
+ aria-label={expandLabel}
303
+ title={expandLabel}
304
+ aria-pressed={panzoom.expanded}
305
+ type="button"
253
306
  onclick={() => panzoom.toggleExpand()}
254
307
  data-panzoom-ignore
255
308
  >
256
309
  {@render (streamdown.icons?.fullscreen || fullscreenIcon)()}
257
310
  </button>
258
- <MermaidDownload {id} />
311
+ {#if streamdown.controls.mermaidDownload}
312
+ <MermaidDownload {token} {id} />
313
+ {/if}
259
314
  </div>
260
315
  {/if}
261
- <svg {@attach panzoom.attach} data-mermaid-svg></svg>
316
+ <svg {@attach panzoom.attach} data-mermaid-svg role="img" aria-label={diagramLabel}></svg>
262
317
  </div>
263
318
  {:else}
264
319
  <div class={streamdown.theme.mermaid.base}></div>
@@ -1,7 +1,9 @@
1
- import type { Tokens } from 'marked';
1
+ import type { CodeToken } from '../marked/index.js';
2
2
  type $$ComponentProps = {
3
- token: Tokens.Code;
3
+ token: CodeToken;
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 Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
7
9
  type Mermaid = ReturnType<typeof Mermaid>;
@@ -6,16 +6,24 @@
6
6
  import { useClickOutside } from '../utils/useClickOutside.svelte.js';
7
7
  import { useKeyDown } from '../utils/useKeyDown.svelte.js';
8
8
  import { save } from '../utils/save.js';
9
+ import type { CodeToken } from '../marked/index.js';
9
10
 
10
11
  let {
12
+ token,
11
13
  id
12
14
  }: {
15
+ token: CodeToken;
13
16
  id: string;
14
17
  } = $props();
15
18
 
16
19
  const streamdown = useStreamdown();
17
20
  const popover = new Popover();
18
21
 
22
+ const filename = (extension: string) => {
23
+ const base = streamdown.controls.mermaidDownloadFilename;
24
+ return `${typeof base === 'function' ? base(token) : base}.${extension}`;
25
+ };
26
+
19
27
  useKeyDown({
20
28
  keys: ['Escape'],
21
29
  get isActive() {
@@ -68,7 +76,7 @@
68
76
  }
69
77
 
70
78
  const svgString = new XMLSerializer().serializeToString(clonedSvg);
71
- save('mermaid-diagram.svg', svgString, 'image/svg+xml');
79
+ save(filename('svg'), svgString, 'image/svg+xml');
72
80
  popover.isOpen = false;
73
81
  };
74
82
 
@@ -128,7 +136,7 @@
128
136
  const url = URL.createObjectURL(blob);
129
137
  const link = document.createElement('a');
130
138
  link.href = url;
131
- link.download = 'mermaid-diagram.png';
139
+ link.download = filename('png');
132
140
  document.body.appendChild(link);
133
141
  link.click();
134
142
  document.body.removeChild(link);
@@ -145,18 +153,28 @@
145
153
  popover.isOpen = false;
146
154
  };
147
155
 
148
- const download = (type: 'SVG' | 'PNG') => {
156
+ const formats = $derived([
157
+ { type: 'PNG', label: streamdown.translations.controls.downloadDiagramPng },
158
+ { type: 'SVG', label: streamdown.translations.controls.downloadDiagramSvg },
159
+ { type: 'MMD', label: streamdown.translations.controls.downloadDiagramMmd }
160
+ ] as const);
161
+
162
+ const download = (type: 'SVG' | 'PNG' | 'MMD') => {
149
163
  if (type === 'SVG') {
150
164
  downloadSvg();
151
- } else {
165
+ } else if (type === 'PNG') {
152
166
  downloadPng();
167
+ } else {
168
+ // The diagram source, same trailing-newline trim as the rendered chart.
169
+ save(filename('mmd'), token.text.replace(/\n+$/, ''), 'text/plain');
170
+ popover.isOpen = false;
153
171
  }
154
172
  };
155
173
  </script>
156
174
 
157
175
  {#if popover.isOpen}
158
176
  <dialog
159
- id={'mermaid-download-popover'}
177
+ id={'mermaid-download-popover-' + id}
160
178
  aria-modal="false"
161
179
  transition:scale|global={{ start: 0.95, duration: 100 }}
162
180
  {@attach clickOutside.attachment}
@@ -166,13 +184,14 @@
166
184
  style:min-width="fit-content !important"
167
185
  class={streamdown.theme.components.popover}
168
186
  >
169
- {#each ['PNG', 'SVG'] as type}
187
+ {#each formats as { type, label } (type)}
170
188
  <button
171
189
  style="width: 100%; text-align: left; justify-content: flex-start; padding: 1rem 1rem; margin: 0.2rem 0;"
172
- onclick={() => download(type as 'SVG' | 'PNG')}
190
+ onclick={() => download(type)}
173
191
  class={streamdown.theme.components.button}
192
+ type="button"
174
193
  >
175
- {type}
194
+ {label}
176
195
  </button>
177
196
  {/each}
178
197
  </dialog>
@@ -189,7 +208,9 @@
189
208
  popover.isOpen = true;
190
209
  }}
191
210
  {@attach clickOutside.attachment}
192
- title="Download diagram"
211
+ type="button"
212
+ title={streamdown.translations.controls.downloadDiagram}
213
+ aria-label={streamdown.translations.controls.downloadDiagram}
193
214
  data-panzoom-ignore
194
215
  >
195
216
  {@render (streamdown.icons?.download || downloadIcon)()}
@@ -1,4 +1,6 @@
1
+ import type { CodeToken } from '../marked/index.js';
1
2
  type $$ComponentProps = {
3
+ token: CodeToken;
2
4
  id: string;
3
5
  };
4
6
  declare const MermaidDownload: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -8,6 +8,10 @@
8
8
  import type { TableToken } from '../marked/marked-table.js';
9
9
  import { useCopy } from '../utils/copy.svelte.js';
10
10
  import { save } from '../utils/save.js';
11
+ import { srOnly } from './srOnly.js';
12
+ import { extractTableData, tableDataToCSV, tableDataToTSV } from '../utils/table-export.js';
13
+
14
+ type Format = 'Markdown' | 'HTML' | 'CSV' | 'TSV';
11
15
 
12
16
  let {
13
17
  token,
@@ -19,6 +23,11 @@
19
23
  const streamdown = useStreamdown();
20
24
  const popover = new Popover();
21
25
  let modeState = $state<'download' | 'copy'>('download');
26
+ const modes = $derived(
27
+ (['download', 'copy'] as const).filter((mode) =>
28
+ mode === 'download' ? streamdown.controls.tableDownload : streamdown.controls.tableCopy
29
+ )
30
+ );
22
31
 
23
32
  useKeyDown({
24
33
  keys: ['Escape'],
@@ -46,22 +55,41 @@
46
55
  }
47
56
  });
48
57
 
49
- const copyOrDownload = (type: 'Markdown' | 'HTML' | 'CSV') => {
58
+ const filename = (extension: string) => {
59
+ const base = streamdown.controls.tableDownloadFilename;
60
+ return `${typeof base === 'function' ? base(token) : base}.${extension}`;
61
+ };
62
+
63
+ const formats = $derived([
64
+ { type: 'Markdown', label: streamdown.translations.controls.tableFormatMarkdown },
65
+ { type: 'HTML', label: streamdown.translations.controls.tableFormatHtml },
66
+ { type: 'CSV', label: streamdown.translations.controls.tableFormatCsv },
67
+ { type: 'TSV', label: streamdown.translations.controls.tableFormatTsv }
68
+ ] as const satisfies readonly { type: Format; label: string }[]);
69
+
70
+ const emit = (content: string, extension: string, mimeType: string) => {
71
+ copyValue = content;
72
+ if (modeState === 'copy') {
73
+ copy.copy();
74
+ } else {
75
+ save(filename(extension), content, mimeType);
76
+ }
77
+ };
78
+
79
+ const tableElement = () => document.querySelector(`[data-streamdown-table="${id}"]`);
80
+
81
+ const copyOrDownload = (type: Format) => {
50
82
  if (type === 'Markdown') {
51
- copyValue = token.raw;
52
- if (modeState === 'copy') {
53
- copy.copy();
54
- } else {
55
- save('table.md', copyValue, 'text/markdown');
56
- }
83
+ // `token.raw` is the author's own markdown; a DOM round-trip through
84
+ // tableDataToMarkdown() would lose the inline formatting.
85
+ emit(token.raw, 'md', 'text/markdown');
57
86
  } else if (type === 'HTML') {
58
- const table = document.querySelector(`[data-streamdown-table=${id}]`);
87
+ const table = tableElement();
59
88
 
60
89
  if (table) {
61
90
  let html = (table.cloneNode(true) as HTMLElement).outerHTML;
62
91
  // remove comments
63
92
  html = html.replace(/<!--[\s\S]*?-->/g, '');
64
- copyValue = html;
65
93
  // Remove class and style attributes
66
94
  html = html.replace(/class="[^"]*"/g, '');
67
95
  html = html.replace(/style="[^"]*"/g, '');
@@ -74,73 +102,17 @@
74
102
  // Collapse multiple spaces within tags to single space
75
103
  html = html.replace(/<([^>]+)>/g, (match) => match.replace(/\s+/g, ' '));
76
104
 
77
- copyValue = html;
78
-
79
- if (modeState === 'copy') {
80
- copy.copy();
81
- } else {
82
- save('table.html', copyValue, 'text/html');
83
- }
105
+ emit(html, 'html', 'text/html');
84
106
  }
85
- } else if (type === 'CSV') {
86
- const table = document.querySelector(`[data-streamdown-table=${id}]`);
107
+ } else {
108
+ const table = tableElement();
87
109
 
88
110
  if (table) {
89
- const rows = table.querySelectorAll('tr');
90
- const rowSpanFills: Array<{ rowIndex: number; colIndex: number; colSpan: number }> = [];
91
-
92
- const matrix = Array.from(rows).reduce((acc, row, rowIndex) => {
93
- const cells = row.querySelectorAll('td, th');
94
- const rowData: string[] = [];
95
- let actualCol = 0; // Track actual column position in the output matrix
96
-
97
- Array.from(cells).forEach((cell) => {
98
- const colSpan = parseInt(cell.getAttribute('colspan') || '1');
99
- const rowSpan = parseInt(cell.getAttribute('rowspan') || '1');
100
-
101
- // Add the cell content
102
- // Add the cell content, quoting if it contains commas, quotes, or newlines
103
- const content = cell.textContent || '';
104
- const needsQuoting = /[,"\n]/.test(content);
105
- const escapedContent = content.replace(/"/g, '""');
106
- rowData.push(needsQuoting ? `"${escapedContent}"` : content);
107
-
108
- // Add empty cells for colspan
109
- for (let i = 0; i < colSpan - 1; i++) {
110
- rowData.push('');
111
- }
112
-
113
- // Track rowspan fills needed in future rows
114
- if (rowSpan > 1) {
115
- for (let r = 1; r < rowSpan; r++) {
116
- rowSpanFills.push({
117
- rowIndex: rowIndex + r,
118
- colIndex: actualCol,
119
- colSpan: colSpan
120
- });
121
- }
122
- }
123
-
124
- actualCol += colSpan;
125
- });
126
-
127
- acc.push(rowData);
128
- return acc;
129
- }, [] as string[][]);
130
-
131
- // Process rowspan fills - insert empty cells at correct positions
132
- rowSpanFills.forEach(({ rowIndex, colIndex, colSpan }) => {
133
- if (matrix[rowIndex]) {
134
- matrix[rowIndex].splice(colIndex, 0, ...Array(colSpan).fill(''));
135
- }
136
- });
137
-
138
- const csv = matrix.map((row) => row.join(',')).join('\n');
139
- copyValue = csv;
140
- if (modeState === 'copy') {
141
- copy.copy();
111
+ const data = extractTableData(table);
112
+ if (type === 'CSV') {
113
+ emit(tableDataToCSV(data, streamdown.controls.tableCsvSeparator), 'csv', 'text/csv');
142
114
  } else {
143
- save('table.csv', copyValue, 'text/csv');
115
+ emit(tableDataToTSV(data), 'tsv', 'text/tab-separated-values');
144
116
  }
145
117
  }
146
118
  }
@@ -150,7 +122,7 @@
150
122
 
151
123
  {#if popover.isOpen}
152
124
  <dialog
153
- id={'table-download-popover'}
125
+ id={'table-download-popover-' + id}
154
126
  aria-modal="false"
155
127
  transition:scale|global={{ start: 0.95, duration: 100 }}
156
128
  {@attach clickOutside.attachment}
@@ -160,13 +132,14 @@
160
132
  style:min-width="fit-content !important"
161
133
  class={streamdown.theme.components.popover}
162
134
  >
163
- {#each ['Markdown', 'HTML', 'CSV'] as type}
135
+ {#each formats as { type, label } (type)}
164
136
  <button
165
137
  style="width: 100%; text-align: left; justify-content: flex-start; padding: 1rem 1rem; margin: 0.2rem 0;"
166
- onclick={() => copyOrDownload(type as 'Markdown' | 'HTML' | 'CSV')}
138
+ onclick={() => copyOrDownload(type)}
167
139
  class={streamdown.theme.components.button}
140
+ type="button"
168
141
  >
169
- {type}
142
+ {label}
170
143
  </button>
171
144
  {/each}
172
145
  </dialog>
@@ -176,7 +149,7 @@
176
149
  data-streamdown-table-download
177
150
  class=" right-0 ml-auto flex items-center justify-end gap-2 p-1"
178
151
  >
179
- {#each ['download', 'copy'] as mode (mode)}
152
+ {#each modes as mode (mode)}
180
153
  <button
181
154
  class={streamdown.theme.components.button}
182
155
  onclick={async (e: MouseEvent) => {
@@ -196,7 +169,13 @@
196
169
  modeState = mode as 'download' | 'copy';
197
170
  }}
198
171
  {@attach clickOutside.attachment}
199
- title={mode === 'download' ? 'Download table' : 'Copy table'}
172
+ type="button"
173
+ title={mode === 'download'
174
+ ? streamdown.translations.controls.downloadTable
175
+ : streamdown.translations.controls.copyTable}
176
+ aria-label={mode === 'download'
177
+ ? streamdown.translations.controls.downloadTable
178
+ : streamdown.translations.controls.copyTable}
200
179
  >
201
180
  {#if mode === 'download'}
202
181
  {@render (streamdown.icons?.download || downloadIcon)()}
@@ -207,6 +186,9 @@
207
186
  {/if}
208
187
  </button>
209
188
  {/each}
189
+ <span aria-live="polite" style={srOnly}
190
+ >{copy.isCopied ? streamdown.translations.controls.copiedTable : ''}</span
191
+ >
210
192
  </div>
211
193
 
212
194
  <style>