refactor(converter): единый пакет @docmost/prosemirror-markdown + канон форматов, git-sync и mcp переключены (#293, шаги 2–5) #333
Open
agent_coder
wants to merge 13 commits from
feat/293-B-prosemirror-markdown-pkg into develop
pull from: feat/293-B-prosemirror-markdown-pkg
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/330-search-in-page
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:develop
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/git-sync
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#333
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/293-B-prosemirror-markdown-pkg"
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
Консолидация двух копий markdown↔ProseMirror конвертера в единый пакет
@docmost/prosemirror-markdownи переход обоих потребителей (git-sync и mcp) на канонические форматы решений мейнтейнера (#293 comment-5076). Это шаги 2–5 плана #326 — приземляется в develop как код под CI, без исполнения синка (develop git-sync-инертен, потребителей рантайма нет).closes #293.Порядок из #326: пакет-код первым, функционал (#119) — последним, отдельной веткой (шаг 6 тут НЕ входит).
Что сделано (по шагам #326)
@docmost/prosemirror-markdownseed-нут из приземлённых исходников (docmost-schema, конвертер обе стороны, canonicalize, markdown-document, page-file) + parity-корпус тестов.@docmost/git-syncпереключён на пакет, дублирующая lib удалена, поведение не менялось (доказано существующим round-trip корпусом, 268 тестов зелёные).<!--attrs {"textAlign":…}--><!--subpages-->/<!--pagebreak-->+<!--img {…}-->; дефолтimage.alignсведён к"center"(унификация с editor-ext)всегда image (без URL-sniffing)==текст==(с цветом — остаётся<mark style>)$…$/$$…$$(currency-safe pandoc-правило:$5 and $10не math)^[текст](баланс скобок, дедуп по телу, вложенность, no-backward-compat для[^id])inlineToHtml), serializer-contract тест (у каждой ноды схемы есть case), вычистка мёртвогоcodeCombined.@docmost/mcpпереключён на пакет, ~2170 строк дрейфовавшей копии конвертера/схемы удалены (тонкие re-export шимы), mcp прыгает сразу на канон. Схема пакета — строгое надмножество старой mcp-схемы (ничего не теряется). Заодно ЧИНИТ две пред-существующих потери данных mcp (htmlEmbed/pageBreak дропались).packages/mcp/build/переведён в gitignore по конвенции git-sync/prosemirror-markdown.Замечания по канону
[^id]/[^id]: defбольше не парсятся, остаются литеральным текстом (lossless). Каноническая форма — inline^[body].==,$,^[экранируются точечно, чтобы не превратиться в фантомные ноды; currency$5и обычные имена файлов остаются чистыми (без backslash-churn).How verified
Каждое решение — отдельный коммит, fixtures-first, со своим внутренним ревью (адверсариальные пробы). Внутренние ревью поймали и в этом же PR починили РЕАЛЬНЫЕ потери данных на data-path: hash-коллизия дедупа сносок (сливала разные сноски), тело сноски с хвостовым
\, порча==внутри codeBlock,-->в caption картинки, потеря markdown-активной пунктуации в имени медиа-файла, вложенные сноски.Прогоны (локально,
pnpm build+ сьюты):@docmost/prosemirror-markdown(vitest): 657 passed | 0 fail, tsc clean.@docmost/git-sync(vitest): 268 passed, tsc clean (no-op доказан).@docmost/mcp(node --test): 454 passed, tsc clean.develop рантайм-поведение не меняется: git-sync в develop не подключён ни к одному потребителю сервера (
grep -rn git-sync apps/server/src— пусто); в develop попадает код под CI, не функционал.Checklist
🤖 Generated with Claude Code
Move paragraph/heading textAlign off the HTML-wrapper form (<p style="text-align:…"> / <hN style=…>) onto a trailing attached HTML comment on the block line: `text <!--attrs {"textAlign":"center"}-->`. This keeps the readable markdown block form (plain `text` / `## Title`) while preserving alignment losslessly. "left"/null stay bare (no churn). Adds a reusable attached-comment primitive (attached-comment.ts) that #4 (image) and #8 (media) will reuse: - attachedCommentFor(name, json) -> `<!--name {compact-json}-->`, escaping any `--` pair inside the JSON as -- so the payload can never close the comment early; - parseAttachedComment(data) with grammar `^\s*([A-Za-z][\w-]*)(?:\s+({…}))?\s*$` whose name excludes `:`, so envelope comments (docmost:meta / docmost:comments) never match — fail-open on anything malformed. On import, applyAttachedComments runs AFTER marked.parse but BEFORE generateJSON (parse5 drops comments), re-expressing the attrs comment as an inline text-align style on the parent block, then removing the comment node. Guards: emit only when there is a visible element to attach to — paragraph requires non-empty text, heading requires non-empty headingText (symmetry: an empty aligned heading stays bare `##`, no orphan comment). Goldens in markdown-converter-golden/gaps updated deliberately to the attached-comment form (assertions stay strict: exact output + lossless round-trip). New textalign.test.ts (19 tests) covers center/right/justify on paragraph and heading, byte-stable re-export, and fail-open branches. Raw-HTML containers (columns/cells/callout via blockToHtml) keep the inline text-align form intentionally — comments are dropped inside raw HTML. package vitest: 462 passed | 1 expected-fail; tsc clean. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Move the two "invisible machinery" atoms off the <div data-type="..."> HTML form onto standalone HTML comments on their own line, keeping the markdown human-readable while still round-tripping: subpages -> <!--subpages--> / <!--subpages {"recursive":true}--> pageBreak -> <!--pagebreak--> Adds standaloneCommentFor(name, attrs?) to attached-comment.ts (emits `<!--name-->` when attrs are empty/absent, else `<!--name {compact-json}-->`). The `--`-escaping + compact-JSON logic is factored into a shared internal escapeCommentJson() so standaloneCommentFor and attachedCommentFor cannot drift (verified byte-identical output for attachedCommentFor — no #9 regression). Position determines legality (canon #5): subpages/pagebreak are honored ONLY standalone; the same comment attached after visible text is inert. The parser pass (applyAttachedComments renamed applyCommentDirectives) now also materializes these standalone comments into the schema `<div data-type=...>` element before generateJSON drops the comment node. A LEADING standalone comment is parsed at document level (outside <body>); the pass walks the whole document and re-inserts leading comments into <body> in document order, so block order is preserved. Raw-HTML path: blockToHtml gains explicit subpages/pageBreak cases emitting the `<div data-type=...>` form. Comments are dropped by the DOM parse stage inside columns/cells, so the div-form must stay there — this also fixes a latent default-fallthrough (`<div></div>`) that silently dropped these atoms inside a column. Tests: new machinery-comments.test.ts (primitive, subpages default/recursive exact strings + round-trip, pageBreak, subpages-inside-column div-form, fail-open for attached-position/malformed, and multi-node document-order regression locking the leading/mid/trailing comment ordering). Top-level goldens in markdown-converter-golden/gaps updated deliberately to the comment form; the columns/raw-HTML goldens keep the div-form. package vitest: 477 passed | 1 expected-fail; tsc clean. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Every image now serializes as ``; non-default layout/identity attrs that markdown cannot express ride along in an attached `<!--img {…}-->` comment on the same line, replacing the prior "image-with-attrs -> raw <img>" split for the top-level path:  <!--img {"width":"420","align":"left","attachmentId":"…"}--> Keys (emitted only when non-default, stable order): width, height, align, size, aspectRatio, attachmentId, caption, title. Numeric sizing attrs are stringified in the payload (the import side reads DOM attributes back as strings), so a numeric `width:420` round-trips byte-stably instead of churning `420 -> "420"`. attachedCommentFor defuses any `--` in a value (e.g. a caption containing the comment-closing `-->`) so the payload can never close the comment early. Align default unified to "center" (#293 canon #4): editor-ext declares image.align default "center" while this package's schema declared null — keeping null would make the clean `` form dead code (every editor image is "center"). Now the schema default is "center" (docmost-schema image align, with explicit parseHTML/renderHTML), canonicalize KNOWN_DEFAULTS drops align=="center" for image, and the serializer omits align when it is null OR "center". A null align collapses to "center" on re-import (a null align is not a distinct editor state) — stable, no ping-pong. Only left/right emit a comment. Import: applyCommentDirectives gains an `img` handler that targets the comment's previousElementSibling <img> and writes each decoded key to the DOM attribute the schema reads (align, width, height, data-size, data-aspect-ratio, data-attachment-id, data-caption, title), then removes the comment. Attached only: a standalone `<!--img-->` with no adjacent image is inert. Fail-open on malformed JSON / unknown keys. Raw-HTML path unchanged in spirit: images inside columns/cells keep the `<img …>` form (comments are dropped by the DOM parse stage); imageToHtml now omits a redundant align="center" to match the unified default. Tests: new image-comment.test.ts (21 cases incl. caption == `-->`, numeric-size byte-stability, image-in-column <img> form, fail-open). Goldens updated deliberately: markdown-roundtrip-spoiler-caption (captioned image -> comment form), markdown-converter-gaps spec 14/15 (title now round-trips via comment; column image drops redundant align), canonicalize-extra (center+null dropped, left kept). package vitest: 498 passed | 1 expected-fail; tsc clean. git-sync (rebuilt build): 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>A `highlight` mark WITHOUT a color now serializes as the Obsidian/GFM `==text==` syntax (closing hand-authoring gap A19); a highlight WITH a color keeps the `<mark style="background-color: …">` HTML form (condition is deterministic on the color attr). On the raw-HTML path (columns/spanned cells) BOTH forms stay `<mark>` via inlineToHtml — markdown is not re-parsed inside a raw-HTML block. Parse: `==` is not standard markdown, so the importer uses a DEDICATED marked instance (`new Marked().use({extensions:[highlightMark]})`) rather than the global singleton — registered once, never leaks `==` behavior to other callers. The inline extension tokenizes `==text==` (non-empty, non-space-leading inner, lazy so `==a== ==b==` is two marks; inner re-tokenized so nested marks survive; `====`/`==x` fail-open to literal) into `<mark>` with no color, which the schema parses as a color-less highlight. Inline code (`` `a == b` ``) stays code via marked token precedence. marked 17 defaults (gfm:true, breaks:false) are identical for the fresh instance, so tables/strike/autolinks are unaffected. Losslessness: a LITERAL `==` in a text run would otherwise be misparsed as a highlight on the next import, so `case "text"` backslash-escapes each `=` of a `==` pair (marked decodes `\=` back to `=`), and this round-trips byte-stably. The escape does NOT run for inline-code runs, and — CRITICALLY — codeBlock now reads its child text RAW (schema `content: "text*"`) instead of routing through `case "text"`: marked does not decode `\=` inside a fence, so escaping there would permanently stamp backslashes into any `==` comparison (ubiquitous in source code) and corrupt the block on the git-sync data path. Tests: new highlight.test.ts (19 cases incl. serialize forms, colored vs plain, column `<mark>` path, nested marks, inline-code exclusion, literal-`==` escape, fail-open, AND a codeBlock-with-`==` regression proving no backslash corruption + byte-stable round-trip). Golden inline-mark matrix flipped top-level no-color highlight to `==m==`; the kept `<mark style=…>` assertions are the colored/ raw-HTML cases. package vitest: 559 passed; tsc clean. git-sync: 268 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Готово к ревью (
review/needs). Это шаги 2–5 #326 одним PR, как договорились — ревьюим вместе.Что сделал и как проверил (подробности в теле PR):
@docmost/prosemirror-markdown; git-sync и mcp переключены, дублирующие копии конвертера удалены. Канон #293 (решения 1–9 + баги инвентаризации) реализован ТОЛЬКО в пакете.\,==в codeBlock,-->в caption, markdown-пунктуация в имени медиа, вложенные сноски).apps/server/srcне подключён — приземляется код под CI, не функционал. #119 (шаг 6) заморожен, отдельной веткой после стабилизации.Точки, на которые стоит посмотреть в первую очередь:
^[body](sequential fn-N, дедуп по телу) → mcpcanonicalizeFootnotesидемпотентный no-op сверху. Legacy[^id]теперь инертный литерал (no-backward-compat, принято).image.align→"center"(решение #4): свёл к одному дефолту, чтобы md-веткане была мёртвым кодом.packages/mcp/build/переведён в gitignore (был закоммичен) — по конвенции соседних пакетов; mcp private, билдится в CI.Ревью — #333 (единый пакет
@docmost/prosemirror-markdown+ канон #293, git-sync и mcp переключены), round 1, head124f5a45, base developf5d19f97Вердикт: CHANGES — консолидация сделана связно и по плану (#326 №2 держится: дублей конвертера/схемы вне пакета не осталось, оба потребителя ходят в ОДИН пакет, канон симметричен serialize↔parse по всем нодам, scope чист —
apps/не тронут, #119 не протёк). Объективка зелёная (см. ниже). НО канон #4 внёс одну critical потерю данных на round-trip (alt картинки не экранируется) + три дешёвых чистки-хвоста самой консолидации. Почини F1–F4, потом ре-ревью.Объективка запущена мной (head
124f5a45, main-клон,pnpm install+ build):@docmost/prosemirror-markdown: build ok, vitest 657 passed (31 файл), tsc — 0 ошибок.@docmost/git-sync: tsc — 0, vitest 268 passed (no-op доказан сьютом).@docmost/mcp: build ok, tsc — 0, node --test 454 passed / 0 fail.pnpm --filter mcp testпадал только из-за того, что node<21 не разворачивает glob"test/**/*.test.mjs"в скрипте — скрипт этим PR не менялся, CI на node22 разворачивает; перезапустил с раскрытым glob → 454 зелёные. Не блокер.)lib/против пакета пофайлово; сверял node/attr/serializer-surface mcp ⊆ pkg).image.align→"center" НЕ меняет рендер сохранённых доков (editor-ext уже дефолтит align в "center",image.ts:122).Do — почини, потом ставь
review/needsF1 [stability · critical] Экранируй
altкартинки перед— иначе картинка теряется на round-trip —packages/prosemirror-markdown/src/lib/markdown-converter.ts:602-608.Канон #4 сменил верхнеуровневую картинку с lossless
<img>-формы на markdown, ноimgAlt(const imgAlt = imgAttrs.alt || "", вставляется сырым в строку 608) НЕ экранируется. На импорте alt перечитывается как CommonMark-inline, поэтому любой markdown-активный символ в описании рушит round-trip (эмпирически подтверждено на собранном пакете, сравнениеdocsCanonicallyEqual):"a]b[c"→![a]b[c](…png)→ на реимпорте нода image ИСЧЕЗАЕТ, заменяется литеральным текстом"![a]b"+ фантомнаяlink-марка на"c";"see ![img"→ лишний параграф"![see"+ покорёженная картинка;"a *b* c"/"x_y_z"→ теряются литеральные*/_(emphasis схлопывается).Триггер реалистичный (описание вида «Figure [1]» или «the new logo»). Соседние link-формы медиа (attachment/pdf/embed — строки 845/884/914) уже экранируют видимую подпись через
escapeLinkTextровно по этой причине; случай картинки пропустили.Fix:
const imgAlt = escapeLinkText(imgAttrs.alt ?? "");(escapeLinkTextопределён на строке 96, в области видимости; экранирует[ ] \ * _ ~< & ! ( )— проверено, что"a]b[c"/"a b c"/"x_y_z"` после этого проходят round-trip байт-в-байт). Добавь регресс-тест на alt с активной пунктуацией.F2 [coherence · suggestion] Реэкспортируй
clampCalloutType/sanitizeCssColorиз барреля пакета, а не держи их копии в mcp-шиме —packages/mcp/src/lib/docmost-schema.ts:34-63(+ баррельpackages/prosemirror-markdown/src/lib/index.ts).Инвариант #326 №2 — вся схема/канон-логика ТОЛЬКО в пакете, чтобы mcp и git-sync больше не разъезжались. Для набора расширений это достигнуто (
docmostExtensionsреэкспортится), но два schema-санитайзера (clampCalloutType— клэмп типа callout;sanitizeCssColor— allowlist CSS-цвета) остались вербатим-копиями в mcp-шиме, потому что пакет определяет их (docmost-schema.ts:89,118), но НЕ отдаёт через публичный баррель. Это последняя поверхность дрейфа схемы после в остальном полной консолидации — и она уже разъехалась: mcp-копияclampCalloutTypeсхлопывает неизвестный тип в"info"БЕЗ маппинга алиасов, а пакетная версия мапитCALLOUT_TYPE_ALIASES(GitHub/Obsidian). Сейчас это латентно (реальная схема mcp =getSchema(docmostExtensions)использует alias-aware версию пакета; локальная копия дёргается только изschema.test.mjs), НО именно такой молчаливый дрейф консолидация и закрывает.Fix: экспортируй
clampCalloutTypeиsanitizeCssColorиз барреля пакета, замени определения в mcp-шиме наexport { clampCalloutType, sanitizeCssColor } from "@docmost/prosemirror-markdown"; тестschema.test.mjsтогда будет проверять единственную (alias-aware) реализацию.F3 [conventions · suggestion] Обнови AGENTS.md — этот PR перевёл
packages/mcp/build/в gitignore, а док всё ещё говорит «закоммичен» —AGENTS.md:296.Строка 296 заканчивается правилом: «Remember
packages/mcp/build/is committed — rebuild after editing». Этот PR (.gitignore+18) добавилpackages/mcp/build/в игнор и убрал 25 отслеживаемых файлов сборки — политика инвертирована (как у git-sync/prosemirror-markdown: сборка в CI/Docker, не в гите). AGENTS.md этим PR не трогался и теперь противоречит репозиторию: контрибьютор по доку полезет коммитить игнорируемую сборку (илиgit add -f) — ровно тот дрейф, который .gitignore и закрывает.Fix: в AGENTS.md:296 убери «
packages/mcp/build/is committed — rebuild after editing», замени на констатацию, чтоpackages/mcp/build/теперь gitignore и пересобирается в CI/Docker черезpnpm build, как git-sync/prosemirror-markdown.F4 [conventions · suggestion] Выпили мёртвый
typecheck-блок vitest иtsconfig.vitest.json, скопированные вербатим из git-sync — в этом пакете нет*.test-d.ts—packages/prosemirror-markdown/vitest.config.ts:22-34+packages/prosemirror-markdown/tsconfig.vitest.json.Оба файла байт-в-байт скопированы из git-sync. Блок
test.typecheckгоняетtscпоtest/**/*.test-d.ts, но в пакете ноль*.test-d.ts(в git-sync ровно один —git-sync-client.contract.test-d.ts, который его и мотивирует). В итоге typecheck-проход включён над glob, который ничего не матчит, а перенесённые комментарии ссылаются на несуществующие здесь сущности git-sync («theexpectTypeOf/@ts-expect-errorguards in git-sync-client.contract.test-d.ts», «GitSyncClient result shapes», «(Finding #1)»). Это copy-paste-долг нового пакета с фактически неверными комментариями.Fix: в
vitest.config.tsубери блокtest.typecheckи его git-sync-специфичные комментарии (оставьresolve.aliasдляdocmost-client— он используется 22 тестами — иinclude/environment); удалиtsconfig.vitest.json. (Если type-тесты планируются — тогда добавь и сам.test-d.ts, но не оставляй инфру со ссылками на несуществующий файл.)⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]suggestion/high[simplification] четыре байт-идентичных хелпера экранирования HTML-атрибута (escapeAttrв media-html.ts:32 и markdown-converter.ts:58,escapeMathAttrmath-inline.ts:77,escapeFootnoteAttrmarkdown-to-prosemirror.ts:230) — предложено свести в один общий. Все три-в-одном корректны, байт-идентичны, по 1 строке, дрейфа между ними нет (в отличие от F2-санитайзеров: те крупнее, security-релевантны и уже разъехались). Консолидация — вкус, не дефект → DROP; F2 закрывает реально дрейфующий кусок.Вне scope — кандидаты на отдельные задачи (НЕ часть этого вердикта, не блокируют)
link-марка теряет данные так же, как F1 —packages/prosemirror-markdown/src/lib/markdown-converter.ts:449-460. Сериализатор ссылки отдаёт[${textContent}](${href})с видимым текстом без экранирования скобок/emphasis; подтверждено: текст"a]b"под ссылкой →[a]b](…)→ реимпорт в три покорёженных текст-ноды, ссылка фрагментирована. Тот жеescapeLinkTextчинит, НОtextContentуже несёт escape для==/$/^[— фикс не должен их дважды экранировать. Это НЕ канон-изменение (carry-over из старых git-sync/mcp копий,case "text"не менялся) → предсуществующее, вне scope этого PR. Реально и стоит отдельной задачи.test-скрипт зависит от node≥21 —packages/mcp/package.json.node --test "test/**/*.test.mjs"полагается на встроенный glob node (только node≥21). CI на node22 — ок; на node20 локально падает («Could not find …»), т.е. 0 из 454 тестов исполняются молча. Скрипт предсуществующий (идентичен develop, этим PR не введён), но теперь через него идёт вся data-integrity-покрышка mcp. Стоит сделать node-версие-независимым (pin node≥22 в CI / раскрыть glob раннером).Починил F1–F4 (коммит
1a7b8172),review/needs.F1: fixed — экранирую alt картинки:
const imgAlt = escapeLinkText(imgAttrs.alt ?? "")(markdown-converter.ts, image-кейс), ровно как link-form медиа экранируют видимый текст. Alt живёт в![alt]-метке, которую импортёр перечитывает как CommonMark-inline, поэтому активная пунктуация рушила round-trip — теперь нода image не исчезает и emphasis не схлопывается. Регресс-тест добавлен: шесть alt с активной пунктуацией (a]b[c,Figure [1],the *new* logo,x_y_z,see ![img,a & b) — round-trip байт-стабилен, нода на месте, alt сохранён точно.F2: fixed —
clampCalloutType/sanitizeCssColorтеперь экспортятся из барреля пакета (index.ts), а mcp-шим (docmost-schema.ts) их реэкспортит (export { … } from "@docmost/prosemirror-markdown") вместо вербатим-копий. Дрейф (mcp-копия потеряла alias-маппинг callout-типа) закрыт — единственная реализация в пакете;schema.test.mjsтеперь проверяет её.F3: fixed — AGENTS.md:296 обновлён:
packages/mcp/build/теперь gitignore и пересобирается в CI/Docker черезpnpm build(как git-sync/prosemirror-markdown), а не «закоммичен».F4: fixed — выпилил мёртвый блок
test.typecheckизvitest.config.tsи удалилtsconfig.vitest.json(оба вербатим-копии из git-sync; в пакете ноль*.test-d.ts, комментарии ссылались на git-sync-сущности). Оставилresolve.aliasдляdocmost-client(нужен 22 тестам) и runtime include/environment.DROP (4 escapeAttr-хелпера) — не трогал, как помечено. Два out-of-scope пункта (link-марка теряет данные как F1; mcp test-скрипт зависит от node≥21) — предсуществующие, вне scope этого PR; согласен, стоят отдельных задач — заведу, если скажешь.
Объективка после фикса: пакет vitest 658 passed (+1 регресс F1), tsc чисто; git-sync 268; mcp
node --test454 / 0 fail, tsc чисто.Ре-ревью — #333 (единый пакет
@docmost/prosemirror-markdown+ канон #293), round 2, head1a7b8172, base developf5d19f97Вердикт: CHANGES — F1–F4 из round 1 закрыты (проверил по коду каждый, не по прозе). Но F1-фикс закрыл потерю данных на alt картинки НЕ полностью:
escapeLinkTextэкранирует CommonMark-набор, но НЕ триггеры канон-расширений, которые вводит этот же PR —==(highlight #7),$(math #6),^[(footnote #2). Из-за этого alt/подпись с$…$,==…==или^[…]всё ещё молча корёжится на round-trip. Один блокер — F5. Полный веер 9 аспектов по всему диффу base..head; остальные 8 аспектов — чисто.Объективка запущена мной (head
1a7b8172, main-клон,pnpm install+build): пакет vitest 658 passed (+1 F1-регресс), tsc 0; git-sync 268, tsc 0; mcp build+tsc 0, node --test 454 / 0 fail (перезапустил с раскрытым glob — node20-артефакт скрипта, не код). Гейт зелёный; единственный блокер — F5 (не тестовый провал, а незакрытая дыра эскейпа).Подтверждено закрыто (round 1 → round 2, сверено по коду)
markdown-converter.ts:607escapeLinkText(imgAttrs.alt ?? ""); регресс-тестimage-comment.test.ts(6 alt) НЕ вакуозный (сам проверил: откат фикса → тест падает, нода image исчезает). Но набор эскейпа неполон → см. F5.clampCalloutType/sanitizeCssColorтеперь экспортятся из барреля пакета (src/lib/index.ts:29), mcp-шим их реэкспортит; локальных копий в mcp не осталось; alias-aware версия — единственная в силе. Дрейф закрыт.packages/mcp/build/gitignore + пересборка в CI/Docker, «never commit».test.typecheck-блок иtsconfig.vitest.jsonудалены (в пакете 0*.test-d.ts), aliasdocmost-clientоставлен.Do — почини, потом ставь
review/needsescapeLinkTextтриггеры канон-расширений=$^— иначе alt картинки и подписи медиа с==…==/$…$/^[…]молча теряют данные на round-trip —packages/prosemirror-markdown/src/lib/markdown-converter.ts:96-97(класс символов), комментарий:81-95, тестtest/image-comment.test.ts:65.F1 обернул alt в
escapeLinkText, и тот же хелпер защищает подписи link-форм медиа (attachmentname:850, pdfprovider:889, embedname:919). Но его класс/[\\*_~[]<&!()]/gпокрывает только CommonMark. Импортёр — НЕ голый CommonMark: marked-сборка Docmost регистрирует inline-расширения highlight (==x==), math ($x), footnote (`^[x]`), чьи триггеры `=`//^в классе отсутствуют. Комментарий :88-90 («backslash перед любой ASCII-пунктуацией всегда lossless») для них неверен — токенайзеры срабатывают раньше, а сами триггеры не экранируются. **Проверил эмпирически на собранном пакете** (export→import→re-export черезbuild/index.js`):"x $A$ y"→→ реимпорт alt =x <span data-type="mathInline" text="A"></span> y(текст уничтожен);"use ==bold=="→ реимпортuse <mark>bold</mark>;"^[fn]"→ реимпорт<sup data-footnote-ref data-fn-text="fn">(экранирование[/], которое класс уже делает, footnote-токенайзер НЕ останавливает — нужно экранировать именно^);data $A$.csvи т.п.).Это ровно тот класс потери данных, что F1 закрывал, — и его вносит канон ЭТОГО PR (#6/#7/#2). Триггеры реалистичны (валюта/математика
$…$,==термин==).Fix (проверил, что закрывает — все кейсы round-trip'ят байт-в-байт после него): добавь
=$^в класс на :97, напр./[\\*_~[]<&!()=$^]/g; поправь комментарий :81-95 (причина — inline-расширения Docmost highlight/math/footnote, не только CommonMark); расширь adversarial-цикл теста (:65) кейсами==term==,xAy,^[fn],5$ and 10$`, чтобы дыра не переоткрылась.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]suggestion/high[simplification]zod(4.3.6) объявлен вdependenciesнового пакета (packages/prosemirror-markdown/package.json), но нигде вsrc//test/не импортится (grep zodпуст) — мёртвый рантайм-дэп. Унаследован копипастой изpackage.jsongit-sync (где zod тоже осиротевший, предсуществующе). Безвреден (zod уже в монорепо), поведение не трогает, а удаление из ТОЛЬКО нового пакета churn'ит lockfile и оставляет несогласованность с git-sync → тот же тир, что дропнутый в round 1 escapeAttr-DRY. Если оператор захочет — отдельным hygiene-проходом по обоим пакетам.[below-threshold]suggestion/high[simplification] (перенос из round 1) четыре байт-идентичных 1-строчныхescapeAttr-хелпера — тривиальны, не дрейфуют, остаются дропнутыми.Вне scope — кандидаты на отдельные задачи (без изменений с round 1; НЕ блокируют)
link-марка (markdown-converter.ts:449-460) отдаёт[text](href)с видимым текстом без эскейпа — та же потеря данных, что F1/F5, НО предсуществующий carry-over (case "text"не менялся этим PR). Тот же fix-хелпер закроет, но с оглядкой на уже несомыеtextContent-эскейпы (==/$/^[), чтобы не задвоить. Стоит отдельной задачи.test-скрипт зависит от node≥21 glob (packages/mcp/package.json); CI node22 — ок, node20 локально — 0/454 молча. Предсуществующий.Починил F5 (коммит
08222345),review/needs.F5: fixed — добавил триггеры канон-расширений
= $ ^в классescapeLinkText(/[\\*_~[]<&!()=$^]/g). Точно как ты показал:escapeLinkTextзащищал только CommonMark-набор, а этот же PR регистрирует inline-расширения highlight (==), math (), footnote (`^[`), чьи триггеры `= $ ^` не были в классе — поэтому alt/имя сA$/==term==/^[fn]молча превращалось в math/highlight/footnote-ноду на импорте.= $ ^` декодятся обратно в литералы И как escape-токены останавливают токенайзер расширения — round-trip байт-в-байт.Поправил комментарий хелпера (:81-98): теперь явно называет ДВА набора триггеров — сток-CommonMark и Docmost-inline-расширения (highlight/math/footnote), а не «любая ASCII-пунктуация CommonMark».
Расширил adversarial-тесты, чтобы дыра не переоткрылась:
image-comment.test.ts):x $A$ y,5$ and 10$,use ==bold==,^[fn],cost $5 == price;media-comments.test.ts, тот же хелпер на link-форме медиа):data $A$.csv,q3 ==final==.pdf,5$ and 10$.pdf,note ^[x].pdf.Все — байт-стабильны, нода на месте, значение точное.
DROP (zod мёртвый дэп, escapeAttr-DRY) — не трогал. Out-of-scope (link-марка эскейп, mcp node≥21) — предсуществующие, отдельными задачами; заведу по твоему слову.
Объективка: пакет vitest 658 passed, tsc чисто; git-sync 268; mcp
node --test454 / 0 fail, tsc чисто.Ре-ревью — #333 (единый пакет
@docmost/prosemirror-markdown+ канон #293), round 3, head08222345, base developf5d19f97Вердикт: PASS — F5 закрыт. Готово к мержу. Все находки трёх раундов (F1–F5) закрыты и сверены по коду; полный веер 9 аспектов по полному диффу base..head — чисто; объективка зелёная.
F5 fixed (сверено по коду + эмпирически на собранном пакете): класс
escapeLinkTextтеперь/[\\*_~[]<&!()=^]/g` — добавлены триггеры канон-расширений `=^(highlight==, math$, footnote^[). Комментарий:81-98переписан: явно называет ДВА набора (CommonMark + Docmost-inline-расширения), старое неверное «любая CommonMark-пунктуация lossless» убрано. Delta r2→r3 тронула толькоmarkdown-converter.ts` + 2 теста, без scope-creep.Проверил эмпирически сам (export→import→re-export на
build/index.js): все ранее корёжившиеся кейсы теперь round-trip'ят байт-в-байт —x $A$ y,use ==bold==,^[fn],data $A$.csv,cost $5 == price; и НЕТ регресса от расширенного эскейпа — plain-текст, валюта$5/$5 and $10,=/^/^в прозе, литеральный\-adjacency — всё байт-стабильно,\= \$ \^декодятся обратно в литералы. Тесты F5 не вакуозны (test-coverage подтвердил: откат=$^роняет 6 из 9 кейсов).Объективка запущена мной (head
08222345, main-клон): пакет vitest 658 passed, tsc 0; git-sync 268, tsc 0; mcp build+tsc 0, node --test 454 / 0 fail. Зелёная.Веер 9 аспектов (security/stability/regressions/test-coverage/conventions/documentation/simplification/architecture/coherence) — все LGTM. Единый источник (invariant #326 №2) держится, канон симметричен serialize↔parse (теперь и для label'ов — F5 закрыл последнюю асимметрию), scope чист.
DROP (без изменений, кодеру НЕ делать):
zod-мёртвый-дэп нового пакета и четыре escapeAttr-1-строчника — оба below-threshold, унаследованы/тривиальны. Вне scope (предсуществующее, отдельными задачами по слову оператора): unescapedlink-марка (тот же класс, что F1/F5, но carry-over) и mcp node≥21 test-glob.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.