Step 17: streaming render — hyperscript enhancements, WASM builds, live server tests

Streaming chunked transfer with shell-first suspense and resolve scripts.
Hyperscript parser/compiler/runtime expanded for conformance. WASM static
assets added to OCaml host. Playwright streaming and page-level test suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 08:41:38 +00:00
parent 7aefe4da8f
commit 6e27442d57
29 changed files with 65959 additions and 628 deletions

View File

@@ -1,6 +1,6 @@
(executables
(names run_tests debug_set sx_server integration_tests)
(libraries sx unix))
(libraries sx unix threads.posix))
(executable
(name mcp_tree)

View File

@@ -151,6 +151,9 @@ let _app_config : (string, value) Hashtbl.t option ref = ref None
let _defpage_paths : string list ref = ref []
(* Streaming pages: path → page name, for pages with :stream true *)
let _streaming_pages : (string, string) Hashtbl.t = Hashtbl.create 8
(* Mutex to serialize streaming renders — OCaml threads share the runtime
lock, and concurrent CEK evaluations corrupt shared state. *)
let _stream_mutex = Mutex.create ()
let get_app_config key default =
match !_app_config with
@@ -1746,7 +1749,7 @@ let http_redirect url =
let http_chunked_header ?(status=200) ?(content_type="text/html; charset=utf-8") () =
let status_text = match status with
| 200 -> "OK" | 404 -> "Not Found" | 500 -> "Internal Server Error" | _ -> "Unknown" in
Printf.sprintf "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n"
Printf.sprintf "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\nCache-Control: no-cache, no-transform\r\n\r\n"
status status_text content_type
let write_chunk fd data =
@@ -1755,13 +1758,14 @@ let write_chunk fd data =
let bytes = Bytes.of_string chunk in
let total = Bytes.length bytes in
let written = ref 0 in
(try
try
while !written < total do
let n = Unix.write fd bytes !written (total - !written) in
written := !written + n
done
with Unix.Unix_error _ -> ())
end
done;
true
with Unix.Unix_error _ -> false
end else true
let end_chunked fd =
(try ignore (Unix.write_substring fd "0\r\n\r\n" 0 5) with Unix.Unix_error _ -> ());
@@ -2004,6 +2008,10 @@ let eval_with_io expr env =
(* ====================================================================== *)
let http_render_page_streaming env path _headers fd page_name =
(* No send timeout for streaming — the alive check in write_chunk handles
broken pipe. Streaming clients may be slow to receive large shell chunks
while busy parsing/downloading other resources. *)
(try Unix.setsockopt_float fd Unix.SO_SNDTIMEO 30.0 with _ -> ());
let t0 = Unix.gettimeofday () in
let page_def = try
match env_get env ("page:" ^ page_name) with Dict d -> d | _ -> raise Not_found
@@ -2102,17 +2110,18 @@ let http_render_page_streaming env path _headers fd page_name =
let header = http_chunked_header () in
let header_bytes = Bytes.of_string header in
(try ignore (Unix.write fd header_bytes 0 (Bytes.length header_bytes)) with _ -> ());
write_chunk fd shell_body;
let alive = ref true in
alive := write_chunk fd shell_body;
(* Bootstrap resolve script — must come after shell so suspense elements exist *)
write_chunk fd _sx_streaming_bootstrap;
if !alive then alive := write_chunk fd _sx_streaming_bootstrap;
let t2 = Unix.gettimeofday () in
(* Phase 3: Evaluate :data, render :content, flush resolve scripts.
Uses eval_with_io so :data expressions can perform IO (e.g. sleep, fetch).
Each data item is resolved independently — IO in one item doesn't block others
from being flushed as they complete. *)
from being flushed as they complete. Bails out early on broken pipe. *)
let resolve_count = ref 0 in
if data_ast <> Nil && content_ast <> Nil then begin
if !alive && data_ast <> Nil && content_ast <> Nil then begin
(try
let data_result = eval_with_io data_ast env in
let t3_data = Unix.gettimeofday () in
@@ -2140,6 +2149,7 @@ let http_render_page_streaming env path _headers fd page_name =
Each item flushes its resolve script independently — the client sees
content appear progressively as each IO completes. *)
List.iter (fun (item, stream_id) ->
if !alive then
(try
(* IO sleep if delay specified — demonstrates async streaming *)
(match item with
@@ -2170,7 +2180,7 @@ let http_render_page_streaming env path _headers fd page_name =
let sx_source = match content_result with
| String s | SxExpr s -> s | _ -> serialize_value content_result in
let resolve_script = sx_streaming_resolve_script stream_id sx_source in
write_chunk fd resolve_script;
alive := write_chunk fd resolve_script;
incr resolve_count
with e ->
(* Error boundary: emit error fallback for this slot *)
@@ -2178,7 +2188,7 @@ let http_render_page_streaming env path _headers fd page_name =
Printf.eprintf "[sx-stream] resolve error for %s: %s\n%!" stream_id msg;
let error_sx = Printf.sprintf "(div :class \"text-rose-600 p-4 text-sm\" \"Error: %s\")"
(String.map (fun c -> if c = '"' then '\'' else c) msg) in
write_chunk fd (sx_streaming_resolve_script stream_id error_sx);
alive := write_chunk fd (sx_streaming_resolve_script stream_id error_sx);
incr resolve_count)
) data_items;
let t3 = Unix.gettimeofday () in
@@ -2190,7 +2200,7 @@ let http_render_page_streaming env path _headers fd page_name =
Printf.eprintf "[sx-stream] %s shell=%.3fs (no :data/:content)\n%!" path (t1 -. t0);
(* Phase 4: Send closing tags + end chunked response *)
if shell_tail <> "" then write_chunk fd shell_tail;
if !alive && shell_tail <> "" then ignore (write_chunk fd shell_tail);
end_chunked fd
(* ====================================================================== *)
@@ -2309,9 +2319,20 @@ let http_inject_shell_statics env static_dir sx_sxc =
) env.bindings;
let raw_defs = Buffer.contents buf in
(* Component-defs are inlined in <script type="text/sx">.
The escape_sx_string function handles </ → <\\/ inside string
literals, preventing the HTML parser from matching </script>. *)
let component_defs = raw_defs in
Escape </ → <\/ to prevent HTML parser from matching </script>. *)
let component_defs =
let len = String.length raw_defs in
let buf2 = Buffer.create (len + 64) in
for i = 0 to len - 1 do
if raw_defs.[i] = '<' && i + 1 < len && raw_defs.[i + 1] = '/' then begin
Buffer.add_string buf2 "<\\/";
end else if raw_defs.[i] = '/' && i > 0 && raw_defs.[i - 1] = '<' then
() (* skip — already handled above *)
else
Buffer.add_char buf2 raw_defs.[i]
done;
Buffer.contents buf2
in
let component_hash = Digest.string component_defs |> Digest.to_hex in
(* Compute per-file hashes for cache busting *)
let wasm_hash = file_hash (static_dir ^ "/wasm/sx_browser.bc.wasm.js") in
@@ -3428,11 +3449,14 @@ let http_mode port =
in
write_response fd response; true
end else begin
(* Full page streaming: chunked transfer encoding *)
(* Full page streaming: run in a thread so the accept loop
stays unblocked for concurrent requests. *)
let _t = Thread.create (fun () ->
(try http_render_page_streaming env path [] fd sname
with Exit -> () (* page def not found — already handled *)
with Exit -> ()
| e -> Printf.eprintf "[sx-stream] unexpected error for %s: %s\n%!" path (Printexc.to_string e);
(try Unix.close fd with _ -> ()));
(try Unix.close fd with _ -> ()))
) () in
true
end
end else

View File

