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:
claude_code
2026-06-21 03:22:37 +03:00
parent 20b9f61c3e
commit e9ceb0f899
11 changed files with 333 additions and 33 deletions

View File

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

View File

@@ -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 {

View File

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