From 7fcf6f8e7096429b0f425042341d6bf45b9f4f01 Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 24 Aug 2026 09:24:02 +0000 Subject: [PATCH 1/5] fix(tiptap-editor): repair empty lists and rowless tables on paste bulletList and orderedList are "listItem+" and table is "tableRow+", so a container holding none is schema-invalid. node.check() runs outside the try/catch in insertContentAt, so the RangeError escapes the paste handler and the user loses the whole clipboard, not just the empty container. #1650 fixed this one level down, for empty items, blockquotes and cells. Unwrap the list rather than dropping it: a list whose only child is another list is invalid the same way (no direct item) and dropping it would take the nested one with it. Tables are removed, since a rowless table has nothing to keep. Runs before the item repair so an item left empty by an unwrap still gets its paragraph. Reachable from an ordinary paste, not just the HTML callers: the paste guard is /<[a-z]+>.*<\/[a-z]+>/gim with no s flag, so a snippet with its open and close tag on separate lines is never diverted to the HTML handler. 9 of the spec's 14 cases fail on develop. Closes #1653 --- .../functions/parse-all-extensions-to-doc.ts | 19 ++++ .../empty-container-paste.spec.ts | 93 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts diff --git a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts index ce83f5d3c3..7e389690eb 100644 --- a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts +++ b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts @@ -261,6 +261,25 @@ export function parseAllExtensionsToDoc(value?: string) { el.removeAttribute("data-align"); }); + // A list with no item, and a table with no row, are invalid the same way an empty + // item is: bulletList and orderedList are "listItem+", table is "tableRow+". The + // paste throws and the user loses everything, not just the empty container. + // Both render as nothing, so unwrap the list (which keeps a nested list that was + // its only child) and drop the table. This has to run BEFORE the item repair + // below so an item left empty here still gets its paragraph. + (Array.from(tree.querySelectorAll("ul, ol")) as HTMLElement[]).forEach((list) => { + const hasItem = Array.from(list.children).some((child) => child.tagName === "LI"); + if (!hasItem) { + list.replaceWith(...Array.from(list.childNodes)); + } + }); + + (Array.from(tree.querySelectorAll("table")) as HTMLElement[]).forEach((table) => { + if (!table.querySelector("tr")) { + table.remove(); + } + }); + // ProseMirror's listItem schema is "paragraph block*", so an item's FIRST child // has to be a paragraph. Markdown routinely produces items that break that rule, // and insertContent throws for the whole paste rather than for the one bad item, diff --git a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts new file mode 100644 index 0000000000..a2e32a38e5 --- /dev/null +++ b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts @@ -0,0 +1,93 @@ +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 Image from "@tiptap/extension-image"; +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 { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; + +const EXTENSIONS = [ + StarterKit, + Image.configure({ inline: true }), + Table, + TableRow, + TableCell, + TableHeader +]; + +function insert(html: string): string { + const editor = new Editor({ extensions: EXTENSIONS, content: "

