Temperature converter tests (6 tests): initial value, computed fahrenheit derivation, +5/-5 click handlers, reactive propagation, multiple click accumulation. New components: - sx/sx/reactive-islands/test-runner.sx — reusable defisland that parses test source, runs defsuite/deftest forms via cek-eval, and displays pass/fail results with re-run button - sx/sx/reactive-islands/test-temperature.sx — standalone test file Added cek-try primitive to both browser (sx_browser.ml) and server (sx_server.ml) for safe test execution with error catching. Browser bundle now includes harness files (harness.sx, harness-reactive.sx, harness-web.sx) for inline test execution. Known: SSR renders test runner body instead of placeholder, causing arity error on complex str expressions. Needs island SSR handling fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
/**
|
|
* 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";
|
|
|
|
var K = globalThis.SxKernel;
|
|
if (!K) { console.error("[sx-platform] SxKernel not found"); return; }
|
|
|
|
// ================================================================
|
|
// 8 FFI Host Primitives
|
|
// ================================================================
|
|
|
|
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 — pass through
|
|
if (typeof fn === "function") return fn;
|
|
// SX callable (has __sx_handle) — wrap as JS function
|
|
if (fn && fn.__sx_handle !== undefined) {
|
|
return function() {
|
|
var a = Array.prototype.slice.call(arguments);
|
|
return K.callFn(fn, a);
|
|
};
|
|
}
|
|
return function() {};
|
|
});
|
|
|
|
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";
|
|
});
|
|
|
|
// ================================================================
|
|
// 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 from current script
|
|
var _cacheBust = "";
|
|
(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);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load all web adapter .sx files in dependency order.
|
|
* Called after the 8 FFI primitives are registered.
|
|
*/
|
|
function loadWebStack() {
|
|
var files = [
|
|
// Spec modules
|
|
"sx/render.sx",
|
|
"sx/core-signals.sx",
|
|
"sx/signals.sx",
|
|
"sx/deps.sx",
|
|
"sx/router.sx",
|
|
"sx/page-helpers.sx",
|
|
// Freeze scope (signal persistence)
|
|
"sx/freeze.sx",
|
|
// Bytecode compiler + VM
|
|
"sx/bytecode.sx",
|
|
"sx/compiler.sx",
|
|
"sx/vm.sx",
|
|
// Web libraries (use 8 FFI primitives)
|
|
"sx/dom.sx",
|
|
"sx/browser.sx",
|
|
// Web adapters
|
|
"sx/adapter-html.sx",
|
|
"sx/adapter-sx.sx",
|
|
"sx/adapter-dom.sx",
|
|
// Boot helpers (platform functions in pure SX)
|
|
"sx/boot-helpers.sx",
|
|
"sx/hypersx.sx",
|
|
// Test harness (for inline test runners)
|
|
"sx/harness.sx",
|
|
"sx/harness-reactive.sx",
|
|
"sx/harness-web.sx",
|
|
// Web framework
|
|
"sx/engine.sx",
|
|
"sx/orchestration.sx",
|
|
"sx/boot.sx",
|
|
];
|
|
|
|
var loaded = 0;
|
|
for (var i = 0; i < files.length; i++) {
|
|
var r = loadSxFile(files[i]);
|
|
if (typeof r === "number") loaded += r;
|
|
}
|
|
console.log("[sx-platform] Loaded " + loaded + " expressions from " + files.length + " files");
|
|
return loaded;
|
|
}
|
|
|
|
// ================================================================
|
|
// 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)"));
|
|
// Try manual island query
|
|
console.log("[sx] manual island query:", K.eval("(len (dom-query-all (dom-body) \"[data-sx-island]\"))"));
|
|
// 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);
|
|
}
|
|
console.log("[sx] boot done");
|
|
}
|
|
}
|
|
};
|
|
|
|
// ================================================================
|
|
// Auto-init: load web stack and boot on DOMContentLoaded
|
|
// ================================================================
|
|
|
|
if (typeof document !== "undefined") {
|
|
var _doInit = function() {
|
|
loadWebStack();
|
|
Sx.init();
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", _doInit);
|
|
} else {
|
|
_doInit();
|
|
}
|
|
}
|
|
|
|
})();
|