feat(preview): add sandboxed live code previews

Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 03:34:19 -05:00
committed by Athena
co-authored by Cursor
parent 42eaed647a
commit 656c14caf3
13 changed files with 3079 additions and 31 deletions
+19
View File
@@ -8,6 +8,7 @@
"name": "web",
"version": "1.2.0",
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
@@ -2236,6 +2237,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+2
View File
@@ -10,9 +10,11 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/preview/jsx-transform.test.js",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
+393 -6
View File
@@ -1,4 +1,11 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
// Which languages get a live sandboxed preview (RenderBlock) instead of a plain
// syntax block (CodeBlock), and how each becomes a document body, lives in
// ./preview/languages.js. A language like `js` is deliberately absent —
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
// comment for the sandboxing model).
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
// Parse content into an array of {type, value, lang, streaming} blocks.
// Handles:
@@ -51,11 +58,13 @@ export function Markdown({ content }) {
const blocks = parseBlocks(content);
return (
<div style={{ lineHeight: "1.6" }}>
{blocks.map((block, i) =>
block.type === "code"
? <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />
: <TextBlock key={i} text={block.value} />
)}
{blocks.map((block, i) => {
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
const lang = (block.lang || "").toLowerCase();
return RENDERABLE_LANGS.has(lang)
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
})}
</div>
);
}
@@ -117,6 +126,384 @@ function CodeBlock({ lang, value, streaming }) {
);
}
// Content-Security-Policy for the rendered preview. Together with the iframe's
// `sandbox` attribute below, this is the entire trust boundary for model-
// authored HTML/SVG, so it stays conservative rather than convenient:
// - script-src/style-src 'unsafe-inline' inline <script>/<style> in the
// fence run (that's the whole point - charts, small interactive demos),
// but nothing else is allowed to load.
// - img-src/font-src data: embedded (base64) images/fonts
// work; remote https:// ones silently fail to load, on purpose.
// - connect-src 'none' no fetch/XHR/WebSocket out - a
// model-authored block can't phone home or probe the LAN.
// - default-src 'none' blanket deny for everything else
// (frames, media, workers, ...) not explicitly allowed above.
// - base-uri 'none' base-uri does NOT fall back to
// default-src, so it has to be named explicitly or a <base> tag would slip
// through the blanket deny above.
const _RENDER_CSP =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; " +
"form-action 'none'; base-uri 'none';";
// Injected ahead of the model's markup in every preview document, so it is
// installed before that markup's own scripts can throw. The literal
// `</script>` below is safe unescaped because this module is emitted as an
// external .js asset - it is never inlined into index.html, where the HTML
// parser would end the surrounding script tag early.
//
// postMessage is the one channel an opaque-origin sandboxed frame still has to
// the parent, and this is the entire protocol over it: one message shape,
// outbound only, carrying a content height and an error string. Nothing flows
// the other way. The parent treats both fields as untrusted data - the height
// is clamped and the message is rendered as text, never as markup - because
// they were produced by the same code the sandbox exists to contain.
//
// Without this the frame is silent: a preview whose script throws just renders
// blank, which is why the server-side validator in synapse/tools.py has to
// guess at runtime failures it can't observe.
const _PREVIEW_BOOTSTRAP = `<script>
(function () {
var observers = [];
// Measure the body box, never documentElement: <html>'s scrollHeight is at
// least the viewport, i.e. at least whatever height the parent just applied,
// so feeding it back would make every preview climb to the cap. body height
// is auto, so its scrollHeight tracks content alone; its own margins sit
// outside that box and have to be added back by hand.
var measure = function () {
var b = document.body;
if (!b) return 0;
var cs = getComputedStyle(b);
return b.scrollHeight
+ (parseFloat(cs.marginTop) || 0)
+ (parseFloat(cs.marginBottom) || 0);
};
// The first error is remembered and re-sent with every later message. A
// document can throw while parsing, before the parent has attached its
// listener, and a dropped error leaves a blank frame with no explanation -
// the exact failure this bootstrap exists to prevent. Re-sending costs
// nothing: the parent setting the same string twice is a no-op.
var firstErr = "";
var post = function (err) {
if (err && !firstErr) firstErr = String(err).slice(0, 500);
try {
parent.postMessage({ __nexusPreview: 1, h: measure(), err: firstErr }, "*");
} catch (e) { /* parent went away - nothing to report to */ }
};
// Coalesce bursts: one re-render can fire many mutations.
var pending = 0;
var soon = function () {
if (pending) return;
pending = setTimeout(function () { pending = 0; post(); }, 50);
};
window.onerror = function (msg, src, line, col, err) {
// Line numbers are document-relative; the user reads them against their own
// source in the Code tab. Subtract everything above it: the shell, this
// bootstrap, and for JSX the inlined view library and import stubs.
// (No backticks anywhere in here - this whole script is a template literal.)
var off = (window.__previewLineOffset | 0);
var n = line - off;
// Walk the stack for the innermost frame that lands in the user's own code.
// The top frame is often shell: a component that throws while rendering is
// caught and rethrown by the view library, and a stubbed import throws from
// the stub. Both sit above the user's first line, so they subtract to less
// than 1 and the next frame down is the one worth reporting.
if (err && err.stack) {
var re = /:(\\d+):\\d+/g, m;
while ((m = re.exec(String(err.stack)))) {
var cand = (+m[1]) - off;
if (cand >= 1) { n = cand; break; }
}
}
post(n >= 1 ? msg + " (line " + n + ")" : msg);
return false;
};
window.addEventListener("unhandledrejection", function (e) {
var r = e.reason;
post("Unhandled promise rejection: " + ((r && r.message) || r));
});
window.addEventListener("load", function () {
post();
// Two observers, because neither covers the other's case. A
// MutationObserver catches content and inline-style changes - what a
// component re-render does - and runs off the microtask queue. A
// ResizeObserver catches size changes with no DOM change behind them, such
// as a CSS transition or a media query, but is delivered as part of the
// rendering lifecycle, so a frame that is never composited never gets one.
// The references are held so neither is collected while still observing.
if (window.MutationObserver && document.body) {
observers.push(new MutationObserver(soon));
observers[observers.length - 1].observe(document.body, {
childList: true, subtree: true, attributes: true, characterData: true
});
}
if (window.ResizeObserver && document.body) {
observers.push(new ResizeObserver(soon));
observers[observers.length - 1].observe(document.body);
}
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
});
})();
</script>`;
// Substituted with the real line offset once the document is assembled and its
// shell can be measured. Sits on one line so replacing it can't shift any.
const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__";
/**
* Build the sandboxed document for a fence. Returns {doc, error}: a language
* whose source doesn't parse (JSX, today) has no document to show, and the
* caller renders the message instead of a frame.
*
* The shell - charset, CSP, bootstrap - is identical for every language; only
* the body differs, so only that part goes through the registry. Nothing about
* the sandboxing is per-language and shouldn't be: SVG can carry <script> and
* event-handler attributes exactly like HTML can, and transformed JSX is just
* more script. Every language is contained the same way.
*/
function buildSrcDoc(lang, value) {
const entry = PREVIEW_LANGS[lang];
if (!entry) return { doc: null, error: `No preview for '${lang}'.` };
let body;
try {
body = entry.toBody(value);
} catch (e) {
return { doc: null, error: e && e.message ? e.message : String(e) };
}
const head =
"<!doctype html><html><head><meta charset=\"utf-8\">" +
`<meta http-equiv="Content-Security-Policy" content="${_RENDER_CSP}">` +
`<script>window.__previewLineOffset=${_OFFSET_TOKEN};</script>` +
_PREVIEW_BOOTSTRAP +
"</head><body style=\"margin:0\">";
// Lines of shell above the user's own code: the document head, plus whatever
// the language puts in the body ahead of it (the Preact build, for JSX).
const offset = (head.match(/\n/g) || []).length + body.userOffset;
return {
doc: (head + body.html + "</body></html>").replace(_OFFSET_TOKEN, String(offset)),
error: "",
};
}
// Auto-height bounds. The frame is sized from content, and content sized in
// viewport/percentage units is therefore sized from the frame - a body with its
// own margin makes that loop grow by the margin on every pass. Measuring the
// body box rather than documentElement is what actually settles that loop;
// _MAX_PREVIEW_H then caps anything still climbing within a few iterations.
//
// _MAX_H_STEPS is only a last resort against a document that oscillates
// forever, so it is generous: an interactive component legitimately changes
// height on every click, and a tight budget would freeze the frame mid-session
// at whatever size it happened to reach.
const _MIN_PREVIEW_H = 160;
const _MAX_PREVIEW_H = 720;
const _MAX_H_STEPS = 60;
// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle,
// rendered via a sandboxed <iframe srcDoc>.
//
// Trust boundary: `sandbox="allow-scripts"` — deliberately without
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
// allow-same-origin forces the iframe onto an opaque origin, which is what
// actually matters here: even the inline scripts the CSP allows to run can't
// read this app's cookies/localStorage, can't call its API (no credentialed
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
// above closes the remaining gap same-origin-denial doesn't cover on its own:
// arbitrary outbound network access to the open internet. Nothing here is
// treated as a substitute for real code execution sandboxing (Docker, a WASM
// runtime, etc.) - this block never runs anything outside the browser's own
// iframe sandbox, which is the point: no backend attack surface at all.
function RenderBlock({ lang, value, streaming }) {
const [tab, setTab] = useState("preview");
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(value.trimEnd()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
// Don't preview a block whose fence hasn't closed yet - it's incomplete
// markup by definition, and re-pointing an iframe's srcDoc at a half-formed
// document on every streamed token is both wasteful and flickery. Code view
// already has its own streaming indicator (the same dot CodeBlock uses).
const showPreview = tab === "preview" && !streaming;
return (
<div style={{
background: "#0d0d0d",
border: "1px solid #2a2a2a",
borderRadius: "6px",
margin: "0.5rem 0",
overflow: "hidden",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.3rem 0.75rem",
background: "#161616",
borderBottom: "1px solid #2a2a2a",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
<TabButton active={tab === "preview"} disabled={streaming} onClick={() => setTab("preview")}>
Preview
</TabButton>
<TabButton active={tab === "code"} onClick={() => setTab("code")}>
Code
</TabButton>
<span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
{lang}
{streaming && <span style={{ color: "#444", marginLeft: "0.4rem" }}></span>}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{showPreview && (
<button onClick={() => setExpanded((e) => !e)} style={_chromeButtonStyle("#555")}>
{expanded ? "Collapse" : "Expand"}
</button>
)}
{!streaming && (
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
{copied ? "Copied!" : "Copy"}
</button>
)}
</div>
</div>
{showPreview ? (
// Keyed by the markup: new markup is a new document, so remounting is
// what resets the reported error and measured height. No reset effect.
<PreviewFrame key={`${lang}:${value}`} lang={lang} value={value} expanded={expanded} />
) : (
<pre style={{
padding: "0.75rem 1rem",
overflowX: "auto",
fontSize: "0.85rem",
lineHeight: "1.5",
margin: 0,
fontFamily: "monospace",
}}>
<code>{value.trimEnd()}</code>
</pre>
)}
</div>
);
}
// The sandboxed frame plus the two things it reports back: its content height
// and its first uncaught error. Split out of RenderBlock so the caller can key
// it by markup - a fresh document then gets fresh state by remounting.
function PreviewFrame({ lang, value, expanded }) {
const [error, setError] = useState("");
const [height, setHeight] = useState(240);
const frameRef = useRef(null);
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
const stepsRef = useRef(0);
// Receive the bootstrap's reports. The frame is on an opaque origin, so
// e.origin is the string "null" and proves nothing - identify the sender by
// its window instead, which content inside the sandbox cannot forge.
useEffect(() => {
const onMessage = (e) => {
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
const data = e.data;
if (!data || data.__nexusPreview !== 1) return;
if (typeof data.err === "string" && data.err) setError(data.err);
if (typeof data.h === "number" && Number.isFinite(data.h) && stepsRef.current < _MAX_H_STEPS) {
const next = Math.min(_MAX_PREVIEW_H, Math.max(_MIN_PREVIEW_H, Math.round(data.h)));
if (Math.abs(next - heightRef.current) >= 8) {
heightRef.current = next;
stepsRef.current += 1;
setHeight(next);
}
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
// A build failure (JSX that doesn't parse) has no document to show at all, so
// the message stands in for the frame rather than sitting under it.
const { doc, error: buildError } = buildSrcDoc(lang, value);
const shown = buildError || error;
return (
<>
{doc && (
<iframe
ref={frameRef}
title="rendered output"
sandbox="allow-scripts"
srcDoc={doc}
style={{
width: "100%",
height: expanded ? "70vh" : `${height}px`,
border: "none",
background: "#fff",
display: "block",
}}
/>
)}
{shown && (
<div style={{
background: "#2a1414",
borderTop: "1px solid #4a2020",
color: "#ff8a80",
fontFamily: "monospace",
fontSize: "0.75rem",
padding: "0.4rem 0.75rem",
// Text from inside the sandbox: rendered as a string, and wrapped
// rather than allowed to stretch the block.
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{shown}
</div>
)}
</>
);
}
function _chromeButtonStyle(color) {
return {
background: "transparent",
border: "none",
color,
cursor: "pointer",
fontSize: "0.75rem",
padding: "0.1rem 0.3rem",
};
}
function TabButton({ active, disabled, onClick, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
background: active ? "#262626" : "transparent",
border: "none",
borderRadius: "4px",
color: disabled ? "#3a3a3a" : active ? "#eee" : "#888",
cursor: disabled ? "default" : "pointer",
fontSize: "0.75rem",
padding: "0.15rem 0.5rem",
}}
>
{children}
</button>
);
}
function TextBlock({ text }) {
const lines = text.split("\n");
const elements = [];
+1 -1
View File
@@ -359,7 +359,7 @@ export function Playbook() {
/>
<input
type="text"
placeholder="Tools: search_memory, search_history, search_documents, list_models, get_time, web_search, fetch_url, remember"
placeholder="Playbook tools: search_memory, … (render_preview auto-attaches on visual asks)"
value={form.tools}
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
+686
View File
@@ -0,0 +1,686 @@
/*
* jsx-transform.js — JSX/TSX in, plain JS out. No dependencies.
*
* Scope is deliberately the single self-contained component a model writes into
* a chat fence: elements, fragments, attributes, spreads, expression children,
* and the TypeScript annotations that decorate them. It is not a TypeScript
* compiler and does not try to be - the real one costs more bytes than this
* whole app, and nothing here needs to survive input the user didn't ask a
* local model to produce.
*
* Failure is loud on purpose. Anything this can't parse throws TransformError,
* which the caller shows in place of the preview; anything it declines to touch
* is passed through, so unsupported TypeScript reaches the browser and surfaces
* as a SyntaxError in the preview's error bar (see Markdown.jsx's bootstrap).
* Both beat quietly emitting code that runs and does the wrong thing.
*
* Pure string -> string, no DOM and no eval, which is what lets it run in the
* parent app instead of inside the sandbox: transforming untrusted text is not
* executing it. Tested headless by jsx-transform.test.js (node --test).
*/
export class TransformError extends Error {}
// Characters after which a value may start, so a `<` opens JSX rather than
// acting as less-than and a `/` starts a regex rather than dividing.
//
// `)` and `]` are deliberately absent: they *end* a value, so `f(x) / 2` is a
// division and `xs[0] < 3` is a comparison. Including them made
// `Math.sin(t) / 6` scan as an unterminated regex.
const EXPR_START = new Set([
"", "(", ",", "=", ":", ";", "{", "}", "[", "&", "|", "?", "!",
"+", "-", "*", "/", "%", "^", "~", ">", "<",
]);
// Same idea for keywords: `return <div/>` is JSX, `a in <b` is not real code.
const EXPR_KEYWORDS = new Set([
"return", "yield", "await", "typeof", "in", "of", "case", "do", "else",
"new", "delete", "void", "throw", "default", "instanceof",
]);
const ID_START = /[A-Za-z_$]/;
const ID_CHAR = /[\w$]/;
// A generic argument list holds only type syntax. Used to tell `useState<T>(0)`
// (strip the <T>) from `a < b > (c)` (arithmetic, leave alone).
const TYPE_ARG_CHARS = /^[\w$\s,.[\]|&<>'"-]*$/;
function isIdentifier(ch) {
return ch !== undefined && ID_CHAR.test(ch);
}
function countNewlines(text) {
let n = 0;
for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
return n;
}
/** The newlines `consumed` had that `produced` lost, so line numbers hold. */
function missingNewlines(consumed, produced) {
return "\n".repeat(Math.max(0, countNewlines(consumed) - countNewlines(produced)));
}
class Scanner {
constructor(src) {
this.src = src;
this.i = 0;
this.out = [];
this.prevSig = ""; // last significant char emitted
this.prevWord = ""; // last identifier/keyword emitted
this.defaultExport = null;
this.components = []; // capitalized declarations, in source order
this.imports = []; // {names, module} for every import statement dropped
// One frame per unclosed opener. `ch` tells a parameter list from an object
// literal - the difference between stripping a type annotation and eating a
// property's value - and `ternaries` counts `?`s still waiting for their
// `:`, so a conditional's else-branch isn't mistaken for a type either.
this.frames = [{ ch: "", ternaries: 0 }];
}
get frame() {
return this.frames[this.frames.length - 1];
}
get context() {
return this.frame.ch;
}
get eof() {
return this.i >= this.src.length;
}
peek(offset = 0) {
return this.src[this.i + offset];
}
emit(text) {
if (!text) return;
this.out.push(text);
const trimmed = text.trimEnd();
if (trimmed) this.prevSig = trimmed[trimmed.length - 1];
}
fail(message) {
const line = this.src.slice(0, this.i).split("\n").length;
throw new TransformError(`${message} (line ${line})`);
}
}
/** Transform a JSX/TSX source string into runnable JS. */
export function transform(source) {
const sc = new Scanner(String(source ?? ""));
scanCode(sc, null);
return {
code: sc.out.join(""),
defaultExport: sc.defaultExport,
components: sc.components,
imports: sc.imports,
};
}
/**
* Scan code until `stop` is reached at depth zero (or end of input). Used for
* the whole program (stop = null) and for the interiors of `${...}` and JSX
* `{...}`, both of which may contain arbitrary code including more JSX.
*/
function scanCode(sc, stop) {
let depth = 0;
while (!sc.eof) {
const ch = sc.peek();
if (stop && depth === 0 && ch === stop) return;
if (ch === "{" || ch === "(" || ch === "[") { depth++; sc.frames.push({ ch, ternaries: 0 }); }
if (ch === "}" || ch === ")" || ch === "]") { depth--; if (sc.frames.length > 1) sc.frames.pop(); }
// `?` opens a conditional whose `:` is not a type annotation. `?.` and `??`
// are their own operators, and a trailing `?` marks an optional parameter.
if (ch === "?" && sc.peek(1) !== "." && sc.peek(1) !== "?" && !/\s*:/.test(sc.src.slice(sc.i + 1, sc.i + 3))) {
sc.frame.ternaries++;
}
// --- things copied through verbatim ---
if (ch === "/" && sc.peek(1) === "/") { copyLineComment(sc); continue; }
if (ch === "/" && sc.peek(1) === "*") { copyBlockComment(sc); continue; }
if (ch === '"' || ch === "'") { copyString(sc, ch); continue; }
if (ch === "`") { copyTemplate(sc); continue; }
if (ch === "/" && regexAllowed(sc)) { copyRegex(sc); continue; }
// --- JSX ---
if (ch === "<" && jsxAllowed(sc)) {
const from = sc.i;
const call = parseElement(sc);
// A multi-line element becomes a single-line h() call, which would shift
// every line after it. The preview reports runtime errors by line number
// and the user reads those against the original in the Code tab, so the
// difference is padded back.
sc.emit(call + missingNewlines(sc.src.slice(from, sc.i), call));
continue;
}
// --- identifiers and keywords (where TS and module syntax live) ---
if (ID_START.test(ch)) {
const word = readWord(sc);
if (handleWord(sc, word)) continue;
sc.emit(word);
sc.prevWord = word;
continue;
}
// --- TypeScript punctuation ---
if (ch === ":" && annotationAhead(sc)) { skipTypeAnnotation(sc); continue; }
if (ch === "!" && nonNullAssertion(sc)) { sc.i++; continue; }
if (!/\s/.test(ch)) sc.prevWord = "";
sc.emit(ch);
sc.i++;
}
if (stop) sc.fail(`unterminated block, expected '${stop}'`);
}
// ── verbatim copiers ────────────────────────────────────────────────────────
function copyLineComment(sc) {
const end = sc.src.indexOf("\n", sc.i);
const stop = end === -1 ? sc.src.length : end;
sc.out.push(sc.src.slice(sc.i, stop));
sc.i = stop;
}
function copyBlockComment(sc) {
const end = sc.src.indexOf("*/", sc.i + 2);
if (end === -1) sc.fail("unterminated block comment");
sc.out.push(sc.src.slice(sc.i, end + 2));
sc.i = end + 2;
}
function copyString(sc, quote) {
const start = sc.i;
sc.i++;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "\\") { sc.i += 2; continue; }
if (ch === quote) { sc.i++; sc.emit(sc.src.slice(start, sc.i)); return; }
if (ch === "\n") break;
sc.i++;
}
sc.fail("unterminated string");
}
function copyTemplate(sc) {
sc.emit("`");
sc.i++;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "\\") { sc.out.push(sc.src.slice(sc.i, sc.i + 2)); sc.i += 2; continue; }
if (ch === "`") { sc.emit("`"); sc.i++; return; }
// `${...}` can hold anything, JSX included - hand it back to the scanner.
if (ch === "$" && sc.peek(1) === "{") {
sc.emit("${");
sc.i += 2;
const saved = sc.prevSig;
sc.prevSig = "";
scanCode(sc, "}");
sc.prevSig = saved;
sc.emit("}");
sc.i++;
continue;
}
sc.out.push(ch);
sc.i++;
}
sc.fail("unterminated template literal");
}
function copyRegex(sc) {
const start = sc.i;
sc.i++;
let inClass = false;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "\\") { sc.i += 2; continue; }
if (ch === "[") inClass = true;
else if (ch === "]") inClass = false;
else if (ch === "/" && !inClass) {
sc.i++;
while (isIdentifier(sc.peek())) sc.i++; // flags
sc.emit(sc.src.slice(start, sc.i));
return;
} else if (ch === "\n") break;
sc.i++;
}
sc.fail("unterminated regular expression");
}
/** A `/` starts a regex only where a value may start. */
function regexAllowed(sc) {
if (sc.peek(1) === "=") return false;
if (EXPR_KEYWORDS.has(sc.prevWord)) return true;
if (sc.prevWord) return false;
return sc.prevSig === "" || EXPR_START.has(sc.prevSig);
}
/** A `<` opens JSX only where a value may start, and only before a tag name. */
function jsxAllowed(sc) {
const next = sc.peek(1);
if (next !== ">" && !ID_START.test(next ?? "")) return false;
if (EXPR_KEYWORDS.has(sc.prevWord)) return true;
if (sc.prevWord) return false; // identifier before `<` means comparison or generics
return EXPR_START.has(sc.prevSig);
}
// ── identifiers, modules, TypeScript declarations ───────────────────────────
function readWord(sc) {
const start = sc.i;
while (isIdentifier(sc.peek())) sc.i++;
return sc.src.slice(start, sc.i);
}
function skipSpace(sc) {
while (!sc.eof && /\s/.test(sc.peek())) sc.i++;
}
/** True when the word was consumed here and needs no further emitting. */
function handleWord(sc, word) {
const atStatement = sc.prevSig === "" || sc.prevSig === ";" || sc.prevSig === "}";
// Note capitalized declarations as they go past: with no default export and
// nothing named App, the caller mounts the last one declared.
if (word === "function" || word === "class" || word === "const" || word === "let" || word === "var") {
const named = sc.src.slice(sc.i).match(/^\s*\*?\s*([A-Z][\w$]*)/);
if (named && !sc.components.includes(named[1])) sc.components.push(named[1]);
}
// Nothing can be imported into the sandbox - there is no module loader and
// no network. Hooks arrive as globals instead, so `import {useState} from
// "react"` is dropped rather than rewritten.
if (word === "import" && atStatement) {
if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; } // import()/import.meta
const from = sc.i;
skipStatement(sc);
recordImport(sc, sc.src.slice(from, sc.i));
return true;
}
if (word === "export" && atStatement) {
skipSpace(sc);
if (sc.src.startsWith("default", sc.i) && !isIdentifier(sc.peek(7))) {
sc.i += 7;
skipSpace(sc);
// `export default App;` names a component; `export default function App()`
// declares one. Either way the mount target is what follows.
const rest = sc.src.slice(sc.i);
const named = rest.match(/^(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/);
if (named) sc.defaultExport = named[1];
if (/^[A-Za-z_$][\w$]*\s*;?\s*$/.test(rest)) { sc.i = sc.src.length; return true; }
}
sc.prevWord = "";
return true; // `export ` itself is dropped either way
}
// `interface X {...}` / `type X = ...` are types entire - drop the statement.
if ((word === "interface" || word === "type") && atStatement && typeDeclarationAhead(sc)) {
if (word === "interface") skipBalancedBraces(sc);
else skipStatement(sc);
return true;
}
// `x as Foo` / `x satisfies Foo` - drop the cast, keep the value.
if ((word === "as" || word === "satisfies") && sc.prevSig && sc.prevSig !== "=") {
skipSpace(sc);
if (sc.peek() === "c" && sc.src.startsWith("const", sc.i)) sc.i += 5;
else skipTypeExpression(sc);
sc.prevWord = "";
return true;
}
// `useState<number>(0)` - a generic argument list, not a comparison.
const save = sc.i;
skipSpace(sc);
if (sc.peek() === "<") {
const end = matchTypeArgs(sc, sc.i);
if (end !== -1) {
sc.emit(word);
sc.prevWord = word;
sc.i = end;
return true;
}
}
sc.i = save;
return false;
}
/**
* Note the bindings an import would have introduced, so the caller can say
* where a missing name was supposed to come from.
*
* Without this, dropping `import { useInView } from 'react-infinite-scroll'`
* turns into "useInView is not defined" at the first *use* — a line nowhere
* near the import, describing a symptom rather than the cause. Parsing is
* deliberately forgiving: models write malformed imports (a missing brace was
* what prompted this), and a half-read name is still worth naming.
*/
function recordImport(sc, statement) {
const quoted = statement.match(/['"]([^'"]+)['"]\s*;?\s*$/);
const module = quoted ? quoted[1] : "";
const clause = statement
.replace(/^\s*import\s*/, "")
.replace(/['"][^'"]*['"]\s*;?\s*$/, "")
.replace(/\bfrom\b/, "");
const names = clause
.replace(/[{}]/g, " ")
.split(",")
.map((part) => part.trim().replace(/^\*\s*as\s+/, "").split(/\s+as\s+/).pop().trim())
.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
if (module || names.length) sc.imports.push({ names, module });
}
function typeDeclarationAhead(sc) {
return /^\s+[A-Za-z_$][\w$]*\s*[<={]/.test(sc.src.slice(sc.i));
}
/**
* Skip to the end of the current statement (semicolon or line end). Nothing is
* emitted, so the scanner's idea of the last significant character has to be
* restored - otherwise a dropped `import ... from "react";` leaves a quote
* behind as `prevSig` and the next statement no longer looks like one.
*/
function skipStatement(sc) {
const sig = sc.prevSig;
const word = sc.prevWord;
while (!sc.eof) {
const ch = sc.peek();
if (ch === ";") { sc.i++; break; }
if (ch === "\n") break;
if (ch === '"' || ch === "'") { const o = sc.out.length; copyString(sc, ch); sc.out.length = o; continue; }
sc.i++;
}
sc.prevSig = sig;
sc.prevWord = word;
}
function skipBalancedBraces(sc) {
const from = sc.i;
const sig = sc.prevSig;
const word = sc.prevWord;
while (!sc.eof && sc.peek() !== "{") sc.i++;
let depth = 0;
while (!sc.eof) {
const ch = sc.peek();
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
sc.i++;
if (depth === 0) {
// A multi-line `interface {...}` would otherwise shift the code below it.
sc.out.push(missingNewlines(sc.src.slice(from, sc.i), ""));
sc.prevSig = sig;
sc.prevWord = word;
return;
}
continue;
}
sc.i++;
}
sc.fail("unterminated type declaration");
}
/**
* Find the `>` closing a generic argument list starting at `from`, or -1 when
* the contents don't look like types at all (then it was a comparison).
*/
function matchTypeArgs(sc, from) {
let depth = 0;
for (let j = from; j < sc.src.length; j++) {
const ch = sc.src[j];
if (ch === "<") depth++;
else if (ch === ">") {
depth--;
if (depth === 0) {
const inner = sc.src.slice(from + 1, j);
if (!TYPE_ARG_CHARS.test(inner)) return -1;
// Only a call or a value position may follow a real generic list.
const after = sc.src.slice(j + 1).match(/^\s*(.)/);
return after && "(;,)]}=".includes(after[1]) ? j + 1 : -1;
}
} else if (ch === "\n" || ch === "{" || ch === ";") return -1;
}
return -1;
}
// ── TypeScript annotations ──────────────────────────────────────────────────
/**
* True when this `:` introduces a type annotation rather than an object-literal
* key, a ternary branch, or a label. Only the positions a small component
* actually uses are recognised; everything else is left alone deliberately.
*/
function annotationAhead(sc) {
// An unresolved `?` claims this `:` for its conditional first.
if (sc.frame.ternaries > 0) { sc.frame.ternaries--; return false; }
const before = sc.src.slice(0, sc.i);
// `const x: T` and a return type `): T` read the same anywhere.
if (/(?:\b(?:const|let|var)\s+[A-Za-z_$][\w$]*|\))\s*$/.test(before)) return true;
// The rest are parameter annotations, and only count inside a parameter
// list. The same shapes inside braces are object literals, where the value
// after `:` must survive.
if (sc.context !== "(") return false;
// `(a: T`, `, b: T`, `(...rest: T`
if (/[(,]\s*(?:\.\.\.)?[A-Za-z_$][\w$]*\??\s*$/.test(before)) return true;
// `({ a, b }: Props`, `([x, y]: T` - a destructured parameter
return /[}\]]\s*\??\s*$/.test(before);
}
function skipTypeAnnotation(sc) {
sc.i++; // the ':'
skipTypeExpression(sc);
sc.prevWord = "";
}
/** Consume a type expression, stopping where the surrounding value resumes. */
function skipTypeExpression(sc) {
let depth = 0;
while (!sc.eof) {
const ch = sc.peek();
if ("({[<".includes(ch)) depth++;
else if (")}]>".includes(ch)) {
if (depth === 0) return; // the enclosing paren/brace, not ours
depth--;
} else if (depth === 0) {
if (ch === "," || ch === ";" || ch === "\n") return;
if (ch === "=" && sc.peek(1) !== ">") return;
if (ch === "{") return;
if (ch === "=" && sc.peek(1) === ">") return;
}
sc.i++;
}
}
function nonNullAssertion(sc) {
const next = sc.peek(1);
if (next === "=" ) return false; // `!=`
return /[\w$)\]]/.test(sc.src[sc.i - 1] ?? ""); // `foo!.bar`, `arr[0]!`
}
// ── JSX ─────────────────────────────────────────────────────────────────────
/** Parse one JSX element (cursor on `<`) and return the equivalent h() call. */
function parseElement(sc) {
sc.i++; // '<'
if (sc.peek() === ">") { // fragment
sc.i++;
const kids = parseChildren(sc, "");
return `h(Fragment,null${kids})`;
}
const name = readTagName(sc);
if (!name) sc.fail("expected a JSX tag name after '<'");
const props = parseAttributes(sc);
const tag = /^[a-z][\w-]*$/.test(name) ? JSON.stringify(name) : name;
if (sc.peek() === "/") { // self-closing
sc.i++;
if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name} />`);
sc.i++;
return `h(${tag},${props})`;
}
if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name}>`);
sc.i++;
const kids = parseChildren(sc, name);
return `h(${tag},${props}${kids})`;
}
function readTagName(sc) {
if (!ID_START.test(sc.peek() ?? "")) return "";
const start = sc.i;
while (!sc.eof && /[\w$.-]/.test(sc.peek())) sc.i++;
return sc.src.slice(start, sc.i);
}
function parseAttributes(sc) {
const parts = [];
for (;;) {
skipSpace(sc);
const ch = sc.peek();
if (ch === undefined) sc.fail("unterminated JSX tag");
if (ch === ">" || ch === "/") break;
if (ch === "{") { // {...spread}
sc.i++;
skipSpace(sc);
if (!sc.src.startsWith("...", sc.i)) sc.fail("expected '...' in a JSX attribute spread");
sc.i += 3;
parts.push(`...${captureExpression(sc, "}")}`);
continue;
}
const name = readAttrName(sc);
if (!name) sc.fail("expected a JSX attribute name");
const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
skipSpace(sc);
if (sc.peek() !== "=") { parts.push(`${key}:true`); continue; }
sc.i++;
skipSpace(sc);
const valueCh = sc.peek();
if (valueCh === '"' || valueCh === "'") {
parts.push(`${key}:${JSON.stringify(readQuoted(sc, valueCh))}`);
} else if (valueCh === "{") {
sc.i++;
parts.push(`${key}:${captureExpression(sc, "}")}`);
} else if (valueCh === "<") {
parts.push(`${key}:${parseElement(sc)}`);
} else {
sc.fail(`unsupported value for JSX attribute '${name}'`);
}
}
return parts.length ? `{${parts.join(",")}}` : "null";
}
function readAttrName(sc) {
const start = sc.i;
while (!sc.eof && /[\w$:.-]/.test(sc.peek())) sc.i++;
return sc.src.slice(start, sc.i);
}
function readQuoted(sc, quote) {
sc.i++;
const start = sc.i;
while (!sc.eof && sc.peek() !== quote) sc.i++;
if (sc.eof) sc.fail("unterminated JSX attribute value");
const text = sc.src.slice(start, sc.i);
sc.i++;
return text;
}
/**
* Capture a braced expression as source, running it through the scanner so
* nested JSX inside it is transformed too. Leaves the cursor past `close`.
*/
function captureExpression(sc, close) {
const inner = new Scanner(sc.src);
inner.i = sc.i;
scanCode(inner, close);
if (inner.eof) sc.fail("unterminated JSX expression");
sc.i = inner.i + 1;
const code = inner.out.join("").trim();
return code === "" ? "undefined" : `(${code})`;
}
function parseChildren(sc, tagName) {
const kids = [];
for (;;) {
if (sc.eof) sc.fail(tagName ? `unclosed <${tagName}>` : "unclosed fragment");
// closing tag?
if (sc.peek() === "<" && sc.peek(1) === "/") {
sc.i += 2;
const closing = readTagName(sc);
skipSpace(sc);
if (sc.peek() !== ">") sc.fail(`expected '>' to close </${closing}>`);
sc.i++;
if (closing !== tagName) {
sc.fail(`</${closing}> does not match <${tagName || ""}>`);
}
break;
}
if (sc.peek() === "<") { kids.push(parseElement(sc)); continue; }
if (sc.peek() === "{") {
sc.i++;
skipSpace(sc);
// `{/* note */}` is a JSX comment - it renders nothing.
if (sc.peek() === "/" && sc.peek(1) === "*") {
const end = sc.src.indexOf("*/", sc.i);
if (end === -1) sc.fail("unterminated JSX comment");
sc.i = end + 2;
skipSpace(sc);
if (sc.peek() !== "}") sc.fail("expected '}' after a JSX comment");
sc.i++;
continue;
}
const expr = captureExpression(sc, "}");
if (expr !== "undefined") kids.push(expr);
continue;
}
const text = readText(sc);
if (text !== null) kids.push(JSON.stringify(text));
}
return kids.length ? `,${kids.join(",")}` : "";
}
/**
* JSX text, with JSX's whitespace rule: runs of whitespace containing a newline
* are layout, not content, so they collapse away at the edges and become a
* single space in the middle.
*/
function readText(sc) {
const start = sc.i;
while (!sc.eof && sc.peek() !== "<" && sc.peek() !== "{") sc.i++;
const raw = sc.src.slice(start, sc.i);
if (raw === "") return null;
const lines = raw.split("\n");
if (lines.length === 1) return raw;
const kept = lines
.map((line, idx) => (idx === 0 ? line.replace(/\s+$/, "") : line.trim()))
.filter((line) => line !== "");
const text = kept.join(" ");
return text === "" ? null : text;
}
@@ -0,0 +1,251 @@
/*
* node --test src/preview/jsx-transform.test.js
*
* The transform runs on model-authored text, so these cases are the shapes a
* model actually emits, plus the ones where a naive scanner silently produces
* code that runs and does the wrong thing (generics read as comparisons, `<` in
* a string read as a tag). Silent-wrong is the failure mode worth testing;
* anything that throws is already visible to the user.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { transform, TransformError } from "./jsx-transform.js";
// Whitespace is normalised for comparison: the transform drops the space a
// stripped annotation sat in (`const x: T = 1` -> `const x= 1`), which is
// invisible to everyone because the Code tab shows the original source, not
// this output.
const js = (src) => transform(src).code.replace(/\s+/g, " ").trim();
// ── elements ────────────────────────────────────────────────────────────────
test("element with no attributes or children", () => {
assert.equal(js("const a = <div />;"), 'const a = h("div",null);');
});
test("lowercase tags become strings, capitalized stay identifiers", () => {
assert.equal(js("<div />"), 'h("div",null)');
assert.equal(js("<App />"), "h(App,null)");
assert.equal(js("<Foo.Bar />"), "h(Foo.Bar,null)");
});
test("string, expression, boolean and hyphenated attributes", () => {
assert.equal(js('<a href="/x" />'), 'h("a",{href:"/x"})');
assert.equal(js("<a n={1 + 2} />"), 'h("a",{n:(1 + 2)})');
assert.equal(js("<input disabled />"), 'h("input",{disabled:true})');
assert.equal(js('<a data-id="7" />'), 'h("a",{"data-id":"7"})');
});
test("attribute spread", () => {
assert.equal(js("<div {...props} id=\"x\" />"), 'h("div",{...(props),id:"x"})');
});
test("children: text, expressions and nesting", () => {
assert.equal(js("<p>hi</p>"), 'h("p",null,"hi")');
assert.equal(js("<p>{name}</p>"), 'h("p",null,(name))');
assert.equal(js("<p>a {b} c</p>"), 'h("p",null,"a ",(b)," c")');
assert.equal(js("<ul><li>x</li></ul>"), 'h("ul",null,h("li",null,"x"))');
});
test("fragments", () => {
assert.equal(js("<><a /><b /></>"), 'h(Fragment,null,h("a",null),h("b",null))');
});
test("JSX nested inside an expression child", () => {
assert.equal(
js("<ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>"),
'h("ul",null,(items.map((i) => h("li",{key:(i)},(i)))))',
);
});
test("JSX comments render nothing", () => {
assert.equal(js("<div>{/* note */}<a /></div>"), 'h("div",null,h("a",null))');
});
test("whitespace-only lines between elements collapse away", () => {
assert.equal(
js("<div>\n <a />\n <b />\n</div>"),
'h("div",null,h("a",null),h("b",null))',
);
});
test("text spanning lines keeps single spaces", () => {
assert.equal(js("<p>\n one\n two\n</p>"), 'h("p",null,"one two")');
});
// ── things that must NOT be treated as JSX ──────────────────────────────────
test("comparisons and arrow bodies are left alone", () => {
assert.equal(js("const t = a < b;"), "const t = a < b;");
assert.equal(js("if (x < 3 && y > 1) {}"), "if (x < 3 && y > 1) {}");
assert.equal(js("const f = (a, b) => a < b;"), "const f = (a, b) => a < b;");
});
test("angle brackets inside strings, templates and regexes survive", () => {
assert.equal(js('const s = "<div>not jsx</div>";'), 'const s = "<div>not jsx</div>";');
assert.equal(js("const s = `a <b> c`;"), "const s = `a <b> c`;");
assert.equal(js("const r = /<[a-z]+>/g;"), "const r = /<[a-z]+>/g;");
});
test("template interpolation can still contain JSX", () => {
assert.equal(js("const s = `${<a />}`;"), "const s = `${h(\"a\",null)}`;");
});
test("division is not mistaken for a regex", () => {
assert.equal(js("const r = (a + b) / 2 / c;"), "const r = (a + b) / 2 / c;");
// A `/` right after a call's closing paren: `)` ends a value, so this is
// division. Treating it as a regex swallowed the rest of the line.
assert.equal(js("const y = Math.sin((i + s) / 6) * 70;"), "const y = Math.sin((i + s) / 6) * 70;");
assert.equal(js("const m = xs[0] / total;"), "const m = xs[0] / total;");
});
test("indexing and calls before < are comparisons, not JSX", () => {
assert.equal(js("if (xs[0] < 3) {}"), "if (xs[0] < 3) {}");
assert.equal(js("while (f(i) < n) { i++; }"), "while (f(i) < n) { i++; }");
});
// ── TypeScript ──────────────────────────────────────────────────────────────
test("parameter and variable annotations are stripped", () => {
assert.equal(js("function f(a: string, b: number) {}"), "function f(a, b) {}");
assert.equal(js("const x: number = 5;"), "const x= 5;");
assert.equal(js("const f = (n: number): string => String(n);"), "const f = (n)=> String(n);");
});
test("interface and type declarations are dropped whole", () => {
assert.equal(js("interface Props { a: string; b?: number }\nconst x = 1;"), "const x = 1;");
assert.equal(js("type Id = string | number;\nconst x = 1;"), "const x = 1;");
});
test("generic call arguments are stripped, not read as comparisons", () => {
// The silent-wrong case: `useState<number>(0)` is valid JS meaning
// `(useState < number) > (0)`, so getting this wrong yields a boolean.
assert.equal(js("const [n, setN] = useState<number>(0);"), "const [n, setN] = useState(0);");
assert.equal(js("useRef<HTMLCanvasElement | null>(null);"), "useRef(null);");
});
test("as-casts and non-null assertions are stripped", () => {
assert.equal(js("const el = x as HTMLElement;"), "const el = x ;");
assert.equal(js("const v = raw as const;"), "const v = raw ;");
assert.equal(js("ref.current!.focus();"), "ref.current.focus();");
});
test("optional parameters keep their default values", () => {
assert.equal(js("function f(a: number = 3) { return a; }"), "function f(a= 3) { return a; }");
});
test("object literals and ternaries are not mistaken for annotations", () => {
assert.equal(js("const o = { a: 1, b: 'two' };"), "const o = { a: 1, b: 'two' };");
assert.equal(js("const v = c ? 1 : 2;"), "const v = c ? 1 : 2;");
assert.equal(js("el.style = { color: 'red' };"), "el.style = { color: 'red' };");
// Object literals passed as arguments sit inside a parameter list, where
// parameter annotations also live.
assert.equal(js("f({ a: 1, b: 2 });"), "f({ a: 1, b: 2 });");
assert.equal(js("ctx.fillRect(x, y, { w: 1 });"), "ctx.fillRect(x, y, { w: 1 });");
});
test("ternaries inside a call keep their else-branch", () => {
// Both of these end in `}` or `)` before the `:`, exactly like a destructured
// parameter annotation and a return type - only the pending `?` tells them apart.
assert.equal(js("f(cond ? { a: 1 } : { b: 2 });"), "f(cond ? { a: 1 } : { b: 2 });");
assert.equal(js("f(cond ? g() : h2());"), "f(cond ? g() : h2());");
assert.equal(js("const s = ok ? 'y' : 'n';"), "const s = ok ? 'y' : 'n';");
});
test("destructured parameter annotations are stripped", () => {
assert.equal(js("function C({ start }: Props) {}"), "function C({ start }) {}");
assert.equal(js("const f = ({ a, b }: P) => a + b;"), "const f = ({ a, b }) => a + b;");
assert.equal(js("function g([x, y]: Pair) {}"), "function g([x, y]) {}");
});
// ── modules ─────────────────────────────────────────────────────────────────
test("imports are dropped - the sandbox has no module loader", () => {
assert.equal(js('import React, { useState } from "react";\nconst x = 1;'), "const x = 1;");
assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
});
test("dropped imports report the bindings they would have provided", () => {
// So a missing name can say where it was supposed to come from, instead of
// surfacing as "useInView is not defined" at its first use.
const { imports } = transform(
'import React, { useState, useEffect } from "react";\n' +
'import { useInView } from "react-infinite-scroll";\n' +
'import * as d3 from "d3";\nconst x = 1;',
);
assert.deepEqual(imports, [
{ names: ["React", "useState", "useEffect"], module: "react" },
{ names: ["useInView"], module: "react-infinite-scroll" },
{ names: ["d3"], module: "d3" },
]);
});
test("a malformed import still yields its name and module", () => {
// Straight from a transcript: a missing closing brace. The import is dropped
// either way, so the binding it meant to create is what matters.
const { imports } = transform("import { useInView from 'react-infinite-scroll';\nconst x = 1;");
assert.deepEqual(imports, [{ names: ["useInView"], module: "react-infinite-scroll" }]);
});
test("export default names the mount target", () => {
assert.equal(transform("export default function App() {}").defaultExport, "App");
assert.equal(transform("function A() {}\nexport default A;").defaultExport, "A");
assert.equal(js("export default function App() {}"), "function App() {}");
});
test("named exports are unwrapped", () => {
assert.equal(js("export const x = 1;"), "const x = 1;");
assert.equal(js("export function Chart() {}"), "function Chart() {}");
});
// ── failure is loud ─────────────────────────────────────────────────────────
test("mismatched closing tag throws with a line number", () => {
assert.throws(() => transform("<div>\n<span>x</div>"), (e) =>
e instanceof TransformError && /does not match/.test(e.message) && /line 2/.test(e.message));
});
test("unterminated element throws", () => {
assert.throws(() => transform("const a = <div>"), TransformError);
});
// ── a whole component, end to end ───────────────────────────────────────────
test("a realistic component transforms to runnable JS", () => {
const src = `
import { useState } from "react";
interface Props { start: number }
export default function Counter({ start }: Props) {
const [n, setN] = useState<number>(start);
return (
<div className="box">
<button onClick={() => setN(n + 1)}>+1</button>
<span>{n} clicks</span>
{n > 3 && <em>many!</em>}
</div>
);
}`;
const out = transform(src);
assert.equal(out.defaultExport, "Counter");
assert.match(out.code, /function Counter\(\{ start \}\)/);
assert.match(out.code, /useState\(start\)/);
assert.match(out.code, /h\("button",\{onClick:\(\(\) => setN\(n \+ 1\)\)\},"\+1"\)/);
assert.doesNotMatch(out.code, /interface|import|: Props|<number>/);
// The real proof: it parses as JS.
assert.doesNotThrow(() => new Function(out.code));
});
test("output of every element case parses as JS", () => {
for (const src of [
"<div />",
"<a href=\"/x\">link</a>",
"<><p>a</p><p>b</p></>",
"const v = <ul>{xs.map((x) => <li key={x}>{x}</li>)}</ul>;",
"const v = <Foo {...p} n={1} on={() => f(`${x}`)} />;",
]) {
const { code } = transform(src);
assert.doesNotThrow(() => new Function("h", "Fragment", "xs", "p", "x", "f", code), src);
}
});
+99
View File
@@ -0,0 +1,99 @@
/*
* languages.js — what the render window can preview, one entry per language.
*
* Each entry turns a fence's contents into the <body> of the sandboxed frame:
*
* toBody(value) -> { html, userOffset }
*
* `userOffset` is how many lines of that body come before the user's own code.
* The frame reports runtime errors by line number and those numbers are
* document-relative, so without this an error in a JSX component would be
* reported at some line deep inside the inlined Preact build. The caller adds
* the lines of document shell above the body and hands the total to the
* bootstrap, which subtracts it before reporting.
*
* A `toBody` may throw: JSX that doesn't parse has no preview to show. The
* caller catches and shows the message in place of the frame.
*
* The backend keeps a matching registry (PREVIEW_LANGS in synapse/tools.py)
* that says how each language is *validated* rather than rendered. Neither
* depends on the other at runtime; tests/test_tools.py asserts the key sets
* stay equal.
*/
import { transform } from "./jsx-transform.js";
import { PREACT_RUNTIME } from "./runtime.js";
const countNewlines = (text) => (text.match(/\n/g) || []).length;
/** Markup languages: the fence is already a document body. */
const markup = (value) => ({ html: value, userOffset: 0 });
/**
* Pick what to mount. An explicit default export wins, then a component named
* App, then the last capitalized declaration - models tend to define helpers
* first and the thing they were asked for last.
*/
function mountTarget({ defaultExport, components }) {
if (defaultExport) return defaultExport;
if (components.includes("App")) return "App";
if (components.length) return components[components.length - 1];
throw new Error(
"No component found to render. Name one `App`, or `export default` it.",
);
}
/**
* One line of stubs for every binding an import would have provided.
*
* Imports are dropped — there is no module loader in the sandbox — so a name
* that came from a package is simply missing, and the first use of it reports
* "useInView is not defined" at a line far from the import that explains it.
* Each stub throws with the module name instead, and `||` means anything the
* runtime already provides (useState and friends) keeps its real implementation.
*/
function importStubs(imports) {
const names = new Map();
for (const { names: bound, module } of imports || []) {
for (const name of bound) if (!names.has(name)) names.set(name, module);
}
if (!names.size) return "";
const lines = [...names].map(([name, module]) => {
const why = JSON.stringify(
`${name} came from ${module ? `"${module}"` : "an import"}, which the preview ` +
"cannot load — it has no module loader and no network. Inline what you need, " +
"or use the built-in hooks, which are already in scope.",
);
return `window[${JSON.stringify(name)}] = window[${JSON.stringify(name)}] ` +
`|| function () { throw new Error(${why}); };`;
});
return `<script>${lines.join("")}</script>\n`;
}
function jsxBody(value) {
const result = transform(value);
const target = mountTarget(result);
const head =
'<div id="root"></div>\n' +
`<script>${PREACT_RUNTIME}</script>\n` +
importStubs(result.imports) +
"<script>\n";
return {
html:
head +
result.code +
`\n;render(h(${target}, null), document.getElementById("root"));\n` +
"</script>",
userOffset: countNewlines(head),
};
}
export const PREVIEW_LANGS = {
html: { toBody: markup },
svg: { toBody: markup },
jsx: { toBody: jsxBody },
tsx: { toBody: jsxBody },
};
export const RENDERABLE_LANGS = new Set(Object.keys(PREVIEW_LANGS));
+42
View File
@@ -0,0 +1,42 @@
/*
* runtime.js — the JS a JSX preview needs in scope, as a string.
*
* It has to be a string because the preview frame is on an opaque origin: it
* cannot fetch this app's assets, and it cannot read a blob: URL the parent
* created either. Anything a preview needs must be handed to it as bytes,
* which is what makes payload size the real currency here.
*
* Preact rather than React for exactly that reason - ~15 KB of UMD against
* ~140 KB, per preview. The alternative of re-rendering the whole tree on every
* state change and skipping the vdom entirely was rejected on behaviour, not
* size: it would wipe <canvas> contents on each update, and canvas is what most
* of these previews draw into.
*/
// Imported by file path, not by package specifier: preact's exports map puts
// the UMD builds behind a "umd" condition that a bundler targeting ESM never
// asks for, so `preact/dist/preact.umd.js` does not resolve. UMD is what we
// want here precisely because it has no module system - it assigns globals when
// loaded as a plain <script>, which is all the sandbox can offer it.
import preactSrc from "../../node_modules/preact/dist/preact.umd.js?raw";
import hooksSrc from "../../node_modules/preact/hooks/dist/hooks.umd.js?raw";
// Both UMD builds fall back to a global (`preact`, `preactHooks`) when there is
// no module system, which is the case inside an inline <script>. This lifts
// what transformed JSX expects - h/Fragment/render and the hooks - to bare
// globals, and mirrors them onto `React` so a model that writes React.useState
// or forgets to remove its import still works.
const GLUE = `
;(function (p, hooks) {
window.h = p.h;
window.Fragment = p.Fragment;
window.render = p.render;
window.createElement = p.h;
for (var k in hooks) window[k] = hooks[k];
window.React = Object.assign({}, p, hooks, { createElement: p.h, Fragment: p.Fragment });
window.ReactDOM = { render: function (v, el) { p.render(v, el); }, createRoot: function (el) {
return { render: function (v) { p.render(v, el); } };
} };
})(preact, preactHooks);
`;
export const PREACT_RUNTIME = `${preactSrc}\n${hooksSrc}\n${GLUE}`;