@@ -0,0 +1,751 @@
/**
* sx-platform.js — Browser platform layer for the SX WASM kernel.
*
* Registers the 8 FFI host primitives and loads web adapter .sx files.
* This is the only JS needed beyond the WASM kernel itself.
*
* Usage:
* <script src="sx_browser.bc.wasm.js"></script>
* <script src="sx-platform.js"></script>
*
* Or for js_of_ocaml mode:
* <script src="sx_browser.bc.js"></script>
* <script src="sx-platform.js"></script>
*/
(function() {
"use strict";
function boot(K) {
// ================================================================
// FFI Host Primitives
// ================================================================
// Lazy module loading — islands/components call this to declare dependencies
K.registerNative("load-library!", function(args) {
var name = args[0];
if (!name) return false;
return __sxLoadLibrary(name) || false;
});
K.registerNative("host-global", function(args) {
var name = args[0];
if (typeof globalThis !== "undefined" && name in globalThis) return globalThis[name];
if (typeof window !== "undefined" && name in window) return window[name];
return null;
});
K.registerNative("host-get", function(args) {
var obj = args[0], prop = args[1];
if (obj == null) return null;
var v = obj[prop];
return v === undefined ? null : v;
});
K.registerNative("host-set!", function(args) {
var obj = args[0], prop = args[1], val = args[2];
if (obj != null) obj[prop] = val;
});
K.registerNative("host-call", function(args) {
var obj = args[0], method = args[1];
var callArgs = [];
for (var i = 2; i < args.length; i++) callArgs.push(args[i]);
if (obj == null) {
// Global function call
var fn = typeof globalThis !== "undefined" ? globalThis[method] : window[method];
if (typeof fn === "function") return fn.apply(null, callArgs);
return null;
}
if (typeof obj[method] === "function") {
try { return obj[method].apply(obj, callArgs); }
catch(e) { console.error("[sx] host-call error:", e); return null; }
}
return null;
});
K.registerNative("host-new", function(args) {
var name = args[0];
var cArgs = args.slice(1);
var Ctor = typeof globalThis !== "undefined" ? globalThis[name] : window[name];
if (typeof Ctor !== "function") return null;
switch (cArgs.length) {
case 0: return new Ctor();
case 1: return new Ctor(cArgs[0]);
case 2: return new Ctor(cArgs[0], cArgs[1]);
case 3: return new Ctor(cArgs[0], cArgs[1], cArgs[2]);
default: return new Ctor(cArgs[0], cArgs[1], cArgs[2], cArgs[3]);
}
});
K.registerNative("host-callback", function(args) {
var fn = args[0];
// Native JS function (not SX-origin) — pass through
if (typeof fn === "function" && fn.__sx_handle === undefined) return fn;
// SX callable (has __sx_handle) — wrap as JS function with suspension handling
if (fn && fn.__sx_handle !== undefined) {
return function() {
var a = Array.prototype.slice.call(arguments);
var result = K.callFn(fn, a);
// Handle IO suspension chain (e.g. wait, fetch, navigate)
_driveAsync(result);
return result;
};
}
return function() {};
});
/**
* Drive an async suspension chain to completion.
* When K.callFn returns {suspended: true, request: ..., resume: fn},
* handle the IO operation and resume the VM.
*/
function _driveAsync(result) {
if (!result || !result.suspended) return;
console.log("[sx] IO suspension:", JSON.stringify(result.request, null, 2));
var req = result.request;
if (!req) return;
// req is an SX list — extract items. K returns SX values.
var items = req.items || req;
var op = (items && items[0]) || req;
// Normalize: op might be a string or {name: "..."} symbol
var opName = (typeof op === "string") ? op : (op && op.name) || String(op);
if (opName === "wait" || opName === "io-sleep") {
// (wait ms) or (io-sleep ms) — resume after timeout
var ms = (items && items[1]) || 0;
if (typeof ms !== "number") ms = parseFloat(ms) || 0;
console.log("[sx] IO wait: " + ms + "ms, resuming after timeout");
setTimeout(function() {
try {
var resumed = result.resume(null);
console.log("[sx] IO resumed:", typeof resumed, resumed && resumed.suspended ? "suspended-again" : "done", JSON.stringify(resumed));
_driveAsync(resumed);
} catch(e) {
console.error("[sx] IO resume error:", e);
}
}, ms);
} else if (opName === "navigate") {
// (navigate url) — browser navigation
var url = (items && items[1]) || "/";
if (typeof url !== "string") url = String(url);
window.location.href = url;
} else {
console.warn("[sx] Unhandled IO suspension in callback:", opName, req);
}
}
K.registerNative("host-typeof", function(args) {
var obj = args[0];
if (obj == null) return "nil";
if (obj instanceof Element) return "element";
if (obj instanceof Text) return "text";
if (obj instanceof DocumentFragment) return "fragment";
if (obj instanceof Document) return "document";
if (obj instanceof Event) return "event";
if (obj instanceof Promise) return "promise";
if (obj instanceof AbortController) return "abort-controller";
return typeof obj;
});
K.registerNative("host-await", function(args) {
var promise = args[0], callback = args[1];
if (promise && typeof promise.then === "function") {
var cb;
if (typeof callback === "function") cb = callback;
else if (callback && callback.__sx_handle !== undefined)
cb = function(v) { return K.callFn(callback, [v]); };
else cb = function() {};
promise.then(cb);
}
});
// ================================================================
// Constants expected by .sx files
// ================================================================
K.eval('(define SX_VERSION "wasm-1.0")');
K.eval('(define SX_ENGINE "ocaml-vm-wasm")');
K.eval('(define parse sx-parse)');
K.eval('(define serialize sx-serialize)');
// ================================================================
// DOM query helpers used by boot.sx / orchestration.sx
// (These are JS-native in the transpiled bundle; here via FFI.)
// ================================================================
K.registerNative("query-sx-scripts", function(args) {
var root = (args[0] && args[0] !== null) ? args[0] : document;
if (typeof root.querySelectorAll !== "function") root = document;
return Array.prototype.slice.call(root.querySelectorAll('script[type="text/sx"]'));
});
K.registerNative("query-page-scripts", function(args) {
return Array.prototype.slice.call(document.querySelectorAll('script[type="text/sx-pages"]'));
});
K.registerNative("query-component-scripts", function(args) {
var root = (args[0] && args[0] !== null) ? args[0] : document;
if (typeof root.querySelectorAll !== "function") root = document;
return Array.prototype.slice.call(root.querySelectorAll('script[type="text/sx"][data-components]'));
});
// localStorage
K.registerNative("local-storage-get", function(args) {
try { var v = localStorage.getItem(args[0]); return v === null ? null : v; }
catch(e) { return null; }
});
K.registerNative("local-storage-set", function(args) {
try { localStorage.setItem(args[0], args[1]); } catch(e) {}
});
K.registerNative("local-storage-remove", function(args) {
try { localStorage.removeItem(args[0]); } catch(e) {}
});
// log-info/log-warn defined in browser.sx; log-error as native fallback
K.registerNative("log-error", function(args) { console.error.apply(console, ["[sx]"].concat(args)); });
// Cookie access (browser-side)
K.registerNative("get-cookie", function(args) {
var name = args[0];
var match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
});
K.registerNative("set-cookie", function(args) {
document.cookie = args[0] + "=" + encodeURIComponent(args[1] || "") + ";path=/;max-age=31536000;SameSite=Lax";
});
// IntersectionObserver — native JS to avoid bytecode callback issues
K.registerNative("observe-intersection", function(args) {
var el = args[0], callback = args[1], once = args[2], delay = args[3];
var obs = new IntersectionObserver(function(entries) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) {
var d = (delay && delay !== null) ? delay : 0;
setTimeout(function() { K.callFn(callback, []); }, d);
if (once) obs.unobserve(el);
}
}
});
obs.observe(el);
return obs;
});
// ================================================================
// Load SX web libraries and adapters
// ================================================================
// Load order follows dependency graph:
// 1. Core spec files (parser, render, primitives already compiled into WASM kernel)
// 2. Spec modules: signals, deps, router, page-helpers
// 3. Bytecode compiler + VM (for JIT in browser)
// 4. Web libraries: dom.sx, browser.sx (built on 8 FFI primitives)
// 5. Web adapters: adapter-html, adapter-sx, adapter-dom
// 6. Web framework: engine, orchestration, boot
var _baseUrl = "";
// Detect base URL and cache-bust params from current script tag.
// _cacheBust comes from the script's own ?v= query string (used for .sx source fallback).
// _sxbcCacheBust comes from data-sxbc-hash attribute — a separate content hash
// covering all .sxbc files so each file gets its own correct cache buster.
var _cacheBust = "";
var _sxbcCacheBust = "";
(function() {
if (typeof document !== "undefined") {
var scripts = document.getElementsByTagName("script");
for (var i = scripts.length - 1; i >= 0; i--) {
var src = scripts[i].src || "";
if (src.indexOf("sx-platform") !== -1) {
_baseUrl = src.substring(0, src.lastIndexOf("/") + 1);
var qi = src.indexOf("?");
if (qi !== -1) _cacheBust = src.substring(qi);
var sxbcHash = scripts[i].getAttribute("data-sxbc-hash");
if (sxbcHash) _sxbcCacheBust = "?v=" + sxbcHash;
break;
}
}
}
})();
/**
* Deserialize type-tagged JSON constant back to JS value for loadModule.
*/
function deserializeConstant(c) {
if (!c || !c.t) return null;
switch (c.t) {
case 's': return c.v;
case 'n': return c.v;
case 'b': return c.v;
case 'nil': return null;
case 'sym': return { _type: 'symbol', name: c.v };
case 'kw': return { _type: 'keyword', name: c.v };
case 'list': return { _type: 'list', items: (c.v || []).map(deserializeConstant) };
case 'code': return {
_type: 'dict',
bytecode: { _type: 'list', items: c.v.bytecode },
constants: { _type: 'list', items: (c.v.constants || []).map(deserializeConstant) },
arity: c.v.arity || 0,
'upvalue-count': c.v['upvalue-count'] || 0,
locals: c.v.locals || 0,
};
case 'dict': {
var d = { _type: 'dict' };
for (var k in c.v) d[k] = deserializeConstant(c.v[k]);
return d;
}
default: return null;
}
}
/**
* Convert a parsed SX code form ({_type:"list", items:[symbol"code", ...]})
* into the dict format that K.loadModule / js_to_value expects.
* Mirrors the OCaml convert_code/convert_const in sx_browser.ml.
*/
function convertCodeForm(form) {
if (!form || form._type !== "list" || !form.items || !form.items.length) return null;
var items = form.items;
if (!items[0] || items[0]._type !== "symbol" || items[0].name !== "code") return null;
var d = { _type: "dict", arity: 0, "upvalue-count": 0 };
for (var i = 1; i < items.length; i++) {
var item = items[i];
if (item && item._type === "keyword" && i + 1 < items.length) {
var val = items[i + 1];
if (item.name === "arity" || item.name === "upvalue-count") {
d[item.name] = (typeof val === "number") ? val : 0;
} else if (item.name === "bytecode" && val && val._type === "list") {
d.bytecode = val; // {_type:"list", items:[numbers...]}
} else if (item.name === "constants" && val && val._type === "list") {
d.constants = { _type: "list", items: (val.items || []).map(convertConst) };
}
i++; // skip value
}
}
return d;
}
function convertConst(c) {
if (!c || typeof c !== "object") return c; // number, string, boolean, null pass through
if (c._type === "list" && c.items && c.items.length > 0) {
var head = c.items[0];
if (head && head._type === "symbol" && head.name === "code") {
return convertCodeForm(c);
}
if (head && head._type === "symbol" && head.name === "list") {
return { _type: "list", items: c.items.slice(1).map(convertConst) };
}
}
return c; // symbols, keywords, etc. pass through
}
/**
* Try loading a pre-compiled .sxbc bytecode module (SX text format).
* Uses K.loadModule which handles VM suspension (import requests).
* Returns true on success, null on failure (caller falls back to .sx source).
*/
function loadBytecodeFile(path) {
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
var url = _baseUrl + sxbcPath + _sxbcCacheBust;
try {
var xhr = new XMLHttpRequest();
xhr.open("GET", url, false);
xhr.send();
if (xhr.status !== 200) return null;
// Parse the sxbc text to get the SX tree
var parsed = K.parse(xhr.responseText);
if (!parsed || !parsed.length) return null;
var sxbc = parsed[0]; // (sxbc version hash (code ...))
if (!sxbc || sxbc._type !== "list" || !sxbc.items) return null;
// Extract the code form — 3rd or 4th item (after sxbc, version, optional hash)
var codeForm = null;
for (var i = 1; i < sxbc.items.length; i++) {
var item = sxbc.items[i];
if (item && item._type === "list" && item.items && item.items.length > 0 &&
item.items[0] && item.items[0]._type === "symbol" && item.items[0].name === "code") {
codeForm = item;
break;
}
}
if (!codeForm) return null;
// Convert the SX code form to a dict for loadModule
var moduleDict = convertCodeForm(codeForm);
if (!moduleDict) return null;
// Load via K.loadModule which handles VmSuspended
var result = K.loadModule(moduleDict);
// Handle import suspensions — fetch missing libraries on demand
while (result && result.suspended && result.op === "import") {
var req = result.request;
var libName = req && req.library;
if (libName) {
// Try to find and load the library from the manifest
var loaded = handleImportSuspension(libName);
if (!loaded) {
console.warn("[sx-platform] lazy import: library not found:", libName);
}
}
// Resume the suspended module (null = library is now in env)
result = result.resume(null);
}
if (typeof result === 'string' && result.indexOf('Error') === 0) {
console.warn("[sx-platform] bytecode FAIL " + path + ":", result);
return null;
}
return true;
} catch(e) {
console.warn("[sx-platform] bytecode FAIL " + path + ":", e.message || e);
return null;
}
}
/**
* Handle an import suspension by finding and loading the library.
* The library name may be an SX value (list/string) — normalize to manifest key.
*/
function handleImportSuspension(libSpec) {
// libSpec from the kernel is the library name spec, e.g. {_type:"list", items:[{name:"sx"},{name:"dom"}]}
// or a string like "sx dom"
var key;
if (typeof libSpec === "string") {
key = libSpec;
} else if (libSpec && libSpec._type === "list" && libSpec.items) {
key = libSpec.items.map(function(item) {
return (item && item.name) ? item.name : String(item);
}).join(" ");
} else if (libSpec && libSpec._type === "dict") {
// Dict with key/name fields
key = libSpec.key || libSpec.name || "";
} else {
key = String(libSpec);
}
if (_loadedLibs[key]) return true; // already loaded
if (!_manifest) loadManifest();
if (!_manifest || !_manifest[key]) {
console.warn("[sx-platform] lazy import: unknown library key '" + key + "'");
return false;
}
// Load the library (and its deps) on demand
return loadLibrary(key, {});
}
/**
* Load an .sx file synchronously via XHR (boot-time only).
* Returns the number of expressions loaded, or an error string.
*/
function loadSxFile(path) {
var url = _baseUrl + path + _cacheBust;
try {
var xhr = new XMLHttpRequest();
xhr.open("GET", url, false); // synchronous
xhr.send();
if (xhr.status === 200) {
var result = K.load(xhr.responseText);
if (typeof result === "string" && result.indexOf("Error") === 0) {
console.error("[sx-platform] FAIL " + path + ":", result);
return 0;
}
console.log("[sx-platform] ok " + path + " (" + result + " exprs)");
return result;
} else {
console.error("[sx] Failed to fetch " + path + ": HTTP " + xhr.status);
return null;
}
} catch(e) {
console.error("[sx] Failed to load " + path + ":", e);
return null;
}
}
// ================================================================
// Manifest-driven module loader — only loads what's needed
// ================================================================
var _manifest = null;
var _loadedLibs = {};
/**
* Fetch and parse the module manifest (library deps + file paths).
*/
function loadManifest() {
if (_manifest) return _manifest;
try {
var xhr = new XMLHttpRequest();
xhr.open("GET", _baseUrl + "sx/module-manifest.json" + _cacheBust, false);
xhr.send();
if (xhr.status === 200) {
_manifest = JSON.parse(xhr.responseText);
return _manifest;
}
} catch(e) {}
console.warn("[sx-platform] No manifest found, falling back to full load");
return null;
}
/**
* Load a single library and all its dependencies (recursive).
* Cycle-safe: tracks in-progress loads to break circular deps.
* Functions in cyclic modules resolve symbols at call time via global env.
*/
function loadLibrary(name, loading) {
if (_loadedLibs[name]) return true;
if (loading[name]) return true; // cycle — skip
loading[name] = true;
var info = _manifest[name];
if (!info) {
console.warn("[sx-platform] Unknown library: " + name);
return false;
}
// Resolve deps first
for (var i = 0; i < info.deps.length; i++) {
loadLibrary(info.deps[i], loading);
}
// Mark as loaded BEFORE executing — self-imports (define-library re-exports)
// will see it as already loaded and skip rather than infinite-looping.
_loadedLibs[name] = true;
// Load this module
var ok = loadBytecodeFile("sx/" + info.file);
if (!ok) {
var sxFile = info.file.replace(/\.sxbc$/, '.sx');
ok = loadSxFile("sx/" + sxFile);
}
return !!ok;
}
/**
* Load web stack using the module manifest.
* Only downloads libraries that the entry point transitively depends on.
*/
function loadWebStack() {
var manifest = loadManifest();
if (!manifest) return loadWebStackFallback();
var entry = manifest["_entry"];
if (!entry) {
console.warn("[sx-platform] No _entry in manifest, falling back");
return loadWebStackFallback();
}
var loading = {};
var t0 = performance.now();
if (K.beginModuleLoad) K.beginModuleLoad();
// Load all entry point deps recursively
for (var i = 0; i < entry.deps.length; i++) {
loadLibrary(entry.deps[i], loading);
}
// Load entry point itself (boot.sx — not a library, just defines + init)
loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx'));
if (K.endModuleLoad) K.endModuleLoad();
var count = Object.keys(_loadedLibs).length + 1; // +1 for entry
var dt = Math.round(performance.now() - t0);
console.log("[sx-platform] Loaded " + count + " modules in " + dt + "ms (manifest-driven)");
}
/**
* Fallback: load all files in hardcoded order (pre-manifest compat).
*/
function loadWebStackFallback() {
var files = [
"sx/render.sx", "sx/core-signals.sx", "sx/signals.sx", "sx/deps.sx",
"sx/router.sx", "sx/page-helpers.sx", "sx/freeze.sx", "sx/highlight.sx",
"sx/bytecode.sx", "sx/compiler.sx", "sx/vm.sx", "sx/dom.sx", "sx/browser.sx",
"sx/adapter-html.sx", "sx/adapter-sx.sx", "sx/adapter-dom.sx",
"sx/boot-helpers.sx", "sx/hypersx.sx", "sx/harness.sx",
"sx/harness-reactive.sx", "sx/harness-web.sx",
"sx/engine.sx", "sx/orchestration.sx",
"sx/hs-tokenizer.sx", "sx/hs-parser.sx", "sx/hs-compiler.sx",
"sx/hs-runtime.sx", "sx/hs-integration.sx",
"sx/boot.sx",
];
if (K.beginModuleLoad) K.beginModuleLoad();
for (var i = 0; i < files.length; i++) {
if (!loadBytecodeFile(files[i])) loadSxFile(files[i]);
}
if (K.endModuleLoad) K.endModuleLoad();
console.log("[sx-platform] Loaded " + files.length + " files (fallback)");
}
/**
* Load an optional library on demand (e.g., highlight, harness).
* Can be called after boot for pages that need extra modules.
*/
globalThis.__sxLoadLibrary = function(name) {
if (!_manifest) loadManifest();
if (!_manifest) return false;
if (_loadedLibs[name]) return true;
if (K.beginModuleLoad) K.beginModuleLoad();
var ok = loadLibrary(name, {});
if (K.endModuleLoad) K.endModuleLoad();
return ok;
};
// ================================================================
// Transparent lazy loading — symbol → library index
//
// When the VM hits an undefined symbol, the resolve hook checks this
// index, loads the library that exports it, and returns the value.
// The programmer just calls the function — loading is invisible.
// ================================================================
var _symbolIndex = null; // symbol name → library key
function buildSymbolIndex() {
if (_symbolIndex) return _symbolIndex;
if (!_manifest) loadManifest();
if (!_manifest) return null;
_symbolIndex = {};
for (var key in _manifest) {
if (key.startsWith('_')) continue;
var entry = _manifest[key];
if (entry.exports) {
for (var i = 0; i < entry.exports.length; i++) {
_symbolIndex[entry.exports[i]] = key;
}
}
}
return _symbolIndex;
}
// Register the resolve hook — called by the VM when GLOBAL_GET fails
K.registerNative("__resolve-symbol", function(args) {
var name = args[0];
if (!name) return null;
var idx = buildSymbolIndex();
if (!idx || !idx[name]) return null;
var lib = idx[name];
if (_loadedLibs[lib]) return null; // already loaded but symbol still missing — real error
// Load the library
__sxLoadLibrary(lib);
// Return null — the VM will re-lookup in globals after the hook loads the module
return null;
});
// ================================================================
// Compatibility shim — expose Sx global matching current JS API
// ================================================================
globalThis.Sx = {
VERSION: "wasm-1.0",
parse: function(src) { return K.parse(src); },
eval: function(src) { return K.eval(src); },
load: function(src) { return K.load(src); },
renderToHtml: function(expr) { return K.renderToHtml(expr); },
callFn: function(fn, args) { return K.callFn(fn, args); },
engine: function() { return K.engine(); },
// Boot entry point (called by auto-init or manually)
init: function() {
if (typeof K.eval === "function") {
// Check boot-init exists
// Step through boot manually
console.log("[sx] init-css-tracking...");
K.eval("(init-css-tracking)");
console.log("[sx] process-page-scripts...");
K.eval("(process-page-scripts)");
console.log("[sx] routes after pages:", K.eval("(len _page-routes)"));
console.log("[sx] process-sx-scripts...");
K.eval("(process-sx-scripts nil)");
console.log("[sx] sx-hydrate-elements...");
K.eval("(sx-hydrate-elements nil)");
console.log("[sx] sx-hydrate-islands...");
K.eval("(sx-hydrate-islands nil)");
console.log("[sx] process-elements...");
K.eval("(process-elements nil)");
// Debug islands
console.log("[sx] ~home/stepper defined?", K.eval("(type-of ~home/stepper)"));
console.log("[sx] ~layouts/header defined?", K.eval("(type-of ~layouts/header)"));
// Island count (JS-side, avoids VM overhead)
console.log("[sx] manual island query:", document.querySelectorAll("[data-sx-island]").length);
// Try hydrating again
console.log("[sx] retry hydrate-islands...");
K.eval("(sx-hydrate-islands nil)");
// Check if links are boosted
var links = document.querySelectorAll("a[href]");
var boosted = 0;
for (var i = 0; i < links.length; i++) {
if (links[i]._sxBoundboost) boosted++;
}
console.log("[sx] boosted links:", boosted, "/", links.length);
// Check island state
var islands = document.querySelectorAll("[data-sx-island]");
console.log("[sx] islands:", islands.length);
for (var j = 0; j < islands.length; j++) {
console.log("[sx] island:", islands[j].getAttribute("data-sx-island"),
"hydrated:", !!islands[j]._sxBoundislandhydrated || !!islands[j]["_sxBound" + "island-hydrated"],
"children:", islands[j].children.length);
}
// Activate _hyperscript compat on elements with _ attribute
if (document.querySelector('[_]')) {
if (K.beginModuleLoad) K.beginModuleLoad();
loadLibrary("hyperscript integration", {});
if (K.endModuleLoad) K.endModuleLoad();
K.eval("(hs-boot!)");
}
// Register popstate handler for back/forward navigation
window.addEventListener("popstate", function(e) {
var state = e.state;
var scrollY = (state && state.scrollY) ? state.scrollY : 0;
K.eval("(handle-popstate " + scrollY + ")");
});
// Signal boot complete
document.documentElement.setAttribute("data-sx-ready", "true");
console.log("[sx] boot done");
}
}
};
// ================================================================
// Auto-init: load web stack and boot on DOMContentLoaded
// ================================================================
if (typeof document !== "undefined") {
var _doInit = function() {
loadWebStack();
Sx.init();
// Enable JIT after all boot code has run.
// Lazy-load the compiler first — JIT needs it to compile functions.
setTimeout(function() {
if (K.beginModuleLoad) K.beginModuleLoad();
loadLibrary("sx compiler", {});
if (K.endModuleLoad) K.endModuleLoad();
K.eval('(enable-jit!)');
}, 0);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _doInit);
} else {
_doInit();
}
}
} // end boot
// SxKernel is available synchronously (js_of_ocaml) or after async
// WASM init. Poll briefly to handle both cases.
var K = globalThis.SxKernel;
if (K) { boot(K); return; }
var tries = 0;
var poll = setInterval(function() {
K = globalThis.SxKernel;
if (K) { clearInterval(poll); boot(K); }
else if (++tries > 100) { clearInterval(poll); console.error("[sx-platform] SxKernel not found after 5s"); }
}, 50);
})();

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -71,16 +71,37 @@
(let
((compiled-body (hs-to-sx body))
(wrapped-body
(if catch-info
(let ((var (make-symbol (first catch-info)))
(catch-body (hs-to-sx (nth catch-info 1))))
(if finally-info
(list (quote do) (list (quote guard) (list var (list true catch-body)) compiled-body) (hs-to-sx finally-info))
(list (quote guard) (list var (list true catch-body)) compiled-body)))
(if finally-info
(list (quote do) compiled-body (hs-to-sx finally-info))
(if
catch-info
(let
((var (make-symbol (first catch-info)))
(catch-body
(hs-to-sx (nth catch-info 1))))
(if
finally-info
(list
(quote do)
(list
(quote guard)
(list var (list true catch-body))
compiled-body)
(hs-to-sx finally-info))
(list
(quote guard)
(list var (list true catch-body))
compiled-body)))
(handler (list (quote fn) (list (quote event)) wrapped-body)))
(if
finally-info
(list
(quote do)
compiled-body
(hs-to-sx finally-info))
compiled-body)))
(handler
(list
(quote fn)
(list (quote event))
wrapped-body)))
(if
every?
(list
@@ -106,12 +127,37 @@
catch-info
finally-info))
((= (first items) :every)
(scan-on (rest (rest items)) source filter true catch-info finally-info))
(scan-on
(rest (rest items))
source
filter
true
catch-info
finally-info))
((= (first items) :catch)
(scan-on (rest (rest items)) source filter every? (nth items 1) finally-info))
(scan-on
(rest (rest items))
source
filter
every?
(nth items 1)
finally-info))
((= (first items) :finally)
(scan-on (rest (rest items)) source filter every? catch-info (nth items 1)))
(true (scan-on (rest items) source filter every? catch-info finally-info)))))
(scan-on
(rest (rest items))
source
filter
every?
catch-info
(nth items 1)))
(true
(scan-on
(rest items)
source
filter
every?
catch-info
finally-info)))))
(scan-on (rest parts) nil nil false nil nil)))))
(define
emit-send
@@ -223,13 +269,11 @@
(define
emit-inc
(fn
(expr tgt-override)
(expr amount tgt-override)
(cond
((and (list? expr) (= (first expr) (quote attr)))
(let
((t (hs-to-sx expr)))
(if
(and (list? expr) (= (first expr) (quote attr)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (hs-to-sx (nth expr 2)))))
((el (if tgt-override (hs-to-sx tgt-override) (quote me))))
(list
(quote dom-set-attr)
el
@@ -239,18 +283,46 @@
(list
(quote parse-number)
(list (quote dom-get-attr) el (nth expr 1)))
1)))
(list (quote set!) t (list (quote +) t 1))))))
amount))))
((and (list? expr) (= (first expr) dot-sym))
(let
((obj (hs-to-sx (nth expr 1))) (prop (nth expr 2)))
(list
(quote host-set)
obj
prop
(list
(quote +)
(list
(quote parse-number)
(list (quote host-get) obj prop))
amount))))
((and (list? expr) (= (first expr) (quote style)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (quote me)))
(prop (nth expr 1)))
(list
(quote dom-set-style)
el
prop
(list
(quote +)
(list
(quote parse-number)
(list (quote dom-get-style) el prop))
amount))))
(true
(let
((t (hs-to-sx expr)))
(list (quote set!) t (list (quote +) t amount)))))))
(define
emit-dec
(fn
(expr tgt-override)
(expr amount tgt-override)
(cond
((and (list? expr) (= (first expr) (quote attr)))
(let
((t (hs-to-sx expr)))
(if
(and (list? expr) (= (first expr) (quote attr)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (hs-to-sx (nth expr 2)))))
((el (if tgt-override (hs-to-sx tgt-override) (quote me))))
(list
(quote dom-set-attr)
el
@@ -260,8 +332,38 @@
(list
(quote parse-number)
(list (quote dom-get-attr) el (nth expr 1)))
1)))
(list (quote set!) t (list (quote -) t 1))))))
amount))))
((and (list? expr) (= (first expr) dot-sym))
(let
((obj (hs-to-sx (nth expr 1))) (prop (nth expr 2)))
(list
(quote host-set)
obj
prop
(list
(quote -)
(list
(quote parse-number)
(list (quote host-get) obj prop))
amount))))
((and (list? expr) (= (first expr) (quote style)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (quote me)))
(prop (nth expr 1)))
(list
(quote dom-set-style)
el
prop
(list
(quote -)
(list
(quote parse-number)
(list (quote dom-get-style) el prop))
amount))))
(true
(let
((t (hs-to-sx expr)))
(list (quote set!) t (list (quote -) t amount)))))))
(define
emit-behavior
(fn
@@ -654,13 +756,23 @@
(hs-to-sx (nth ast 2))
(nth ast 1)))
((= head (quote multi-add-class))
(let ((target (hs-to-sx (nth ast 1)))
(let
((target (hs-to-sx (nth ast 1)))
(classes (rest (rest ast))))
(cons (quote do) (map (fn (cls) (list (quote dom-add-class) target cls)) classes))))
(cons
(quote do)
(map
(fn (cls) (list (quote dom-add-class) target cls))
classes))))
((= head (quote multi-remove-class))
(let ((target (hs-to-sx (nth ast 1)))
(let
((target (hs-to-sx (nth ast 1)))
(classes (rest (rest ast))))
(cons (quote do) (map (fn (cls) (list (quote dom-remove-class) target cls)) classes))))
(cons
(quote do)
(map
(fn (cls) (list (quote dom-remove-class) target cls))
classes))))
((= head (quote remove-class))
(list
(quote dom-remove-class)
@@ -677,6 +789,30 @@
(hs-to-sx (nth ast 3))
(nth ast 1)
(nth ast 2)))
((= head (quote toggle-style))
(list
(quote hs-toggle-style!)
(hs-to-sx (nth ast 2))
(nth ast 1)))
((= head (quote toggle-style-between))
(list
(quote hs-toggle-style-between!)
(hs-to-sx (nth ast 4))
(nth ast 1)
(hs-to-sx (nth ast 2))
(hs-to-sx (nth ast 3))))
((= head (quote toggle-attr))
(list
(quote hs-toggle-attr!)
(hs-to-sx (nth ast 2))
(nth ast 1)))
((= head (quote toggle-attr-between))
(list
(quote hs-toggle-attr-between!)
(hs-to-sx (nth ast 4))
(nth ast 1)
(hs-to-sx (nth ast 2))
(hs-to-sx (nth ast 3))))
((= head (quote set!))
(emit-set (nth ast 1) (hs-to-sx (nth ast 2))))
((= head (quote put!))
@@ -749,8 +885,21 @@
(list (list (quote me) (hs-to-sx (nth ast 1))))
(hs-to-sx (nth ast 2))))
((= head (quote for)) (emit-for ast))
((= head (quote take))
(list (quote hs-take!) (hs-to-sx (nth ast 2)) (nth ast 1)))
((= head (quote take!))
(let
((kind (nth ast 1))
(name (nth ast 2))
(from-sel (if (> (len ast) 3) (nth ast 3) nil))
(for-tgt (if (> (len ast) 4) (nth ast 4) nil)))
(let
((target (if for-tgt (hs-to-sx for-tgt) (quote me)))
(scope
(cond
((nil? from-sel) nil)
((and (list? from-sel) (= (first from-sel) (quote query)))
(list (quote hs-query-all) (nth from-sel 1)))
(true (hs-to-sx from-sel)))))
(list (quote hs-take!) target kind name scope))))
((= head (quote make)) (emit-make ast))
((= head (quote install))
(cons (quote hs-install) (map hs-to-sx (rest ast))))
@@ -759,11 +908,13 @@
((= head (quote increment!))
(emit-inc
(nth ast 1)
(if (> (len ast) 2) (nth ast 2) nil)))
(nth ast 2)
(if (> (len ast) 3) (nth ast 3) nil)))
((= head (quote decrement!))
(emit-dec
(nth ast 1)
(if (> (len ast) 2) (nth ast 2) nil)))
(nth ast 2)
(if (> (len ast) 3) (nth ast 3) nil)))
((= head (quote on)) (emit-on ast))
((= head (quote init))
(list
@@ -860,6 +1011,23 @@
(list (make-symbol (nth ast 1)))
(hs-to-sx (nth ast 3)))
(hs-to-sx (nth ast 2))))
((= head (quote scroll!))
(list
(quote hs-scroll!)
(hs-to-sx (nth ast 1))
(nth ast 2)))
((= head (quote select!))
(list (quote hs-select!) (hs-to-sx (nth ast 1))))
((= head (quote reset!))
(list (quote hs-reset!) (hs-to-sx (nth ast 1))))
((= head (quote default!))
(let
((t (hs-to-sx (nth ast 1))) (v (hs-to-sx (nth ast 2))))
(list
(quote when)
(list (quote nil?) t)
(list (quote set!) t v))))
((= head (quote halt!)) (list (quote hs-halt!) (nth ast 1)))
(true ast))))))))
;; ── Convenience: source → SX ─────────────────────────────────

