From 9ff3343e032c1a5b121ed15c29c1ff5527cbc8d9 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:30:27 +0800 Subject: [PATCH 1/4] fix: preserve literal HTML in Markdown table exports Escape HTML-sensitive characters in table cell text and verify DOM-to-Markdown round trips. Assisted-by: OpenAI GPT-6 --- .../preserve-markdown-table-literals.md | 5 +++++ .../streamdown/__tests__/table-utils.test.ts | 21 +++++++++++++++++++ packages/streamdown/lib/table/utils.ts | 15 ++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .changeset/preserve-markdown-table-literals.md 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..e82ac0aa 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -1,3 +1,4 @@ +import { marked } from "marked"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { escapeMarkdownTableCell, @@ -481,6 +482,26 @@ describe("Table Utils", () => { }); 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 convert simple table data to Markdown", () => { 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); } From c3c005f8f5bf9cb2c2de954d5486e70e7ff25b81 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 15 Sep 2026 10:40:32 +0200 Subject: [PATCH 2/4] test: cover escapeMarkdownTableCell HTML entity contract Add direct expects for Array, ©, literal a
b, and mixed &/<>. --- packages/streamdown/__tests__/table-utils.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index e82ac0aa..ef7dbafe 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -479,6 +479,17 @@ 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", () => { From 72358a507691b210161c08a8cf171f4ddb71b2d2 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 15 Sep 2026 10:40:32 +0200 Subject: [PATCH 3/4] test: round-trip table Markdown via marked and Streamdown Keep the original marked DOM round-trip and add a duplicate fixture through Streamdown's Markdown path (remark-gfm + rehype-raw). --- .../streamdown/__tests__/table-utils.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index ef7dbafe..df3c21e9 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -1,5 +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, @@ -513,6 +517,33 @@ describe("Table Utils", () => { 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("should convert simple table data to Markdown", () => { const data: TableData = { headers: ["Name", "Age", "City"], From 1abac05ed496a223a2d05a5280c833069e36c1a0 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 15 Sep 2026 10:40:32 +0200 Subject: [PATCH 4/4] test: document Markdown metachar re-parse on table export Capture the pre-existing limitation that bold, code, and links still re-interpret when exported table Markdown is rendered again. --- .../streamdown/__tests__/table-utils.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/streamdown/__tests__/table-utils.test.ts b/packages/streamdown/__tests__/table-utils.test.ts index df3c21e9..45810257 100644 --- a/packages/streamdown/__tests__/table-utils.test.ts +++ b/packages/streamdown/__tests__/table-utils.test.ts @@ -544,6 +544,37 @@ describe("Table Utils", () => { 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"],