diff --git a/bin/check.sh b/bin/check.sh index 177d8ab..c060d53 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/bin/sync.py b/bin/sync.py index d54e463..a5ff68c 100644 --- a/bin/sync.py +++ b/bin/sync.py @@ -18,6 +18,7 @@ bin/db-compare.sh could never work there. A fresh machine clones first (git clone nexus-core), then runs this. """ import argparse +import contextlib import os import shutil import sqlite3 @@ -125,7 +126,7 @@ def dump_db() -> bool: if not DB.exists(): return False try: - with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn: + with contextlib.closing(sqlite3.connect(f"file:{DB}?mode=ro", uri=True)) as conn: DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8") except sqlite3.Error as exc: print(f"Warning: could not dump {DB} ({exc}) - DB not captured") @@ -171,9 +172,12 @@ def compare(db: Path = DB, dump: Path = DB_SQL) -> str: if not db.exists(): return "no-live" try: - with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn: + # closing(), not sqlite3's context manager: that one commits without + # closing, and restore_db() unlinks the DB right after calling this - + # Windows fails that unlink while any handle is still open. + with contextlib.closing(sqlite3.connect(f"file:{db}?mode=ro", uri=True)) as live_conn: live = _state(live_conn) - with sqlite3.connect(":memory:") as dump_conn: + with contextlib.closing(sqlite3.connect(":memory:")) as dump_conn: dump_conn.executescript(dump.read_text(encoding="utf-8")) backup = _state(dump_conn) except (sqlite3.Error, OSError): @@ -216,7 +220,7 @@ def restore_db() -> None: for suffix in ("", "-wal", "-shm"): Path(str(DB) + suffix).unlink(missing_ok=True) try: - with sqlite3.connect(DB) as conn: + with contextlib.closing(sqlite3.connect(DB)) as conn, conn: conn.executescript(DB_SQL.read_text(encoding="utf-8")) print("Memory DB restored (conversations + history + facts).") except sqlite3.Error as exc: diff --git a/interface/web/package-lock.json b/interface/web/package-lock.json index cecd34e..cc301f7 100644 --- a/interface/web/package-lock.json +++ b/interface/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "1.0.0", "dependencies": { + "preact": "^10.29.8", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -21,6 +22,9 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "vite": "^8.0.4" + }, + "engines": { + "node": ">=20.19" } }, "node_modules/@babel/code-frame": { @@ -2233,6 +2237,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", diff --git a/interface/web/package.json b/interface/web/package.json index 80940ba..d6fa21e 100644 --- a/interface/web/package.json +++ b/interface/web/package.json @@ -10,9 +10,11 @@ "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" }, diff --git a/interface/web/src/Markdown.jsx b/interface/web/src/Markdown.jsx index 127f018..35370f5 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,384 @@ 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; + +// Live preview for a ```html or ```svg fenced block: a Preview/Code toggle, +// rendered via a sandboxed