View File

@@ -364,11 +364,15 @@
((match-kw "in")
(list (quote not-in?) left (parse-expr)))
((match-kw "between")
(let ((lo (parse-atom)))
(let
((lo (parse-atom)))
(match-kw "and")
(let ((hi (parse-atom)))
(list (quote not)
(list (quote and)
(let
((hi (parse-atom)))
(list
(quote not)
(list
(quote and)
(list (quote >=) left lo)
(list (quote <=) left hi))))))
((match-kw "really")
@@ -429,10 +433,13 @@
(list (quote >=) left (parse-expr)))
(list (quote >) left (parse-expr)))))
((match-kw "between")
(let ((lo (parse-atom)))
(let
((lo (parse-atom)))
(match-kw "and")
(let ((hi (parse-atom)))
(list (quote and)
(let
((hi (parse-atom)))
(list
(quote and)
(list (quote >=) left lo)
(list (quote <=) left hi)))))
((match-kw "in") (list (quote in?) left (parse-expr)))
@@ -491,10 +498,14 @@
((and (= typ "keyword") (= val "exists"))
(do (adv!) (list (quote exists?) left)))
((and (or (= typ "keyword") (= typ "ident")) (= val "starts"))
(do (adv!) (match-kw "with")
(do
(adv!)
(match-kw "with")
(list (quote starts-with?) left (parse-expr))))
((and (or (= typ "keyword") (= typ "ident")) (= val "ends"))
(do (adv!) (match-kw "with")
(do
(adv!)
(match-kw "with")
(list (quote ends-with?) left (parse-expr))))
((and (= typ "keyword") (= val "matches"))
(do (adv!) (list (quote matches?) left (parse-expr))))
@@ -687,20 +698,26 @@
(if
(= (tp-type) "class")
(let
((cls (get (adv!) "value"))
(extra-classes (list)))
;; Collect additional class refs
(define collect-classes!
(fn ()
(when (= (tp-type) "class")
(set! extra-classes (append extra-classes (list (get (adv!) "value"))))
((cls (get (adv!) "value")) (extra-classes (list)))
(define
collect-classes!
(fn
()
(when
(= (tp-type) "class")
(set!
extra-classes
(append extra-classes (list (get (adv!) "value"))))
(collect-classes!))))
(collect-classes!)
(let
((tgt (parse-tgt-kw "to" (list (quote me)))))
(if (empty? extra-classes)
(if
(empty? extra-classes)
(list (quote add-class) cls tgt)
(cons (quote multi-add-class) (cons tgt (cons cls extra-classes))))))
(cons
(quote multi-add-class)
(cons tgt (cons cls extra-classes))))))
nil)))
(define
parse-remove-cmd
@@ -709,19 +726,26 @@
(if
(= (tp-type) "class")
(let
((cls (get (adv!) "value"))
(extra-classes (list)))
(define collect-classes!
(fn ()
(when (= (tp-type) "class")
(set! extra-classes (append extra-classes (list (get (adv!) "value"))))
((cls (get (adv!) "value")) (extra-classes (list)))
(define
collect-classes!
(fn
()
(when
(= (tp-type) "class")
(set!
extra-classes
(append extra-classes (list (get (adv!) "value"))))
(collect-classes!))))
(collect-classes!)
(let
((tgt (parse-tgt-kw "from" (list (quote me)))))
(if (empty? extra-classes)
(if
(empty? extra-classes)
(list (quote remove-class) cls tgt)
(cons (quote multi-remove-class) (cons tgt (cons cls extra-classes))))))
(cons
(quote multi-remove-class)
(cons tgt (cons cls extra-classes))))))
nil)))
(define
parse-toggle-cmd
@@ -732,12 +756,12 @@
(if
(= (tp-type) "class")
(let
((cls1 (get (adv!) "value")))
((cls1 (do (let ((v (tp-val))) (adv!) v))))
(expect-kw! "and")
(if
(= (tp-type) "class")
(let
((cls2 (get (adv!) "value")))
((cls2 (do (let ((v (tp-val))) (adv!) v))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-between) cls1 cls2 tgt)))
@@ -745,10 +769,47 @@
nil))
((= (tp-type) "class")
(let
((cls (get (adv!) "value")))
((cls (do (let ((v (tp-val))) (adv!) v))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-class) cls tgt))))
((= (tp-type) "style")
(let
((prop (do (let ((v (tp-val))) (adv!) v))))
(if
(match-kw "between")
(let
((val1 (parse-atom)))
(expect-kw! "and")
(let
((val2 (parse-atom)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-style-between) prop val1 val2 tgt))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-style) prop tgt)))))
((= (tp-type) "attr")
(let
((attr-name (do (let ((v (tp-val))) (adv!) v))))
(if
(match-kw "between")
(let
((val1 (parse-atom)))
(expect-kw! "and")
(let
((val2 (parse-atom)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list
(quote toggle-attr-between)
attr-name
val1
val2
tgt))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-attr) attr-name tgt)))))
(true nil))))
(define
parse-set-cmd
@@ -772,9 +833,16 @@
(list (quote put!) value "after" (parse-expr)))
((match-kw "at")
(cond
((match-kw "start") (do (expect-kw! "of") (list (quote put!) value "start" (parse-expr))))
((match-kw "end") (do (expect-kw! "of") (list (quote put!) value "end" (parse-expr))))
(true (error (str "Expected start/end after at, position " p)))))
((match-kw "start")
(do
(expect-kw! "of")
(list (quote put!) value "start" (parse-expr))))
((match-kw "end")
(do
(expect-kw! "of")
(list (quote put!) value "end" (parse-expr))))
(true
(error (str "Expected start/end after at, position " p)))))
(true
(error (str "Expected into/before/after/at at position " p)))))))
(define
@@ -862,18 +930,22 @@
()
(let
((expr (parse-expr)))
(let
((amount (if (match-kw "by") (parse-expr) 1)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote increment!) expr tgt)))))
(list (quote increment!) expr amount tgt))))))
(define
parse-dec-cmd
(fn
()
(let
((expr (parse-expr)))
(let
((amount (if (match-kw "by") (parse-expr) 1)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote decrement!) expr tgt)))))
(list (quote decrement!) expr amount tgt))))))
(define
parse-hide-cmd
(fn
@@ -909,12 +981,25 @@
parse-repeat-cmd
(fn
()
(cond
((and (= (tp-type) "keyword") (= (tp-val) "for"))
(do (adv!) (parse-for-cmd)))
((and (= (tp-type) "keyword") (= (tp-val) "in"))
(do
(adv!)
(let
((mode (cond ((match-kw "forever") (list (quote forever))) ((match-kw "while") (list (quote while) (parse-expr))) ((match-kw "until") (list (quote until) (parse-expr))) ((= (tp-type) "number") (let ((n (parse-dur (get (adv!) "value")))) (expect-kw! "times") (list (quote times) n))) (true (list (quote forever))))))
((collection (parse-expr)))
(let
((body (parse-cmd-list)))
(match-kw "end")
(list (quote repeat) mode body)))))
(list (quote for) "it" collection nil body)))))
(true
(let
((mode (cond ((match-kw "forever") (list (quote forever))) ((match-kw "while") (list (quote while) (parse-expr))) ((match-kw "until") (list (quote until) (parse-expr))) (true (let ((n (parse-expr))) (if (match-kw "times") (list (quote times) n) (list (quote forever))))))))
(let
((body (parse-cmd-list)))
(match-kw "end")
(list (quote repeat) mode body)))))))
(define
parse-fetch-cmd
(fn
@@ -959,16 +1044,24 @@
parse-take-cmd
(fn
()
(if
(= (tp-type) "class")
(cond
((= (tp-type) "class")
(let
((cls (get (adv!) "value")))
((cls (do (let ((v (tp-val))) (adv!) v))))
(let
((tgt (if (match-kw "for")
(parse-expr)
(parse-tgt-kw "from" (list (quote me))))))
(list (quote take) cls tgt)))
nil)))
((from-sel (if (match-kw "from") (parse-expr) nil)))
(let
((for-tgt (if (match-kw "for") (parse-expr) nil)))
(list (quote take!) "class" cls from-sel for-tgt)))))
((= (tp-type) "attr")
(let
((attr-name (do (let ((v (tp-val))) (adv!) v))))
(let
((from-sel (if (match-kw "from") (parse-expr) nil)))
(let
((for-tgt (if (match-kw "for") (parse-expr) nil)))
(list (quote take!) "attr" attr-name from-sel for-tgt)))))
(true nil))))
(define
parse-go-cmd
(fn () (match-kw "to") (list (quote go) (parse-expr))))
@@ -1124,6 +1217,44 @@
(let
((tgt (parse-expr)))
(list (quote measure) (if (nil? tgt) (list (quote me)) tgt)))))
(define
parse-scroll-cmd
(fn
()
(let
((tgt (if (or (at-end?) (and (= (tp-type) "keyword") (or (= (tp-val) "then") (= (tp-val) "end")))) (list (quote me)) (parse-expr))))
(let
((pos (cond ((match-kw "top") "top") ((match-kw "bottom") "bottom") ((match-kw "left") "left") ((match-kw "right") "right") (true "top"))))
(list (quote scroll!) tgt pos)))))
(define
parse-select-cmd
(fn
()
(let
((tgt (if (or (at-end?) (and (= (tp-type) "keyword") (or (= (tp-val) "then") (= (tp-val) "end")))) (list (quote me)) (parse-expr))))
(list (quote select!) tgt))))
(define
parse-reset-cmd
(fn
()
(let
((tgt (if (or (at-end?) (and (= (tp-type) "keyword") (or (= (tp-val) "then") (= (tp-val) "end")))) (list (quote me)) (parse-expr))))
(list (quote reset!) tgt))))
(define
parse-default-cmd
(fn
()
(let
((tgt (parse-expr)))
(expect-kw! "to")
(let ((val (parse-expr))) (list (quote default!) tgt val)))))
(define
parse-halt-cmd
(fn
()
(let
((the-event (and (match-kw "the") (or (match-kw "event") (match-kw "default")))))
(list (quote halt!) (if the-event "event" "default")))))
(define
parse-param-list
(fn () (if (= (tp-type) "paren-open") (parse-call-args) (list))))
@@ -1241,7 +1372,6 @@
(let
((typ (tp-type)) (val (tp-val)))
(cond
;; Terminators — these end a command list, not start a command
((and (= typ "keyword") (or (= val "catch") (= val "finally") (= val "end") (= val "else") (= val "otherwise")))
nil)
((and (= typ "keyword") (= val "add"))
@@ -1304,11 +1434,61 @@
(do (adv!) (parse-measure-cmd)))
((and (= typ "keyword") (= val "render"))
(do (adv!) (parse-render-cmd)))
((and (= typ "keyword") (= val "scroll"))
(do (adv!) (parse-scroll-cmd)))
((and (= typ "keyword") (= val "select"))
(do (adv!) (parse-select-cmd)))
((and (= typ "keyword") (= val "reset"))
(do (adv!) (parse-reset-cmd)))
((and (= typ "keyword") (= val "default"))
(do (adv!) (parse-default-cmd)))
((and (= typ "keyword") (= val "halt"))
(do (adv!) (parse-halt-cmd)))
(true (parse-expr))))))
(define
parse-cmd-list
(fn
()
(define
cmd-kw?
(fn
(v)
(or
(= v "add")
(= v "remove")
(= v "toggle")
(= v "set")
(= v "put")
(= v "if")
(= v "wait")
(= v "send")
(= v "trigger")
(= v "log")
(= v "increment")
(= v "decrement")
(= v "hide")
(= v "show")
(= v "transition")
(= v "repeat")
(= v "fetch")
(= v "call")
(= v "take")
(= v "settle")
(= v "go")
(= v "return")
(= v "throw")
(= v "append")
(= v "tell")
(= v "for")
(= v "make")
(= v "install")
(= v "measure")
(= v "render")
(= v "halt")
(= v "default")
(= v "scroll")
(= v "select")
(= v "reset"))))
(define
cl-collect
(fn
@@ -1320,7 +1500,11 @@
acc
(let
((acc2 (append acc (list cmd))))
(if (match-kw "then") (cl-collect acc2) acc2))))))
(cond
((match-kw "then") (cl-collect acc2))
((and (not (at-end?)) (= (tp-type) "keyword") (cmd-kw? (tp-val)))
(cl-collect acc2))
(true acc2)))))))
(let
((cmds (cl-collect (list))))
(cond
@@ -1341,18 +1525,10 @@
((source (if (match-kw "from") (parse-expr) nil)))
(let
((body (parse-cmd-list)))
;; Parse optional catch/finally
(let
((catch-clause
(if (match-kw "catch")
(let ((var (let ((v (tp-val))) (adv!) v))
(handler (parse-cmd-list)))
(list var handler))
nil))
((catch-clause (if (match-kw "catch") (let ((var (let ((v (tp-val))) (adv!) v)) (handler (parse-cmd-list))) (list var handler)) nil))
(finally-clause
(if (match-kw "finally")
(parse-cmd-list)
nil)))
(if (match-kw "finally") (parse-cmd-list) nil)))
(match-kw "end")
(let
((parts (list (quote on) event-name)))
@@ -1362,9 +1538,12 @@
((parts (if flt (append parts (list :filter flt)) parts)))
(let
((parts (if source (append parts (list :from source)) parts)))
(let ((parts (if catch-clause (append parts (list :catch catch-clause)) parts)))
(let ((parts (if finally-clause (append parts (list :finally finally-clause)) parts)))
(let ((parts (append parts (list body))))
(let
((parts (if catch-clause (append parts (list :catch catch-clause)) parts)))
(let
((parts (if finally-clause (append parts (list :finally finally-clause)) parts)))
(let
((parts (append parts (list body))))
parts)))))))))))))))
(define
parse-init-feat
@@ -1403,5 +1582,4 @@
(first features)
(cons (quote do) features))))))
;; ── Convenience: source string → AST ─────────────────────────────
(define hs-compile (fn (src) (hs-parse (hs-tokenize src) src)))

View File

@@ -64,22 +64,50 @@
;; Take a class from siblings — add to target, remove from others.
;; (hs-take! target cls) — like radio button class behavior
(define
hs-take!
hs-toggle-style!
(fn
(target cls)
(target prop)
(let
((parent (dom-parent target)))
(when
parent
(for-each
(fn (child) (dom-remove-class child cls))
(dom-child-list parent)))
(dom-add-class target cls))))
((cur (dom-get-style target prop)))
(cond
((= prop "visibility")
(if
(= cur "hidden")
(dom-set-style target prop "visible")
(dom-set-style target prop "hidden")))
((or (= prop "display") (= prop "opacity"))
(if
(or (= cur "none") (= cur "0"))
(dom-set-style target prop "")
(dom-set-style target prop (if (= prop "display") "none" "0"))))
(true
(if
(or (= cur "") (= cur nil))
(dom-set-style target prop "hidden")
(dom-set-style target prop "")))))))
;; ── DOM insertion ───────────────────────────────────────────────
;; Put content at a position relative to a target.
;; pos: "into" | "before" | "after"
(define
hs-take!
(fn
(target kind name scope)
(let
((els (if scope (if (list? scope) scope (list scope)) (let ((parent (host-get target "parentNode"))) (if parent (dom-child-list parent) (list))))))
(if
(= kind "class")
(do
(for-each (fn (el) (dom-remove-class el name)) els)
(dom-add-class target name))
(do
(for-each (fn (el) (dom-remove-attr el name)) els)
(dom-set-attr target name "true"))))))
;; ── Navigation / traversal ──────────────────────────────────────
;; Navigate to a URL.
(define
hs-put!
(fn
@@ -92,12 +120,38 @@
((= pos "start") (dom-insert-adjacent-html target "afterbegin" value))
((= pos "end") (dom-insert-adjacent-html target "beforeend" value)))))
;; ── Navigation / traversal ──────────────────────────────────────
;; Navigate to a URL.
;; Find next sibling matching a selector (or any sibling).
(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url))))
;; Find next sibling matching a selector (or any sibling).
;; Find previous sibling matching a selector.
(define
hs-scroll!
(fn
(target position)
(host-call
target
"scrollIntoView"
(list
(cond
((= position "bottom") (dict :block "end"))
(true (dict :block "start")))))))
;; First element matching selector within a scope.
(define
hs-halt!
(fn
(mode)
(when
event
(host-call event "preventDefault" (list))
(when (= mode "event") (host-call event "stopPropagation" (list))))))
;; Last element matching selector.
(define hs-select! (fn (target) (host-call target "select" (list))))
;; First/last within a specific scope.
(define hs-reset! (fn (target) (host-call target "reset" (list))))
(define
hs-next
(fn
@@ -117,7 +171,9 @@
(true (find-next (dom-next-sibling el))))))
(find-next sibling)))))
;; Find previous sibling matching a selector.
;; ── Iteration ───────────────────────────────────────────────────
;; Repeat a thunk N times.
(define
hs-previous
(fn
@@ -137,12 +193,27 @@
(true (find-prev (dom-get-prop el "previousElementSibling"))))))
(find-prev sibling)))))
;; First element matching selector within a scope.
;; Repeat forever (until break — relies on exception/continuation).
(define
hs-query-all
(fn
(sel)
(dom-query-all
(host-call (host-global "document") "querySelector" (list "body"))
sel)))
;; ── Fetch ───────────────────────────────────────────────────────
;; Fetch a URL, parse response according to format.
;; (hs-fetch url format) — format is "json" | "text" | "html"
(define
hs-query-first
(fn (sel) (host-call (host-global "document") "querySelector" sel)))
;; Last element matching selector.
;; ── Type coercion ───────────────────────────────────────────────
;; Coerce a value to a type by name.
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
(define
hs-query-last
(fn
@@ -151,9 +222,17 @@
((all (dom-query-all (dom-body) sel)))
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
;; First/last within a specific scope.
;; ── Object creation ─────────────────────────────────────────────
;; Make a new object of a given type.
;; (hs-make type-name) — creates empty object/collection
(define hs-first (fn (scope sel) (dom-query-all scope sel)))
;; ── Behavior installation ───────────────────────────────────────
;; Install a behavior on an element.
;; A behavior is a function that takes (me ...params) and sets up features.
;; (hs-install behavior-fn me ...args)
(define
hs-last
(fn
@@ -162,9 +241,10 @@
((all (dom-query-all scope sel)))
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
;; ── Iteration ───────────────────────────────────────────────────
;; ── Measurement ─────────────────────────────────────────────────
;; Repeat a thunk N times.
;; Measure an element's bounding rect, store as local variables.
;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define
hs-repeat-times
(fn
@@ -174,7 +254,10 @@
(fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1))))))
(do-repeat 0)))
;; Repeat forever (until break — relies on exception/continuation).
;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration.
;; (hs-transition target prop value duration)
(define
hs-repeat-forever
(fn
@@ -182,10 +265,6 @@
(define do-forever (fn () (thunk) (do-forever)))
(do-forever)))
;; ── Fetch ───────────────────────────────────────────────────────
;; Fetch a URL, parse response according to format.
;; (hs-fetch url format) — format is "json" | "text" | "html"
(define
hs-fetch
(fn
@@ -198,10 +277,6 @@
((= format "html") (perform (list (quote io-parse-html) response)))
(true response)))))
;; ── Type coercion ───────────────────────────────────────────────
;; Coerce a value to a type by name.
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
(define
hs-coerce
(fn
@@ -235,19 +310,10 @@
((= type-name "Date") (str value))
(true value))))
;; ── Object creation ─────────────────────────────────────────────
;; Make a new object of a given type.
;; (hs-make type-name) — creates empty object/collection
(define
hs-add
(fn (a b) (if (or (string? a) (string? b)) (str a b) (+ a b))))
;; ── Behavior installation ───────────────────────────────────────
;; Install a behavior on an element.
;; A behavior is a function that takes (me ...params) and sets up features.
;; (hs-install behavior-fn me ...args)
(define
hs-make
(fn
@@ -259,16 +325,8 @@
((= type-name "Map") (dict))
(true (dict)))))
;; ── Measurement ─────────────────────────────────────────────────
;; Measure an element's bounding rect, store as local variables.
;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define hs-install (fn (behavior-fn) (behavior-fn me)))
;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration.
;; (hs-transition target prop value duration)
(define
hs-measure
(fn (target) (perform (list (quote io-measure) target))))
@@ -311,6 +369,10 @@
hs-strict-eq
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
(define
hs-falsy?
(fn
@@ -331,7 +393,8 @@
(string? target)
(if (= pattern ".*") true (string-contains? target pattern))
false)))
;; ── Sandbox/test runtime additions ──────────────────────────────
;; Property access — dot notation and .length
(define
hs-contains?
(fn
@@ -351,7 +414,7 @@
true
(hs-contains? (rest collection) item)))))
(true false))))
;; DOM query stub — sandbox returns empty list
(define
hs-empty?
(fn
@@ -362,15 +425,13 @@
((list? v) (= (len v) 0))
((dict? v) (= (len (keys v)) 0))
(true false))))
;; Method dispatch — obj.method(args)
(define hs-first (fn (lst) (first lst)))
;; ── 0.9.90 features ─────────────────────────────────────────────
;; beep! — debug logging, returns value unchanged
(define hs-last (fn (lst) (last lst)))
;; Property-based is — check obj.key truthiness
(define
hs-template
(fn
@@ -456,7 +517,7 @@
(set! i (+ i 1))
(tpl-loop)))))))
(do (tpl-loop) result))))
;; Array slicing (inclusive both ends)
(define
hs-make-object
(fn
@@ -468,8 +529,7 @@
(fn (pair) (dict-set! d (first pair) (nth pair 1)))
pairs)
d))))
;; ── Sandbox/test runtime additions ──────────────────────────────
;; Property access — dot notation and .length
;; Collection: sorted by
(define
hs-method-call
(fn
@@ -492,13 +552,11 @@
(if (= (first lst) item) i (idx-loop (rest lst) (+ i 1))))))
(idx-loop obj 0)))
(true nil))))
;; DOM query stub — sandbox returns empty list
;; Collection: sorted by descending
(define hs-beep (fn (v) v))
;; Method dispatch — obj.method(args)
;; Collection: split by
(define hs-prop-is (fn (obj key) (not (hs-falsy? (host-get obj key)))))
;; ── 0.9.90 features ─────────────────────────────────────────────
;; beep! — debug logging, returns value unchanged
;; Collection: joined by
(define
hs-slice
(fn
@@ -507,7 +565,7 @@
((s (if (nil? start) 0 start))
(e (if (nil? end) (len col) (+ end 1))))
(slice col s e))))
;; Property-based is — check obj.key truthiness
(define
hs-sorted-by
(fn
@@ -517,7 +575,7 @@
(map
(fn (p) (nth p 1))
(sort (fn (a b) (if (< (first a) (first b)) true false)) pairs)))))
;; Array slicing (inclusive both ends)
(define
hs-sorted-by-desc
(fn
@@ -527,11 +585,11 @@
(map
(fn (p) (nth p 1))
(sort (fn (a b) (if (> (first a) (first b)) true false)) pairs)))))
;; Collection: sorted by
(define hs-split-by (fn (s sep) (split s sep)))
;; Collection: sorted by descending
(define hs-joined-by (fn (col sep) (join sep col)))
;; Collection: split by
(define
hs-sorted-by
(fn
@@ -567,7 +625,7 @@
(append acc (list (nth found 1)))
(filter (fn (x) (not (= x found))) remaining)))))))
(reorder sorted-dec (list) decorated)))))
;; Collection: joined by
(define
hs-sorted-by-desc
(fn (col key-fn) (reverse (hs-sorted-by col key-fn))))

