diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 3330f641..70353fee 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -183,6 +183,7 @@
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
"System settings": "System settings",
+ "Template": "Template",
"Templates": "Templates",
"Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
@@ -473,6 +474,7 @@
"Make sub-pages public too": "Make sub-pages public too",
"Allow search engines to index page": "Allow search engines to index page",
"Open page": "Open page",
+ "Open source page": "Open source page",
"Page": "Page",
"Delete public share link": "Delete public share link",
"Delete share": "Delete share",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 233ced69..8c91dd5a 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -183,6 +183,7 @@
"Successfully imported": "Успешно импортировано",
"Successfully restored": "Успешно восстановлено",
"System settings": "Системные настройки",
+ "Template": "Шаблон",
"Templates": "Шаблоны",
"Theme": "Тема",
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
@@ -478,6 +479,7 @@
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
"Open page": "Открыть страницу",
+ "Open source page": "Открыть исходную страницу",
"Page": "Страница",
"Delete public share link": "Удалить публичную ссылку",
"Delete share": "Удалить общий доступ",
diff --git a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx
index 6b9f3e27..867922d2 100644
--- a/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx
+++ b/apps/client/src/features/editor/components/page-embed/page-embed-ancestry-context.test.tsx
@@ -1,71 +1,149 @@
import { describe, it, expect } from "vitest";
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import {
PageEmbedAncestryProvider,
usePageEmbedAncestry,
+ isPageEmbedCycle,
+ isPageEmbedTooDeep,
+ PAGE_EMBED_MAX_DEPTH,
} from "./page-embed-ancestry-context";
-// Probe child: renders the current ancestry context value as JSON so the test
-// can assert on the accumulated chain and host without any Tiptap editor.
-function Probe({ testId }: { testId: string }) {
- const ancestry = usePageEmbedAncestry();
- return
{JSON.stringify(ancestry)}
;
-}
-
-function read(el: HTMLElement) {
- return JSON.parse(el.textContent || "{}") as {
- chain: string[];
- hostPageId: string | null;
- };
+/**
+ * Tiny probe that renders the current ancestry context as serialized data
+ * attributes so tests can assert the accumulated chain / threaded hostPageId
+ * without mounting the heavy Tiptap node view.
+ */
+function AncestryProbe({ testId = "probe" }: { testId?: string }) {
+ const { chain, hostPageId } = usePageEmbedAncestry();
+ return (
+
+ );
}
describe("PageEmbedAncestryProvider", () => {
- it("accumulates the chain in order across nested providers", () => {
- const { getByTestId } = render(
+ it("defaults to an empty chain and null host with no provider", () => {
+ render();
+ const probe = screen.getByTestId("probe");
+ expect(probe.getAttribute("data-chain")).toBe("");
+ expect(probe.getAttribute("data-chain-length")).toBe("0");
+ expect(probe.getAttribute("data-host")).toBe("");
+ });
+
+ it("accumulates sourcePageId into the chain across nested providers", () => {
+ render(
-
+ ,
);
- const value = read(getByTestId("leaf"));
- expect(value.chain).toEqual(["a", "b", "c"]);
- expect(value.hostPageId).toBe("host");
+ const probe = screen.getByTestId("probe");
+ // Chain is built outermost -> innermost.
+ expect(probe.getAttribute("data-chain")).toBe("a,b,c");
+ expect(probe.getAttribute("data-chain-length")).toBe("3");
});
- it("leaves the chain unchanged when sourcePageId is absent, still propagating the host", () => {
- const { getByTestId } = render(
+ it("threads the host page id from the outermost provider down the tree", () => {
+ render(
+
+
+
+
+ ,
+ );
+ const probe = screen.getByTestId("probe");
+ // The first host wins (parent.hostPageId ?? hostPageId); deeper hosts are
+ // ignored so the original host is preserved for self-embed detection.
+ expect(probe.getAttribute("data-host")).toBe("host-page");
+ });
+
+ it("does not add an entry to the chain when sourcePageId is missing", () => {
+ render(
-
-
+
+
+
+ ,
);
- const value = read(getByTestId("leaf"));
- expect(value.chain).toEqual(["a"]);
- expect(value.hostPageId).toBe("host");
+ const probe = screen.getByTestId("probe");
+ // null / undefined sources are pass-through: chain stays ["a"], host kept.
+ expect(probe.getAttribute("data-chain")).toBe("a");
+ expect(probe.getAttribute("data-host")).toBe("host");
});
- it("keeps the first (top-level) host even if an inner provider passes a different one", () => {
- const { getByTestId } = render(
-
-
-
+ it("adopts a host provided only at a deeper level when the root had none", () => {
+ render(
+
+
+ ,
);
- const value = read(getByTestId("leaf"));
- expect(value.chain).toEqual(["a", "b"]);
- // Inner host is ignored: the top-level host is set once and propagated.
- expect(value.hostPageId).toBe("top-host");
- });
-
- it("defaults to an empty chain and null host with no provider", () => {
- const { getByTestId } = render();
- const value = read(getByTestId("leaf"));
- expect(value.chain).toEqual([]);
- expect(value.hostPageId).toBeNull();
+ const probe = screen.getByTestId("probe");
+ expect(probe.getAttribute("data-host")).toBe("late-host");
+ });
+});
+
+describe("isPageEmbedCycle", () => {
+ it("is false when the source is not in the chain and is not the host", () => {
+ expect(isPageEmbedCycle(["a", "b"], "host", "c")).toBe(false);
+ });
+
+ it("is true when the source is already present in the ancestor chain", () => {
+ expect(isPageEmbedCycle(["a", "b", "c"], "host", "b")).toBe(true);
+ });
+
+ it("is true for a top-level self-embed (host === source, empty chain)", () => {
+ expect(isPageEmbedCycle([], "self", "self")).toBe(true);
+ });
+
+ it("is true when the source equals the host even mid-chain", () => {
+ expect(isPageEmbedCycle(["x"], "self", "self")).toBe(true);
+ });
+
+ it("is false when there is no source id (nothing to embed yet)", () => {
+ expect(isPageEmbedCycle(["a"], "host", null)).toBe(false);
+ expect(isPageEmbedCycle([], "host", "")).toBe(false);
+ });
+
+ it("is false when host is null and source is not in the chain", () => {
+ expect(isPageEmbedCycle(["a", "b"], null, "c")).toBe(false);
+ });
+});
+
+describe("isPageEmbedTooDeep", () => {
+ it("is false below the max depth", () => {
+ expect(isPageEmbedTooDeep([])).toBe(false);
+ expect(
+ isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH - 1).fill("x")),
+ ).toBe(false);
+ });
+
+ it("is true once the chain length reaches the max depth", () => {
+ expect(
+ isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH).fill("x")),
+ ).toBe(true);
+ });
+
+ it("is true when the chain length exceeds the max depth", () => {
+ expect(
+ isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH + 3).fill("x")),
+ ).toBe(true);
+ });
+
+ it("guards at exactly PAGE_EMBED_MAX_DEPTH (=5)", () => {
+ // Pin the documented constant so an accidental change is caught.
+ expect(PAGE_EMBED_MAX_DEPTH).toBe(5);
+ expect(isPageEmbedTooDeep(["1", "2", "3", "4"])).toBe(false);
+ expect(isPageEmbedTooDeep(["1", "2", "3", "4", "5"])).toBe(true);
});
});
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
index c989ee21..cdd7f109 100644
--- 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
@@ -51,3 +51,26 @@ export function PageEmbedAncestryProvider({
export function usePageEmbedAncestry() {
return useContext(PageEmbedAncestryContext);
}
+
+/**
+ * Pure cycle predicate used by the page-embed node view. Returns true when the
+ * source page would recurse into itself: either it is already present in the
+ * ancestor chain, or it is the host page (top-level self-embed). Extracted so
+ * the anti-DoS guard can be unit-tested without mounting the Tiptap NodeView.
+ */
+export function isPageEmbedCycle(
+ chain: string[],
+ hostPageId: string | null,
+ sourcePageId: string | null,
+): boolean {
+ if (!sourcePageId) return false;
+ return chain.includes(sourcePageId) || hostPageId === sourcePageId;
+}
+
+/**
+ * Pure depth-limit predicate. Returns true once the ancestor chain has reached
+ * the hard cap, before a deeper nested editor is mounted.
+ */
+export function isPageEmbedTooDeep(chain: string[]): boolean {
+ return chain.length >= PAGE_EMBED_MAX_DEPTH;
+}
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
index aa2a8caf..d29c19dc 100644
--- 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
@@ -55,7 +55,9 @@ export function PageEmbedLookupProvider({
try {
const { items } = await lookupTemplate({ sourcePageIds: ids });
+ const returned = new Set();
for (const r of items) {
+ returned.add(r.sourcePageId);
resultCacheRef.current.set(r.sourcePageId, r);
inFlightRef.current.delete(r.sourcePageId);
const subs = subscribersRef.current.get(r.sourcePageId);
@@ -64,6 +66,17 @@ export function PageEmbedLookupProvider({
}
resolveWaiters(r.sourcePageId);
}
+ // Harden against a partial/short server response: any requested id not
+ // present in `items` would otherwise stay in `inFlightRef` forever
+ // (subscribe/refresh are guarded by `!inFlightRef.has(id)`) and its
+ // refresh() promise would never resolve. Clear + resolve those ids,
+ // mirroring the catch branch, so no id can be stranded in-flight.
+ for (const id of ids) {
+ if (!returned.has(id)) {
+ inFlightRef.current.delete(id);
+ resolveWaiters(id);
+ }
+ }
} catch (err) {
// Surface the failure: errors must never be swallowed silently.
console.error("[pageEmbed] template lookup failed", err);
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
index 9a308bf6..d9189388 100644
--- 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
@@ -2,10 +2,9 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
- IconArrowsMaximize,
IconDots,
- IconExternalLink,
IconEyeOff,
+ IconFileText,
IconInfoCircle,
IconRefresh,
IconRepeat,
@@ -138,20 +137,6 @@ function PageEmbedBody({
- {sourceHref && (
-
-
-
-
-
- )}