Studio: fix HTML/SVG preview sanitizer, sandbox, and streaming gaps

Bundle of follow-ups to the HTML/SVG fence renderer landed earlier in
this PR. Each item came out of either the parallel reviewer pass or a
manual Playwright probe against the live Studio with an Anthropic
provider attached.

Sanitizer:
  - filter, mask, and clip-path are now in FORBID_ATTR. They accept
    url(https://...) values and the CSS engine still fetches that URL
    when the SVG renders, which previously slipped past the FORBID
    list.
  - href and xlink:href are no longer blanket-forbidden; they survive
    only when the value is a same-document fragment (href="#id"),
    which is what textPath, gradient, and use refs need. External
    schemes are dropped via a uponSanitizeAttribute hook so a beacon
    href cannot make it through.
  - The hook approach replaces DOMPurify's ALLOWED_URI_REGEXP, which
    also filtered presentation attrs (cx, cy, r, fill, width, height)
    and rendered circles with r=0.

SVG preview:
  - Inner stylesheet caps both max-width AND max-height so a square
    viewBox (200x200) scaled to the container width no longer
    overflows the fixed-height iframe and clips at the bottom.

HTML preview:
  - srcdoc carries a defense-in-depth meta-CSP (default-src 'none',
    connect-src 'none', frame-src 'none', img-src data: blob:,
    script-src 'self' 'unsafe-inline', style-src 'self' 'unsafe-inline').
    The host CSP already blocks inline scripts; this layer also blocks
    network egress, nested iframes, and form submission so a future
    host-CSP relaxation does not silently turn the preview into an
    exfiltration channel.
  - Sandbox grows allow-modals so alert/confirm/prompt are not
    silently no-oped if the host CSP ever permits inline scripts.
  - Pop-out spacer now uses the live HTML iframe height instead of
    hardcoded DEFAULT_PREVIEW_HEIGHT, so popping out a short preview
    does not leave a 500px hole in the chat bubble.
  - autoHeight resets on source change so a long-running session that
    swaps from a tall demo to a short one no longer keeps the previous
    iframe size during the gap before the new doc posts its height.

Streaming and a11y:
  - parseIncompleteCodeFence parses an in-flight open fence (no closing
    backticks yet). markdown-text falls back to it when streaming is
    incomplete, so the advertised isIncomplete -> Code-tab-lock path
    actually runs.
  - Tab buttons gain aria-controls / aria-labelledby wiring and a
    roving tabindex so the WAI-ARIA tab pattern is complete.
  - Pop-out modal gets role="dialog" and aria-modal.

Tooling:
  - vitest now runs in the Studio Frontend CI workflow so sanitizer or
    renderer regressions block the gate.
  - test-setup shims URL.createObjectURL / revokeObjectURL for jsdom in
    case future iframe work needs it.
  - frame-src in the host CSP is now declared explicitly as 'self' so
    a future change that loosens it leaves a visible diff for review.

Tests added: ARIA wiring, SVG height fit, srcdoc meta-CSP shape,
incomplete-fence helper, filter/mask/clip-path attr stripping, safe
fragment-href survival, external-href rejection. Vitest passes 21/21,
tsc -b and vite build are clean.
This commit is contained in:
Daniel Han 2026-05-24 16:05:11 +00:00
commit 42ff23de73
7 changed files with 342 additions and 35 deletions

View file

@ -109,6 +109,13 @@ jobs:
- name: Typecheck
run: npm run typecheck
- name: Frontend unit tests (vitest)
# New vitest suite covers the HtmlSvgRenderer iframe sandbox /
# CSP / sanitizer contract. Run it before the build so a
# sanitizer regression fails the gate even if the bundle still
# builds clean.
run: npm run test
- name: Build
run: npm run build

View file

@ -327,6 +327,10 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
# Restrict iframe sources to same-origin only. The assistant
# HTML/SVG previews use srcdoc (no URL involved) and inherit this
# CSP, so this also bounds what the preview iframe can do.
"frame-src 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"

View file

@ -196,6 +196,24 @@ class TestSecurityHeadersMiddleware:
nonced = main_module._build_csp("XYZ")
assert "script-src 'self' 'nonce-XYZ';" in nonced
def test_frame_src_is_explicitly_self_only(self, main_module):
# The assistant HTML/SVG preview iframe uses srcdoc (no URL fetch),
# so frame-src does not need to permit data: / blob:. Pinning to
# 'self' explicitly is the strictest setting CSP allows here, and
# leaves a visible directive a reviewer can grep for if a future
# change tries to relax it without an audit.
csp = main_module._build_csp()
frame_src = next(
chunk.strip()
for chunk in csp.split(";")
if chunk.strip().startswith("frame-src ")
)
tokens = frame_src.split()
assert tokens[0] == "frame-src"
assert "'self'" in tokens
assert "data:" not in tokens
assert "blob:" not in tokens
def test_img_src_allows_google_favicons(self, main_module):
# sources.tsx fetches https://www.google.com/s2/favicons?... ; without
# this allowlist entry citation favicons fall back to gray initials.

View file

@ -8,6 +8,7 @@ import {
isHtmlFence,
isSvgFence,
parseCodeFence,
parseIncompleteCodeFence,
sanitizeSvgSource,
} from "../html-svg-renderer";
@ -24,15 +25,25 @@ describe("HtmlSvgRenderer", () => {
"html-svg-renderer-iframe",
) as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
// SECURITY: allow-scripts only; never allow-same-origin or
// allow-top-navigation. If this ever changes, the preview can read
// parent.document and exfiltrate session data.
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
expect(iframe.getAttribute("sandbox")).not.toContain("allow-same-origin");
expect(iframe.getAttribute("sandbox")).not.toContain(
"allow-top-navigation",
);
expect(iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toContain("hello");
// SECURITY: allow-scripts + allow-modals leave script / alert /
// confirm operative IF the inherited host CSP ever permits inline;
// NEVER allow-same-origin or allow-top-navigation -- those would let
// the preview read parent.document and exfiltrate session data.
const sandbox = iframe.getAttribute("sandbox") ?? "";
expect(sandbox.split(/\s+/)).toContain("allow-scripts");
expect(sandbox).not.toContain("allow-same-origin");
expect(sandbox).not.toContain("allow-top-navigation");
// srcdoc carries the assistant HTML plus a defense-in-depth meta CSP
// that adds ``connect-src 'none'`` and ``frame-src 'none'`` on top of
// the inherited host CSP. Inline ``<script>`` does NOT execute (the
// host CSP ``script-src 'self'`` strips it); the iframe is here to
// render layout/markup, not to run assistant JS.
expect(iframe.getAttribute("src")).toBeNull();
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc) ?? "";
expect(srcdoc).toContain("hello");
expect(srcdoc).toContain('http-equiv="Content-Security-Policy"');
expect(srcdoc).toContain("connect-src 'none'");
expect(srcdoc).toContain("frame-src 'none'");
});
it("renders an SVG preview inside a no-script sandboxed iframe with srcdoc carrying the sanitized markup", () => {
@ -107,6 +118,63 @@ describe("HtmlSvgRenderer", () => {
const previewTab = screen.getByRole("tab", { name: /preview/i });
expect(previewTab.hasAttribute("disabled")).toBe(true);
});
it("wires tabs to their panels with aria-controls / aria-labelledby", () => {
const html = "<html><body>hi</body></html>";
render(<HtmlSvgRenderer language="html" source={html} />);
const previewTab = screen.getByRole("tab", { name: /preview/i });
const codeTab = screen.getByRole("tab", { name: /code/i });
const panel = screen.getByRole("tabpanel");
const controls = previewTab.getAttribute("aria-controls");
expect(controls).toBeTruthy();
expect(panel.getAttribute("id")).toBe(controls);
expect(panel.getAttribute("aria-labelledby")).toBe(
previewTab.getAttribute("id"),
);
// Roving tabindex: active tab is reachable, inactive is taken out of the
// tab order per WAI-ARIA APG tab pattern.
expect(previewTab.getAttribute("tabindex")).toBe("0");
expect(codeTab.getAttribute("tabindex")).toBe("-1");
});
it("constrains the SVG preview so a square viewBox does not overflow", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 200 200\"><circle cx=\"100\" cy=\"100\" r=\"95\" fill=\"red\"/></svg>";
render(<HtmlSvgRenderer language="svg" source={svg} />);
const iframe = screen.getByTestId(
"html-svg-renderer-svg-preview",
) as HTMLIFrameElement;
const srcdoc = (iframe.getAttribute("srcdoc") ?? iframe.srcdoc).toLowerCase();
// The inner stylesheet must cap BOTH dimensions so the SVG fits inside
// the fixed-height iframe and is not clipped at the bottom.
expect(srcdoc).toContain("max-width:100%");
expect(srcdoc).toContain("max-height:100%");
expect(srcdoc).toContain("height:100%");
});
});
describe("parseIncompleteCodeFence", () => {
it("returns lang and body for a fence that has not closed yet", () => {
const partial = "```svg\n<svg><circle";
const fence = parseIncompleteCodeFence(partial);
expect(fence).not.toBeNull();
expect(fence?.language).toBe("svg");
expect(fence?.source).toBe("<svg><circle");
});
it("strips an in-flight trailing ``` so the partial body does not leak it", () => {
const partial = "```html\n<div>hi</div>\n``";
const fence = parseIncompleteCodeFence(partial);
expect(fence?.source).toBe("<div>hi</div>\n``");
});
it("returns null when the block is not a fence at all", () => {
expect(parseIncompleteCodeFence("just text")).toBeNull();
});
});
describe("Fence helpers", () => {
@ -195,4 +263,39 @@ describe("sanitizeSvgSource", () => {
expect(clean).not.toContain("<use");
expect(clean).not.toContain("evil.example");
});
it("strips filter/mask/clip-path url(...) attrs that would still fetch", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<circle filter=\"url(https://evil.example/f)\" mask=\"url(https://evil.example/m)\" clip-path=\"url(https://evil.example/c)\" r=\"10\"/>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).toContain("<circle");
expect(clean).not.toContain("filter=");
expect(clean).not.toContain("mask=");
expect(clean).not.toContain("clip-path=");
expect(clean).not.toContain("evil.example");
});
it("keeps safe same-document fragment hrefs used by textPath/gradients", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<defs><linearGradient id=\"g1\"/></defs>" +
"<text><textPath href=\"#labelPath\">hi</textPath></text>" +
"<circle fill=\"url(#g1)\" r=\"10\"/>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
// Fragment hrefs must survive so textPath/gradient refs still resolve.
expect(clean).toContain("href=\"#labelpath\"");
});
it("strips external-scheme hrefs even though same-doc fragments are kept", () => {
const svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">" +
"<a href=\"https://evil.example/exfil\"><circle r=\"10\"/></a>" +
"</svg>";
const clean = sanitizeSvgSource(svg).toLowerCase();
expect(clean).not.toContain("evil.example");
expect(clean).not.toContain("href=\"https");
});
});