View File

@@ -161,7 +161,12 @@
"split"
"joined"
"descending"
"ascending"))
"ascending"
"scroll"
"select"
"reset"
"default"
"halt"))
(define hs-keyword? (fn (word) (some (fn (k) (= k word)) hs-keywords)))

View File

@@ -635,6 +635,13 @@
renderToHtml: function(expr) { return K.renderToHtml(expr); },
callFn: function(fn, args) { return K.callFn(fn, args); },
engine: function() { return K.engine(); },
resolveSuspense: function(id, sx) {
try {
K.eval('(resolve-suspense ' + JSON.stringify(id) + ' ' + JSON.stringify(sx) + ')');
} catch (e) {
console.error("[sx] resolveSuspense error for id=" + id, e);
}
},
// Boot entry point (called by auto-init or manually)
init: function() {
if (typeof K.eval === "function") {
@@ -682,51 +689,34 @@
var scrollY = (state && state.scrollY) ? state.scrollY : 0;
K.eval("(handle-popstate " + scrollY + ")");
});
// Process any streaming suspense resolutions that arrived before boot
if (globalThis.__sxPending) {
for (var pi = 0; pi < globalThis.__sxPending.length; pi++) {
try {
Sx.resolveSuspense(globalThis.__sxPending[pi].id, globalThis.__sxPending[pi].sx);
} catch(e) { console.error("[sx] pending resolve error:", e); }
}
globalThis.__sxPending = null;
}
// Set up direct resolution for future streaming chunks
globalThis.__sxResolve = function(id, sx) { Sx.resolveSuspense(id, sx); };
// Signal boot complete
document.documentElement.setAttribute("data-sx-ready", "true");
console.log("[sx] boot done");
}
},
// Resolve a streaming suspense placeholder via the SX kernel.
// boot.sx defines resolve-suspense but its imports may fail in WASM,
// so we define it here as a fallback using primitives that DO load.
resolveSuspense: function(id, sx) {
try {
// Ensure resolve-suspense exists (boot.sx imports may not have loaded)
if (!Sx._resolveReady) {
try { K.eval('(type-of resolve-suspense)'); } catch(_) {
K.eval('(define resolve-suspense (fn (id sx) (let ((el (dom-query (str "[data-suspense=\\"" id "\\"]")))) (when el (let ((exprs (sx-parse sx)) (env (get-render-env nil))) (dom-set-text-content el "") (for-each (fn (expr) (dom-append el (render-to-dom expr env nil))) exprs))))))');
}
Sx._resolveReady = true;
}
K.eval('(resolve-suspense "' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '" "' + sx.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '")');
} catch(e) {
console.error("[sx] resolveSuspense error for id=" + id, e);
}
}
};
// ================================================================
// Auto-init: load web stack and boot on DOMContentLoaded
// Auto-init: load web stack eagerly, boot on DOMContentLoaded
// ================================================================
if (typeof document !== "undefined") {
var _doInit = function() {
loadWebStack();
// Pre-process component scripts eagerly so resolve-suspense doesn't
// hit the 'Undefined symbol: default' error on first call.
// The error occurs during component loading but is non-fatal.
try { K.eval("(process-sx-scripts nil)"); } catch(e) {}
var _doInit = function() {
Sx.init();
// Enable JIT after all boot code has run.
// Lazy-load the compiler first — JIT needs it to compile functions.
// Drain streaming resolves that arrived before boot or were deferred on error
if (window.__sxPending) {
for (var pi = 0; pi < window.__sxPending.length; pi++) {
Sx.resolveSuspense(window.__sxPending[pi].id, window.__sxPending[pi].sx);
}
window.__sxPending = null;
}
window.__sxResolve = function(id, sx) { Sx.resolveSuspense(id, sx); };
setTimeout(function() {
if (K.beginModuleLoad) K.beginModuleLoad();
loadLibrary("sx compiler", {});

View File

@@ -728,7 +728,7 @@
(and
(not (empty? rest-args))
(= (type-of (first rest-args)) "keyword"))
(let
(letrec
((skip-annotations (fn (items) (if (empty? items) nil (if (= (type-of (first items)) "keyword") (skip-annotations (rest (rest items))) (first items))))))
(skip-annotations rest-args))
(first rest-args)))))

File diff suppressed because one or more lines are too long

View File

@@ -71,16 +71,37 @@
(let
((compiled-body (hs-to-sx body))
(wrapped-body
(if catch-info
(let ((var (make-symbol (first catch-info)))
(catch-body (hs-to-sx (nth catch-info 1))))
(if finally-info
(list (quote do) (list (quote guard) (list var (list true catch-body)) compiled-body) (hs-to-sx finally-info))
(list (quote guard) (list var (list true catch-body)) compiled-body)))
(if finally-info
(list (quote do) compiled-body (hs-to-sx finally-info))
(if
catch-info
(let
((var (make-symbol (first catch-info)))
(catch-body
(hs-to-sx (nth catch-info 1))))
(if
finally-info
(list
(quote do)
(list
(quote guard)
(list var (list true catch-body))
compiled-body)
(hs-to-sx finally-info))
(list
(quote guard)
(list var (list true catch-body))
compiled-body)))
(handler (list (quote fn) (list (quote event)) wrapped-body)))
(if
finally-info
(list
(quote do)
compiled-body
(hs-to-sx finally-info))
compiled-body)))
(handler
(list
(quote fn)
(list (quote event))
wrapped-body)))
(if
every?
(list
@@ -106,12 +127,37 @@
catch-info
finally-info))
((= (first items) :every)
(scan-on (rest (rest items)) source filter true catch-info finally-info))
(scan-on
(rest (rest items))
source
filter
true
catch-info
finally-info))
((= (first items) :catch)
(scan-on (rest (rest items)) source filter every? (nth items 1) finally-info))
(scan-on
(rest (rest items))
source
filter
every?
(nth items 1)
finally-info))
((= (first items) :finally)
(scan-on (rest (rest items)) source filter every? catch-info (nth items 1)))
(true (scan-on (rest items) source filter every? catch-info finally-info)))))
(scan-on
(rest (rest items))
source
filter
every?
catch-info
(nth items 1)))
(true
(scan-on
(rest items)
source
filter
every?
catch-info
finally-info)))))
(scan-on (rest parts) nil nil false nil nil)))))
(define
emit-send
@@ -223,13 +269,11 @@
(define
emit-inc
(fn
(expr tgt-override)
(expr amount tgt-override)
(cond
((and (list? expr) (= (first expr) (quote attr)))
(let
((t (hs-to-sx expr)))
(if
(and (list? expr) (= (first expr) (quote attr)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (hs-to-sx (nth expr 2)))))
((el (if tgt-override (hs-to-sx tgt-override) (quote me))))
(list
(quote dom-set-attr)
el
@@ -239,18 +283,46 @@
(list
(quote parse-number)
(list (quote dom-get-attr) el (nth expr 1)))
1)))
(list (quote set!) t (list (quote +) t 1))))))
amount))))
((and (list? expr) (= (first expr) dot-sym))
(let
((obj (hs-to-sx (nth expr 1))) (prop (nth expr 2)))
(list
(quote host-set)
obj
prop
(list
(quote +)
(list
(quote parse-number)
(list (quote host-get) obj prop))
amount))))
((and (list? expr) (= (first expr) (quote style)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (quote me)))
(prop (nth expr 1)))
(list
(quote dom-set-style)
el
prop
(list
(quote +)
(list
(quote parse-number)
(list (quote dom-get-style) el prop))
amount))))
(true
(let
((t (hs-to-sx expr)))
(list (quote set!) t (list (quote +) t amount)))))))
(define
emit-dec
(fn
(expr tgt-override)
(expr amount tgt-override)
(cond
((and (list? expr) (= (first expr) (quote attr)))
(let
((t (hs-to-sx expr)))
(if
(and (list? expr) (= (first expr) (quote attr)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (hs-to-sx (nth expr 2)))))
((el (if tgt-override (hs-to-sx tgt-override) (quote me))))
(list
(quote dom-set-attr)
el
@@ -260,8 +332,38 @@
(list
(quote parse-number)
(list (quote dom-get-attr) el (nth expr 1)))
1)))
(list (quote set!) t (list (quote -) t 1))))))
amount))))
((and (list? expr) (= (first expr) dot-sym))
(let
((obj (hs-to-sx (nth expr 1))) (prop (nth expr 2)))
(list
(quote host-set)
obj
prop
(list
(quote -)
(list
(quote parse-number)
(list (quote host-get) obj prop))
amount))))
((and (list? expr) (= (first expr) (quote style)))
(let
((el (if tgt-override (hs-to-sx tgt-override) (quote me)))
(prop (nth expr 1)))
(list
(quote dom-set-style)
el
prop
(list
(quote -)
(list
(quote parse-number)
(list (quote dom-get-style) el prop))
amount))))
(true
(let
((t (hs-to-sx expr)))
(list (quote set!) t (list (quote -) t amount)))))))
(define
emit-behavior
(fn
@@ -654,13 +756,23 @@
(hs-to-sx (nth ast 2))
(nth ast 1)))
((= head (quote multi-add-class))
(let ((target (hs-to-sx (nth ast 1)))
(let
((target (hs-to-sx (nth ast 1)))
(classes (rest (rest ast))))
(cons (quote do) (map (fn (cls) (list (quote dom-add-class) target cls)) classes))))
(cons
(quote do)
(map
(fn (cls) (list (quote dom-add-class) target cls))
classes))))
((= head (quote multi-remove-class))
(let ((target (hs-to-sx (nth ast 1)))
(let
((target (hs-to-sx (nth ast 1)))
(classes (rest (rest ast))))
(cons (quote do) (map (fn (cls) (list (quote dom-remove-class) target cls)) classes))))
(cons
(quote do)
(map
(fn (cls) (list (quote dom-remove-class) target cls))
classes))))
((= head (quote remove-class))
(list
(quote dom-remove-class)
@@ -677,6 +789,30 @@
(hs-to-sx (nth ast 3))
(nth ast 1)
(nth ast 2)))
((= head (quote toggle-style))
(list
(quote hs-toggle-style!)
(hs-to-sx (nth ast 2))
(nth ast 1)))
((= head (quote toggle-style-between))
(list
(quote hs-toggle-style-between!)
(hs-to-sx (nth ast 4))
(nth ast 1)
(hs-to-sx (nth ast 2))
(hs-to-sx (nth ast 3))))
((= head (quote toggle-attr))
(list
(quote hs-toggle-attr!)
(hs-to-sx (nth ast 2))
(nth ast 1)))
((= head (quote toggle-attr-between))
(list
(quote hs-toggle-attr-between!)
(hs-to-sx (nth ast 4))
(nth ast 1)
(hs-to-sx (nth ast 2))
(hs-to-sx (nth ast 3))))
((= head (quote set!))
(emit-set (nth ast 1) (hs-to-sx (nth ast 2))))
((= head (quote put!))
@@ -749,8 +885,21 @@
(list (list (quote me) (hs-to-sx (nth ast 1))))
(hs-to-sx (nth ast 2))))
((= head (quote for)) (emit-for ast))
((= head (quote take))
(list (quote hs-take!) (hs-to-sx (nth ast 2)) (nth ast 1)))
((= head (quote take!))
(let
((kind (nth ast 1))
(name (nth ast 2))
(from-sel (if (> (len ast) 3) (nth ast 3) nil))
(for-tgt (if (> (len ast) 4) (nth ast 4) nil)))
(let
((target (if for-tgt (hs-to-sx for-tgt) (quote me)))
(scope
(cond
((nil? from-sel) nil)
((and (list? from-sel) (= (first from-sel) (quote query)))
(list (quote hs-query-all) (nth from-sel 1)))
(true (hs-to-sx from-sel)))))
(list (quote hs-take!) target kind name scope))))
((= head (quote make)) (emit-make ast))
((= head (quote install))
(cons (quote hs-install) (map hs-to-sx (rest ast))))
@@ -759,11 +908,13 @@
((= head (quote increment!))
(emit-inc
(nth ast 1)
(if (> (len ast) 2) (nth ast 2) nil)))
(nth ast 2)
(if (> (len ast) 3) (nth ast 3) nil)))
((= head (quote decrement!))
(emit-dec
(nth ast 1)
(if (> (len ast) 2) (nth ast 2) nil)))
(nth ast 2)
(if (> (len ast) 3) (nth ast 3) nil)))
((= head (quote on)) (emit-on ast))
((= head (quote init))
(list
@@ -860,6 +1011,23 @@
(list (make-symbol (nth ast 1)))
(hs-to-sx (nth ast 3)))
(hs-to-sx (nth ast 2))))
((= head (quote scroll!))
(list
(quote hs-scroll!)
(hs-to-sx (nth ast 1))
(nth ast 2)))
((= head (quote select!))
(list (quote hs-select!) (hs-to-sx (nth ast 1))))
((= head (quote reset!))
(list (quote hs-reset!) (hs-to-sx (nth ast 1))))
((= head (quote default!))
(let
((t (hs-to-sx (nth ast 1))) (v (hs-to-sx (nth ast 2))))
(list
(quote when)
(list (quote nil?) t)
(list (quote set!) t v))))
((= head (quote halt!)) (list (quote hs-halt!) (nth ast 1)))
(true ast))))))))
;; ── Convenience: source → SX ─────────────────────────────────

File diff suppressed because one or more lines are too long

View File

