From 363d3f9eac95644b53eb992f105d8de2fa529266 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 24 Aug 2026 10:37:42 +0200 Subject: [PATCH] feat(table): configure csvSeparator through controls.table Add controls.table.csvSeparator (",", ";", "\t", "auto") so table CSV copy and download share one locale-friendly setting, and make tableDataToCSV escape against the resolved delimiter. Co-authored-by: aradhyacp --- .changeset/ten-pandas-swim.md | 7 + apps/website/content/docs/components.mdx | 13 +- apps/website/content/docs/configuration.mdx | 1 + apps/website/content/docs/gfm.mdx | 10 + apps/website/content/docs/interactivity.mdx | 8 + .../__tests__/table-dropdowns.test.tsx | 183 +++++++++++++++++- .../streamdown/__tests__/table-utils.test.ts | 127 +++++++++++- packages/streamdown/index.tsx | 3 + .../streamdown/lib/table/copy-dropdown.tsx | 19 +- .../lib/table/download-dropdown.tsx | 14 +- packages/streamdown/lib/table/utils.ts | 61 ++++-- skills/streamdown/references/api.md | 14 +- skills/streamdown/references/features.md | 6 +- 13 files changed, 429 insertions(+), 37 deletions(-) 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..6e8c9407 --- /dev/null +++ b/.changeset/ten-pandas-swim.md @@ -0,0 +1,7 @@ +--- +"streamdown": minor +--- + +- Add `controls.table.csvSeparator` (`"," | ";" | "\t" | "auto"`) for table copy and download CSV +- Reuse `tableDataToCSV` separator handling, including locale-aware `"auto"` mode +- Improve CSV escaping to respect the selected separator for Excel compatibility diff --git a/apps/website/content/docs/components.mdx b/apps/website/content/docs/components.mdx index 50ffee35..f92b05a8 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 for table copy and download with `controls.table.csvSeparator`. Supported values are `","`, `";"`, `"\t"`, and `"auto"` (locale-aware selection). The default is `","`. + +```tsx title="app/page.tsx" + + {markdown} + +``` + ### 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/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 78d7c2fb..fa45b730 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -346,6 +346,7 @@ The `controls` prop can be configured granularly: copy: true, // Show table copy button download: true, // Show table download button fullscreen: true, // Show table fullscreen button + csvSeparator: ",", // "," | ";" | "\t" | "auto" }, code: { copy: true, // Show code copy button diff --git a/apps/website/content/docs/gfm.mdx b/apps/website/content/docs/gfm.mdx index 6d0b1443..b45df23e 100644 --- a/apps/website/content/docs/gfm.mdx +++ b/apps/website/content/docs/gfm.mdx @@ -118,6 +118,16 @@ You can disable the table download button: ``` +### Custom CSV Separator + +By default, copied and downloaded CSV uses a comma. Set `controls.table.csvSeparator` to `";"`, `"\t"`, or `"auto"` (picks `;` in comma-decimal locales): + +```tsx + + {markdown} + +``` + ## Task Lists Create interactive todo lists: diff --git a/apps/website/content/docs/interactivity.mdx b/apps/website/content/docs/interactivity.mdx index dc17fe97..88d745ba 100644 --- a/apps/website/content/docs/interactivity.mdx +++ b/apps/website/content/docs/interactivity.mdx @@ -41,6 +41,14 @@ Tables include a copy button that opens a dropdown menu allowing users to copy t Tables can be downloaded in two formats: CSV and Markdown. The download button will be shown for tables in the top-right corner on hover. The download button opens a dropdown menu with options to download as CSV or Markdown, making it easy to export table data for use in spreadsheets or documentation. +CSV copy and download use a comma by default. Customize the delimiter with `controls.table.csvSeparator` (`","`, `";"`, `"\t"`, or `"auto"`): + +```tsx + + {markdown} + +``` + ## Code Block Buttons ### Copy Code diff --git a/packages/streamdown/__tests__/table-dropdowns.test.tsx b/packages/streamdown/__tests__/table-dropdowns.test.tsx index 8bea6d4e..cddb356a 100644 --- a/packages/streamdown/__tests__/table-dropdowns.test.tsx +++ b/packages/streamdown/__tests__/table-dropdowns.test.tsx @@ -79,7 +79,7 @@ describe("TableDownloadDropdown", () => { expect(save).toHaveBeenCalledWith( "table.csv", - expect.any(String), + "Name,Age\nAlice,30", "text/csv" ); expect(onDownload).toHaveBeenCalledWith("csv"); @@ -114,6 +114,57 @@ describe("TableDownloadDropdown", () => { expect(onDownload).toHaveBeenCalledWith("markdown"); }); + it("should use csvSeparator from controls for CSV downloads", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + + const { container } = render( + +
+ + + + + + + + + + + + + +
NameAge
Alice30
+ +
+
+ ); + + const toggleBtn = container.querySelector('button[title="Download table"]'); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(toggleBtn!); + + const csvBtn = container.querySelector( + 'button[title="Download table as CSV"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(csvBtn!); + + expect(save).toHaveBeenCalledWith( + "table.csv", + "Name;Age\nAlice;30", + "text/csv" + ); + expect(onDownload).toHaveBeenCalledWith("csv"); + }); + it("should download when inside table-fullscreen", async () => { const { save } = await import("../lib/utils"); const onDownload = vi.fn(); @@ -263,6 +314,53 @@ describe("TableDownloadButton with format='markdown'", () => { expect(onDownload).toHaveBeenCalled(); }); + it("should use csvSeparator from controls for CSV button downloads", async () => { + const { save } = await import("../lib/utils"); + const onDownload = vi.fn(); + + const { container } = render( + +
+ + + + + + + + + + + + + +
NameAge
Alice30
+ +
+
+ ); + + const btn = container.querySelector( + 'button[title="Download table as CSV"]' + ); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(btn!); + + expect(save).toHaveBeenCalledWith( + "table.csv", + "Name;Age\nAlice;30", + "text/csv" + ); + expect(onDownload).toHaveBeenCalled(); + }); + it("should handle default format (fallback to csv)", () => { const { container } = renderInTableWrapper( @@ -406,6 +504,20 @@ describe("TableCopyDropdown", () => { it("should copy as CSV when csv button clicked", async () => { const onCopy = vi.fn(); + const OriginalBlob = globalThis.Blob; + const blobPartsByType: Record = {}; + const blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function ( + this: Blob, + parts?: BlobPart[], + opts?: BlobPropertyBag + ) { + const type = opts?.type ?? ""; + if (parts) { + blobPartsByType[type] = parts; + } + return new OriginalBlob(parts, opts); + } as unknown as typeof Blob); + const { container } = renderInTableWrapper( ); @@ -421,7 +533,76 @@ describe("TableCopyDropdown", () => { fireEvent.click(csvBtn!); }); + expect(navigator.clipboard.write).toHaveBeenCalled(); + expect(String(blobPartsByType["text/plain"]?.[0] ?? "")).toBe( + "Name,Age\nAlice,30" + ); + expect(onCopy).toHaveBeenCalledWith("csv"); + blobSpy.mockRestore(); + }); + + it("should copy CSV when csvSeparator is configured", async () => { + const onCopy = vi.fn(); + const OriginalBlob = globalThis.Blob; + const blobPartsByType: Record = {}; + const blobSpy = vi.spyOn(globalThis, "Blob").mockImplementation(function ( + this: Blob, + parts?: BlobPart[], + opts?: BlobPropertyBag + ) { + const type = opts?.type ?? ""; + if (parts) { + blobPartsByType[type] = parts; + } + return new OriginalBlob(parts, opts); + } as unknown as typeof Blob); + + const { container } = render( + +
+ + + + + + + + + + + + + +
NameAge
Alice30
+ +
+
+ ); + + const toggleBtn = container.querySelector('button[title="Copy table"]'); + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(toggleBtn!); + + const csvBtn = container.querySelector('button[title="Copy table as CSV"]'); + // biome-ignore lint/suspicious/useAwait: act needs async to flush clipboard promises + await act(async () => { + // biome-ignore lint/style/noNonNullAssertion: test assertion + fireEvent.click(csvBtn!); + }); + + expect(navigator.clipboard.write).toHaveBeenCalled(); + expect(String(blobPartsByType["text/plain"]?.[0] ?? "")).toBe( + "Name;Age\nAlice;30" + ); expect(onCopy).toHaveBeenCalledWith("csv"); + blobSpy.mockRestore(); }); it("should copy as TSV when tsv button clicked", async () => { diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index 5ad2409f..b0c1e454 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { escapeMarkdownTableCell, extractTableDataFromElement, + getTableCsvSeparator, type TableData, tableDataToCSV, tableDataToMarkdown, @@ -205,6 +206,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"], @@ -227,6 +281,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: [], @@ -248,6 +324,55 @@ 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("getTableCsvSeparator", () => { + it("returns comma when controls is a boolean", () => { + expect(getTableCsvSeparator(true)).toBe(","); + expect(getTableCsvSeparator(false)).toBe(","); + }); + + it("returns comma when table is missing or a boolean", () => { + expect(getTableCsvSeparator({})).toBe(","); + expect(getTableCsvSeparator({ table: true })).toBe(","); + expect(getTableCsvSeparator({ table: false })).toBe(","); + }); + + it("returns comma when csvSeparator is not set", () => { + expect(getTableCsvSeparator({ table: {} })).toBe(","); + }); + + it("returns the configured separator", () => { + expect(getTableCsvSeparator({ table: { csvSeparator: ";" } })).toBe(";"); + expect(getTableCsvSeparator({ table: { csvSeparator: "\t" } })).toBe( + "\t" + ); + expect(getTableCsvSeparator({ table: { csvSeparator: "auto" } })).toBe( + "auto" + ); + }); }); describe("tableDataToTSV", () => { diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index cc3983b2..9ce7df88 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -44,6 +44,7 @@ import { preprocessLiteralTagContent } from "./lib/preprocess-literal-tag-conten import { rehypeBlockDirection } from "./lib/rehype/block-direction"; import { rehypeLiteralTagContent } from "./lib/rehype/literal-tag-content"; import { remarkCodeMeta } from "./lib/remark/code-meta"; +import type { CSVSeparator } from "./lib/table/utils"; import { defaultTranslations, type StreamdownTranslations, @@ -97,6 +98,7 @@ export { type TableDownloadDropdownProps, } from "./lib/table/download-dropdown"; export { + type CSVSeparator, escapeMarkdownTableCell, extractTableDataFromElement, type TableData, @@ -142,6 +144,7 @@ export type ControlsConfig = | boolean | { copy?: boolean; + csvSeparator?: CSVSeparator; download?: boolean; fullscreen?: boolean; }; diff --git a/packages/streamdown/lib/table/copy-dropdown.tsx b/packages/streamdown/lib/table/copy-dropdown.tsx index f019a9fd..5b795bca 100644 --- a/packages/streamdown/lib/table/copy-dropdown.tsx +++ b/packages/streamdown/lib/table/copy-dropdown.tsx @@ -5,6 +5,7 @@ import { useCn } from "../prefix-context"; import { useTranslations } from "../translations-context"; import { extractTableDataFromElement, + getTableCsvSeparator, tableDataToCSV, tableDataToMarkdown, tableDataToTSV, @@ -30,8 +31,9 @@ export const TableCopyDropdown = ({ const [isCopied, setIsCopied] = useState(false); const dropdownRef = useRef(null); const timeoutRef = useRef(0); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); + const csvSeparator = getTableCsvSeparator(controls); const copyTableData = async (format: "csv" | "tsv" | "md") => { if (typeof window === "undefined" || !navigator?.clipboard?.write) { @@ -53,14 +55,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..39e18ccd 100644 --- a/packages/streamdown/lib/table/download-dropdown.tsx +++ b/packages/streamdown/lib/table/download-dropdown.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "../translations-context"; import { save } from "../utils"; import { extractTableDataFromElement, + getTableCsvSeparator, tableDataToCSV, tableDataToMarkdown, } from "./utils"; @@ -28,10 +29,10 @@ export const TableDownloadButton = ({ filename, }: TableDownloadButtonProps) => { const cn = useCn(); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); const icons = useIcons(); - + const csvSeparator = getTableCsvSeparator(controls); const downloadTableData = (event: React.MouseEvent) => { try { // Find the closest table element @@ -53,7 +54,7 @@ export const TableDownloadButton = ({ switch (format) { case "csv": - content = tableDataToCSV(tableData); + content = tableDataToCSV(tableData, csvSeparator); mimeType = "text/csv"; extension = "csv"; break; @@ -63,7 +64,7 @@ export const TableDownloadButton = ({ extension = "md"; break; default: - content = tableDataToCSV(tableData); + content = tableDataToCSV(tableData, csvSeparator); mimeType = "text/csv"; extension = "csv"; } @@ -110,9 +111,10 @@ export const TableDownloadDropdown = ({ const cn = useCn(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isAnimating } = useContext(StreamdownContext); + const { isAnimating, controls } = useContext(StreamdownContext); const t = useTranslations(); const icons = useIcons(); + const csvSeparator = getTableCsvSeparator(controls); const downloadTableData = (format: "csv" | "markdown") => { try { @@ -131,7 +133,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 81fc6d25..aece0df5 100644 --- a/packages/streamdown/lib/table/utils.ts +++ b/packages/streamdown/lib/table/utils.ts @@ -47,35 +47,62 @@ export const extractTableDataFromElement = ( return { headers, rows }; }; -export const tableDataToCSV = (data: TableData): string => { +export type CSVSeparator = "," | ";" | "\t" | "auto"; + +export const getTableCsvSeparator = ( + config: boolean | { table?: boolean | { csvSeparator?: CSVSeparator } } +): CSVSeparator => { + if (typeof config !== "object") { + return ","; + } + + const tableConfig = config.table; + if (typeof tableConfig !== "object") { + return ","; + } + + return tableConfig.csvSeparator ?? ","; +}; + +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 @@ -85,13 +112,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; } diff --git a/skills/streamdown/references/api.md b/skills/streamdown/references/api.md index bd897036..99e2fbe1 100644 --- a/skills/streamdown/references/api.md +++ b/skills/streamdown/references/api.md @@ -147,9 +147,19 @@ interface RemendOptions { ## ControlsConfig ```tsx +type CSVSeparator = "," | ";" | "\t" | "auto"; + type ControlsConfig = boolean | { - table?: boolean; - code?: boolean; + table?: boolean | { + copy?: boolean; + download?: boolean; + fullscreen?: boolean; + csvSeparator?: CSVSeparator; // default: "," + }; + code?: boolean | { + copy?: boolean; + download?: boolean; + }; mermaid?: boolean | { download?: boolean; copy?: boolean; diff --git a/skills/streamdown/references/features.md b/skills/streamdown/references/features.md index 99ee7b47..85f8576a 100644 --- a/skills/streamdown/references/features.md +++ b/skills/streamdown/references/features.md @@ -118,7 +118,11 @@ Auto-added buttons for images, tables, code, and Mermaid. ```tsx