refactor(client): вставка markdown через канонический пакет + удаление md-слоя editor-ext (#347) #498
Reference in New Issue
Block a user
Delete Branch "refactor/347-client-md-paste"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Третий (технически нетривиальный) шаг #345: клиентская вставка markdown переезжает с md-слоя editor-ext (marked, не знал канона —
^[…],<!--img {…}-->,<!--subpages-->при вставке не распознавались) на канонический@docmost/prosemirror-markdown. По завершении md-слой editor-ext удалён целиком. closes #347Browser-entry пакета (инъекция DOM — jsdom только в Node)
dom-parser.ts— 2 слота инъекции (HtmlDocumentParser,GenerateJsonFn), без импорта DOM.dom-parser.node.tsрегистрирует jsdom +@tiptap/html/server;dom-parser.browser.ts— нативныйDOMParser+@tiptap/html(browser), экспонирован черезexports-условие"browser"+ сабпас"./browser". Клиент всегда импортирует явный сабпас/browser(не полагается на порядок условий). Node-потребители (mcp/server) идут по"."→default→index.js→jsdom — не затронуты.markdown-clipboard.ts (шов сохранён)
Изменился только ИСТОЧНИК:
markdownToProseMirror(browser) → PM-JSON → HTML через живую схемуDOMSerializer→ неизменённый downstream (normalizeTableColumnWidths→parseSlice→canonicalizePastedFootnotes→dispatch). Эвристики/fragment-insertion не тронуты. Конвертер async →handlePasteзахватывает диапазон, забирает событие, диспатчит на резолве; обе ветки (success + fail-open) защищены guard'омdoc!==startDoc.Удаление md-слоя
packages/editor-ext/src/lib/markdown/удалён целиком (+markedиз package.json, turndown ушёл из бандла). Мигрированы ВСЕ потребители:ai-chat/utils/markdown.ts(→ новыйmarkdownToProseMirrorSync),use-generate-page-title.ts/page-header-menu.tsx(→convertProseMirrorToMarkdown), серверный spec. editor-ext = только схема/расширения.How verified
prosemirror-markdowntsc чисто; vitest 744; клиентская сборка успешна, grep бандлаdist/assets/*.jsнаnew JSDOM|parse5|GlobalWindow|happy-dom|TurndownService→ 0 (утечки нет).$5/==/[^1]остаются литералом), async-paste (claim→convert→dispatch + fail-open). Клиентский suite + paste/canonicalize 34; editor-ext 196; node-потребители (mcp/server/git-sync) зелёные.<li><p>), добавлен scoped.markdown li p{margin:0}— тесты обновлены. Визуальная QA чата — на стенде.⚠️ Требует ручной проверки (вне автостенда)
Checklist
Готово к ревью. Клиентская вставка markdown переехала на канонический пакет через новый browser-entry (нативный DOMParser, jsdom только в Node), md-слой editor-ext удалён целиком, все потребители мигрированы. Критерии #347 выполнены на автостенде.
Внутренний цикл: 2 прохода. Первый (адверсарно, фокус на утечку jsdom в клиентский бандл #333-класса и сохранение async paste-шва) — утечки нет (grep бандла чист: 0 JSDOM/parse5/happy-dom/turndown), клиент импортирует явный сабпас /browser, downstream-шов вставки байт-в-байт сохранён, node-потребители зелёные; блокеров нет. Кодер по ходу поймал 2 реальных бага browser-пути (defaultView===null у нативного Document; @tiptap/html generateJSON на Node тянул browser-entry без window — починил инъекцией @tiptap/html/server). Второй проход (полиш): fail-open ветка вставки теперь тоже под guard'ом doc!==startDoc (не бросит RangeError по устаревшему диапазону → без unhandled rejection на горячем пути).
⚠️ Требует РУЧНОЙ проверки (вне автостенда) — важно
Автотесты покрывают конвертер и негативы, но НЕ полный клиентский round-trip вставки через живую схему редактора. Прошу на стенде:
$5,==,[^1]) — не должен внезапно конвертироваться.<li><p>+ scoped CSS): списки/таблицы/код в ответах.Ревью #498 — вставка markdown через канонический пакет + удаление md-слоя. Вердикт: changes-requested
Крепкий рефактор, направление верное, и главное — изоляция bundle подтверждена реальной сборкой, а не только дизайном. Корректных багов нет: async-paste безопасен (конвертер sync-core → резолв на микротаске, интерливинг невозможен), XSS двойно загейчен схемой+DOMPurify, downstream-шов не тронут, удаление слоя ничего не оставило висеть. Блокирует пять некрупных, но реальных пунктов: одна визуальная регрессия (которую сам PR чинил, но пропустил вторую поверхность), «глухой» catch без диагностики, непокрытый async-guard и рассинхрон доков/комментов.
Открыто: F1 (регрессия отступов списков в панели Reasoning), F2 (глухой catch вставки без логов), F3 (нет теста на doc-changed guard), F4 (устаревшие/неверные комменты), F5 (внешние доки не отражают нового клиента-потребителя).
✅ Что проверил (объективка + верификация)
prosemirror-markdowntsc+build ок, vitest 744/744;editor-extvitest 196/196 (удаление слоя чистое — ничто не ссылается); КЛИЕНТСКАЯ СБОРКА успешна; grep бандлаdist/assets/*.jsнаJSDOM|parse5|GlobalWindow|happy-dom|TurndownService→ 0 (утечки нет — эмпирически, не только по дизайну); клиентский paste/canonicalize 34/34; mcp tsc ок.import jsdomвdom-parser.node.ts, грузится ТОЛЬКО из default-энтриindex.ts; клиент во ВСЕХ местах импортит явный сабпас@docmost/prosemirror-markdown/browser→browser.ts→ нативныйDOMParser, jsdom недостижим.sideEffectsне выставлен (=true) → регистрирующий side-effect импорт не выпиливается.markdownToProseMirrorобъявлен async, но тело =return markdownToProseMirrorSync(...)без await → промис резолвится на микротаске, до любого макротаска (следующий paste/collab-апдейт/клавиша). Интерливинг двух вставок и веткаdoc!==startDocв проде недостижимы. (Security+coherence подтвердили.)htmlToMarkdown(getHTML)→convertProseMirrorToMarkdown(getJSON)— оба sync, расхождение только там, где turndown≠канон (намеренная конвергенция к export-форме). Серверный spec — тест-онли правка. Потерянное покрытие удалённых*.markedтестов полностью перекрыто вpackages/prosemirror-markdown/test/(footnote/math/callout/spoiler/html-embed). Негативы ($5/==/[^1]) покрыты на новом пути.🔧 Сделать, потом ре-ревью
F1 [regressions] Регрессия отступов списков в панели Reasoning — CSS-фикс поставлен только для
.markdown, пропущен.reasoningText.apps/client/src/features/ai-chat/components/ai-chat.module.css:63(.markdown li p{margin:0}) vs:180(.reasoningText p{margin:0 0 4px}). Миграция заставилаrenderChatMarkdownвсегда эмитить<li><p>…</p></li>. Тело ответа (message-item.tsx, класс.markdown) — покрыто новым правилом; панель размышлений (reasoning-block.tsx:56,90— тот жеrenderChatMarkdown, класс.reasoningText) правила.reasoningText li pНЕ имеет → каждый<p>внутри<li>ловит.reasoningText p'smargin:0 0 4px= лишние 4px на пункт. Раньше marked давал<li>text</li>без<p>, было плотно. Это ровно та регрессия, которую CSS-фикс PR и должен был снять, но пропустил на второй поверхности.Фикс: добавить
.reasoningText li p{margin:0}зеркально.markdown li p.F2 [stability] Fail-open
.catchвставки глотает ошибку без диагностики.markdown-clipboard.ts:180—.catch(() => {…})без аргумента и без лога, ловит ошибки И конвертера, И тела success-.then(в т.ч.PMNode.fromJSON(schema, doc), который бросает при дрейфе схемы между каноническим пакетом и живой схемой редактора). При сбое вставка тихо деградирует в сырой текст (fail-open — данные не теряются, хорошо), но БЕЗ console/телеметрии → невидимая регрессия «моя таблица вставилась текстом», невоспроизводимая для разработчиков.Фикс:
.catch((err) => { console.error("markdown paste conversion failed, inserting raw text", err); … })(или через app error-reporter).F3 [test-coverage] Async doc-changed guard (обе ветки) + две вставки в полёте — не покрыты. Суть нового async-шва — remap-гард
view.state.doc !== startDocна success (:163) и fail-open (:192).markdown-clipboard.paste.test.ts(3 теста) НИ в одном не меняет doc между синхронным claim и микротаском.then. Fail-open ветка со stale-from/to(её же коммент предупреждает про RangeError/unhandled-rejection) — ноль покрытия. Достижимо в тесте: вызватьhandlePaste, доflush()диспатчнуть doc-меняющую транзакцию.Фикс: два теста — (a) success: doc сменился mid-flight → контент лёг в живую селекцию без клоббера/исключения; (b) fail-open: форсить сбой конверсии с изменённым doc → сырой текст переставлен без RangeError. Плюс тест «две вставки в полёте — оба пейлоада выживают».
F4 [documentation] Устаревшие/неверные комментарии.
markdown-clipboard.ts:106-111,159: «marked pipeline is declared async» — фактически неверно, конвертер SYNC (async-обёртка сохранена лишь ради Node-контракта). И «map the range through doc steps» переоценивает — код не StepMap-мапит, а падает на живую селекцию. Переформулировать («returns a Promise, kept async for the Node contract; pipeline is sync» / «fall back to the live selection»).editor-ext/src/lib/footnote/footnote-sync.ts:106(и мягчеfootnote-util.ts:66, +тест): комментарий ссылается на удалённый этим PRfootnote.marked.ts. Перенаправить на@docmost/prosemirror-markdown.F5 [documentation] Внешние доки не отражают нового клиента-потребителя канона.
AGENTS.md:336-337,463иpackages/prosemirror-markdown/README.md:3-9утверждают «no browser runtime … consumed by mcp, git-sync, apps/server» + «do NOT reintroduce a per-package copy» — теперь ложно:apps/clientпотребляет пакет через/browser. Добавитьapps/client(черезbrowser-энтри, нативный DOMParser, без jsdom в бандле) и снять «no browser runtime». Плюс запись вCHANGELOG.md [Unreleased](клиентская вставка markdown теперь через канонический конвертер; удалён marked/turndown md-слой) — репо ведёт changelog для paste-изменений (ср. #192).⛔ DROP — кодеру НЕ делать · калибровка (оператору)
[intended]low/med[regressions] Сырой inline-HTML из LLM (<kbd>,<sub>,произвольные<span>) теперь дропается schema-round-trip'ом (текст сохраняется). Намеренная конвергенция к каноничной схеме; чат — untrusted+санитайзится. Принять.[speculative]low/high[stability] Будущий реальныйawaitв конвертере превратит мёртвый doc-changed guard в живой клоббер (fallback берёт сырую текущую селекцию). Не баг сегодня (sync-core); F3 частично пиннит инвариант.[below-threshold]low/high[test-coverage] Parity-тестdom-parser-both-pathsсвопает только DOM-parser, неgenerateJson— обе ноги гоняют node(happy-dom) generateJSON; кросс-движковое расхождение в HTML→PM-JSON пройдёт тихо. Но покрыт вероятный источник расхождения (parser-проходы); дивергенция generateJSON удалённа.[below-threshold]low/med[test-coverage] Нет закоммиченного regression-теста «нет jsdom в клиентском бандле» — инвариант держится grep-гейтом + статической сепарацией графа; долговечность nice-to-have.[speculative]low/high[architecture] Нет поляsideEffects— безопасно сегодня (дефолт true), но будущий"sideEffects": falseвыпилит регистрирующий импортbrowser.ts→ рантайм-throw. Стоит пин/коммент.[below-threshold]suggestion/low[simplification] Дублирование range-guard в обеих ветках.then/.catch→ вынести хелпер. И: инъекцию можно заменитьbrowser-field remap'ом — текущая структура защитима, автор вправе оставить.9 аспектов (security · stability · regressions · test-coverage · conventions · documentation · simplification · architecture · coherence) + гейт с реальной клиентской сборкой и grep бандла (0 утечек). security/stability(crux)/architecture/coherence — корректность и изоляция чисты. Все F дёшевы; на ре-ревью прогоню тем же прогоном + мутацию async-guard.
Все 5 закрыты.
F1: fixed —
.reasoningText li p{margin:0}(зеркало.markdown li p). Reasoning-панель рендерит через тот же renderChatMarkdown (), но
.reasoningText p{margin 0 0 4px}давал 4px на пункт. Теперь обе поверхности плотные.F2: fixed —
.catchвставки теперьconsole.error(...)с контекстом (покрывает и конвертер, и тело success-.then); тихая деградация в raw-текст видима.F3: fixed — +3 теста doc-changed guard (success/fail-open ветки при mid-flight изменении дока → живая selection без клоббера/RangeError; две вставки в полёте — «ни один payload не потерян»).
F4: fixed — устаревшие комменты про удалённый md-слой поправлены в markdown-clipboard.ts, footnote-sync/util, docmost-schema, foreign-markdown, footnote-canonicalize.
F5: fixed — AGENTS.md (apps/client как потребитель через browser-entry) + prosemirror-markdown/README (Node vs browser entry, markdownToProseMirrorSync) + CHANGELOG.
Тесты: client paste+ai-chat 61; pmd 744; editor-ext 196; клиентская сборка успешна, grep бандла на JSDOM/parse5/happy-dom/turndown — 0.
(Ручной paste-QA полного round-trip + Docker-сборка — по-прежнему за стендом, как отмечено в PR.)
Ревью #498 — раунд 2. Вердикт: changes-requested (1 из 5)
Четыре из пяти закрыты чисто. Но F3 переоткрываю: три новых теста doc-changed-guard ВХОЛОСТУЮ — доказал мутацией (дважды, независимо + контроль): они зелёные даже при полностью отключённом гарде. Гейт зелёный, изоляция бандла держится (0 утечек).
Закрыто: F1 ✅, F2 ✅, F4 ✅, F5 ✅. Открыто: F3 (тесты не ловят сломанный гард).
🔧 Сделать, потом ре-ревью
markdown-clipboard.paste.test.ts:129-177против гардаmarkdown-clipboard.ts:166-171(success) /:204-209(fail-open).Доказательство: форсировал ОБА гарда
if (view.state.doc !== startDoc)→if (false), пересобрал, прогнал — все 6 тестов остались зелёными. Контроль (сломал success-путь → 2 теста покраснели) подтверждает, что vitest читает мутированный исходник, т.е. зелёные-под-мутацией — настоящие. Тесты НЕ отличают рабочий гард от сломанного.Корень: каждый тест вставляет в ПУСТОЙ курсор (
from==to==1), аmutateDocтолько РАСТИТ документ. Поэтому «протухший» захваченный диапазон — всегда валидная нулевой-ширины точка ВСТАВКИ:replaceRange(1,1,…)при from==to — чистая вставка, MARKER никогда не затирается; документ только растёт →toне выходит за границы → RangeError не бросается. Коммент «a stale-range replaceRange would have clobbered it» ложен для нулевого диапазона.Фикс (проверен адверсариально):
"AAAABBBB",setTextSelection({from:1,to:5}), затем paste), и mid-flight вставить «MARKER» в pos 1 → при рабочем гарде MARKER цел, при сломанном stale-диапазон 1–5 сотрёт «MARKE» (тест обязан краснеть под мутацией).toвышел ЗА ГРАНИЦЫ (после захвата УМЕНЬШИТЬ документ, напр. удалить контент), тогда сломанный гард бросит RangeError → ассертить, что его нет.Прогони новые версии тем же способом: мутируй гард → они должны краснеть.
✅ Что закрыто (проверено против кода)
.reasoningText li p{margin:0}(зеркало.markdown li p, ai-chat.module.css:188); reasoning-панель рендерит тот же<li><p>через renderChatMarkdown. Regressions: селектор не переохватывает. Закрыто..catch((err)=>{ console.error("markdown paste conversion failed…", err); … })— чисто аддитивно, гард/диспатч не тронуты (сверил диффом). Тихая деградация в raw-текст теперь видима. Закрыто.@docmost/prosemirror-markdown). Documentation-аспект: аккуратно, новых неверных claim'ов нет. Закрыто.<li><p>). Всё сверено с кодом. Закрыто.Раунд 2, спотлайт на закрытии F1–F5 + мутационная проверка. F3 не закрыт — тесты выглядят рабочими, но проходят при выключенном гарде (доказано). Остальное закрыто, объективка зелёная. Инфра-заметка (не блокер): клиентский тест-файл требует предварительно собранного
@docmost/prosemirror-markdown/build— CI-ный nx^buildэто делает, в чистой комнате собирал вручную.Ревьюер мутационно доказал: 3 теста doc-changed-guard были ВХОЛОСТУЮ — зелёные даже при обоих гардах `if(false)`. Причина: вставка в ПУСТОЙ курсор (from==to==1) + mutateDoc только РАСТИТ док → протухший нулевой диапазон всегда валидная точка вставки: replaceRange(1,1,…) не затирает, растущий док не выводит `to` за границы, RangeError не бросается. Гард-код корректен — вхолостую были ТЕСТЫ. Переписаны по рецепту ревьюера: - success: вставка поверх НЕПУСТОГО выделения ("AAAABBBB", selection {1,5}); mid-flight вставить "MARKER" в pos 1 + курсор в конец. Рабочий гард → замена в живой (конечной) selection, MARKER цел; сломанный → stale {1,5} стирает голову MARKER. - fail-open: захваченный `to` выводится ЗА ГРАНИЦЫ — после захвата {1,9} и провала конверсии док СЖИМАЕТСЯ до пустого параграфа. Рабочий гард → raw-текст в живую selection; сломанный → insertText(md,1,9) на size-2 доке → RangeError, ничего не ложится. - two-pastes оставлен (пинит «ни один payload не потерян», не гард). Мутационно проверено: оба гарда→if(false) → тесты 1 и 2 КРАСНЕЮТ (stale-range clobber; stale-`to` RangeError), 3-й и не-гард-тесты зелёные; реальный гард восстановлен → 6/6. Тесты теперь отличают рабочий гард от сломанного. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>F3: fixed — тесты были вхолостую именно по твоему диагнозу (вставка в пустой курсор + растущий док → протухший нулевой диапазон всегда валиден). Гард-код корректен, переписал ТЕСТЫ по твоему рецепту:
to=9, после захвата док СЖИМАЕТСЯ → сломанный insertText(md,1,9) на size-2 доке бросает RangeError; ассерт что raw-текст лёг.Мутационно проверил (как ты): оба гарда→if(false) → тесты 1 и 2 КРАСНЕЮТ ('...ERAAAABBBB' не содержит MARKER; RangeError Position 9 out of range → '' не содержит 'raw heading'); реальный гард восстановлен → 6/6.
git diffmarkdown-clipboard.ts vs HEAD пуст (гард-код не тронут). Теперь тесты отличают рабочий гард от сломанного.Ревью #498 — раунд 3. Вердикт: approved ✅
F3 закрыт — теперь тесты НЕ вхолостую, проверил той же мутацией, что вскрыла проблему. Мержить можно.
Как проверил закрытие F3 (мутационно)
Кодер переписал тесты ровно по рецепту (правил только тест-файл;
markdown-clipboard.tsне тронут —git diffguard-кода = 0 строк):{1,5}над "AAAA", mid-flight "MARKER" в pos 1 + курсор в конец → рабочий гард пишет в живую селекцию (MARKER цел), сломанный пишет в протухший{1,5}(стирает голову MARKER).{1,9}, конверсия падает (# heading→ schema throw → catch), затем док СЖИМАЕТСЯ → протухшийto=9за границами → сломанныйinsertText(md,1,9)на size-2 доке бросает RangeError → "raw heading" отсутствует.Моя мутация (оба гарда
view.state.doc !== startDoc→if(false), пересборка): baseline 6/6 → под мутацией 2 failed | 4 passed (тесты 1 и 2 краснеют). В прошлом раунде те же тесты оставались 6/6 зелёными (вхолостую) — теперь отличают рабочий гард от сломанного. Пробел закрыт по-настоящему.Все пять находок закрыты: F1 (CSS отступы reasoning), F2 (лог в fail-open catch), F3 (тесты async-guard — мутационно-надёжны), F4 (комменты про удалённый md-слой), F5 (AGENTS.md/README/CHANGELOG). Объективка из р2 в силе (r2→r3 дельта — только тест-файл, guard-код байт-идентичен): pmd 744, editor-ext 196, client paste+ai-chat 371, клиентская сборка ок, grep бандла на JSDOM/parse5/happy-dom/turndown → 0. → approved.
Раунд 3, F3-only: мутационная верификация, что переписанные тесты ловят сломанный гард. Остальное неизменно с р2 (гейт зелёный). Ручной paste-QA полного round-trip + Docker — за стендом, как отмечено в PR.