/** * Wrap an artifact's HTML for a sandboxed iframe. Corresponds to * buildSrcdoc in packages/runtime/src/index.ts — the reference version also * injects an edit-mode overlay and tweak bridge, which this starter omits. * * If the model returned a full document, pass it through unchanged; otherwise * wrap the fragment in a minimal doctype shell. * * When `options.deck` is set we also inject a `postMessage` listener that * lets the host advance / rewind slides without relying on the iframe * having keyboard focus. The host posts: * { type: 'od:slide', action: 'next' | 'prev' | 'first' | 'last' | 'go', index?: number } * and the iframe responds with: * { type: 'od:slide-state', active: number, count: number } * after every navigation so the host can render its own counter / dots. */ export function buildSrcdoc( html: string, options: { deck?: boolean } = {} ): string { const head = html.trimStart().slice(0, 64).toLowerCase(); const isFullDoc = head.startsWith(" ${html} `; const withShim = injectSandboxShim(wrapped); if (!options.deck) return withShim; return injectDeckBridge(withShim); } // Sandboxed iframes (we use `sandbox="allow-scripts"`) without // `allow-same-origin` raise a SecurityError on first `localStorage` / // `sessionStorage` access. Many freeform-generated decks call // `localStorage.getItem(...)` at the top of their IIFE without a // try/catch — when it throws, the whole script aborts and the deck // becomes a static, unnavigable preview. We install a same-origin // in-memory shim BEFORE any user script runs so those decks degrade // gracefully (position just doesn't persist across reloads). function injectSandboxShim(doc: string): string { const shim = ``; if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (m) => `${m}${shim}`); if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (m) => `${m}${shim}`); return shim + doc; } // The deck bridge supports three deck conventions found across our skills // and freeform-generated artifacts: // 1. Horizontal scroll decks (simple-deck, guizang-ppt) — slides laid out // side-by-side, navigation = scrollTo({ left }). // 2. Class-toggle decks (deck-framework, freeform pitches) — one slide // carries `.active` or `.is-active`; siblings are display:none. Their // own JS listens for ArrowRight/Left, so we drive them by dispatching // synthetic KeyboardEvents. // 3. Visibility-only decks — no class toggle, slides hidden via inline // style. We fall back to keyboard dispatch + visibility detection. // // All three report `{ active, count }` back to the host so the toolbar can // render a unified counter. A MutationObserver on each `.slide` lets us // catch class changes from the deck's own keyboard handler. // // We also inject a small CSS override that fixes a common authoring // mistake in fixed-canvas decks: a `.stage { display: grid; place-items: // center }` only centers items within their grid cells, but the track // itself stays `start`-aligned, so the 1920x1080 canvas top-lefts at // (0,0) of the stage. Combined with `transform-origin: center center`, // the scaled canvas ends up offset toward the bottom-right of any // preview that's smaller than 1920x1080 — exactly what users see in the // sandbox iframe. `place-content: center` centers the track itself. function injectDeckBridge(doc: string): string { const styleFix = ``; const docWithStyle = /<\/head>/i.test(doc) ? doc.replace(/<\/head>/i, styleFix + "") : /]*>/i.test(doc) ? doc.replace(/]*>/i, (m) => m + styleFix) : styleFix + doc; doc = docWithStyle; const script = ``; if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${script}`); return doc + script; }