feat(editor): page templates — live whole-page embed (MVP) #17

Merged
Ghost merged 5 commits from feat/page-templates into develop 2026-06-20 20:34:44 +03:00

Implements docs/page-templates-plan.md (MVP, Variant A — a separate pageEmbed node).

What

Embed another page's live content into a host page — when the source changes, every embed updates (a live read-only view, not a static copy). A page can be flagged a template so it surfaces in the insert picker; any accessible page can be embedded.

How

Server

  • Migrations (additive): pages.is_template (+ partial index) and page_template_references (whole-page back-refs). db.d.ts/entity types hand-merged (this repo curates db.d.ts).
  • POST /pages/toggle-template (CASL Edit) flips the flag; is_template is returned by findById and the sidebar tree select so the tree menu label reflects state. Search suggestions gain onlyTemplates for the picker.
  • POST /pages/template/lookup ({sourcePageIds[]}, ≤50): returns each accessible source's {title, icon, slugId, content, sourceUpdatedAt} with comment marks stripped — using the same access path as block transclusion (filterViewerAccessiblePageIds: workspace + space membership + page permissions). Inaccessible → no_access, missing → not_found, error → not_found (never raw content).
  • Reference sync (collectPageEmbedsFromPmJson + syncPageTemplateReferences) on the Yjs save hook; duplicatePage remaps pageEmbed.sourcePageId and inserts refs.

Client

  • pageEmbed node (editor-ext), registered in both client and server schemas (or the server strips it). Read-only NodeView with a batching lookup; /Embed page slash + a template picker (self-embed prevented); Make/Unset template in the tree node menu.
  • Cycle guard: an ancestry-chain React context + a depth cap (5) render a "circular embed" placeholder instead of recursing — verified A↔B doesn't hang.
  • Public shares render a placeholder (no public lookup in MVP).

Reasoning / decisions

  • Variant A (separate node, not extending transclusionReference) — avoids polluting the working block-transclusion invariants/UNIQUE constraint with a whole-page sentinel; reuses its renderer/access helpers.
  • Lookup does not require is_template — the flag is for picker discovery only, so removing the flag later doesn't break existing embeds.

Review findings & fixes

Review confirmed no access leak/pages/template/lookup uses the identical access checks as the trusted transclusion lookup (tried cross-space / restricted / cross-workspace ids — all filtered, workspace re-applied at the repo). Cycle guard, workspace-scoped refs, additive migrations all verified. Fixes applied:

  • WARNING: the sidebar tree query didn't select is_template, so the tree menu was stuck on "Make template" (couldn't un-template after reload) → added isTemplate to the tree select + buildTree.
  • Error-path comment-mark leak → error path now returns not_found.
  • Removed an unused import; the "open source" link now uses the real slugId (added to the lookup response) via buildPageUrl instead of a raw UUID.

Verification

  • pnpm --filter @docmost/editor-ext build + pnpm --filter server build + pnpm --filter client build — clean.
  • Tests: page-embed.util.spec (collect/dedup + HTML↔JSON round-trip through the server schema), page-template-lookup.spec (access mapping: no_access/not_found/content+comment-strip) — pass.
  • Browser (headless Chromium, collab on the branch schema): flagged a page template (menu label flipped to "Unset as template"); inserted a pageEmbed via the picker into a host page → host showed the source's live content; editing the source then reloading the host showed the update (live, not a snapshot); an A↔B cycle rendered the "circular embed" placeholder with no hang/crash; self-embed not offered. No real app errors. Screenshots captured.

🤖 Generated with Claude Code

Implements `docs/page-templates-plan.md` (MVP, **Variant A** — a separate `pageEmbed` node). ## What Embed another page's **live** content into a host page — when the source changes, every embed updates (a live read-only view, not a static copy). A page can be flagged a **template** so it surfaces in the insert picker; any accessible page can be embedded. ## How **Server** - Migrations (additive): `pages.is_template` (+ partial index) and `page_template_references` (whole-page back-refs). `db.d.ts`/entity types **hand-merged** (this repo curates `db.d.ts`). - `POST /pages/toggle-template` (CASL Edit) flips the flag; `is_template` is returned by `findById` **and the sidebar tree select** so the tree menu label reflects state. Search suggestions gain `onlyTemplates` for the picker. - `POST /pages/template/lookup` (`{sourcePageIds[]}`, ≤50): returns each **accessible** source's `{title, icon, slugId, content, sourceUpdatedAt}` with `comment` marks stripped — using the **same access path as block transclusion** (`filterViewerAccessiblePageIds`: workspace + space membership + page permissions). Inaccessible → `no_access`, missing → `not_found`, error → `not_found` (never raw content). - Reference sync (`collectPageEmbedsFromPmJson` + `syncPageTemplateReferences`) on the Yjs save hook; `duplicatePage` remaps `pageEmbed.sourcePageId` and inserts refs. **Client** - `pageEmbed` node (editor-ext), registered in **both** client and server schemas (or the server strips it). Read-only NodeView with a batching lookup; `/Embed page` slash + a template picker (self-embed prevented); `Make/Unset template` in the tree node menu. - **Cycle guard:** an ancestry-chain React context + a depth cap (5) render a "circular embed" placeholder instead of recursing — verified A↔B doesn't hang. - Public shares render a placeholder (no public lookup in MVP). ## Reasoning / decisions - **Variant A** (separate node, not extending `transclusionReference`) — avoids polluting the working block-transclusion invariants/UNIQUE constraint with a whole-page sentinel; reuses its renderer/access helpers. - Lookup does **not** require `is_template` — the flag is for picker discovery only, so removing the flag later doesn't break existing embeds. ## Review findings & fixes Review **confirmed no access leak** — `/pages/template/lookup` uses the identical access checks as the trusted transclusion lookup (tried cross-space / restricted / cross-workspace ids — all filtered, workspace re-applied at the repo). Cycle guard, workspace-scoped refs, additive migrations all verified. Fixes applied: - **WARNING:** the sidebar tree query didn't select `is_template`, so the tree menu was stuck on "Make template" (couldn't un-template after reload) → added `isTemplate` to the tree select + `buildTree`. - Error-path comment-mark leak → error path now returns `not_found`. - Removed an unused import; the "open source" link now uses the real `slugId` (added to the lookup response) via `buildPageUrl` instead of a raw UUID. ## Verification - `pnpm --filter @docmost/editor-ext build` + `pnpm --filter server build` + `pnpm --filter client build` — clean. - Tests: `page-embed.util.spec` (collect/dedup + HTML↔JSON round-trip through the server schema), `page-template-lookup.spec` (access mapping: no_access/not_found/content+comment-strip) — pass. - Browser (headless Chromium, collab on the branch schema): flagged a page template (menu label flipped to "Unset as template"); inserted a `pageEmbed` via the picker into a host page → host showed the source's live content; **editing the source then reloading the host showed the update** (live, not a snapshot); an A↔B cycle rendered the "circular embed" placeholder with no hang/crash; self-embed not offered. No real app errors. Screenshots captured. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 2 commits 2026-06-20 10:05:33 +03:00
Embed another page's LIVE content into a host page (it updates when the source
changes, not a static copy). A page can be flagged a template for discovery in
the picker; any accessible page can be embedded.

