Files
gitmost/docs/page-templates-plan.md
vvzvlad a473df7f32 docs: add design plan for live page-template embeds
Add docs/page-templates-plan.md describing a whole-page live
transclusion feature: pages flagged is_template, a new pageEmbed
node referencing a source page, a whole-page lookup endpoint reusing
the existing transclusion access-control and share paths, reference
sync, duplicate remap, and cycle/deletion/access/export edge cases.
Decision: separate pageEmbed node over extending transclusionReference.
2026-06-18 18:45:57 +03:00

23 KiB

Шаблоны страниц — живая вставка целой страницы в другие — дизайн

Статус: черновик / дизайн. Реализация ещё не начата. Исходный кейс: одну страницу-«шаблон» нужно вставлять в несколько других так, чтобы при правке источника вставки обновлялись автоматически.

Принятые на старте решения (выбор пользователя):

  • Семантика — живая синхронная вставка (контент источника обновляется в местах вставки), НЕ статическая копия.
  • Сценарий — вставка ноды в тело существующей страницы через slash-команду + пикер.
  • Источник — обычная страница со спец-флагом is_template.

1. Что уже есть в кодовой базе (и почему мы это расширяем)

В Gitmost уже реализована блочная транслюзия (synced blocks) — она покрывает «вставить ОДИН блок живой ссылкой в другие страницы»:

  • Ноды transclusionSource / transclusionReferencepackages/editor-ext/src/lib/transclusion/.
  • Таблицы page_transclusions (снапшот каждого source-блока на странице) и page_transclusion_references (кто кого ссылается) — миграция.
  • Сервис transclusion.service.ts: lookup, lookupWithAccessSet, syncPageTransclusions, syncPageReferences, unsyncReference, listReferences, insert*ForPages.
  • Контроль доступа: filterViewerAccessiblePageIds (членство в space + page-permissions) и публичный share-путь ShareService.lookupTransclusionForShare (граф доступа share, токенизация вложений, срезание комментариев).
  • Клиент: read-only рендерер transclusion-content.tsx, батчинг-контекст transclusion-lookup-context.tsx, вьюха ссылки transclusion-reference-view.tsx.
  • Синхронизация ссылок происходит в persistence.extension.ts (syncTransclusion после сохранения документа), только для Yjs-путей (живой коллаб). REST-обновления контента сейчас транслюзию не пересинхронизируют.

Вывод: нужная фича — это та же транслюзия, но на уровне целой страницы, а не блока, плюс пометка источника флагом. ~70 % инфраструктуры переиспользуется; писать с нуля нужно только нодy pageEmbed, whole-page lookup, флаг is_template и UI-вставку.

Что НЕ переиспользуем

В БД есть upstream-таблица Templates (Docmost), настройка allowMemberTemplates, тип избранного template и урезанный TemplateSlashCommand/templateExtensions. Это другая, статическая механика («создать страницу из шаблона-копии») и она не подходит под выбранный сценарий (живой синхрон + источник-страница). Не конфликтуем с ней, но и не строим на ней — ведём отдельный флаг is_template на странице. Урезанный TemplateSlashCommand к нашей фиче отношения не имеет.

2. Модель

  • Шаблон = обычная, живая, редактируемая страница с pages.is_template = true. Флаг меняет только то, как страница всплывает (пикер шаблонов, опционально — группировка/скрытие в дереве), но не запрещает её редактировать или открывать как обычную.
  • Вставка = новая Tiptap-нода pageEmbed (блочная, atom, isolating) с атрибутом sourcePageId. Рендерится read-only: вьюха тянет весь текущий контент страницы-источника и показывает его. Снапшот контента в документе хоста НЕ хранится — только ссылка sourcePageId. За счёт этого вставка «живая».
  • Обратные ссылки = таблица page_template_references (reference_page_id, source_page_id) — чтобы знать «где используется этот шаблон» (для предупреждения при удалении и инвалидации кэша). Аналог page_transclusion_references, но whole-page.

3. Развилка: отдельная нода pageEmbed vs расширение transclusionReference

Вариант A (рекомендуется) — отдельная нода pageEmbed