@@ -364,11 +364,15 @@
((match-kw "in")
(list (quote not-in?) left (parse-expr)))
((match-kw "between")
(let ((lo (parse-atom)))
(let
((lo (parse-atom)))
(match-kw "and")
(let ((hi (parse-atom)))
(list (quote not)
(list (quote and)
(let
((hi (parse-atom)))
(list
(quote not)
(list
(quote and)
(list (quote >=) left lo)
(list (quote <=) left hi))))))
((match-kw "really")
@@ -429,10 +433,13 @@
(list (quote >=) left (parse-expr)))
(list (quote >) left (parse-expr)))))
((match-kw "between")
(let ((lo (parse-atom)))
(let
((lo (parse-atom)))
(match-kw "and")
(let ((hi (parse-atom)))
(list (quote and)
(let
((hi (parse-atom)))
(list
(quote and)
(list (quote >=) left lo)
(list (quote <=) left hi)))))
((match-kw "in") (list (quote in?) left (parse-expr)))
@@ -491,10 +498,14 @@
((and (= typ "keyword") (= val "exists"))
(do (adv!) (list (quote exists?) left)))
((and (or (= typ "keyword") (= typ "ident")) (= val "starts"))
(do (adv!) (match-kw "with")
(do
(adv!)
(match-kw "with")
(list (quote starts-with?) left (parse-expr))))
((and (or (= typ "keyword") (= typ "ident")) (= val "ends"))
(do (adv!) (match-kw "with")
(do
(adv!)
(match-kw "with")
(list (quote ends-with?) left (parse-expr))))
((and (= typ "keyword") (= val "matches"))
(do (adv!) (list (quote matches?) left (parse-expr))))
@@ -687,20 +698,26 @@
(if
(= (tp-type) "class")
(let
((cls (get (adv!) "value"))
(extra-classes (list)))
;; Collect additional class refs
(define collect-classes!
(fn ()
(when (= (tp-type) "class")
(set! extra-classes (append extra-classes (list (get (adv!) "value"))))
((cls (get (adv!) "value")) (extra-classes (list)))
(define
collect-classes!
(fn
()
(when
(= (tp-type) "class")
(set!
extra-classes
(append extra-classes (list (get (adv!) "value"))))
(collect-classes!))))
(collect-classes!)
(let
((tgt (parse-tgt-kw "to" (list (quote me)))))
(if (empty? extra-classes)
(if
(empty? extra-classes)
(list (quote add-class) cls tgt)
(cons (quote multi-add-class) (cons tgt (cons cls extra-classes))))))
(cons
(quote multi-add-class)
(cons tgt (cons cls extra-classes))))))
nil)))
(define
parse-remove-cmd
@@ -709,19 +726,26 @@
(if
(= (tp-type) "class")
(let
((cls (get (adv!) "value"))
(extra-classes (list)))
(define collect-classes!
(fn ()
(when (= (tp-type) "class")
(set! extra-classes (append extra-classes (list (get (adv!) "value"))))
((cls (get (adv!) "value")) (extra-classes (list)))
(define
collect-classes!
(fn
()
(when
(= (tp-type) "class")
(set!
extra-classes
(append extra-classes (list (get (adv!) "value"))))
(collect-classes!))))
(collect-classes!)
(let
((tgt (parse-tgt-kw "from" (list (quote me)))))
(if (empty? extra-classes)
(if
(empty? extra-classes)
(list (quote remove-class) cls tgt)
(cons (quote multi-remove-class) (cons tgt (cons cls extra-classes))))))
(cons
(quote multi-remove-class)
(cons tgt (cons cls extra-classes))))))
nil)))
(define
parse-toggle-cmd
@@ -732,12 +756,12 @@
(if
(= (tp-type) "class")
(let
((cls1 (get (adv!) "value")))
((cls1 (do (let ((v (tp-val))) (adv!) v))))
(expect-kw! "and")
(if
(= (tp-type) "class")
(let
((cls2 (get (adv!) "value")))
((cls2 (do (let ((v (tp-val))) (adv!) v))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-between) cls1 cls2 tgt)))
@@ -745,10 +769,47 @@
nil))
((= (tp-type) "class")
(let
((cls (get (adv!) "value")))
((cls (do (let ((v (tp-val))) (adv!) v))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-class) cls tgt))))
((= (tp-type) "style")
(let
((prop (do (let ((v (tp-val))) (adv!) v))))
(if
(match-kw "between")
(let
((val1 (parse-atom)))
(expect-kw! "and")
(let
((val2 (parse-atom)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-style-between) prop val1 val2 tgt))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-style) prop tgt)))))
((= (tp-type) "attr")
(let
((attr-name (do (let ((v (tp-val))) (adv!) v))))
(if
(match-kw "between")
(let
((val1 (parse-atom)))
(expect-kw! "and")
(let
((val2 (parse-atom)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list
(quote toggle-attr-between)
attr-name
val1
val2
tgt))))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote toggle-attr) attr-name tgt)))))
(true nil))))
(define
parse-set-cmd
@@ -772,9 +833,16 @@
(list (quote put!) value "after" (parse-expr)))
((match-kw "at")
(cond
((match-kw "start") (do (expect-kw! "of") (list (quote put!) value "start" (parse-expr))))
((match-kw "end") (do (expect-kw! "of") (list (quote put!) value "end" (parse-expr))))
(true (error (str "Expected start/end after at, position " p)))))
((match-kw "start")
(do
(expect-kw! "of")
(list (quote put!) value "start" (parse-expr))))
((match-kw "end")
(do
(expect-kw! "of")
(list (quote put!) value "end" (parse-expr))))
(true
(error (str "Expected start/end after at, position " p)))))
(true
(error (str "Expected into/before/after/at at position " p)))))))
(define
@@ -862,18 +930,22 @@
()
(let
((expr (parse-expr)))
(let
((amount (if (match-kw "by") (parse-expr) 1)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote increment!) expr tgt)))))
(list (quote increment!) expr amount tgt))))))
(define
parse-dec-cmd
(fn
()
(let
((expr (parse-expr)))
(let
((amount (if (match-kw "by") (parse-expr) 1)))
(let
((tgt (parse-tgt-kw "on" (list (quote me)))))
(list (quote decrement!) expr tgt)))))
(list (quote decrement!) expr amount tgt))))))
(define
parse-hide-cmd
(fn
@@ -909,12 +981,25 @@
parse-repeat-cmd
(fn
()
(cond
((and (= (tp-type) "keyword") (= (tp-val) "for"))
(do (adv!) (parse-for-cmd)))
((and (= (tp-type) "keyword") (= (tp-val) "in"))
(do
(adv!)
(let
((mode (cond ((match-kw "forever") (list (quote forever))) ((match-kw "while") (list (quote while) (parse-expr))) ((match-kw "until") (list (quote until) (parse-expr))) ((= (tp-type) "number") (let ((n (parse-dur (get (adv!) "value")))) (expect-kw! "times") (list (quote times) n))) (true (list (quote forever))))))
((collection (parse-expr)))
(let
((body (parse-cmd-list)))
(match-kw "end")
(list (quote repeat) mode body)))))
(list (quote for) "it" collection nil body)))))
(true
(let
((mode (cond ((match-kw "forever") (list (quote forever))) ((match-kw "while") (list (quote while) (parse-expr))) ((match-kw "until") (list (quote until) (parse-expr))) (true (let ((n (parse-expr))) (if (match-kw "times") (list (quote times) n) (list (quote forever))))))))
(let
((body (parse-cmd-list)))
(match-kw "end")
(list (quote repeat) mode body)))))))
(define
parse-fetch-cmd
(fn
@@ -959,16 +1044,24 @@
parse-take-cmd
(fn
()
(if
(= (tp-type) "class")
(cond
((= (tp-type) "class")
(let
((cls (get (adv!) "value")))
((cls (do (let ((v (tp-val))) (adv!) v))))
(let
((tgt (if (match-kw "for")
(parse-expr)
(parse-tgt-kw "from" (list (quote me))))))
(list (quote take) cls tgt)))
nil)))
((from-sel (if (match-kw "from") (parse-expr) nil)))
(let
((for-tgt (if (match-kw "for") (parse-expr) nil)))
(list (quote take!) "class" cls from-sel for-tgt)))))
((= (tp-type) "attr")
(let
((attr-name (do (let ((v (tp-val))) (adv!) v))))
(let
((from-sel (if (match-kw "from") (parse-expr) nil)))
(let
((for-tgt (if (match-kw "for") (parse-expr) nil)))
(list (quote take!) "attr" attr-name from-sel for-tgt)))))
(true nil))))
(define
parse-go-cmd
(fn () (match-kw "to") (list (quote go) (parse-expr))))
@@ -1124,6 +1217,44 @@
(let
((tgt (parse-expr)))
(list (quote measure) (if (nil? tgt) (list (quote me)) tgt)))))
(define
parse-scroll-cmd
(fn
()
(let
((tgt (if (or (at-end?) (and (= (tp-type) "keyword") (or (= (tp-val) "then") (= (tp-val) "end")))) (list (quote me)) (parse-expr))))
(let
((pos (cond ((match-kw "top") "top") ((match-kw "bottom") "bottom") ((match-kw "left") "left") ((match-kw "right") "right") (true "top"))))
(list (quote scroll!) tgt pos)))))
(define
parse-select-cmd
(fn
()
(let
((tgt (if (or (at-end?) (and (= (tp-type) "keyword") (or (= (tp-val) "then") (= (tp-val) "end")))) (list (quote me)) (parse-expr))))
(list (quote select!) tgt))))
(define
parse-reset-cmd
(fn
()
(let
((tgt (if (or (at-end?) (and (= (tp-type) "keyword") (or (= (tp-val) "then") (= (tp-val) "end")))) (list (quote me)) (parse-expr))))
(list (quote reset!) tgt))))
(define
parse-default-cmd
(fn
()
(let
((tgt (parse-expr)))
(expect-kw! "to")
(let ((val (parse-expr))) (list (quote default!) tgt val)))))
(define
parse-halt-cmd
(fn
()
(let
((the-event (and (match-kw "the") (or (match-kw "event") (match-kw "default")))))
(list (quote halt!) (if the-event "event" "default")))))
(define
parse-param-list
(fn () (if (= (tp-type) "paren-open") (parse-call-args) (list))))
@@ -1241,7 +1372,6 @@
(let
((typ (tp-type)) (val (tp-val)))
(cond
;; Terminators — these end a command list, not start a command
((and (= typ "keyword") (or (= val "catch") (= val "finally") (= val "end") (= val "else") (= val "otherwise")))
nil)
((and (= typ "keyword") (= val "add"))
@@ -1304,11 +1434,61 @@
(do (adv!) (parse-measure-cmd)))
((and (= typ "keyword") (= val "render"))
(do (adv!) (parse-render-cmd)))
((and (= typ "keyword") (= val "scroll"))
(do (adv!) (parse-scroll-cmd)))
((and (= typ "keyword") (= val "select"))
(do (adv!) (parse-select-cmd)))
((and (= typ "keyword") (= val "reset"))
(do (adv!) (parse-reset-cmd)))
((and (= typ "keyword") (= val "default"))
(do (adv!) (parse-default-cmd)))
((and (= typ "keyword") (= val "halt"))
(do (adv!) (parse-halt-cmd)))
(true (parse-expr))))))
(define
parse-cmd-list
(fn
()
(define
cmd-kw?
(fn
(v)
(or
(= v "add")
(= v "remove")
(= v "toggle")
(= v "set")
(= v "put")
(= v "if")
(= v "wait")
(= v "send")
(= v "trigger")
(= v "log")
(= v "increment")
(= v "decrement")
(= v "hide")
(= v "show")
(= v "transition")
(= v "repeat")
(= v "fetch")
(= v "call")
(= v "take")
(= v "settle")
(= v "go")
(= v "return")
(= v "throw")
(= v "append")
(= v "tell")
(= v "for")
(= v "make")
(= v "install")
(= v "measure")
(= v "render")
(= v "halt")
(= v "default")
(= v "scroll")
(= v "select")
(= v "reset"))))
(define
cl-collect
(fn
@@ -1320,7 +1500,11 @@
acc
(let
((acc2 (append acc (list cmd))))
(if (match-kw "then") (cl-collect acc2) acc2))))))
(cond
((match-kw "then") (cl-collect acc2))
((and (not (at-end?)) (= (tp-type) "keyword") (cmd-kw? (tp-val)))
(cl-collect acc2))
(true acc2)))))))
(let
((cmds (cl-collect (list))))
(cond
@@ -1341,18 +1525,10 @@
((source (if (match-kw "from") (parse-expr) nil)))
(let
((body (parse-cmd-list)))
;; Parse optional catch/finally
(let
((catch-clause
(if (match-kw "catch")
(let ((var (let ((v (tp-val))) (adv!) v))
(handler (parse-cmd-list)))
(list var handler))
nil))
((catch-clause (if (match-kw "catch") (let ((var (let ((v (tp-val))) (adv!) v)) (handler (parse-cmd-list))) (list var handler)) nil))
(finally-clause
(if (match-kw "finally")
(parse-cmd-list)
nil)))
(if (match-kw "finally") (parse-cmd-list) nil)))
(match-kw "end")
(let
((parts (list (quote on) event-name)))
@@ -1362,9 +1538,12 @@
((parts (if flt (append parts (list :filter flt)) parts)))
(let
((parts (if source (append parts (list :from source)) parts)))
(let ((parts (if catch-clause (append parts (list :catch catch-clause)) parts)))
(let ((parts (if finally-clause (append parts (list :finally finally-clause)) parts)))
(let ((parts (append parts (list body))))
(let
((parts (if catch-clause (append parts (list :catch catch-clause)) parts)))
(let
((parts (if finally-clause (append parts (list :finally finally-clause)) parts)))
(let
((parts (append parts (list body))))
parts)))))))))))))))
(define
parse-init-feat
@@ -1403,5 +1582,4 @@
(first features)
(cons (quote do) features))))))
;; ── Convenience: source string → AST ─────────────────────────────
(define hs-compile (fn (src) (hs-parse (hs-tokenize src) src)))

File diff suppressed because one or more lines are too long

View File

@@ -64,22 +64,50 @@
;; Take a class from siblings — add to target, remove from others.
;; (hs-take! target cls) — like radio button class behavior
(define
hs-take!
hs-toggle-style!
(fn
(target cls)
(target prop)
(let
((parent (dom-parent target)))
(when
parent
(for-each
(fn (child) (dom-remove-class child cls))
(dom-child-list parent)))
(dom-add-class target cls))))
((cur (dom-get-style target prop)))
(cond
((= prop "visibility")
(if
(= cur "hidden")
(dom-set-style target prop "visible")
(dom-set-style target prop "hidden")))
((or (= prop "display") (= prop "opacity"))
(if
(or (= cur "none") (= cur "0"))
(dom-set-style target prop "")
(dom-set-style target prop (if (= prop "display") "none" "0"))))
(true
(if
(or (= cur "") (= cur nil))
(dom-set-style target prop "hidden")
(dom-set-style target prop "")))))))
;; ── DOM insertion ───────────────────────────────────────────────
;; Put content at a position relative to a target.
;; pos: "into" | "before" | "after"
(define
hs-take!
(fn
(target kind name scope)
(let
((els (if scope (if (list? scope) scope (list scope)) (let ((parent (host-get target "parentNode"))) (if parent (dom-child-list parent) (list))))))
(if
(= kind "class")
(do
(for-each (fn (el) (dom-remove-class el name)) els)
(dom-add-class target name))
(do
(for-each (fn (el) (dom-remove-attr el name)) els)
(dom-set-attr target name "true"))))))
;; ── Navigation / traversal ──────────────────────────────────────
;; Navigate to a URL.
(define
hs-put!
(fn
@@ -92,12 +120,38 @@
((= pos "start") (dom-insert-adjacent-html target "afterbegin" value))
((= pos "end") (dom-insert-adjacent-html target "beforeend" value)))))
;; ── Navigation / traversal ──────────────────────────────────────
;; Navigate to a URL.
;; Find next sibling matching a selector (or any sibling).
(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url))))
;; Find next sibling matching a selector (or any sibling).
;; Find previous sibling matching a selector.
(define
hs-scroll!
(fn
(target position)
(host-call
target
"scrollIntoView"
(list
(cond
((= position "bottom") (dict :block "end"))
(true (dict :block "start")))))))
;; First element matching selector within a scope.
(define
hs-halt!
(fn
(mode)
(when
event
(host-call event "preventDefault" (list))
(when (= mode "event") (host-call event "stopPropagation" (list))))))
;; Last element matching selector.
(define hs-select! (fn (target) (host-call target "select" (list))))
;; First/last within a specific scope.
(define hs-reset! (fn (target) (host-call target "reset" (list))))
(define
hs-next
(fn
@@ -117,7 +171,9 @@
(true (find-next (dom-next-sibling el))))))
(find-next sibling)))))
;; Find previous sibling matching a selector.
;; ── Iteration ───────────────────────────────────────────────────
;; Repeat a thunk N times.
(define
hs-previous
(fn
@@ -137,12 +193,27 @@
(true (find-prev (dom-get-prop el "previousElementSibling"))))))
(find-prev sibling)))))
;; First element matching selector within a scope.
;; Repeat forever (until break — relies on exception/continuation).
(define
hs-query-all
(fn
(sel)
(dom-query-all
(host-call (host-global "document") "querySelector" (list "body"))
sel)))
;; ── Fetch ───────────────────────────────────────────────────────
;; Fetch a URL, parse response according to format.
;; (hs-fetch url format) — format is "json" | "text" | "html"
(define
hs-query-first
(fn (sel) (host-call (host-global "document") "querySelector" sel)))
;; Last element matching selector.
;; ── Type coercion ───────────────────────────────────────────────
;; Coerce a value to a type by name.
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
(define
hs-query-last
(fn
@@ -151,9 +222,17 @@
((all (dom-query-all (dom-body) sel)))
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
;; First/last within a specific scope.
;; ── Object creation ─────────────────────────────────────────────
;; Make a new object of a given type.
;; (hs-make type-name) — creates empty object/collection
(define hs-first (fn (scope sel) (dom-query-all scope sel)))
;; ── Behavior installation ───────────────────────────────────────
;; Install a behavior on an element.
;; A behavior is a function that takes (me ...params) and sets up features.
;; (hs-install behavior-fn me ...args)
(define
hs-last
(fn
@@ -162,9 +241,10 @@
((all (dom-query-all scope sel)))
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
;; ── Iteration ───────────────────────────────────────────────────
;; ── Measurement ─────────────────────────────────────────────────
;; Repeat a thunk N times.
;; Measure an element's bounding rect, store as local variables.
;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define
hs-repeat-times
(fn
@@ -174,7 +254,10 @@
(fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1))))))
(do-repeat 0)))
;; Repeat forever (until break — relies on exception/continuation).
;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration.
;; (hs-transition target prop value duration)
(define
hs-repeat-forever
(fn
@@ -182,10 +265,6 @@
(define do-forever (fn () (thunk) (do-forever)))
(do-forever)))
;; ── Fetch ───────────────────────────────────────────────────────
;; Fetch a URL, parse response according to format.
;; (hs-fetch url format) — format is "json" | "text" | "html"
(define
hs-fetch
(fn
@@ -198,10 +277,6 @@
((= format "html") (perform (list (quote io-parse-html) response)))
(true response)))))
;; ── Type coercion ───────────────────────────────────────────────
;; Coerce a value to a type by name.
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
(define
hs-coerce
(fn
@@ -235,19 +310,10 @@
((= type-name "Date") (str value))
(true value))))
;; ── Object creation ─────────────────────────────────────────────
;; Make a new object of a given type.
;; (hs-make type-name) — creates empty object/collection
(define
hs-add
(fn (a b) (if (or (string? a) (string? b)) (str a b) (+ a b))))
;; ── Behavior installation ───────────────────────────────────────
;; Install a behavior on an element.
;; A behavior is a function that takes (me ...params) and sets up features.
;; (hs-install behavior-fn me ...args)
(define
hs-make
(fn
@@ -259,16 +325,8 @@
((= type-name "Map") (dict))
(true (dict)))))
;; ── Measurement ─────────────────────────────────────────────────
;; Measure an element's bounding rect, store as local variables.
;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define hs-install (fn (behavior-fn) (behavior-fn me)))
;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration.
;; (hs-transition target prop value duration)
(define
hs-measure
(fn (target) (perform (list (quote io-measure) target))))
@@ -311,6 +369,10 @@
hs-strict-eq
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
(define
hs-falsy?
(fn
@@ -331,7 +393,8 @@
(string? target)
(if (= pattern ".*") true (string-contains? target pattern))
false)))
;; ── Sandbox/test runtime additions ──────────────────────────────
;; Property access — dot notation and .length
(define
hs-contains?
(fn
@@ -351,7 +414,7 @@
true
(hs-contains? (rest collection) item)))))
(true false))))
;; DOM query stub — sandbox returns empty list
(define
hs-empty?
(fn
@@ -362,15 +425,13 @@
((list? v) (= (len v) 0))
((dict? v) (= (len (keys v)) 0))
(true false))))
;; Method dispatch — obj.method(args)
(define hs-first (fn (lst) (first lst)))
;; ── 0.9.90 features ─────────────────────────────────────────────
;; beep! — debug logging, returns value unchanged
(define hs-last (fn (lst) (last lst)))
;; Property-based is — check obj.key truthiness
(define
hs-template
(fn
@@ -456,7 +517,7 @@
(set! i (+ i 1))
(tpl-loop)))))))
(do (tpl-loop) result))))
;; Array slicing (inclusive both ends)
(define
hs-make-object
(fn
@@ -468,8 +529,7 @@
(fn (pair) (dict-set! d (first pair) (nth pair 1)))
pairs)
d))))
;; ── Sandbox/test runtime additions ──────────────────────────────
;; Property access — dot notation and .length
;; Collection: sorted by
(define
hs-method-call
(fn
@@ -492,13 +552,11 @@
(if (= (first lst) item) i (idx-loop (rest lst) (+ i 1))))))
(idx-loop obj 0)))
(true nil))))
;; DOM query stub — sandbox returns empty list
;; Collection: sorted by descending
(define hs-beep (fn (v) v))
;; Method dispatch — obj.method(args)
;; Collection: split by
(define hs-prop-is (fn (obj key) (not (hs-falsy? (host-get obj key)))))
;; ── 0.9.90 features ─────────────────────────────────────────────
;; beep! — debug logging, returns value unchanged
;; Collection: joined by
(define
hs-slice
(fn
@@ -507,7 +565,7 @@
((s (if (nil? start) 0 start))
(e (if (nil? end) (len col) (+ end 1))))
(slice col s e))))
;; Property-based is — check obj.key truthiness
(define
hs-sorted-by
(fn
@@ -517,7 +575,7 @@
(map
(fn (p) (nth p 1))
(sort (fn (a b) (if (< (first a) (first b)) true false)) pairs)))))
;; Array slicing (inclusive both ends)
(define
hs-sorted-by-desc
(fn
@@ -527,11 +585,11 @@
(map
(fn (p) (nth p 1))
(sort (fn (a b) (if (> (first a) (first b)) true false)) pairs)))))
;; Collection: sorted by
(define hs-split-by (fn (s sep) (split s sep)))
;; Collection: sorted by descending
(define hs-joined-by (fn (col sep) (join sep col)))
;; Collection: split by
(define
hs-sorted-by
(fn
@@ -567,7 +625,7 @@
(append acc (list (nth found 1)))
(filter (fn (x) (not (= x found))) remaining)))))))
(reorder sorted-dec (list) decorated)))))
;; Collection: joined by
(define
hs-sorted-by-desc
(fn (col key-fn) (reverse (hs-sorted-by col key-fn))))

File diff suppressed because one or more lines are too long

View File

@@ -161,7 +161,12 @@
"split"
"joined"
"descending"
"ascending"))
"ascending"
"scroll"
"select"
"reset"
"default"
"halt"))
(define hs-keyword? (fn (word) (some (fn (k) (= k word)) hs-keywords)))

File diff suppressed because one or more lines are too long

View File

@@ -963,11 +963,17 @@
"hs-settle",
"hs-toggle-class!",
"hs-toggle-between!",
"hs-toggle-style!",
"hs-take!",
"hs-put!",
"hs-navigate!",
"hs-scroll!",
"hs-halt!",
"hs-select!",
"hs-reset!",
"hs-next",
"hs-previous",
"hs-query-all",
"hs-query-first",
"hs-query-last",
"hs-first",

View File

