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.
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.
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", () => {
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,
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" }),
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}`;
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;
}