diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx new file mode 100644 index 00000000..c989ee21 --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useContext, useMemo } from "react"; + +/** Hard cap on nesting depth for whole-page embeds (cycle/runaway guard). */ +export const PAGE_EMBED_MAX_DEPTH = 5; + +type AncestryValue = { + /** sourcePageIds of every ancestor pageEmbed up the render tree. */ + chain: string[]; + /** Includes the host page id so a top-level self-embed is also caught. */ + hostPageId: string | null; +}; + +const PageEmbedAncestryContext = createContext({ + chain: [], + hostPageId: null, +}); + +/** + * Carries the ancestor `sourcePageId` chain down the nested read-only editors. + * The node view reads it to detect cycles (current id already in the chain) and + * to enforce a hard depth limit before mounting a deeper nested editor. + */ +export function PageEmbedAncestryProvider({ + sourcePageId, + hostPageId, + children, +}: { + sourcePageId?: string | null; + hostPageId?: string | null; + children: React.ReactNode; +}) { + const parent = useContext(PageEmbedAncestryContext); + const value = useMemo(() => { + const nextHost = parent.hostPageId ?? hostPageId ?? null; + if (!sourcePageId) { + return { chain: parent.chain, hostPageId: nextHost }; + } + return { + chain: [...parent.chain, sourcePageId], + hostPageId: nextHost, + }; + }, [parent, sourcePageId, hostPageId]); + + return ( + + {children} + + ); +} + +export function usePageEmbedAncestry() { + return useContext(PageEmbedAncestryContext); +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-content.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-content.tsx new file mode 100644 index 00000000..a9c173f6 --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-content.tsx @@ -0,0 +1,49 @@ +import { EditorProvider } from "@tiptap/react"; +import { useMemo } from "react"; +import { mainExtensions } from "@/features/editor/extensions/extensions"; +import { UniqueID } from "@docmost/editor-ext"; + +type Props = { + content: unknown; +}; + +/** + * Read-only nested renderer for embedded whole-page content. Same pattern as + * the transclusion read-only renderer: drop uniqueID/globalDragHandle, never + * write back, and isolate pointer/drag events from the host editor. Nested + * `pageEmbed`/`transclusionReference` nodes inside the content render with + * their own views (the cycle/depth guard lives in the node view itself). + */ +export default function PageEmbedContent({ content }: Props) { + const extensions = useMemo(() => { + const filtered = mainExtensions.filter( + (e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle", + ); + return [ + ...filtered, + UniqueID.configure({ + types: ["heading", "paragraph", "transclusionSource"], + updateDocument: false, + }), + ]; + }, []); + + const stop = (e: React.SyntheticEvent) => e.stopPropagation(); + + return ( +
+ +
+ ); +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx new file mode 100644 index 00000000..aa2a8caf --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-lookup-context.tsx @@ -0,0 +1,162 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { lookupTemplate } from "@/features/page-embed/services/page-embed-api"; +import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types"; + +type ContextValue = { + subscribe: (s: { + sourcePageId: string; + setResult: (r: PageTemplateLookup) => void; + }) => () => void; + refresh: (sourcePageId: string) => Promise; +}; + +const PageEmbedLookupContext = createContext(null); + +/** + * Batching/de-dup lookup context for whole-page embeds (pageEmbed). Mirrors the + * transclusion lookup context but keys purely on `sourcePageId`. On public + * shares there is no lookup in MVP, so the context simply isn't mounted (the + * node view renders a placeholder when the context is absent). + */ +export function PageEmbedLookupProvider({ + children, +}: { + children: React.ReactNode; +}) { + const subscribersRef = useRef(new Map void>>()); + const queueRef = useRef(new Set()); + const tickRef = useRef | null>(null); + const resultCacheRef = useRef(new Map()); + const inFlightRef = useRef(new Set()); + const pendingRef = useRef(new Map void>>()); + + const flush = useCallback(async () => { + tickRef.current = null; + const ids = Array.from(queueRef.current); + queueRef.current.clear(); + if (ids.length === 0) return; + + for (const id of ids) inFlightRef.current.add(id); + + const resolveWaiters = (id: string) => { + const waiters = pendingRef.current.get(id); + if (!waiters) return; + pendingRef.current.delete(id); + for (const w of waiters) w(); + }; + + try { + const { items } = await lookupTemplate({ sourcePageIds: ids }); + for (const r of items) { + resultCacheRef.current.set(r.sourcePageId, r); + inFlightRef.current.delete(r.sourcePageId); + const subs = subscribersRef.current.get(r.sourcePageId); + if (subs) { + for (const set of subs) set(r); + } + resolveWaiters(r.sourcePageId); + } + } catch (err) { + // Surface the failure: errors must never be swallowed silently. + console.error("[pageEmbed] template lookup failed", err); + for (const id of ids) { + inFlightRef.current.delete(id); + resolveWaiters(id); + } + } + }, []); + + const enqueue = useCallback( + (id: string) => { + queueRef.current.add(id); + if (tickRef.current === null) { + tickRef.current = setTimeout(flush, 10); + } + }, + [flush], + ); + + const subscribe = useCallback( + ({ sourcePageId, setResult }) => { + const list = subscribersRef.current.get(sourcePageId) ?? []; + list.push(setResult); + subscribersRef.current.set(sourcePageId, list); + + const cached = resultCacheRef.current.get(sourcePageId); + if (cached) { + setResult(cached); + } else if (!inFlightRef.current.has(sourcePageId)) { + enqueue(sourcePageId); + } + + return () => { + const cur = subscribersRef.current.get(sourcePageId) ?? []; + const next = cur.filter((x) => x !== setResult); + if (next.length === 0) subscribersRef.current.delete(sourcePageId); + else subscribersRef.current.set(sourcePageId, next); + }; + }, + [enqueue], + ); + + const refresh = useCallback( + (sourcePageId) => + new Promise((resolve) => { + resultCacheRef.current.delete(sourcePageId); + inFlightRef.current.delete(sourcePageId); + const waiters = pendingRef.current.get(sourcePageId) ?? []; + waiters.push(resolve); + pendingRef.current.set(sourcePageId, waiters); + enqueue(sourcePageId); + }), + [enqueue], + ); + + useEffect( + () => () => { + if (tickRef.current) clearTimeout(tickRef.current); + }, + [], + ); + + const value = useMemo( + () => ({ subscribe, refresh }), + [subscribe, refresh], + ); + + return ( + + {children} + + ); +} + +export function usePageEmbedLookup(sourcePageId: string | null | undefined): { + result: PageTemplateLookup | null; + refresh: () => Promise; + available: boolean; +} { + const ctx = useContext(PageEmbedLookupContext); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!ctx || !sourcePageId) return; + const unsubscribe = ctx.subscribe({ sourcePageId, setResult }); + return unsubscribe; + }, [ctx, sourcePageId]); + + const refresh = useCallback(async () => { + if (!ctx || !sourcePageId) return; + await ctx.refresh(sourcePageId); + }, [ctx, sourcePageId]); + + return { result, refresh, available: Boolean(ctx) }; +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-picker.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-picker.tsx new file mode 100644 index 00000000..7648b05e --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-picker.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from "react"; +import { Modal, ScrollArea, TextInput, Text, UnstyledButton, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { IconFileText, IconSearch } from "@tabler/icons-react"; +import type { Editor, Range } from "@tiptap/core"; +import { searchSuggestions } from "@/features/search/services/search-service"; +import type { IPage } from "@/features/page/types/page.types"; + +export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker"; + +type PickerDetail = { + editor: Editor; + range: Range; + /** Host page id, used to forbid self-embed in the picker. */ + hostPageId?: string; +}; + +/** + * Modal page picker for inserting a `pageEmbed`. Queries search-suggestions + * with `onlyTemplates` so only template-flagged pages are offered. Forbids + * selecting the current (host) page (self-embed guard at insertion time). + * Mounted once per editor; opened via a CustomEvent dispatched by the slash + * command item. + */ +export default function PageEmbedPicker() { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + const [query, setQuery] = useState(""); + const detailRef = useRef(null); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.editor) return; + detailRef.current = detail; + setQuery(""); + setOpened(true); + }; + document.addEventListener(PAGE_EMBED_PICKER_EVENT, handler); + return () => document.removeEventListener(PAGE_EMBED_PICKER_EVENT, handler); + }, []); + + const { data, isFetching } = useQuery({ + queryKey: ["page-embed-template-picker", query], + queryFn: () => + searchSuggestions({ + query, + includePages: true, + onlyTemplates: true, + limit: 20, + }), + enabled: opened, + staleTime: 30 * 1000, + }); + + const hostPageId = detailRef.current?.hostPageId; + const pages = ((data?.pages ?? []) as IPage[]).filter( + (p) => p && p.id !== hostPageId, + ); + + const handleSelect = (page: IPage) => { + const detail = detailRef.current; + if (!detail) return; + const { editor, range } = detail; + editor + .chain() + .focus() + .deleteRange(range) + .insertPageEmbed({ sourcePageId: page.id }) + .run(); + setOpened(false); + }; + + return ( + setOpened(false)} + title={t("Embed page")} + size="md" + > + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + autoFocus + mb="sm" + /> + + {pages.length === 0 && !isFetching && ( + + {t("No templates found")} + + )} + {pages.map((page) => ( + handleSelect(page)} + style={{ display: "block", width: "100%", padding: "8px 4px" }} + > + + {page.icon ? ( + {page.icon} + ) : ( + + )} + + {page.title || t("Untitled")} + + + + ))} + + + ); +} diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-view.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-view.tsx new file mode 100644 index 00000000..63890eec --- /dev/null +++ b/apps/client/src/features/editor/components/page-embed/page-embed-view.tsx @@ -0,0 +1,253 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { ActionIcon, Menu, Tooltip } from "@mantine/core"; +import { + IconAlertTriangle, + IconArrowsMaximize, + IconDots, + IconExternalLink, + IconEyeOff, + IconInfoCircle, + IconRefresh, + IconRepeat, + IconTrash, +} from "@tabler/icons-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ErrorBoundary } from "react-error-boundary"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import classes from "../transclusion/transclusion.module.css"; +import { usePageEmbedLookup } from "./page-embed-lookup-context"; +import { + PageEmbedAncestryProvider, + usePageEmbedAncestry, + PAGE_EMBED_MAX_DEPTH, +} from "./page-embed-ancestry-context"; +import PageEmbedContent from "./page-embed-content"; + +function Placeholder({ + icon, + label, +}: { + icon: React.ReactNode; + label: string; +}) { + return ( +
+ {icon} + {label} +
+ ); +} + +export default function PageEmbedView(props: NodeViewProps) { + const isEditable = props.editor.isEditable; + const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null; + const [openMenus, setOpenMenus] = useState(0); + const trackOpen = (open: boolean) => + setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1))); + + return ( + 0 ? "true" : "false"} + contentEditable={false} + > + + // Never swallow: log the full error with the offending source id. + console.error("[pageEmbed] render error", { sourcePageId, err }) + } + fallback={ + } + label="Failed to load this embedded page" + /> + } + > + + + + ); +} + +function PageEmbedBody({ + editor, + node, + deleteNode, + trackOpen, +}: NodeViewProps & { trackOpen: (open: boolean) => void }) { + const { t } = useTranslation(); + const sourcePageId: string | null = node.attrs.sourcePageId ?? null; + const isEditable = editor.isEditable; + const ancestry = usePageEmbedAncestry(); + + // @ts-ignore - editor.storage.pageId is set by the host editor + const hostPageId: string | undefined = editor.storage?.pageId; + + const { result, refresh, available } = usePageEmbedLookup(sourcePageId); + const [refreshing, setRefreshing] = useState(false); + const handleRefresh = async () => { + setRefreshing(true); + try { + await refresh(); + } finally { + setRefreshing(false); + } + }; + + // --- Cycle / depth guard (evaluated before any lookup is rendered) --------- + // Self-embed or a source already present in the ancestor chain → cycle. + const isCycle = + !!sourcePageId && + (ancestry.chain.includes(sourcePageId) || + ancestry.hostPageId === sourcePageId); + const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH; + + const sourceTitle = + result && !("status" in result) ? result.title : null; + const sourceIcon = result && !("status" in result) ? result.icon : null; + // The app routes pages by slugId, not the raw UUID. Build the link from the + // resolved slugId (the `/p/:pageSlug` route redirects to the full URL). + const sourceSlugId = + result && !("status" in result) ? result.slugId : null; + const sourceHref = sourceSlugId + ? buildPageUrl(undefined, sourceSlugId, sourceTitle ?? undefined) + : null; + + const controls = isEditable ? ( +
e.preventDefault()} + > + + + + + + {sourceHref && ( + + + + + + )} + + + + + + + + } + onClick={() => deleteNode()} + > + {t("Remove from page")} + + + +
+ ) : null; + + const header = + sourceTitle || sourceIcon ? ( +
+ {sourceIcon ? `${sourceIcon} ` : } + {sourceHref ? ( + + {sourceTitle || t("Untitled")} + + ) : ( + sourceTitle || t("Untitled") + )} +
+ ) : null; + + let body: React.ReactNode; + if (!sourcePageId) { + body = ( + } + label={t("No page selected")} + /> + ); + } else if (isCycle) { + body = ( + } + label={t("Circular embed: this page is already shown above")} + /> + ); + } else if (isTooDeep) { + body = ( + } + label={t("Embed nesting limit reached")} + /> + ); + } else if (!available) { + // No lookup context (e.g. public share) → placeholder, no fetch in MVP. + body = ( + } + label={t("Embedded page is not available here")} + /> + ); + } else if (!result) { + body =
; + } else if (!("status" in result)) { + body = ( + + + + ); + } else if (result.status === "no_access") { + body = ( + } + label={t("You don't have access to this page")} + /> + ); + } else { + body = ( + } + label={t("The embedded page no longer exists")} + /> + ); + } + + return ( + <> + {controls} + {header} + {body} + + ); +} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 7f856755..7400fa14 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -28,7 +28,9 @@ import { IconTag, IconMoodSmile, IconRotate2, + IconArrowsMaximize, } from "@tabler/icons-react"; +import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker"; import { CommandProps, SlashMenuGroupedItemsType, @@ -535,6 +537,29 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Embed page", + description: "Insert a live, read-only copy of another page.", + searchTerms: [ + "template", + "embed", + "embed page", + "page", + "live", + "include", + "reuse", + ], + icon: IconArrowsMaximize, + command: ({ editor, range }: CommandProps) => { + // @ts-ignore - editor.storage.pageId is set by the host editor + const hostPageId: string | undefined = editor.storage?.pageId; + document.dispatchEvent( + new CustomEvent(PAGE_EMBED_PICKER_EVENT, { + detail: { editor, range, hostPageId }, + }), + ); + }, + }, { title: "2 Columns", description: "Split content into two columns.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 87c7b9e5..c411c568 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -60,6 +60,7 @@ import { Status, TransclusionSource, TransclusionReference, + PageEmbed, TableView, } from "@docmost/editor-ext"; import { @@ -91,6 +92,7 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; +import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx"; import { common, createLowlight } from "lowlight"; import plaintext from "highlight.js/lib/languages/plaintext"; import powershell from "highlight.js/lib/languages/powershell"; @@ -230,7 +232,7 @@ export const mainExtensions = [ Typography, TrailingNode, GlobalDragHandle.configure({ - customNodes: ["transclusionSource", "transclusionReference"], + customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"], }), TextStyle, Color, @@ -381,6 +383,9 @@ export const mainExtensions = [ TransclusionReference.configure({ view: TransclusionReferenceView, }), + PageEmbed.configure({ + view: PageEmbedView, + }), MarkdownClipboard.configure({ transformPastedText: true, }), @@ -420,7 +425,8 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([ "Draw.io (diagrams.net)", "Excalidraw (Whiteboard)", "Audio", - "Synced block" + "Synced block", + "Embed page" ]); const TemplateSlashCommand = Command.configure({ diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 2851d22a..41b6538e 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -73,6 +73,9 @@ import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; +import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed/page-embed-lookup-context"; +import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context"; +import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker"; import { useTranslation } from "react-i18next"; interface PageEditorProps { @@ -407,6 +410,8 @@ export default function PageEditor({ return ( + + {showStatic ? ( )} + {editor && editorIsEditable && }
editor.commands.focus("end")} @@ -461,6 +467,8 @@ export default function PageEditor({ >
)} + + ); } diff --git a/apps/client/src/features/page-embed/queries/page-embed-query.ts b/apps/client/src/features/page-embed/queries/page-embed-query.ts new file mode 100644 index 00000000..cbd9f937 --- /dev/null +++ b/apps/client/src/features/page-embed/queries/page-embed-query.ts @@ -0,0 +1,20 @@ +import { useMutation } from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { toggleTemplate } from "@/features/page-embed/services/page-embed-api"; +import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types"; + +export function useToggleTemplateMutation() { + return useMutation< + ToggleTemplateResponse, + Error, + { pageId: string; isTemplate?: boolean } + >({ + mutationFn: (data) => toggleTemplate(data), + onError: (err: any) => { + notifications.show({ + message: err?.response?.data?.message || "Failed to update template", + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/page-embed/services/page-embed-api.ts b/apps/client/src/features/page-embed/services/page-embed-api.ts new file mode 100644 index 00000000..be203c2c --- /dev/null +++ b/apps/client/src/features/page-embed/services/page-embed-api.ts @@ -0,0 +1,20 @@ +import api from "@/lib/api-client"; +import type { + PageTemplateLookup, + ToggleTemplateResponse, +} from "../types/page-embed.types"; + +export async function lookupTemplate(params: { + sourcePageIds: string[]; +}): Promise<{ items: PageTemplateLookup[] }> { + const r = await api.post("/pages/template/lookup", params); + return r.data; +} + +export async function toggleTemplate(params: { + pageId: string; + isTemplate?: boolean; +}): Promise { + const r = await api.post("/pages/toggle-template", params); + return r.data; +} diff --git a/apps/client/src/features/page-embed/types/page-embed.types.ts b/apps/client/src/features/page-embed/types/page-embed.types.ts new file mode 100644 index 00000000..63ed1a38 --- /dev/null +++ b/apps/client/src/features/page-embed/types/page-embed.types.ts @@ -0,0 +1,16 @@ +export type PageTemplateLookup = + | { + sourcePageId: string; + slugId: string; + title: string | null; + icon: string | null; + content: unknown; + sourceUpdatedAt: string; + } + | { sourcePageId: string; status: "not_found" } + | { sourcePageId: string; status: "no_access" }; + +export type ToggleTemplateResponse = { + pageId: string; + isTemplate: boolean; +}; diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index 6a33445d..d21305a3 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -12,6 +12,7 @@ import { IconLink, IconStar, IconStarFilled, + IconTemplate, IconTrash, } from "@tabler/icons-react"; @@ -30,6 +31,7 @@ import { useRemoveFavoriteMutation, } from "@/features/favorite/queries/favorite-query"; +import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; @@ -63,6 +65,26 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { const addFavorite = useAddFavoriteMutation(); const removeFavorite = useRemoveFavoriteMutation(); const isFavorited = favoriteIds.has(node.id); + const toggleTemplate = useToggleTemplateMutation(); + const isTemplate = !!node.isTemplate; + + const handleToggleTemplate = async () => { + const next = !isTemplate; + try { + await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next }); + // Reflect the new flag locally so the menu label updates immediately. + setData((prev) => + treeModel.update(prev, node.id, { isTemplate: next } as any), + ); + notifications.show({ + message: next + ? t("Page marked as template") + : t("Page is no longer a template"), + }); + } catch { + // mutation surfaces the error via notifications + } + }; const handleCopyLink = () => { const pageUrl = @@ -217,6 +239,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { {t("Copy to space")} + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleToggleTemplate(); + }} + > + {isTemplate ? t("Unset as template") : t("Make template")} + + /database/$1", "^@docmost/transactional/(.*)$": "/integrations/transactional/$1", - "^@docmost/ee/(.*)$": "/ee/$1" + "^@docmost/ee/(.*)$": "/ee/$1", + "^src/(.*)$": "/$1" } } } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 554aa43b..83c6a539 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -44,6 +44,7 @@ import { htmlToMarkdown, TransclusionSource, TransclusionReference, + PageEmbed, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -109,6 +110,7 @@ export const tiptapExtensions = [ Status, TransclusionSource, TransclusionReference, + PageEmbed, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index af4137d6..247a76be 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -371,5 +371,17 @@ export class PersistenceExtension implements Extension { 'Failed to sync transclusion references for page', ); } + try { + await this.transclusionService.syncPageTemplateReferences( + pageId, + workspaceId, + tiptapJson, + ); + } catch (err) { + this.logger.error( + { err, pageId }, + 'Failed to sync page template references for page', + ); + } } } diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index cc1dfb24..d41165c5 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -319,6 +319,7 @@ export class PageService { 'parentPageId', 'spaceId', 'creatorId', + 'isTemplate', 'deletedAt', ]) .select((eb) => this.pageRepo.withHasChildren(eb)) @@ -665,6 +666,18 @@ export class PageService { } } + // Remap whole-page embeds (pageEmbed) the same way: if the embedded + // source page is also part of the copied set, point at its new copy; + // otherwise leave it pointing at the original (live embed of original). + if (node.type.name === 'pageEmbed') { + const sourcePageId = node.attrs.sourcePageId; + if (sourcePageId && pageMap.has(sourcePageId)) { + const mappedPage = pageMap.get(sourcePageId); + //@ts-ignore + node.attrs.sourcePageId = mappedPage.newPageId; + } + } + // Update internal page links in link marks for (const mark of node.marks) { if ( @@ -757,6 +770,21 @@ export class PageService { ); } + try { + await this.transclusionService.insertTemplateReferencesForPages( + insertablePages.map((p) => ({ + id: p.id, + workspaceId: p.workspaceId, + content: p.content, + })), + ); + } catch (err) { + this.logger.error( + 'Failed to insert page template references for duplicated pages', + err, + ); + } + const insertedPageIds = insertablePages.map((page) => page.id); this.eventEmitter.emit(EventName.PAGE_CREATED, { pageIds: insertedPageIds, diff --git a/apps/server/src/core/page/transclusion/dto/template-lookup.dto.ts b/apps/server/src/core/page/transclusion/dto/template-lookup.dto.ts new file mode 100644 index 00000000..8267d2aa --- /dev/null +++ b/apps/server/src/core/page/transclusion/dto/template-lookup.dto.ts @@ -0,0 +1,12 @@ +import { + ArrayMaxSize, + IsArray, + IsUUID, +} from 'class-validator'; + +export class TemplateLookupDto { + @IsArray() + @ArrayMaxSize(50) + @IsUUID('all', { each: true }) + sourcePageIds!: string[]; +} diff --git a/apps/server/src/core/page/transclusion/dto/toggle-template.dto.ts b/apps/server/src/core/page/transclusion/dto/toggle-template.dto.ts new file mode 100644 index 00000000..8bdc6b50 --- /dev/null +++ b/apps/server/src/core/page/transclusion/dto/toggle-template.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class ToggleTemplateDto { + @IsUUID() + pageId!: string; + + /** When omitted, the flag is toggled relative to its current value. */ + @IsOptional() + @IsBoolean() + isTemplate?: boolean; +} diff --git a/apps/server/src/core/page/transclusion/page-template.controller.ts b/apps/server/src/core/page/transclusion/page-template.controller.ts new file mode 100644 index 00000000..a86bfcbc --- /dev/null +++ b/apps/server/src/core/page/transclusion/page-template.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { User } from '@docmost/db/types/entity.types'; +import { TransclusionService } from './transclusion.service'; +import { TemplateLookupDto } from './dto/template-lookup.dto'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PageAccessService } from '../page-access/page-access.service'; +import { ToggleTemplateDto } from './dto/toggle-template.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('pages') +export class PageTemplateController { + constructor( + private readonly transclusionService: TransclusionService, + private readonly pageRepo: PageRepo, + private readonly pageAccessService: PageAccessService, + ) {} + + /** + * Whole-page live embed lookup for authenticated viewers. Returns current + * content (comment marks stripped) for accessible source pages. + */ + @HttpCode(HttpStatus.OK) + @Post('template/lookup') + async lookup(@Body() dto: TemplateLookupDto, @AuthUser() user: User) { + return this.transclusionService.lookupTemplate( + dto.sourcePageIds, + user.id, + user.workspaceId, + ); + } + + /** + * Flip `pages.is_template`. Requires Edit on the page/space (CASL is enforced + * inside `validateCanEdit`). The flag only affects template picker discovery; + * it does not restrict editing or embedding. + */ + @HttpCode(HttpStatus.OK) + @Post('toggle-template') + async toggleTemplate( + @Body() dto: ToggleTemplateDto, + @AuthUser() user: User, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + await this.pageAccessService.validateCanEdit(page, user); + + const isTemplate = + typeof dto.isTemplate === 'boolean' ? dto.isTemplate : !page.isTemplate; + + await this.pageRepo.updatePage({ isTemplate }, page.id); + + return { pageId: page.id, isTemplate }; + } +} diff --git a/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts b/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts new file mode 100644 index 00000000..2bdca7b7 --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/page-embed.util.spec.ts @@ -0,0 +1,102 @@ +import { collectPageEmbedsFromPmJson } from '../utils/transclusion-prosemirror.util'; +import { + htmlToJson, + jsonToHtml, +} from '../../../../collaboration/collaboration.util'; + +describe('collectPageEmbedsFromPmJson', () => { + it('returns [] for null/undefined doc', () => { + expect(collectPageEmbedsFromPmJson(null)).toEqual([]); + expect(collectPageEmbedsFromPmJson(undefined)).toEqual([]); + }); + + it('returns [] for a doc with no pageEmbed nodes', () => { + const doc = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([]); + }); + + it('extracts a top-level pageEmbed', () => { + const doc = { + type: 'doc', + content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'p1' }]); + }); + + it('skips pageEmbed nodes missing sourcePageId', () => { + const doc = { + type: 'doc', + content: [ + { type: 'pageEmbed', attrs: {} }, + { type: 'pageEmbed', attrs: { sourcePageId: '' } }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([]); + }); + + it('dedupes identical sourcePageIds, first-seen order preserved', () => { + const doc = { + type: 'doc', + content: [ + { type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }, + { type: 'pageEmbed', attrs: { sourcePageId: 'p2' } }, + { type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([ + { sourcePageId: 'p1' }, + { sourcePageId: 'p2' }, + ]); + }); + + it('finds pageEmbed nested in other block containers (column)', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'column', + content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'nested' } }], + }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([ + { sourcePageId: 'nested' }, + ]); + }); + + it('does not descend into a transclusion source', () => { + const doc = { + type: 'doc', + content: [ + { + type: 'transclusionSource', + attrs: { id: 'src' }, + content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'hidden' } }], + }, + ], + }; + expect(collectPageEmbedsFromPmJson(doc)).toEqual([]); + }); +}); + +describe('pageEmbed HTML <-> JSON round-trip (server schema)', () => { + it('preserves sourcePageId across jsonToHtml -> htmlToJson', () => { + const doc = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'before' }] }, + { type: 'pageEmbed', attrs: { sourcePageId: 'abc-123' } }, + ], + }; + + const html = jsonToHtml(doc); + expect(html).toContain('data-source-page-id="abc-123"'); + + const back = htmlToJson(html); + const embeds = collectPageEmbedsFromPmJson(back); + expect(embeds).toEqual([{ sourcePageId: 'abc-123' }]); + }); +}); diff --git a/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts new file mode 100644 index 00000000..f62a047c --- /dev/null +++ b/apps/server/src/core/page/transclusion/spec/page-template-lookup.spec.ts @@ -0,0 +1,113 @@ +import { TransclusionService } from '../transclusion.service'; + +/** + * Exercises the pure access/mapping logic of `lookupTemplate`: + * - accessible + present -> content (comments stripped) + meta + * - accessible + missing -> not_found + * - inaccessible -> no_access + * The access decision is taken from `filterViewerAccessiblePageIds`, which we + * stub; DB/repo internals are mocked. + */ +describe('TransclusionService.lookupTemplate (access mapping)', () => { + function makeService(opts: { + accessibleIds: string[]; + pages: Array<{ + id: string; + title: string | null; + icon: string | null; + content: unknown; + updatedAt: Date; + }>; + }) { + const pageRepo = { + findManyByIds: jest.fn().mockResolvedValue(opts.pages), + }; + + const service = new TransclusionService( + {} as any, // db + {} as any, // pageTransclusionsRepo + {} as any, // pageTransclusionReferencesRepo + {} as any, // pageTemplateReferencesRepo + pageRepo as any, + {} as any, // pagePermissionRepo + {} as any, // spaceMemberRepo + {} as any, // attachmentRepo + {} as any, // storageService + {} as any, // pageAccessService + ); + + jest + .spyOn(service, 'filterViewerAccessiblePageIds') + .mockResolvedValue(opts.accessibleIds); + + return { service, pageRepo }; + } + + const now = new Date('2026-06-20T00:00:00.000Z'); + + it('returns no_access for ids the viewer cannot see', async () => { + const { service } = makeService({ accessibleIds: [], pages: [] }); + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toEqual([{ sourcePageId: 'p1', status: 'no_access' }]); + }); + + it('returns not_found for accessible-but-missing pages', async () => { + const { service } = makeService({ accessibleIds: ['p1'], pages: [] }); + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toEqual([{ sourcePageId: 'p1', status: 'not_found' }]); + }); + + it('returns content + meta for accessible pages and strips comment marks', async () => { + const content = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'hello', + marks: [{ type: 'comment', attrs: { commentId: 'c1' } }], + }, + ], + }, + ], + }; + const { service } = makeService({ + accessibleIds: ['p1'], + pages: [ + { id: 'p1', title: 'Tmpl', icon: '📄', content, updatedAt: now }, + ], + }); + + const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1'); + expect(items).toHaveLength(1); + const item = items[0] as any; + expect(item.status).toBeUndefined(); + expect(item.title).toBe('Tmpl'); + expect(item.icon).toBe('📄'); + expect(item.sourceUpdatedAt).toBe(now); + + // comment mark must be gone from the returned content + const json = JSON.stringify(item.content); + expect(json).not.toContain('comment'); + expect(json).toContain('hello'); + }); + + it('maps a mixed batch positionally', async () => { + const { service } = makeService({ + accessibleIds: ['ok'], + pages: [ + { id: 'ok', title: 'A', icon: null, content: { type: 'doc', content: [] }, updatedAt: now }, + ], + }); + const { items } = await service.lookupTemplate( + ['no', 'ok', 'gone'], + 'u1', + 'w1', + ); + expect((items[0] as any).status).toBe('no_access'); + expect((items[1] as any).status).toBeUndefined(); + expect((items[2] as any).status).toBe('no_access'); + }); +}); diff --git a/apps/server/src/core/page/transclusion/transclusion.module.ts b/apps/server/src/core/page/transclusion/transclusion.module.ts index e01e386d..ab8fada5 100644 --- a/apps/server/src/core/page/transclusion/transclusion.module.ts +++ b/apps/server/src/core/page/transclusion/transclusion.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { TransclusionController } from './transclusion.controller'; +import { PageTemplateController } from './page-template.controller'; import { TransclusionService } from './transclusion.service'; @Module({ - controllers: [TransclusionController], + controllers: [TransclusionController, PageTemplateController], providers: [TransclusionService], exports: [TransclusionService], }) diff --git a/apps/server/src/core/page/transclusion/transclusion.service.ts b/apps/server/src/core/page/transclusion/transclusion.service.ts index e208707c..419158e2 100644 --- a/apps/server/src/core/page/transclusion/transclusion.service.ts +++ b/apps/server/src/core/page/transclusion/transclusion.service.ts @@ -10,17 +10,27 @@ import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo'; import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo'; +import { PageTemplateReferencesRepo } from '@docmost/db/repos/page-template-references/page-template-references.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { StorageService } from '../../../integrations/storage/storage.service'; import { + collectPageEmbedsFromPmJson, collectReferencesFromPmJson, collectTransclusionsFromPmJson, } from './utils/transclusion-prosemirror.util'; import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util'; -import { TransclusionLookup } from './transclusion.types'; +import { + PageTemplateLookup, + TransclusionLookup, +} from './transclusion.types'; +import { + getProsemirrorContent, + removeMarkTypeFromDoc, +} from '../../../common/helpers/prosemirror/utils'; +import { jsonToNode } from '../../../collaboration/collaboration.util'; import { Page, User } from '@docmost/db/types/entity.types'; import { PageAccessService } from '../page-access/page-access.service'; @@ -41,6 +51,7 @@ export class TransclusionService { @InjectKysely() private readonly db: KyselyDB, private readonly pageTransclusionsRepo: PageTransclusionsRepo, private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo, + private readonly pageTemplateReferencesRepo: PageTemplateReferencesRepo, private readonly pageRepo: PageRepo, private readonly pagePermissionRepo: PagePermissionRepo, private readonly spaceMemberRepo: SpaceMemberRepo, @@ -217,6 +228,153 @@ export class TransclusionService { return { inserted: rows.length }; } + // --------------------------------------------------------------------------- + // Whole-page live embeds (pageEmbed node) + // --------------------------------------------------------------------------- + + /** + * Diff `page_template_references` for a host page against the `pageEmbed` + * nodes currently in its content. Mirror of `syncPageReferences` but keyed by + * `sourcePageId` only (whole-page, no transclusionId). Idempotent. + */ + async syncPageTemplateReferences( + referencePageId: string, + workspaceId: string, + pmJson: unknown, + trx?: KyselyTransaction, + ): Promise<{ inserted: number; deleted: number }> { + const desired = collectPageEmbedsFromPmJson(pmJson); + const desiredIds = new Set(desired.map((d) => d.sourcePageId)); + + const existing = + await this.pageTemplateReferencesRepo.findByReferencePageId( + referencePageId, + trx, + ); + const existingIds = new Set(existing.map((e) => e.sourcePageId)); + + const toInsert = desired + .filter((d) => !existingIds.has(d.sourcePageId)) + .map((d) => ({ + workspaceId, + referencePageId, + sourcePageId: d.sourcePageId, + })); + + const toDelete = existing + .filter((e) => !desiredIds.has(e.sourcePageId)) + .map((e) => e.sourcePageId); + + if (toInsert.length > 0) { + await this.pageTemplateReferencesRepo.insertMany(toInsert, trx); + } + if (toDelete.length > 0) { + await this.pageTemplateReferencesRepo.deleteByReferenceAndSources( + referencePageId, + toDelete, + trx, + ); + } + + return { inserted: toInsert.length, deleted: toDelete.length }; + } + + /** + * Bulk-insert `page_template_references` for brand-new pages (duplication, + * import) where there is nothing to diff against. + */ + async insertTemplateReferencesForPages( + pages: Array<{ id: string; workspaceId: string; content: unknown }>, + trx?: KyselyTransaction, + ): Promise<{ inserted: number }> { + const rows: Array<{ + workspaceId: string; + referencePageId: string; + sourcePageId: string; + }> = []; + for (const page of pages) { + const embeds = collectPageEmbedsFromPmJson(page.content); + for (const e of embeds) { + rows.push({ + workspaceId: page.workspaceId, + referencePageId: page.id, + sourcePageId: e.sourcePageId, + }); + } + } + if (rows.length === 0) return { inserted: 0 }; + await this.pageTemplateReferencesRepo.insertMany(rows, trx); + return { inserted: rows.length }; + } + + /** + * Resolve whole-page content for a set of source page ids on behalf of an + * authenticated viewer. For each accessible page returns its current content + * with `comment` marks stripped (comments belong to the source). Inaccessible + * pages return `no_access`, missing/deleted pages return `not_found`. Does NOT + * require `is_template` — any accessible page can be embedded (the template + * flag only affects picker discovery). + */ + async lookupTemplate( + sourcePageIds: string[], + viewerUserId: string, + workspaceId: string, + ): Promise<{ items: PageTemplateLookup[] }> { + if (sourcePageIds.length === 0) return { items: [] }; + + const uniqueIds = Array.from(new Set(sourcePageIds)); + const accessibleSet = new Set( + await this.filterViewerAccessiblePageIds( + uniqueIds, + viewerUserId, + workspaceId, + ), + ); + + const accessibleIds = uniqueIds.filter((id) => accessibleSet.has(id)); + const pages = await this.pageRepo.findManyByIds(accessibleIds, { + workspaceId, + includeContent: true, + }); + const pageById = new Map(pages.map((p) => [p.id, p])); + + const items: PageTemplateLookup[] = sourcePageIds.map((sourcePageId) => { + if (!accessibleSet.has(sourcePageId)) { + return { sourcePageId, status: 'no_access' as const }; + } + const page = pageById.get(sourcePageId); + if (!page) { + return { sourcePageId, status: 'not_found' as const }; + } + + let content: unknown = null; + try { + const pmJson = getProsemirrorContent(page.content); + const doc = jsonToNode(pmJson); + content = doc ? removeMarkTypeFromDoc(doc, 'comment').toJSON() : pmJson; + } catch (err) { + this.logger.error( + { err, sourcePageId }, + 'Failed to prepare template content for lookup', + ); + // Never return content carrying the source's comment marks. If the + // happy-path stripping failed, treat the page as not resolvable. + return { sourcePageId, status: 'not_found' as const }; + } + + return { + sourcePageId, + slugId: page.slugId, + title: page.title ?? null, + icon: page.icon ?? null, + content, + sourceUpdatedAt: page.updatedAt, + }; + }); + + return { items }; + } + /** * Resolve viewer access for source page IDs supplied by an authenticated * caller. Restricts candidates to pages the viewer can see at the space @@ -224,7 +382,7 @@ export class TransclusionService { * cannot read a sync block from a private space they don't belong to via * an unrestricted source page. */ - private async filterViewerAccessiblePageIds( + async filterViewerAccessiblePageIds( pageIds: string[], viewerUserId: string, workspaceId: string, diff --git a/apps/server/src/core/page/transclusion/transclusion.types.ts b/apps/server/src/core/page/transclusion/transclusion.types.ts index 240d121b..7501202b 100644 --- a/apps/server/src/core/page/transclusion/transclusion.types.ts +++ b/apps/server/src/core/page/transclusion/transclusion.types.ts @@ -12,3 +12,15 @@ export type TransclusionNodeSnapshot = { transclusionId: string; content: unknown; }; + +export type PageTemplateLookup = + | { + sourcePageId: string; + slugId: string; + title: string | null; + icon: string | null; + content: unknown; + sourceUpdatedAt: Date; + } + | { sourcePageId: string; status: 'not_found' } + | { sourcePageId: string; status: 'no_access' }; diff --git a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts index 307985f8..eeeea0e2 100644 --- a/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts +++ b/apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts @@ -2,12 +2,17 @@ import { TransclusionNodeSnapshot } from '../transclusion.types'; const TRANSCLUSION_TYPE = 'transclusionSource'; const REFERENCE_TYPE = 'transclusionReference'; +const PAGE_EMBED_TYPE = 'pageEmbed'; export type TransclusionReferenceSnapshot = { sourcePageId: string; transclusionId: string; }; +export type PageEmbedSnapshot = { + sourcePageId: string; +}; + /** * Walks a ProseMirror JSON document and returns one snapshot per top-level * `transclusion` node. Does not recurse into transclusions (schema disallows @@ -93,3 +98,42 @@ export function collectReferencesFromPmJson( visit(doc); return out; } + +/** + * Walks a ProseMirror JSON document and returns one snapshot per unique + * `sourcePageId` found on `pageEmbed` nodes (whole-page live embeds). Order + * preserved by first-seen, duplicates deduped. `pageEmbed` is an atom so it + * has no relevant children; we don't descend into transclusion sources. + */ +export function collectPageEmbedsFromPmJson( + doc: unknown, +): PageEmbedSnapshot[] { + if (!doc || typeof doc !== 'object') return []; + + const seen = new Set(); + const out: PageEmbedSnapshot[] = []; + + const visit = (node: any): void => { + if (!node || typeof node !== 'object') return; + + if (node.type === PAGE_EMBED_TYPE) { + const sourcePageId = node.attrs?.sourcePageId; + if (typeof sourcePageId === 'string' && sourcePageId.length > 0) { + if (!seen.has(sourcePageId)) { + seen.add(sourcePageId); + out.push({ sourcePageId }); + } + } + return; // atom node - no children + } + + if (node.type === TRANSCLUSION_TYPE) return; + + if (Array.isArray(node.content)) { + for (const child of node.content) visit(child); + } + }; + + visit(doc); + return out; +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 40486a52..f23dd4d5 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -58,6 +58,10 @@ export class SearchSuggestionDTO { @IsBoolean() includePages?: boolean; + @IsOptional() + @IsBoolean() + onlyTemplates?: boolean; + @IsOptional() @IsString() spaceId?: string; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b265..c3807398 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -216,6 +216,11 @@ export class SearchService { .where('workspaceId', '=', workspaceId) .limit(limit); + // Template picker: restrict to pages flagged as templates. + if (suggestion.onlyTemplates) { + pageSearch = pageSearch.where('isTemplate', '=', true); + } + // search all spaces the user has access to, prioritizing the current space const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 6193eae2..53827d9c 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -13,6 +13,7 @@ import { PagePermissionRepo } from './repos/page/page-permission.repo'; import { CommentRepo } from './repos/comment/comment.repo'; import { PageTransclusionsRepo } from './repos/page-transclusions/page-transclusions.repo'; import { PageTransclusionReferencesRepo } from './repos/page-transclusions/page-transclusion-references.repo'; +import { PageTemplateReferencesRepo } from './repos/page-template-references/page-template-references.repo'; import { PageHistoryRepo } from './repos/page/page-history.repo'; import { AttachmentRepo } from './repos/attachment/attachment.repo'; import { KyselyDB } from '@docmost/db/types/kysely.types'; @@ -85,6 +86,7 @@ import { normalizePostgresUrl } from '../common/helpers'; PagePermissionRepo, PageTransclusionsRepo, PageTransclusionReferencesRepo, + PageTemplateReferencesRepo, PageHistoryRepo, CommentRepo, FavoriteRepo, @@ -115,6 +117,7 @@ import { normalizePostgresUrl } from '../common/helpers'; PagePermissionRepo, PageTransclusionsRepo, PageTransclusionReferencesRepo, + PageTemplateReferencesRepo, PageHistoryRepo, CommentRepo, FavoriteRepo, diff --git a/apps/server/src/database/migrations/20260620T130000-page-is-template.ts b/apps/server/src/database/migrations/20260620T130000-page-is-template.ts new file mode 100644 index 00000000..3b6c7359 --- /dev/null +++ b/apps/server/src/database/migrations/20260620T130000-page-is-template.ts @@ -0,0 +1,20 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('pages') + .addColumn('is_template', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .execute(); + + // Partial index backing the template picker: only template rows are indexed. + await sql`CREATE INDEX pages_is_template_idx ON pages (workspace_id) WHERE is_template`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('pages_is_template_idx').execute(); + await db.schema.alterTable('pages').dropColumn('is_template').execute(); +} diff --git a/apps/server/src/database/migrations/20260620T131000-page-template-references.ts b/apps/server/src/database/migrations/20260620T131000-page-template-references.ts new file mode 100644 index 00000000..0d201062 --- /dev/null +++ b/apps/server/src/database/migrations/20260620T131000-page-template-references.ts @@ -0,0 +1,42 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('page_template_references') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('reference_page_id', 'uuid', (col) => + col.notNull().references('pages.id').onDelete('cascade'), + ) + .addColumn('source_page_id', 'uuid', (col) => + col.notNull().references('pages.id').onDelete('cascade'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('page_template_references_unique', [ + 'reference_page_id', + 'source_page_id', + ]) + .execute(); + + await db.schema + .createIndex('page_template_references_source_idx') + .on('page_template_references') + .column('source_page_id') + .execute(); + + await db.schema + .createIndex('page_template_references_ws_idx') + .on('page_template_references') + .column('workspace_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('page_template_references').execute(); +} diff --git a/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts b/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts new file mode 100644 index 00000000..a678422f --- /dev/null +++ b/apps/server/src/database/repos/page-template-references/page-template-references.repo.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { + InsertablePageTemplateReference, + PageTemplateReference, +} from '@docmost/db/types/entity.types'; + +@Injectable() +export class PageTemplateReferencesRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findByReferencePageId( + referencePageId: string, + trx?: KyselyTransaction, + ): Promise { + return dbOrTx(this.db, trx) + .selectFrom('pageTemplateReferences') + .selectAll() + .where('referencePageId', '=', referencePageId) + .execute(); + } + + async findReferencePageIdsBySource( + sourcePageId: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const rows = await dbOrTx(this.db, trx) + .selectFrom('pageTemplateReferences') + .select('referencePageId') + .distinct() + .where('workspaceId', '=', workspaceId) + .where('sourcePageId', '=', sourcePageId) + .execute(); + return rows.map((r) => r.referencePageId); + } + + async insertMany( + rows: InsertablePageTemplateReference[], + trx?: KyselyTransaction, + ): Promise { + if (rows.length === 0) return; + await dbOrTx(this.db, trx) + .insertInto('pageTemplateReferences') + .values(rows) + .onConflict((oc) => + oc.columns(['referencePageId', 'sourcePageId']).doNothing(), + ) + .execute(); + } + + async deleteByReferenceAndSources( + referencePageId: string, + sourcePageIds: string[], + trx?: KyselyTransaction, + ): Promise { + if (sourcePageIds.length === 0) return; + await dbOrTx(this.db, trx) + .deleteFrom('pageTemplateReferences') + .where('referencePageId', '=', referencePageId) + .where('sourcePageId', 'in', sourcePageIds) + .execute(); + } +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index b2884603..f757803f 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -40,6 +40,7 @@ export class PageRepo { 'spaceId', 'workspaceId', 'isLocked', + 'isTemplate', 'createdAt', 'updatedAt', 'deletedAt', @@ -112,6 +113,7 @@ export class PageRepo { opts?: { trx?: KyselyTransaction; workspaceId?: string; + includeContent?: boolean; }, ): Promise { if (pageIds.length === 0) return []; @@ -120,6 +122,7 @@ export class PageRepo { let query = db .selectFrom('pages') .select(this.baseFields) + .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', 'in', pageIds); if (opts?.workspaceId) { diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 9557a464..314f9a88 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -240,6 +240,14 @@ export interface PageTransclusionReferences { workspaceId: string; } +export interface PageTemplateReferences { + createdAt: Generated; + id: Generated; + referencePageId: string; + sourcePageId: string; + workspaceId: string; +} + export interface PageTransclusions { content: Json; createdAt: Generated; @@ -281,6 +289,7 @@ export interface Pages { icon: string | null; id: Generated; isLocked: Generated; + isTemplate: Generated; lastUpdatedAiChatId: string | null; lastUpdatedById: string | null; lastUpdatedSource: Generated; @@ -615,6 +624,7 @@ export interface DB { notifications: Notifications; pageAccess: PageAccess; pageTransclusionReferences: PageTransclusionReferences; + pageTemplateReferences: PageTemplateReferences; pageTransclusions: PageTransclusions; pagePermissions: PagePermissions; pageHistory: PageHistory; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index fca76a29..a4ecb461 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -11,6 +11,7 @@ import { PageAccess as _PageAccess, PageTransclusions, PageTransclusionReferences, + PageTemplateReferences, PagePermissions as _PagePermissions, PageVerifications as _PageVerifications, PageVerifiers as _PageVerifiers, @@ -180,6 +181,14 @@ export type UpdatablePageTransclusionReference = Updateable< Omit >; +// Page Template Reference (whole-page live embed back-references) +export type PageTemplateReference = Selectable; +export type InsertablePageTemplateReference = + Insertable; +export type UpdatablePageTemplateReference = Updateable< + Omit +>; + // File Task export type FileTask = Selectable; export type InsertableFileTask = Insertable; diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 003d2288..77ce9809 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -22,6 +22,7 @@ export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; export * from "./lib/subpages"; export * from "./lib/transclusion"; +export * from "./lib/page-embed"; export * from "./lib/highlight"; export * from "./lib/indent"; export * from "./lib/heading/heading"; diff --git a/packages/editor-ext/src/lib/page-embed/index.ts b/packages/editor-ext/src/lib/page-embed/index.ts new file mode 100644 index 00000000..43cb3a9c --- /dev/null +++ b/packages/editor-ext/src/lib/page-embed/index.ts @@ -0,0 +1 @@ +export * from "./page-embed"; diff --git a/packages/editor-ext/src/lib/page-embed/page-embed.ts b/packages/editor-ext/src/lib/page-embed/page-embed.ts new file mode 100644 index 00000000..119acfd4 --- /dev/null +++ b/packages/editor-ext/src/lib/page-embed/page-embed.ts @@ -0,0 +1,88 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface PageEmbedOptions { + HTMLAttributes: Record; + view: any; +} + +export interface PageEmbedAttributes { + sourcePageId?: string | null; +} + +declare module "@tiptap/core" { + interface Commands { + pageEmbed: { + insertPageEmbed: (attributes: PageEmbedAttributes) => ReturnType; + }; + } +} + +/** + * Whole-page live embed. Holds only a `sourcePageId` reference; the node view + * fetches the source page's current content at render time, so the embed stays + * live (no snapshot is stored in the host document). Separate from + * `transclusionReference` (which addresses a single block by `transclusionId`). + */ +export const PageEmbed = Node.create({ + name: "pageEmbed", + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + group: "block", + atom: true, + isolating: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + sourcePageId: { + default: null, + parseHTML: (el) => el.getAttribute("data-source-page-id"), + renderHTML: (attrs) => + attrs.sourcePageId + ? { "data-source-page-id": attrs.sourcePageId } + : {}, + }, + }; + }, + + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + addCommands() { + return { + insertPageEmbed: + (attributes) => + ({ commands }) => + commands.insertContent({ + type: this.name, + attrs: attributes, + }), + }; + }, + + addNodeView() { + if (!this.options.view) return null; + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +});