fix(prosemirror-markdown): markdown-импорт помечает внутренние ссылки на страницы как internal (same-tab, превью, backlinks) #522

Closed
opened 2026-07-12 00:13:51 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

При импорте 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 не распознаёт внутренний путь:

  • markdownToProseMirrorSyncpackages/prosemirror-markdown/src/lib/markdown-to-prosemirror.ts:1084-1098 (ядро; markdownToProseMirror L1105-1109 — async-обёртка).
  • link-mark атрибут 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:81 parseInternalLink(href, mark.attrs.internal), :395/:503 target={isInternal ? undefined : "_blank"}, SPA-navigate() и hover-превью — всё завязано на internal.
  • Сервер (backlinks): apps/server/src/common/helpers/prosemirror/utils.ts:72-91 extractInternalLinkSlugIds берёт ссылку в граф только если mark.attrs.internal && href.match(INTERNAL_LINK_REGEX).
  • Канон «что есть внутренний путь»: apps/server/src/integrations/export/utils.ts:12-13 INTERNAL_LINK_REGEX + extractPageSlugId (L132).
  • Прецедент в этом же репозитории: файловый импортёр Docmost уже помечает внутренние <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/insertNode markdown-формы (nodes-write.ts:412,582markdown-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, чистый хелпер:

// Matches ONLY the space-qualified, root-relative internal page path.
// A strict SUBSET of the server's INTERNAL_LINK_REGEX
// (apps/server/src/integrations/export/utils.ts) so every link we mark
// internal is guaranteed backlink-able and export-rewritable.
const INTERNAL_PAGE_PATH = /^\/s\/[^/]+\/p\/[a-zA-Z0-9-]+\/?$/;
export function isInternalPagePath(href: string): boolean {
  return typeof href === "string" && INTERNAL_PAGE_PATH.test(href);
}

Принимает: /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-нод; для каждой mark type==="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-внутренняя, но невидимая графу». Осознанно не трогаем; выравнивание серверного регэкспа под эти формы — отдельная возможная правка (см. Связи).
  • Не входит: детект абсолютных URL на свой же хост (https://<self>/s/.../p/...) — у чистого конвертера нет знания о своём хосте; это уровень MCP/сервера с appUrl, отдельная фича.
  • Не входит: смежная жалоба про getPageJson, отдающий закешированную версию после записи — это read-cache/collab-persist, не markdown-импорт.
  • Не трогаем docmost-schema.ts и не добавляем data-internal parseHTML (ортогонально; для этого бага не нужно).

Критерии приёмки

  1. updatePageMarkdown на странице с телом [t](/s/<space>/p/abc123)getPageJson/getNode показывает у этой link-mark internal:true, target:null, rel:null.
  2. Целевая страница показывает входящий backlink на страницу-источник (backlink API/UI сервера).
  3. Внешняя [t](https://example.com/p/x) в том же теле остаётся internal:null/absent с target:"_blank" — не помечается внутренней. Бесспейсовый [t](/p/abc123) тоже остаётся внешним (задокументированное ограничение).
  4. Кликом по внутренней ссылке в редакторе — переход в той же вкладке + hover-превью (не новая вкладка).
  5. createComment с внутренней ссылкой в теле → mark тоже internal:true (единое поведение).
  6. git-sync: повторная стабилизация страницы с внутренней ссылкой даёт байт-идентичный .md (нет git-diff — проверяемо git status).
  7. Round-trip property-тесты (flat-roundtrip.property, nested-roundtrip.property, roundtrip.test) зелёные; свойство «парсер тотален» сохранено.
  8. Внешняя ссылка редакторного происхождения с internal:false проходит docsCanonicallyEqual(stored, reimport) === true (правка canonicalize).
  9. Новый юнит-тест: 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 (не хуже прежней внешней вкладки). Алерт не нужен.
  • Детект — чистая функция без внешних эффектов; спец-метрика не нужна (мисдетект = ссылка осталась внешней, видно глазами).
  • Необратимых действий не вводится; audit trail не затрагивается.

Совместимость и миграции

  • Изменение поведения для существующих пользователей (явно): markdown-импортированные внутренние ссылки во всех перечисленных путях (MCP, серверный REST/импорт, ai-chat, комментарии, git-sync, вставка в редактор) начнут открываться в той же вкладке, получать превью и участвовать в backlinks. Это и есть цель, но это смена наблюдаемого поведения.
  • Разовый churn версий в БД: при первом ре-импорте страницы с внутренней ссылкой internal:null→true — реальное изменение контента → одна новая версия. Однократно, не осциллирует (детект детерминирован; экспорт роняет internal, git-байты не меняются → push не зациклится; nodes-write.ts:216-220 dedup — на live-doc edits editPageText, markInternalLinks там не бежит). Ленивая «миграция» — на следующем редактировании/ресинке; скрипта и блокировки горячих таблиц нет.
  • Git-файлы: churn нет — internal не имеет markdown-представления, стабилизация байт-идентична.
  • Rollback: правка чисто кодовая (конвертер + одна строка canonicalize), откат = revert коммита; данные откатывать не нужно, runtime-флаг не заводим (низкий риск, форма совпадает с той, что редактор и так производит для вставленных ссылок).

Решённые развилки

  • Где чинить: общий конвертер пакета vs MCP-only post-walk → общий конвертер (R2/R3: примитив на авторитетной стороне; MCP-only оставил бы сломанными комментарии, серверный REST, ai-chat, git-sync и дублировал бы логику). (дизайн-очевидность)
  • HTML-инъекция 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-внутренняя-но-невидимая-графу ссылка; сужение делает конвертер строгим подмножеством → всё помеченное гарантированно бэклинкуется). (нашла состязательная атака)
  • Правка canonicalize internal:false — в этот же PR? → да (раз internal делаем load-bearing, обязаны закрыть §11-асимметрию false; та же связная задача «корректная канонизация internal»). (нашла состязательная атака)

Связи

  • Апстрим-контекст: Docmost issue #2164 (internal links handled inconsistently) — фиксим на своей стороне.
  • Возможный follow-up (не в этом PR): выровнять серверный INTERNAL_LINK_REGEX под бесспейсовый /p/<slug> и якорные #-ссылки, чтобы и они бэклинкались (ограничение есть и для нативных ссылок).
## Проблема / Цель При импорте 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` (ядро; `markdownToProseMirror` L1105-1109 — async-обёртка). - link-mark атрибут `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:81` `parseInternalLink(href, mark.attrs.internal)`, `:395/:503` `target={isInternal ? undefined : "_blank"}`, SPA-`navigate()` и hover-превью — всё завязано на `internal`. - Сервер (backlinks): `apps/server/src/common/helpers/prosemirror/utils.ts:72-91` `extractInternalLinkSlugIds` берёт ссылку в граф только если `mark.attrs.internal && href.match(INTERNAL_LINK_REGEX)`. - Канон «что есть внутренний путь»: `apps/server/src/integrations/export/utils.ts:12-13` `INTERNAL_LINK_REGEX` + `extractPageSlugId` (L132). - Прецедент в этом же репозитории: файловый импортёр Docmost уже помечает внутренние `<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`/`insertNode` markdown-формы (`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`, чистый хелпер: ```ts // Matches ONLY the space-qualified, root-relative internal page path. // A strict SUBSET of the server's INTERNAL_LINK_REGEX // (apps/server/src/integrations/export/utils.ts) so every link we mark // internal is guaranteed backlink-able and export-rewritable. const INTERNAL_PAGE_PATH = /^\/s\/[^/]+\/p\/[a-zA-Z0-9-]+\/?$/; export function isInternalPagePath(href: string): boolean { return typeof href === "string" && INTERNAL_PAGE_PATH.test(href); } ``` Принимает: `/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-нод; для каждой mark `type==="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-внутренняя, но невидимая графу». Осознанно не трогаем; выравнивание серверного регэкспа под эти формы — отдельная возможная правка (см. Связи). - **Не входит:** детект абсолютных URL на свой же хост (`https://<self>/s/.../p/...`) — у чистого конвертера нет знания о своём хосте; это уровень MCP/сервера с `appUrl`, отдельная фича. - **Не входит:** смежная жалоба про `getPageJson`, отдающий закешированную версию после записи — это read-cache/collab-persist, не markdown-импорт. - **Не трогаем** `docmost-schema.ts` и не добавляем `data-internal` parseHTML (ортогонально; для этого бага не нужно). ## Критерии приёмки 1. `updatePageMarkdown` на странице с телом `[t](/s/<space>/p/abc123)` → `getPageJson`/`getNode` показывает у этой link-mark `internal:true`, `target:null`, `rel:null`. 2. Целевая страница показывает входящий backlink на страницу-источник (backlink API/UI сервера). 3. Внешняя `[t](https://example.com/p/x)` в том же теле остаётся `internal:null`/absent с `target:"_blank"` — не помечается внутренней. Бесспейсовый `[t](/p/abc123)` тоже остаётся внешним (задокументированное ограничение). 4. Кликом по внутренней ссылке в редакторе — переход в той же вкладке + hover-превью (не новая вкладка). 5. `createComment` с внутренней ссылкой в теле → mark тоже `internal:true` (единое поведение). 6. git-sync: повторная стабилизация страницы с внутренней ссылкой даёт байт-идентичный `.md` (нет git-diff — проверяемо `git status`). 7. Round-trip property-тесты (`flat-roundtrip.property`, `nested-roundtrip.property`, `roundtrip.test`) зелёные; свойство «парсер тотален» сохранено. 8. Внешняя ссылка редакторного происхождения с `internal:false` проходит `docsCanonicallyEqual(stored, reimport) === true` (правка canonicalize). 9. Новый юнит-тест: `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 (не хуже прежней внешней вкладки). Алерт не нужен. - Детект — чистая функция без внешних эффектов; спец-метрика не нужна (мисдетект = ссылка осталась внешней, видно глазами). - Необратимых действий не вводится; audit trail не затрагивается. ## Совместимость и миграции - **Изменение поведения для существующих пользователей (явно):** markdown-импортированные внутренние ссылки во всех перечисленных путях (MCP, серверный REST/импорт, ai-chat, комментарии, git-sync, вставка в редактор) начнут открываться в той же вкладке, получать превью и участвовать в backlinks. Это и есть цель, но это смена наблюдаемого поведения. - **Разовый churn версий в БД:** при первом ре-импорте страницы с внутренней ссылкой `internal:null→true` — реальное изменение контента → одна новая версия. Однократно, не осциллирует (детект детерминирован; экспорт роняет `internal`, git-байты не меняются → push не зациклится; `nodes-write.ts:216-220` dedup — на live-doc edits `editPageText`, `markInternalLinks` там не бежит). Ленивая «миграция» — на следующем редактировании/ресинке; скрипта и блокировки горячих таблиц нет. - **Git-файлы:** churn нет — `internal` не имеет markdown-представления, стабилизация байт-идентична. - **Rollback:** правка чисто кодовая (конвертер + одна строка canonicalize), откат = revert коммита; данные откатывать не нужно, runtime-флаг не заводим (низкий риск, форма совпадает с той, что редактор и так производит для вставленных ссылок). ## Решённые развилки - Где чинить: общий конвертер пакета vs MCP-only post-walk → **общий конвертер** (R2/R3: примитив на авторитетной стороне; MCP-only оставил бы сломанными комментарии, серверный REST, ai-chat, git-sync и дублировал бы логику). *(дизайн-очевидность)* - HTML-инъекция `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-внутренняя-но-невидимая-графу ссылка; сужение делает конвертер строгим подмножеством → всё помеченное гарантированно бэклинкуется). *(нашла состязательная атака)* - Правка canonicalize `internal:false` — в этот же PR? → **да** (раз `internal` делаем load-bearing, обязаны закрыть §11-асимметрию `false`; та же связная задача «корректная канонизация internal»). *(нашла состязательная атака)* ## Связи - Апстрим-контекст: Docmost issue #2164 (internal links handled inconsistently) — фиксим на своей стороне. - Возможный follow-up (не в этом PR): выровнять серверный `INTERNAL_LINK_REGEX` под бесспейсовый `/p/<slug>` и якорные `#`-ссылки, чтобы и они бэклинкались (ограничение есть и для нативных ссылок).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#522