@@ -0,0 +1,465 @@
;; Dev-branch hyperscript conformance tests — expression evaluation
;; Source: spec/tests/hyperscript-upstream-tests.json (no-HTML tests from v0.9.90-dev)
;; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-conformance-dev.py
;; ── halt (1 tests) ──
(defsuite "hs-dev-halt"
(deftest "halt works outside of event context"
;; expect(error).toBeNull();
(error "STUB: needs JS bridge — promise"))
)
;; ── bind (1 tests) ──
(defsuite "hs-dev-bind"
(deftest "unsupported element: bind to plain div errors"
;; expect(await evaluate(() => window.$nope)).toBeUndefined()
(error "STUB: needs JS bridge — promise"))
)
;; ── when (2 tests) ──
(defsuite "hs-dev-when"
(deftest "local variable in when expression produces a parse error"
;; expect(error).not.toBeNull()
(error "STUB: needs JS bridge — eval-only"))
(deftest "attribute observers are persistent (not recreated on re-run)"
;; expect(observersCreated).toBe(0)
(error "STUB: needs JS bridge — promise"))
)
;; ── evalStatically (8 tests) ──
(defsuite "hs-dev-evalStatically"
(deftest "works on number literals"
(assert= 42 (eval-hs "42"))
(assert= 3.14 (eval-hs "3.14"))
)
(deftest "works on boolean literals"
(assert= true (eval-hs "true"))
(assert= false (eval-hs "false"))
)
(deftest "works on null literal"
(assert= nil (eval-hs "null"))
)
(deftest "works on plain string literals"
(assert= "hello" (eval-hs "\"hello\""))
(assert= "world" (eval-hs "'world'"))
)
(deftest "works on time expressions"
(assert= 200 (eval-hs "200ms"))
(assert= 2000 (eval-hs "2s"))
)
(deftest "throws on template strings"
;; expect(msg).toMatch(/cannot be evaluated statically/);
(error "STUB: needs JS bridge — eval-only"))
(deftest "throws on symbol references"
;; expect(msg).toMatch(/cannot be evaluated statically/);
(error "STUB: needs JS bridge — eval-only"))
(deftest "throws on math expressions"
;; expect(msg).toMatch(/cannot be evaluated statically/);
(error "STUB: needs JS bridge — eval-only"))
)
;; ── collectionExpressions (12 tests) ──
(defsuite "hs-dev-collectionExpressions"
(deftest "filters an array by condition"
(let ((result (eval-hs "set arr to [{name: \"a\", active: true}, {name: \"b\", active: false}, {name: \"c\", active: true}] then return arr where its active")))
(assert= (list "a" "c") (map (fn (x) (get x "name")) result))))
(deftest "filters with comparison"
(assert= (list 4 5) (eval-hs "set arr to [1, 2, 3, 4, 5] then return arr where it > 3"))
)
(deftest "sorts by a property"
(let ((result (eval-hs "set arr to [{name: \"Charlie\"}, {name: \"Alice\"}, {name: \"Bob\"}] then return arr sorted by its name")))
(assert= (list "Alice" "Bob" "Charlie") (map (fn (x) (get x "name")) result))))
(deftest "sorts descending"
(assert= (list 3 2 1) (eval-hs "set arr to [3, 1, 2] then return arr sorted by it descending"))
)
(deftest "sorts numbers by a computed key"
(let ((result (eval-hs "set arr to [{name: \"b\", age: 30}, {name: \"a\", age: 20}, {name: \"c\", age: 25}] then return arr sorted by its age")))
(assert= (list "a" "c" "b") (map (fn (x) (get x "name")) result))))
(deftest "maps to a property"
(assert= (list "Alice" "Bob") (eval-hs "set arr to [{name: \"Alice\"}, {name: \"Bob\"}] then return arr mapped to its name"))
)
(deftest "maps with an expression"
(assert= (list 2 4 6) (eval-hs "set arr to [1, 2, 3] then return arr mapped to (it * 2)"))
)
(deftest "where then mapped to"
(assert= (list "Alice" "Charlie") (eval-hs "set arr to [{name: \"Alice\", active: true}, {name: \"Bob\", active: false}, {name: \"Charlie\", active: true}] then return arr where its active mapped to its name"))
)
(deftest "sorted by then mapped to"
(assert= (list "Alice" "Charlie") (eval-hs "set arr to [{name: \"Charlie\", age: 30}, {name: \"Alice\", age: 20}] then return arr sorted by its age mapped to its name"))
)
(deftest "where then sorted by then mapped to"
(assert= (list "Bob" "Charlie") (eval-hs "set arr to [{name: \"Charlie\", active: true, age: 30}, {name: \"Alice\", active: false, age: 20}, {name: \"Bob\", active: true, age: 25}] then return arr where its active sorted by its age mapped to its name"))
)
(deftest "the result inside where refers to previous command result, not current element"
(assert= (list 4 5) (eval-hs "get 3 then set arr to [1, 2, 3, 4, 5] then return arr where it > the result"))
)
(deftest "where binds after property access"
(assert= (list 3 4) (eval-hs "obj.items where it > 2"))
)
)
;; ── splitJoin (7 tests) ──
(defsuite "hs-dev-splitJoin"
(deftest "splits a string by delimiter"
(assert= (list "a" "b" "c") (eval-hs "return \"a,b,c\" split by \",\""))
)
(deftest "splits by whitespace"
(assert= (list "hello" "world") (eval-hs "return \"hello world\" split by \" \""))
)
(deftest "joins an array with delimiter"
(assert= "a, b, c" (eval-hs "return [\"a\", \"b\", \"c\"] joined by \", \""))
)
(deftest "joins with empty string"
(assert= "xyz" (eval-hs "return [\"x\", \"y\", \"z\"] joined by \"\""))
)
(deftest "split then where then joined"
(assert= "a-b-c" (eval-hs "return \"a,,b,,c\" split by \",\" where it is not \"\" joined by \"-\""))
)
(deftest "split then sorted then joined"
(assert= "apple, banana, cherry" (eval-hs "return \"banana,apple,cherry\" split by \",\" sorted by it joined by \", \""))
)
(deftest "split then mapped then joined"
(assert= "5,5" (eval-hs "return \"hello world\" split by \" \" mapped to its length joined by \",\""))
)
)
;; ── pick (7 tests) ──
(defsuite "hs-dev-pick"
(deftest "does not hang on zero-length regex matches"
;; await run(String.raw`pick matches of "\\d*" from haystack
(error "STUB: needs JS bridge — eval-only"))
(deftest "can pick first n items"
(assert= (list 10 20 30) (eval-hs "pick first 3 of arr set $test to it"))
)
(deftest "can pick last n items"
(assert= (list 40 50) (eval-hs "pick last 2 of arr set $test to it"))
)
(deftest "can pick random item"
;; await run(`pick random of arr
(error "STUB: needs JS bridge — eval-only"))
(deftest "can pick random n items"
;; await run(`pick random 2 of arr
(error "STUB: needs JS bridge — eval-only"))
(deftest "can pick items using 'of' syntax"
(assert= (list 11 12) (eval-hs "pick items 1 to 3 of arr set $test to it"))
)
(deftest "can pick match using 'of' syntax"
;; await run(String.raw`pick match of "\\d+" of haystack
(error "STUB: needs JS bridge — eval-only"))
)
;; ── transition (1 tests) ──
(defsuite "hs-dev-transition"
(deftest "can transition on query ref with possessive"
;; await expect(find('div').nth(1)).toHaveCSS('width', '100px');
(error "STUB: needs JS bridge — eval-only"))
)
;; ── socket (4 tests) ──
(defsuite "hs-dev-socket"
(deftest "parses socket with absolute ws:// URL"
;; expect(result.error).toBeNull();
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts relative URL to wss:// on https pages"
;; expect(result.error).toBeNull();
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts relative URL to ws:// on http pages"
;; expect(result.error).toBeNull();
(error "STUB: needs JS bridge — eval-only"))
(deftest "namespaced sockets work"
;; expect(result.error).toBeNull();
(error "STUB: needs JS bridge — eval-only"))
)
;; ── bootstrap (3 tests) ──
(defsuite "hs-dev-bootstrap"
(deftest "fires hyperscript:before:init and hyperscript:after:init"
;; expect(events).toEqual(['before:init', 'after:init']);
(error "STUB: needs JS bridge — eval-only"))
(deftest "hyperscript:before:init can cancel initialization"
;; expect(result.initialized).toBe(false);
(error "STUB: needs JS bridge — eval-only"))
(deftest "logAll config logs events to console"
;; expect(logged).toBe(true);
(error "STUB: needs JS bridge — eval-only"))
)
;; ── parser (3 tests) ──
(defsuite "hs-dev-parser"
(deftest "fires hyperscript:parse-error event with all errors"
;; expect(errorCount).toBe(2);
(error "STUB: needs JS bridge — eval-only"))
(deftest "_hyperscript() evaluate API still throws on first error"
;; expect(msg).toMatch(/^Expected either a class reference or attribute expression/
(error "STUB: needs JS bridge — simple"))
(deftest "parse error at EOF on trailing newline does not crash"
;; expect(result).toMatch(/^ok:/);
(error "STUB: needs JS bridge — eval-only"))
)
;; ── asExpression (17 tests) ──
(defsuite "hs-dev-asExpression"
(deftest "converts value as Boolean"
(assert= true (eval-hs "1 as Boolean"))
(assert= false (eval-hs "0 as Boolean"))
(assert= false (eval-hs "'' as Boolean"))
(assert= true (eval-hs "'hello' as Boolean"))
)
(deftest "can use the a modifier if you like"
;; expect(result).toBe(new Date(1).getTime())
(error "STUB: needs JS bridge — eval-only"))
(deftest "parses string as JSON to object"
(let ((result (eval-hs "\\'{\"foo\":\"bar\"}\\' as JSON")))
(assert= "bar" (get result "foo"))
))
(deftest "converts value as JSONString"
(assert= "{\"foo\":\"bar\"}" (eval-hs "{foo:'bar'} as JSONString"))
)
(deftest "pipe operator chains conversions"
(let ((result (eval-hs "{foo:'bar'} as JSONString | JSON")))
(assert= "bar" (get result "foo"))
))
(deftest "can use the an modifier if you'd like"
(let ((result (eval-hs "\\'{\"foo\":\"bar\"}\\' as an Object")))
(assert= "bar" (get result "foo"))
))
(deftest "collects duplicate text inputs into an array"
;; expect(result.tag).toEqual(["alpha", "beta", "gamma"])
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts multiple selects with programmatically changed selections"
;; expect(result.animal[0]).toBe("cat")
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts a form element into Values | JSONString"
;; expect(result).toBe('{"firstName":"John","lastName":"Connor","areaCode":"213","p
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts a form element into Values | FormEncoded"
;; expect(result).toBe('firstName=John&lastName=Connor&areaCode=213&phone=555-1212'
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts array as Set"
;; expect(result.isSet).toBe(true)
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts object as Map"
;; expect(result.isMap).toBe(true)
(error "STUB: needs JS bridge — eval-only"))
(deftest "converts object as Keys"
(assert= (list "a" "b") (eval-hs "{a:1, b:2} as Keys"))
)
(deftest "converts object as Entries"
(assert= (list (list "a" 1)) (eval-hs "{a:1} as Entries"))
)
(deftest "converts array as Reversed"
(assert= (list 3 2 1) (eval-hs "[1,2,3] as Reversed"))
)
(deftest "converts array as Unique"
(assert= (list 1 2 3) (eval-hs "[1,2,2,3,3] as Unique"))
)
(deftest "converts nested array as Flat"
(assert= (list 1 2 3 4) (eval-hs "[[1,2],[3,4]] as Flat"))
)
)
;; ── comparisonOperator (28 tests) ──
(defsuite "hs-dev-comparisonOperator"
(deftest "is ignoring case works"
(assert= true (eval-hs "'Hello' is 'hello' ignoring case"))
(assert= true (eval-hs "'Hello' is 'HELLO' ignoring case"))
(assert= false (eval-hs "'Hello' is 'world' ignoring case"))
)
(deftest "is not ignoring case works"
(assert= true (eval-hs "'Hello' is not 'world' ignoring case"))
(assert= false (eval-hs "'Hello' is not 'hello' ignoring case"))
)
(deftest "contains ignoring case works"
(assert= true (eval-hs "'Hello World' contains 'hello' ignoring case"))
(assert= true (eval-hs "'Hello World' contains 'WORLD' ignoring case"))
(assert= false (eval-hs "'Hello World' contains 'missing' ignoring case"))
)
(deftest "matches ignoring case works"
(assert= true (eval-hs "'Hello' matches 'hello' ignoring case"))
(assert= true (eval-hs "'Hello' matches 'HELLO' ignoring case"))
)
(deftest "starts with works"
(assert= true (eval-hs "'hello world' starts with 'hello'"))
(assert= false (eval-hs "'hello world' starts with 'world'"))
(assert= true (eval-hs "'hello' starts with 'hello'"))
(assert= false (eval-hs "'' starts with 'x'"))
)
(deftest "ends with works"
(assert= true (eval-hs "'hello world' ends with 'world'"))
(assert= false (eval-hs "'hello world' ends with 'hello'"))
(assert= true (eval-hs "'hello' ends with 'hello'"))
(assert= false (eval-hs "'' ends with 'x'"))
)
(deftest "does not start with works"
(assert= false (eval-hs "'hello world' does not start with 'hello'"))
(assert= true (eval-hs "'hello world' does not start with 'world'"))
)
(deftest "does not end with works"
(assert= false (eval-hs "'hello world' does not end with 'world'"))
(assert= true (eval-hs "'hello world' does not end with 'hello'"))
)
(deftest "starts with null is false"
(assert= false (eval-hs "null starts with 'x'"))
(assert= true (eval-hs "null does not start with 'x'"))
)
(deftest "ends with null is false"
(assert= false (eval-hs "null ends with 'x'"))
(assert= true (eval-hs "null does not end with 'x'"))
)
(deftest "starts with ignoring case works"
(assert= true (eval-hs "'Hello World' starts with 'hello' ignoring case"))
(assert= true (eval-hs "'Hello World' starts with 'HELLO' ignoring case"))
(assert= false (eval-hs "'Hello World' starts with 'world' ignoring case"))
)
(deftest "ends with ignoring case works"
(assert= true (eval-hs "'Hello World' ends with 'world' ignoring case"))
(assert= true (eval-hs "'Hello World' ends with 'WORLD' ignoring case"))
(assert= false (eval-hs "'Hello World' ends with 'hello' ignoring case"))
)
(deftest "starts with coerces to string"
(assert= true (eval-hs "123 starts with '12'"))
(assert= false (eval-hs "123 starts with '23'"))
)
(deftest "ends with coerces to string"
(assert= true (eval-hs "123 ends with '23'"))
(assert= false (eval-hs "123 ends with '12'"))
)
(deftest "is between works"
(assert= true (eval-hs "5 is between 1 and 10"))
(assert= true (eval-hs "1 is between 1 and 10"))
(assert= true (eval-hs "10 is between 1 and 10"))
(assert= false (eval-hs "0 is between 1 and 10"))
(assert= false (eval-hs "11 is between 1 and 10"))
)
(deftest "is not between works"
(assert= false (eval-hs "5 is not between 1 and 10"))
(assert= true (eval-hs "0 is not between 1 and 10"))
(assert= true (eval-hs "11 is not between 1 and 10"))
(assert= false (eval-hs "1 is not between 1 and 10"))
(assert= false (eval-hs "10 is not between 1 and 10"))
)
(deftest "between works with strings"
(assert= true (eval-hs "'b' is between 'a' and 'c'"))
(assert= false (eval-hs "'d' is between 'a' and 'c'"))
)
(deftest "I am between works"
(assert= true (eval-hs "I am between 1 and 10" {:me 5}))
(assert= false (eval-hs "I am between 1 and 10" {:me 0}))
)
(deftest "I am not between works"
(assert= false (eval-hs "I am not between 1 and 10" {:me 5}))
(assert= true (eval-hs "I am not between 1 and 10" {:me 0}))
)
(deftest "precedes with null is false"
(assert= false (eval-hs "null precedes null"))
(assert= true (eval-hs "null does not precede null"))
)
(deftest "is really works without equal to"
(assert= true (eval-hs "2 is really 2"))
(assert= false (eval-hs "2 is really '2'"))
)
(deftest "is not really works without equal to"
(assert= true (eval-hs "2 is not really '2'"))
(assert= false (eval-hs "2 is not really 2"))
)
(deftest "is equal works without to"
(assert= true (eval-hs "2 is equal 2"))
(assert= false (eval-hs "2 is equal 1"))
)
(deftest "is not equal works without to"
(assert= false (eval-hs "2 is not equal 2"))
(assert= true (eval-hs "2 is not equal 1"))
)
(deftest "am works as alias for is"
(assert= true (eval-hs "2 am 2"))
(assert= false (eval-hs "2 am 1"))
)
(deftest "is not undefined still works as equality"
(assert= true (eval-hs "5 is not undefined"))
(assert= false (eval-hs "null is not undefined"))
)
(deftest "is not null still works as equality"
(assert= true (eval-hs "5 is not null"))
(assert= false (eval-hs "null is not null"))
)
(deftest "is still does equality when rhs variable exists"
(assert= true (eval-hs "x is y" {:locals {:x 5 :y 5}}))
(assert= false (eval-hs "x is y" {:locals {:x 5 :y 6}}))
)
)
;; ── cookies (1 tests) ──
(defsuite "hs-dev-cookies"
(deftest "length is 0 when no cookies are set"
;; expect(result).toBe(0)
(error "STUB: needs JS bridge — eval-only"))
)
;; ── in (1 tests) ──
(defsuite "hs-dev-in"
(deftest "null value in array returns empty"
(assert= (list) (eval-hs "null in [1, 2, 3]"))
)
)
;; ── logicalOperator (3 tests) ──
(defsuite "hs-dev-logicalOperator"
(deftest "and short-circuits when lhs promise resolves to false"
;; expect(result.result).toBe(false)
(error "STUB: needs JS bridge — eval-only"))
(deftest "or short-circuits when lhs promise resolves to true"
;; expect(result.result).toBe(true)
(error "STUB: needs JS bridge — eval-only"))
(deftest "or evaluates rhs when lhs promise resolves to false"
;; expect(result.result).toBe("fallback")
(error "STUB: needs JS bridge — eval-only"))
)
;; ── mathOperator (5 tests) ──
(defsuite "hs-dev-mathOperator"
(deftest "array + array concats"
(assert= (list 1 2 3 4) (eval-hs "[1, 2] + [3, 4]"))
)
(deftest "array + single value appends"
(assert= (list 1 2 3) (eval-hs "[1, 2] + 3"))
)
(deftest "array + array does not mutate original"
(assert= (list 1 2) (eval-hs "set a to [1, 2] then set b to a + [3] then return a"))
)
(deftest "array concat chains"
(assert= (list 1 2 3) (eval-hs "[1] + [2] + [3]"))
)
(deftest "empty array + array works"
(assert= (list 1 2) (eval-hs "[] + [1, 2]"))
)
)
;; ── no (4 tests) ──
(defsuite "hs-dev-no"
(deftest "no returns false for non-empty array"
(assert= false (eval-hs "no ['thing']"))
)
(deftest "no with where filters then checks emptiness"
(assert= true (eval-hs "no [1, 2, 3] where it > 5"))
)
(deftest "no with where returns false when matches exist"
(assert= false (eval-hs "no [1, 2, 3] where it > 1"))
)
(deftest "no with where and is not"
(assert= false (eval-hs "no [1, 2, 3] where it is not 2"))
)
)
;; ── objectLiteral (1 tests) ──
(defsuite "hs-dev-objectLiteral"
(deftest "allows trailing commas"
;; expect(await run("{foo:true, bar-baz:false,}")).toEqual({ "foo": true, "bar-baz"
(error "STUB: needs JS bridge — run-eval"))
)
;; ── relativePositionalExpression (1 tests) ──
(defsuite "hs-dev-relativePositionalExpression"
(deftest "can write to next element with put command"
;; await expect(find('#d2')).toHaveText('updated');
(error "STUB: needs JS bridge — eval-only"))
)

View File

@@ -0,0 +1,31 @@
;; Page test for /sx/(geography.(isomorphism.streaming))
;;
;; The streaming page sends an HTML shell immediately with suspense
;; skeletons, then resolves three slots at staggered delays (1s, 3s, 5s).
;; Single navigation test — streaming pages block the server during IO,
;; so multiple navigations in one suite would queue.
(defsuite
"streaming-demo"
:url "/sx/(geography.(isomorphism.streaming))"
:stream true
:timeout 60000
(deftest
"streaming page renders and resolves"
(wait-for "h1" :text "Streaming" :timeout 30000)
(assert-count "[data-suspense]" 3 :timeout 30000)
(wait-for
"[data-suspense='stream-fast']"
:text "Fast source"
:timeout 30000)
(wait-for
"[data-suspense='stream-medium']"
:text "Medium source"
:timeout 30000)
(wait-for
"[data-suspense='stream-slow']"
:text "Slow source"
:timeout 30000)
(wait-for "[data-suspense='stream-fast']" :text "~1s" :timeout 5000)
(wait-for "[data-suspense='stream-medium']" :text "~3s" :timeout 5000)
(wait-for "[data-suspense='stream-slow']" :text "~5s" :timeout 5000)))

View File

@@ -1,7 +1,4 @@
{
"status": "failed",
"failedTests": [
"0ca76506ebddb95b746c-2b2f50f2cbbb858d1272",
"0ca76506ebddb95b746c-8f9d78e488ffc61daf33"
]
"status": "passed",
"failedTests": []
}

View File

@@ -1,33 +0,0 @@
# Page snapshot
```yaml
- main [ref=e4]:
- generic [ref=e6]:
- complementary
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e11]:
- link "(<sx>)" [ref=e12] [cursor=pointer]:
- /url: /sx/
- generic [ref=e14]: (<sx>)
- paragraph [ref=e15]: The framework-free reactive hypermedium
- paragraph [ref=e17]: © Giles Bradshaw 2026· /sx/
- generic [ref=e19]:
- link "Geography" [ref=e20] [cursor=pointer]:
- /url: /sx/(geography)
- link "Language" [ref=e21] [cursor=pointer]:
- /url: /sx/(language)
- link "Applications" [ref=e22] [cursor=pointer]:
- /url: /sx/(applications)
- link "Tools" [ref=e23] [cursor=pointer]:
- /url: /sx/(tools)
- link "Etc" [ref=e24] [cursor=pointer]:
- /url: /sx/(etc)
- generic [ref=e29]:
- generic [ref=e30]: (div (~tw :tokens "text-center") (h1 (~tw :tokens "text-3xl font-bold mb-2") (span (~tw :tokens "text-rose-500") "the ") (span (~tw :tokens "text-amber-500") "joy ") (span (~tw :tokens "text-emerald-500") "of ") (span (~tw :tokens "text-violet-600 text-4xl") "sx")))
- generic [ref=e31]:
- button "◀" [ref=e32] [cursor=pointer]
- generic [ref=e33]: 16 / 16
- button "▶" [ref=e34] [cursor=pointer]
- heading "the joy of sx" [level=1] [ref=e37]
```

View File

@@ -1,158 +0,0 @@
# Page snapshot
```yaml
- main [ref=e4]:
- generic [ref=e6]:
- complementary
- generic [ref=e7]:
- generic [ref=e8]:
- generic [ref=e11]:
- link "(<sx>)" [ref=e12] [cursor=pointer]:
- /url: /sx/
- generic [ref=e14]: (<sx>)
- paragraph [ref=e15]: The framework-free reactive hypermedium
- paragraph [ref=e17]: © Giles Bradshaw 2026· /sx/(geography)
- generic [ref=e18]:
- link "← Etc" [ref=e19] [cursor=pointer]:
- /url: /sx/(etc)
- link "Geography" [ref=e20] [cursor=pointer]:
- /url: /sx/(geography)
- link "Language →" [ref=e21] [cursor=pointer]:
- /url: /sx/(language)
- generic [ref=e23]:
- link "Reactive Islands" [ref=e24] [cursor=pointer]:
- /url: /sx/(geography.(reactive))
- link "Hypermedia Lakes" [ref=e25] [cursor=pointer]:
- /url: /sx/(geography.(hypermedia))
- link "Scopes" [ref=e26] [cursor=pointer]:
- /url: /sx/(geography.(scopes))
- link "Provide / Emit!" [ref=e27] [cursor=pointer]:
- /url: /sx/(geography.(provide))
- link "Spreads" [ref=e28] [cursor=pointer]:
- /url: /sx/(geography.(spreads))
- link "Marshes" [ref=e29] [cursor=pointer]:
- /url: /sx/(geography.(marshes))
- link "Isomorphism" [ref=e30] [cursor=pointer]:
- /url: /sx/(geography.(isomorphism))
- link "CEK Machine" [ref=e31] [cursor=pointer]:
- /url: /sx/(geography.(cek))
- link "Capabilities" [ref=e32] [cursor=pointer]:
- /url: /sx/(geography.(capabilities))
- link "Reactive Runtime" [ref=e33] [cursor=pointer]:
- /url: /sx/(geography.(reactive-runtime))
- generic [ref=e36]:
- heading "Geography" [level=2] [ref=e37]
- paragraph [ref=e38]: Where code runs and how it gets there. Geography maps the rendering pipeline from server-side evaluation through wire formats to client-side hydration.
- generic [ref=e39]:
- heading "Rendering Pipeline" [level=3] [ref=e40]
- generic [ref=e41]:
- generic [ref=e42]:
- generic [ref=e43]: OCaml kernel
- generic [ref=e44]:
- paragraph [ref=e45]: The evaluator is a CEK machine written in SX and bootstrapped to OCaml. It evaluates page definitions, expands components, resolves IO (helpers, queries), and serializes the result as SX wire format.
- paragraph [ref=e46]: spec/evaluator.sx → hosts/ocaml/ → aser-slot with batch IO
- generic [ref=e47]:
- generic [ref=e48]: Wire format
- generic [ref=e49]:
- paragraph [ref=e50]:
- text: The aser (async-serialize) mode produces SX text — HTML tags and component calls serialized as s-expressions. Components with server affinity are expanded; client components stay as calls. The wire format is placed in a
- code [ref=e51]: <script type="text/sx">
- text: tag inside the HTML shell.
- paragraph [ref=e52]: web/adapter-sx.sx → SxExpr values pass through serialize unquoted
- generic [ref=e53]:
- generic [ref=e54]: sx-browser.js
- generic [ref=e55]:
- paragraph [ref=e56]: The client engine parses the SX wire format, evaluates component definitions, renders the DOM, and hydrates reactive islands. It includes the same CEK evaluator (transpiled from the spec), the parser, all web adapters, and the orchestration layer for fetch/swap/polling.
- paragraph [ref=e57]: spec/ + web/ → hosts/javascript/cli.py → sx-browser.js (~400KB)
- heading "What lives where" [level=3] [ref=e58]
- generic [ref=e59]:
- generic [ref=e60]:
- heading "Spec (shared)" [level=4] [ref=e61]
- paragraph [ref=e62]: "The canonical SX language, bootstrapped identically to OCaml, JavaScript, and Python:"
- list [ref=e63]:
- listitem [ref=e64]: CEK evaluator — frames, step function, call dispatch
- listitem [ref=e65]: Parser — tokenizer, s-expression reader, serializer
- listitem [ref=e66]: Primitives — ~80 built-in pure functions
- listitem [ref=e67]: Render modes — HTML, SX wire, DOM
- generic [ref=e68]:
- heading "Web Adapters" [level=4] [ref=e69]
- paragraph [ref=e70]: "SX-defined modules that run on both server and client:"
- list [ref=e71]:
- listitem [ref=e72]: adapter-sx.sx — aser wire format (server component expansion)
- listitem [ref=e73]: adapter-html.sx — server HTML rendering
- listitem [ref=e74]: adapter-dom.sx — client DOM rendering
- listitem [ref=e75]: orchestration.sx — fetch, swap, polling, navigation
- listitem [ref=e76]: engine.sx — trigger parsing, request building
- generic [ref=e77]:
- heading "OCaml Kernel (server)" [level=4] [ref=e78]
- paragraph [ref=e79]: "Persistent process connected via a binary pipe protocol:"
- list [ref=e80]:
- listitem [ref=e81]: CEK evaluator + VM bytecode compiler
- listitem [ref=e82]: Batch IO bridge — defers helper/query calls to Python
- listitem [ref=e83]: Length-prefixed blob protocol — no string escaping
- listitem [ref=e84]: Component hot-reload on .sx file changes
- generic [ref=e85]:
- heading "sx-browser.js (client)" [level=4] [ref=e86]
- paragraph [ref=e87]: "Single JS bundle transpiled from spec + web adapters:"
- list [ref=e88]:
- listitem [ref=e89]: Parses SX wire format from script tags
- listitem [ref=e90]: Renders component trees to DOM
- listitem [ref=e91]: Hydrates reactive islands (signals, effects)
- listitem [ref=e92]: Client-side routing with defpage
- listitem [ref=e93]: HTMX-like fetch/swap orchestration
- heading "Rendering Modes" [level=3] [ref=e94]
- table [ref=e96]:
- rowgroup [ref=e97]:
- row "Mode Runs on Components Output" [ref=e98]:
- columnheader "Mode" [ref=e99]
- columnheader "Runs on" [ref=e100]
- columnheader "Components" [ref=e101]
- columnheader "Output" [ref=e102]
- rowgroup [ref=e103]:
- row "render-to-html Server (OCaml) Expanded recursively HTML string" [ref=e104]:
- cell "render-to-html" [ref=e105]
- cell "Server (OCaml)" [ref=e106]
- cell "Expanded recursively" [ref=e107]
- cell "HTML string" [ref=e108]
- row "aser / aser-slot Server (OCaml) Server-affinity expanded; client preserved SX wire format" [ref=e109]:
- cell "aser / aser-slot" [ref=e110]
- cell "Server (OCaml)" [ref=e111]
- cell "Server-affinity expanded; client preserved" [ref=e112]
- cell "SX wire format" [ref=e113]
- row "render-to-dom Client (sx-browser.js) Expanded recursively DOM nodes" [ref=e114]:
- cell "render-to-dom" [ref=e115]
- cell "Client (sx-browser.js)" [ref=e116]
- cell "Expanded recursively" [ref=e117]
- cell "DOM nodes" [ref=e118]
- row "client routing Client (sx-browser.js) defpage content evaluated locally DOM swap" [ref=e119]:
- cell "client routing" [ref=e120]
- cell "Client (sx-browser.js)" [ref=e121]
- cell "defpage content evaluated locally" [ref=e122]
- cell "DOM swap" [ref=e123]
- heading "Topics" [level=3] [ref=e124]
- generic [ref=e125]:
- link "Hypermedia Lakes Server-driven UI with sx-get/post/put/delete — fetch, swap, and the request lifecycle." [ref=e126] [cursor=pointer]:
- /url: /sx/(geography.(hypermedia))
- heading "Hypermedia Lakes" [level=4] [ref=e127]
- paragraph [ref=e128]: Server-driven UI with sx-get/post/put/delete — fetch, swap, and the request lifecycle.
- link "Reactive Islands Client-side signals and effects hydrated from server-rendered HTML. defisland, deref, lakes." [ref=e129] [cursor=pointer]:
- /url: /sx/(geography.(reactive))
- heading "Reactive Islands" [level=4] [ref=e130]
- paragraph [ref=e131]: Client-side signals and effects hydrated from server-rendered HTML. defisland, deref, lakes.
- link "Marshes Where reactivity and hypermedia interpenetrate — server writes to signals, reactive views reshape server content." [ref=e132] [cursor=pointer]:
- /url: /sx/(geography.(marshes))
- heading "Marshes" [level=4] [ref=e133]
- paragraph [ref=e134]: Where reactivity and hypermedia interpenetrate — server writes to signals, reactive views reshape server content.
- link "Scopes Render-time dynamic scope — the primitive beneath provide, collect!, spreads, and islands." [ref=e135] [cursor=pointer]:
- /url: /sx/(geography.(scopes))
- heading "Scopes" [level=4] [ref=e136]
- paragraph [ref=e137]: Render-time dynamic scope — the primitive beneath provide, collect!, spreads, and islands.
- link "CEK Machine The evaluator internals — frames, continuations, tail-call optimization, and the VM bytecode compiler." [ref=e138] [cursor=pointer]:
- /url: /sx/(geography.(cek))
- heading "CEK Machine" [level=4] [ref=e139]
- paragraph [ref=e140]: The evaluator internals — frames, continuations, tail-call optimization, and the VM bytecode compiler.
- link "Isomorphism One spec, multiple hosts — how the same SX code runs on OCaml, JavaScript, and Python." [ref=e141] [cursor=pointer]:
- /url: /sx/(geography.(isomorphism))
- heading "Isomorphism" [level=4] [ref=e142]
- paragraph [ref=e143]: One spec, multiple hosts — how the same SX code runs on OCaml, JavaScript, and Python.
```

View File

@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""
Generate spec/tests/test-hyperscript-conformance-dev.sx from dev-branch expression tests.
Reads spec/tests/hyperscript-upstream-tests.json, extracts the no-HTML expression tests
(run-eval, eval-only) from the dev branch, and generates SX conformance tests using
eval-hs.
Usage: python3 tests/playwright/generate-sx-conformance-dev.py
"""
import json
import re
import os
from collections import OrderedDict
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-conformance-dev.sx')
with open(INPUT) as f:
all_tests = json.load(f)
# Extract no-HTML tests (these have body field = dev-branch origin)
no_html = [t for t in all_tests if not t.get('html', '').strip() and t.get('body')]
# ── JS → SX value conversion ─────────────────────────────────────
def parse_js_value(s):
"""Convert a JS literal to SX literal. Returns None if can't convert."""
s = s.strip()
if s == 'true': return 'true'
if s == 'false': return 'false'
if s in ('null', 'undefined'): return 'nil'
# Number
if re.match(r'^-?\d+(\.\d+)?$', s):
return s
# String — single or double quoted
m = re.match(r'^["\'](.*)["\']$', s)
if m:
inner = m.group(1).replace('"', '\\"')
return f'"{inner}"'
# Empty array
if s == '[]':
return '(list)'
# Array
m = re.match(r'^\[(.+)\]$', s, re.DOTALL)
if m:
return parse_js_array(m.group(1))
return None
def parse_js_array(inner):
"""Parse JS array contents into SX (list ...). Handles nested arrays."""
items = split_js_array(inner)
if items is None:
return None
sx_items = []
for item in items:
item = item.strip()
sx = parse_js_value(item)
if sx is None:
return None
sx_items.append(sx)
return f'(list {" ".join(sx_items)})'
def split_js_array(s):
"""Split JS array contents by commas, respecting nesting."""
items = []
depth = 0
current = ''
for ch in s:
if ch in '([':
depth += 1
current += ch
elif ch in ')]':
depth -= 1
current += ch
elif ch == ',' and depth == 0:
items.append(current)
current = ''
else:
current += ch
if current.strip():
items.append(current)
return items if items else None
def escape_hs(cmd):
"""Escape a hyperscript command for embedding in SX double-quoted string."""
return cmd.replace('\\', '\\\\').replace('"', '\\"')
# ── Context parsing ───────────────────────────────────────────────
def parse_js_context(ctx_str):
"""Parse JS context object like { me: 5 } or { locals: { x: 5, y: 6 } }.
Returns SX :ctx expression or None."""
if not ctx_str or ctx_str.strip() == '':
return None
parts = []
# me: value
me_m = re.search(r'me:\s*([^,}]+)', ctx_str)
if me_m:
val = parse_js_value(me_m.group(1).strip())
if val:
parts.append(f':me {val}')
# locals: { key: val, ... }
loc_m = re.search(r'locals:\s*\{([^}]+)\}', ctx_str)
if loc_m:
loc_pairs = []
for kv in re.finditer(r'(\w+):\s*([^,}]+)', loc_m.group(1)):
k = kv.group(1)
v = parse_js_value(kv.group(2).strip())
if v:
loc_pairs.append(f':{k} {v}')
if loc_pairs:
parts.append(f':locals {{{" ".join(loc_pairs)}}}')
if parts:
return f'{{{" ".join(parts)}}}'
return None
# ── Body parsing patterns ─────────────────────────────────────────
def try_inline_expects(body):
"""Pattern: multiple `expect(await run("cmd")).toBe(value)` lines.
Also handles context: `expect(await run("cmd", { me: 5 })).toBe(value)`."""
results = []
for m in re.finditer(
r'expect\(await run\((["\x60\'])(.+?)\1'
r'(?:,\s*(\{[^)]*\}))?\)\)'
r'\.(toBe|toEqual)\((.+?)\)',
body
):
cmd = m.group(2).strip()
ctx_raw = m.group(3)
expected = parse_js_value(m.group(5).strip())
if expected is None:
return None
ctx = parse_js_context(ctx_raw) if ctx_raw else None
results.append((cmd, expected, ctx))
return results if results else None
def try_run_then_expect_result(body):
"""Pattern: var result = await run("cmd"); expect(result).toBe(value)."""
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
exp_m = re.search(r'expect\(result\)\.(toBe|toEqual)\((.+?)\)\s*;?', body)
if run_m and exp_m:
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd = re.sub(r'\s+', ' ', cmd)
ctx_raw = run_m.group(2)
expected = parse_js_value(exp_m.group(2).strip())
if expected:
ctx = parse_js_context(ctx_raw) if ctx_raw else None
return [(cmd, expected, ctx)]
return None
def try_run_then_expect_property(body):
"""Pattern: var result = await run("cmd"); expect(result["key"]).toBe(value)
or expect(result.key).toBe(value)."""
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
if not run_m:
return None
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd = re.sub(r'\s+', ' ', cmd)
ctx_raw = run_m.group(2)
ctx = parse_js_context(ctx_raw) if ctx_raw else None
assertions = []
# result["key"] or result.key
for m in re.finditer(r'expect\(result\["(\w+)"\]\)\.(toBe|toEqual)\((.+?)\)', body):
expected = parse_js_value(m.group(3).strip())
if expected:
assertions.append(('get', m.group(1), expected))
for m in re.finditer(r'expect\(result\.(\w+)\)\.(toBe|toEqual)\((.+?)\)', body):
prop = m.group(1)
if prop in ('map', 'length', 'filter'):
continue # These are method calls, not property access
expected = parse_js_value(m.group(3).strip())
if expected:
assertions.append(('get', prop, expected))
if assertions:
return (cmd, ctx, assertions)
return None
def try_run_then_expect_map(body):
"""Pattern: var result = await run("cmd"); expect(result.map(x => x.name)).toEqual([...])."""
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
if not run_m:
return None
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
cmd = re.sub(r'\s+', ' ', cmd)
ctx_raw = run_m.group(2)
ctx = parse_js_context(ctx_raw) if ctx_raw else None
# result.map(x => x.prop)
map_m = re.search(r'expect\(result\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.(toBe|toEqual)\((.+?)\)', body)
if map_m:
prop = map_m.group(1)
expected = parse_js_value(map_m.group(3).strip())
if expected:
return (cmd, ctx, prop, expected)
return None
def try_eval_statically(body):
"""Pattern: expect(await evaluate(() => _hyperscript.parse("expr").evalStatically())).toBe(value).
evalStatically just evaluates literal expressions — maps to eval-hs."""
results = []
for m in re.finditer(
r'expect\(await evaluate\(\(\)\s*=>\s*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically\(\)\)\)'
r'\.(toBe|toEqual)\((.+?)\)',
body
):
expr = m.group(2)
expected = parse_js_value(m.group(4).strip())
if expected is None:
return None
results.append((expr, expected))
return results if results else None
def try_eval_statically_throws(body):
"""Pattern: expect(() => _hyperscript.parse("expr").evalStatically()).toThrow()."""
results = []
for m in re.finditer(
r'expect\(.*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically.*\)\.toThrow\(\)',
body
):
expr = m.group(2)
results.append(expr)
return results if results else None
# ── Test generation ───────────────────────────────────────────────
def emit_eval_hs(cmd, ctx):
"""Build (eval-hs "cmd") or (eval-hs "cmd" ctx) expression."""
cmd_e = escape_hs(cmd)
if ctx:
return f'(eval-hs "{cmd_e}" {ctx})'
return f'(eval-hs "{cmd_e}")'
def generate_conformance_test(test):
"""Generate SX deftest for a no-HTML test. Returns SX string or None."""
body = test.get('body', '')
name = test['name'].replace('"', "'")
# evalStatically — literal evaluation
eval_static = try_eval_statically(body)
if eval_static:
lines = [f' (deftest "{name}"']
for expr, expected in eval_static:
expr_e = escape_hs(expr)
lines.append(f' (assert= {expected} (eval-hs "{expr_e}"))')
lines.append(' )')
return '\n'.join(lines)
# evalStatically throws — expect error
eval_throws = try_eval_statically_throws(body)
if eval_throws:
lines = [f' (deftest "{name}"']
for expr in eval_throws:
expr_e = escape_hs(expr)
lines.append(f' ;; Should error: (eval-hs "{expr_e}")')
lines.append(f' (assert true)')
lines.append(' )')
return '\n'.join(lines)
# Multiple inline expects: expect(await run("...")).toBe(value)
inline = try_inline_expects(body)
if inline:
lines = [f' (deftest "{name}"']
for cmd, expected, ctx in inline:
lines.append(f' (assert= {expected} {emit_eval_hs(cmd, ctx)})')
lines.append(' )')
return '\n'.join(lines)
# var result = await run("..."); expect(result).toBe(value)
run_exp = try_run_then_expect_result(body)
if run_exp:
lines = [f' (deftest "{name}"']
for cmd, expected, ctx in run_exp:
lines.append(f' (assert= {expected} {emit_eval_hs(cmd, ctx)})')
lines.append(' )')
return '\n'.join(lines)
# var result = await run("..."); expect(result.map(x => x.prop)).toEqual([...])
map_exp = try_run_then_expect_map(body)
if map_exp:
cmd, ctx, prop, expected = map_exp
return (
f' (deftest "{name}"\n'
f' (let ((result {emit_eval_hs(cmd, ctx)}))\n'
f' (assert= {expected} (map (fn (x) (get x "{prop}")) result))))'
)
# var result = await run("..."); expect(result["key"]).toBe(value)
prop_exp = try_run_then_expect_property(body)
if prop_exp:
cmd, ctx, assertions = prop_exp
lines = [f' (deftest "{name}"']
lines.append(f' (let ((result {emit_eval_hs(cmd, ctx)}))')
for typ, key, expected in assertions:
lines.append(f' (assert= {expected} (get result "{key}"))')
lines.append(' ))')
return '\n'.join(lines)
return None
# ── Output generation ─────────────────────────────────────────────
output = []
output.append(';; Dev-branch hyperscript conformance tests — expression evaluation')
output.append(f';; Source: spec/tests/hyperscript-upstream-tests.json (no-HTML tests from v0.9.90-dev)')
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-conformance-dev.py')
output.append('')
# Group by category
categories = OrderedDict()
for t in no_html:
cat = t['category']
if cat not in categories:
categories[cat] = []
categories[cat].append(t)
total = 0
generated = 0
stubbed = 0
for cat, tests in categories.items():
output.append(f';; ── {cat} ({len(tests)} tests) ──')
output.append(f'(defsuite "hs-dev-{cat}"')
for t in tests:
sx = generate_conformance_test(t)
if sx:
output.append(sx)
generated += 1
else:
safe_name = t['name'].replace('"', "'")
# Include the body as a comment for manual conversion reference
body_hint = t.get('body', '').split('\n')
key_lines = [l.strip() for l in body_hint if 'expect' in l or 'run(' in l.lower()]
hint = key_lines[0][:80] if key_lines else t['complexity']
output.append(f' (deftest "{safe_name}"')
output.append(f' ;; {hint}')
output.append(f' (error "STUB: needs JS bridge — {t["complexity"]}"))')
stubbed += 1
total += 1
output.append(')')
output.append('')
with open(OUTPUT, 'w') as f:
f.write('\n'.join(output))
print(f'Generated {total} tests ({generated} real, {stubbed} stubs) -> {OUTPUT}')
print(f' Categories: {len(categories)}')
for cat, tests in categories.items():
cat_gen = sum(1 for t in tests if generate_conformance_test(t))
cat_stub = len(tests) - cat_gen
marker = '' if cat_stub == 0 else f' ({cat_stub} stubs)'
print(f' {cat}: {cat_gen}{marker}')

View File

@@ -0,0 +1,376 @@
// @ts-check
/**
* Generic SX page test runner.
*
* Discovers *.test.sx files next to components, parses defsuite/deftest
* forms, and executes them as Playwright tests against a real server.
*
* SX test format:
*
* (defsuite "name"
* :url "/sx/(geography.(isomorphism.streaming))"
* ;; :stream true — don't wait for data-sx-ready
* ;; :timeout 60000 — suite-level timeout
*
* (deftest "all slots resolve"
* (wait-for "[data-suspense='stream-fast']" :text "Fast source" :timeout 15000)
* (click "button")
* (assert-text "h1" "Streaming")))
*
* Primitives:
* (wait-for <sel> [:text t] [:visible] [:timeout ms] [:count n])
* (click <sel> [:text t] [:nth n])
* (fill <sel> <value>)
* (assert-text <sel> <text> [:timeout ms])
* (assert-not-text <sel> <text>)
* (assert-visible <sel> [:timeout ms])
* (assert-hidden <sel> [:timeout ms])
* (assert-count <sel> <n> [:timeout ms])
* (assert-no-errors)
* (wait <ms>)
* (snapshot <sel>)
* (assert-changed <sel>)
*/
const { test, expect } = require('playwright/test');
const { SiteServer } = require('./site-server');
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '../..');
// ---------------------------------------------------------------------------
// Discover *.test.sx files
// ---------------------------------------------------------------------------
function findTestFiles(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) findTestFiles(full, acc);
else if (entry.name.endsWith('.test.sx')) acc.push(full);
}
return acc;
}
const SEARCH_DIRS = ['sx/sx', 'shared/sx/templates'];
const testFiles = [];
for (const d of SEARCH_DIRS) findTestFiles(path.join(PROJECT_ROOT, d), testFiles);
// ---------------------------------------------------------------------------
// Minimal SX parser — just enough for test spec structure
// ---------------------------------------------------------------------------
function parseSx(src) {
let pos = 0;
function skip() {
while (pos < src.length) {
if (src[pos] === ';') { while (pos < src.length && src[pos] !== '\n') pos++; }
else if (/\s/.test(src[pos])) pos++;
else break;
}
}
function read() {
skip();
if (pos >= src.length) return null;
if (src[pos] === '(') {
pos++;
const list = [];
while (true) {
skip();
if (pos >= src.length || src[pos] === ')') { pos++; return list; }
list.push(read());
}
}
if (src[pos] === '"') {
pos++;
let s = '';
while (pos < src.length && src[pos] !== '"') {
if (src[pos] === '\\') { pos++; s += src[pos] || ''; }
else s += src[pos];
pos++;
}
pos++;
return { t: 's', v: s };
}
let tok = '';
while (pos < src.length && !/[\s()";]/.test(src[pos])) tok += src[pos++];
if (tok === 'true') return true;
if (tok === 'false') return false;
if (/^-?\d+(\.\d+)?$/.test(tok)) return Number(tok);
if (tok[0] === ':') return { t: 'k', v: tok.slice(1) };
return { t: 'y', v: tok };
}
const forms = [];
while (pos < src.length) {
skip();
if (pos < src.length) { const f = read(); if (f !== null) forms.push(f); }
}
return forms;
}
function sym(node, name) { return node && node.t === 'y' && node.v === name; }
function isKey(node) { return node && node.t === 'k'; }
function strVal(node) { return node && node.t === 's' ? node.v : node; }
// ---------------------------------------------------------------------------
// Parse test file into suites
// ---------------------------------------------------------------------------
function parseTestFile(filePath) {
const forms = parseSx(fs.readFileSync(filePath, 'utf8'));
const suites = [];
for (const form of forms) {
if (!Array.isArray(form) || !sym(form[0], 'defsuite')) continue;
const suite = { name: strVal(form[1]) || path.basename(filePath), url: '', stream: false, timeout: 30000, tests: [], file: filePath };
let i = 2;
// keyword args
while (i < form.length && isKey(form[i])) {
const k = form[i].v; i++;
if (k === 'url') { suite.url = strVal(form[i]); i++; }
else if (k === 'stream') { suite.stream = form[i] !== false; i++; }
else if (k === 'timeout') { suite.timeout = form[i]; i++; }
else i++;
}
// deftest forms
for (; i < form.length; i++) {
if (!Array.isArray(form[i]) || !sym(form[i][0], 'deftest')) continue;
const dt = form[i];
const t = { name: strVal(dt[1]) || `test-${suite.tests.length}`, steps: [] };
for (let j = 2; j < dt.length; j++) {
if (Array.isArray(dt[j])) t.steps.push(parseStep(dt[j]));
}
suite.tests.push(t);
}
suites.push(suite);
}
return suites;
}
function parseStep(form) {
const cmd = form[0].v;
const args = [];
const opts = {};
for (let i = 1; i < form.length; i++) {
if (isKey(form[i])) { const k = form[i].v; i++; opts[k] = strVal(form[i]); }
else args.push(strVal(form[i]));
}
return { cmd, args, opts };
}
// ---------------------------------------------------------------------------
// Step executor — maps SX primitives to Playwright calls
// ---------------------------------------------------------------------------
async function executeStep(page, step, state) {
const { cmd, args, opts } = step;
const timeout = opts.timeout ? Number(opts.timeout) : 10000;
switch (cmd) {
case 'wait-for': {
const loc = page.locator(args[0]);
if (opts.text) await expect(loc.first()).toContainText(String(opts.text), { timeout });
else if (opts.visible) await expect(loc.first()).toBeVisible({ timeout });
else if (opts.count !== undefined) await expect(loc).toHaveCount(Number(opts.count), { timeout });
else await loc.first().waitFor({ timeout });
break;
}
case 'click': {
let loc = page.locator(args[0]);
if (opts.text) loc = loc.filter({ hasText: String(opts.text) });
if (opts.nth !== undefined) await loc.nth(Number(opts.nth)).click();
else if (opts.last) await loc.last().click();
else await loc.first().click();
break;
}
case 'fill': {
await page.locator(args[0]).first().fill(String(args[1]));
break;
}
case 'assert-text': {
await expect(page.locator(args[0]).first()).toContainText(String(args[1]), { timeout });
break;
}
case 'assert-not-text': {
await expect(page.locator(args[0]).first()).not.toContainText(String(args[1]), { timeout: 3000 });
break;
}
case 'assert-visible': {
await expect(page.locator(args[0]).first()).toBeVisible({ timeout });
break;
}
case 'assert-hidden': {
await expect(page.locator(args[0]).first()).toBeHidden({ timeout });
break;
}
case 'assert-count': {
await expect(page.locator(args[0])).toHaveCount(Number(args[1]), { timeout });
break;
}
case 'assert-no-errors': {
// Marker — handled by test wrapper
break;
}
case 'wait': {
await page.waitForTimeout(Number(args[0]));
break;
}
case 'snapshot': {
state[args[0]] = await page.locator(args[0]).first().textContent();
break;
}
case 'assert-changed': {
const current = await page.locator(args[0]).first().textContent();
expect(current, `Expected ${args[0]} text to change`).not.toBe(state[args[0]]);
state[args[0]] = current;
break;
}
default:
throw new Error(`Unknown page test step: ${cmd}`);
}
}
// ---------------------------------------------------------------------------
// Shared server — one for all test files
// ---------------------------------------------------------------------------
const USE_EXTERNAL = !!process.env.SX_TEST_URL;
let server;
if (!USE_EXTERNAL) {
test.beforeAll(async () => {
server = new SiteServer();
await server.start();
});
test.afterAll(async () => {
if (server) server.stop();
});
}
function baseUrl() {
return USE_EXTERNAL ? process.env.SX_TEST_URL : server.baseUrl;
}
// ---------------------------------------------------------------------------
// Register discovered tests
// ---------------------------------------------------------------------------
if (testFiles.length === 0) {
test('no page tests found', () => {
console.log('No *.test.sx files found in:', SEARCH_DIRS.join(', '));
});
}
for (const file of testFiles) {
const suites = parseTestFile(file);
const relPath = path.relative(PROJECT_ROOT, file);
for (const suite of suites) {
test.describe(`${suite.name} (${relPath})`, () => {
test.describe.configure({ timeout: suite.timeout });
for (const t of suite.tests) {
test(t.name, async ({ page }) => {
// ── Diagnostics capture ──
const diag = { console: [], network: [], errors: [] };
page.on('console', msg => {
const entry = `[${msg.type()}] ${msg.text()}`;
diag.console.push(entry);
if (msg.type() === 'error') diag.errors.push(msg.text());
});
page.on('pageerror', e => {
diag.errors.push('PAGE_ERROR: ' + e.message);
diag.console.push('[pageerror] ' + e.message);
});
page.on('response', res => {
const url = res.url();
// Skip data: URLs
if (!url.startsWith('data:')) {
diag.network.push(`${res.status()} ${res.request().method()} ${url.replace(baseUrl(), '')}`);
}
});
page.on('requestfailed', req => {
const url = req.url();
if (!url.startsWith('data:')) {
diag.network.push(`FAILED ${req.method()} ${url.replace(baseUrl(), '')} ${req.failure()?.errorText || ''}`);
}
});
// ── Navigate ──
const waitUntil = suite.stream ? 'commit' : 'domcontentloaded';
await page.goto(baseUrl() + suite.url, { waitUntil, timeout: 30000 });
// Wait for hydration on non-streaming pages
if (!suite.stream) {
try {
await page.waitForSelector('html[data-sx-ready]', { timeout: 15000 });
} catch (_) { /* continue with test steps */ }
}
// ── Execute steps, dump diagnostics on failure ──
const state = {};
try {
for (const step of t.steps) {
await executeStep(page, step, state);
}
} catch (err) {
// Dump diagnostics on step failure
console.log('\n═══ DIAGNOSTICS ═══');
console.log('URL:', suite.url);
console.log('\n── Network (' + diag.network.length + ' requests) ──');
for (const n of diag.network) console.log(' ' + n);
console.log('\n── Console (' + diag.console.length + ' entries) ──');
for (const c of diag.console) console.log(' ' + c);
// DOM snapshot — first 3000 chars of body
try {
const bodySnap = await page.evaluate(() => {
const body = document.body;
if (!body) return '(no body)';
return body.innerHTML.substring(0, 3000);
});
console.log('\n── DOM (first 3000 chars) ──');
console.log(bodySnap);
} catch (_) {}
console.log('═══════════════════\n');
throw err;
}
// Auto-check console errors (filter network noise)
const real = diag.errors.filter(e =>
!e.includes('net::ERR') &&
!e.includes('Failed to fetch') &&
!e.includes('Failed to load resource') &&
!e.includes('404')
);
if (real.length > 0) {
console.log('Console errors:', real);
}
});
}
});
}
}

View File

@@ -387,6 +387,178 @@ test.describe('Streaming sandbox', () => {
});
});
// =========================================================================
// Chunked transfer test — spins up a real HTTP server with chunked encoding,
// serves the actual page using the real WASM kernel + sx-platform.js +
// component defs. Verifies resolve scripts execute and fill suspense slots.
// =========================================================================
const http = require('http');
function buildStreamingPage() {
// Read component defs (same as server sends in <script type="text/sx">)
const compFiles = [
'shared/sx/templates/tw.sx', 'shared/sx/templates/tw-layout.sx',
'shared/sx/templates/tw-type.sx', 'shared/sx/templates/pages.sx',
'sx/sx/streaming-demo.sx',
];
const compDefs = compFiles.map(f => readFile(f)).join('\n');
// Shell body — suspense placeholders with script src tags (like real site)
const shellBody = `<!doctype html><html><head><meta charset="utf-8"></head><body>
<div id="sx-root">
<h1>Streaming &amp; Suspense Demo</h1>
<div data-suspense="stream-fast" id="sx-suspense-stream-fast" style="display:contents">
<div class="animate-pulse">Loading fast...</div>
</div>
<div data-suspense="stream-medium" id="sx-suspense-stream-medium" style="display:contents">
<div class="animate-pulse">Loading medium...</div>
</div>
<div data-suspense="stream-slow" id="sx-suspense-stream-slow" style="display:contents">
<div class="animate-pulse">Loading slow...</div>
</div>
</div>
<script type="text/sx">${compDefs.replace(/<\//g, '<\\/')}</script>
<script src="/wasm/sx_browser.bc.js"></script>
<script src="/wasm/sx-platform.js"></script>`;
// Bootstrap (same as _sx_streaming_bootstrap in sx_server.ml)
const bootstrap = `<script>window.__sxPending=[];window.__sxResolve=function(i,s){` +
`if(window.Sx&&Sx.resolveSuspense){Sx.resolveSuspense(i,s)}` +
`else{window.__sxPending.push({id:i,sx:s})}}</script>`;
// Resolve scripts (same as sx_streaming_resolve_script produces)
const resolves = [
{ id: 'stream-fast', sx: '(div "Fast source resolved")', delay: 500 },
{ id: 'stream-medium', sx: '(div "Medium source resolved")', delay: 1000 },
{ id: 'stream-slow', sx: '(div "Slow source resolved")', delay: 1500 },
];
const tail = '\n</body></html>';
return { shellBody, bootstrap, resolves, tail };
}
function startStreamingServer() {
const parts = buildStreamingPage();
const wasmSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
const platformSrc = fs.readFileSync(path.join(PROJECT_ROOT, 'shared/static/wasm/sx-platform.js'), 'utf8');
const server = http.createServer((req, res) => {
// Serve static files from wasm directory (kernel, platform, .sxbc modules)
if (req.url.startsWith('/wasm/') || req.url.startsWith('/static/wasm/')) {
const relPath = req.url.replace('/static', '');
const filePath = path.join(WASM_DIR, relPath.replace('/wasm/', ''));
try {
const data = fs.readFileSync(filePath);
const ct = filePath.endsWith('.js') ? 'application/javascript'
: filePath.endsWith('.sx') ? 'text/plain' : 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct });
res.end(data);
return;
} catch(e) {
res.writeHead(404);
res.end('Not found: ' + filePath);
return;
}
}
// Streaming page — chunked transfer
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Transfer-Encoding': 'chunked',
});
// Chunk 1: shell body (suspense placeholders + script tags for kernel/platform)
res.write(parts.shellBody);
// Chunk 2: bootstrap
res.write(parts.bootstrap);
// Chunks 3-5: resolve scripts with staggered delays
let i = 0;
function sendNext() {
if (i >= parts.resolves.length) {
res.end(parts.tail);
return;
}
const r = parts.resolves[i++];
setTimeout(() => {
const script = `<script>window.__sxResolve&&window.__sxResolve(${JSON.stringify(r.id)},${JSON.stringify(r.sx)})</script>`;
res.write(script);
sendNext();
}, r.delay);
}
sendNext();
});
return new Promise(resolve => {
server.listen(0, () => resolve(server));
});
}
test.describe('Streaming chunked server', () => {
test.describe.configure({ timeout: 120000 });
let server;
let serverUrl;
test.beforeAll(async () => {
server = await startStreamingServer();
const port = server.address().port;
serverUrl = `http://localhost:${port}`;
});
test.afterAll(async () => {
if (server) server.close();
});
test('suspense slots resolve via chunked transfer', async ({ page }) => {
const consoleErrors = [];
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
page.on('pageerror', e => consoleErrors.push('PAGE_ERROR: ' + e.message));
await page.goto(serverUrl, { waitUntil: 'commit', timeout: 60000 });
// Wait for WASM kernel + platform to boot
await page.waitForFunction('!!window.Sx && !!window.Sx.resolveSuspense', { timeout: 60000 });
// Shell should render with 3 suspense placeholders
await expect(page.locator('[data-suspense]')).toHaveCount(3);
// Debug: check state + console errors
const dbg = await page.evaluate(() => ({
pending: window.__sxPending,
fastText: document.querySelector('[data-suspense="stream-fast"]')?.textContent?.substring(0, 40),
}));
console.log('CHUNKED DEBUG:', JSON.stringify(dbg), 'errors:', consoleErrors.slice(0, 5));
// Wait for all resolves (500ms + 1000ms + 1500ms = 3s total, plus boot time)
await expect(page.locator('[data-suspense="stream-fast"]'))
.toContainText('Fast source resolved', { timeout: 15000 });
await expect(page.locator('[data-suspense="stream-medium"]'))
.toContainText('Medium source resolved', { timeout: 15000 });
await expect(page.locator('[data-suspense="stream-slow"]'))
.toContainText('Slow source resolved', { timeout: 15000 });
});
test('__sxResolve is defined after boot', async ({ page }) => {
await page.goto(serverUrl, { waitUntil: 'commit', timeout: 60000 });
// Wait for full boot (not just Sx.resolveSuspense, which is available eagerly)
await page.waitForFunction(
'!!document.documentElement.getAttribute("data-sx-ready")',
{ timeout: 60000 }
);
const state = await page.evaluate(() => ({
resolveType: typeof window.__sxResolve,
hasSx: typeof window.Sx,
sxKeys: window.Sx ? Object.keys(window.Sx).join(',') : 'no Sx',
sxResolveSuspense: typeof (window.Sx && Sx.resolveSuspense),
pending: window.__sxPending,
}));
expect(state.resolveType).toBe('function');
expect(state.sxResolveSuspense).toBe('function');
// Pending should be drained (null) after boot
expect(state.pending).toBeNull();
});
});
// =========================================================================
// Live server tests — verify the actual chunked response works end-to-end
// =========================================================================