Server:
- migrations: pages.is_template (+ partial index) and page_template_references
  (whole-page back-refs); db.d.ts/entity types hand-merged (db.d.ts is curated).
- POST /pages/toggle-template (CASL Edit) flips is_template; is_template is
  returned by findById + the sidebar tree select so the tree menu label
  reflects state. Search suggestions gain an onlyTemplates filter for the picker.
- POST /pages/template/lookup ({sourcePageIds[]}, <=50): returns each accessible
  source's {title, icon, slugId, content, sourceUpdatedAt} with comment marks
  stripped (same access path as transclusion: filterViewerAccessiblePageIds;
  inaccessible -> no_access, missing -> not_found; error path -> not_found, never
  raw content).
- reference sync (collectPageEmbedsFromPmJson + syncPageTemplateReferences) on
  the Yjs save hook; duplicatePage remaps pageEmbed.sourcePageId + inserts refs.
  Known MVP gap: REST content updates don't resync refs (lookup uses in-doc ids).

Client:
- pageEmbed node (editor-ext, registered in BOTH client + server schemas);
  read-only NodeView with a batching lookup; '/Embed page' slash + template
  picker (self-embed prevented); 'Make/Unset template' in the tree node menu.
- Cycle guard: an ancestry-chain context + depth cap (5) render a 'circular
  embed' placeholder instead of recursing.
- Public shares show a placeholder (no public lookup in MVP).

MVP excludes (follow-ups): public-share lookup, unsync->static copy, server-side
expansion for export/RAG, MCP schema mirror, point-in-time snapshots.

Implements docs/page-templates-plan.md (MVP, variant A).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 15:16:26 +03:00
Release-cycle review: POST /pages/template/lookup had only JwtAuthGuard and the
embed depth cap was client-only, so a scripted client could drive heavy
full-content fan-out (access control holds per-id, but a cost/DoS gap). And
page_template_references rows were written for any sourcePageId with no
workspace check at sync time (no leak today since lookup re-checks access, but
the graph could accumulate cross-space rows).

- Apply the standard per-user throttler (PAGE_TEMPLATE_THROTTLER, 30/min) to
  /pages/template/lookup and /pages/toggle-template (mirrors ai-chat); auth +
  the toggle's validateCanEdit CASL are unchanged.
- syncPageTemplateReferences / insertTemplateReferencesForPages now restrict
  inserts to in-workspace source ids (filterInWorkspaceSourceIds, workspace +
  not-deleted scoped, trx-aware) and still delete stale out-of-workspace rows
  (self-heal). SECURITY comment: the ref table is NOT access-filtered; every
  consumer must permission-filter at read time (as lookupTemplate does).
- Tests: lookup access exercises the REAL filterViewerAccessiblePageIds
  (no_access / cross-workspace excluded / accessible+comment-stripped / <=50);
  toggle controller CASL (cannot-edit -> Forbidden, flag not flipped); ref-sync
  excludes cross-workspace and keeps in-workspace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 20:10:22 +03:00
PageTemplateController (added on this branch) guards its lookup/toggle routes
with UserThrottlerGuard, which depends on the throttler options provided by
ThrottleModule. CollaborationModule -> TransclusionModule registers that
controller, and the collab server bootstraps CollabAppModule, which did not
import ThrottleModule. The API server's AppModule does, so :3000 booted, but
the collab server (:3001) crashed at startup with
'Nest can't resolve dependencies of the UserThrottlerGuard ... THROTTLER:MODULE_OPTIONS'.
Without collab the editor can't sync, so live editing was broken on this branch.

Import ThrottleModule into CollabAppModule, mirroring AppModule, so the guard
resolves in the collab process too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-20 20:34:32 +03:00
# Conflicts:
#	apps/server/src/integrations/throttle/throttle.module.ts
#	apps/server/src/integrations/throttle/throttler-names.ts
Ghost merged commit 19ae6a0efa into develop 2026-06-20 20:34:44 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#17