Merge gitea/develop into develop
Some checks failed
Develop / test (push) Has been cancelled
Develop / build (push) Has been cancelled

Reconcile the diverged develop (13 ahead / 20 behind) with gitea/develop.

Conflict resolution — html-embed: keep the local sandboxed-iframe model
(opaque-origin srcdoc, no role-gating) and supersede gitea's same-origin
strip/kill-switch hardening (#26/#28/#29/#30). The 4 conflicted html-embed
source files resolve to the local version; the 3 strip-era spec files stay
deleted. The strip apparatus (stripDisallowedHtmlEmbedNodes,
collectHtmlEmbedSources, canAuthorHtmlEmbed, htmlEmbedAllowed) is fully gone.

Integrate gitea's page-templates / page-embed work (#31-#40) cleanly.

Fix an auto-merge arity mismatch: two new gitea page-template specs
constructed TransclusionService with the pre-sandbox 11-arg signature; drop
the trailing workspaceRepo argument to match the reduced 10-arg constructor.

Verified: server + client tsc --noEmit clean; jest (html-embed + transclusion)
14 suites / 119 tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 05:21:20 +03:00
19 changed files with 1080 additions and 104 deletions

View File

@@ -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 <div data-testid={testId}>{JSON.stringify(ancestry)}</div>;
}
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 (
<span
data-testid={testId}
data-chain={chain.join(",")}
data-chain-length={String(chain.length)}
data-host={hostPageId ?? ""}
/>
);
}
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(<AncestryProbe />);
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(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
<PageEmbedAncestryProvider sourcePageId="b">
<PageEmbedAncestryProvider sourcePageId="c">
<Probe testId="leaf" />
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
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(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host-page">
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="ignored">
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
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(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
<PageEmbedAncestryProvider>
<Probe testId="leaf" />
<PageEmbedAncestryProvider sourcePageId={null}>
<PageEmbedAncestryProvider>
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
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(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="top-host">
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="inner-host">
<Probe testId="leaf" />
it("adopts a host provided only at a deeper level when the root had none", () => {
render(
<PageEmbedAncestryProvider sourcePageId="a">
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="late-host">
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
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(<Probe testId="leaf" />);
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);
});
});

View File

@@ -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;
}

View File

@@ -55,7 +55,9 @@ export function PageEmbedLookupProvider({
try {
const { items } = await lookupTemplate({ sourcePageIds: ids });
const returned = new Set<string>();
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);

View File

@@ -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({
<IconRefresh size={14} />
</ActionIcon>
</Tooltip>
{sourceHref && (
<Tooltip label={t("Open source page")}>
<ActionIcon
component={Link}
to={sourceHref}
variant="subtle"
color="gray"
size="sm"
style={{ textDecoration: "none", borderBottom: "none" }}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
)}
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
@@ -172,13 +157,18 @@ function PageEmbedBody({
) : null;
const header =
sourceTitle || sourceIcon ? (
// Render the badge whenever the source resolves (sourceHref), not only when
// it has a title/icon — the title link is now the single way to open the
// source, so it must not disappear when title and icon are both empty.
sourceTitle || sourceIcon || sourceHref ? (
<div className={classes.transclusionBadge}>
{sourceIcon ? `${sourceIcon} ` : <IconArrowsMaximize size={12} />}
{sourceIcon ? `${sourceIcon} ` : <IconFileText size={12} />}
{sourceHref ? (
<Link
to={sourceHref}
style={{ borderBottom: "none", textDecoration: "none" }}
title={t("Open source page")}
aria-label={t("Open source page")}
>
{sourceTitle || t("Untitled")}
</Link>
@@ -226,7 +216,17 @@ function PageEmbedBody({
sourcePageId={sourcePageId}
hostPageId={hostPageId}
>
<PageEmbedContent content={result.content} />
{/*
Tiptap's EditorProvider consumes `content` only at initial mount, so a
changed `content` prop (e.g. after Refresh re-fetches fresh content)
would not update the read-only sub-editor. Key on the source's
updatedAt to remount PageEmbedContent (and its inner EditorProvider)
whenever the source page changes, applying the refreshed content.
*/}
<PageEmbedContent
key={result.sourceUpdatedAt}
content={result.content}
/>
</PageEmbedAncestryProvider>
);
} else if (embedState === "no_access") {

View File

@@ -183,7 +183,8 @@
}
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode),
:global(.react-renderer.node-pageEmbed.ProseMirror-selectednode) {
outline: none;
}