diff --git a/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts b/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts
index 1c8a816392..705b03b8c4 100644
--- a/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts
+++ b/apps/web/src/features/tiptap-editor/functions/markdown-to-html.ts
@@ -1,9 +1,15 @@
// @ts-ignore
-import { strikethrough } from "@joplin/turndown-plugin-gfm";
+import { strikethrough, tables } from "@joplin/turndown-plugin-gfm";
import Turndown from "turndown";
import { TEXT_COLOR_CLASS_PREFIX } from "@/app/publish/_constants/text-colors";
+/** Total
/ | in a table, used to spot the single-cell case GFM cannot express. */
+function countTableCells(node: Node): number {
+ const el = node as HTMLElement;
+ return typeof el.querySelectorAll === "function" ? el.querySelectorAll("th, td").length : 0;
+}
+
const CENTERED_TEXT_RULE_NODES = ["P", "H1", "H2", "H3", "H4", "H5", "H6"];
const CENTERED_TEXT_ALIGNMENTS = new Set(["center", "right", "left", "justify"]);
@@ -46,6 +52,15 @@ export function markdownToHtml(html: string | undefined) {
html = html.replace(/]*data-type="mention"[^>]*>([^<]*)<\/span>/gi, "$1");
html = html.replace(/]*data-type="tag"[^>]*>([^<]*)<\/span>/gi, "$1");
+ // TipTap renders tables as ……, with the
+ // header cells as | in the first | row rather than in a .
+ // The GFM table rule only accepts a whose previous sibling is absent
+ // or an empty , so the makes it miss the heading row and
+ // emit an empty one above the real headers. Dropping (it carries
+ // only editor column widths, which markdown cannot express anyway) restores
+ // the check without touching the plugin.
+ html = html.replace(//gi, "");
+
return new Turndown({
codeBlockStyle: "fenced"
})
@@ -170,5 +185,22 @@ export function markdownToHtml(html: string | undefined) {
}
})
.use(strikethrough)
+ // Turndown has no built-in table rule. Without this the editor's
+ // HTML -> markdown pass drops every and leaves the cell text
+ // stacked as loose paragraphs, so a pasted or inserted table is
+ // destroyed on the next serialization.
+ .use(tables)
+ // Added AFTER the plugin so it takes precedence (Turndown checks the most
+ // recently added rule first). The GFM rule deliberately skips single-cell
+ // tables, treating them as layout markup, but the editor can produce one
+ // from the toolbar: insert a table, then deleteColumn and deleteRow. Such a
+ // table serialized to bare cell text, or to nothing at all when the cell was
+ // empty, so it disappeared on the next draft load or publish. GFM cannot
+ // express a headerless single-cell table, so keep it as HTML, which the
+ // renderer accepts and the sanitizer allows.
+ .addRule("singleCellTable", {
+ filter: (node) => node.nodeName === "TABLE" && countTableCells(node) <= 1,
+ replacement: (_content, node) => `\n\n${(node as HTMLElement).outerHTML}\n\n`
+ })
.turndown(html);
}
diff --git a/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts b/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts
new file mode 100644
index 0000000000..a77988f0f4
--- /dev/null
+++ b/apps/web/src/specs/features/tiptap-editor/table-roundtrip.spec.ts
@@ -0,0 +1,149 @@
+import { vi } from "vitest";
+
+vi.mock("@/features/tiptap-editor/extensions", () => ({
+ HIVE_POST_PURE_REGEX: /$a^/,
+ LOOM_REGEX: /$a^/,
+ TAG_MENTION_PURE_REGEX: /$a^/,
+ USER_MENTION_PURE_REGEX: /$a^/,
+ YOUTUBE_REGEX: /$a^/
+}));
+
+import { Editor } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import Table from "@tiptap/extension-table";
+import TableCell from "@tiptap/extension-table-cell";
+import TableHeader from "@tiptap/extension-table-header";
+import TableRow from "@tiptap/extension-table-row";
+import { simpleMarkdownToHTML } from "@ecency/render-helper";
+
+import { markdownToHtml } from "@/features/tiptap-editor/functions/markdown-to-html";
+import { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc";
+
+const TABLE_MARKDOWN = [
+ "| Date | Account | Amount |",
+ "| --- | --- | --- |",
+ "| 2023-01-06 | valueplan | 52,239.55 |",
+ "| 2020-04-03 | ecency | 941.94 |"
+].join("\n");
+
+const TABLE_EXTENSIONS = [StarterKit, Table, TableRow, TableCell, TableHeader];
+
+/**
+ * Mirrors what the publish editor actually does: paste plain text (converted by
+ * the clipboard strategy), then serialize the document back to markdown the way
+ * `use-publish-editor` does on every update.
+ */
+function pasteThenSerialize(markdown: string): string {
+ const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "" });
+ try {
+ editor.chain().insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))).run();
+ return markdownToHtml(editor.getHTML());
+ } finally {
+ editor.destroy();
+ }
+}
+
+/** Runs `build` against a live editor, then serializes exactly as publish does. */
+function buildThenSerialize(build: (editor: Editor) => void): string {
+ const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "" });
+ try {
+ build(editor);
+ return markdownToHtml(editor.getHTML());
+ } finally {
+ editor.destroy();
+ }
+}
+
+describe("markdown table round-trip through the publish editor", () => {
+ // Regression: Turndown was built with only the `strikethrough` GFM plugin, so
+ // it had no rule for and flattened every pasted table into loose text
+ // on the first serialization pass.
+ it("keeps a pasted table a table", () => {
+ const result = pasteThenSerialize(TABLE_MARKDOWN);
+
+ expect(result).toContain("| Date | Account | Amount |");
+ expect(result).toContain("| 2023-01-06 | valueplan | 52,239.55 |");
+ expect(result).toContain("| 2020-04-03 | ecency | 941.94 |");
+ });
+
+ it("keeps every row on its own line with a delimiter row", () => {
+ const lines = pasteThenSerialize(TABLE_MARKDOWN)
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.startsWith("|"));
+
+ // header + delimiter + 2 body rows
+ expect(lines).toHaveLength(4);
+ expect(lines[1]).toMatch(/^\|[-\s|:]+\|$/);
+ lines.forEach((line) => expect(line.split("|")).toHaveLength(5));
+ });
+
+ // Regression: TipTap emits before and keeps the header
+ // cells as | inside that | , which made the GFM rule miss the heading
+ // row and prepend an empty one ("| | | |") above the real headers.
+ it("uses the real header row rather than prepending an empty one", () => {
+ const lines = pasteThenSerialize(TABLE_MARKDOWN)
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.startsWith("|"));
+
+ expect(lines[0]).toContain("Date");
+ expect(lines[0]).toContain("Account");
+ expect(lines[0]).not.toMatch(/^\|(\s*\|)+$/);
+ });
+
+ it("does not flatten cells into loose paragraphs", () => {
+ const result = pasteThenSerialize(TABLE_MARKDOWN);
+
+ // the pre-fix output was "Date\n\nAccount\n\nAmount\n\n2023-01-06\n\n..."
+ expect(result).not.toMatch(/^Date\s*$/m);
+ expect(result).not.toMatch(/^valueplan\s*$/m);
+ });
+
+ it("serializes a table built with the editor's own insertTable command", () => {
+ const result = buildThenSerialize((editor) => {
+ editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
+ });
+
+ expect(result).toMatch(/\|.*\|/);
+ });
+});
+
+describe("single-cell tables the GFM rule cannot express", () => {
+ // Regression: the GFM table rule deliberately skips single-cell tables, so a
+ // 1x1 built from the toolbar serialized to bare text, or to nothing at all
+ // when empty, and vanished on the next draft load or publish.
+ const shrinkToSingleCell = (editor: Editor) => {
+ editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
+ editor.chain().focus().deleteColumn().run();
+ editor.chain().focus().deleteRow().run();
+ };
+
+ it("keeps an empty 1x1 table instead of serializing it away", () => {
+ const result = buildThenSerialize(shrinkToSingleCell);
+
+ expect(result.trim()).not.toBe("");
+ expect(result).toContain(" {
+ const result = buildThenSerialize((editor) => {
+ shrinkToSingleCell(editor);
+ editor.commands.insertContent("solo");
+ });
+
+ expect(result).toContain(" {
+ const result = buildThenSerialize((editor) => {
+ editor.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run();
+ });
+
+ expect(result).not.toContain(" |