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),
};