正文组成的前端在验收时隔离预览,避免模板脚本碰到宿主页面。

超限只展示源码,测试钉住沙箱和文档拼装。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 15:26:33 +08:00
co-authored by Cursor
parent c2c9203fb0
commit 8bd469ceeb
5 changed files with 389 additions and 1 deletions
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
PRESENT_BYTE_LIMIT,
PRESENT_FRAME_SANDBOX,
buildPresentSrcdoc,
isFrontendBundle,
presentSourceBytes,
previewPacket,
} from "../web/present-frame.js";
describe("present frame srcdoc", () => {
it("keeps the sandbox flag to scripts only", () => {
expect(PRESENT_FRAME_SANDBOX).toBe("allow-scripts");
expect(PRESENT_FRAME_SANDBOX).not.toContain("allow-same-origin");
});
it("embeds the skeleton, styles, script, and sample packet", () => {
const srcdoc = buildPresentSrcdoc({
html: `<div id="app"></div>`,
css: `#app { color: #e1e7f0; }`,
js: `present.onData(function (data) { document.getElementById("app").textContent = data.blocks.body; });`,
packet: { schema: "present.v1", blocks: { body: "示例正文" } },
frameId: "pf1",
});
expect(srcdoc).toContain(`<div id="app"></div>`);
expect(srcdoc).toContain(`#app { color: #e1e7f0; }`);
expect(srcdoc).toContain("present.onData");
expect(srcdoc).toContain("示例正文");
expect(srcdoc).toContain(`default-src 'none'`);
expect(srcdoc.indexOf("window.present")).toBeLessThan(srcdoc.indexOf("data.blocks.body"));
const scripts = [...srcdoc.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
expect(scripts.length).toBeGreaterThan(0);
for (const source of scripts) {
expect(() => new Function(source)).not.toThrow();
}
});
it("does not let a script closer in the template js break the document", () => {
const srcdoc = buildPresentSrcdoc({
html: "<div id=\"app\"></div>",
css: "body{}",
js: `var s = "</script><script>parent.stolen = 1";`,
packet: { schema: "present.v1", blocks: {} },
frameId: "pf2",
});
expect(srcdoc).not.toContain(`</script><script>parent.stolen`);
expect(srcdoc).toContain(`<\\/script>`);
});
it("recognizes a frontend bundle and the 60 KB budget", () => {
expect(isFrontendBundle({ html: `<div id="app"></div>`, css: "body{}" })).toBe(true);
expect(isFrontendBundle({ html: "只是说明" })).toBe(false);
expect(presentSourceBytes({ html: "abc", css: "de", js: "f" })).toBe(6);
expect(PRESENT_BYTE_LIMIT).toBe(60 * 1024);
});
it("normalizes sample data into present.v1 blocks", () => {
expect(previewPacket({ schema: "present.v1", blocks: { body: "x" } })).toEqual({
schema: "present.v1",
blocks: { body: "x" },
});
expect(previewPacket(null)).toEqual({ schema: "present.v1", blocks: {} });
});
});
+51
View File
@@ -17,6 +17,15 @@ import {
renderPresentShellHtml, renderPresentShellHtml,
stripPresentSourceFences, stripPresentSourceFences,
} from "./present-shells.js"; } from "./present-shells.js";
import {
PRESENT_BYTE_LIMIT,
isFrontendBundle,
presentFrameSlotHtml,
presentSourceBytes,
previewPacket,
queuePresentFrame,
watchPresentFrames,
} from "./present-frame.js";
const HIDE_KINDS = new Set(["worker_stub"]); const HIDE_KINDS = new Set(["worker_stub"]);
@@ -4113,6 +4122,9 @@ function renderObjectSectionsHtml(obj, depth) {
.map(([k, v]) => { .map(([k, v]) => {
if (k === "自评") return renderSelfScoreHtml(v); if (k === "自评") return renderSelfScoreHtml(v);
if (k === "追问") return renderProbeHtml(v); if (k === "追问") return renderProbeHtml(v);
if (k === "frontend" && isFrontendBundle(v)) {
return renderFrontendPreviewBlock(v, obj["示例灌数"]);
}
return `<section class="ws-section"><h4>${esc(k)}</h4>${renderStructuredValueHtml(v, depth + 1)}</section>`; return `<section class="ws-section"><h4>${esc(k)}</h4>${renderStructuredValueHtml(v, depth + 1)}</section>`;
}) })
.join(""); .join("");
@@ -4219,6 +4231,9 @@ function renderObjectKvHtml(obj, depth) {
const rows = entries const rows = entries
.map(([k, v]) => { .map(([k, v]) => {
if (k === "frontend" && isFrontendBundle(v)) {
return renderFrontendPreviewBlock(v, obj["示例灌数"]);
}
const isComplex = v != null && typeof v === "object"; const isComplex = v != null && typeof v === "object";
if (isComplex && isDiagnosticNode(v)) { if (isComplex && isDiagnosticNode(v)) {
return renderDiagnosticDimHtml(k, v); return renderDiagnosticDimHtml(k, v);
@@ -6394,6 +6409,40 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) {
renderAgentPanel(view, loading); renderAgentPanel(view, loading);
} }
/** 正文组成的 frontend:同一位置渲染隔离预览,源码收在后面 */
function renderFrontendPreviewBlock(bundle, sample) {
const source = renderFrontendSourceDetails(bundle);
if (presentSourceBytes(bundle) > PRESENT_BYTE_LIMIT) {
return `<section class="af-section present-preview">
<h4 class="af-section-title">frontend</h4>
<p class="present-preview-status">前端超过 60 KB,这里不执行。</p>
${source}
</section>`;
}
const id = queuePresentFrame(bundle, previewPacket(sample));
const note = sample ? "按示例灌数渲染,不是正史正文" : "骨架预览,还没有示例灌数";
return `<section class="af-section present-preview">
<h4 class="af-section-title">frontend</h4>
<p class="present-preview-note">${esc(note)}</p>
${presentFrameSlotHtml(id)}
<p class="present-preview-status" hidden></p>
<div class="present-preview-fallback" hidden></div>
${source}
</section>`;
}
function renderFrontendSourceDetails(bundle) {
const parts = ["html", "css", "js"]
.filter((key) => typeof bundle[key] === "string" && bundle[key].trim())
.map(
(key) =>
`<div class="af-def"><div class="af-def-k">${esc(key)}</div><div class="af-def-v">${renderProseHtml(bundle[key])}</div></div>`,
)
.join("");
if (!parts) return "";
return `<details class="skill-example present-preview-source"><summary>源码</summary><div class="af-def-list">${parts}</div></details>`;
}
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if (!e.target.closest("#msg-action-menu")) hideMsgMenu(); if (!e.target.closest("#msg-action-menu")) hideMsgMenu();
if (!e.target.closest("#dictate-product-menu")) hideDictateProductMenu(); if (!e.target.closest("#dictate-product-menu")) hideDictateProductMenu();
@@ -6425,3 +6474,5 @@ document.addEventListener("scroll", (e) => {
hideDictateProductMenu(); hideDictateProductMenu();
hideFlowNodeMenu(); hideFlowNodeMenu();
}, true); }, true);
watchPresentFrames();
+1 -1
View File
@@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="/styles.css?v=20260928-shell-order" /> <link rel="stylesheet" href="/styles.css?v=20260930-present-preview" />
<script> <script>
(function () { (function () {
try { try {
+241
View File
@@ -0,0 +1,241 @@
/**
* 正文组成 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) {
const id = `pf${seq.toString(36)}${(seq += 1).toString(36)}`;
frames.set(id, { bundle, packet });
return id;
}
export function presentFrameSlotHtml(id) {
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
return `<div class="present-frame-host" data-present-id="${safe}"></div>`;
}
function neutralizeClosing(source, tag) {
return String(source ?? "").replace(new RegExp(`</${tag}`, "gi"), `<\\/${tag}`);
}
function embedJson(value) {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frameId }) {
const safeId = String(frameId).replace(/[^a-zA-Z0-9_-]/g, "");
const cssSafe = neutralizeClosing(css, "style");
const htmlSafe = String(html).replace(/<\/(body|html|head)\s*>/gi, "");
const jsSafe = neutralizeClosing(js, "script");
const packetJson = embedJson(packet ?? { schema: "present.v1", blocks: {} });
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; script-src 'unsafe-inline';">
<style>html,body{margin:0}</style>
<style>${cssSafe}</style>
<script>
(function () {
var FRAME_ID = ${JSON.stringify(safeId)};
var PACKET = ${packetJson};
var failed = false;
function post(type, extra) {
var msg = { source: "present-frame", type: type, id: FRAME_ID };
if (extra) {
for (var k in extra) msg[k] = extra[k];
}
parent.postMessage(msg, "*");
}
function reportError(err) {
failed = true;
post("error", { message: String(err && err.message ? err.message : err) });
}
function markdown(raw) {
var s = String(raw == null ? "" : raw)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
s = s.replace(/\`([^\`]+)\`/g, "<code>$1</code>");
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>");
return s.split(/\\n{2,}/).map(function (part) {
return "<p>" + part.replace(/\\n/g, "<br>") + "</p>";
}).join("");
}
window.present = {
onData: function (cb) {
try { cb(PACKET); }
catch (err) { reportError(err); }
},
markdown: markdown
};
window.addEventListener("error", function (e) {
reportError(e.error || e.message || "脚本错误");
});
window.present.__seal = function () {
if (failed) return;
post("ready");
var sendHeight = function () {
var h = Math.max(
document.documentElement ? document.documentElement.scrollHeight : 0,
document.body ? document.body.scrollHeight : 0
);
post("height", { height: h });
};
sendHeight();
window.addEventListener("resize", sendHeight);
if (window.ResizeObserver && document.documentElement) {
new ResizeObserver(sendHeight).observe(document.documentElement);
}
};
})();
</script>
</head>
<body>
${htmlSafe}
<script>${jsSafe}</script>
<script>if (window.present && present.__seal) present.__seal();</script>
</body>
</html>`;
}
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.title = "版式预览";
const srcdoc = buildPresentSrcdoc({
html: payload.bundle.html,
css: payload.bundle.css,
js: payload.bundle.js,
packet: payload.packet,
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) {
frame.style.height = `${Math.min(960, Math.max(180, Math.ceil(height)))}px`;
}
}
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 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 });
}
+32
View File
@@ -3894,6 +3894,38 @@ body.design-workspace .af-mosaic {
.af-section:last-child { .af-section:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.present-preview-note,
.present-preview-status {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--muted);
}
.present-frame-host {
width: 100%;
min-width: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: var(--surface);
}
.present-preview-frame {
display: block;
width: 100%;
height: 220px;
border: 0;
background: transparent;
}
.present-preview-fallback {
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg);
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.55;
}
.af-section-title { .af-section-title {
margin: 0 0 8px; margin: 0 0 8px;
font-family: var(--font-ui); font-family: var(--font-ui);