正文组成的前端在验收时隔离预览,避免模板脚本碰到宿主页面。
超限只展示源码,测试钉住沙箱和文档拼装。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
renderPresentShellHtml,
|
||||
stripPresentSourceFences,
|
||||
} 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"]);
|
||||
|
||||
@@ -4113,6 +4122,9 @@ function renderObjectSectionsHtml(obj, depth) {
|
||||
.map(([k, v]) => {
|
||||
if (k === "自评") return renderSelfScoreHtml(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>`;
|
||||
})
|
||||
.join("");
|
||||
@@ -4219,6 +4231,9 @@ function renderObjectKvHtml(obj, depth) {
|
||||
|
||||
const rows = entries
|
||||
.map(([k, v]) => {
|
||||
if (k === "frontend" && isFrontendBundle(v)) {
|
||||
return renderFrontendPreviewBlock(v, obj["示例灌数"]);
|
||||
}
|
||||
const isComplex = v != null && typeof v === "object";
|
||||
if (isComplex && isDiagnosticNode(v)) {
|
||||
return renderDiagnosticDimHtml(k, v);
|
||||
@@ -6394,6 +6409,40 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) {
|
||||
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) => {
|
||||
if (!e.target.closest("#msg-action-menu")) hideMsgMenu();
|
||||
if (!e.target.closest("#dictate-product-menu")) hideDictateProductMenu();
|
||||
@@ -6425,3 +6474,5 @@ document.addEventListener("scroll", (e) => {
|
||||
hideDictateProductMenu();
|
||||
hideFlowNodeMenu();
|
||||
}, true);
|
||||
|
||||
watchPresentFrames();
|
||||
|
||||
+1
-1
@@ -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"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles.css?v=20260928-shell-order" />
|
||||
<link rel="stylesheet" href="/styles.css?v=20260930-present-preview" />
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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 });
|
||||
}
|
||||
@@ -3894,6 +3894,38 @@ body.design-workspace .af-mosaic {
|
||||
.af-section:last-child {
|
||||
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 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-ui);
|
||||
|
||||
Reference in New Issue
Block a user