transclusionReference адресует конкретный блок по transclusionId внутри sourcePageId. У whole-page нет transclusionId. Можно было бы подставлять sentinel (transclusionId = '__page__'), но это засоряет инварианты уже работающей блочной транслюзии и её UNIQUE-констрейнт.

  • Плюсы: проверенный блочный путь не трогаем (нулевой риск регрессии); чистое разделение; при этом переиспользуем хелперы (рендерер, батчинг, контроль доступа).
  • Минусы: чуть больше нового кода (новая нода, вьюха, эндпоинт, таблица).

Вариант B — расширить transclusionReference на whole-page (transclusionId = null)

  • Плюсы: максимум переиспользования (та же нода, lookup, unsync, ремап при duplicate).
  • Минусы: NULL в UNIQUE-констрейнте Postgres ведёт себя нетривиально (NULL-ы различны); ломаются инварианты рабочей фичи; риск регрессии блочной транслюзии.

Решение: Вариант A. Дальше дизайн исходит из pageEmbed.

4. Модель данных (миграции)

Соглашение по именованию: apps/server/src/database/migrations/YYYYMMDDThhmmss-description.ts. Только ДОБАВЛЯЕМ столбцы/таблицы. После — pnpm --filter server migration:codegen для регенерации src/database/types/db.d.ts.

Миграция 1 — флаг шаблона:

ALTER TABLE pages ADD COLUMN is_template boolean NOT NULL DEFAULT false;
-- частичный индекс под пикер шаблонов
CREATE INDEX pages_is_template_idx ON pages (workspace_id) WHERE is_template;

Миграция 2 — обратные ссылки whole-page (можно отложить до фазы 2, см. §9):

