5d8083f8ff
Третий шаг #345: клиентская вставка markdown переезжает с marked-слоя editor-ext (не знал канона — ^[…], <!--img {…}-->, <!--subpages--> при вставке не распознавались) на канонический пакет. По завершении слой удалён целиком. closes #347 - Browser-entry пакета (инъекция DOM-парсера): jsdom только в Node-пути. dom-parser.ts — 2 слота инъекции (HtmlDocumentParser для HTML→Document, GenerateJsonFn для @tiptap/html), без импорта DOM. dom-parser.node.ts регистрирует jsdom + @tiptap/html/server; dom-parser.browser.ts — нативный DOMParser + @tiptap/html (browser), экспонирован через exports-условие "browser" + сабпас "./browser". markdown-to-prosemirror.ts: убраны статический импорт jsdom и module-level global.window-шим. Клиент ВСЕГДА импортирует явный сабпас /browser — не полагается на порядок условий. Node-потребители (mcp/ server) идут по "." → default → index.js → jsdom, не затронуты. - markdown-clipboard.ts: конвертация через browser-entry (markdownToProseMirror → PM-JSON → HTML через живую схему редактора DOMSerializer → НЕИЗМЕНЁННЫЙ downstream-шов normalizeTableColumnWidths→parseSlice→canonicalizePastedFootnotes →dispatch). Эвристики/fragment-insertion не тронуты. Конвертер async → handle Paste захватывает диапазон, забирает событие, диспатчит на резолве; и success, и fail-open ветки защищены guard'ом doc!==startDoc (не диспатчить по устаревшему диапазону). clipboardTextSerializer (copy PM→md) — через convertProseMirror ToMarkdown. - Удалён packages/editor-ext/src/lib/markdown/ целиком (+ marked из package.json). Мигрированы ВСЕ потребители markdownToHtml/htmlToMarkdown: ai-chat/utils/ markdown.ts (→ новый markdownToProseMirrorSync + DOMSerializer), use-generate- page-title.ts / page-header-menu.tsx (→ convertProseMirrorToMarkdown(getJSON)), серверный spec. Grep: осиротевших импортов нет, editor-ext = только схема/ расширения. Turndown ушёл из бандла (был в старом htmlToMarkdown). - AI-чат теперь рендерит markdown через схему редактора (li в <p>); добавлен .markdown li p{margin:0} (CSS-модуль, скоуп только чата) — визуально плотно. Проверка: pmd tsc + vitest 744; client build УСПЕШЕН, grep бандла на JSDOM/parse5/happy-dom/turndown — 0 (утечки нет); клиентский suite + paste-тесты зелёные (34); editor-ext 196; node-потребители (mcp/server/git-sync) зелёные. Юнит-тесты: dual-path parity (jsdom==DOMParser), канон-формы == серверный импорт, негативы ($5/==/[^1] не корёжатся), async-paste (claim→convert→dispatch, fail-open). Ручная paste-QA полного клиентского round-trip (footnote/callout/math/image-comment; вставка из VSCode/Obsidian/GitHub в список/таблицу/callout) и Docker-сборка клиента (#333-класс) — за пределами автостенда, оставлено на ручную проверку. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
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(
|
|
"<table><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
|
|
);
|
|
normalizeTableColumnWidths(container);
|
|
expect(firstRowColWidths(container)).toEqual(["150", "150", "150"]);
|
|
});
|
|
|
|
it("derives column widths from a colgroup", () => {
|
|
const container = root(
|
|
"<table>" +
|
|
'<colgroup><col style="width:200px"><col style="width:80px"></colgroup>' +
|
|
"<tbody><tr><td>a</td><td>b</td></tr></tbody>" +
|
|
"</table>",
|
|
);
|
|
normalizeTableColumnWidths(container);
|
|
expect(firstRowColWidths(container)).toEqual(["200", "80"]);
|
|
});
|
|
|
|
it("derives column widths from per-cell width attributes", () => {
|
|
const container = root(
|
|
'<table><tbody><tr><td width="120">a</td><td width="90">b</td></tr></tbody></table>',
|
|
);
|
|
normalizeTableColumnWidths(container);
|
|
expect(firstRowColWidths(container)).toEqual(["120", "90"]);
|
|
});
|
|
|
|
it("derives column widths from a cell style:width:px", () => {
|
|
const container = root(
|
|
'<table><tbody><tr><td style="width:140px">a</td><td>b</td></tr></tbody></table>',
|
|
);
|
|
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(
|
|
"<table>" +
|
|
'<colgroup><col style="width:200px"><col></colgroup>' +
|
|
'<tbody><tr><td colspan="2">merged</td></tr></tbody>' +
|
|
"</table>",
|
|
);
|
|
normalizeTableColumnWidths(container);
|
|
expect(firstRowColWidths(container)).toEqual(["200,100"]);
|
|
});
|
|
|
|
it("splits a measured width across a colspanned cell", () => {
|
|
const container = root(
|
|
'<table><tbody><tr><td colspan="2" width="300">merged</td><td width="100">x</td></tr></tbody></table>',
|
|
);
|
|
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(
|
|
'<table><tbody><tr><td colspan="2">merged</td><td>x</td></tr></tbody></table>',
|
|
);
|
|
normalizeTableColumnWidths(container);
|
|
expect(firstRowColWidths(container)).toEqual(["150,150", "150"]);
|
|
});
|
|
|
|
it("leaves cells that already have a colwidth untouched", () => {
|
|
const container = root(
|
|
'<table><tbody><tr><td colwidth="42">a</td><td>b</td></tr></tbody></table>',
|
|
);
|
|
normalizeTableColumnWidths(container);
|
|
expect(firstRowColWidths(container)).toEqual(["42", "150"]);
|
|
});
|
|
|
|
it("normalizes every table in the subtree", () => {
|
|
const container = root(
|
|
"<table><tbody><tr><td>a</td></tr></tbody></table>" +
|
|
"<table><tbody><tr><td>b</td><td>c</td></tr></tbody></table>",
|
|
);
|
|
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(
|
|
"<table><tbody>" +
|
|
"<tr><td>a</td><td>b</td></tr>" +
|
|
"<tr><td>c</td><td>d</td></tr>" +
|
|
"</tbody></table>",
|
|
);
|
|
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<string>()): Set<string> => {
|
|
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('<!--img {…}--> attached image comment -> image with align', () => {
|
|
const doc = markdownToProseMirrorSync(
|
|
' <!--img {"align":"left"}-->',
|
|
);
|
|
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("<!--subpages--> standalone comment -> subpages node", () => {
|
|
const doc = markdownToProseMirrorSync("intro\n\n<!--subpages-->\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<string>()): Set<string> => {
|
|
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]");
|
|
});
|
|
});
|