refactor(preview): simplify compile and validation paths

Load Sucrase only when a JSX/TSX preview is opened, remove the hand-written transform, and leave subjective render evaluation to the reader while retaining structural fence validation.
This commit is contained in:
2026-08-26 03:37:29 -05:00
parent 7262e7730e
commit 952ef8a0c4
10 changed files with 399 additions and 1981 deletions
+101 -8
View File
@@ -10,7 +10,8 @@
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"sucrase": "^3.35.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -528,7 +529,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -550,7 +550,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -560,14 +559,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -994,6 +991,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1134,6 +1137,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1444,7 +1456,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2016,6 +2027,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2069,6 +2086,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -2105,6 +2133,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2199,7 +2236,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2208,6 +2244,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/postcss": {
"version": "8.5.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
@@ -2402,6 +2447,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2415,11 +2482,31 @@
"node": ">=8"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2432,6 +2519,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+2 -1
View File
@@ -16,7 +16,8 @@
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"sucrase": "^3.35.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+16 -3
View File
@@ -262,13 +262,13 @@ const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__";
* 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) {
async 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);
body = await entry.toBody(value);
} catch (e) {
return { doc: null, error: e && e.message ? e.message : String(e) };
}
@@ -404,6 +404,8 @@ function RenderBlock({ lang, value, streaming }) {
// it by markup - a fresh document then gets fresh state by remounting.
function PreviewFrame({ lang, value, expanded }) {
const [error, setError] = useState("");
const [doc, setDoc] = useState("");
const [buildError, setBuildError] = useState("");
const [height, setHeight] = useState(240);
const frameRef = useRef(null);
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
@@ -433,9 +435,20 @@ function PreviewFrame({ lang, value, expanded }) {
return () => window.removeEventListener("message", onMessage);
}, []);
useEffect(() => {
let current = true;
setDoc("");
setBuildError("");
buildSrcDoc(lang, value).then((result) => {
if (!current) return;
setDoc(result.doc || "");
setBuildError(result.error || "");
});
return () => { current = false; };
}, [lang, value]);
// 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 frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
const shown = buildError || error;
+48 -721
View File
@@ -1,731 +1,58 @@
/*
* jsx-transform.js — JSX/TSX in, plain JS out. No dependencies.
* JSX/TSX compiler adapter.
*
* 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.
* JSX and TypeScript are parsed by Sucrase rather than by preview-specific
* lexer code. The dependency is dynamically imported so ordinary chat and
* HTML/SVG previews do not download the compiler chunk. Only this small adapter
* stays in the main bundle.
*
* 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.
* Sucrase's CommonJS transform is intentional: a preview frame has no module
* loader or network access, but languages.js can provide local React/Preact
* modules through a tiny `require` shim. Unsupported imports then fail loudly
* at evaluation time with the package name that cannot be loaded.
*/
export class TransformError extends Error {
constructor(message, options) {
super(message, options);
this.name = "TransformError";
}
}
/**
* Find fallback component declarations for model output that omits an export.
*
* 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).
* This is deliberately not syntax transformation. Sucrase owns all parsing;
* these names only form guarded `typeof Name !== "undefined"` mount choices.
* A false match is therefore ignored at runtime. Default exports and App take
* precedence, so this compatibility fallback is used only for a bare component
* such as `function Counter() { ... }`.
*/
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 componentCandidates(source) {
const names = [];
const declarations = /\b(?:function|class|const|let|var)\s+([A-Z][$\w]*)/g;
for (const match of source.matchAll(declarations)) {
if (!names.includes(match[1])) names.push(match[1]);
}
return names;
}
function countNewlines(text) {
let n = 0;
for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
return n;
}
/** Compile a self-contained JSX/TSX component into browser-ready CommonJS. */
export async function transform(source) {
const input = String(source ?? "");
/** 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})`);
try {
const { transform: compile } = await import("sucrase");
const { code } = compile(input, {
transforms: ["typescript", "jsx", "imports"],
jsxPragma: "h",
jsxFragmentPragma: "Fragment",
production: true,
filePath: "preview.tsx",
});
return { code, components: componentCandidates(input) };
} catch (error) {
const detail = error && error.message ? error.message : String(error);
throw new TransformError(`Could not compile JSX/TSX: ${detail}`, { cause: error });
}
}
/** 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;
skipImportStatement(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;
}
/**
* Drop a static import, including the common multiline named-import form.
* A bare newline before the module string is part of the import; one after it
* ends a semicolon-free import. Semicolons always win so a malformed import
* (for example a missing `}`) cannot swallow the rest of the component.
*/
function skipImportStatement(sc) {
const from = sc.i;
const sig = sc.prevSig;
const word = sc.prevWord;
let sawModule = false;
while (!sc.eof) {
const ch = sc.peek();
if (ch === ";") { sc.i++; break; }
if (ch === "\n" && sawModule) break;
if (ch === '"' || ch === "'") {
const outLength = sc.out.length;
copyString(sc, ch);
sc.out.length = outLength;
sawModule = true;
continue;
}
if (ch === "/" && sc.peek(1) === "/") {
const outLength = sc.out.length;
copyLineComment(sc);
sc.out.length = outLength;
continue;
}
if (ch === "/" && sc.peek(1) === "*") {
const outLength = sc.out.length;
copyBlockComment(sc);
sc.out.length = outLength;
continue;
}
sc.i++;
}
// Keep runtime error line numbers aligned with the source shown in Code.
sc.out.push(missingNewlines(sc.src.slice(from, 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;
}
+124 -242
View File
@@ -1,265 +1,147 @@
/*
* 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();
async function compile(source) {
return transform(source);
}
// ── elements ────────────────────────────────────────────────────────────────
function assertRunnable(code) {
assert.doesNotThrow(() => new Function(
"module", "exports", "require", "h", "Fragment", code,
));
}
test("element with no attributes or children", () => {
assert.equal(js("const a = <div />;"), 'const a = h("div",null);');
test("compiles elements, attributes, spreads, children, and fragments", async () => {
const { code } = await compile(`
const view = <>
<section {...props} data-id="7">
<button disabled onClick={() => go()}>go {name}</button>
</section>
</>;
`);
assertRunnable(code);
assert.match(code, /h\(Fragment/);
assert.match(code, /h\('section'/);
assert.doesNotMatch(code, /<section/);
});
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("compiles nested JSX inside expression children", async () => {
const { code } = await compile(
"const view = <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;",
);
assertRunnable(code);
assert.match(code, /items\.map/);
assert.doesNotMatch(code, /<li/);
});
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("does not confuse comparisons with JSX", async () => {
const { code } = await compile(
"if (xs[0] < 3 && f(i) < n) { const less = a < b; }",
);
assertRunnable(code);
assert.match(code, /xs\[0\] < 3/);
assert.match(code, /a < b/);
});
test("attribute spread", () => {
assert.equal(js("<div {...props} id=\"x\" />"), 'h("div",{...(props),id:"x"})');
test("does not confuse division with a regular expression", async () => {
const { code } = await compile(
"const y = Math.sin((i + s) / 6) * 70; const m = xs[0] / total;",
);
assertRunnable(code);
assert.match(code, /\(i \+ s\) \/ 6/);
assert.match(code, /xs\[0\] \/ total/);
});
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("preserves angle brackets and slashes in literals", async () => {
const { code } = await compile(
'const s = "<div>not jsx</div>"; const t = `a <b> c`; const r = /<[a-z]+>/g;',
);
assertRunnable(code);
assert.match(code, /not jsx/);
assert.match(code, /\/<\[a-z\]\+>\/g/);
});
test("fragments", () => {
assert.equal(js("<><a /><b /></>"), 'h(Fragment,null,h("a",null),h("b",null))');
test("strips TypeScript annotations, declarations, generics, and assertions", async () => {
const { code } = await compile(`
interface Props { start: number }
type Pair = [number, number];
function f({ start }: Props, pair: Pair): number {
const ref = useRef<HTMLCanvasElement | null>(null);
return (pair[0] as number) + ref.current!.width + start;
}
`);
assertRunnable(code);
assert.doesNotMatch(code, /interface Props|type Pair|: Props|HTMLCanvasElement|as number|current!/);
});
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("keeps object literals, destructuring, and ternaries intact", async () => {
const { code } = await compile(
"const f = ({a, b}: Props) => ok ? {value: a} : {value: b};",
);
assertRunnable(code);
assert.match(code, /ok \? \{value: a\} : \{value: b\}/);
});
test("handles TSX generic arrow functions without treating them as elements", async () => {
const { code } = await compile(
"const identity = <T,>(value: T): T => value; const view = <p>{identity(3)}</p>;",
);
assertRunnable(code);
assert.match(code, /identity = \s*\(value\) => value/);
});
test("converts imports and exports to CommonJS for the frame shim", async () => {
const { code } = await compile(`
import React, { useState } from "react";
export default function App() { const [n] = useState(0); return <p>{n}</p>; }
`);
assertRunnable(code);
assert.match(code, /require\(['"]react['"]\)/);
assert.match(code, /exports\.default = App/);
assert.doesNotMatch(code, /export default|<p>/);
});
test("keeps unsupported package names in generated require calls", async () => {
const { code } = await compile(
'import { motion } from "framer-motion"; export default () => <motion.div />;',
);
assert.match(code, /require\(['"]framer-motion['"]\)/);
});
test("records fallback component declarations without choosing a mount target", async () => {
const result = await compile(`
function Helper() { return null; }
const Counter = () => <button>count</button>;
`);
assert.deepEqual(result.components, ["Helper", "Counter"]);
});
test("compiles a realistic stateful component end to end", async () => {
const result = await compile(`
import { useState } from "react";
interface Props { start: number }
export default function Counter({ start }: Props) {
const [n, setN] = useState<number>(start);
return <button onClick={() => setN(n + 1)}>{n} clicks</button>;
}
`);
assertRunnable(result.code);
assert.match(result.code, /function Counter\(\{ start \}\)/);
assert.match(result.code, /useState\(start\)/);
assert.doesNotMatch(result.code, /interface|: Props|<number>|<button/);
});
test("reports malformed JSX as a TransformError", async () => {
await assert.rejects(
() => compile("const view = <div>\n<span>x</div>;"),
(error) => error instanceof TransformError && /compile JSX\/TSX/.test(error.message),
);
});
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("reports malformed TypeScript as a TransformError", async () => {
await assert.rejects(
() => compile("interface Props { value: string"),
TransformError,
);
});
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("multiline named imports are dropped as one statement", () => {
const source = `import {
useState,
useEffect,
} from "react";
const x = 1;`;
const out = transform(source);
assert.equal(out.code, "\n\n\n\nconst x = 1;");
assert.deepEqual(out.imports, [
{ names: ["useState", "useEffect"], module: "react" },
]);
assert.doesNotThrow(() => new Function(out.code));
});
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);
}
});
+33 -46
View File
@@ -3,7 +3,7 @@
*
* Each entry turns a fence's contents into the <body> of the sandboxed frame:
*
* toBody(value) -> { html, userOffset }
* await 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
@@ -16,9 +16,8 @@
* 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.
* for tool descriptions and language tags. 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";
@@ -29,61 +28,49 @@ const countNewlines = (text) => (text.match(/\n/g) || []).length;
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.
* Build the mount expression. 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.",
function mountExpression(components) {
const names = ["App", ...components.slice().reverse()]
.filter((name, index, all) => all.indexOf(name) === index);
const lexical = names.map(
(name) => `(typeof ${name} !== "undefined" ? ${name} : null)`,
);
return [
"module.exports.default",
"module.exports.App",
...lexical,
"Object.values(module.exports).find((value) => typeof value === 'function')",
].join(" || ");
}
/**
* 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);
async function jsxBody(value) {
const result = await transform(value);
const target = mountExpression(result.components);
const head =
'<div id="root"></div>\n' +
`<script>${PREACT_RUNTIME}</script>\n` +
importStubs(result.imports) +
"<script>\n";
"<script>\n" +
"const module = { exports: {} }; const exports = module.exports;\n" +
"const require = (name) => {\n" +
" const modules = { react: React, 'react-dom': ReactDOM, preact, 'preact/hooks': preactHooks };\n" +
" if (Object.prototype.hasOwnProperty.call(modules, name)) return modules[name];\n" +
" throw new Error(`Cannot import '${name}' — the preview has no module loader or network.`);\n" +
"};\n";
return {
html:
head +
result.code +
`\n;render(h(${target}, null), document.getElementById("root"));\n` +
`\n;const __NexusComponent = ${target};\n` +
"if (!__NexusComponent) throw new Error(" +
"'No component found to render. Name one `App`, or `export default` it.');\n" +
"const __NexusView = typeof __NexusComponent === 'function' " +
"? h(__NexusComponent, null) : __NexusComponent;\n" +
"render(__NexusView, document.getElementById('root'));\n" +
"</script>",
userOffset: countNewlines(head),
};
+4 -113
View File
@@ -139,7 +139,6 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
pending_approvals: Dict[str, Dict[str, Any]] = {}
_APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all"
def _as_tool_calls(obj) -> list:
"""Normalize a parsed JSON value into Ollama-style tool_calls entries."""
if isinstance(obj, list):
@@ -208,27 +207,6 @@ def _coerce_tool_calls(msg: dict, allowed_names: set[str] | None = None) -> list
return []
_VISUAL_HINTS = _tools._RENDER_HINTS
def _render_nudge_text() -> str:
"""The one retry given to a model that ignored render_preview on a visual ask.
It arrives as a *user* turn, which means the model answers whatever it says.
Earlier wording pointed at "THIS user request" — a thing the model cannot
see — and offered "if the user's term is unclear, ask them to clarify". It
took both: two transcripts answered with "please provide the user's request
for the rendering" and nothing else. So this says only what to do next, with
no dangling reference and no escape hatch, and it names the languages the
render window actually supports rather than a hardcoded pair."""
return (
f"Use the render_preview tool now. Send complete {_tools._lang_prose()} "
f"markup drawn on a {_tools._STAGE_W}x{_tools._STAGE_H} stage, with the "
"values computed into an array and plotted point by point. Do not write "
"a ``` fence yourself."
)
def _strip_internal_turns(messages: list) -> list:
"""Flatten tool-loop messages for the final, tool-free streaming turn.
@@ -236,15 +214,13 @@ def _strip_internal_turns(messages: list) -> list:
the tools schema isn't re-sent. Their content must not go with them, though:
search/memory/document results are the reason the loop ran. Preserve those
results as an explicitly untrusted user-context turn immediately before the
real request, while dropping assistant tool-call envelopes and the synthetic
render nudge. Keeping the real request last also prevents the model from
answering the nudge or treating a tool result as the user's question."""
nudge = _render_nudge_text()
real request, while dropping assistant tool-call envelopes. Keeping the real
request last prevents the model from treating a tool result as the user's
question."""
kept = [
m for m in messages
if m.get("role") != "tool"
and not m.get("tool_calls")
and m.get("content") != nudge
]
results = [
str(m.get("content") or "")
@@ -270,35 +246,6 @@ def _strip_internal_turns(messages: list) -> list:
return kept
def _should_nudge_render(messages: list, tool_schemas: list | None) -> bool:
"""True when render_preview is available, unused, and the user asked for a visual."""
names = {
(s.get("function") or {}).get("name")
for s in (tool_schemas or [])
if isinstance(s, dict)
}
if "render_preview" not in names:
return False
for m in messages:
if m.get("role") == "assistant":
for c in (m.get("tool_calls") or []):
if (c.get("function") or {}).get("name") == "render_preview":
return False
if m.get("role") == "tool":
try:
body = _json.loads(m.get("content") or "")
if isinstance(body, dict) and ("fence" in body or "issues" in body):
return False
except Exception:
pass
user = ""
for m in reversed(messages):
if m.get("role") == "user":
user = (m.get("content") or "").lower()
break
return _tools.wants_render_preview(user)
async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu,
conversation_id="", policy="allow"):
"""Let the model call tools before the final streamed answer.
@@ -317,8 +264,6 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
# Let the UI show activity immediately — the first tool-turn is a full
# non-stream generation and can sit silent for a long time otherwise.
yield "__status__tools"
nudged_render = False
render_rejects = 0
allowed_names = {
(schema.get("function") or {}).get("name")
for schema in (tool_schemas or [])
@@ -333,11 +278,6 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
break # None/error or no tool support -> fall back to plain stream
calls = _coerce_tool_calls(msg, allowed_names)
if not calls:
# One retry: small models often skip render_preview on visual asks.
if not nudged_render and _should_nudge_render(messages, tool_schemas):
nudged_render = True
messages.append({"role": "user", "content": _render_nudge_text()})
continue
break
# Normalize content-JSON tool calls into the shape later turns expect.
if not msg.get("tool_calls"):
@@ -380,50 +320,19 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
continue
yield f"__status__{name}"
call_args = fn.get("arguments")
# Tell render_preview how many times it has already turned this
# model away. It withholds its scaffold on a first rejection - a
# complete, styled demo handed to a struggling model gets pasted
# rather than adapted, and then persists into the conversation as a
# template for later requests.
if name == "render_preview" and isinstance(call_args, dict):
call_args = {**call_args, "_attempt": render_rejects}
result = await _tools.dispatch(name, call_args)
messages.append({"role": "tool", "content": result})
# Cap render_preview reject loops — each retry is another full
# non-stream generation and looks like the UI is "stuck thinking".
if name == "render_preview":
try:
body = _json.loads(result)
except Exception:
body = {}
if isinstance(body, dict) and body.get("ok") is False:
render_rejects += 1
if render_rejects >= 2:
stop_after = True
elif isinstance(body, dict) and body.get("ok") is True:
if isinstance(body, dict) and body.get("ok") is True:
# Good fence in hand — let the model write the reply next.
stop_after = True
if stop_after:
break
def _last_ok_render_fence(messages: list) -> tuple[str | None, dict]:
"""Return (fence, tool_payload) from the latest successful render_preview."""
for m in reversed(messages or []):
if m.get("role") != "tool":
continue
try:
body = _json.loads(m.get("content") or "")
except Exception:
continue
if not isinstance(body, dict) or not body.get("ok"):
continue
fence = str(body.get("fence") or "").strip()
if fence.startswith("```"):
return fence, body
return None, {}
# -------------------------
# Streaming implementation
# -------------------------
@@ -471,24 +380,6 @@ async def stream_chat_response(
except Exception:
_logger.exception("tool loop failed; streaming without tools")
# If render_preview already produced a validated fence, emit it ourselves.
# Small models often "paste" a rewritten, broken copy that never runs in Preview.
forced_fence, render_meta = _last_ok_render_fence(messages)
if forced_fence:
label = render_meta.get("title") or "Interactive preview"
reply = f"{label}:\n\n{forced_fence}\n"
_logger.info(
"stream_chat_response: emitting validated render fence (chars=%d)",
len(forced_fence),
)
_synapse_trace(f"\n── TURN [{model} | render fence] {'' * 30}\n")
_synapse_trace(f"USR: {user_message}\n{'' * 50}\n")
_synapse_trace(reply.replace("\n", " ")[:500] + "\n")
step = 64
for i in range(0, len(reply), step):
yield reply[i:i + step]
return
messages = _strip_internal_turns(messages)
_logger.info("stream_chat_response: starting stream (model=%s, turns=%d, timeout=%s)", model, len(messages), timeout)
+1 -1
View File
@@ -72,7 +72,7 @@ _MEMORY_PREAMBLE = (
# Static capability hint, appended to every system prompt. The live Preview UI
# is frontend-only (Markdown.jsx); the model reaches it by calling the standing
# `render_preview` tool (structured markup in, validated fence out) rather than
# `render_preview` tool (structured markup in, packaged fence out) rather than
# freestyling an empty ```html stub. The tool schema carries the detailed
# requirements; this preamble just points at it.
# See synapse/tools.py: keep this short and imperative for the same reason the
+25 -555
View File
@@ -215,473 +215,24 @@ async def _list_files(pattern: str = "", **_) -> str:
return json.dumps(sorted(hits))
# Canvas drawing APIs a real visualization must use — resizing width/height alone
# clears the buffer and draws nothing (a failure mode small models hit often).
_CANVAS_DRAW_APIS = (
"fillrect", "strokerect", "filltext", "stroketext", "lineto", "arc(",
"beziercurveto", "quadraticcurveto", "fill(", "stroke(", "putimagedata",
"drawimage", "ellips(",
)
# Rejection threshold for a preview stage. Tiny 40×40 tiles (a recurring
# small-model collapse, often copied from earlier demos) can't show a sequence.
#
# These numbers are a threshold, never advice: every message that mentions a
# size quotes _STAGE_W/_STAGE_H instead. Weak models copy the first dimensions
# they read, so a message saying "at least 320x200, prefer 480x280" reliably
# produces 320x200 — three separate transcripts landed on exactly the minimum,
# including one that had a 480x280 example in front of it. Name one size.
_MIN_CANVAS_W = 320
_MIN_CANVAS_H = 200
# The size to ask for, and the only one any message should mention.
_STAGE_W = 480
_STAGE_H = 280
# Domain-agnostic interactive shell returned on reject as a *pattern* to adapt —
# not a finished demo for any particular algorithm. The model must implement
# generate() for the user's request (or ask them to clarify first).
_INTERACTIVE_SCAFFOLD_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{margin:0;font:14px/1.4 system-ui,sans-serif;background:#111;color:#eee;padding:12px}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
input,button{font:inherit;padding:6px 10px}
canvas{display:block;width:480px;max-width:100%;height:auto;background:#1a1a1a;border:1px solid #333}
</style></head><body>
<div class="row">
<label>n <input id="n" type="number" min="1" value="20"></label>
<button id="go">Plot</button>
<span id="meta"></span>
</div>
<canvas id="c" width="480" height="280"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
/** Return an array of numbers (or {x,y} points) for THIS demo. */
function generate(n) {
// TODO: implement the user's algorithm / data here. Do not leave empty.
const seq = [];
for (let i = 0; i < n; i++) seq.push(i); // placeholder replace
return seq;
}
function plot(seq) {
if (!seq || seq.length < 2) return;
const vals = seq.map(v => (typeof v === 'number' ? v : v.y));
const max = Math.max(1, ...vals);
const w = canvas.width, h = canvas.height, pad = 16;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
seq.forEach((v, i) => {
const x = pad + i * ((w - 2 * pad) / Math.max(1, seq.length - 1));
const y = h - pad - ((typeof v === 'number' ? v : v.y) / max) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
document.getElementById('meta').textContent = seq.length + ' points · max ' + max;
}
function go() {
plot(generate(+document.getElementById('n').value || 20));
}
document.getElementById('go').onclick = go;
go();
</script></body></html>"""
def _wants_data_visual(purpose: str = "", title: str = "", markup: str = "") -> bool:
"""True when the submission claims to be a chart/plot/interactive visual."""
blob = f"{purpose} {title} {markup}".lower()
return any(k in blob for k in (
"plot", "chart", "graph", "visual", "sequence", "orbit", "interactive",
"demo", "canvas", "diagram", "animation", "simulate", "conjecture",
))
# Component counterpart to _INTERACTIVE_SCAFFOLD_HTML, handed back when a
# jsx/tsx submission is rejected. Same contract: a pattern to adapt, not a demo
# to paste. No imports — the preview puts hooks and h/render in scope already,
# and there is no module loader in the sandbox to satisfy an import anyway.
_INTERACTIVE_SCAFFOLD_JSX = """export default function App() {
const canvasRef = useRef(null);
const [n, setN] = useState(20);
/** Return an array of numbers for THIS demo. */
function generate(count) {
// TODO: implement the user's algorithm / data here. Do not leave empty.
const seq = [];
for (let i = 0; i < count; i++) seq.push(i); // placeholder replace
return seq;
}
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const seq = generate(n);
const max = Math.max(1, ...seq);
const w = canvas.width, h = canvas.height, pad = 16;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
seq.forEach((v, i) => {
const x = pad + i * ((w - 2 * pad) / Math.max(1, seq.length - 1));
const y = h - pad - (v / max) * (h - 2 * pad);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
}, [n]);
return (
<div style={{ font: '14px system-ui', background: '#111', color: '#eee', padding: 12 }}>
<label>n <input type="number" value={n} onInput={(e) => setN(+e.target.value || 1)} /></label>
<canvas ref={canvasRef} width="480" height="280" style={{ display: 'block', background: '#1a1a1a' }} />
</div>
);
}"""
def _wants_chart(purpose: str = "", title: str = "", markup: str = "") -> bool:
"""True only when the submission claims to draw *data* — a narrower test
than _wants_data_visual, which also counts "interactive" and "demo".
That wider net is right for an HTML fence, where an interactive demo with no
canvas is usually a model writing prose and calling it a visualization. It
is wrong for a component fence: a JSX counter or form is interactive through
its own elements and state, and demanding a <canvas> of it would reject the
most ordinary thing JSX is for."""
blob = f"{purpose} {title} {markup}".lower()
return any(k in blob for k in (
"plot", "chart", "graph", "visualiz", "diagram", "sequence", "orbit",
"histogram", "scatter",
))
def _decorative_svg_not_plot(markup: str) -> bool:
"""True when SVG is present but doesn't encode a multi-point data chart."""
import re
lower = markup.lower()
if "<svg" not in lower:
return False
# <defs> holds definitions, not output — nothing in it is drawn unless a
# <use>/fill references it. A long path parked in there was passing as proof
# of a real chart while the preview rendered an empty box.
if "<defs" in lower and not re.search(r"<use\b|url\(#", lower):
lower = re.sub(r"<defs\b.*?</defs\s*>", " ", lower, flags=re.S)
rich_poly = bool(re.search(
r"<polyline\b[^>]*\bpoints\s*=\s*[\"'][^\"']{40,}", lower,
))
rich_path = bool(re.search(
r"<path\b[^>]*\bd\s*=\s*[\"'][^\"']{40,}", lower,
))
builds_plot = bool(
re.search(r"createelementns\s*\(", lower)
and ("polyline" in lower or "path" in lower or "line" in lower)
and any(k in lower for k in ("foreach", "for (", "for(", "while(", "while ("))
and any(k in lower for k in ("seq", "points", "push(", "data"))
)
canvas_plot = "getcontext" in lower and any(a in lower for a in _CANVAS_DRAW_APIS)
return not (rich_poly or rich_path or builds_plot or canvas_plot)
def _critique_shared(markup: str) -> list[str]:
"""Checks that hold for every preview language."""
import re
issues: list[str] = []
lower = markup.lower()
external_attr = re.search(
r"""(?i)\b(?:src|srcset|href|xlink:href|poster|action|formaction|data)\b\s*=\s*(?:['"]\s*)?https?://""",
markup,
)
external_css = re.search(
r"""(?i)(?:url\s*\(\s*['"]?\s*https?://|@import\s+(?:url\s*\(\s*)?['"]?\s*https?://)""",
markup,
)
if external_attr or external_css:
issues.append(
"Remove external http(s) URLs — the sandboxed preview blocks them. "
"Inline CSS/JS; use data: URIs for images/fonts."
)
# Model talking about the chat UI instead of building the visual.
if any(p in lower for p in (
"preview/code", "render_preview", "live preview/code",
"paste the returned", "fenced block",
)):
issues.append(
"Do not describe the chat Preview UI — submit only the visualization "
"markup (canvas/SVG that plots data)."
)
return issues
def _critique_svg(markup: str, wants_plot: bool) -> list[str]:
import re
issues: list[str] = []
lower = markup.lower()
if "<svg" not in lower:
issues.append("SVG markup must include an <svg> root element.")
root = re.search(r"<svg\b[^>]*>", markup, re.I)
if root:
tag = root.group(0)
wm = re.search(r'\bwidth\s*=\s*["\']?(\d+)', tag, re.I)
hm = re.search(r'\bheight\s*=\s*["\']?(\d+)', tag, re.I)
if wm and int(wm.group(1)) < _MIN_CANVAS_W:
issues.append(
f'SVG width is {wm.group(1)}px — too small to read. Use '
f'width="{_STAGE_W}" height="{_STAGE_H}".'
)
if hm and int(hm.group(1)) < _MIN_CANVAS_H:
issues.append(
f'SVG height is {hm.group(1)}px — too small. Use height="{_STAGE_H}".'
)
if len(re.sub(r"\s+", "", markup)) < 60:
issues.append(
"SVG is too empty — add shapes (path/rect/circle/line/text) that "
"actually illustrate the idea."
)
if wants_plot and _decorative_svg_not_plot(markup):
issues.append(
"This SVG is decorative (gradient/rect/single line), not a data "
"chart. Build a <polyline>/<path> from many computed points, or "
"prefer a <canvas> with getContext + lineTo over an array."
)
return issues
def _critique_html(markup: str, wants_plot: bool) -> list[str]:
import re
issues: list[str] = []
lower = markup.lower()
if re.search(r"(?:width|height)\s*:\s*40px", markup, re.I):
issues.append(
"Do not use 40x40 CSS tiles — that is not a visualization. Size the "
f"stage {_STAGE_W}x{_STAGE_H}px."
)
if "<pre" in lower and ("<html" in lower or "<!doctype" in lower):
issues.append(
"Do not nest another HTML document inside <pre>. Put one interactive "
"<canvas> (or <svg>) in the body and draw there."
)
has_canvas = "<canvas" in lower
has_svg = "<svg" in lower
issues += _critique_prose(markup)
if wants_plot and not has_canvas and not has_svg:
issues.append(
"For chart/plot/interactive demos include a <canvas> or <svg> that "
"draws the data — prose alone is rejected."
)
if wants_plot and has_svg and not has_canvas and _decorative_svg_not_plot(markup):
issues.append(
"SVG stage present but it does not plot data (no multi-point "
"polyline/path, no JS that builds one from an array). Prefer "
f'<canvas width="{_STAGE_W}" height="{_STAGE_H}"> + getContext + lineTo.'
)
issues += _critique_canvas(markup)
if len(re.sub(r"\s+", "", markup)) < 40:
issues.append("markup is too short to be a useful preview.")
return issues
def _critique_prose(markup: str) -> list[str]:
"""Reject an explanation dressed up as a visualization — paragraphs, a list,
maybe a button that reveals more text, and nothing that draws.
Shared by html and jsx: a component returning four <p> elements is the same
non-answer as a page of them, and for a while jsx was accepted precisely
because this check lived only on the html side."""
import re
lower = markup.lower()
if "<canvas" in lower or "<svg" in lower:
return []
prose_tags = len(re.findall(r"<(?:p|li|h[1-6]|ul|ol)\b", lower))
toggle_only = prose_tags >= 3 and (
"display" in lower or "toggle" in lower or "<button" in lower
)
if prose_tags >= 4 or toggle_only:
return [
"This is an explanation, not a visualization. Draw the data on a "
f'<canvas width="{_STAGE_W}" height="{_STAGE_H}"> (or an <svg> chart) '
"— not paragraphs, lists, or a button that only reveals more text."
]
return []
def _critique_canvas(markup: str) -> list[str]:
"""Checks for markup that has a <canvas> in it, wherever that markup came
from a plain HTML body or the JSX that renders one."""
import re
issues: list[str] = []
lower = markup.lower()
if "<canvas" in lower:
if "getcontext" not in lower:
issues.append(
"Canvas is present but never gets a 2D context — call "
"canvas.getContext('2d') and draw with it."
)
if not any(api in lower for api in _CANVAS_DRAW_APIS):
issues.append(
"Canvas never draws anything — plot each step with "
"fillRect/stroke/lineTo/arc/fillText (etc.). Do not only assign "
"canvas.width/height inside a loop; that clears the canvas."
)
for tag in re.findall(r"<canvas\b[^>]*>", markup, re.I):
wm = re.search(r'\bwidth\s*=\s*["\']?(\d+)', tag, re.I)
hm = re.search(r'\bheight\s*=\s*["\']?(\d+)', tag, re.I)
if wm and int(wm.group(1)) < _MIN_CANVAS_W:
issues.append(
f'Canvas width="{wm.group(1)}" is too small — use width="{_STAGE_W}" '
f'height="{_STAGE_H}", then map each data value to (x, y) pixels.'
)
if hm and int(hm.group(1)) < _MIN_CANVAS_H:
issues.append(
f'Canvas height="{hm.group(1)}" is too small — use height="{_STAGE_H}".'
)
if re.search(r'\bwidth\s*=\s*["\']?100%', tag, re.I):
issues.append(
"Use numeric canvas width/height attributes (e.g. width=\"480\"), "
"not percentages — the bitmap size must be explicit."
)
full_blit = bool(re.search(
r"(?:fillrect|clearrect)\s*\(\s*0\s*,\s*0\s*,\s*\d+\s*,\s*\d+\s*\)",
lower,
))
plots_points = bool(
re.search(r"lineto\s*\(", lower)
or re.search(r"fillrect\s*\(\s*(?!0\s*,\s*0)", lower)
or re.search(r"filltext\s*\(", lower)
or re.search(r"arc\s*\(", lower)
or re.search(r"strokerect\s*\(\s*(?!0\s*,\s*0)", lower)
)
if full_blit and not plots_points:
issues.append(
"You are only fillRect/clearRect(0,0,W,H) — that paints the whole "
"canvas, not the data. Collect values into an array, then for each "
"index i draw at x=i*step, y=height - value*scale (lineTo or "
"fillRect(x, y, barW, barH))."
)
return issues
def _critique_jsx(markup: str, wants_plot: bool) -> list[str]:
"""A JSX/TSX fence is one self-contained component. It is transformed and
mounted in the browser (interface/web/src/preview/), so the checks here are
the things that transform cannot recover from or would mount into nothing."""
import re
issues: list[str] = []
lower = markup.lower()
# Something has to be mounted: an explicit default export, a component named
# App, or some capitalized declaration to fall back to.
has_component = bool(
re.search(r"\bexport\s+default\b", markup)
or re.search(r"\bfunction\s+[A-Z]\w*", markup)
or re.search(r"\b(?:const|let|var)\s+[A-Z]\w*\s*=", markup)
or re.search(r"\bclass\s+[A-Z]\w*", markup)
)
if not has_component:
issues.append(
"No component to mount — define one with a capitalized name "
"(e.g. `function App() { ... }`) or `export default` it."
)
if "<" not in markup or not re.search(r"<[A-Za-z>]", markup):
issues.append(
"No JSX found — the component must return elements "
"(e.g. `return <div>…</div>;`)."
)
# Imports are stripped before the code runs: there is no module loader and
# no network in the sandbox. React/Preact itself is already in scope.
for module in re.findall(r"""\bfrom\s+['"]([^'"]+)['"]""", markup):
if module.split("/")[0] not in ("react", "react-dom", "preact"):
issues.append(
f"Cannot import '{module}' — the preview has no module loader and "
"no network. Inline what you need; React/Preact hooks are already "
"in scope without importing."
)
if wants_plot and "<canvas" not in lower and "<svg" not in lower:
issues.append(
"For chart/plot/interactive demos render a <canvas> or <svg> that "
"draws the data — prose alone is rejected."
)
issues += _critique_prose(markup)
issues += _critique_canvas(markup)
if len(re.sub(r"\s+", "", markup)) < 40:
issues.append("markup is too short to be a useful preview.")
return issues
# The one place that says which languages the render window supports. Each entry
# owns that language's validation; the tool schema's `lang` enum, the dispatch in
# _critique_render, and the capability line in the system prompt are all derived
# from these keys rather than repeating them.
# The one place that says which languages the render window supports. The tool
# schema's `lang` enum and the capability line in the system prompt are derived
# from these keys rather than repeated.
#
# The frontend keeps its own matching registry (PREVIEW_LANGS in
# interface/web/src/Markdown.jsx) because the two sides need different things per
# language - this side validates, that side renders - and neither should depend
# on the other at runtime. tests/test_tools.py asserts the key sets stay equal,
# so drift fails the check gate instead of silently degrading to a plain code
# block in the chat.
# interface/web/src/preview/languages.js) because the two sides need different
# things per language - this side describes them, that side renders them - and
# neither should depend on the other at runtime. tests/test_tools.py asserts the key sets
# stay equal, so drift fails the check gate instead of silently degrading to a
# plain code block in the chat.
PREVIEW_LANGS: dict[str, dict] = {
"html": {
"summary": "self-contained HTML document",
"critique": _critique_html,
# HTML also covers ordinary interactive UIs (forms, calculators, DOM
# demos). Only require a drawing surface when the request specifically
# claims to be a chart/plot/data visualization.
"wants_visual": _wants_chart,
"scaffold": _INTERACTIVE_SCAFFOLD_HTML,
},
"svg": {
"summary": "standalone SVG image",
"critique": _critique_svg,
"wants_visual": _wants_data_visual,
"scaffold": _INTERACTIVE_SCAFFOLD_HTML,
},
"jsx": {
"summary": "single Preact/React component (JSX)",
"critique": _critique_jsx,
"wants_visual": _wants_chart,
"scaffold": _INTERACTIVE_SCAFFOLD_JSX,
},
"tsx": {
"summary": "single Preact/React component (TypeScript JSX)",
"critique": _critique_jsx,
"wants_visual": _wants_chart,
"scaffold": _INTERACTIVE_SCAFFOLD_JSX,
},
"html": {"summary": "self-contained HTML document"},
"svg": {"summary": "standalone SVG image"},
"jsx": {"summary": "single Preact/React component (JSX)"},
"tsx": {"summary": "single Preact/React component (TypeScript JSX)"},
}
def _scaffold_for(lang: str) -> str:
"""The starting pattern handed back on reject. Per-language: answering a
rejected component with a full HTML document tells the model to write the
wrong thing entirely."""
entry = PREVIEW_LANGS.get(lang)
return (entry["scaffold"] if entry else _INTERACTIVE_SCAFFOLD_HTML).strip()
def _lang_prose() -> str:
"""'html or svg' — the supported languages as a phrase for prompts/errors."""
names = list(PREVIEW_LANGS)
@@ -690,74 +241,15 @@ def _lang_prose() -> str:
return f"{', '.join(names[:-1])} or {names[-1]}"
def _critique_render(lang: str, markup: str, purpose: str = "", title: str = "") -> list[str]:
"""Cheap static checks so render_preview rejects empty/fake visuals before
the model pastes them into the chat as a 'working' demo.
Scope: things that RUN but are not what was asked for a 40x40 stage, a
decorative gradient standing in for a chart, prose with no drawing in it, a
canvas that only paints itself one colour. These fail silently no matter
what, so static checks are the only thing that can catch them.
Not in scope: code that throws. The preview reports its own runtime errors
now (the bootstrap in interface/web/src/Markdown.jsx), so guessing at them
here bought nothing and cost accuracy. Three checks were removed once that
landed, each verified against the real error channel first:
const canvas = el.getContext('2d') ... ctx.lineTo()
-> "ReferenceError: ctx is not defined (line 4)"
function collatz() ... coll(27)
-> "ReferenceError: coll is not defined (line 5)"
document.createElementNS('line')
-> "TypeError: ... 2 arguments required, but only 1 present. (line 3)"
The real messages are better than the regexes were: they carry a line
number, and they catch *any* undefined name rather than the two spellings
someone thought to anticipate. Resist re-adding a static check for anything
that already throws."""
entry = PREVIEW_LANGS.get(lang)
if entry is None:
return [f"unsupported preview language {lang!r} — use {_lang_prose()}."]
# What counts as "claimed a visual" differs by language: see _wants_chart.
wants_plot = entry["wants_visual"](purpose, title, markup)
return _critique_shared(markup) + entry["critique"](markup, wants_plot)
def _with_scaffold(payload: dict, lang: str, attempt: int) -> dict:
"""Attach the starting pattern, but only from the second rejection on.
A complete, styled, runnable document handed to a struggling model does not
get adapted it gets pasted, and then it persists in the conversation and
comes back as retrieved context for the next request, carrying its example
domain with it. Transcripts show exactly that: a scaffold's CSS reappearing
verbatim in an answer to an unrelated prompt, in a conversation where this
tool was never even called. So the first rejection says only what is wrong;
the pattern appears once that has not been enough."""
if attempt < 1:
return payload
return {
**payload,
"scaffold": _scaffold_for(lang),
"scaffold_note": (
"A pattern to adapt, not an answer to paste. Replace generate() with "
"the logic this request actually needs; keep nothing you do not use."
),
}
async def _render_preview(
lang: str = "html",
title: str = "",
markup: str = "",
purpose: str = "",
_attempt: int = 0,
**_,
) -> str:
"""Validate + package a live-preview fence. Read-only: nothing is executed
server-side; the chat UI renders the returned fence in a sandboxed iframe.
`_attempt` is supplied by the tool loop, not by the model it is how many
times this call has already been rejected in the current turn."""
"""Package a live-preview fence. Read-only: nothing is executed server-side;
the chat UI parses and renders the fence in a sandboxed iframe."""
lang = (lang or "html").strip().lower()
markup = (markup or "").strip()
title = (title or "").strip()
@@ -766,28 +258,10 @@ async def _render_preview(
if lang not in PREVIEW_LANGS:
return json.dumps({"ok": False, "error": f"lang must be {_lang_prose()}"})
if not markup:
return json.dumps(_with_scaffold({
return json.dumps({
"ok": False,
"error": (
f"markup is required — send the complete {lang} for the visual you "
"want, with all CSS and JS inline and no external URLs."
),
}, lang, _attempt))
issues = _critique_render(lang, markup, purpose=purpose, title=title)
if issues:
return json.dumps(_with_scaffold({
"ok": False,
"issues": issues,
"hint": (
"Fix these and call render_preview again, building the thing that "
f"was actually asked for. Draw on a {_STAGE_W}x{_STAGE_H} stage; "
"compute the values into an array first, then plot them point by "
"point with lineTo/fillRect(x,y,w,h); add <input>/<button> controls "
"when it should be interactive."
),
"purpose": purpose or None,
}, lang, _attempt))
"error": f"markup is required — send the complete {lang} preview.",
})
fence = f"```{lang}\n{markup}\n```"
return json.dumps({
@@ -912,16 +386,10 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
# terse, keep it second-person, and add nothing the model can
# recite in place of acting.
"description": (
f"Build a working visual — chart, plot, diagram, interactive demo "
f"as self-contained {_lang_prose()} and send it here to check. "
f"Draw on a {_STAGE_W}x{_STAGE_H} stage. Compute your values into an "
"array, then plot them point by point (canvas: getContext, then "
"lineTo/fillRect(x,y,w,h)/arc per point). Add <input>/<button> "
"controls if it should be interactive. Inline all CSS and JS; the "
"preview is sandboxed with no network, so external URLs will not "
"load. Build what was asked for, not a similar demo you know better. "
"Rejected: fix what `issues` lists and send it again. "
"Accepted: paste the returned `fence` into your reply unchanged."
f"Package a working visual or interactive demo as self-contained "
f"{_lang_prose()}. Inline required CSS and JS; the sandbox has no "
"network, so external resources will not load. Paste the returned "
"`fence` into your reply unchanged."
),
"parameters": {
"type": "object",
@@ -948,8 +416,10 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
"markup": {
"type": "string",
"description": (
"Full self-contained HTML document or SVG. Inline all "
"CSS/JS. No external script/style/img URLs."
"Complete self-contained source for the selected preview "
"language. React, ReactDOM, Preact, and Preact hooks are "
"available locally; other packages and external resources "
"cannot be loaded."
),
},
},
+45 -291
View File
@@ -208,6 +208,8 @@ def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
assert {"read_file", "list_files"} <= granted, granted
# none of them are action tools, so they survive the default policy (off)
assert tools.schemas_for(sorted(granted), allow_actions=False)
def test_standing_schemas_include_render_preview():
names = [s["function"]["name"] for s in tools.standing_schemas()]
assert names == ["render_preview"]
@@ -217,48 +219,18 @@ def test_standing_schemas_include_render_preview():
assert not tools.wants_render_preview("what's the weather vibe today")
def test_render_preview_rejects_canvas_that_never_draws():
bad = """<!DOCTYPE html><html><body>
<canvas id="c" width="480" height="240"></canvas>
<script>
const canvas = document.getElementById('c');
function spin(num) {
while (num !== 1) {
num = num % 2 === 0 ? num / 2 : 3 * num + 1;
canvas.width = canvas.width;
}
}
spin(40);
</script></body></html>"""
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "title": "Demo", "markup": bad,
})))
assert out["ok"] is False
joined = " ".join(out.get("issues", []))
assert "draw" in joined.lower() or "getcontext" in joined.lower()
assert "scaffold" not in out # withheld on a first rejection
def test_render_preview_rejects_forty_by_forty_stub_with_scaffold():
# Tiny stubs fail critique; tool returns fix hints + generic scaffold — not a
# canned Collatz/Recamán demo.
bad = """<!DOCTYPE html><html><head><style>
.colla { width: 40px; height: 40px; background-color: #2563eb; }
</style></head><body>
def test_render_preview_packages_markup_without_grading_its_quality():
markup = """<!DOCTYPE html><html><body>
<canvas id="c" width="40" height="40"></canvas>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
ctx.fillRect(0, 0, 40, 40);
</script></body></html>"""
<script>c.width = c.width;</script>
</body></html>"""
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "markup": bad, "purpose": "interactive plot demo",
"lang": "html", "title": "Demo", "markup": markup,
})))
assert out["ok"] is False
assert out.get("repaired") is not True
assert "fence" not in out or not out.get("fence")
assert "scaffold" not in out # withheld on a first rejection
assert "issues" in out
assert out["ok"] is True
assert markup in out["fence"]
assert "issues" not in out
assert "scaffold" not in out
def test_render_preview_accepts_canvas_that_plots():
@@ -305,8 +277,7 @@ def test_code_that_throws_is_left_to_the_previews_own_error_channel():
Static guessing at runtime failures only ever caught the spellings someone
anticipated; the error channel catches every one of them and carries a line
number. What stays in the critique is the opposite case markup that runs
perfectly and still isn't a visualization."""
number."""
broken_at_runtime = """<!DOCTYPE html><html><body>
<canvas id="c" width="480" height="280"></canvas>
<script>
@@ -341,35 +312,6 @@ function plot() {
assert out["ok"] is True, out.get("issues")
def test_render_preview_rejects_decorative_svg_without_guessing_algorithm():
bad = """<!DOCTYPE html><html><body>
<div class="wrap">
<input id="n" type="number" value="27"><button id="go">Plot</button>
</div>
<svg width="480" height="200" viewBox="0 0 480 200">
<defs><linearGradient id="g"><stop offset="0%" stop-color="#1a1a1a"/></linearGradient></defs>
<rect x="0" y="0" width="480" height="200" fill="url(#g)"/>
<line x1="0" y1="100" x2="480" y2="100" stroke="#fff"/>
</svg>
<script>
document.getElementById('go').onclick = function() {
var line = document.createElementNS('line');
document.querySelector('.wrap').appendChild(line);
};
</script></body></html>"""
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html",
"title": "Unknown sequence",
"purpose": "interactive sequence demo",
"markup": bad,
})))
assert out["ok"] is False
assert out.get("repaired") is not True
blob = json.dumps(out).lower()
assert "recaman" not in blob and "collatz" not in blob
assert "scaffold" not in out # withheld on a first rejection
def test_no_sequence_render_seed_helper():
assert not hasattr(tools, "sequence_render_seed")
@@ -432,72 +374,16 @@ def test_render_preview_accepts_a_jsx_component():
assert out["fence"].startswith("```jsx\n")
def test_interactive_ui_needs_no_canvas_but_a_chart_does():
"""Forms and calculators are interactive through DOM elements in either
HTML or JSX; only a request claiming to be a chart needs a drawing surface."""
def test_render_preview_does_not_grade_jsx_against_its_purpose():
component = """export default function Form() {
const [name, setName] = useState("");
return <label>Name <input value={name} onInput={(e) => setName(e.target.value)} /></label>;
}"""
ok = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": component, "purpose": "an interactive demo",
})))
assert ok["ok"] is True, ok.get("issues")
bad = json.loads(asyncio.run(tools.dispatch("render_preview", {
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": component, "purpose": "a chart of the results",
})))
assert bad["ok"] is False
assert any("canvas" in i for i in bad["issues"])
html = """<!doctype html><html><body>
<label>Value <input id="value" type="number" value="2"></label>
<button onclick="result.textContent = +value.value * 2">Double</button>
<output id="result">4</output>
</body></html>"""
html_ok = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "markup": html, "purpose": "an interactive calculator demo",
})))
assert html_ok["ok"] is True, html_ok.get("issues")
def test_scaffold_is_withheld_until_the_model_has_failed_twice():
"""A complete, styled, runnable document handed to a struggling model gets
pasted rather than adapted and then persists in the conversation and comes
back as retrieved context for later requests, carrying its example domain
with it. A transcript showed this scaffold's CSS reappearing verbatim in an
answer to an unrelated prompt, in a conversation where the tool was never
called. So the first rejection says only what is wrong."""
for args in ({"lang": "html", "markup": "<div>too short</div>"},
{"lang": "jsx", "markup": ""}):
first = json.loads(asyncio.run(tools.dispatch("render_preview", args)))
assert first["ok"] is False
assert "scaffold" not in first, args
assert "issues" in first or "error" in first
again = json.loads(asyncio.run(tools.dispatch(
"render_preview", {**args, "_attempt": 1})))
assert again["ok"] is False
assert "scaffold" in again, args
def test_repeat_reject_hands_back_the_language_that_was_asked_for():
"""Answering a rejected component with a full HTML document tells the model
to write the wrong thing entirely."""
jsx = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": "<div>too short</div>", "_attempt": 1,
})))
assert "export default function App" in jsx["scaffold"]
assert "<!DOCTYPE html>" not in jsx["scaffold"]
html = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "markup": "<div>too short</div>", "_attempt": 1,
})))
assert "<!DOCTYPE html>" in html["scaffold"]
empty = json.loads(asyncio.run(tools.dispatch(
"render_preview", {"lang": "tsx", "markup": "", "_attempt": 1})))
assert "export default function App" in empty["scaffold"]
assert out["ok"] is True
assert "issues" not in out
def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
@@ -524,14 +410,14 @@ def test_asking_for_a_preview_language_or_pointer_interaction_offers_the_tool():
assert tools.wants_render_preview("compare these graphs")
def test_external_preview_resources_are_rejected_in_attributes_and_css():
for markup in (
'<img src=https://example.com/chart.png alt="chart">',
'<style>.chart { background: url("https://example.com/chart.png"); }</style>',
'<style>@import "https://example.com/chart.css";</style>',
):
issues = tools._critique_shared(markup)
assert any("external http(s)" in issue for issue in issues), markup
def test_external_preview_resources_are_packaged_for_the_csp_to_block():
markup = '<img src="https://example.com/chart.png" alt="chart">'
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "markup": markup,
})))
assert out["ok"] is True
assert markup in out["fence"]
assert "issues" not in out
def test_every_preview_language_hints_for_itself():
@@ -539,81 +425,6 @@ def test_every_preview_language_hints_for_itself():
assert lang in tools._RENDER_HINTS, lang
def test_size_guidance_never_quotes_the_minimum():
"""Weak models copy the first dimensions they read. Three transcripts
produced exactly 320x200 the old minimum including one that had a
480x280 example in front of it. Only the wanted size may be spoken."""
schema, _ = tools.REGISTRY["render_preview"]
surfaces = [json.dumps(schema)]
for lang, markup in (("html", '<canvas width="40" height="40"></canvas>' + "x" * 60),
("svg", '<svg width="40" height="40"><rect/></svg>' + "x" * 60)):
surfaces.append(json.dumps(asyncio.run(
tools._render_preview(lang=lang, markup=markup, purpose="a chart"))))
blob = " ".join(surfaces)
assert str(tools._MIN_CANVAS_W) not in blob, "the minimum leaked into guidance"
assert str(tools._STAGE_W) in blob
def test_prose_only_component_is_rejected_like_a_prose_page():
"""The JSX that started the Euler misunderstanding: a component returning
three paragraphs. It was accepted because the prose check lived only on the
html side."""
prose = """export default function App() {
return (
<div>
<h1>Euler's Formula</h1>
<p>The sum of the first n natural numbers is:</p>
<p>{`f(x) = ${sumOfCubes(10)}`}</p>
<p>For example, the sum of the cubes of the first 10 is: {sumOfCubes(10)}</p>
</div>
);
}"""
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": prose, "purpose": "Euler fluid field",
})))
assert out["ok"] is False
assert any("not a visualization" in i for i in out["issues"])
def test_a_path_parked_in_defs_is_not_a_plot():
"""Straight from a transcript: a long <path> inside <defs> — never drawn —
passed as proof of a real chart while the preview rendered an empty box."""
undrawn = (
'<svg width="480" height="280" xmlns="http://www.w3.org/2000/svg"><defs>'
'<path d="M10,20L 10,190L 20,180L 30,170L 40,160L 50,150L 60,140L 70,130L 200L 0L" />'
'</defs><rect x="0" y="0" width="480" height="280" fill="none" stroke="#000" /></svg>'
)
assert tools._decorative_svg_not_plot(undrawn)
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "svg", "markup": undrawn, "purpose": "a plot of the field",
})))
assert out["ok"] is False
# The same path where it actually renders is still a plot.
drawn = undrawn.replace("<defs>", "").replace("</defs>", "")
assert not tools._decorative_svg_not_plot(drawn)
def test_render_nudge_never_reaches_the_streaming_turn():
"""The nudge is a synthetic user turn. Left in place it becomes the last
thing the user appears to have said, and the model answers it which is
exactly what shipped: "please provide the user's request for the rendering",
twice, in place of a bouncing particle system."""
from synapse.chat import _strip_internal_turns, _render_nudge_text
real = {"role": "user", "content": "draw me a bouncing particle system"}
kept = _strip_internal_turns([
real,
{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "x"}}]},
{"role": "tool", "content": "{}"},
{"role": "user", "content": _render_nudge_text()},
])
assert kept[-1] == real
assert len(kept) == 2
assert kept[0]["role"] == "user"
assert "Tool results" in kept[0]["content"]
assert "{}" in kept[0]["content"]
def test_normal_tool_results_reach_streaming_turn():
"""Flatten Ollama's tool roles without discarding the retrieved data."""
from synapse.chat import _strip_internal_turns
@@ -630,43 +441,19 @@ def test_normal_tool_results_reach_streaming_turn():
assert all(m.get("role") != "tool" and not m.get("tool_calls") for m in kept)
def test_render_nudge_says_only_what_to_do_next():
"""It cannot refer to something the model can't see, offer a way out, or
name a size or language that isn't the one we want — it gets answered
literally."""
from synapse.chat import _render_nudge_text
nudge = _render_nudge_text().lower()
assert "this user request" not in nudge # dangling reference -> "please provide it"
assert "clarif" not in nudge # escape hatch -> it gets taken
assert str(tools._MIN_CANVAS_W) not in nudge
assert f"{tools._STAGE_W}x{tools._STAGE_H}" in nudge
for lang in tools.PREVIEW_LANGS: # not a hardcoded "html or svg"
assert lang in nudge, lang
def test_every_language_offers_a_scaffold():
for lang in tools.PREVIEW_LANGS:
assert tools._scaffold_for(lang), f"{lang} has no scaffold"
def test_render_preview_rejects_jsx_with_no_component():
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": "const x = 1;\nconsole.log(x);\n// nothing to mount",
})))
assert out["ok"] is False
assert any("No component to mount" in i for i in out["issues"])
def test_render_preview_rejects_jsx_importing_a_third_party_module():
src = """import { motion } from "framer-motion";
export default function App() {
return <motion.div>hello there friend</motion.div>;
}"""
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": src,
})))
assert out["ok"] is False
assert any("framer-motion" in i for i in out["issues"])
def test_render_preview_leaves_jsx_runtime_judgment_to_the_browser():
sources = (
"const x = 1;\nconsole.log(x);\n// nothing to mount",
'import { motion } from "framer-motion"; export default () => <motion.div />;',
"export default () => <div style={{width: 40}}>tiny</div>;",
)
for source in sources:
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "jsx", "markup": source,
})))
assert out["ok"] is True
assert source in out["fence"]
assert "issues" not in out
def test_render_preview_allows_react_imports_in_jsx():
@@ -681,43 +468,13 @@ export default function App() {
assert out["ok"] is True, out.get("issues")
def test_render_preview_rejects_tiny_decorative_tile():
stub = """<div style="width:40px;height:40px;background:#2563eb;border:1px solid #000"></div>"""
def test_render_preview_still_rejects_missing_markup():
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html", "markup": stub,
"lang": "tsx", "markup": "",
})))
assert out["ok"] is False
def test_render_preview_rejects_prose_page():
bad = """<!DOCTYPE html><html><body>
<div><h1>Some Topic</h1>
<p>This explains an idea in several paragraphs without drawing anything.</p>
<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>
<p>To view a live Preview/Code toggle, use:</p>
<pre><html><body><p>more prose</p></body></html></pre>
</div></body></html>"""
out = json.loads(asyncio.run(tools.dispatch("render_preview", {
"lang": "html",
"title": "Topic",
"purpose": "interactive demo",
"markup": bad,
})))
assert out["ok"] is False
assert "scaffold" not in out # withheld on a first rejection
assert out.get("repaired") is not True
def test_last_ok_render_fence_prefers_tool_result():
from synapse.chat import _last_ok_render_fence
fence, meta = _last_ok_render_fence([
{"role": "tool", "content": json.dumps({
"ok": True,
"fence": "```html\n<canvas width=\"480\" height=\"280\"></canvas>\n```",
})},
])
assert fence.startswith("```html")
assert meta.get("ok") is True
assert "markup is required" in out["error"]
assert "scaffold" not in out
def test_coerce_tool_calls_from_content_json():
@@ -788,8 +545,7 @@ def test_tool_loop_runs_content_json_tool_call(monkeypatch):
assert json.loads(tool_msgs[0]["content"])["ok"] is True
def test_tool_loop_nudges_render_preview_on_visual_ask():
"""First turn skips tools; nudge forces a second turn that calls render_preview."""
def test_tool_loop_does_not_inject_a_render_preview_nudge():
class _SkipThenCall:
def __init__(self):
self.n = 0
@@ -821,11 +577,9 @@ def test_tool_loop_nudges_render_preview_on_visual_ask():
_SkipThenCall(), "m", tools.standing_schemas(),
user="Visualize the Collatz conjecture with an interactive chart",
))
assert any(s == "__status__render_preview" for s in statuses)
assert any(
m.get("role") == "user" and "render_preview tool now" in (m.get("content") or "")
for m in messages
)
assert statuses == ["__status__tools"]
assert len(messages) == 1
assert messages[0]["content"].startswith("Visualize")
async def _drain_with_messages(manager, model, schemas, user="draw a circle"):