diff --git a/.changeset/preserve-markdown-table-literals.md b/.changeset/preserve-markdown-table-literals.md new file mode 100644 index 00000000..73ce3003 --- /dev/null +++ b/.changeset/preserve-markdown-table-literals.md @@ -0,0 +1,5 @@ +--- +"streamdown": patch +--- + +Preserve literal HTML tags and character entities when copying or downloading tables as Markdown. diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index b0c1e454..45810257 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -1,4 +1,9 @@ +import { render } from "@testing-library/react"; +import { marked } from "marked"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Markdown } from "../lib/markdown"; import { escapeMarkdownTableCell, extractTableDataFromElement, @@ -478,9 +483,98 @@ describe("Table Utils", () => { const result = escapeMarkdownTableCell("Paragraph one.\nParagraph two."); expect(result).toBe("Paragraph one.
Paragraph two."); }); + + it("should escape HTML-sensitive characters as entities", () => { + expect(escapeMarkdownTableCell("Array")).toBe( + "Array<string>" + ); + expect(escapeMarkdownTableCell("©")).toBe("&copy;"); + expect(escapeMarkdownTableCell("a
b")).toBe("a<br>b"); + expect(escapeMarkdownTableCell("x < y & z > w")).toBe( + "x < y & z > w" + ); + }); }); describe("tableDataToMarkdown", () => { + it("should preserve literal HTML and entities when rendered again", () => { + const table = document.createElement("table"); + table.innerHTML = ` + Type <T>Entities + + Array<string> + &copy; and &#124; + + `; + const data = extractTableDataFromElement(table); + const markdown = tableDataToMarkdown(data); + const container = document.createElement("div"); + container.innerHTML = marked.parse(markdown, { async: false }); + const restoredTable = container.querySelector( + "table" + ) as HTMLTableElement; + + expect(extractTableDataFromElement(restoredTable)).toEqual(data); + }); + + it("should preserve literal HTML and entities via Streamdown Markdown", () => { + const table = document.createElement("table"); + table.innerHTML = ` + Type <T>Entities + + Array<string> + &copy; and &#124; + + `; + const data = extractTableDataFromElement(table); + const markdown = tableDataToMarkdown(data); + + // Same fixture through Streamdown's production pipeline (remark-gfm + rehype-raw) + const { container } = render( + Markdown({ + children: markdown, + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeRaw], + }) + ); + const restoredTable = container.querySelector( + "table" + ) as HTMLTableElement; + + expect(extractTableDataFromElement(restoredTable)).toEqual(data); + }); + + it("documents that other Markdown syntax is still re-parsed on render", () => { + // Pre-existing limitation: export escapes HTML (&/< />) and table syntax + // (|, \, newlines), but not general Markdown metacharacters. So pasted + // cells like **bold**, `code`, and [link](url) re-interpret on render. + // Broader metachar escaping is out of scope for the HTML-literal fix. + const data: TableData = { + headers: ["Cell"], + rows: [["**bold**"], ["`code`"], ["[link](https://example.com)"]], + }; + const markdown = tableDataToMarkdown(data); + + expect(markdown).toContain("**bold**"); + expect(markdown).toContain("`code`"); + expect(markdown).toContain("[link](https://example.com)"); + + const { container } = render( + Markdown({ + children: markdown, + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeRaw], + }) + ); + const cells = [...container.querySelectorAll("tbody td")].map( + (cell) => cell.innerHTML + ); + + expect(cells[0]).toContain(""); + expect(cells[1]).toContain(""); + expect(cells[2]).toContain(" { const data: TableData = { headers: ["Name", "Age", "City"], diff --git a/packages/streamdown/lib/table/utils.ts b/packages/streamdown/lib/table/utils.ts index aece0df5..05475940 100644 --- a/packages/streamdown/lib/table/utils.ts +++ b/packages/streamdown/lib/table/utils.ts @@ -184,7 +184,14 @@ 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 === "|" || char === "\n") { + if ( + char === "\\" || + char === "|" || + char === "\n" || + char === "&" || + char === "<" || + char === ">" + ) { needsEscaping = true; break; } @@ -202,6 +209,12 @@ export const escapeMarkdownTableCell = (cell: string): string => { parts.push("\\|"); } else if (char === "\n") { parts.push("
"); + } else if (char === "&") { + parts.push("&"); + } else if (char === "<") { + parts.push("<"); + } else if (char === ">") { + parts.push(">"); } else { parts.push(char); }