/** * 正文组成 frontend 的隔离预览。 * iframe 只用 sandbox="allow-scripts",不加 allow-same-origin。 * 模板通过 present.onData(cb) 收示例灌数,不能访问宿主页面。 */ export const PRESENT_FRAME_SANDBOX = "allow-scripts"; export const PRESENT_BYTE_LIMIT = 60 * 1024; const frames = new Map(); let seq = 0; export function isFrontendBundle(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; return typeof value.html === "string" && /<\w+/.test(value.html); } export function presentSourceBytes(bundle) { const html = typeof bundle?.html === "string" ? bundle.html : ""; const css = typeof bundle?.css === "string" ? bundle.css : ""; const js = typeof bundle?.js === "string" ? bundle.js : ""; return html.length + css.length + js.length; } export function previewPacket(sample) { if (!sample || typeof sample !== "object" || Array.isArray(sample)) { return { schema: "present.v1", blocks: {} }; } if (sample.blocks && typeof sample.blocks === "object" && !Array.isArray(sample.blocks)) { return { schema: typeof sample.schema === "string" ? sample.schema : "present.v1", blocks: sample.blocks }; } return { schema: "present.v1", blocks: sample }; } export function queuePresentFrame(bundle, packet, slots) { const id = `pf${seq.toString(36)}${(seq += 1).toString(36)}`; frames.set(id, { bundle, packet, slots: Array.isArray(slots) ? slots : [] }); return id; } export function presentFrameSlotHtml(id, minHeight) { const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, ""); const h = Number(minHeight); const style = Number.isFinite(h) && h > 8 ? ` style="min-height:${Math.ceil(h)}px"` : ""; return `
`; } function neutralizeClosing(source, tag) { return String(source ?? "").replace(new RegExp(`${tag}`, "gi"), `<\\/${tag}`); } function embedJson(value) { return JSON.stringify(value) .replace(//gi, ""); const jsSafe = neutralizeClosing(js, "script"); const packetJson = embedJson(packet ?? { schema: "present.v1", blocks: {} }); const slotsJson = embedJson(Array.isArray(slots) ? slots : []); return ` ${htmlSafe} `; } function blocksToPlain(packet) { const blocks = packet?.blocks; if (!blocks || typeof blocks !== "object") return ""; return Object.entries(blocks) .map(([key, value]) => { const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); return `${key}\n${text}`; }) .join("\n\n"); } function mountOne(host, id, payload) { const frame = document.createElement("iframe"); frame.className = "present-preview-frame"; frame.setAttribute("sandbox", PRESENT_FRAME_SANDBOX); frame.setAttribute("referrerpolicy", "no-referrer"); frame.setAttribute("scrolling", "no"); frame.title = "版式预览"; const hinted = Number.parseFloat(host.style.minHeight); if (Number.isFinite(hinted) && hinted > 8) { frame.style.height = `${Math.ceil(hinted)}px`; } const srcdoc = buildPresentSrcdoc({ html: payload.bundle.html, css: payload.bundle.css, js: payload.bundle.js, packet: payload.packet, slots: payload.slots, frameId: id, }); const section = host.closest(".present-preview"); const status = section?.querySelector(".present-preview-status"); const fallback = section?.querySelector(".present-preview-fallback"); let settled = false; const fail = (message) => { frame.hidden = true; if (status) { status.hidden = false; status.textContent = message; } const plain = blocksToPlain(payload.packet); if (fallback && plain) { fallback.hidden = false; fallback.textContent = plain; } }; const timer = window.setTimeout(() => { if (settled) return; settled = true; fail("模板 3 秒内没有就绪,下面是示例内容。"); }, 3000); const onMessage = (event) => { if (!frame.isConnected) { window.removeEventListener("message", onMessage); window.clearTimeout(timer); return; } if (event.source !== frame.contentWindow) return; const data = event.data; if (!data || data.source !== "present-frame" || data.id !== id) return; if (data.type === "height" && !frame.hidden) { const height = Number(data.height); if (height > 0) { const next = Math.max(1, Math.ceil(height)); const prev = Math.ceil(Number.parseFloat(frame.style.height) || 0); frame.style.height = `${next}px`; host.style.minHeight = ""; if (Math.abs(next - prev) >= 2) { frame.dispatchEvent(new CustomEvent("present-frame-resize", { bubbles: true })); } } } if (data.type === "ready") { settled = true; window.clearTimeout(timer); } if (data.type === "error") { window.clearTimeout(timer); if (!settled) { settled = true; fail(data.message ? `模板没有跑起来:${data.message}` : "模板没有跑起来。"); } else if (status) { status.hidden = false; status.textContent = data.message || "模板运行出错"; } } }; window.addEventListener("message", onMessage); host.replaceChildren(frame); frame.srcdoc = srcdoc; } export function updatePresentFramePacket(frame, packet) { const win = frame?.contentWindow; if (!win) return false; try { win.postMessage( { source: "present-host", type: "packet", packet: previewPacket(packet) }, "*", ); return true; } catch { return false; } } export function mountPresentFrames(root) { if (!root || typeof root.querySelectorAll !== "function") return; root.querySelectorAll(".present-frame-host[data-present-id]").forEach((host) => { if (host.dataset.mounted === "1") return; const id = host.dataset.presentId; const payload = frames.get(id); if (!payload) return; host.dataset.mounted = "1"; frames.delete(id); mountOne(host, id, payload); }); } export function watchPresentFrames() { if (typeof document === "undefined" || watchPresentFrames.started) return; watchPresentFrames.started = true; const run = () => mountPresentFrames(document); const start = () => { const obs = new MutationObserver(run); obs.observe(document.body, { childList: true, subtree: true }); run(); }; if (document.body) start(); else document.addEventListener("DOMContentLoaded", start, { once: true }); }