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>
64 lines
3.0 KiB
TypeScript
64 lines
3.0 KiB
TypeScript
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
|
import { encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
|
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
|
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
|
|
|
/**
|
|
* CONTRACT: imported markdown/HTML can carry an htmlEmbed in the *serialized*
|
|
* DOM form —
|
|
* <div data-type="htmlEmbed" data-source="...">
|
|
* — directly, bypassing the editor's `<!--html-embed:-->` comment marker.
|
|
*
|
|
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
|
|
* this exercises the REAL server import conversion path that ImportService uses
|
|
* (`markdownToProseMirror`, the canonical converter — issue #345/#347) and
|
|
* asserts that such a node is DETECTED and STRIPPABLE — so the share read path's
|
|
* master-toggle strip can remove it when the workspace toggle is OFF.
|
|
*/
|
|
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
|
it('round-trips through markdownToProseMirror and is DETECTED (base64 data-source)', async () => {
|
|
const source = '<script>steal()</script>';
|
|
const encoded = encodeHtmlEmbedSource(source);
|
|
const md = [
|
|
'Hello',
|
|
'',
|
|
`<div data-type="htmlEmbed" data-source="${encoded}"></div>`,
|
|
'',
|
|
'World',
|
|
].join('\n');
|
|
|
|
// The canonical importer parses the raw block-level div into a real
|
|
// htmlEmbed node carrying the decoded source.
|
|
const json = await markdownToProseMirror(md);
|
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
|
|
|
// Because it is detected, the share master-toggle strip can remove it.
|
|
const stripped = stripHtmlEmbedNodes(json);
|
|
expect(hasHtmlEmbedNode(stripped)).toBe(false);
|
|
// Surrounding non-embed content is retained.
|
|
expect(JSON.stringify(stripped)).toContain('Hello');
|
|
expect(JSON.stringify(stripped)).toContain('World');
|
|
});
|
|
|
|
it('round-trips through direct HTML conversion (htmlToJson) and is DETECTED', () => {
|
|
const source = '<script>steal()</script>';
|
|
const encoded = encodeHtmlEmbedSource(source);
|
|
const html = `<p>Hello</p><div data-type="htmlEmbed" data-source="${encoded}"></div><p>World</p>`;
|
|
|
|
const json = htmlToJson(html);
|
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
|
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
|
});
|
|
|
|
it('is still DETECTED even when the data-source is NOT valid base64', async () => {
|
|
// A naive raw inline source (HTML-escaped, not base64) still parses as an
|
|
// htmlEmbed NODE — the decoder just yields an empty source. Detection (and
|
|
// therefore stripping) does not depend on the source being well-formed, so
|
|
// the bypass cannot be hidden by sending a malformed data-source.
|
|
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
|
const json = await markdownToProseMirror(md);
|
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
|
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
|
});
|
|
});
|