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("©");
+ 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> |
+ © and | |
+
+ `;
+ 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> |
+ © and | |
+
+ `;
+ 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);
}