Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ 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()
);
}

/** 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 <tbody> or a <caption>, and the markup ends up in the document as escaped
* literal text: `<p>&lt;caption&gt;Totals&lt;/caption&gt;</p>`. 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);
Expand Down Expand Up @@ -261,6 +299,47 @@ 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(...keptOnUnwrap(list));
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
});

// Unwrap the table too rather than dropping it: a rowless one can still hold a
// 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")) {
return;
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

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 &amp; B` reaches the document as the literal characters
// `A &amp;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
// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ function insertPastedMarkdown(markdown: string) {
describe("blockquote paste normalization", () => {
// Regression: pasting a bare quote marker produced <blockquote></blockquote>,
// 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("<blockquote></blockquote>")).toBe(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { vi } from "vitest";

// 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 { 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 = PUBLISH_EDITOR_EXTENSIONS;

function insert(html: string): string {
const editor = new Editor({ extensions: EXTENSIONS, content: "<p></p>" });
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", "<ul></ul>"],
["an empty ordered list", "<ol></ol>"],
["a list holding only whitespace", "<ul> </ul>"],
["an empty list inside an item", "<ul><li>a</li><li><ul></ul></li></ul>"],
["a list whose only child is a list", "<ul><ul><li>x</li></ul></ul>"],
["an empty table", "<table></table>"],
["a table with an empty body", "<table><tbody></tbody></table>"]
])("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<table>\n</table>")).not.toThrow();
});

it("keeps the rest of the paste when an empty list is in it", () => {
const html = insert("<p>before</p><ul></ul><p>after</p>");

expect(html).toContain("before");
expect(html).toContain("after");
});

it("keeps a nested list that was its parent's only child", () => {
expect(insert("<ul><ul><li>x</li></ul></ul>")).toContain("x");
});

it("leaves an item empty by removal with a paragraph, not a broken bullet", () => {
const html = "<ul><li>a</li><li><ul></ul></li></ul>";

expect(parseAllExtensionsToDoc(html)).toContain("<li><p></p></li>");
expect(insert(html)).toBe("<ul><li><p>a</p></li><li><p></p></li></ul>");
});

it.each([
["a real list", "- one\n- two", "one"],
["a real table", "| a |\n| --- |\n| 1 |", "<table"]
])("leaves %s alone", (_label: string, markdown: string, kept: string) => {
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", "<p>a</p><ul> </ul><p>b</p>"],
["a newline", "<p>a</p><ul>\n</ul><p>b</p>"]
])(
"does not leave a blank paragraph behind for a list holding only %s",
(_l: string, html: string) => {
expect(parseAllExtensionsToDoc(html)).toBe(html.replace(/<ul>[\s]*<\/ul>/, ""));
expect(insert(html)).toBe("<p>a</p><p>b</p>");
}
);

// Review: a rowless table can still carry a caption, and that text is the
// author's, so unwrap rather than drop. Assert the editor's own output, not the
// intermediate HTML: keeping the <caption> 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("<table><caption>Quarterly totals</caption></table>");

expect(html).toContain("Quarterly totals");
expect(html).not.toContain("&lt;");
});

it.each([
["an empty body", "<table><tbody></tbody></table>"],
["an empty head and body", "<table><thead></thead><tbody></tbody></table>"],
["a column group", "<table><colgroup><col></colgroup></table>"],
["a caption beside an empty body", "<table><caption>Totals</caption><tbody></tbody></table>"]
])("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("&lt;");
expect(rendered).not.toContain("&gt;");
});

// 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", "<table><caption>A &amp; B</caption></table>", "A &amp; B"],
["a less-than", "<table><caption>1 &lt; 2</caption></table>", "1 &lt; 2"]
])("keeps caption text containing %s intact", (_l: string, html: string, expected: string) => {
expect(insert(html)).toBe(`<p>${expected}</p>`);
});

it("keeps an image held in the caption of a rowless table", () => {
const rendered = insert(
'<table><caption><img src="https://images.test/a.png"></caption></table>'
);

expect(rendered).toContain("https://images.test/a.png");
expect(rendered).not.toContain("&lt;");
});

it("keeps a nested table whose outer table has no row of its own", () => {
const html =
"<table><caption><table><tbody><tr><td>x</td></tr></tbody></table></caption></table>";

expect(() => insert(html)).not.toThrow();
expect(insert(html)).toContain("x");
expect(insert(html)).not.toContain("&lt;");
});

it("keeps a list whose only item is empty", () => {
expect(parseAllExtensionsToDoc("<ul><li></li></ul>")).toBe("<ul><li><p></p></li></ul>");
});
});
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p></p>" });
try {
editor.chain().insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown))).run();
editor
.chain()
.insertContent(parseAllExtensionsToDoc(simpleMarkdownToHTML(markdown)))
.run();
return editor.getHTML();
} finally {
editor.destroy();
Expand Down Expand Up @@ -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");
Expand All @@ -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("<table><tbody><tr><td>1</td><td></td></tr></tbody></table>");
const doc = parseAllExtensionsToDoc(
"<table><tbody><tr><td>1</td><td></td></tr></tbody></table>"
);

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

expect(doc).toContain("<td><p></p></td>");
expect(doc).not.toContain("&nbsp;<p>");
});
])(
"normalises a cell holding only %s to a single empty paragraph",
(_label: string, filler: string) => {
const doc = parseAllExtensionsToDoc(
`<table><tbody><tr><td>1</td><td>${filler}</td></tr></tbody></table>`
);

expect(doc).toContain("<td><p></p></td>");
expect(doc).not.toContain("&nbsp;<p>");
}
);

it("renders a visually blank cell as exactly one paragraph in the editor", () => {
const html = pasteHtml("<table><tbody><tr><td>1</td><td>&nbsp;</td></tr></tbody></table>");
Expand All @@ -118,7 +128,9 @@ describe("pasting a markdown table with blank cells", () => {
});

it("leaves a cell with real content alone", () => {
const doc = parseAllExtensionsToDoc("<table><tbody><tr><td>1</td><td>kept</td></tr></tbody></table>");
const doc = parseAllExtensionsToDoc(
"<table><tbody><tr><td>1</td><td>kept</td></tr></tbody></table>"
);

expect(doc).toContain("kept");
expect(doc).not.toContain("<td><p></p></td>");
Expand Down
Loading
Loading