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 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
in the first
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 , 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("
inside that