CREATE TABLE page_template_references (
  id uuid PRIMARY KEY DEFAULT gen_uuid_v7(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  reference_page_id uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, -- где встроено
  source_page_id    uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE, -- какой шаблон
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (reference_page_id, source_page_id)
);
CREATE INDEX page_template_references_source_idx ON page_template_references (source_page_id);
CREATE INDEX page_template_references_ws_idx     ON page_template_references (workspace_id);

5. Бэкенд

5.1. Флаг is_template

  • Тоггл: новый POST /pages/toggle-template (или поле в существующем POST /pages/update) → pages.is_template. Авторизация — стандартная CASL (право Edit на page/space, как у прочих мутаций страницы).
  • is_template добавить в выдачу pageRepo.findById (колонка уже попадёт в pages select; убедиться, что отдаётся клиенту в IPage).
  • Поиск: расширить search-suggestions фильтром onlyTemplates (для пикера показывать только is_template = true).

5.2. Whole-page lookup (для авторизованных)

Новый эндпоинт POST /pages/template/lookup:

Body: { sourcePageIds: string[] }   // ≤ 50, как у block-lookup
Resp: { items: Array<
          | { sourcePageId, title, icon, content, sourceUpdatedAt }
          | { sourcePageId, status: 'no_access' | 'not_found' }
        > }
  • Доступ: переиспользовать filterViewerAccessiblePageIds (членство в space + pagePermissionRepo.filterAccessiblePageIds). Если страница недоступна → no_access; удалена/нет → not_found.
  • Контент: брать pages.content; срезать comment-марки (комментарии принадлежат источнику) через removeMarkTypeFromDoc(doc, 'comment') — как делает share-путь.
  • not_template: можно НЕ запрещать встраивать не-шаблон (флаг — это про обнаружение в пикере, а не жёсткий констрейнт). Решение: lookup отдаёт контент любой доступной страницы; пикер же показывает только шаблоны. Это упрощает и не создаёт «битых» вставок, если со страницы потом сняли флаг.

5.3. Синхронизация обратных ссылок

  • Добавить collectPageEmbedsFromPmJson(doc) рядом с transclusion-prosemirror.util.ts — обход PM JSON, сбор pageEmbed нод → { sourcePageId }[] (дедуп).
  • Добавить syncPageTemplateReferences(referencePageId, workspaceId, pmJson) (diff с page_template_references) и дёрнуть его в persistence.extension.syncTransclusion.
  • Известный пробел: REST-обновления контента (агент/AI через updatePageContent) не вызывают syncTransclusion. Для нашей фичи это терпимо: lookup работает по sourcePageId из самой ноды, а рассинхрон затронет только обратную таблицу (UI «где используется»). Отметить как follow-up.

5.4. Публичный share-путь (фаза 2)

Зеркалить ShareService.lookupTransclusionForSharePOST /shares/template/lookup:

  • источник-шаблон резолвится, только если он сам попадает в граф доступа share (его шарили / есть расшаренный предок с includeSubPages);
  • токенизация вложений источника, срезание комментариев, схлопывание not_found → no_access (анти-утечка).
  • UX-нюанс: шаблоны обычно лежат вне расшаренного поддерева → по умолчанию в публичном share они дадут no_access (вьюха покажет плейсхолдер). Это безопасный дефолт (без случайной утечки). Альтернатива «запекать контент шаблона в хост для share-зрителя» — отдельное решение, фаза 3.

5.5. Ремап при дублировании страниц

В duplicatePage (page.service.ts) уже ремапятся mention и transclusionReference.sourcePageId. Добавить ремап pageEmbed.sourcePageId (если источник тоже в копируемом наборе → указать на новую копию; иначе оставить как есть). Плюс insertTemplateReferencesForPages по аналогии с insertReferencesForPages.

5.6. Регистрация ноды в серверной схеме (критично!)

Нода pageEmbed должна быть зарегистрирована в серверном tiptapExtensions (collaboration.util.ts), иначе сервер вырежет её при сохранении/коллаборации (та же ловушка, что описана в arbitrary-html-embed-plan.md §2). MCP-зеркало схемы (packages/mcp/src/lib/) — обновлять не обязательно для MVP (MCP может трактовать ноду как opaque), отметить как follow-up.

6. Клиент

6.1. Нода pageEmbed

  • Новый модуль packages/editor-ext/src/lib/page-embed/page-embed.ts: Node.create({ name:'pageEmbed', group:'block', atom:true, isolating:true }), атрибут sourcePageId с parseHTML/renderHTML через data-source-page-id (для round-trip HTML↔JSON и paste). Экспорт в packages/editor-ext/src/index.ts.
  • Регистрация в клиентских mainExtensions (extensions.ts) и серверной схеме (§5.6).

6.2. NodeView page-embed-view.tsx

  • Тянет whole-page контент через useTemplateLookup (расширить/обобщить батчинг-паттерн transclusion-lookup-context.tsx, или TanStack Query с ключом sourcePageId).
  • Тело рендерит read-only вложенным редактором по образцу transclusion-content.tsx (изоляция событий, editable=false, UniqueID с updateDocument:false).
  • Шапка: иконка+заголовок шаблона со ссылкой на источник, кнопка «обновить», меню «отвязать → превратить в статическую копию» (новый unsyncPageEmbed, запекает текущий контент в документ хоста — по образцу unsyncReference).
  • Защита от циклов (см. §7.1).

6.3. Slash-команда + пикер

  • Slash-пункт /template (или /embed page) открывает пикер страниц — переиспользовать mention-list.tsx + search-query с фильтром onlyTemplates → вставляет pageEmbed с выбранным sourcePageId.

6.4. Пометить страницу как шаблон

  • Тоггл «Сделать шаблоном / Снять» в меню узла дерева (space-tree-node-menu.tsx) и/или в «...» меню заголовка страницы → мутация на POST /pages/toggle-template.
  • (Опционально, фаза 2) Галерея/раздел «Шаблоны».

7. Краевые случаи (главное)

7.1. Циклы / бесконечная рекурсия (самое важное)

A встраивает B, B встраивает A → бесконечная вложенность на клиенте. Сервер из lookup отдаёт «сырой» контент одного уровня и зациклиться не может — гард обязателен на клиенте:

  • React-контекст с цепочкой sourcePageId предков; если текущий sourcePageId уже в цепочке → рендерить плейсхолдер «циклическая вставка», не рекурсировать.
  • Жёсткий лимит глубины вложенности (например, 5).
  • При выборе в пикере запрещать вставку самой текущей страницы (self-embed). Полное обнаружение циклов на вставке (обход графа) — избыточно, опираемся на рендер-гард.

7.2. Удаление шаблона

Удаление страницы-шаблона — soft-delete (корзина) → вставки дают not_found/no_access, вьюха показывает «шаблон в корзине/не найден». Таблица page_template_references позволяет предупредить «используется в N страницах» перед удалением. При восстановлении вставки снова резолвятся.

7.3. Доступ

Зритель хоста может не иметь доступа к странице-источнику (другой space/ограничение) → lookup вернёт no_access, вьюха — плейсхолдер. Это корректно (без утечки).

7.4. Комментарии

Срезать comment-марки из встроенного контента (removeMarkTypeFromDoc) — комментарии относятся к источнику.

7.5. Вложения

Встроенный контент ссылается на вложения источника. Для авторизованных доступ обычный (lookup уже проверил доступ к источнику). Для публичных share — токенизация по образцу share-пути (фаза 2).

7.6. Вложенные транслюзии внутри шаблона

Шаблон может содержать transclusionSource/transclusionReference/pageEmbed. При whole-page рендере они отрисуются своими вьюхами (доп. вложенные lookup-и) — работает, но учитывать в гарде глубины (§7.1).

7.7. История версий хоста

В истории хоста хранится только нода-ссылка (мелкая), не снапшот. Значит старые версии хоста покажут текущий контент шаблона (живой), без point-in-time точности. Снапшот-режим — вне scope, отметить.

7.8. Экспорт (Markdown/HTML) и RAG/поиск

jsonToHtml/jsonToMarkdown/jsonToText на сервере не развернут pageEmbed (в документе только ссылка) → экспорт и textContent хоста не содержат текста шаблона; полнотекстовый/RAG-поиск не найдёт хост по тексту шаблона. Для MVP — плейсхолдер/ссылка; серверное разворачивание вставок при экспорте/индексации — фаза 3.

8. Реестр переиспользования

Что Файл Как используем
Read-only рендерер transclusion-content.tsx тело pageEmbed
Батчинг lookup transclusion-lookup-context.tsx useTemplateLookup
Контроль доступа transclusion.service.ts::filterViewerAccessiblePageIds / lookupWithAccessSet whole-page lookup
Share-путь share.service.ts::lookupTransclusionForShare lookupTemplateForShare (фаза 2)
Sync ссылок persistence.extension.ts::syncTransclusion + collectReferencesFromPmJson + collectPageEmbedsFromPmJson / syncPageTemplateReferences
Unsync→копия transclusion.service.ts::unsyncReference unsyncPageEmbed
Пикер страниц mention-list.tsx + search-query пикер шаблонов (onlyTemplates)
Ремап при копировании page.service.ts::duplicatePage + ремап pageEmbed.sourcePageId
Меню страницы space-tree-node-menu.tsx тоггл «Сделать шаблоном»
Серверная схема collaboration.util.ts::tiptapExtensions регистрация pageEmbed (критично)

9. Этапность

  • MVP: флаг is_template + тоггл-UI; нода pageEmbed + вьюха (живой read-only fetch с гардом циклов); /template slash + пикер; auth-эндпоинт lookup; синхронизация ссылок; ремап при duplicate. Без share (на публичных страницах — плейсхолдер), без разворачивания при экспорте. Таблица page_template_references — желательна, но можно начать с резолва по in-doc нодам.
  • Фаза 2: публичный share-lookup; «отвязать → статическая копия»; «используется в N страницах» + предупреждение при удалении; галерея шаблонов.
  • Фаза 3: разворачивание вставок на сервере для экспорта/RAG/textContent; режим point-in-time снапшота; обновление MCP-зеркала схемы; sync ссылок на REST-пути.

10. Открытые вопросы

  1. Прятать ли страницы-шаблоны из обычного дерева space или показывать с бейджем? (предлагаю: показывать с бейджем, отдельную «галерею» — фаза 2).
  2. Ограничивать ли источник только is_template-страницами на бэке, или разрешать встраивать любую доступную (флаг — только для пикера)? (предлагаю второе — меньше «битых» вставок).
  3. Нужен ли whole-page embed на публичных share сразу в MVP или плейсхолдер достаточен на старте? (предлагаю плейсхолдер → фаза 2).