diff --git a/bin/check.sh b/bin/check.sh index 02b9b21..48be6a2 100644 --- a/bin/check.sh +++ b/bin/check.sh @@ -25,6 +25,16 @@ else echo "-- skipped: interface/web/node_modules missing (npm install)" fi +echo "== frontend unit tests ==" +# The JSX/TSX transform behind the preview window is a pure module with a +# node --test suite. Nothing else in the frontend has tests, so this is cheap; +# without it the transform's silent-wrong cases go unguarded. +if [ -d interface/web/node_modules ]; then + (cd interface/web && npm test) || fail=1 +else + echo "-- skipped: interface/web/node_modules missing (npm install)" +fi + echo "== powershell parse ==" # The Windows installer has died at parse twice. Cheap to catch here if pwsh # happens to be installed on the Linux box; the ASCII guard in tests/ is the diff --git a/interface/web/index.html b/interface/web/index.html index 6c697db..6b32eed 100644 --- a/interface/web/index.html +++ b/interface/web/index.html @@ -2,6 +2,9 @@ + + NexusOS diff --git a/interface/web/package-lock.json b/interface/web/package-lock.json index 0d46442..bb6813a 100644 --- a/interface/web/package-lock.json +++ b/interface/web/package-lock.json @@ -8,8 +8,10 @@ "name": "web", "version": "1.2.0", "dependencies": { + "preact": "^10.29.8", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "sucrase": "^3.35.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -527,7 +529,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -549,7 +550,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -559,14 +559,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -993,6 +991,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1133,6 +1137,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1443,7 +1456,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2015,6 +2027,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2068,6 +2086,17 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -2104,6 +2133,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2198,7 +2236,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2207,6 +2244,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { "version": "8.5.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", @@ -2236,6 +2282,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2383,6 +2447,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2396,11 +2482,31 @@ "node": ">=8" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2413,6 +2519,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/interface/web/package.json b/interface/web/package.json index f8413a2..34d3b01 100644 --- a/interface/web/package.json +++ b/interface/web/package.json @@ -10,11 +10,14 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "node --test src/preview/jsx-transform.test.js", "preview": "vite preview" }, "dependencies": { + "preact": "^10.29.8", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "sucrase": "^3.35.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/interface/web/src/Markdown.jsx b/interface/web/src/Markdown.jsx index 127f018..e95550f 100644 --- a/interface/web/src/Markdown.jsx +++ b/interface/web/src/Markdown.jsx @@ -1,4 +1,11 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; + +// Which languages get a live sandboxed preview (RenderBlock) instead of a plain +// syntax block (CodeBlock), and how each becomes a document body, lives in +// ./preview/languages.js. A language like `js` is deliberately absent — +// auto-executing bare script isn't this feature's job (see RenderBlock's doc +// comment for the sandboxing model). +import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js"; // Parse content into an array of {type, value, lang, streaming} blocks. // Handles: @@ -51,11 +58,13 @@ export function Markdown({ content }) { const blocks = parseBlocks(content); return (
- {blocks.map((block, i) => - block.type === "code" - ? - : - )} + {blocks.map((block, i) => { + if (block.type !== "code") return ; + const lang = (block.lang || "").toLowerCase(); + return RENDERABLE_LANGS.has(lang) + ? + : ; + })}
); } @@ -117,6 +126,435 @@ function CodeBlock({ lang, value, streaming }) { ); } +// Content-Security-Policy for the rendered preview. Together with the iframe's +// `sandbox` attribute below, this is the entire trust boundary for model- +// authored HTML/SVG, so it stays conservative rather than convenient: +// - script-src/style-src 'unsafe-inline' inline ` below is safe unescaped because this module is emitted as an +// external .js asset - it is never inlined into index.html, where the HTML +// parser would end the surrounding script tag early. +// +// postMessage is the one channel an opaque-origin sandboxed frame still has to +// the parent, and this is the entire protocol over it: one message shape, +// outbound only, carrying a content height and an error string. Nothing flows +// the other way. The parent treats both fields as untrusted data - the height +// is clamped and the message is rendered as text, never as markup - because +// they were produced by the same code the sandbox exists to contain. +// +// Without this the frame is silent: a preview whose script throws just renders +// blank, which is why the server-side validator in synapse/tools.py has to +// guess at runtime failures it can't observe. +const _PREVIEW_BOOTSTRAP = ``; + +// Substituted with the real line offset once the document is assembled and its +// shell can be measured. Sits on one line so replacing it can't shift any. +const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__"; + +/** + * Build the sandboxed document for a fence. Returns {doc, error}: a language + * whose source doesn't parse (JSX, today) has no document to show, and the + * caller renders the message instead of a frame. + * + * The shell - charset, CSP, bootstrap - is identical for every language; only + * the body differs, so only that part goes through the registry. Nothing about + * the sandboxing is per-language and shouldn't be: SVG can carry ` + + _PREVIEW_BOOTSTRAP + + ""; + + // Lines of shell above the user's own code: the document head, plus whatever + // the language puts in the body ahead of it (the Preact build, for JSX). + const offset = (head.match(/\n/g) || []).length + body.userOffset; + + return { + doc: (head + body.html + "").replace(_OFFSET_TOKEN, String(offset)), + error: "", + }; +} + +// Auto-height bounds. The frame is sized from content, and content sized in +// viewport/percentage units is therefore sized from the frame - a body with its +// own margin makes that loop grow by the margin on every pass. Measuring the +// body box rather than documentElement is what actually settles that loop; +// _MAX_PREVIEW_H then caps anything still climbing within a few iterations. +// +// _MAX_H_STEPS is only a last resort against a document that oscillates +// forever, so it is generous: an interactive component legitimately changes +// height on every click, and a tight budget would freeze the frame mid-session +// at whatever size it happened to reach. +const _MIN_PREVIEW_H = 160; +const _MAX_PREVIEW_H = 720; +const _MAX_H_STEPS = 60; + +// A frame that never posts again — a synchronous `while(true)` in the user's +// own script, or a runaway re-render loop the bootstrap's own coalescing +// can't outpace — has nothing else to signal it. Silence past this long since +// mount (or since the last message) is treated as hung and the frame is torn +// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never +// gets close to this. +const _WATCHDOG_MS = 6000; + +// Live preview for a renderable fenced block: a Preview/Code toggle rendered +// via a sandboxed iframe whose document is an encoded data: URL. +// +// Trust boundary: `sandbox="allow-scripts"` — deliberately without +// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No +// allow-same-origin forces the iframe onto an opaque origin, which is what +// actually matters here: even the inline scripts the CSP allows to run can't +// read this app's cookies/localStorage, can't call its API (no credentialed +// or same-origin fetch is possible), and can't reach `window.parent`. The CSP +// above blocks resource and script-initiated network access. The embedding +// document's `frame-src data:` policy in index.html closes a separate CSP gap: +// a child is otherwise allowed to navigate its own browsing context to a URL. +// The initial data: document is allowed and inherits the parent policy, while +// an http(s) navigation is rejected before its request is sent. Nothing here +// substitutes for a general code-execution sandbox (Docker, WASM, etc.); +// model-authored code runs only inside the browser's sandboxed frame. +function RenderBlock({ lang, value, streaming }) { + const [tab, setTab] = useState("preview"); + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const copy = () => { + navigator.clipboard.writeText(value.trimEnd()).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + // Don't preview a block whose fence hasn't closed yet - it's incomplete + // markup by definition, and re-pointing an iframe at a half-formed + // document on every streamed token is both wasteful and flickery. Code view + // already has its own streaming indicator (the same dot CodeBlock uses). + const showPreview = tab === "preview" && !streaming; + + return ( +
+
+
+ setTab("preview")}> + Preview + + setTab("code")}> + Code + + + {lang} + {streaming && } + +
+
+ {showPreview && ( + + )} + {!streaming && ( + + )} +
+
+ {showPreview ? ( + // Keyed by the markup: new markup is a new document, so remounting is + // what resets the reported error and measured height. No reset effect. + + ) : ( +
+          {value.trimEnd()}
+        
+ )} +
+ ); +} + +// The sandboxed frame plus the two things it reports back: its content height +// and its first uncaught error. Split out of RenderBlock so the caller can key +// it by markup - a fresh document then gets fresh state by remounting. +function PreviewFrame({ lang, value, expanded }) { + const [error, setError] = useState(""); + const [doc, setDoc] = useState(""); + const [buildError, setBuildError] = useState(""); + const [height, setHeight] = useState(240); + const [hung, setHung] = useState(false); + const frameRef = useRef(null); + const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe + const stepsRef = useRef(0); + const lastMsgRef = useRef(0); // set for real by the watchdog effect below + + // Receive the bootstrap's reports. The frame is on an opaque origin, so + // e.origin is the string "null" and proves nothing - identify the sender by + // its window instead, which content inside the sandbox cannot forge. + useEffect(() => { + const onMessage = (e) => { + if (!frameRef.current || e.source !== frameRef.current.contentWindow) return; + const data = e.data; + if (!data || data.__nexusPreview !== 1) return; + lastMsgRef.current = Date.now(); + + if (typeof data.err === "string" && data.err) setError(data.err); + + if (typeof data.h === "number" && Number.isFinite(data.h) && stepsRef.current < _MAX_H_STEPS) { + const next = Math.min(_MAX_PREVIEW_H, Math.max(_MIN_PREVIEW_H, Math.round(data.h))); + if (Math.abs(next - heightRef.current) >= 8) { + heightRef.current = next; + stepsRef.current += 1; + setHeight(next); + } + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + // Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a + // synchronous infinite loop in the model's own script, which blocks even + // the bootstrap's heartbeat from ever running — gets torn down rather than + // left spinning. Checked on an interval rather than a single timeout so a + // message arriving late (slow compile, heavy first paint) keeps resetting + // the clock instead of tripping early. + useEffect(() => { + lastMsgRef.current = Date.now(); + const id = setInterval(() => { + if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) { + setHung(true); + clearInterval(id); + } + }, 1000); + return () => clearInterval(id); + }, [lang, value]); + + useEffect(() => { + let current = true; + setDoc(""); + setBuildError(""); + buildSrcDoc(lang, value).then((result) => { + if (!current) return; + setDoc(result.doc || ""); + setBuildError(result.error || ""); + }); + return () => { current = false; }; + }, [lang, value]); + + // A build failure (JSX that doesn't parse) has no document to show at all, so + // the message stands in for the frame rather than sitting under it. A hung + // frame tears down the same way: dropping frameUrl unmounts the iframe, + // which is what actually stops a runaway script from holding the tab. + const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : ""; + const shown = hung + ? "Preview stopped responding (likely an infinite loop) and was stopped." + : buildError || error; + + return ( + <> + {frameUrl && ( +