fix(html-embed): address code-review findings on the sandbox commit
Follow-up fixes to the htmlEmbed-sandbox / trackerHead change:
- share-seo: inject trackerHead via a function replacer so `$`-sequences
($&, $', $`, $$) in the admin snippet are inserted literally instead of
being treated as String.replace substitution patterns; warn when the
</head> marker is absent instead of silently skipping injection.
- mcp: register a passthrough `htmlEmbed` node in the schema mirror so an
AI/MCP edit of a page containing an embed no longer throws
"Unknown node type: htmlEmbed" in TiptapTransformer.toYdoc.
- editor-ext + client: treat a non-finite `data-height` as auto (null) so a
crafted/corrupted height cannot disable auto-resize or yield a NaN iframe
height; extract a shared clampHeight helper.
- client: rename render-raw-html.{ts,test.ts} -> html-embed-sandbox.{...} and
shouldExecute -> shouldRender so the seam name matches the sandbox model.
- client: i18n the iframe title; surface the real error reason in
tracker-settings (console.error + err.response.data.message).
- docs: note hasHtmlEmbedNode is now a test-only helper; add an Unreleased
CHANGELOG entry; drop the dangling "arbitrary HTML embed" planning-doc ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,8 @@ import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
shouldExecute,
|
||||
} from "./render-raw-html";
|
||||
shouldRender,
|
||||
} from "./html-embed-sandbox";
|
||||
|
||||
describe("buildSandboxSrcdoc", () => {
|
||||
it("embeds the user source verbatim", () => {
|
||||
@@ -32,19 +32,19 @@ describe("buildSandboxSrcdoc", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExecute (render policy)", () => {
|
||||
describe("shouldRender (render policy)", () => {
|
||||
it("read-only renders regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldExecute(false, false)).toBe(true);
|
||||
expect(shouldExecute(false, true)).toBe(true);
|
||||
expect(shouldRender(false, false)).toBe(true);
|
||||
expect(shouldRender(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT render", () => {
|
||||
expect(shouldExecute(true, false)).toBe(false);
|
||||
expect(shouldRender(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON renders", () => {
|
||||
expect(shouldExecute(true, true)).toBe(true);
|
||||
expect(shouldRender(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Pure helpers for the HTML embed node view. Kept out of the React component so
|
||||
* the sandbox srcdoc builder and the execution/edit policy can be unit-tested
|
||||
* the sandbox srcdoc builder and the render/edit policy can be unit-tested
|
||||
* against a bare environment with no Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
@@ -51,7 +51,7 @@ export function buildSandboxSrcdoc(source: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution policy split by editor mode:
|
||||
* Render policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace master toggle is OFF). An anonymous viewer has no workspace and
|
||||
@@ -60,7 +60,7 @@ export function buildSandboxSrcdoc(source: string): string {
|
||||
* - EDITABLE editor: gate on the per-workspace master toggle so an author sees
|
||||
* the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldExecute(
|
||||
export function shouldRender(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit as computeCanEdit,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
shouldExecute as computeShouldExecute,
|
||||
} from "./render-raw-html.ts";
|
||||
shouldRender as computeShouldRender,
|
||||
} from "./html-embed-sandbox.ts";
|
||||
|
||||
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
||||
// page layout, and a sensible default before the first height message arrives.
|
||||
@@ -34,6 +34,10 @@ const MIN_IFRAME_HEIGHT = 40;
|
||||
const MAX_IFRAME_HEIGHT = 4000;
|
||||
const DEFAULT_IFRAME_HEIGHT = 150;
|
||||
|
||||
// Clamp a reported/configured height into the sane iframe bounds.
|
||||
const clampHeight = (h: number) =>
|
||||
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
@@ -48,7 +52,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
|
||||
|
||||
const shouldExecute = computeShouldExecute(
|
||||
const shouldRender = computeShouldRender(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
@@ -60,7 +64,9 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
|
||||
// Auto-resize height tracked in state (used only when no fixed height is set).
|
||||
const [autoHeight, setAutoHeight] = useState<number>(
|
||||
height ?? DEFAULT_IFRAME_HEIGHT,
|
||||
typeof height === "number" && Number.isFinite(height)
|
||||
? height
|
||||
: DEFAULT_IFRAME_HEIGHT,
|
||||
);
|
||||
|
||||
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
|
||||
@@ -70,24 +76,22 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
// match by event.origin — we match by event.source instead. No-op when a
|
||||
// fixed height is configured.
|
||||
useEffect(() => {
|
||||
if (typeof height === "number") return;
|
||||
if (typeof height === "number" && Number.isFinite(height)) return;
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = event.data as { type?: string; height?: number };
|
||||
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
|
||||
const next = Number(data.height);
|
||||
if (!Number.isFinite(next)) return;
|
||||
setAutoHeight(
|
||||
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, next)),
|
||||
);
|
||||
setAutoHeight(clampHeight(next));
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [height]);
|
||||
|
||||
const effectiveHeight =
|
||||
typeof height === "number"
|
||||
? Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, height))
|
||||
typeof height === "number" && Number.isFinite(height)
|
||||
? clampHeight(height)
|
||||
: autoHeight;
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
@@ -130,11 +134,11 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldExecute ? (
|
||||
{!shouldRender ? (
|
||||
// Feature disabled for this workspace AND we're in the editable editor:
|
||||
// render a neutral placeholder so an existing embed is visibly inert for
|
||||
// the author. Read-only / share viewers never hit this branch
|
||||
// (`shouldExecute` is always true there) — they render exactly the
|
||||
// (`shouldRender` is always true there) — they render exactly the
|
||||
// source the server chose to serve.
|
||||
<div className={classes.htmlEmbedPlaceholder}>
|
||||
<IconCode size={18} />
|
||||
@@ -151,7 +155,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
className={classes.htmlEmbedFrame}
|
||||
sandbox="allow-scripts allow-popups allow-forms"
|
||||
srcDoc={srcdoc}
|
||||
title="HTML embed"
|
||||
title={t("HTML embed")}
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ width: "100%", border: "none", height: effectiveHeight }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user