- Extension DOM mode: auth-context serializer — inlines CSS + images as data URLs, freezes form state, masks [data-dp-mask], strips scripts (R3 client) - API: extractDataUrls rewrites large inline resources into content-addressed assets (R3 server; verified 939KB inline -> 82-byte row + served asset) - Lead-gate steps: config on step, player overlay form blocks advance, submits to leads endpoint (editor toggle + MCP set_lead_gate) - Step-guide export (v2 slice): authed markdown + public HTML at /p/:token/guide - Video export (v3 slice, v0): ffmpeg slideshow of image steps + optional narration mux; DOM rasterization deferred to Chromium service (R4) - Editor: reorder arrows, embed-code copy, guide link, video export - REST reorder endpoint; MCP surface now 13 tools (export_guide, set_lead_gate, render_video) - ffmpeg added to runtime image E2E verified incl. R3 extraction round-trip, lead_gate in public JSON, guide renders, MCP agent exported guide + rendered video.
110 lines
4.2 KiB
JavaScript
110 lines
4.2 KiB
JavaScript
// DOM-mode capture: on each click, serialize the live document into a
|
|
// self-contained HTML snapshot (R3 client half — runs inside the user's auth
|
|
// context so protected images/styles resolve). Scripts stripped; form state
|
|
// frozen; [data-dp-mask] elements masked.
|
|
(function () {
|
|
if (window.__dpDomCaptureInstalled) return;
|
|
window.__dpDomCaptureInstalled = true;
|
|
|
|
var busy = Promise.resolve();
|
|
|
|
async function toDataUrl(url) {
|
|
try {
|
|
var res = await fetch(url, { credentials: "include" });
|
|
if (!res.ok) return null;
|
|
var blob = await res.blob();
|
|
if (blob.size > 4 * 1024 * 1024) return null; // cap single asset at 4MB
|
|
return await new Promise(function (resolve) {
|
|
var r = new FileReader();
|
|
r.onload = function () { resolve(r.result); };
|
|
r.onerror = function () { resolve(null); };
|
|
r.readAsDataURL(blob);
|
|
});
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
async function collectCss() {
|
|
var parts = [];
|
|
for (var i = 0; i < document.styleSheets.length; i++) {
|
|
var sheet = document.styleSheets[i];
|
|
try {
|
|
var rules = sheet.cssRules;
|
|
var buf = [];
|
|
for (var j = 0; j < rules.length; j++) buf.push(rules[j].cssText);
|
|
parts.push(buf.join("\n"));
|
|
} catch (_) {
|
|
// cross-origin: fetch the stylesheet ourselves
|
|
if (sheet.href) {
|
|
try {
|
|
var res = await fetch(sheet.href, { credentials: "include" });
|
|
if (res.ok) parts.push(await res.text());
|
|
} catch (_) { /* skip */ }
|
|
}
|
|
}
|
|
}
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
async function serialize() {
|
|
var root = document.documentElement.cloneNode(true);
|
|
|
|
// strip live/script-ish elements
|
|
root.querySelectorAll("script,noscript,iframe,link[rel=stylesheet],style,object,embed").forEach(function (el) { el.remove(); });
|
|
|
|
// mask sensitive elements
|
|
root.querySelectorAll("[data-dp-mask]").forEach(function (el) { el.textContent = "•••••"; });
|
|
|
|
// freeze form state from the LIVE dom onto the clone (index-aligned)
|
|
var liveInputs = document.querySelectorAll("input,textarea,select");
|
|
var cloneInputs = root.querySelectorAll("input,textarea,select");
|
|
liveInputs.forEach(function (live, i) {
|
|
var c = cloneInputs[i];
|
|
if (!c) return;
|
|
if (live.tagName === "TEXTAREA") c.textContent = live.value;
|
|
else if (live.tagName === "SELECT") {
|
|
Array.prototype.forEach.call(c.options, function (o, k) { if (k === live.selectedIndex) o.setAttribute("selected", ""); else o.removeAttribute("selected"); });
|
|
} else {
|
|
if (live.type === "checkbox" || live.type === "radio") { if (live.checked) c.setAttribute("checked", ""); else c.removeAttribute("checked"); }
|
|
else if (live.type === "password") c.setAttribute("value", "•••••");
|
|
else c.setAttribute("value", live.value);
|
|
}
|
|
});
|
|
|
|
// inline images from the LIVE dom (auth context), index-aligned
|
|
var liveImgs = document.querySelectorAll("img");
|
|
var cloneImgs = root.querySelectorAll("img");
|
|
for (var i = 0; i < liveImgs.length; i++) {
|
|
var src = liveImgs[i].currentSrc || liveImgs[i].src;
|
|
if (!src || src.startsWith("data:")) continue;
|
|
var d = await toDataUrl(src);
|
|
if (cloneImgs[i]) {
|
|
if (d) { cloneImgs[i].setAttribute("src", d); cloneImgs[i].removeAttribute("srcset"); }
|
|
else cloneImgs[i].setAttribute("src", src); // fall back to absolute URL
|
|
}
|
|
}
|
|
|
|
var css = await collectCss();
|
|
var head = root.querySelector("head") || root.insertBefore(document.createElement("head"), root.firstChild);
|
|
var style = document.createElement("style");
|
|
style.textContent = css;
|
|
head.appendChild(style);
|
|
var base = document.createElement("base");
|
|
base.setAttribute("href", location.origin);
|
|
head.insertBefore(base, head.firstChild);
|
|
|
|
return "<!doctype html>\n" + root.outerHTML;
|
|
}
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
function (e) {
|
|
var xPct = (e.clientX / window.innerWidth) * 100;
|
|
var yPct = (e.clientY / window.innerHeight) * 100;
|
|
busy = busy.then(async function () {
|
|
var html = await serialize();
|
|
chrome.runtime.sendMessage({ type: "domstep", html: html, xPct: xPct, yPct: yPct });
|
|
});
|
|
},
|
|
true,
|
|
);
|
|
})();
|