Skip to content
8 changes: 8 additions & 0 deletions .changeset/ten-pandas-swim.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion apps/website/content/docs/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,22 @@ import { TableDownloadButton } from "streamdown";
<TableDownloadButton format="csv" />
```

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"
<TableCopyDropdown csvSeparator="auto" />
<TableDownloadDropdown csvSeparator=";" />
<TableDownloadButton format="csv" csvSeparator="\t" />
```

### Lower-level utilities

For fully custom implementations, use the extraction and conversion utilities directly:

```tsx title="app/page.tsx"
import {
extractTableDataFromElement,
type CSVSeparator,
tableDataToCSV,
tableDataToTSV,
tableDataToMarkdown,
Expand All @@ -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 `<source>`, `<mention>`, etc.) using the `allowedTags` prop alongside `components`. This is useful when you instruct the AI to output structured data that renders as interactive components.
Expand Down
99 changes: 98 additions & 1 deletion packages/streamdown/__tests__/table-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
escapeMarkdownTableCell,
extractTableDataFromElement,
Expand Down Expand Up @@ -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"],
Expand All @@ -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: [],
Expand All @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
type TableDownloadDropdownProps,
} from "./lib/table/download-dropdown";
export {
type CSVSeparator,
escapeMarkdownTableCell,
extractTableDataFromElement,
type TableData,
Expand Down
18 changes: 11 additions & 7 deletions packages/streamdown/lib/table/copy-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -21,6 +23,7 @@ export interface TableCopyDropdownProps {
export const TableCopyDropdown = ({
children,
className,
csvSeparator,
onCopy,
onError,
timeout = 2000,
Expand Down Expand Up @@ -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" }),
Expand Down
11 changes: 8 additions & 3 deletions packages/streamdown/lib/table/download-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCn } from "../prefix-context";
import { useTranslations } from "../translations-context";
import { save } from "../utils";
import {
type CSVSeparator,
extractTableDataFromElement,
tableDataToCSV,
tableDataToMarkdown,
Expand All @@ -13,6 +14,7 @@ import {
export interface TableDownloadButtonProps {
children?: React.ReactNode;
className?: string;
csvSeparator?: CSVSeparator;
filename?: string;
format?: "csv" | "markdown";
onDownload?: () => void;
Expand All @@ -22,6 +24,7 @@ export interface TableDownloadButtonProps {
export const TableDownloadButton = ({
children,
className,
csvSeparator,
onDownload,
onError,
format = "csv",
Expand Down Expand Up @@ -53,7 +56,7 @@ export const TableDownloadButton = ({

switch (format) {
case "csv":
content = tableDataToCSV(tableData);
content = tableDataToCSV(tableData, csvSeparator);
mimeType = "text/csv";
extension = "csv";
break;
Expand All @@ -63,7 +66,7 @@ export const TableDownloadButton = ({
extension = "md";
break;
default:
content = tableDataToCSV(tableData);
content = tableDataToCSV(tableData, csvSeparator);
mimeType = "text/csv";
extension = "csv";
}
Expand Down Expand Up @@ -97,13 +100,15 @@ export const TableDownloadButton = ({
export interface TableDownloadDropdownProps {
children?: React.ReactNode;
className?: string;
csvSeparator?: CSVSeparator;
onDownload?: (format: "csv" | "markdown") => void;
onError?: (error: Error) => void;
}

export const TableDownloadDropdown = ({
children,
className,
csvSeparator,
onDownload,
onError,
}: TableDownloadDropdownProps) => {
Expand Down Expand Up @@ -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}`;
Expand Down
46 changes: 29 additions & 17 deletions packages/streamdown/lib/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
Loading