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..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
@@ -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
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);
@@ -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));
+ }
+ });
+
+ // 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;
+ }
+
+ 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 & 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,
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
new file mode 100644
index 0000000000..22d756510c
--- /dev/null
+++ b/apps/web/src/specs/features/tiptap-editor/empty-container-paste.spec.ts
@@ -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: "" });
+ 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")).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 html = "";
+
+ expect(parseAllExtensionsToDoc(html)).toContain("");
+ expect(insert(html)).toBe("");
+ });
+
+ it.each([
+ ["a real list", "- one\n- two", "one"],
+ ["a real table", "| a |\n| --- |\n| 1 |", " {
+ 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
b
"]
+ ])(
+ "does not leave a blank paragraph behind for a list holding only %s",
+ (_l: string, html: string) => {
+ 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. 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("");
+
+ 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", ""]
+ ])("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 less-than", "", "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(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", () => {
+ const html =
+ "";
+
+ expect(() => insert(html)).not.toThrow();
+ expect(insert(html)).toContain("x");
+ expect(insert(html)).not.toContain("<");
+ });
+
+ it("keeps a list whose only item is empty", () => {
+ expect(parseAllExtensionsToDoc("")).toBe("");
+ });
+});
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("");
+ const doc = parseAllExtensionsToDoc(
+ ""
+ );
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(
- ``
- );
-
- 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(
+ `
`
+ );
+
+ expect(doc).toContain(" | ");
+ expect(doc).not.toContain(" ");
+ }
+ );
it("renders a visually blank cell as exactly one paragraph in the editor", () => {
const html = pasteHtml("
");
@@ -118,7 +128,9 @@ describe("pasting a markdown table with blank cells", () => {
});
it("leaves a cell with real content alone", () => {
- const doc = parseAllExtensionsToDoc("");
+ const doc = parseAllExtensionsToDoc(
+ ""
+ );
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 4223f3cf4d..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
@@ -1,20 +1,34 @@
import { vi } from "vitest";
-vi.mock("@/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
+// 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 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
@@ -85,3 +99,30 @@ 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 ");
+
+ expect(html).toContain('src="https://files.peakd.com/file/peakd-hive/@bob/p.png"');
+ expect(html).toContain('data-type="mention"');
+ 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", () => {
+ const html = render("ping @alice about it");
+
+ expect(html).toContain("ping");
+ expect(html).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 =
'
';
- const {
- markdownAfterSave,
- reopenedHtml,
- markdownAfterPublishing,
- postEditHtml
- } = await runEditorRoundTrip(initialHtml);
+ const { markdownAfterSave, reopenedHtml, markdownAfterPublishing, postEditHtml } =
+ await runEditorRoundTrip(initialHtml);
expect(markdownAfterSave).toContain("![]()