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:
Generated
+101
-8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user