From 5f890196afb25965bf83372f179c9b721053358c Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 19 May 2026 21:34:22 +0530 Subject: [PATCH 1/7] fix(table): enhance CSV export with dynamic separator handling --- packages/streamdown/lib/table/utils.ts | 46 ++++++++++++++++---------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/streamdown/lib/table/utils.ts b/packages/streamdown/lib/table/utils.ts index 13c98cf8..cb4f6b8c 100644 --- a/packages/streamdown/lib/table/utils.ts +++ b/packages/streamdown/lib/table/utils.ts @@ -29,35 +29,47 @@ export const extractTableDataFromElement = ( return { headers, rows }; }; -export const tableDataToCSV = (data: TableData): string => { +export type CSVSeparator = "," | ";" | "\t" | "auto"; + +export const tableDataToCSV = ( + data: TableData, + separator: CSVSeparator = "," +): string => { + let resolvedSeparator: string; + + if (separator === "auto") { + const formatter = Intl.NumberFormat().format(1.1); + + if (formatter.includes(",")) { + resolvedSeparator = ";"; + } else { + resolvedSeparator = ","; + } + } else { + resolvedSeparator = separator; + } const { headers, rows } = data; const escapeCSV = (value: string): string => { - // OPTIMIZATION: Fast path for values that don't need escaping - // Check characters directly to avoid multiple string scans let needsEscaping = false; - let hasQuote = false; for (const char of value) { - if (char === '"') { + if ( + char === resolvedSeparator || + char === '"' || + char === "\n" || + char === "\r" + ) { needsEscaping = true; - hasQuote = true; break; } - if (char === "," || char === "\n") { - needsEscaping = true; - } } if (!needsEscaping) { return value; } - - // If the value contains comma, quote, or newline, wrap in quotes and escape internal quotes - if (hasQuote) { - return `"${value.replace(/"/g, '""')}"`; - } - return `"${value}"`; + // Escape internal quotes by doubling them + return `"${value.replace(/"/g, '""')}"`; }; // Pre-allocate array with known size @@ -67,13 +79,13 @@ export const tableDataToCSV = (data: TableData): string => { // Add headers if (headers.length > 0) { - csvRows[rowIndex] = headers.map(escapeCSV).join(","); + csvRows[rowIndex] = headers.map(escapeCSV).join(resolvedSeparator); rowIndex += 1; } // Add data rows for (const row of rows) { - csvRows[rowIndex] = row.map(escapeCSV).join(","); + csvRows[rowIndex] = row.map(escapeCSV).join(resolvedSeparator); rowIndex += 1; } From 0cee592d5c6804612697cfe2784d1c326d2bf79c Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 19 May 2026 22:12:18 +0530 Subject: [PATCH 2/7] feat(table): add support for custom CSV separator in download components --- packages/streamdown/lib/table/download-dropdown.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx index ead50cf8..fcf2e318 100644 --- a/packages/streamdown/lib/table/download-dropdown.tsx +++ b/packages/streamdown/lib/table/download-dropdown.tsx @@ -5,6 +5,7 @@ import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; import { save } from "../utils"; import { + type CSVSeparator, extractTableDataFromElement, tableDataToCSV, tableDataToMarkdown, @@ -13,6 +14,7 @@ import { export interface TableDownloadButtonProps { children?: React.ReactNode; className?: string; + csvSeparator?: CSVSeparator; filename?: string; format?: "csv" | "markdown"; onDownload?: () => void; @@ -22,6 +24,7 @@ export interface TableDownloadButtonProps { export const TableDownloadButton = ({ children, className, + csvSeparator, onDownload, onError, format = "csv", @@ -53,7 +56,7 @@ export const TableDownloadButton = ({ switch (format) { case "csv": - content = tableDataToCSV(tableData); + content = tableDataToCSV(tableData, csvSeparator); mimeType = "text/csv"; extension = "csv"; break; @@ -63,7 +66,7 @@ export const TableDownloadButton = ({ extension = "md"; break; default: - content = tableDataToCSV(tableData); + content = tableDataToCSV(tableData, csvSeparator); mimeType = "text/csv"; extension = "csv"; } @@ -97,6 +100,7 @@ export const TableDownloadButton = ({ export interface TableDownloadDropdownProps { children?: React.ReactNode; className?: string; + csvSeparator?: CSVSeparator; onDownload?: (format: "csv" | "markdown") => void; onError?: (error: Error) => void; } @@ -104,6 +108,7 @@ export interface TableDownloadDropdownProps { export const TableDownloadDropdown = ({ children, className, + csvSeparator, onDownload, onError, }: TableDownloadDropdownProps) => { @@ -131,7 +136,7 @@ export const TableDownloadDropdown = ({ const tableData = extractTableDataFromElement(tableElement); const content = format === "csv" - ? tableDataToCSV(tableData) + ? tableDataToCSV(tableData, csvSeparator) : tableDataToMarkdown(tableData); const extension = format === "csv" ? "csv" : "md"; const filename = `table.${extension}`; From 0dc9a9b7102f04984518fc5c4012fbb567ba0b23 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 19 May 2026 23:56:01 +0530 Subject: [PATCH 3/7] feat(table): add CSVSeparator type export for improved type handling --- packages/streamdown/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..26ae048b 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -94,6 +94,7 @@ export { type TableDownloadDropdownProps, } from "./lib/table/download-dropdown"; export { + type CSVSeparator, escapeMarkdownTableCell, extractTableDataFromElement, type TableData, From fa3fba9c056eda5d20e07fded904df8429d8cee9 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 20 May 2026 00:38:38 +0530 Subject: [PATCH 4/7] feat(table): add csvSeparator prop to TableCopyDropdown for customizable CSV formatting --- .../streamdown/lib/table/copy-dropdown.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/streamdown/lib/table/copy-dropdown.tsx b/packages/streamdown/lib/table/copy-dropdown.tsx index f019a9fd..4624ffec 100644 --- a/packages/streamdown/lib/table/copy-dropdown.tsx +++ b/packages/streamdown/lib/table/copy-dropdown.tsx @@ -4,6 +4,7 @@ import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; import { + type CSVSeparator, extractTableDataFromElement, tableDataToCSV, tableDataToMarkdown, @@ -13,6 +14,7 @@ import { export interface TableCopyDropdownProps { children?: React.ReactNode; className?: string; + csvSeparator?: CSVSeparator; onCopy?: (format: "csv" | "tsv" | "md") => void; onError?: (error: Error) => void; timeout?: number; @@ -21,6 +23,7 @@ export interface TableCopyDropdownProps { export const TableCopyDropdown = ({ children, className, + csvSeparator, onCopy, onError, timeout = 2000, @@ -53,14 +56,15 @@ export const TableCopyDropdown = ({ } const tableData = extractTableDataFromElement(tableElement); + let content = ""; - const formatters = { - csv: tableDataToCSV, - tsv: tableDataToTSV, - md: tableDataToMarkdown, - }; - const formatter = formatters[format] || tableDataToMarkdown; - const content = formatter(tableData); + if (format === "csv") { + content = tableDataToCSV(tableData, csvSeparator); + } else if (format === "tsv") { + content = tableDataToTSV(tableData); + } else { + content = tableDataToMarkdown(tableData); + } const clipboardItemData = new ClipboardItem({ "text/plain": new Blob([content], { type: "text/plain" }), From 054dfe0dad0ee0908d88dec68a9780218e65e1fc Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 20 May 2026 01:20:04 +0530 Subject: [PATCH 5/7] test(table): enhance CSV export tests with additional scenarios for separators and escaping --- .../streamdown/__tests__/table-utils.test.ts | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index 761f6c91..96781a43 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { escapeMarkdownTableCell, extractTableDataFromElement, @@ -147,6 +147,59 @@ describe("Table Utils", () => { expect(result).toBe('Name,Location\nJohn,"New York, USA"'); }); + it("should escape quotes and separator together", () => { + const data: TableData = { + headers: ["Message"], + rows: [['Hello, "World"']], + }; + + const result = tableDataToCSV(data); + + expect(result).toBe('Message\n"Hello, ""World"""'); + }); + + it("should support semicolon separators", () => { + const data: TableData = { + headers: ["Name", "Location"], + rows: [["Aradhya", "New York; USA"]], + }; + + const result = tableDataToCSV(data, ";"); + + expect(result).toBe('Name;Location\nAradhya;"New York; USA"'); + }); + + it("should use semicolon separator in auto mode for comma-decimal locales", () => { + const numberFormatSpy = vi.spyOn(Intl, "NumberFormat").mockImplementation( + () => + ({ + format: () => "1,1", + }) as Intl.NumberFormat + ); + + const data: TableData = { + headers: ["Name", "City"], + rows: [["John", "Paris; France"]], + }; + + const result = tableDataToCSV(data, "auto"); + + expect(result).toBe('Name;City\nJohn;"Paris; France"'); + + numberFormatSpy.mockRestore(); + }); + + it("should escape carriage returns", () => { + const data: TableData = { + headers: ["Text"], + rows: [["line1\rline2"]], + }; + + const result = tableDataToCSV(data); + + expect(result).toBe('Text\n"line1\rline2"'); + }); + it("should escape quotes in values", () => { const data: TableData = { headers: ["Quote"], @@ -169,6 +222,28 @@ describe("Table Utils", () => { expect(result).toBe('Text\n"Line 1\nLine 2"'); }); + it("should support tab separators", () => { + const data: TableData = { + headers: ["Name", "Note"], + rows: [["Mike", "A\tB"]], + }; + + const result = tableDataToCSV(data, "\t"); + + expect(result).toBe('Name\tNote\nMike\t"A\tB"'); + }); + + it("should not use separators as its single column", () => { + const data: TableData = { + headers: ["Name"], + rows: [["John"]], + }; + + const result = tableDataToCSV(data, ";"); + + expect(result).toBe("Name\nJohn"); + }); + it("should handle empty headers", () => { const data: TableData = { headers: [], @@ -190,6 +265,28 @@ describe("Table Utils", () => { expect(result).toBe("Header1,Header2"); }); + + it("should handle empty values", () => { + const data: TableData = { + headers: ["Name", "Age"], + rows: [["", ""]], + }; + + const result = tableDataToCSV(data); + + expect(result).toBe("Name,Age\n,"); + }); + + it("should handle empty tables", () => { + const data: TableData = { + headers: [], + rows: [], + }; + + const result = tableDataToCSV(data); + + expect(result).toBe(""); + }); }); describe("tableDataToTSV", () => { From a216a08cc90a4908017ad59ebd1bca672cc42d5a Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 20 May 2026 01:20:09 +0530 Subject: [PATCH 6/7] feat(table): add configurable csvSeparator option for enhanced CSV export functionality --- .changeset/ten-pandas-swim.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/ten-pandas-swim.md diff --git a/.changeset/ten-pandas-swim.md b/.changeset/ten-pandas-swim.md new file mode 100644 index 00000000..82cd79af --- /dev/null +++ b/.changeset/ten-pandas-swim.md @@ -0,0 +1,8 @@ +--- +"streamdown": minor +--- + +- Add `csvSeparator` option for CSV export (`"," | ";" | "\t" | "auto"`). +- Update `tableDataToCSV` to use configurable separator instead of hardcoded comma. +- Add `"auto"` mode using locale-based decimal detection via `Intl.NumberFormat`. +- Improve CSV escaping to respect selected separator for proper Excel compatibility. From 31037b54382d895b51bde30f19b0c9c602909678 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Wed, 20 May 2026 01:31:07 +0530 Subject: [PATCH 7/7] docs: update CSV export documentation for configurable separator support --- apps/website/content/docs/components.mdx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/website/content/docs/components.mdx b/apps/website/content/docs/components.mdx index 50ffee35..26d2d045 100644 --- a/apps/website/content/docs/components.mdx +++ b/apps/website/content/docs/components.mdx @@ -224,6 +224,14 @@ import { TableDownloadButton } from "streamdown"; ``` +You can control the CSV delimiter used by the table actions with the optional `csvSeparator` prop. Supported values are `","`, `";"`, `"\t"`, and `"auto"` (locale-aware selection): + +```tsx title="app/page.tsx" + + + +``` + ### Lower-level utilities For fully custom implementations, use the extraction and conversion utilities directly: @@ -231,6 +239,7 @@ For fully custom implementations, use the extraction and conversion utilities di ```tsx title="app/page.tsx" import { extractTableDataFromElement, + type CSVSeparator, tableDataToCSV, tableDataToTSV, tableDataToMarkdown, @@ -240,11 +249,13 @@ import { const data = extractTableDataFromElement(tableElement); // Convert to various formats -const csv = tableDataToCSV(data); +const csv = tableDataToCSV(data, "auto"); const tsv = tableDataToTSV(data); const markdown = tableDataToMarkdown(data); ``` +The `tableDataToCSV` helper accepts an optional `CSVSeparator` argument (`"," | ";" | "\t" | "auto"`) so you can choose the delimiter explicitly or let `"auto"` pick a locale-friendly separator. + ## Custom HTML Tags You can render custom HTML tags from AI responses (like ``, ``, etc.) using the `allowedTags` prop alongside `components`. This is useful when you instruct the AI to output structured data that renders as interactive components.