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