fix(prosemirror-markdown): markdown-импорт помечает внутренние ссылки на страницы как internal (same-tab, превью, backlinks) #522
Closed
opened 2026-07-12 00:13:51 +03:00 by agent_vscode
·
0 comments
No Branch/Tag Specified
main
develop
feat/568-page-history-redesign
feat/561-comments-redesign
feat/557-apikey-phase3a
feat/530-search-semantic
feat/566-worktime-modal-redesign
feat/370-agent-save-version
recover/506-apikey-ui
feat/370-share-approved
fix/520-recovery-escalation
fix/554-literal-br
feat/529-search-lexical
feat/501-mcp-apikey-auth
feat/506-apikeys-ui
feat/395-work-time
feat/502-mcp-md-modes
feat/492-incremental-render
fix/549-hardbreak-md-canon
feat/542-resolve-undo
fix/505-drawio-strip-comments
fix/503-hardbreak-canon
docs/471-breadcrumb-permission
fix/534-mcp-spaceid
feat/481-version-coherence
fix/535-list-seam-coalesce
fix/525-insert-unloaded
fix/522-internal-links
fix/507-drawio-cyrillic
fix/541-reconnect-timeout
feat/493-converter
feat/370-page-versioning
fix/515-code-emphasis
fix/523-tree-dnd
fix/517-toasts
feat/495-tails
feat/494-mcp-registry-guards
feat/496-suggestions-audit
integ/497-wave1
feat/488-client-fsm
ci/476-publish-gates
feat/489-tool-resilience
feat/487-terminal-lifecycle
fix/486-iter1-prod-fires
refactor/347-client-md-paste
perf/479-getpage-cache
refactor/449-write-invariants
refactor/450-split-client
feat/443-get-page-context
feat/443-get-tree
feat/443-search
docs/415-lossy-descriptions
feat/413-markdown-default
refactor/412-camelcase-tool-names
feat/425-drawio-graph
fix/464-diffdocs-cpu-guard
refactor/411-update-page-markdown
feat/424-drawio-rules
fix/409-invalid-node-validation
refactor/448-generate-inventory
refactor/445-execute-mapping
refactor/446-derive-client-types
fix/447-registry-stamp-ci
fix/444-agent-loop-guards
fix/396-sendnow-detached-run
fix/452-immutable-cache-control
docs/footnote-authoring-comment-cleanup
feat/437-error-diagnostics
fix/442-cursor-pagination
feat/419-footnote-normalize
perf/399-comment-resolve-async
perf/435-collab-token-cache
perf/344-background-rerenders
perf/348-backend-lowhanging
feat/423-drawio-crud
perf/401-collab-hotpath
feat/430-live-reconnect
perf/400-collab-session
refactor/414-dedup-node-ops
feat/417-new-comments-signal
feat/408-createcomment-hints
feat/407-persist-tool-errors
feat/420-footnote-render
dummy-review/mcp-hang-fix
dummy-review/mcp-hang-base
feat/381-resumable-sse-pr1
feat/git-sync-2
docs/agents-workspace-build-order
feature/offline-sync
perf/342-code-splitting
perf/346-compression-cache
feat/196-multi-cursor
perf/343-typing-latency
docs/how-to-test
fix/ai-sdk-partial-output-oom
test/351-generative-converter
feat/371-roles-catalog
refactor/345-server-converter
refactor/294-spec-registry-cont
fix/363-migration-order
fix/e2e-callout-and-gate-build
fix/docker-re2-toolchain
feat/git-sync
fix/media-roundtrip-stability
fix/340-comment-panel-perf
fix/332-deferred-tools
fix/329-ephemeral-suggestions
fix/330-search-in-page
fix/328-resolved-anchor-spam
fix/331-intraline-diff
fix/324-coverage-gate
fix/325-mobile-390
feat/293-A-git-sync-package
feat/300-avatar-oklch
fix/321-banner-mobile
feat/300-avatar-colors
feat/315-comment-suggestions
feat/scroll-restore-stable-wait
feat/300-agent-avatar-stack
feat/300-avatar-polish
refactor/294-tool-spec-registry
feat/scroll-restore-ux
fix/responsive-tablet-sidebar
feature/ai-chat-page-change-observability
image-inline-center
fix/283-short-remap-title
fix/283-slash-layout
image-inline-row
feat/276-ai-chat-dock
fix/269-table-menu-refocus
docs/dev-stand-guide
feat/266-scroll-position
fix/260-collab-docname-slugid
test/244-phase2-tail
fix/262-reindex-progress-realtime
fix/258-changelog-compare-links
fix/244-dataloss-bugs
feat/246-spoiler
feat/221-image-captions
test/244-part-b
feat/251-intentional-clear
fix/embeddings-reindex-progress
refactor/193-tool-spec-registry
fix/255-ws-redis-adapter-leak
fix/252-e2e-open-handles
feat/229-catalog-yaml
feat/243-blob-sandbox
feat/228-inline-footnotes
fix/qa-ui-bugs-216-218
feature/agent-roles-catalog
fix/share-alias-rename
fix/ai-chat-empty-render
feat/191-chat-doc-binding
feat/201-temporary-notes
feat/198-interrupt-agent
feat/ai-chat-full-history
feat/199-ai-generate-title
feat/205-share-aliases
batch/issues-189-187-170
feat/170-mcp-test-button
feat/189-context-badge
feat/198-interrupt-agent-send-now
fix/issues-190-159
fix/ai-chat-new-chat-during-stream
fix/ai-chat-stream-perf
batch/issues-2026-06-25
feat/ai-chat-persistent-history
fix/ai-chat-copy-chat-wysiwyg
fix/ai-stream-reset-resilience
fix/ai-stream-undici-timeout
fix/footnote-review-1227-followup
fix/ai-chat-token-counter-realtime
docs/manual-qa-test-plan
v0.94.1
v0.94.0
v0.93.0
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
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#522
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 "%!s()"
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?
Проблема / Цель
При импорте markdown ссылка на внутреннюю страницу вики, записанная обычным markdown —
[текст](/s/<space>/p/<slugId>)— сохраняется как внешняя: link-mark получаетinternal: null,target: "_blank",rel: "noopener noreferrer nofollow". Из-за этого ссылка открывается новой вкладкой как внешний URL, без hover-превью страницы и без обратных ссылок (backlinks), хотя ведёт на ту же вики.Единственный способ получить нативную внутреннюю ссылку сегодня — ручной JSON-патч (
updatePageJson/patchNode) с{internal:true, target:null, rel:null}. Это обходной путь, который надо устранить: markdown-импорт должен сам распознавать внутренний путь и помечать ссылку внутренней.Затрагивает не только
updatePageMarkdown, а все markdown-пути (см. Контекст) — они используют один общий конвертер, который сейчас теряетinternal.Контекст и исследование
Корень. Конвертер markdown→ProseMirror в пакете
@docmost/prosemirror-markdownне распознаёт внутренний путь:markdownToProseMirrorSync—packages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts:1084-1098(ядро;markdownToProseMirrorL1105-1109 — async-обёртка).internalобъявлен безparseHTML:packages/prosemirror-markdown/src/lib/docmost-schema.ts:324-327→{ internal: { default: null } }, всегдаnull.target:"_blank"/rel:"noopener noreferrer nofollow"появляются именно на стадии конвертера (дефолты StarterKit Link materialize в JSON), подтвержденоcanonicalize.ts:31-34,54-59— а не добавляются сервером/read-path.Как устроены внутренние ссылки (что нужно получить).
apps/client/src/features/editor/components/link/link-view.tsx:81parseInternalLink(href, mark.attrs.internal),:395/:503target={isInternal ? undefined : "_blank"}, SPA-navigate()и hover-превью — всё завязано наinternal.apps/server/src/common/helpers/prosemirror/utils.ts:72-91extractInternalLinkSlugIdsберёт ссылку в граф только еслиmark.attrs.internal && href.match(INTERNAL_LINK_REGEX).apps/server/src/integrations/export/utils.ts:12-13INTERNAL_LINK_REGEX+extractPageSlugId(L132).<a>—apps/server/src/integrations/import/utils/import-formatter.ts:387-392($a.attr('data-internal','true')), а editor-ext link это читает (packages/editor-ext/src/lib/link.ts:11-17). То есть пробел — только у markdown-конвертера пакета.Радиус (все идут через общий конвертер → фиксятся разом): MCP
updatePageMarkdown/importPageMarkdown(packages/mcp/src/client/pages.ts:181,390),patchNode/insertNodemarkdown-формы (nodes-write.ts:412,582→markdown-fragment.ts:138), тела комментариев (comments.ts:397,569); серверный REST create/update в формате markdown (apps/server/src/core/page/services/page.service.ts:1318), single/zip-импорт (import.service.ts:148,file-import-task.service.ts:479); ai-chat (in-process@docmost/mcp,docmost-client.loader.ts:283); git-sync pull (packages/git-sync/src/engine/stabilize.ts:76); вставка markdown в редактор (apps/client/.../markdown-clipboard.ts:119); рендер markdown в ai-chat (apps/client/.../ai-chat/utils/markdown.ts:24).Существующих ишью на этот баг нет; техдолга по schema-mirror, который бы углублялся, — тоже. Апстрим-контекст: Docmost issue #2164 (internal links handled inconsistently); фиксим на своей стороне, без зависимости от апстрима.
Дизайн
Одно место — общий конвертер: post-walk по готовому ProseMirror-документу, помечающий только однозначно-внутренние ссылки. Примитив живёт на авторитетной стороне (конвертер владеет md→PM), не как компенсация на клиенте (R2).
1. Матчер (host-less, якорный, строгое подмножество серверного регэкспа). Новый модуль
packages/prosemirror-markdown/src/lib/internal-links.ts, чистый хелпер:Принимает:
/s/<space>/p/<slug>и/s/<space>/p/<slug>/. Отвергает: любой scheme/host (http(s)://,//host/...), бесспейсовый/p/<slug>, относительныйp/<slug>,?query,#anchor,/api/....<slug>=[a-zA-Z0-9-]+(как group 5 серверногоextractPageSlugId),<space>=[^/]+.2. Пометка. В том же модуле
markInternalLinks(doc)— обход всех inline-нод; для каждой marktype==="link"сisInternalPagePath(attrs.href)выставитьinternal:true,target:null,rel:null. Важно: link-mark хранится по КАЖДОЙ text-ноде, которую ссылка покрывает (все с одним href) — пометить надо на каждой (иначе часть слов ссылки останется внешней), включая случай вложенных марок (bold/italic внутри ссылки). Функция чистая и идемпотентная.3. Разводка. В
markdownToProseMirrorSyncприменитьmarkInternalLinksк документу послеgenerateJsonWith/stripEmptyParagraphs(передreturn, L1096-1098). Все вызывающие наследуют поведение автоматически.docmost-schema.tsне трогаем — атрибутinternalуже есть, ставим значение прямо в JSON.4. Идемпотентность / §11 (обязательная сопутствующая правка). Раз
internalстановится load-bearing (internal:trueпереживает canonicalize), надо закрыть асимметриюinternal:false: редакторные внешние ссылки хранятinternal:false(дефолтpackages/editor-ext/src/lib/link.ts:11-12), аcanonicalize.tsего не роняет (false !== null, нет вKNOWN_DEFAULTS.link), из-за чего внешняя ссылка ломаетdocsCanonicallyEqual(stored, reimport)(воспроизведено критиком эмпирически — возвращаетfalse). → Добавитьinternal: falseвKNOWN_DEFAULTS.link(packages/prosemirror-markdown/src/lib/canonicalize.ts:54-59), чтобыinternal:false ≡ absent ≡ null ≡ external;internal:true(≠ default) продолжает переживать канонизацию.Где живёт канон (R3). Канон «что есть внутренний путь» — серверный
INTERNAL_LINK_REGEX. Пакет (нижний слой) не может импортировать серверное приложение, поэтому в пакете живёт задокументированное строгое подмножество (только/s/<space>/p/<slug>), с комментарием-ссылкой на серверный регэксп и наimport-formatter.ts:387-392. Механический guard (вместо «держать в синхроне») — юнит-тест пакета, пиннящий точный accept/reject-набор.Семантика отказа (R4). Fail-toward-external: помечаем internal только при однозначном совпадении якорного регэкспа. Любая неоднозначность (host, scheme,
#,?, отсутствующий/не-строковый href) → остаётся внешней (internal:null). Ложно-внешняя = мягкая деградация (новая вкладка); ложно-внутренняя дала бы битую SPA-навигацию — поэтому консервативный дефолт «внешняя».Внутренность выводится из href, а не хранится как состояние. Для формы
/s/<space>/p/<slug>markdown не выражает «это намеренно внешняя ссылка»: экспорт роняетinternal, ре-импорт заново выводитinternal:trueиз href. Это by design (детерминированный вывод из href), а не пользовательский тоггл; совпадает с тем, как клиент и так рендерит host-less internal-form href как внутренний.Границы scope
/p/<slug>и якорные/s/<space>/p/<slug>#section— серверныйINTERNAL_LINK_REGEXих и так не бэклинкует (ограничение есть и для нативных ссылок), поэтому пометка их internal здесь дала бы «UI-внутренняя, но невидимая графу». Осознанно не трогаем; выравнивание серверного регэкспа под эти формы — отдельная возможная правка (см. Связи).https://<self>/s/.../p/...) — у чистого конвертера нет знания о своём хосте; это уровень MCP/сервера сappUrl, отдельная фича.getPageJson, отдающий закешированную версию после записи — это read-cache/collab-persist, не markdown-импорт.docmost-schema.tsи не добавляемdata-internalparseHTML (ортогонально; для этого бага не нужно).Критерии приёмки
updatePageMarkdownна странице с телом[t](/s/<space>/p/abc123)→getPageJson/getNodeпоказывает у этой link-markinternal:true,target:null,rel:null.[t](https://example.com/p/x)в том же теле остаётсяinternal:null/absent сtarget:"_blank"— не помечается внутренней. Бесспейсовый[t](/p/abc123)тоже остаётся внешним (задокументированное ограничение).createCommentс внутренней ссылкой в теле → mark тожеinternal:true(единое поведение)..md(нет git-diff — проверяемоgit status).flat-roundtrip.property,nested-roundtrip.property,roundtrip.test) зелёные; свойство «парсер тотален» сохранено.internal:falseпроходитdocsCanonicallyEqual(stored, reimport) === true(правка canonicalize).isInternalPagePathпиннит accept/reject-набор (edge: trailing slash,#,?,/p/xбез спейса,https://h/p/x,/api/..., uppercase);markInternalLinks— на ссылке из нескольких text-нод (и со вложенным bold/italic) все ноды получаютinternal:true.Отказы и наблюдаемость
/s/<space>/p/deadslug): backlink не создаётся —backlinks.task.ts:53-62фильтрует по реально существующим страницам; клик — обычная SPA-навигация на 404 (не хуже прежней внешней вкладки). Алерт не нужен.Совместимость и миграции
internal:null→true— реальное изменение контента → одна новая версия. Однократно, не осциллирует (детект детерминирован; экспорт роняетinternal, git-байты не меняются → push не зациклится;nodes-write.ts:216-220dedup — на live-doc editseditPageText,markInternalLinksтам не бежит). Ленивая «миграция» — на следующем редактировании/ресинке; скрипта и блокировки горячих таблиц нет.internalне имеет markdown-представления, стабилизация байт-идентична.Решённые развилки
data-internal+ schema parseHTML vs JSON post-walk → JSON post-walk (даёт точную нативную формуinternal:true,target:null,rel:nullв одном месте; StarterKit-дефолтtarget:_blankиначе остался бы; не тянет schema-wide parseHTML-изменение). (дизайн-очевидность)/p/<slug>+/s/<space>/p/<slug>vs только спейс-квалифицированная → только/s/<space>/p/<slug>(критики независимо: серверный регэксп не бэклинкует/p/<slug>, вышла бы UI-внутренняя-но-невидимая-графу ссылка; сужение делает конвертер строгим подмножеством → всё помеченное гарантированно бэклинкуется). (нашла состязательная атака)internal:false— в этот же PR? → да (разinternalделаем load-bearing, обязаны закрыть §11-асимметриюfalse; та же связная задача «корректная канонизация internal»). (нашла состязательная атака)Связи
INTERNAL_LINK_REGEXпод бесспейсовый/p/<slug>и якорные#-ссылки, чтобы и они бэклинкались (ограничение есть и для нативных ссылок).