" }); + try { + editor.chain().insertContent(parseAllExtensionsToDoc(html)).run(); + return editor.getHTML(); + } finally { + editor.destroy(); + } +} + +const pasteMarkdown = (markdown: string) => insert(simpleMarkdownToHTML(markdown)); + +// Regression: bulletList and orderedList are "listItem+" and table is "tableRow+", +// so a container holding none is schema-invalid. insertContent throws from inside +// the paste handler, so the user loses the entire clipboard rather than just the +// empty container. +describe("pasting an empty list or a rowless table", () => { + it.each([ + ["an empty bullet list", ""], + ["an empty ordered list", "
    "], + ["a list holding only whitespace", ""], + ["an empty list inside an item", ""], + ["a list whose only child is a list", ""], + ["an empty table", "
    "], + ["a table with an empty body", "
    "] + ])("survives %s", (_label: string, html: string) => { + expect(() => insert(html)).not.toThrow(); + }); + + it("survives an unfenced table snippet, which the paste guard does not divert", () => { + // The guard is /<[a-z]+>.*<\/[a-z]+>/gim with no s flag, so an open and close + // tag on separate lines travels the markdown path into this function. + expect(() => pasteMarkdown("Use the table tag:\n\n\n
    ")).not.toThrow(); + }); + + it("keeps the rest of the paste when an empty list is in it", () => { + const html = insert("

    before

    after

    "); + + expect(html).toContain("before"); + expect(html).toContain("after"); + }); + + it("keeps a nested list that was its parent's only child", () => { + expect(insert("")).toContain("x"); + }); + + it("leaves an item empty by removal with a paragraph, not a broken bullet", () => { + const doc = parseAllExtensionsToDoc(""); + + expect(doc).toContain("
  1. "); + }); + + it.each([ + ["a real list", "- one\n- two", "one"], + ["a real table", "| a |\n| --- |\n| 1 |", " { + expect(pasteMarkdown(markdown)).toContain(kept); + }); + + it("keeps a list whose only item is empty", () => { + expect(parseAllExtensionsToDoc("")).toBe(""); + }); +}); From 0bd5adaf78d193622809e9cc077a452f8d73014d Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 24 Aug 2026 09:35:11 +0000 Subject: [PATCH 2/5] fix(tiptap-editor): keep visible content when unwrapping an invalid container Two review findings, both real: - unwrapping carried whitespace-only text out of the container, so a list holding nothing but spaces left a blank paragraph in the document - a rowless table was dropped outright, taking a caption's text with it Both now unwrap to the same set: element children, plus text that is actually visible. --- .../functions/parse-all-extensions-to-doc.ts | 17 +++++++++-- .../empty-container-paste.spec.ts | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts index 7e389690eb..79d4f036a0 100644 --- a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts +++ b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts @@ -50,6 +50,17 @@ const RENDERS_AS_NODE = [ 'span[data-type="tag"]' ].join(", "); +/** + * What survives when an invalid container is unwrapped: its elements, plus text + * that is actually visible. Whitespace-only text is dropped, or unwrapping a list + * that held nothing but spaces would leave them behind as a blank paragraph. + */ +function keptOnUnwrap(el: Element) { + return Array.from(el.childNodes).filter( + (node) => node.nodeType === Node.ELEMENT_NODE || node.textContent?.trim() + ); +} + /** True when the element holds nothing the schema would render. */ function holdsNothingRenderable(el: Element) { return !el.textContent?.trim() && !el.querySelector(RENDERS_AS_NODE); @@ -270,13 +281,15 @@ export function parseAllExtensionsToDoc(value?: string) { (Array.from(tree.querySelectorAll("ul, ol")) as HTMLElement[]).forEach((list) => { const hasItem = Array.from(list.children).some((child) => child.tagName === "LI"); if (!hasItem) { - list.replaceWith(...Array.from(list.childNodes)); + list.replaceWith(...keptOnUnwrap(list)); } }); + // Unwrap the table too rather than dropping it: a rowless one can still hold a + // caption, and that text is the author's. (Array.from(tree.querySelectorAll("table")) as HTMLElement[]).forEach((table) => { if (!table.querySelector("tr")) { - table.remove(); + table.replaceWith(...keptOnUnwrap(table)); } }); diff --git a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts index a2e32a38e5..5004054e6c 100644 --- a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts @@ -87,6 +87,36 @@ describe("pasting an empty list or a rowless table", () => { expect(pasteMarkdown(markdown)).toContain(kept); }); + // Review: unwrapping used to carry whitespace-only text out of the container, + // which the editor then rendered as a blank paragraph. + it.each([ + ["spaces", "

    a

    b

    "], + ["a newline", "

    a

      \n

    b

    "] + ])( + "does not leave a blank paragraph behind for a list holding only %s", + (_l: string, html: string) => { + const doc = parseAllExtensionsToDoc(html); + + expect(doc).toBe(html.replace(/
      [\s]*<\/ul>/, "")); + } + ); + + // Review: a rowless table can still carry a caption, and that text is the + // author's, so unwrap rather than drop. + it("keeps the caption of a rowless table", () => { + const doc = parseAllExtensionsToDoc("
      Quarterly totals
      "); + + expect(doc).toContain("Quarterly totals"); + }); + + it("keeps a nested table whose outer table has no row of its own", () => { + const html = + "
      x
      "; + + expect(() => insert(html)).not.toThrow(); + expect(insert(html)).toContain("x"); + }); + it("keeps a list whose only item is empty", () => { expect(parseAllExtensionsToDoc("
      ")).toBe("
      "); }); From 2c3530e1c1c07c1791e85bf92dd7d43fc3a6239d Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 24 Aug 2026 09:38:23 +0000 Subject: [PATCH 3/5] test(tiptap-editor): drive the paste specs with the production mention regexes Carried over from the #1654 review, which flagged the specs for mocking an internal module. The real risk behind that rule is drift: the mention spec hand-copied USER_MENTION_PURE_REGEX, so a change to the production one would leave the spec green while paste broke. Both specs now spread importActual and override only the three link-based regexes. Those three stay stubbed deliberately. Their passes read el.innerText, which jsdom does not implement, and the hive-post filter calls .trim() on it unguarded, so a real hive-post href throws in a spec instead of exercising anything. Same re-mock pattern CLAUDE.md documents for @/utils. --- .../tiptap-editor/empty-container-paste.spec.ts | 12 +++++++++--- .../tiptap-editor/mention-chip-paste.spec.ts | 14 +++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts index 5004054e6c..b3b85353f9 100644 --- a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts @@ -1,10 +1,16 @@ import { vi } from "vitest"; -vi.mock("@/features/tiptap-editor/extensions", () => ({ +// Real mention/tag regexes; nothing here contains a mention or a tag, so they +// are inert. The three link-based ones are stubbed to never match on purpose: +// their passes +// read `el.innerText`, which jsdom does not implement, and the hive-post filter +// calls `.trim()` on it unguarded, so a real hive-post href would throw here +// rather than exercise anything. Same re-mock pattern CLAUDE.md documents for +// `@/utils`. +vi.mock("@/features/tiptap-editor/extensions", async () => ({ + ...(await vi.importActual("@/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^/ })); diff --git a/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts index 4223f3cf4d..8e2897aabb 100644 --- a/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts @@ -1,12 +1,16 @@ import { vi } from "vitest"; -vi.mock("@/features/tiptap-editor/extensions", () => ({ +// Use the production mention/tag regexes so this spec cannot drift from them. +// The three link-based ones are stubbed to never match on purpose: their passes +// read `el.innerText`, which jsdom does not implement, and the hive-post filter +// calls `.trim()` on it unguarded, so a real hive-post href would throw here +// rather than exercise anything. Same re-mock pattern CLAUDE.md documents for +// `@/utils`. +vi.mock("@/features/tiptap-editor/extensions", async () => ({ + ...(await vi.importActual("@/features/tiptap-editor/extensions")), HIVE_POST_PURE_REGEX: /$a^/, LOOM_REGEX: /$a^/, - YOUTUBE_REGEX: /$a^/, - TAG_MENTION_PURE_REGEX: /#\w+/gi, - USER_MENTION_PURE_REGEX: - /@(?=[a-zA-Z][a-zA-Z0-9.-]{1,15}\b)[a-zA-Z][a-zA-Z0-9-]{2,}(?:\.[a-zA-Z][a-zA-Z0-9-]{2,})*\b/gi + YOUTUBE_REGEX: /$a^/ })); import { simpleMarkdownToHTML } from "@ecency/render-helper"; From 4f50004faa202198df499227085d48f97d26f8bb Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 24 Aug 2026 10:48:46 +0000 Subject: [PATCH 4/5] fix(tiptap-editor): flatten rowless table wrappers instead of keeping them Review finding, confirmed in real Chromium as well as jsdom: keeping the structural children meant and reached the document as escaped literal markup, `

      <caption>Totals</caption>

      `. The mechanism is tiptap, not the DOM. insertContentAt leaves isOnlyTextContent true when the parsed fragment is empty, then calls tr.insertText with the RAW HTML STRING. So the wrappers are now discarded and only their content is lifted, and that content is wrapped in a paragraph. Without the wrapper the whole paste is text alone, which takes the same insertText branch and double-escapes an entity: a caption reading `A & B` arrived as `A & B`. The specs were the reason this got through. They asserted on the intermediate HTML from parseAllExtensionsToDoc, where "contains the caption text" is true while the editor shows the tag. They now assert what the editor ends up with. Two more spec corrections while here, both from the same review: - the schema was a strict subset of production's, missing mention, tag and the four embeds, so a guard naming those nodes could never be verified and good content could look like a crash. There is now one shared extension list built from the real extensions, and the sibling specs use it too. - the comment justifying the regex stubs was wrong. Only the hive-post pass reads el.innerText; YouTube and Loom were stubbed for a reason that does not apply to them, which deleted real coverage. Only hive-post is stubbed now. Spec goes from 7 failures on the previous commit and 16 on develop to green. --- .../functions/parse-all-extensions-to-doc.ts | 53 ++++++++++- .../blockquote-paste-normalization.spec.ts | 9 +- .../empty-container-paste.spec.ts | 88 ++++++++++++------- .../empty-list-item-paste.spec.ts | 31 +++---- .../empty-table-cell-paste.spec.ts | 36 +++++--- .../tiptap-editor/mention-chip-paste.spec.ts | 51 +++++++++-- .../publish-editor-extensions.ts | 45 ++++++++++ .../text-color-roundtrip.spec.ts | 68 ++++---------- 8 files changed, 248 insertions(+), 133 deletions(-) create mode 100644 apps/web/src/specs/features/tiptap-editor/publish-editor-extensions.ts diff --git a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts index 79d4f036a0..dd406bd34f 100644 --- a/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts +++ b/apps/web/src/features/tiptap-editor/functions/parse-all-extensions-to-doc.ts @@ -61,6 +61,33 @@ function keptOnUnwrap(el: Element) { ); } +/** Elements that only mean anything inside a table. */ +const TABLE_STRUCTURE = "caption, colgroup, col, thead, tbody, tfoot, tr, td, th"; + +/** Anything that must not be tucked inside a paragraph. */ +const BLOCK_LEVEL = [LEADING_BLOCK, "p", "div"].join(", "); + +/** + * Lifts a rowless table's content out of its structural wrappers. + * + * ⛔ Do not keep those wrappers. Outside a table the HTML parser will not accept + * a or a , and the markup ends up in the document as escaped + * literal text: `

      <caption>Totals</caption>

      `. Only the content + * inside them is worth anything, and loose inline content becomes a paragraph on + * its own. A nested table cannot be lost here, because a table holding one with + * any row at all is left alone. + */ +function unwrapTableStructure(el: Element): Node[] { + return Array.from(el.childNodes).flatMap((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) { + return node.textContent?.trim() ? [node] : []; + } + + const child = node as Element; + return child.matches(TABLE_STRUCTURE) ? unwrapTableStructure(child) : [child]; + }); +} + /** True when the element holds nothing the schema would render. */ function holdsNothingRenderable(el: Element) { return !el.textContent?.trim() && !el.querySelector(RENDERS_AS_NODE); @@ -286,11 +313,31 @@ export function parseAllExtensionsToDoc(value?: string) { }); // Unwrap the table too rather than dropping it: a rowless one can still hold a - // caption, and that text is the author's. + // caption, and that text is the author's. The wrappers themselves are discarded, + // see unwrapTableStructure. (Array.from(tree.querySelectorAll("table")) as HTMLElement[]).forEach((table) => { - if (!table.querySelector("tr")) { - table.replaceWith(...keptOnUnwrap(table)); + if (table.querySelector("tr")) { + return; } + + const kept = unwrapTableStructure(table); + const isInline = kept.every( + (node) => node.nodeType !== Node.ELEMENT_NODE || !(node as Element).matches(BLOCK_LEVEL) + ); + + // ⛔ Wrap loose inline content in a paragraph rather than leaving it bare. + // When the whole paste parses to text alone, tiptap's insertContentAt takes + // its isOnlyTextContent branch and calls tr.insertText with the RAW HTML + // STRING, so `A & B` reaches the document as the literal characters + // `A &amp; B`. A block wrapper keeps it on the normal parse path. + if (kept.length && isInline) { + const paragraph = document.createElement("p"); + paragraph.append(...kept); + table.replaceWith(paragraph); + return; + } + + table.replaceWith(...kept); }); // ProseMirror's listItem schema is "paragraph block*", so an item's FIRST child diff --git a/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts b/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts index f143f88a06..2c90f1db2e 100644 --- a/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/blockquote-paste-normalization.spec.ts @@ -28,12 +28,9 @@ function insertPastedMarkdown(markdown: string) { describe("blockquote paste normalization", () => { // Regression: pasting a bare quote marker produced
      , // which ProseMirror rejects with "RangeError: Invalid content for node blockquote: <>" - it.each([">", "> ", "hello\n\n>", ">>"])( - "inserts markdown %j without throwing", - (markdown) => { - expect(() => insertPastedMarkdown(markdown)).not.toThrow(); - } - ); + it.each([">", "> ", "hello\n\n>", ">>"])("inserts markdown %j without throwing", (markdown) => { + expect(() => insertPastedMarkdown(markdown)).not.toThrow(); + }); it("fills empty blockquotes with a paragraph", () => { expect(parseAllExtensionsToDoc("
      ")).toBe( diff --git a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts index b3b85353f9..22d756510c 100644 --- a/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts @@ -1,38 +1,22 @@ import { vi } from "vitest"; -// Real mention/tag regexes; nothing here contains a mention or a tag, so they -// are inert. The three link-based ones are stubbed to never match on purpose: -// their passes -// read `el.innerText`, which jsdom does not implement, and the hive-post filter -// calls `.trim()` on it unguarded, so a real hive-post href would throw here -// rather than exercise anything. Same re-mock pattern CLAUDE.md documents for -// `@/utils`. +// Only the hive-post pass needs stubbing here: it is the one that reads +// `el.innerText`, which jsdom does not implement, and it calls `.trim()` on it +// unguarded, so a hive-post-shaped href throws in a spec instead of exercising +// anything. Everything else, including the YouTube and Loom passes, runs for +// real. Same re-mock pattern CLAUDE.md documents for `@/utils`. vi.mock("@/features/tiptap-editor/extensions", async () => ({ ...(await vi.importActual("@/features/tiptap-editor/extensions")), - HIVE_POST_PURE_REGEX: /$a^/, - LOOM_REGEX: /$a^/, - YOUTUBE_REGEX: /$a^/ + HIVE_POST_PURE_REGEX: /$a^/ })); import { Editor } from "@tiptap/core"; -import StarterKit from "@tiptap/starter-kit"; -import Image from "@tiptap/extension-image"; -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 { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; +import { PUBLISH_EDITOR_EXTENSIONS } from "./publish-editor-extensions"; -const EXTENSIONS = [ - StarterKit, - Image.configure({ inline: true }), - Table, - TableRow, - TableCell, - TableHeader -]; +const EXTENSIONS = PUBLISH_EDITOR_EXTENSIONS; function insert(html: string): string { const editor = new Editor({ extensions: EXTENSIONS, content: "

      " }); @@ -81,9 +65,10 @@ describe("pasting an empty list or a rowless table", () => { }); it("leaves an item empty by removal with a paragraph, not a broken bullet", () => { - const doc = parseAllExtensionsToDoc("
      • a
        "); + const html = "
        • a
          "; - expect(doc).toContain("
        • "); + expect(parseAllExtensionsToDoc(html)).toContain("
        • "); + expect(insert(html)).toBe("
          • a

          "); }); it.each([ @@ -101,18 +86,54 @@ describe("pasting an empty list or a rowless table", () => { ])( "does not leave a blank paragraph behind for a list holding only %s", (_l: string, html: string) => { - const doc = parseAllExtensionsToDoc(html); - - expect(doc).toBe(html.replace(/
            [\s]*<\/ul>/, "")); + expect(parseAllExtensionsToDoc(html)).toBe(html.replace(/
              [\s]*<\/ul>/, "")); + expect(insert(html)).toBe("

              a

              b

              "); } ); // Review: a rowless table can still carry a caption, and that text is the - // author's, so unwrap rather than drop. - it("keeps the caption of a rowless table", () => { - const doc = parseAllExtensionsToDoc("
              Quarterly totals
              "); + // author's, so unwrap rather than drop. Assert the editor's own output, not the + // intermediate HTML: keeping the wrapper also "contains" the text, + // while the editor renders the tag itself as escaped literal markup. + it("keeps the caption text of a rowless table as text", () => { + const html = insert("
              Quarterly totals
              "); + + expect(html).toContain("Quarterly totals"); + expect(html).not.toContain("<"); + }); + + it.each([ + ["an empty body", "
              "], + ["an empty head and body", "
              "], + ["a column group", "
              "], + ["a caption beside an empty body", "
              Totals
              "] + ])("does not leak the wrappers of a rowless table holding %s", (_l: string, html: string) => { + const rendered = insert(html); + + // Not a tag-name proxy: caption TEXT may legitimately contain those words. + // What must never appear is escaped markup, which is how tiptap surfaces a + // paste that parses to nothing. + expect(rendered).not.toContain("<"); + expect(rendered).not.toContain(">"); + }); + + // Regression: flattening a caption to bare text put the whole paste on tiptap's + // isOnlyTextContent branch, which calls tr.insertText with the RAW HTML string, + // so an entity in the caption reached the document double-escaped. + it.each([ + ["an ampersand", "
              A & B
              ", "A & B"], + ["a less-than", "
              1 < 2
              ", "1 < 2"] + ])("keeps caption text containing %s intact", (_l: string, html: string, expected: string) => { + expect(insert(html)).toBe(`

              ${expected}

              `); + }); + + it("keeps an image held in the caption of a rowless table", () => { + const rendered = insert( + '
              ' + ); - expect(doc).toContain("Quarterly totals"); + expect(rendered).toContain("https://images.test/a.png"); + expect(rendered).not.toContain("<"); }); it("keeps a nested table whose outer table has no row of its own", () => { @@ -121,6 +142,7 @@ describe("pasting an empty list or a rowless table", () => { expect(() => insert(html)).not.toThrow(); expect(insert(html)).toContain("x"); + expect(insert(html)).not.toContain("<"); }); it("keeps a list whose only item is empty", () => { diff --git a/apps/web/src/specs/features/tiptap-editor/empty-list-item-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-list-item-paste.spec.ts index 410b197928..f7e6c27b02 100644 --- a/apps/web/src/specs/features/tiptap-editor/empty-list-item-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/empty-list-item-paste.spec.ts @@ -1,35 +1,24 @@ 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: - /^https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[^\s]*)?/i +// Only the hive-post pass needs stubbing here: it is the one that reads +// `el.innerText`, which jsdom does not implement, and it calls `.trim()` on it +// unguarded, so a hive-post-shaped href throws in a spec instead of exercising +// anything. Everything else, including the YouTube and Loom passes, runs for +// real. Same re-mock pattern CLAUDE.md documents for `@/utils`. +vi.mock("@/features/tiptap-editor/extensions", async () => ({ + ...(await vi.importActual("@/features/tiptap-editor/extensions")), + HIVE_POST_PURE_REGEX: /$a^/ })); import { Editor } from "@tiptap/core"; -import StarterKit from "@tiptap/starter-kit"; -import Image from "@tiptap/extension-image"; -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 { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; +import { PUBLISH_EDITOR_EXTENSIONS } from "./publish-editor-extensions"; // Must mirror the publish editor for the nodes under test. StarterKit alone has // no image node, which would make a perfectly renderable image look like a crash. -const LIST_EXTENSIONS = [ - StarterKit, - Image.configure({ inline: true }), - Table, - TableRow, - TableCell, - TableHeader -]; +const LIST_EXTENSIONS = PUBLISH_EDITOR_EXTENSIONS; /** Pastes markdown exactly as the clipboard text strategy does. */ function pasteMarkdown(markdown: string): string { diff --git a/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts index 865c2ce676..1a76b65406 100644 --- a/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/empty-table-cell-paste.spec.ts @@ -24,7 +24,10 @@ const TABLE_EXTENSIONS = [StarterKit, Table, TableRow, TableCell, TableHeader]; function pasteMarkdown(markdown: string): string { const editor = new Editor({ extensions: TABLE_EXTENSIONS, content: "

              " }); try { - editor.chain().insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))).run(); + editor + .chain() + .insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))) + .run(); return editor.getHTML(); } finally { editor.destroy(); @@ -71,7 +74,9 @@ describe("pasting a markdown table with blank cells", () => { }); it("preserves the content of the cells that are not blank", () => { - const html = pasteMarkdown("| Date | Memo |\n| --- | --- |\n| 2019-02-11 | |\n| 2023-01-06 | closed off |"); + const html = pasteMarkdown( + "| Date | Memo |\n| --- | --- |\n| 2019-02-11 | |\n| 2023-01-06 | closed off |" + ); expect(html).toContain("2019-02-11"); expect(html).toContain("2023-01-06"); @@ -87,7 +92,9 @@ describe("pasting a markdown table with blank cells", () => { }); it("fills a blank cell with an empty paragraph rather than dropping it", () => { - const doc = parseAllExtensionsToDoc("
              1
              "); + const doc = parseAllExtensionsToDoc( + "
              1
              " + ); expect(doc).toContain("

              "); }); @@ -101,14 +108,17 @@ describe("pasting a markdown table with blank cells", () => { ["plain spaces", " "], ["a tab", "\t"], ["mixed invisible content", "   "] - ])("normalises a cell holding only %s to a single empty paragraph", (_label: string, filler: string) => { - const doc = parseAllExtensionsToDoc( - `
              1${filler}
              ` - ); - - expect(doc).toContain("

              "); - expect(doc).not.toContain(" 

              "); - }); + ])( + "normalises a cell holding only %s to a single empty paragraph", + (_label: string, filler: string) => { + const doc = parseAllExtensionsToDoc( + `
              1${filler}
              ` + ); + + expect(doc).toContain("

              "); + expect(doc).not.toContain(" 

              "); + } + ); it("renders a visually blank cell as exactly one paragraph in the editor", () => { const html = pasteHtml("
              1 
              "); @@ -118,7 +128,9 @@ describe("pasting a markdown table with blank cells", () => { }); it("leaves a cell with real content alone", () => { - const doc = parseAllExtensionsToDoc("
              1kept
              "); + const doc = parseAllExtensionsToDoc( + "
              1kept
              " + ); expect(doc).toContain("kept"); expect(doc).not.toContain("

              "); diff --git a/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts index 8e2897aabb..c93e1749ac 100644 --- a/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts @@ -1,24 +1,34 @@ import { vi } from "vitest"; -// Use the production mention/tag regexes so this spec cannot drift from them. -// The three link-based ones are stubbed to never match on purpose: their passes -// read `el.innerText`, which jsdom does not implement, and the hive-post filter -// calls `.trim()` on it unguarded, so a real hive-post href would throw here -// rather than exercise anything. Same re-mock pattern CLAUDE.md documents for -// `@/utils`. +// Only the hive-post pass needs stubbing here: it is the one that reads +// `el.innerText`, which jsdom does not implement, and it calls `.trim()` on it +// unguarded, so a hive-post-shaped href throws in a spec instead of exercising +// anything. Everything else, including the YouTube and Loom passes, runs for +// real. Same re-mock pattern CLAUDE.md documents for `@/utils`. vi.mock("@/features/tiptap-editor/extensions", async () => ({ ...(await vi.importActual("@/features/tiptap-editor/extensions")), - HIVE_POST_PURE_REGEX: /$a^/, - LOOM_REGEX: /$a^/, - YOUTUBE_REGEX: /$a^/ + HIVE_POST_PURE_REGEX: /$a^/ })); +import { Editor } from "@tiptap/core"; import { simpleMarkdownToHTML } from "@ecency/render-helper"; import { parseAllExtensionsToDoc } from "@/features/tiptap-editor/functions/parse-all-extensions-to-doc"; +import { PUBLISH_EDITOR_EXTENSIONS } from "./publish-editor-extensions"; const paste = (markdown: string) => parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown)); +/** What the editor actually ends up showing, which is what the reader sees. */ +function render(markdown: string): string { + const editor = new Editor({ extensions: PUBLISH_EDITOR_EXTENSIONS, content: "

              " }); + try { + editor.chain().insertContent(paste(markdown)).run(); + return editor.getHTML(); + } finally { + editor.destroy(); + } +} + describe("turning mentions and tags into chips on paste", () => { // Regression: the rewrite used to run over innerHTML, which carries attribute // values. Hive image URLs contain /@author/, so a mention beside a hosted image @@ -89,3 +99,26 @@ describe("turning mentions and tags into chips on paste", () => { expect(doc).toContain('data-id="alice"'); }); }); + +// The assertions above read the intermediate HTML. These pin what the editor +// itself ends up with, because that is the thing the corruption destroyed and an +// intermediate-only assertion has already let this class of bug through once. +describe("what the editor ends up showing", () => { + it("still has the image after chipping a mention beside it", () => { + const html = render("@alice ![pic](https://files.peakd.com/file/peakd-hive/@bob/p.png)"); + + expect(html).toContain('src="https://files.peakd.com/file/peakd-hive/@bob/p.png"'); + expect(html).not.toContain("<"); + }); + + it("renders the mention as a node rather than literal markup", () => { + const html = render("hello @alice"); + + expect(html).toContain('data-type="mention"'); + expect(html).not.toContain("<span"); + }); + + it("keeps the surrounding words", () => { + expect(render("ping @alice about it")).toContain("about it"); + }); +}); diff --git a/apps/web/src/specs/features/tiptap-editor/publish-editor-extensions.ts b/apps/web/src/specs/features/tiptap-editor/publish-editor-extensions.ts new file mode 100644 index 0000000000..486f3028e4 --- /dev/null +++ b/apps/web/src/specs/features/tiptap-editor/publish-editor-extensions.ts @@ -0,0 +1,45 @@ +import { AnyExtension } from "@tiptap/core"; +import Image from "@tiptap/extension-image"; +import Mention from "@tiptap/extension-mention"; +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 StarterKit from "@tiptap/starter-kit"; + +import { + HivePostExtension, + LoomVideoExtension, + ThreeSpeakVideoExtension, + YoutubeVideoExtension +} from "@/features/tiptap-editor/extensions"; + +/** + * The nodes the publish editor really has, for specs that assert what the editor + * ends up showing. + * + * ⚠️ A subset schema gives WRONG answers in both directions: a node the spec is + * missing makes perfectly good content look like a crash, and a guard naming a + * node the spec lacks can never be verified. That has already bitten twice, once + * for `image` and once for the four embeds below. Keep this in step with + * app/publish/_hooks/use-publish-editor.ts. + * + * The real embed extensions are imported rather than stubbed so their parse rules + * cannot drift from production. Extensions that add no node or mark (Placeholder, + * TextAlign, Selection) are left out; the marks the editor adds are not exercised + * by the paste-normalisation specs. + */ +export const PUBLISH_EDITOR_EXTENSIONS: AnyExtension[] = [ + StarterKit.configure({ strike: false }) as AnyExtension, + Image.configure({ inline: true }), + Table, + TableRow, + TableCell, + TableHeader, + Mention, + Mention.extend({ name: "tag", priority: 102 }), + YoutubeVideoExtension, + ThreeSpeakVideoExtension, + LoomVideoExtension, + HivePostExtension +]; diff --git a/apps/web/src/specs/features/tiptap-editor/text-color-roundtrip.spec.ts b/apps/web/src/specs/features/tiptap-editor/text-color-roundtrip.spec.ts index 256a5ac68a..babcba58ee 100644 --- a/apps/web/src/specs/features/tiptap-editor/text-color-roundtrip.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/text-color-roundtrip.spec.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest'; +import { vi } from "vitest"; vi.mock("@/features/shared", () => ({ error: vi.fn() @@ -48,29 +48,19 @@ describe("editor formatting persistence", () => { const colorClass = `${TEXT_COLOR_CLASS_PREFIX}${colorSuffix}`; const initialHtml = `

              Colored text

              `; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); - - [markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml].forEach( - (content) => { - expect(content).toContain(colorClass); - } - ); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); + + [markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml].forEach((content) => { + expect(content).toContain(colorClass); + }); }); it("keeps bold text formatting across the editor lifecycle", async () => { const initialHtml = "

              Bold text

              "; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); expect(markdownAfterSave).toContain("**Bold text**"); expect(reopenedHtml).toContain("Bold text"); @@ -81,12 +71,8 @@ describe("editor formatting persistence", () => { it("keeps strikethrough formatting across the editor lifecycle", async () => { const initialHtml = "

              Struck text

              "; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); expect(markdownAfterSave).toContain("~~Struck text~~"); expect(reopenedHtml).toContain("Struck text"); @@ -106,12 +92,8 @@ describe("editor formatting persistence", () => { it("keeps strikethrough formatting applied to headings", async () => { const initialHtml = "

              Struck heading

              "; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); expect(markdownAfterSave).toMatch(/~~Struck heading~~\n[-=]+/); expect(reopenedHtml).toContain(" { it("keeps mixed strikethrough text inside headings", async () => { const initialHtml = "

              Struck and plain

              "; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); expect(markdownAfterSave).toMatch(/~~Struck~~ and plain/); expect(reopenedHtml).toContain(" { it("keeps paragraph alignment metadata for non-image content", async () => { const initialHtml = '

              Aligned text

              '; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); // Turndown outputs data-align, parseAllExtensionsToDoc converts it to style.textAlign expect(markdownAfterSave).toContain('data-align="right"'); @@ -162,12 +136,8 @@ describe("editor formatting persistence", () => { const initialHtml = '

              Example

              '; - const { - markdownAfterSave, - reopenedHtml, - markdownAfterPublishing, - postEditHtml - } = await runEditorRoundTrip(initialHtml); + const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } = + await runEditorRoundTrip(initialHtml); expect(markdownAfterSave).toContain("
              Date: Mon, 24 Aug 2026 10:58:26 +0000 Subject: [PATCH 5/5] test(tiptap-editor): assert both halves of the mention-beside-image case Review: the image test did not check that the mention survived alongside it, and the surrounding-words test checked only the trailing half. Both halves are the point of the case, since the corruption destroyed one while keeping the other. --- .../specs/features/tiptap-editor/mention-chip-paste.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts b/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts index c93e1749ac..41018be7db 100644 --- a/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts +++ b/apps/web/src/specs/features/tiptap-editor/mention-chip-paste.spec.ts @@ -108,6 +108,7 @@ describe("what the editor ends up showing", () => { const html = render("@alice ![pic](https://files.peakd.com/file/peakd-hive/@bob/p.png)"); expect(html).toContain('src="https://files.peakd.com/file/peakd-hive/@bob/p.png"'); + expect(html).toContain('data-type="mention"'); expect(html).not.toContain("<"); }); @@ -119,6 +120,9 @@ describe("what the editor ends up showing", () => { }); it("keeps the surrounding words", () => { - expect(render("ping @alice about it")).toContain("about it"); + const html = render("ping @alice about it"); + + expect(html).toContain("ping"); + expect(html).toContain("about it"); }); });