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('