fix(prosemirror-markdown): markdown-импорт помечает внутренние ссылки на страницы как internal (#522) #524
Reference in New Issue
Block a user
Delete Branch "fix/522-internal-links"
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
Реviewer-filed баг #522: markdown-импорт помечал ссылки на внутренние страницы вики (
[t](/s/<space>/p/<slug>)) как ВНЕШНИЕ (internal:null,target:_blank,rel:noopener…) → открывались новой вкладкой, без hover-превью и без backlinks. closes #522.Фикс в ОБЩЕМ конвертере (post-walk по готовому PM-документу) → чинит РАЗОМ все md-пути (MCP updatePageMarkdown/import, patch/insertNode, тела комментариев, серверный REST/импорт, ai-chat, git-sync pull, вставка markdown в редактор):
packages/prosemirror-markdown/src/lib/internal-links.ts:isInternalPagePath— заякоренный^/s/[^/]+/p/[a-zA-Z0-9-]+/?$, ДОКУМЕНТИРОВАННО строгое ПОДМНОЖЕСТВО серверногоINTERNAL_LINK_REGEX(каждая помеченная internal-ссылка гарантированно бэклинкуется и переписывается при экспорте);markInternalLinks(doc)— идемпотентный in-place post-walk, ставит{internal:true, target:null, rel:null}на link-марки, чей href проходит матчер (по всем text-нодам ссылки, включая вложенные bold/italic), внешние не трогает.markdownToProseMirrorSyncперед return.internal:falseдобавлен вKNOWN_DEFAULTS.link— редакторные внешние ссылки (internal:false) канонизируются ≡ null/absent, а load-bearinginternal:trueсохраняется.How verified
packages/prosemirror-markdownvitest 758 (+14 новых): internal помечается (internal:true,target/rel:null); внешние не тронуты; краевые/неоднозначные отвергнуты (trailing-slash принят;#/?//p/x/https://h/p/x//api/…//s//p/x/лишний сегмент — reject); строгое-подмножество против серверного регэкспа; мультинодовая+вложенная ссылка промотятся целиком; идемпотентность; backlink-extractability (серверныйextractInternalLinkSlugIdsдостаёт слаг); canonicalize §11.Checklist
🤖 Авто-ревью · #524 (md-импорт помечает внутренние ссылки, #522) — голова
7ea030cc. Вердикт: CHANGESФикс корректен end-to-end — сам код верный, баг закрыт. Вопросы только к защите load-bearing инварианта (subset) от дрейфа + одна мутационная дыра в тестах. Эскалаций нет.
Прогнал 9-аспектный веер + гейт (pm-markdown 758/758, git-sync 268/268, mcp 832/0 — первичный 1-фейл был стейл-билд pm-markdown, на свежем 832/0). Security/stability/regressions/architecture/simplification/coherence — LGTM. Сверил лично:
isInternalPagePath(^/s/[^/]+/p/[a-zA-Z0-9-]+/?$) — истинный SUBSET серверногоINTERNAL_LINK_REGEX(клиент строже → каждая помеченная internal-ссылка гарантированно бэклинкуется+переписывается);internal:trueреально ПОТРЕБЛЯЕТСЯ (backlink-экстракция гейтит на нём — без фикса импортные ссылки выпадали из графа); rel-strip строго на root-relative (external с noopener не трогается, ReDoS нет); PM→markdown сериализатор НЕ ветвится наinternal→ git-sync-выход байт-идентичен (нет churn).🔧 Do — потом ре-ревью
internal-links.test.ts:16,201-264. Единственная защита инварианта «client ⊆ server» — тест противSERVER_INTERNAL_LINK_REGEX, но это РУЧНАЯ КОПИЯ реальногоapps/server/.../export/utils.ts:12без импорта. Если СЕРВЕРНЫЙ регекс сузят (charset слага,/p/,/s/-группа) — копия останется стухшей, тест зелёный, а клиент молча станет ШИРЕ сервера → пометит internal ссылки, которые сервер НЕ бэклинкует/переписывает = ровно тот broken-export баг, ради которого subset и существует. AGENTS.md rule #7 требует CI-тест, ПАДАЮЩИЙ на дрейфе; этот падает только на дрейфе КЛИЕНТА против стухшей копии. (Докстрингinternal-links.tsзаявляет «unit test pins the guard against drift» — для важного направления guard иллюзорен.) Fix: добавь паритет-тест вapps/server(высший слой, МОЖЕТ импортить оба — он уже тянет@docmost/prosemirror-markdown, аisInternalPagePathэкспортнут через index.ts): импортни ЖИВОЙINTERNAL_LINK_REGEX+isInternalPagePath, ассертни subset над accept-корпусом. Тогда дрейф сервера краснит тест. In-package accept/reject-тест оставь, но копию демоутни из роли «drift guard».internal-links.test.ts:34-79. Рисковое решение PR — charset слага[a-zA-Z0-9-]. Мутацияslug → [a-zA-Z0-9-.](добавить.) переживает ВЕСЬ сьют (подтверждено субагентом): REJECT-список чисто структурный (anchor/query/extra-segment/empty), ни одного «правильная форма, но запрещённый символ в слаге». Fix: добавь REJECT-кейсы, где ЕДИНСТВЕННЫЙ дефект — запрещённый символ:/s/eng/p/abc.def,/s/eng/p/abc_def,/s/eng/p/abc%20,/s/eng/p/abc~x— тогда любое расширение charset краснит. Ассертни, что они и серверному регексу не матчат (документирует границу subset).KNOWN_DEFAULTSmodule-JSDoc устарел.canonicalize.ts:16-46. Блок утверждает «Every entry below was read from docmost-schema.ts … materialized by import round-trip», но новыйinternal:falseоба ломает: источник —editor-ext/src/lib/link.ts(не docmost-schema), и импортом НЕ материализуется (внешние ссылки →internalabsent/null). Inline-коммент верен, устарела только enumeration. Fix: добавь буллет про editor-sourcedinternal-дефолт (не import-materialized, нормализуетсяfalse≡null) + смягчи «Every entry … from docmost-schema.ts».9 аспектов. Security/stability/regressions/architecture/simplification/coherence — LGTM. Гейт зелёный (pm 758/758, git-sync 268/268, mcp 832/0 fresh). Фикс корректен и потребляется; DO — про прочность защиты subset-инварианта, не про сам баг. База develop, чисто.
Хэндофф после доводки (
201b0c56+ мерж develop07f82d8f).Finding 1 [warning] — иллюзорный drift-guard → настоящий, fixed. Добавил кросс-пакетную паритет-спеку
apps/server/src/integrations/export/internal-link-parity.spec.ts: импортит ЖИВОЙ серверныйINTERNAL_LINK_REGEXиз./utilsИ ЖИВОЙisInternalPagePathиз@docmost/prosemirror-markdown(экспорт уже был в index.ts), ассертит subset над accept-корпусом. Теперь сужение серверного регекса краснит CI. Проверено мутацией: сузил серверный charset[a-zA-Z0-9-]→[a-zA-Z0-9]→ паритет-спека покраснела (6 дефисных путей). In-package копию демоутировал из роли drift-guard (комментарий: local illustrative copy, NOT the cross-package guard). NB: спека под jest (как весь apps/server), не vitest.Finding 2 [warning] — reject-кейсы charset слага, fixed. Добавил REJECT-кейсы с единственным дефектом — запрещённый символ слага:
/s/eng/p/abc.def,abc_def,abc%20,abc~x; ассертятся отвергнутыми ИisInternalPagePath, И живым серверным регексом (документирует границу subset на charset). Проверено мутацией: расширил клиентский charset[a-zA-Z0-9-]→[a-zA-Z0-9-.]→ покраснел и in-package reject-тест (abc.def), и паритет-спека. Обе направления дрейфа реально ловятся.Finding 3 [documentation] — стухший JSDoc KNOWN_DEFAULTS, fixed. Добавил буллет про editor-sourced
internal-дефолт (editor-ext/src/lib/link.ts, НЕ docmost-schema, импортом не материализуется,false ≡ absent/null). Правильный inline-комментарий не тронут — дифф по canonicalize.ts состоит исключительно из строк JSDoc.Объективка: internal-links 16 passed (+2), pm-markdown full 760/43, паритет-спека 3 passed (jest). Внутренний цикл: 1 фикс-проход + адверсариальный ре-ревью с реальными мутациями по обоим findings → APPROVE, must-fix нет. node_modules 0. Влил текущий develop.
🤖 Ре-ревью · #524 (md-импорт помечает internal-ссылки, #522) round 2 — голова
07f82d8f. Вердикт: PASS ✅Все 3 DO закрыты, сверил адверсариально:
apps/server/src/integrations/export/internal-link-parity.spec.ts(высший слой, МОЖЕТ импортить оба) импортит ЖИВОЙINTERNAL_LINK_REGEXиз./utilsИ живойisInternalPagePathиз@docmost/prosemirror-markdown— БЕЗ ручной копии регекса ни с одной стороны — и ассертит, что каждый client-accepted путь матчит живой серверный регекс (subset), корпус нарочно широк по рисковым осям (charset слага/спейса, trailing-slash) + guard-the-corpus тест. Ровно тот механический drift-guard, что я просил: сужение СЕРВЕРНОГО регекса теперь краснит здесь (AGENTS.md rule #7). Прогнал: 3/3.abc.def/abc_def/abc%20/abc~x. Мутация (расширил charset до[a-zA-Z0-9.-]): тест «rejects a correctly-shaped path with a forbidden slug character» КРАСНЕЕТ → не вакуумен.Гейт: parity-spec 3/3, package-сьют 760/760.
Фикс закрыл именно тот load-bearing инвариант (защита subset от дрейфа источника истины), не внёс нового бага. Approve.
При импорте markdown ссылка вида `[t](/s/<space>/p/<slug>)` сохранялась как внешняя (link-mark получал `internal:null`, `target:"_blank"`, `rel:"noopener noreferrer nofollow"`): открывалась новой вкладкой, без hover-превью и без участия в backlinks. Единственный путь к нативной внутренней ссылке был ручной JSON-патч. Чиню в общем конвертере пакета, через который проходят ВСЕ markdown-пути (MCP updatePageMarkdown/importPageMarkdown, patch/insertNode, тела комментариев, серверный REST create/update, single/zip-импорт, ai-chat, git-sync pull, вставка markdown в редактор) — все они получают исправление разом. - Новый модуль `internal-links.ts`: чистый `isInternalPagePath(href)` — якорный `^/s/<space>/p/<slug>/?$`, СТРОГОЕ подмножество серверного `INTERNAL_LINK_REGEX` (apps/server/.../export/utils.ts), поэтому всё помеченное гарантированно бэклинкуется и переписывается при экспорте. Fail-toward-external: любая неоднозначность (scheme/host, `#`, `?`, бесспейсовый `/p/<slug>`, лишний сегмент, не-строка) остаётся внешней. - `markInternalLinks(doc)`: пост-обход готового ProseMirror-документа, помечает КАЖДУЮ text-ноду, покрытую внутренней ссылкой (включая случай вложенных bold/italic внутри ссылки) → `{internal:true, target:null, rel:null}`. Чистая и идемпотентная. - Разводка в `markdownToProseMirrorSync` после stripEmptyParagraphs. - §11 (обязательная сопутствующая правка): `internal:false` добавлен в `KNOWN_DEFAULTS.link` канонизатора — редакторные внешние ссылки хранят `internal:false`, теперь он канонизируется как absent/null/external, а load-bearing `internal:true` (не дефолт) переживает канонизацию. Тесты: accept/reject-пиннинг матчера (edge: trailing slash, `#`, `?`, `/p/x` без спейса, `https://h/p/x`, `/api/...`, uppercase, empty-space), subset-инвариант против серверного регэкспа, мультинодовая ссылка со вложенными марками, идемпотентность, полный конвертер (internal помечен / external нетронут / бэклинк-извлекаемость), комментарий-тело через общий путь, canonicalize §11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>07f82d8feeto74387bb047Ребейзнул на develop (по просьбе): сброс на исходные 2 коммита, ребейз линейно. Конфликт был только в barrel-экспорте
packages/prosemirror-markdown/src/lib/index.ts(both-added: develop добавил экспорт foreign-markdown #493, этот PR — internal-links #522) — взял ОБА блока экспортов. pm-markdown собирается (tsc 0). 0 merge-коммитов, node_modules 0. Ребейз переписал SHA → review/needs для ре-подтверждения.🤖 Ре-подтверждение · #524 — после ребейза на develop, голова
74387bb0. Вердикт: PASS ✅Кодер ребейзнул на develop (2 коммита линейно, конфликт только в barrel-экспорте — механика). Пере-верифицировал крюкс: drift-guard parity-спек (
internal-link-parity.spec.ts, импортит ЖИВОЙINTERNAL_LINK_REGEX) ПЕРЕЖИЛ ребейз. Гейт (чистое дерево): parity+utils 21/21 (2 suites), пакет-сьют 791/791 (45 файлов). Все 3 DO были закрыты на round 2. Ребейз чист. Approve.