From 83e67231cd22027ef5de00b345eeb675a1be354a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 16:29:19 +0000 Subject: [PATCH 01/19] Studio: render HTML and SVG fences inline with sandboxed preview tabs Adds a dedicated HtmlSvgRenderer that turns ```html / ```svg fences in assistant messages into an inline preview with a Code/Preview tab toggle. HTML runs inside an iframe with sandbox="allow-scripts" only (no allow-same-origin, no allow-top-navigation) so JS games execute but cannot reach parent.document. SVG is sanitized through DOMPurify with the svg/svgFilters profile before being mounted via dangerouslySetInnerHTML, stripping +`; + + render(); + + const previewHost = screen.getByTestId("html-svg-renderer-svg-preview"); + // The circle survives, but every script tag and on* handler must be + // stripped by DOMPurify before the SVG is inserted into the DOM. + const circle = previewHost.querySelector("circle"); + expect(circle).not.toBeNull(); + expect(circle?.getAttribute("onclick")).toBeNull(); + expect(previewHost.querySelector("script")).toBeNull(); + expect(previewHost.innerHTML.toLowerCase()).not.toContain("onclick"); + expect(previewHost.innerHTML.toLowerCase()).not.toContain("alert"); + }); + + it("toggles between Preview and Code tabs", () => { + const html = "hi"; + render( + {html}} + />, + ); + + // Default tab is preview. + expect(screen.getByTestId("html-svg-renderer-iframe")).toBeTruthy(); + expect(screen.queryByTestId("custom-code-view")).toBeNull(); + + const codeTab = screen.getByRole("tab", { name: /code/i }); + act(() => { + fireEvent.click(codeTab); + }); + + // Iframe is unmounted, custom code view appears. + expect(screen.queryByTestId("html-svg-renderer-iframe")).toBeNull(); + expect(screen.getByTestId("custom-code-view")).toBeTruthy(); + + const previewTab = screen.getByRole("tab", { name: /preview/i }); + act(() => { + fireEvent.click(previewTab); + }); + + expect(screen.getByTestId("html-svg-renderer-iframe")).toBeTruthy(); + expect(screen.queryByTestId("custom-code-view")).toBeNull(); + }); + + it("locks to the Code tab while the fence is still streaming in", () => { + const html = "partial"; + render( + , + ); + + const root = screen.getByTestId("html-svg-renderer"); + expect(root.getAttribute("data-active-tab")).toBe("code"); + + // Preview tab is rendered but disabled while incomplete so the user can + // see the streaming tokens without flicker. + const previewTab = screen.getByRole("tab", { name: /preview/i }); + expect(previewTab.hasAttribute("disabled")).toBe(true); + }); +}); + +describe("Fence helpers", () => { + it("parses a typical markdown code fence", () => { + const block = "```python\nprint('hi')\n```"; + const fence = parseCodeFence(block); + expect(fence).not.toBeNull(); + expect(fence?.language).toBe("python"); + expect(fence?.source).toBe("print('hi')"); + }); + + it("isSvgFence picks up explicit svg fences and html/xml fences that begin with { + expect(isSvgFence({ language: "svg", source: "" })).toBe(true); + expect( + isSvgFence({ language: "html", source: "" }), + ).toBe(true); + expect( + isSvgFence({ + language: "xml", + source: "", + }), + ).toBe(true); + expect(isSvgFence({ language: "html", source: "
" })).toBe(false); + }); + + it("isHtmlFence is true only for non-SVG html fences", () => { + expect(isHtmlFence({ language: "html", source: "
" })).toBe(true); + expect(isHtmlFence({ language: "html", source: "" })).toBe( + false, + ); + expect(isHtmlFence({ language: "python", source: "print()" })).toBe(false); + }); + + it("non-HTML / non-SVG fences are not handled by the renderer", () => { + // The markdown pipeline only invokes HtmlSvgRenderer when these helpers + // agree the fence is html/svg. A python fence must fall through. + const fence = parseCodeFence("```python\nprint('hi')\n```"); + expect(fence).not.toBeNull(); + if (!fence) return; + expect(isSvgFence(fence)).toBe(false); + expect(isHtmlFence(fence)).toBe(false); + }); +}); + +describe("sanitizeSvgSource", () => { + it("removes +`; + + const clean = sanitizeSvgSource(malicious).toLowerCase(); + expect(clean).not.toContain(" processing instructions", () => { + const svg = ``; + const clean = sanitizeSvgSource(svg); + expect(clean.startsWith(" block. + codeView?: ReactNode; + // When the markdown stream is still arriving the fence may be partial. + // In that case we force the Code tab and disable the toggle controls. + isIncomplete?: boolean; +}; + +const DEFAULT_PREVIEW_HEIGHT = 500; +const POPOUT_HEIGHT_VH = 80; +const COPY_RESET_MS = 2000; + +// Conservative regex covering the same vectors we previously stripped before +// DOMPurify was wired in. DOMPurify itself does the heavy lifting; this is +// belt-and-braces so a quick visual inspection of the source still catches the +// usual XSS hot spots. +const HEURISTIC_UNSAFE_SVG_RE = + /]|]|]|]|]/i; + +// SVGs may legitimately reference fonts/images via href, so we keep those. +// We strip every event handler attribute (on*) and any tag DOMPurify would +// otherwise allow that could escape the SVG sandbox. +const SVG_PURIFY_CONFIG = { + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ["script", "foreignObject", "iframe", "embed", "object"], + // DOMPurify already drops on* handlers when USE_PROFILES is set, but we + // call this out explicitly so the intent is obvious to future readers. + ALLOW_DATA_ATTR: false, +}; + +/** Strip every XML processing instruction and disallowed node from an SVG. */ +export function sanitizeSvgSource(source: string): string { + // Drop XML declarations -- DOMPurify keeps them but some renderers choke. + const stripped = source.replace(/^\s*<\?xml[^?]*\?>\s*/i, ""); + // First pass: regex screen. We do NOT bail out -- DOMPurify will still + // produce a safe string -- but logging here helps debugging. + if (HEURISTIC_UNSAFE_SVG_RE.test(stripped)) { + // eslint-disable-next-line no-console + console.debug("SVG renderer: stripping unsafe nodes before sanitize"); + } + return DOMPurify.sanitize(stripped, SVG_PURIFY_CONFIG); +} + +function useCopyState() { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const flash = useCallback(() => { + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setCopied(false); + timeoutRef.current = null; + }, COPY_RESET_MS); + }, []); + + return { copied, flash }; +} + +function CopyButton({ source }: { source: string }) { + const { copied, flash } = useCopyState(); + return ( + + ); +} + +type TabKey = "preview" | "code"; + +function TabButton({ + active, + disabled, + icon, + label, + onSelect, +}: { + active: boolean; + disabled?: boolean; + icon: ReactNode; + label: string; + onSelect: () => void; +}) { + return ( + + ); +} + +function SvgPreview({ source }: { source: string }) { + const safe = useMemo(() => sanitizeSvgSource(source), [source]); + return ( +
+ ); +} + +function HtmlPreview({ + source, + popped, +}: { + source: string; + popped: boolean; +}) { + const iframeRef = useRef(null); + // Tiny helper script that posts the document height back to the parent so + // we can right-size the iframe. Communication is one-way and the iframe + // cannot read parent.document because we never grant allow-same-origin. + const srcDoc = useMemo( + () => + `${source}`, + [source], + ); + + const [autoHeight, setAutoHeight] = useState(null); + + useEffect(() => { + const handler = (e: MessageEvent) => { + if (e.source !== iframeRef.current?.contentWindow) return; + const raw = (e.data as { htmlPreviewHeight?: unknown }) + ?.htmlPreviewHeight; + if (typeof raw === "number" && Number.isFinite(raw)) { + setAutoHeight(Math.max(100, raw)); + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + // In the docked view we cap at DEFAULT_PREVIEW_HEIGHT; in the popout we + // let the iframe fill the modal panel. + const iframeHeight = popped + ? "100%" + : Math.min(autoHeight ?? DEFAULT_PREVIEW_HEIGHT, DEFAULT_PREVIEW_HEIGHT); + + return ( +