From b701d613151ca3303ef2d473be2be1af0c8ea4fb Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 14 Jul 2026 10:26:48 +0530 Subject: [PATCH 1/6] fix(streamdown): enhance table data extraction and markdown escaping - Implemented a new function `extractCellText` to accurately extract text from table cells, handling different node types and line breaks. - Updated `extractTableDataFromElement` to utilize `extractCellText` for header and row cell data extraction. - Modified `escapeMarkdownTableCell` to escape newline characters by converting them to `
` tags for proper markdown formatting. --- packages/streamdown/lib/table/utils.ts | 29 ++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/streamdown/lib/table/utils.ts b/packages/streamdown/lib/table/utils.ts index 13c98cf8..8a28b974 100644 --- a/packages/streamdown/lib/table/utils.ts +++ b/packages/streamdown/lib/table/utils.ts @@ -3,6 +3,26 @@ export interface TableData { rows: string[][]; } +function extractCellText(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ""; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node as HTMLElement; + + if (element.tagName === "BR") { + return "\n"; + } + + return Array.from(element.childNodes) + .map(extractCellText) + .join(""); +} + export const extractTableDataFromElement = ( tableElement: HTMLElement ): TableData => { @@ -12,7 +32,7 @@ export const extractTableDataFromElement = ( // Extract headers const headerCells = tableElement.querySelectorAll("thead th"); for (const cell of headerCells) { - headers.push(cell.textContent?.trim() || ""); + headers.push(extractCellText(cell).trim()); } // Extract rows @@ -21,7 +41,7 @@ export const extractTableDataFromElement = ( const rowData: string[] = []; const cells = row.querySelectorAll("td"); for (const cell of cells) { - rowData.push(cell.textContent?.trim() || ""); + rowData.push(extractCellText(cell).trim()); } rows.push(rowData); } @@ -139,7 +159,7 @@ export const escapeMarkdownTableCell = (cell: string): string => { // OPTIMIZATION: Fast path for cells that don't need escaping - check chars directly let needsEscaping = false; for (const char of cell) { - if (char === "\\" || char === "|") { + if (char === "\\" || char === "|" || char === "\n") { needsEscaping = true; break; } @@ -149,13 +169,14 @@ export const escapeMarkdownTableCell = (cell: string): string => { return cell; } - // OPTIMIZATION: Use array building instead of string concatenation for better performance const parts: string[] = []; for (const char of cell) { if (char === "\\") { parts.push("\\\\"); } else if (char === "|") { parts.push("\\|"); + } else if (char === "\n") { + parts.push("
"); } else { parts.push(char); } From c5290b604f23950674bc207c227ddaa1fe902c51 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 14 Jul 2026 10:26:57 +0530 Subject: [PATCH 2/6] test(streamdown): add tests for line breaks in table cell copying - Introduced a new test suite to verify the preservation of line breaks when copying table data as Markdown, CSV, and TSV. - Ensured that `
` tags are correctly handled and do not merge cell text across line breaks. - Mocked clipboard functionality to validate the output format for different copy options. --- .../__tests__/copy-dropdown.test.tsx | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/packages/streamdown/__tests__/copy-dropdown.test.tsx b/packages/streamdown/__tests__/copy-dropdown.test.tsx index b2dc25af..bb0220e2 100644 --- a/packages/streamdown/__tests__/copy-dropdown.test.tsx +++ b/packages/streamdown/__tests__/copy-dropdown.test.tsx @@ -615,4 +615,211 @@ describe("TableCopyDropdown", () => { expect.any(Function) ); }); + + describe("line breaks inside table cells", () => { + const OriginalBlob = globalThis.Blob; + let blobPartsByType: Record; + let blobSpy: ReturnType; + + const getClipboardPlainText = (): string => { + const plainTextParts = blobPartsByType["text/plain"]; + expect(plainTextParts).toBeDefined(); + return String(plainTextParts[0] ?? ""); + }; + + beforeEach(() => { + blobPartsByType = {}; + 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); + + mockTable.innerHTML = ` + + + Description + Notes + + + + + Paragraph one.
Paragraph two. + Line A
Line B
Line C + + + `; + }); + + afterEach(() => { + blobSpy.mockRestore(); + }); + + it("should preserve
as newlines when copying as Markdown", async () => { + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("Markdown")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(getClipboardPlainText()).toBe( + "| Description | Notes |\n| --- | --- |\n| Paragraph one.
Paragraph two. | Line A
Line B
Line C |" + ); + + mockWrapper.removeChild(dropdownContainer); + }); + + it("should preserve
as newlines when copying as CSV", async () => { + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("CSV")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(getClipboardPlainText()).toBe( + 'Description,Notes\n"Paragraph one.\nParagraph two.","Line A\nLine B\nLine C"' + ); + + mockWrapper.removeChild(dropdownContainer); + }); + + it("should preserve
as escaped newlines when copying as TSV", async () => { + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("TSV")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + expect(getClipboardPlainText()).toBe( + "Description\tNotes\nParagraph one.\\nParagraph two.\tLine A\\nLine B\\nLine C" + ); + + mockWrapper.removeChild(dropdownContainer); + }); + + it("should not merge cell text across
tags", async () => { + mockTable.innerHTML = ` + + + Cell + + + + + Paragraph one.
Paragraph two. + + + `; + + const dropdownContainer = document.createElement("div"); + mockWrapper.appendChild(dropdownContainer); + + const { container, getByText } = render( + + + , + { container: dropdownContainer } + ); + + const button = container.querySelector('button[title="Copy table"]'); + expect(button).toBeTruthy(); + if (button) { + fireEvent.click(button); + } + + fireEvent.click(getByText("CSV")); + + await waitFor(() => { + expect(navigator.clipboard.write).toHaveBeenCalled(); + }); + + const plainText = getClipboardPlainText(); + expect(plainText).not.toContain("Paragraph one.Paragraph two."); + expect(plainText).toContain("Paragraph one.\nParagraph two."); + + mockWrapper.removeChild(dropdownContainer); + }); + }); }); From df4df62416787160f0ca003e0177c0ab51df6488 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 14 Jul 2026 10:27:05 +0530 Subject: [PATCH 3/6] test(streamdown): add tests for
to newline conversion in table cells - Added tests to verify that
elements in table cells are correctly converted to newlines. - Included scenarios for handling
in nested elements and multiple
tags. - Ensured that newlines in table data are converted back to
when generating Markdown output. --- .../streamdown/__tests__/table-utils.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index 761f6c91..5ad2409f 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -119,6 +119,64 @@ describe("Table Utils", () => { expect(result.headers).toEqual([]); expect(result.rows).toEqual([["Data"]]); }); + + it("should convert
elements to newlines in cells", () => { + tableElement.innerHTML = ` + + + Header + + + + + Paragraph one.
Paragraph two. + + + `; + + const result = extractTableDataFromElement(tableElement); + + expect(result.rows).toEqual([["Paragraph one.\nParagraph two."]]); + }); + + it("should convert multiple
elements to newlines", () => { + tableElement.innerHTML = ` + + + Notes + + + + + Line A
Line B
Line C + + + `; + + const result = extractTableDataFromElement(tableElement); + + expect(result.rows).toEqual([["Line A\nLine B\nLine C"]]); + }); + + it("should convert
inside nested elements to newlines", () => { + tableElement.innerHTML = ` + + + Header
with break + + + + + Bold
Italic + + + `; + + const result = extractTableDataFromElement(tableElement); + + expect(result.headers).toEqual(["Header\nwith break"]); + expect(result.rows).toEqual([["Bold\nItalic"]]); + }); }); describe("tableDataToCSV", () => { @@ -290,6 +348,11 @@ describe("Table Utils", () => { const result = escapeMarkdownTableCell(""); expect(result).toBe(""); }); + + it("should convert newlines to
", () => { + const result = escapeMarkdownTableCell("Paragraph one.\nParagraph two."); + expect(result).toBe("Paragraph one.
Paragraph two."); + }); }); describe("tableDataToMarkdown", () => { @@ -378,5 +441,18 @@ describe("Table Utils", () => { // Extra cells are ignored during the mapping expect(result).toBe("| Col1 | Col2 |\n| --- | --- |\n| A | B | C | D |"); }); + + it("should convert cell newlines to
for single-line table rows", () => { + const data: TableData = { + headers: ["Description"], + rows: [["Paragraph one.\nParagraph two."]], + }; + + const result = tableDataToMarkdown(data); + + expect(result).toBe( + "| Description |\n| --- |\n| Paragraph one.
Paragraph two. |" + ); + }); }); }); From 7da5def2ac72ce1cec4f5353017bca34f2a43ace Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 14 Jul 2026 10:39:58 +0530 Subject: [PATCH 4/6] chore: add changeset for preserving table copy line breaks --- .changeset/three-squids-behave.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/three-squids-behave.md diff --git a/.changeset/three-squids-behave.md b/.changeset/three-squids-behave.md new file mode 100644 index 00000000..892e3ea7 --- /dev/null +++ b/.changeset/three-squids-behave.md @@ -0,0 +1,8 @@ +--- +"streamdown": minor +--- + +- Fixed table copy losing line breaks inside cells containing `
` elements. +- Preserved multiline content during table data extraction. +- Improved copied Markdown, CSV, and TSV output consistency for multiline cells. +- Added coverage for `
` handling in table extraction. From 79e0929c7820a7de38ef428b2c89304abc5d5b36 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 14 Jul 2026 23:19:44 +0530 Subject: [PATCH 5/6] refactor(streamdown): simplify cell text extraction in utils - Streamlined the `extractCellText` function by removing unnecessary line breaks in the return statement, improving code readability. --- packages/streamdown/lib/table/utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/streamdown/lib/table/utils.ts b/packages/streamdown/lib/table/utils.ts index 8a28b974..81fc6d25 100644 --- a/packages/streamdown/lib/table/utils.ts +++ b/packages/streamdown/lib/table/utils.ts @@ -18,9 +18,7 @@ function extractCellText(node: Node): string { return "\n"; } - return Array.from(element.childNodes) - .map(extractCellText) - .join(""); + return Array.from(element.childNodes).map(extractCellText).join(""); } export const extractTableDataFromElement = ( From cb95edc29b2a9d249ab02ab1572eba08a3873cc4 Mon Sep 17 00:00:00 2001 From: aradhyacp Date: Tue, 14 Jul 2026 23:27:31 +0530 Subject: [PATCH 6/6] test(streamdown): enhance clipboard tests for text/plain validation - Added checks to ensure that the `blobPartsByType["text/plain"]` is defined in multiple test cases. - Improved the `getClipboardPlainText` function to handle optional chaining for better safety. - Reformatted the HTML structure in the test setup for clarity and consistency. --- .../__tests__/copy-dropdown.test.tsx | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/streamdown/__tests__/copy-dropdown.test.tsx b/packages/streamdown/__tests__/copy-dropdown.test.tsx index bb0220e2..f65cb9bc 100644 --- a/packages/streamdown/__tests__/copy-dropdown.test.tsx +++ b/packages/streamdown/__tests__/copy-dropdown.test.tsx @@ -623,8 +623,7 @@ describe("TableCopyDropdown", () => { const getClipboardPlainText = (): string => { const plainTextParts = blobPartsByType["text/plain"]; - expect(plainTextParts).toBeDefined(); - return String(plainTextParts[0] ?? ""); + return String(plainTextParts?.[0] ?? ""); }; beforeEach(() => { @@ -642,19 +641,19 @@ describe("TableCopyDropdown", () => { } as unknown as typeof Blob); mockTable.innerHTML = ` - - - Description - Notes - - - - - Paragraph one.
Paragraph two. - Line A
Line B
Line C - - - `; + + + Description + Notes + + + + + Paragraph one.
Paragraph two. + Line A
Line B
Line C + + + `; }); afterEach(() => { @@ -691,6 +690,8 @@ describe("TableCopyDropdown", () => { expect(navigator.clipboard.write).toHaveBeenCalled(); }); + expect(blobPartsByType["text/plain"]).toBeDefined(); + expect(getClipboardPlainText()).toBe( "| Description | Notes |\n| --- | --- |\n| Paragraph one.
Paragraph two. | Line A
Line B
Line C |" ); @@ -728,6 +729,8 @@ describe("TableCopyDropdown", () => { expect(navigator.clipboard.write).toHaveBeenCalled(); }); + expect(blobPartsByType["text/plain"]).toBeDefined(); + expect(getClipboardPlainText()).toBe( 'Description,Notes\n"Paragraph one.\nParagraph two.","Line A\nLine B\nLine C"' ); @@ -765,6 +768,8 @@ describe("TableCopyDropdown", () => { expect(navigator.clipboard.write).toHaveBeenCalled(); }); + expect(blobPartsByType["text/plain"]).toBeDefined(); + expect(getClipboardPlainText()).toBe( "Description\tNotes\nParagraph one.\\nParagraph two.\tLine A\\nLine B\\nLine C" ); @@ -810,12 +815,14 @@ describe("TableCopyDropdown", () => { } fireEvent.click(getByText("CSV")); - await waitFor(() => { expect(navigator.clipboard.write).toHaveBeenCalled(); }); + expect(blobPartsByType["text/plain"]).toBeDefined(); + const plainText = getClipboardPlainText(); + expect(plainText).not.toContain("Paragraph one.Paragraph two."); expect(plainText).toContain("Paragraph one.\nParagraph two.");