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
@@ -1,5 +1,8 @@
1
1
  export const save = (filename, content, mimeType) => {
2
- const blob = new Blob([content], { type: mimeType });
2
+ // Excel on Windows assumes the system codepage without a BOM, so accented
3
+ // and CJK text in a downloaded CSV opens as mojibake.
4
+ const body = mimeType.startsWith('text/csv') ? '\uFEFF' + content : content;
5
+ const blob = new Blob([body], { type: mimeType });
3
6
  const url = URL.createObjectURL(blob);
4
7
  const link = document.createElement('a');
5
8
  link.href = url;
@@ -0,0 +1,14 @@
1
+ export type TableData = {
2
+ headers: string[];
3
+ rows: string[][];
4
+ };
5
+ export type CsvSeparator = ',' | ';' | '\t' | 'auto';
6
+ /**
7
+ * Read a rendered table (or any element containing one) into a plain matrix.
8
+ * `colspan`/`rowspan` are expanded into empty cells so every row lines up.
9
+ */
10
+ export declare const extractTableData: (element: Element) => TableData;
11
+ export declare const tableDataToCSV: (data: TableData, separator?: CsvSeparator) => string;
12
+ export declare const tableDataToTSV: (data: TableData) => string;
13
+ export declare const tableDataToMarkdown: (data: TableData) => string;
14
+ export declare const tableDataToHTML: (data: TableData) => string;
@@ -0,0 +1,82 @@
1
+ // textContent flattens a real <br> element, so a multiline cell collapses to
2
+ // 'Paragraph one.Paragraph two.'. Walk the cell instead and keep the breaks;
3
+ // the serializers below quote or escape them.
4
+ const extractCellText = (node) => {
5
+ if (node.nodeType === 3)
6
+ return node.textContent || '';
7
+ if (node.tagName === 'BR')
8
+ return '\n';
9
+ return Array.from(node.childNodes).map(extractCellText).join('');
10
+ };
11
+ /**
12
+ * Read a rendered table (or any element containing one) into a plain matrix.
13
+ * `colspan`/`rowspan` are expanded into empty cells so every row lines up.
14
+ */
15
+ export const extractTableData = (element) => {
16
+ const trs = Array.from(element.querySelectorAll('tr'));
17
+ const rowSpanFills = [];
18
+ const matrix = trs.map((row, rowIndex) => {
19
+ const rowData = [];
20
+ let actualCol = 0; // column position in the output matrix, not in the DOM row
21
+ Array.from(row.querySelectorAll('td, th')).forEach((cell) => {
22
+ const colSpan = parseInt(cell.getAttribute('colspan') || '1');
23
+ const rowSpan = parseInt(cell.getAttribute('rowspan') || '1');
24
+ rowData.push(extractCellText(cell));
25
+ for (let i = 0; i < colSpan - 1; i++) {
26
+ rowData.push('');
27
+ }
28
+ if (rowSpan > 1) {
29
+ for (let r = 1; r < rowSpan; r++) {
30
+ rowSpanFills.push({ rowIndex: rowIndex + r, colIndex: actualCol, colSpan });
31
+ }
32
+ }
33
+ actualCol += colSpan;
34
+ });
35
+ return rowData;
36
+ });
37
+ rowSpanFills.forEach(({ rowIndex, colIndex, colSpan }) => {
38
+ if (matrix[rowIndex]) {
39
+ matrix[rowIndex].splice(colIndex, 0, ...Array(colSpan).fill(''));
40
+ }
41
+ });
42
+ // A markdown table always renders exactly one <thead> row; a DOM-only caller
43
+ // may hand us a table without one, and then everything is a data row.
44
+ const headers = trs[0]?.closest('thead') ? (matrix.shift() ?? []) : [];
45
+ return { headers, rows: matrix };
46
+ };
47
+ const allRows = ({ headers, rows }) => (headers.length ? [headers, ...rows] : rows);
48
+ // Spreadsheets in locales that write 1,1 for one-and-a-tenth read ';' as the
49
+ // column separator; Intl is the only locale source we have without a dependency.
50
+ const resolveSeparator = (separator) => separator === 'auto' ? (Intl.NumberFormat().format(1.1).includes(',') ? ';' : ',') : separator;
51
+ export const tableDataToCSV = (data, separator = ',') => {
52
+ const sep = resolveSeparator(separator);
53
+ const escape = (value) => value.includes(sep) || /["\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
54
+ return allRows(data)
55
+ .map((row) => row.map(escape).join(sep))
56
+ .join('\n');
57
+ };
58
+ export const tableDataToTSV = (data) => {
59
+ // TSV has no quoting, so a tab or a break inside a cell has to become an escape.
60
+ const escape = (value) => value.replace(/\t/g, '\\t').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
61
+ return allRows(data)
62
+ .map((row) => row.map(escape).join('\t'))
63
+ .join('\n');
64
+ };
65
+ export const tableDataToMarkdown = (data) => {
66
+ const { headers, rows } = data;
67
+ if (headers.length === 0)
68
+ return '';
69
+ // Backslashes first: escaping pipes adds backslashes of its own.
70
+ const escape = (cell) => cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, '<br>');
71
+ const line = (cells) => `| ${headers.map((_, i) => escape(cells[i] ?? '')).join(' | ')} |`;
72
+ return [line(headers), `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n');
73
+ };
74
+ export const tableDataToHTML = (data) => {
75
+ const escape = (cell) => cell.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
76
+ const row = (cells, tag) => `<tr>${cells.map((cell) => `<${tag}>${escape(cell)}</${tag}>`).join('')}</tr>`;
77
+ const head = data.headers.length ? `<thead>${row(data.headers, 'th')}</thead>` : '';
78
+ const body = data.rows.length
79
+ ? `<tbody>${data.rows.map((r) => row(r, 'td')).join('')}</tbody>`
80
+ : '';
81
+ return `<table>${head}${body}</table>`;
82
+ };
package/dist/utils/url.js CHANGED
@@ -25,6 +25,7 @@ export const isPathRelativeUrl = (url) => {
25
25
  return false;
26
26
  return url.startsWith('/');
27
27
  };
28
+ const WILDCARD_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
28
29
  export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
29
30
  if (!url)
30
31
  return null;
@@ -61,8 +62,11 @@ export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
61
62
  }
62
63
  // Check for wildcard - allow all URLs
63
64
  if (allowedPrefixes.includes('*')) {
64
- // Wildcard only allows http and https URLs
65
- if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
65
+ // The wildcard allows the protocols a document can legitimately link to.
66
+ // javascript:, data: and vbscript: stay blocked - they execute in the
67
+ // page's origin. mailto:/tel: were missing, so the default ['*'] config
68
+ // rendered a [blocked] badge on every phone and email link.
69
+ if (!WILDCARD_PROTOCOLS.has(parsedUrl.protocol)) {
66
70
  return null;
67
71
  }
68
72
  const inputWasRelative = isPathRelativeUrl(url);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The whole pin/unpin decision, kept pure so it can be tested without a DOM.
3
+ * The user scrolling away from the bottom is what unpins; scrolling back pins
4
+ * again. There is no separate latch: "was the user at the bottom last time they
5
+ * scrolled" is the latch.
6
+ */
7
+ export declare const isAtBottom: (scrollTop: number, scrollHeight: number, clientHeight: number) => boolean;
8
+ export interface PinnedScrollOptions {
9
+ /** The resolved max-height; pinning is off (and costs nothing) without one. */
10
+ readonly maxHeight: string | undefined;
11
+ /** Anything that changes when the content grows — re-runs the scroll. */
12
+ readonly content: unknown;
13
+ }
14
+ /**
15
+ * Attachment that keeps a capped scroll container stuck to the bottom while its
16
+ * content grows, unless the user has scrolled up.
17
+ *
18
+ * Scrolling is always instant: this fires on every streamed chunk, so a smooth
19
+ * animation would never finish before the next one starts (and would ignore
20
+ * `prefers-reduced-motion`).
21
+ */
22
+ export declare const usePinnedScroll: (options: PinnedScrollOptions) => (node: HTMLElement) => (() => void) | undefined;
@@ -0,0 +1,36 @@
1
+ import { on } from 'svelte/events';
2
+ // A scroll position this close to the bottom counts as "at the bottom": browsers
3
+ // report fractional scrollTop on zoomed/hi-dpi displays, so an exact compare
4
+ // would unpin the container on its own.
5
+ const BOTTOM_THRESHOLD_PX = 8;
6
+ /**
7
+ * The whole pin/unpin decision, kept pure so it can be tested without a DOM.
8
+ * The user scrolling away from the bottom is what unpins; scrolling back pins
9
+ * again. There is no separate latch: "was the user at the bottom last time they
10
+ * scrolled" is the latch.
11
+ */
12
+ export const isAtBottom = (scrollTop, scrollHeight, clientHeight) => scrollHeight - scrollTop - clientHeight < BOTTOM_THRESHOLD_PX;
13
+ /**
14
+ * Attachment that keeps a capped scroll container stuck to the bottom while its
15
+ * content grows, unless the user has scrolled up.
16
+ *
17
+ * Scrolling is always instant: this fires on every streamed chunk, so a smooth
18
+ * animation would never finish before the next one starts (and would ignore
19
+ * `prefers-reduced-motion`).
20
+ */
21
+ export const usePinnedScroll = (options) => {
22
+ return (node) => {
23
+ if (!options.maxHeight)
24
+ return;
25
+ let pinned = true;
26
+ const off = on(node, 'scroll', () => {
27
+ pinned = isAtBottom(node.scrollTop, node.scrollHeight, node.clientHeight);
28
+ }, { passive: true });
29
+ $effect(() => {
30
+ options.content;
31
+ if (pinned)
32
+ node.scrollTop = node.scrollHeight;
33
+ });
34
+ return off;
35
+ };
36
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "packageManager": "pnpm@10.32.1",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,7 +22,9 @@
22
22
  "lint": "prettier --check .",
23
23
  "test:ui": "vitest --ui",
24
24
  "test:unit": "vitest",
25
- "test": "npm run test:unit -- --run"
25
+ "test:browser": "vitest --run --project client",
26
+ "test": "npm run test:unit -- --run --project server",
27
+ "test:size": "vitest --run --project server src/tests/bundle-size.test.ts"
26
28
  },
27
29
  "files": [
28
30
  "dist",