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>
102 lines
4.2 KiB
TypeScript
102 lines
4.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { JSDOM } from "jsdom";
|
|
import {
|
|
setHtmlDocumentParser,
|
|
parseHtmlDocument,
|
|
} from "../src/lib/dom-parser.js";
|
|
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
|
|
|
|
/**
|
|
* The markdown import path parses its post-`marked` HTML through an INJECTED
|
|
* DOM parser: jsdom on the Node entry, native `DOMParser` on the browser entry
|
|
* (see dom-parser.node.ts / dom-parser.browser.ts). These tests exercise BOTH
|
|
* registrations against the same canonical inputs — the three DOM passes
|
|
* (task-list bridge, comment directives, footnote assembly) — and assert the
|
|
* converter produces the IDENTICAL ProseMirror doc regardless of which DOM
|
|
* parser is installed. That is the guarantee the client paste path relies on:
|
|
* pasting in the browser must yield the same nodes the server import produces.
|
|
*/
|
|
|
|
// A jsdom-backed parser (the Node entry's registration).
|
|
const jsdomParser = (html: string): Document =>
|
|
new JSDOM(html).window.document as unknown as Document;
|
|
|
|
// A `DOMParser`-backed parser standing in for the BROWSER entry's registration.
|
|
// We drive a real `DOMParser` from a jsdom window (jsdom exposes the same
|
|
// `new DOMParser().parseFromString(html, "text/html")` API the browser and the
|
|
// client's jsdom vitest environment provide), so this path uses the exact code
|
|
// dom-parser.browser.ts runs — a native `DOMParser`, no `JSDOM` document glue.
|
|
const browserWindow = new JSDOM("").window;
|
|
const domParserBackedParser = (html: string): Document =>
|
|
new browserWindow.DOMParser().parseFromString(
|
|
html,
|
|
"text/html",
|
|
) as unknown as Document;
|
|
|
|
// Canonical inputs, each hitting a different post-marked DOM pass.
|
|
const CASES: Record<string, string> = {
|
|
// footnote assembly (assembleFootnotes): `^[…]` -> sup + section/def
|
|
"inline footnote ^[…]": "Body^[a note].",
|
|
// task-list bridge (bridgeTaskLists): checkbox list -> taskList/taskItem
|
|
"task list": "- [x] done\n- [ ] todo",
|
|
// comment directives (applyCommentDirectives): standalone machinery comment
|
|
"standalone subpages comment": "text\n\n<!--subpages-->\n\ntext2",
|
|
// attached image comment (applyCommentDirectives img form)
|
|
"attached image comment": ' <!--img {"align":"left"}-->',
|
|
// github callout (preprocessCallouts bq path) + comment pass
|
|
"obsidian callout": "> [!info]\n> hello",
|
|
// highlight + math (marked extensions; still parsed through the DOM stage)
|
|
"highlight + math": "A ==mark== and $x^2$ end",
|
|
};
|
|
|
|
async function convertWith(
|
|
parser: (html: string) => Document,
|
|
md: string,
|
|
): Promise<any> {
|
|
setHtmlDocumentParser(parser);
|
|
return markdownToProseMirror(md);
|
|
}
|
|
|
|
describe("markdown import: Node (jsdom) and browser (DOMParser) DOM paths agree", () => {
|
|
afterEach(() => {
|
|
// Restore the jsdom parser the suite-wide setup file installs, so later
|
|
// tests in the run are unaffected by our per-case swaps.
|
|
setHtmlDocumentParser(jsdomParser);
|
|
});
|
|
|
|
for (const [name, md] of Object.entries(CASES)) {
|
|
it(`produces identical nodes for: ${name}`, async () => {
|
|
const viaJsdom = await convertWith(jsdomParser, md);
|
|
const viaDomParser = await convertWith(domParserBackedParser, md);
|
|
expect(viaDomParser).toEqual(viaJsdom);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("dom-parser injection contract", () => {
|
|
let saved: (html: string) => Document;
|
|
beforeEach(() => {
|
|
saved = jsdomParser;
|
|
});
|
|
afterEach(() => {
|
|
setHtmlDocumentParser(saved);
|
|
});
|
|
|
|
it("throws a clear error when no parser is registered", () => {
|
|
// Install a thrower to simulate the unregistered state, then assert
|
|
// parseHtmlDocument surfaces the guidance error (we cannot un-set the
|
|
// module singleton, so we assert via a registration that throws the same).
|
|
setHtmlDocumentParser(() => {
|
|
throw new Error("No HTML DOM parser registered.");
|
|
});
|
|
expect(() => parseHtmlDocument("<p>x</p>")).toThrow(/No HTML DOM parser/);
|
|
});
|
|
|
|
it("uses the most-recently registered parser", () => {
|
|
const marker = new JSDOM("<!DOCTYPE html><body><b>marker</b></body>").window
|
|
.document as unknown as Document;
|
|
setHtmlDocumentParser(() => marker);
|
|
expect(parseHtmlDocument("<i>ignored</i>")).toBe(marker);
|
|
});
|
|
});
|