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.
23 KiB
Шаблоны страниц — живая вставка целой страницы в другие — дизайн
Статус: черновик / дизайн. Реализация ещё не начата. Исходный кейс: одну страницу-«шаблон» нужно вставлять в несколько других так, чтобы при правке источника вставки обновлялись автоматически.
Принятые на старте решения (выбор пользователя):
- Семантика — живая синхронная вставка (контент источника обновляется в местах вставки), НЕ статическая копия.
- Сценарий — вставка ноды в тело существующей страницы через slash-команду + пикер.
- Источник — обычная страница со спец-флагом
is_template.
1. Что уже есть в кодовой базе (и почему мы это расширяем)
В Gitmost уже реализована блочная транслюзия (synced blocks) — она покрывает «вставить ОДИН блок живой ссылкой в другие страницы»:
- Ноды
transclusionSource/transclusionReference— packages/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(колонка уже попадёт вpagesselect; убедиться, что отдаётся клиенту в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.lookupTransclusionForShare → POST /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 с гардом циклов);/templateslash + пикер; auth-эндпоинт lookup; синхронизация ссылок; ремап при duplicate. Без share (на публичных страницах — плейсхолдер), без разворачивания при экспорте. Таблицаpage_template_references— желательна, но можно начать с резолва по in-doc нодам. - Фаза 2: публичный share-lookup; «отвязать → статическая копия»; «используется в N страницах» + предупреждение при удалении; галерея шаблонов.
- Фаза 3: разворачивание вставок на сервере для экспорта/RAG/textContent; режим point-in-time снапшота; обновление MCP-зеркала схемы; sync ссылок на REST-пути.
10. Открытые вопросы
- Прятать ли страницы-шаблоны из обычного дерева space или показывать с бейджем? (предлагаю: показывать с бейджем, отдельную «галерею» — фаза 2).
- Ограничивать ли источник только
is_template-страницами на бэке, или разрешать встраивать любую доступную (флаг — только для пикера)? (предлагаю второе — меньше «битых» вставок). - Нужен ли whole-page embed на публичных share сразу в MVP или плейсхолдер достаточен на старте? (предлагаю плейсхолдер → фаза 2).