import { describe, it, expect } from "vitest"; // Markdown conversion now goes through the canonical package's BROWSER entry // (issue #347): the same converter the server import/export uses, resolved via // the `browser` exports condition so it runs on the native `DOMParser` (the // client jsdom vitest env provides one) with jsdom never bundled. import { convertProseMirrorToMarkdown, markdownToProseMirrorSync, } from "@docmost/prosemirror-markdown/browser"; import { normalizeTableColumnWidths, classifyClipboardSelection, } from "./markdown-clipboard"; // normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document). function root(html: string): HTMLElement { const div = document.createElement("div"); div.innerHTML = html; return div; } function firstRowColWidths(container: HTMLElement): (string | null)[] { const row = container.querySelector("tr"); return Array.from(row?.children ?? []).map((c) => c.getAttribute("colwidth"), ); } describe("normalizeTableColumnWidths", () => { // The core "squash столбцов вставленной таблицы" concern: markdown has no // widths, so every pasted table would otherwise render at table-layout:fixed // / 100% and squash columns. This stamps an explicit per-column px width. it("stamps the default px width on every column when no widths are present", () => { const container = root( "
abc
", ); normalizeTableColumnWidths(container); expect(firstRowColWidths(container)).toEqual(["150", "150", "150"]); }); it("derives column widths from a colgroup", () => { const container = root( "" + '' + "" + "
ab
", ); normalizeTableColumnWidths(container); expect(firstRowColWidths(container)).toEqual(["200", "80"]); }); it("derives column widths from per-cell width attributes", () => { const container = root( '
ab
', ); normalizeTableColumnWidths(container); expect(firstRowColWidths(container)).toEqual(["120", "90"]); }); it("derives column widths from a cell style:width:px", () => { const container = root( '
ab
', ); normalizeTableColumnWidths(container); // First cell width parsed; a fully-unmeasured column is left untouched // (the 100 fallback only fills in NULL gaps inside an otherwise-measured // multi-column slice, e.g. a colspan). expect(firstRowColWidths(container)).toEqual(["140", null]); }); it("fills a null gap inside a measured colspanned slice with 100", () => { // colgroup gives [200, null]; the single colspan=2 cell spans both, so its // slice is [200, null] -> the null is backfilled to 100 => "200,100". const container = root( "" + '' + '' + "
merged
", ); normalizeTableColumnWidths(container); expect(firstRowColWidths(container)).toEqual(["200,100"]); }); it("splits a measured width across a colspanned cell", () => { const container = root( '
mergedx
', ); normalizeTableColumnWidths(container); // 300 / colspan(2) = 150 per underlying column => "150,150" on the merged cell. expect(firstRowColWidths(container)).toEqual(["150,150", "100"]); }); it("falls back to the default width per spanned column when nothing is measurable", () => { const container = root( '
mergedx
', ); normalizeTableColumnWidths(container); expect(firstRowColWidths(container)).toEqual(["150,150", "150"]); }); it("leaves cells that already have a colwidth untouched", () => { const container = root( '
ab
', ); normalizeTableColumnWidths(container); expect(firstRowColWidths(container)).toEqual(["42", "150"]); }); it("normalizes every table in the subtree", () => { const container = root( "
a
" + "
bc
", ); normalizeTableColumnWidths(container); const tables = container.querySelectorAll("table"); const widths = Array.from(tables).map((t) => Array.from(t.querySelector("tr")!.children).map((c) => c.getAttribute("colwidth"), ), ); expect(widths).toEqual([["150"], ["150", "150"]]); }); it("only annotates the first row (column widths are defined once)", () => { const container = root( "" + "" + "" + "
ab
cd
", ); normalizeTableColumnWidths(container); const rows = container.querySelectorAll("tr"); expect( Array.from(rows[1].children).map((c) => c.getAttribute("colwidth")), ).toEqual([null, null]); }); }); describe("classifyClipboardSelection", () => { it("serializes a list of 2+ items as markdown", () => { expect( classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]), ).toEqual({ asMarkdown: true, wrapBareRows: false }); }); it("leaves a single-item list as plain text", () => { expect( classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]), ).toEqual({ asMarkdown: false, wrapBareRows: false }); }); it("serializes a whole table without wrapping bare rows", () => { expect( classifyClipboardSelection([{ name: "table", childCount: 3 }]), ).toEqual({ asMarkdown: true, wrapBareRows: false }); }); it("serializes a partial cell selection (bare rows) and flags wrapping", () => { expect( classifyClipboardSelection([ { name: "tableRow", childCount: 2 }, { name: "tableRow", childCount: 2 }, ]), ).toEqual({ asMarkdown: true, wrapBareRows: true }); }); it("leaves plain paragraphs as plain text", () => { expect( classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]), ).toEqual({ asMarkdown: false, wrapBareRows: false }); }); it("does not wrap when rows are mixed with other block types", () => { expect( classifyClipboardSelection([ { name: "tableRow", childCount: 2 }, { name: "paragraph", childCount: 1 }, ]), ).toEqual({ asMarkdown: false, wrapBareRows: false }); }); }); // Output-level tests for the table clipboard regression: copying a table must // yield a real GFM pipe table, NOT one-value-per-line concatenated cells. // These exercise the actual markdown produced by convertProseMirrorToMarkdown — // the same serializer step the clipboardTextSerializer now runs (issue #347) — // so they pin the OUTPUT shape that the classifier-flag tests above do not cover. // Input is ProseMirror JSON (what the copied slice serializes to), matching the // clipboardTextSerializer's new call: it wraps the slice content in a synthetic // `doc` (and the bare-rows case in a `table`) and calls the converter. describe("table clipboard markdown output (convertProseMirrorToMarkdown)", () => { // Trim each line and drop blanks so structural assertions are whitespace-robust. function lines(md: string): string[] { return md .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); } // A GFM separator row like "| --- | --- |" (any number of columns), tolerant // of the padding the serializer emits. function isSeparatorRow(line: string): boolean { const compact = line.replace(/\s+/g, ""); return /^\|(?::?-{2,}:?\|)+$/.test(compact); } // Split a pipe-delimited row into trimmed cell values. function cells(line: string): string[] { return line .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((c) => c.trim()); } const cell = (t: string) => ({ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }], }); const headerCell = (t: string) => ({ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }], }); const row = (nodes: any[]) => ({ type: "tableRow", content: nodes }); it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => { // Mirror the serializer's `wrapBareRows` branch: bare tableRow nodes are // wrapped in a synthetic `table` and convertProseMirrorToMarkdown is called // (see markdown-clipboard.ts clipboardTextSerializer). const rows = [ row([cell("a"), cell("b")]), row([cell("c"), cell("d")]), ]; const md = convertProseMirrorToMarkdown({ type: "doc", content: [{ type: "table", content: rows }], }); const ls = lines(md); // Valid GFM: a header/data separator row is present. expect(ls.some(isSeparatorRow)).toBe(true); // NOT the old broken "one value per line" shape: every line is pipe-delimited. expect(ls.every((l) => l.includes("|"))).toBe(true); expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m); // The cell values land in real pipe-delimited data rows. const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells); expect(dataRows).toContainEqual(["a", "b"]); expect(dataRows).toContainEqual(["c", "d"]); }); it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => { // Mirror the serializer's non-wrap branch: the full `table` node is the // slice content and convertProseMirrorToMarkdown runs on it. const md = convertProseMirrorToMarkdown({ type: "doc", content: [ { type: "table", content: [ row([headerCell("Name"), headerCell("Age")]), row([cell("Alice"), cell("30")]), row([cell("Bob"), cell("25")]), ], }, ], }); const ls = lines(md); // Proper GFM structure: separator row + all rows pipe-delimited. expect(ls.some(isSeparatorRow)).toBe(true); expect(ls.every((l) => l.includes("|"))).toBe(true); const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells); // Header row comes first, followed by both data rows. expect(rows[0]).toEqual(["Name", "Age"]); expect(rows).toContainEqual(["Alice", "30"]); expect(rows).toContainEqual(["Bob", "25"]); // Headline regression: the table is NOT concatenated one-value-per-line. expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m); }); }); // #347 acceptance: pasting CANONICAL markdown yields the SAME nodes the server // import produces for the same text. The paste path calls markdownToProseMirror // (the package browser entry) — the identical converter the server import uses — // so asserting the converter (via the browser entry, on the native DOMParser) // recognizes each canon form pins the paste-parity guarantee. These forms were // NOT recognized by the old editor-ext marked layer the paste used before. describe("canonical markdown paste recognition (browser entry parity)", () => { // Collect every node type present in a doc (recursively). const collectTypes = (n: any, set = new Set()): Set => { if (!n || typeof n !== "object") return set; if (n.type) set.add(n.type); if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set)); return set; }; const findNode = (n: any, type: string): any => { if (!n || typeof n !== "object") return undefined; if (n.type === type) return n; if (Array.isArray(n.content)) { for (const c of n.content) { const hit = findNode(c, type); if (hit) return hit; } } return undefined; }; const allText = (n: any): string => { if (!n || typeof n !== "object") return ""; if (typeof n.text === "string") return n.text; if (Array.isArray(n.content)) return n.content.map(allText).join(""); return ""; }; it("^[…] inline footnote -> footnoteReference + footnotesList", () => { const doc = markdownToProseMirrorSync("Body^[a note here]."); const types = collectTypes(doc); expect(types.has("footnoteReference")).toBe(true); expect(types.has("footnotesList")).toBe(true); expect(types.has("footnoteDefinition")).toBe(true); }); it(' attached image comment -> image with align', () => { const doc = markdownToProseMirrorSync( '![alt](/files/x.png) ', ); const img = findNode(doc, "image"); expect(img).toBeTruthy(); expect(img.attrs?.align).toBe("left"); expect(img.attrs?.src).toBe("/files/x.png"); }); it("> [!type] Obsidian callout -> callout node with type", () => { const doc = markdownToProseMirrorSync("> [!warning]\n> be careful"); const callout = findNode(doc, "callout"); expect(callout).toBeTruthy(); expect(callout.attrs?.type).toBe("warning"); expect(allText(callout)).toContain("be careful"); }); it("$…$ inline math -> mathInline node", () => { const doc = markdownToProseMirrorSync("Euler: $e^{i\\pi}+1=0$ done"); const math = findNode(doc, "mathInline"); expect(math).toBeTruthy(); expect(math.attrs?.text).toContain("e^{i\\pi}"); }); it("==…== highlight -> highlight mark", () => { const doc = markdownToProseMirrorSync("A ==marked== word"); const marked = findNode(doc, "text"); // The highlighted run carries a `highlight` mark somewhere in the doc. const hasHighlight = (n: any): boolean => { if (!n || typeof n !== "object") return false; if ( n.type === "text" && (n.marks || []).some((m: any) => m.type === "highlight") ) return true; return Array.isArray(n.content) ? n.content.some(hasHighlight) : false; }; expect(marked).toBeTruthy(); expect(hasHighlight(doc)).toBe(true); }); it(" standalone comment -> subpages node", () => { const doc = markdownToProseMirrorSync("intro\n\n\n\nafter"); expect(collectTypes(doc).has("subpages")).toBe(true); }); }); // #347 negatives: plain text carrying markdown-LIKE punctuation must NOT be // silently converted/mangled (currency, bare `==`, a `[^1]` reference form). describe("plain-text paste negatives (no phantom conversion)", () => { const findNode = (n: any, type: string): any => { if (!n || typeof n !== "object") return undefined; if (n.type === type) return n; if (Array.isArray(n.content)) { for (const c of n.content) { const hit = findNode(c, type); if (hit) return hit; } } return undefined; }; const collectTypes = (n: any, set = new Set()): Set => { if (!n || typeof n !== "object") return set; if (n.type) set.add(n.type); if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set)); return set; }; const allText = (n: any): string => { if (!n || typeof n !== "object") return ""; if (typeof n.text === "string") return n.text; if (Array.isArray(n.content)) return n.content.map(allText).join(""); return ""; }; it("currency `$5 and $10` is NOT turned into math", () => { const doc = markdownToProseMirrorSync("It costs $5 and $10 total"); expect(findNode(doc, "mathInline")).toBeFalsy(); expect(allText(doc)).toContain("$5 and $10"); }); it("a lone `==` is NOT turned into a highlight", () => { const doc = markdownToProseMirrorSync("compare a == b in code"); const hasHighlight = (n: any): boolean => { if (!n || typeof n !== "object") return false; if ( n.type === "text" && (n.marks || []).some((m: any) => m.type === "highlight") ) return true; return Array.isArray(n.content) ? n.content.some(hasHighlight) : false; }; expect(hasHighlight(doc)).toBe(false); expect(allText(doc)).toContain("== b"); }); it("a `[^1]` reference form (no `^[`) is NOT turned into a footnote", () => { const doc = markdownToProseMirrorSync("see note [^1] for details"); expect(collectTypes(doc).has("footnoteReference")).toBe(false); expect(allText(doc)).toContain("[^1]"); }); });