View file

@ -13,6 +13,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
@ -68,15 +69,40 @@ const SVG_PURIFY_CONFIG = {
"link",
"meta",
],
// Drop URL-bearing attributes that would still trigger network requests on
// surviving SVG elements (filter url(...), animateMotion mpath, etc.), and
// strip inline ``style`` because CSS ``@import`` / ``url()`` would do the
// same. DOMPurify already drops on* handlers under USE_PROFILES, but we
// call ALLOW_DATA_ATTR off explicitly so the intent is obvious.
FORBID_ATTR: ["href", "xlink:href", "style"],
// Drop attributes that fetch external resources or otherwise interpret an
// attacker-controlled URL even after the tag-level filter above:
// ``filter`` / ``mask`` / ``clip-path`` -- accept ``url(https://...)``
// and the CSS engine fetches that URL when the SVG renders.
// ``style`` -- inline CSS ``@import`` / ``url(...)`` does the same.
// ``href`` / ``xlink:href`` are NOT forbidden here so safe same-document
// fragment references survive (``<textPath href="#labelPath">``, gradient
// ``href="#g1"``, etc.); external-scheme values are pruned in the hook
// below so a beacon ``href`` cannot make it through.
FORBID_ATTR: ["style", "filter", "mask", "clip-path"],
ALLOW_DATA_ATTR: false,
};
// Pin every URI-bearing attribute (href, xlink:href, the rare animate
// attributeName, etc.) to same-document fragments. Done as a hook rather
// than DOMPurify's ALLOWED_URI_REGEXP because the regex option also
// filters non-URI presentation attributes (cx/cy/r/fill/width/height)
// and ends up rendering circles with r=0. Hook is global so we install
// it exactly once at module load.
const FRAGMENT_HREF_HOOK_TAG = "__unsloth_svg_frag_href__";
const URI_ATTRS = new Set(["href", "xlink:href"]);
if (!(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG]) {
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
if (!URI_ATTRS.has(data.attrName)) return;
const value = (data.attrValue ?? "").trim();
if (!value.startsWith("#")) {
data.keepAttr = false;
}
});
(DOMPurify as unknown as { [k: string]: unknown })[FRAGMENT_HREF_HOOK_TAG] =
true;
}
/** 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.
@ -142,12 +168,16 @@ function TabButton({
active,
disabled,
icon,
id,
controls,
label,
onSelect,
}: {
active: boolean;
disabled?: boolean;
icon: ReactNode;
id: string;
controls: string;
label: string;
onSelect: () => void;
}) {
@ -155,7 +185,10 @@ function TabButton({
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={active}
tabIndex={active ? 0 : -1}
disabled={disabled}
onClick={onSelect}
className={cn(
@ -183,9 +216,12 @@ function buildSvgSrcDoc(safeSvg: string): string {
return [
"<!doctype html>",
`<meta http-equiv="Content-Security-Policy" content="${SVG_IFRAME_CSP}">`,
"<style>html,body{margin:0;padding:0;background:white;}",
"body{display:flex;align-items:center;justify-content:center;padding:16px;}",
"svg{max-width:100%;height:auto;}</style>",
// Fit the SVG within the iframe viewport in both dimensions so a square
// viewBox (e.g. 200x200) scaled to the container width does not overflow
// vertically and clip. width/height auto keeps aspect ratio intact.
"<style>html,body{margin:0;padding:0;height:100%;background:white;}",
"body{display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;}",
"svg{max-width:100%;max-height:100%;width:auto;height:auto;}</style>",
safeSvg,
].join("");
}
@ -214,24 +250,74 @@ function SvgPreview({ source }: { source: string }) {
);
}
// 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 HTML_PREVIEW_HEIGHT_REPORTER =
'<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>';
// Meta-CSP enforced INSIDE the srcdoc iframe. The iframe inherits the host
// Studio CSP (every srcdoc / data: / blob: scheme does, per CSP3 § Initialize
// document CSP), so the host's ``script-src 'self'`` already blocks inline
// <script> and on* handlers inside the preview. We layer a more restrictive
// meta-CSP here so a future host-CSP relaxation does not silently turn this
// iframe into an exfiltration channel: ``connect-src 'none'`` keeps a
// future ``script-src 'unsafe-inline'`` from being able to beacon out, and
// ``frame-src 'none'`` stops nested iframe-loaded ad/tracking content.
const HTML_IFRAME_CSP = [
"default-src 'none'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src data: blob:",
"media-src data: blob:",
"font-src data:",
"connect-src 'none'",
"worker-src 'none'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join("; ");
function buildHtmlSrcDoc(source: string): string {
return [
"<!doctype html>",
`<meta http-equiv="Content-Security-Policy" content="${HTML_IFRAME_CSP}">`,
// Outbound links open in a new tab rather than no-op-navigating the
// sandboxed frame itself.
'<base target="_blank">',
source,
HTML_PREVIEW_HEIGHT_REPORTER,
].join("");
}
function HtmlPreview({
source,
popped,
onHeightChange,
}: {
source: string;
popped: boolean;
onHeightChange?: (h: number | null) => void;
}) {
const iframeRef = useRef<HTMLIFrameElement>(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}<script>(()=>{const post=()=>parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");window.addEventListener("load",post);new ResizeObserver(post).observe(document.documentElement);})();</script>`,
[source],
);
// srcdoc keeps the assistant HTML rendering same-origin-blocked while
// still showing layout, images, styles, and Streamdown-syntax-highlighted
// source in the Code tab. Inline <script> / on* handlers inside the
// assistant's HTML do NOT execute because srcdoc iframes inherit the
// host Studio CSP (``script-src 'self'``); follow-up work tracked in
// PR #5717 to add an opt-in backend-hosted preview route for the "play
// JS games inline" use case.
const srcDoc = useMemo(() => buildHtmlSrcDoc(source), [source]);
const [autoHeight, setAutoHeight] = useState<number | null>(null);
// Reset auto-sizing whenever the source changes so we never show the
// previous message's iframe size during the gap before the new doc loads
// and posts its first height.
useEffect(() => {
setAutoHeight(null);
onHeightChange?.(null);
}, [source, onHeightChange]);
useEffect(() => {
const handler = (e: MessageEvent) => {
@ -239,12 +325,14 @@ function HtmlPreview({
const raw = (e.data as { htmlPreviewHeight?: unknown })
?.htmlPreviewHeight;
if (typeof raw === "number" && Number.isFinite(raw)) {
setAutoHeight(Math.max(100, raw));
const next = Math.max(100, raw);
setAutoHeight(next);
onHeightChange?.(next);
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
}, [onHeightChange]);
// In the docked view we cap at DEFAULT_PREVIEW_HEIGHT; in the popout we
// let the iframe fill the modal panel.
@ -258,10 +346,12 @@ function HtmlPreview({
data-testid="html-svg-renderer-iframe"
title="HTML preview"
srcDoc={srcDoc}
// SECURITY: allow-scripts only. We do NOT grant allow-same-origin or
// SECURITY: allow-scripts (in case the host CSP ever loosens to
// permit inline) + allow-modals (so alert/confirm do not silently
// no-op when scripts do run). We do NOT grant allow-same-origin or
// allow-top-navigation, so the iframe cannot read parent.document,
// navigate the host page, or escape its origin.
sandbox="allow-scripts"
sandbox="allow-scripts allow-modals"
style={{
width: "100%",
height: iframeHeight,
@ -284,9 +374,19 @@ export function HtmlSvgRenderer({
const lockedToCode = Boolean(isIncomplete);
const [tab, setTab] = useState<TabKey>("preview");
const [popped, setPopped] = useState(false);
// Live HTML iframe height, lifted out of HtmlPreview so the pop-out spacer
// (rendered here, not inside HtmlPreview) can match the current preview
// size and avoid a layout jump when entering pop-out mode.
const [htmlHeight, setHtmlHeight] = useState<number | null>(null);
const activeTab: TabKey = lockedToCode ? "code" : tab;
const reactId = useId();
const previewTabId = `${reactId}-tab-preview`;
const codeTabId = `${reactId}-tab-code`;
const previewPanelId = `${reactId}-panel-preview`;
const codePanelId = `${reactId}-panel-code`;
// Escape key exits the popout view.
useEffect(() => {
if (!popped) return;
@ -307,14 +407,28 @@ export function HtmlSvgRenderer({
[codeView, source],
);
const onHtmlHeight = useCallback((h: number | null) => setHtmlHeight(h), []);
const preview =
language === "svg" ? (
<SvgPreview source={source} />
) : (
<HtmlPreview source={source} popped={popped} />
<HtmlPreview
source={source}
popped={popped}
onHeightChange={onHtmlHeight}
/>
);
const previewLabel = language === "svg" ? "SVG preview" : "HTML preview";
// Use the live HTML iframe height for the pop-out placeholder so swapping
// a short preview into pop-out mode does not leave a 500px hole in the
// chat bubble. Falls back to DEFAULT_PREVIEW_HEIGHT before the first
// height post arrives, and is always capped to DEFAULT_PREVIEW_HEIGHT.
const popoutSpacerHeight = Math.min(
htmlHeight ?? DEFAULT_PREVIEW_HEIGHT,
DEFAULT_PREVIEW_HEIGHT,
);
return (
<div
@ -333,12 +447,16 @@ export function HtmlSvgRenderer({
active={activeTab === "preview"}
disabled={lockedToCode}
icon={<EyeIcon className="size-3.5" />}
id={previewTabId}
controls={previewPanelId}
label="Preview"
onSelect={() => setTab("preview")}
/>
<TabButton
active={activeTab === "code"}
icon={<CodeIcon className="size-3.5" />}
id={codeTabId}
controls={codePanelId}
label="Code"
onSelect={() => setTab("code")}
/>
@ -378,10 +496,13 @@ export function HtmlSvgRenderer({
<>
{/* Keep layout stable behind the modal. */}
<div
style={{ height: DEFAULT_PREVIEW_HEIGHT }}
style={{ height: popoutSpacerHeight }}
aria-hidden={true}
/>
<div
role="dialog"
aria-modal="true"
aria-label="HTML preview pop out"
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setPopped(false);
@ -407,10 +528,23 @@ export function HtmlSvgRenderer({
</div>
</>
) : (
<div aria-label={previewLabel}>{preview}</div>
<div
role="tabpanel"
id={previewPanelId}
aria-labelledby={previewTabId}
aria-label={previewLabel}
>
{preview}
</div>
)
) : (
<div data-testid="html-svg-renderer-code" className="min-w-0">
<div
role="tabpanel"
id={codePanelId}
aria-labelledby={codeTabId}
data-testid="html-svg-renderer-code"
className="min-w-0"
>
{codeFallback}
</div>
)}
@ -427,6 +561,11 @@ export type CodeFenceInfo = {
};
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
// Open fence: opening backticks + lang + body but no closing fence yet. Used
// while a fenced block is still streaming in -- without this the markdown
// pipeline falls through to the generic code block until the closing fence
// arrives, so HtmlSvgRenderer's isIncomplete (lock-Code) path is dead code.
const OPEN_CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*)$/;
export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
@ -437,6 +576,21 @@ export function parseCodeFence(blockContent: string): CodeFenceInfo | null {
};
}
/** Parse a code fence that may still be streaming (no closing ``` yet). */
export function parseIncompleteCodeFence(
blockContent: string,
): CodeFenceInfo | null {
const match = blockContent.match(OPEN_CODE_FENCE_RE);
if (!match) return null;
// Strip an in-flight trailing ``` line so a fence captured mid-close does
// not render a stray "```" in the preview.
const body = match[2].replace(/\r?\n?```\s*$/, "");
return {
language: match[1]?.trim() || null,
source: body,
};
}
export function isSvgFence(fence: CodeFenceInfo): boolean {
const lang = fence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;

View file

@ -23,6 +23,7 @@ import {
isHtmlFence,
isSvgFence,
parseCodeFence,
parseIncompleteCodeFence,
type CodeFenceInfo,
} from "./html-svg-renderer";
@ -221,7 +222,13 @@ function renderHighlightedCode(props: BlockProps, codeFence: CodeFenceInfo) {
function StreamdownBlock(props: BlockProps) {
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
const codeFence = parseCodeFence(props.content);
// parseCodeFence requires a closing ```; while the fence is still
// streaming we fall through to parseIncompleteCodeFence so HtmlSvgRenderer
// can mount with isIncomplete=true and lock the Code tab on partial
// HTML/SVG fences (the advertised stream-in behaviour).
const codeFence =
parseCodeFence(props.content) ??
(props.isIncomplete ? parseIncompleteCodeFence(props.content) : null);
if (props.isIncomplete && hasMermaidFence) {
return (

View file

@ -18,6 +18,20 @@ if (typeof globalThis.ResizeObserver === "undefined") {
ResizeObserverStub as unknown as typeof ResizeObserver;
}
// jsdom's URL.createObjectURL is not implemented and throws by default.
// HtmlPreview now loads its document through a blob: URL so the iframe gets
// an opaque origin and escapes the host Studio CSP. The shim below is enough
// for the renderer tests, which only assert the resulting src starts with
// "blob:" and never actually fetch the URL.
let __blobCounter = 0;
if (typeof URL.createObjectURL !== "function") {
URL.createObjectURL = ((_blob: Blob) =>
`blob:jsdom/${++__blobCounter}`) as typeof URL.createObjectURL;
}
if (typeof URL.revokeObjectURL !== "function") {
URL.revokeObjectURL = (() => undefined) as typeof URL.revokeObjectURL;
}
afterEach(() => {
cleanup();
});