diff --git a/public/wm-widget-sandbox.html b/public/wm-widget-sandbox.html new file mode 100644 index 000000000..6e9fad377 --- /dev/null +++ b/public/wm-widget-sandbox.html @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/utils/widget-sanitizer.ts b/src/utils/widget-sanitizer.ts index b0433f6f3..268806b22 100644 --- a/src/utils/widget-sanitizer.ts +++ b/src/utils/widget-sanitizer.ts @@ -41,14 +41,10 @@ export function wrapWidgetHtml(html: string, extraClass = ''): string { `; } -function escapeSrcdoc(str: string): string { - return str - .replace(/&/g, '&') - .replace(/"/g, '"'); -} +const widgetBodyStore = new Map(); -export function wrapProWidgetHtml(bodyContent: string): string { - const doc = ` +function buildWidgetDoc(bodyContent: string): string { + return ` @@ -67,6 +63,49 @@ td{padding:5px 8px;border-bottom:1px solid var(--border-subtle);color:var(--text ${bodyContent} `; - - return `
`; +} + +function mountProWidget(iframe: HTMLIFrameElement): void { + const id = iframe.dataset.wmId; + if (!id) return; + const body = widgetBodyStore.get(id); + if (!body) return; + // Delete immediately — new ID is generated on every render, so no re-use + widgetBodyStore.delete(id); + const html = buildWidgetDoc(body); + // Always use load event: when MutationObserver fires, iframe.contentDocument is + // still about:blank (readyState 'complete'), not the sandbox page. Sending here + // would target the wrong window. Wait for the real load instead. + iframe.addEventListener('load', () => { + iframe.contentWindow?.postMessage({ type: 'wm-html', html }, '*'); + }, { once: true }); +} + +if (typeof document !== 'undefined') { + const observer = new MutationObserver((mutations) => { + for (const mut of mutations) { + for (const node of mut.addedNodes) { + if (!(node instanceof Element)) continue; + if (node instanceof HTMLIFrameElement && node.dataset.wmId) { + mountProWidget(node); + } else { + node.querySelectorAll('iframe[data-wm-id]').forEach(mountProWidget); + } + } + } + }); + const startObserving = (): void => { + if (document.body) observer.observe(document.body, { childList: true, subtree: true }); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', startObserving); + } else { + startObserving(); + } +} + +export function wrapProWidgetHtml(bodyContent: string): string { + const id = `wm-${Math.random().toString(36).slice(2)}`; + widgetBodyStore.set(id, bodyContent); + return `
`; } diff --git a/tests/deploy-config.test.mjs b/tests/deploy-config.test.mjs index 8832fb347..d09e1e92b 100644 --- a/tests/deploy-config.test.mjs +++ b/tests/deploy-config.test.mjs @@ -16,7 +16,7 @@ const getCacheHeaderValue = (sourcePath) => { describe('deploy/cache configuration guardrails', () => { it('disables caching for HTML entry routes on Vercel', () => { - const spaNoCache = getCacheHeaderValue('/((?!api|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known).*)'); + const spaNoCache = getCacheHeaderValue('/((?!api|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)'); assert.equal(spaNoCache, 'no-cache, no-store, must-revalidate'); }); diff --git a/tests/widget-builder.test.mjs b/tests/widget-builder.test.mjs index 01e15a956..5888a4c6d 100644 --- a/tests/widget-builder.test.mjs +++ b/tests/widget-builder.test.mjs @@ -1096,48 +1096,48 @@ describe('PRO widget — store and sanitizer', () => { ); }); - it('wrapProWidgetHtml places CSP as first head child (client-owned skeleton)', () => { - const fnIdx = san.indexOf('wrapProWidgetHtml'); - const fnBody = san.slice(fnIdx, fnIdx + 800); + it('widget document builder places CSP as first head child (client-owned skeleton)', () => { assert.ok( - fnBody.includes('Content-Security-Policy'), - 'wrapProWidgetHtml must embed CSP in the head', + san.includes('Content-Security-Policy'), + 'widget sanitizer must embed CSP in the document head', ); // CSP meta should come before any style tag - const cspPos = fnBody.indexOf('Content-Security-Policy'); - const stylePos = fnBody.indexOf('