sx-tools: WASM kernel updates, TW/CSSX rework, content refresh, new debugging tools

Build tooling: updated OCaml bootstrapper, compile-modules, bundle.sh, sx-build-all.
WASM browser: rebuilt sx_browser.bc.js/wasm, sx-platform-2.js, .sxbc bytecode files.
CSSX/Tailwind: reworked cssx.sx templates and tw-layout, added tw-type support.
Content: refreshed essays, plans, geography, reactive islands, docs, demos, handlers.
New tools: bisect_sxbc.sh, test-spa.js, render-trace.sx, morph playwright spec.
Tests: added test-match.sx, test-examples.sx, updated test-tw.sx and web tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:31:57 +00:00
parent 9ed1100ef6
commit d40a9c6796
178 changed files with 13591 additions and 9110 deletions

View File

@@ -24,7 +24,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-31T23:22:57Z";
var SX_VERSION = "2026-04-01T20:24:51Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -462,6 +462,8 @@
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; };
PRIMITIVES["into"] = function(target, coll) {
if (target === "list") return Array.isArray(coll) ? coll.slice() : Object.entries(coll).map(function(e) { return [e[0], e[1]]; });
if (target === "dict") { var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; } return r; }
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
return r;
@@ -2864,10 +2866,20 @@ PRIMITIVES["render-html-form?"] = isRenderHtmlForm;
return (isSxTruthy(!isSxTruthy(sxEq(typeOf(head), "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() {
var name = symbolName(head);
var args = rest(expr);
return (isSxTruthy(sxEq(name, "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy(sxEq(name, "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(sxEq(name, "lake")) ? renderHtmlLake(args, env) : (isSxTruthy(sxEq(name, "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(sxOr(sxEq(name, "portal"), sxEq(name, "error-boundary"), sxEq(name, "promise-delayed"))) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
return (isSxTruthy(sxEq(name, "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy(sxEq(name, "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(sxEq(name, "lake")) ? renderHtmlLake(args, env) : (isSxTruthy(sxEq(name, "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(sxEq(name, "error-boundary")) ? (function() {
var hasFallback = (len(args) > 1);
return (function() {
var bodyExprs = (isSxTruthy(hasFallback) ? rest(args) : args);
var fallbackExpr = (isSxTruthy(hasFallback) ? first(args) : NIL);
return (String("<div data-sx-boundary=\"true\">") + String(tryCatch(function() { return join("", map(function(x) { return renderToHtml(x, env); }, bodyExprs)); }, function(err) { return (function() {
var safeErr = replace_(replace_((String(err)), "<", "&lt;"), ">", "&gt;");
return (isSxTruthy((isSxTruthy(fallbackExpr) && !isSxTruthy(isNil(fallbackExpr)))) ? tryCatch(function() { return renderToHtml([trampoline(evalExpr(fallbackExpr, env)), err, NIL], env); }, function(e2) { return (String("<div class=\"sx-render-error\" style=\"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\">Render error: ") + String(safeErr) + String("</div>")); }) : (String("<div class=\"sx-render-error\" style=\"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\">Render error: ") + String(safeErr) + String("</div>")));
})(); })) + String("</div>"));
})();
})() : (isSxTruthy(sxOr(sxEq(name, "portal"), sxEq(name, "promise-delayed"))) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
var val = envGet(env, name);
return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : (String("<!-- unknown component: ") + String(name) + String(" -->"))));
})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))));
})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))))))));
})());
})()); };
PRIMITIVES["render-list-to-html"] = renderListToHtml;
@@ -3099,11 +3111,25 @@ PRIMITIVES["aser"] = aser;
var comp = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : NIL);
var expandAll = (isSxTruthy(envHas(env, "expand-components?")) ? expandComponents_p() : false);
return (isSxTruthy((isSxTruthy(comp) && isMacro(comp))) ? aser(expandMacro(comp, args, env), env) : (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && isSxTruthy(!isSxTruthy(isIsland(comp))) && isSxTruthy(sxOr(expandAll, sxEq(componentAffinity(comp), "server"))) && !isSxTruthy(sxEq(componentAffinity(comp), "client")))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env)));
})() : (isSxTruthy(sxEq(name, "lake")) ? aserCall(name, args, env) : (isSxTruthy(sxEq(name, "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
})() : (isSxTruthy(sxEq(name, "lake")) ? aserCall(name, args, env) : (isSxTruthy(sxEq(name, "marsh")) ? aserCall(name, args, env) : (isSxTruthy(sxEq(name, "error-boundary")) ? (function() {
var hasFallback = (len(args) > 1);
return (function() {
var bodyExprs = (isSxTruthy(hasFallback) ? rest(args) : args);
var errStr = NIL;
return (function() {
var rendered = tryCatch(function() { return join("", map(function(x) { return (function() {
var v = aser(x, env);
return (isSxTruthy(sxEq(typeOf(v), "sx-expr")) ? sxExprSource(v) : (isSxTruthy(isNil(v)) ? "" : serialize(v)));
})(); }, bodyExprs)); }, function(err) { errStr = (String(err));
return NIL; });
return (isSxTruthy(rendered) ? makeSxExpr((String("(error-boundary ") + String(rendered) + String(")"))) : makeSxExpr((String("(div :data-sx-boundary \"true\" ") + String("(div :class \"sx-render-error\" ") + String(":style \"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\" ") + String("\"Render error: ") + String(replace_(replace_(errStr, "\"", "'"), "\\", "\\\\")) + String("\"))"))));
})();
})();
})() : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
var f = trampoline(evalExpr(head, env));
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && isSxTruthy(!isSxTruthy(isComponent(f))) && !isSxTruthy(isIsland(f)))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : (isSxTruthy(isIsland(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f))))))));
})()))))))));
})())))))))));
})());
})(); };
PRIMITIVES["aser-list"] = aserList;
@@ -4522,8 +4548,8 @@ PRIMITIVES["render-dom-portal"] = renderDomPortal;
// render-dom-error-boundary
var renderDomErrorBoundary = function(args, env, ns) { return (function() {
var fallbackExpr = first(args);
var bodyExprs = rest(args);
var fallbackExpr = (isSxTruthy((len(args) > 1)) ? first(args) : NIL);
var bodyExprs = (isSxTruthy((len(args) > 1)) ? rest(args) : args);
var container = domCreateElement("div", NIL);
var retryVersion = signal(0);
domSetAttr(container, "data-sx-boundary", "true");
@@ -4540,7 +4566,13 @@ return (function() {
var fallbackFn = trampoline(evalExpr(fallbackExpr, env));
var retryFn = function() { return swap_b(retryVersion, function(n) { return (n + 1); }); };
return (function() {
var fallbackDom = (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns));
var fallbackDom = (isSxTruthy(isNil(fallbackFn)) ? (function() {
var el = domCreateElement("div", NIL);
domSetAttr(el, "class", "sx-render-error");
domSetAttr(el, "style", "color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;");
domSetTextContent(el, (String("Render error: ") + String(err)));
return el;
})() : (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns)));
return domAppend(container, fallbackDom);
})();
})(); }); });
@@ -4578,7 +4610,7 @@ PRIMITIVES["parse-time"] = parseTime;
PRIMITIVES["parse-trigger-spec"] = parseTriggerSpec;
// default-trigger
var defaultTrigger = function(tagName) { return (isSxTruthy(sxEq(tagName, "FORM")) ? [{["event"]: "submit", ["modifiers"]: {}}] : (isSxTruthy(sxOr(sxEq(tagName, "INPUT"), sxEq(tagName, "SELECT"), sxEq(tagName, "TEXTAREA"))) ? [{["event"]: "change", ["modifiers"]: {}}] : [{["event"]: "click", ["modifiers"]: {}}])); };
var defaultTrigger = function(tagName) { return (isSxTruthy(sxEq(tagName, "form")) ? [{["event"]: "submit", ["modifiers"]: {}}] : (isSxTruthy(sxOr(sxEq(tagName, "input"), sxEq(tagName, "select"), sxEq(tagName, "textarea"))) ? [{["event"]: "change", ["modifiers"]: {}}] : [{["event"]: "click", ["modifiers"]: {}}])); };
PRIMITIVES["default-trigger"] = defaultTrigger;
// get-verb-info
@@ -5053,11 +5085,11 @@ PRIMITIVES["handle-fetch-success"] = handleFetchSuccess;
var container = domCreateElement("div", NIL);
domAppend(container, rendered);
processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t);
swapDomNodes(t, oob, s);
sxHydrate(t);
return processElements(t); });
swapDomNodes(t, (isSxTruthy(sxEq(s, "innerHTML")) ? childrenToFragment(oob) : oob), s);
return postSwap(t); });
return (function() {
var selectSel = domGetAttr(el, "sx-select");
return (function() {
var content = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container));
disposeIslandsIn(target);
return withTransition(useTransition, function() { return (function() {
@@ -5065,6 +5097,7 @@ return processElements(t); });
return postSwap((isSxTruthy(sxEq(swapStyle, "outerHTML")) ? domParent(sxOr(swapResult, target)) : sxOr(swapResult, target)));
})(); });
})();
})();
})() : NIL);
})();
})();
@@ -5078,12 +5111,20 @@ PRIMITIVES["handle-sx-response"] = handleSxResponse;
var selectSel = domGetAttr(el, "sx-select");
disposeIslandsIn(target);
return (isSxTruthy(selectSel) ? (function() {
var html = selectHtmlFromDoc(doc, selectSel);
var container = domCreateElement("div", NIL);
domSetInnerHtml(container, domBodyInnerHtml(doc));
processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t);
swapDomNodes(t, oob, s);
return postSwap(t); });
hoistHeadElements(container);
return (function() {
var html = selectFromContainer(container, selectSel);
return withTransition(useTransition, function() { return (function() {
var swapRoot = swapHtmlString(target, html, swapStyle);
var swapRoot = swapDomNodes(target, html, swapStyle);
logInfo((String("swap-root: ") + String((isSxTruthy(swapRoot) ? domTagName(swapRoot) : "nil")) + String(" target: ") + String(domTagName(target))));
return postSwap(sxOr(swapRoot, target));
})(); });
})();
})() : (function() {
var container = domCreateElement("div", NIL);
domSetInnerHtml(container, domBodyInnerHtml(doc));
@@ -5119,7 +5160,10 @@ PRIMITIVES["handle-retry"] = handleRetry;
return forEach(function(trigger) { return (function() {
var kind = classifyTrigger(trigger);
var mods = get(trigger, "modifiers");
return (isSxTruthy(sxEq(kind, "poll")) ? setInterval_(function() { return executeRequest(el, NIL, NIL); }, get(mods, "interval")) : (isSxTruthy(sxEq(kind, "intersect")) ? observeIntersection(el, function() { return executeRequest(el, NIL, NIL); }, false, get(mods, "delay")) : (isSxTruthy(sxEq(kind, "load")) ? setTimeout_(function() { return executeRequest(el, NIL, NIL); }, sxOr(get(mods, "delay"), 0)) : (isSxTruthy(sxEq(kind, "revealed")) ? observeIntersection(el, function() { return executeRequest(el, NIL, NIL); }, true, get(mods, "delay")) : (isSxTruthy(sxEq(kind, "event")) ? bindEvent(el, get(trigger, "event"), mods, verbInfo) : NIL)))));
return (isSxTruthy(sxEq(kind, "poll")) ? (function() {
var intervalId = NIL;
return (intervalId = setInterval_(function() { return (isSxTruthy(hostGet(el, "isConnected")) ? executeRequest(el, NIL, NIL) : (clearInterval_(intervalId), logInfo("poll stopped: element removed"))); }, get(mods, "interval")));
})() : (isSxTruthy(sxEq(kind, "intersect")) ? observeIntersection(el, function() { return executeRequest(el, NIL, NIL); }, false, get(mods, "delay")) : (isSxTruthy(sxEq(kind, "load")) ? setTimeout_(function() { return executeRequest(el, NIL, NIL); }, sxOr(get(mods, "delay"), 0)) : (isSxTruthy(sxEq(kind, "revealed")) ? observeIntersection(el, function() { return executeRequest(el, NIL, NIL); }, true, get(mods, "delay")) : (isSxTruthy(sxEq(kind, "event")) ? bindEvent(el, get(trigger, "event"), mods, verbInfo) : NIL)))));
})(); }, triggers);
})(); };
PRIMITIVES["bind-triggers"] = bindTriggers;
@@ -5421,7 +5465,32 @@ PRIMITIVES["offline-aware-mutation"] = offlineAwareMutation;
PRIMITIVES["current-page-layout"] = currentPageLayout;
// swap-rendered-content
var swapRenderedContent = function(target, rendered, pathname) { return (disposeIslandsIn(target), domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), sxHydrateIslands(target), runPostRenderHooks(), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); };
var swapRenderedContent = function(target, rendered, pathname) { return (function() {
var container = domCreateElement("div", NIL);
domAppend(container, rendered);
processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t);
swapDomNodes(t, (isSxTruthy(sxEq(s, "innerHTML")) ? childrenToFragment(oob) : oob), s);
return postSwap(t); });
return (function() {
var targetId = domGetAttr(target, "id");
return (function() {
var inner = (isSxTruthy(targetId) ? domQuery(container, (String("#") + String(targetId))) : NIL);
return (function() {
var content = (isSxTruthy(inner) ? childrenToFragment(inner) : childrenToFragment(container));
disposeIslandsIn(target);
domSetTextContent(target, "");
domAppend(target, content);
hoistHeadElementsFull(target);
processElements(target);
sxHydrateElements(target);
sxHydrateIslands(target);
runPostRenderHooks();
domDispatch(target, "sx:clientRoute", {["pathname"]: pathname});
return logInfo((String("sx:route client ") + String(pathname)));
})();
})();
})();
})(); };
PRIMITIVES["swap-rendered-content"] = swapRenderedContent;
// resolve-route-target

View File

@@ -166,6 +166,22 @@
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
// ================================================================
@@ -398,68 +414,6 @@
"children:", islands[j].children.length);
}
console.log("[sx] boot done");
// sx-on: inline event handlers — bind from JS because the WASM
// CSS selector [sx-on\:] doesn't match. Uses MutationObserver to
// also catch elements added after boot (e.g. from swaps).
function _bindSxOn(root) {
var all = (root || document).querySelectorAll('*');
for (var k = 0; k < all.length; k++) {
var el = all[k];
if (el._sxOnBound) continue;
var attrs = el.attributes;
var hasSxOn = false;
for (var a = 0; a < attrs.length; a++) {
var aname = attrs[a].name;
if (aname.indexOf('sx-on:') === 0) {
hasSxOn = true;
var evtName = aname.slice(6);
// HTML lowercases attrs: afterSwap → afterswap.
// Engine dispatches camelCase: sx:afterSwap.
// Listen for both forms.
var evtName2 = null;
if (evtName.indexOf('after') === 0 || evtName.indexOf('before') === 0) {
evtName2 = 'sx:' + evtName; // lowercase form
// Also try camelCase form
var camel = evtName.replace(/swap|request|settle/gi, function(m) {
return m.charAt(0).toUpperCase() + m.slice(1);
});
evtName = 'sx:' + camel;
}
(function(el2, evt, evt2, code) {
var handler = function(e) {
try { new Function('event', code).call(el2, e); }
catch(err) { console.warn('[sx] sx-on:' + evt + ' error:', err); }
};
el2.addEventListener(evt, handler);
if (evt2) el2.addEventListener(evt2, handler);
})(el, evtName, evtName2, attrs[a].value);
}
}
if (hasSxOn) el._sxOnBound = true;
}
}
_bindSxOn(document);
// Re-bind after swaps
document.addEventListener('sx:afterSwap', function(e) {
if (e.target) _bindSxOn(e.target);
});
// Global keyboard shortcut dispatch — WASM host-callbacks on
// document/body don't fire, so handle from:body keyboard
// triggers in JS and call execute-request via the SX engine.
document.addEventListener("keyup", function(e) {
if (e.target && e.target.matches && e.target.matches("input,textarea,select")) return;
var sel = '[sx-trigger*="key==\'' + e.key + '\'"]';
var els = document.querySelectorAll(sel);
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (!el.id) el.id = "_sx_kbd_" + Math.random().toString(36).slice(2);
try {
K.eval('(execute-request (dom-query-by-id "' + el.id + '") nil nil)');
} catch(err) { console.warn("[sx] keyboard dispatch error:", err); }
}
});
}
}
};

View File

@@ -0,0 +1,452 @@
/**
* 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) {
// ================================================================
// 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";
});
// 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;
}
}
/**
* Try loading a pre-compiled .sxbc bytecode module (SX text format).
* 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;
window.__sxbcText = xhr.responseText;
var result = K.eval('(load-sxbc (first (parse (host-global "__sxbcText"))))');
delete window.__sxbcText;
if (typeof result === 'string' && result.indexOf('Error') === 0) {
console.warn("[sx-platform] bytecode FAIL " + path + ":", result);
return null;
}
return true;
} catch(e) {
delete window.__sxbcText;
return null;
}
}
/**
* 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.
* Tries pre-compiled bytecode first, falls back to source.
*/
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) + highlight (syntax coloring)
"sx/freeze.sx",
"sx/highlight.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, bcCount = 0, srcCount = 0;
if (K.beginModuleLoad) K.beginModuleLoad();
for (var i = 0; i < files.length; i++) {
var r = loadBytecodeFile(files[i]);
if (r) { bcCount++; continue; }
// Bytecode not available — end batch, load source, restart batch
if (K.endModuleLoad) K.endModuleLoad();
r = loadSxFile(files[i]);
if (typeof r === "number") { loaded += r; srcCount++; }
if (K.beginModuleLoad) K.beginModuleLoad();
}
if (K.endModuleLoad) K.endModuleLoad();
console.log("[sx-platform] Loaded " + files.length + " files (" + bcCount + " bytecode, " + srcCount + " source, " + loaded + " exprs)");
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)"));
// 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);
}
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
setTimeout(function() { 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);
})();

View File

@@ -437,17 +437,9 @@
(let
((f (trampoline (eval-expr (first args) env)))
(coll (trampoline (eval-expr (nth args 1) env))))
(map
(fn
(item)
(if
(lambda? f)
(let
((local (env-merge (lambda-closure f) env)))
(env-bind! local (first (lambda-params f)) item)
(aser (lambda-body f) local))
(cek-call f (list item))))
coll))
(let
((results (map (fn (item) (if (lambda? f) (let ((local (env-extend (lambda-closure f)))) (env-bind! local (first (lambda-params f)) item) (aser (lambda-body f) local)) (cek-call f (list item)))) coll)))
(aser-fragment results env)))
(= name "map-indexed")
(let
((f (trampoline (eval-expr (first args) env)))

File diff suppressed because one or more lines are too long

View File

@@ -430,17 +430,6 @@
(host-callback thunk))
(thunk))))
(define
observe-intersection
(fn
(el callback once? delay)
(let
((cb (host-callback (fn (entries) (for-each (fn (entry) (when (host-get entry "isIntersecting") (if delay (set-timeout (fn () (callback entry)) delay) (callback entry)) (when once? (host-call observer "unobserve" el)))) (host-call entries "forEach" (host-callback (fn (e) e))))))))
(let
((observer (host-new "IntersectionObserver" (host-callback (fn (entries) (let ((arr-len (host-get entries "length"))) (let loop ((i 0)) (when (< i arr-len) (let ((entry (host-call entries "item" i))) (when (and entry (host-get entry "isIntersecting")) (if delay (set-timeout (fn () (callback entry)) delay) (callback entry)) (when once? (host-call observer "unobserve" el)))) (loop (+ i 1))))))))))
(host-call observer "observe" el)
observer))))
(define
event-source-connect
(fn

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,23 @@
;; Create raw signal dict with value, subs, deps fields
(define
make-signal
(fn
(value)
(dict "__signal" true "value" value "subscribers" (list) "deps" (list))))
;; Type predicate for signals
(define signal? (fn (x) (and (dict? x) (has-key? x "__signal"))))
;; Read current value from signal
(define signal-value (fn (s) (get s "value")))
;; Write value to signal (no notification)
(define signal-set-value! (fn (s v) (dict-set! s "value" v)))
;; List of subscriber functions
(define signal-subscribers (fn (s) (get s "subscribers")))
;; Add a subscriber function
(define
signal-add-sub!
(fn
@@ -20,6 +26,7 @@
(not (contains? (get s "subscribers") f))
(dict-set! s "subscribers" (append (get s "subscribers") (list f))))))
;; Remove a subscriber function
(define
signal-remove-sub!
(fn
@@ -29,15 +36,19 @@
"subscribers"
(filter (fn (sub) (not (identical? sub f))) (get s "subscribers")))))
;; List of upstream signal dependencies
(define signal-deps (fn (s) (get s "deps")))
;; Set upstream dependencies
(define signal-set-deps! (fn (s deps) (dict-set! s "deps" deps)))
;; Create a reactive signal (user-facing constructor)
(define
signal
:effects ()
(fn ((initial-value :as any)) (make-signal initial-value)))
;; Dereference a signal, returning its current value
(define
deref
:effects ()
@@ -58,6 +69,7 @@
(signal-add-sub! s notify-fn))))
(signal-value s)))))
;; Set signal to new value and notify subscribers
(define
reset!
:effects (mutation)
@@ -72,6 +84,7 @@
(signal-set-value! s value)
(notify-subscribers s))))))
;; Apply function to current value and reset
(define
swap!
:effects (mutation)
@@ -87,6 +100,7 @@
(signal-set-value! s new-val)
(notify-subscribers s))))))
;; Create a derived signal that auto-updates from dependencies
(define
computed
:effects (mutation)
@@ -100,6 +114,7 @@
(register-in-scope (fn () (dispose-computed s)))
s))))
;; Create a side-effect that runs when dependencies change
(define
effect
:effects (mutation)
@@ -115,10 +130,13 @@
(register-in-scope dispose-fn)
dispose-fn)))))
;; Nesting counter for batched updates
(define *batch-depth* 0)
;; Queued notifications during batch
(define *batch-queue* (list))
;; Batch multiple signal updates, notify once at end
(define
batch
:effects (mutation)
@@ -148,6 +166,7 @@
queue)
(for-each (fn ((sub :as lambda)) (sub)) pending))))))
;; Notify all subscribers of a signal change
(define
notify-subscribers
:effects (mutation)
@@ -158,6 +177,7 @@
(when (not (contains? *batch-queue* s)) (append! *batch-queue* s))
(flush-subscribers s))))
;; Process queued subscriber notifications
(define
flush-subscribers
:effects (mutation)
@@ -165,6 +185,7 @@
((s :as dict))
(for-each (fn (sub) (cek-call sub nil)) (signal-subscribers s))))
;; Tear down a computed signal, remove from deps
(define
dispose-computed
:effects (mutation)
@@ -177,6 +198,7 @@
(signal-deps s))
(signal-set-deps! s (list)))))
;; Evaluate body in an island disposal scope
(define
with-island-scope
:effects (mutation)
@@ -185,6 +207,7 @@
(scope-push! "sx-island-scope" scope-fn)
(let ((result (body-fn))) (scope-pop! "sx-island-scope") result)))
;; Register a disposable in the current island scope
(define
register-in-scope
:effects (mutation)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,41 +1,62 @@
;; Assert condition is truthy, error with message
(define assert (fn (condition msg) (when (not condition) (error (or msg "Assertion failed")))))
;; Assert two values are equal
(define assert= (fn (actual expected msg) (when (not (= actual expected)) (error (or msg (str "Expected " expected ", got " actual))))))
;; Dict of mock IO operations for testing
(define default-platform {:current-user (fn () nil) :csrf-token (fn () "test-csrf-token") :app-url (fn (service &rest path) "/mock-app-url") :frag (fn (service comp &rest args) "") :sleep (fn (ms) nil) :local-storage-set (fn (key val) nil) :set-cookie (fn (name val &rest opts) nil) :url-for (fn (endpoint &rest args) "/mock-url") :create-element (fn (tag) nil) :request-path (fn () "/") :config (fn (key) nil) :set-attr (fn (el name val) nil) :set-text (fn (el text) nil) :remove-child (fn (parent child) nil) :fetch (fn (url &rest opts) {:status 200 :body "" :ok true}) :query (fn (service name &rest args) (list)) :add-class (fn (el cls) nil) :get-element (fn (id) nil) :now (fn () 0) :abort (fn (code) nil) :action (fn (service name &rest args) {:ok true}) :remove-class (fn (el cls) nil) :append-child (fn (parent child) nil) :request-arg (fn (name) nil) :emit-dom (fn (op &rest args) nil) :local-storage-get (fn (key) nil) :get-cookie (fn (name) nil)})
;; Create a test session with mock IO platform
(define make-harness :effects () (fn (&key platform) (let ((merged (if (nil? platform) default-platform (merge default-platform platform)))) {:log (list) :platform merged :state {:cookies {} :storage {} :dom nil}})))
;; Clear IO log and state for a new test
(define harness-reset! :effects () (fn (session) (dict-set! session "log" (list)) (dict-set! session "state" {:cookies {} :storage {} :dom nil}) session))
;; Append an IO call record to session log
(define harness-log :effects () (fn (session &key op) (let ((log (get session "log"))) (if (nil? op) log (filter (fn (entry) (= (get entry "op") op)) log)))))
;; Read state value from session store
(define harness-get :effects () (fn (session key) (get (get session "state") key)))
;; Write state value to session store
(define harness-set! :effects () (fn (session key value) (dict-set! (get session "state") key value) nil))
;; Wrap a mock fn to record calls in the IO log
(define make-interceptor :effects () (fn (session op-name mock-fn) (fn (&rest args) (let ((result (if (empty? args) (mock-fn) (if (= 1 (len args)) (mock-fn (first args)) (if (= 2 (len args)) (mock-fn (first args) (nth args 1)) (if (= 3 (len args)) (mock-fn (first args) (nth args 1) (nth args 2)) (apply mock-fn args)))))) (log (get session "log"))) (append! log {:args args :result result :op op-name}) result))))
;; Bind all interceptors into the eval environment
(define install-interceptors :effects () (fn (session env) (for-each (fn (key) (let ((mock-fn (get (get session "platform") key)) (interceptor (make-interceptor session key mock-fn))) (env-bind! env key interceptor))) (keys (get session "platform"))) env))
;; Query IO log: all calls, or filtered by op name
(define io-calls :effects () (fn (session op-name) (filter (fn (entry) (= (get entry "op") op-name)) (get session "log"))))
;; Count IO calls, optionally filtered by op name
(define io-call-count :effects () (fn (session op-name) (len (io-calls session op-name))))
;; Get the nth IO call record
(define io-call-nth :effects () (fn (session op-name n) (let ((calls (io-calls session op-name))) (if (< n (len calls)) (nth calls n) nil))))
;; Get args from the nth call to an operation
(define io-call-args :effects () (fn (session op-name n) (let ((call (io-call-nth session op-name n))) (if (nil? call) nil (get call "args")))))
;; Get return value from the nth call to an operation
(define io-call-result :effects () (fn (session op-name n) (let ((call (io-call-nth session op-name n))) (if (nil? call) nil (get call "result")))))
;; Assert an IO operation was called at least once
(define assert-io-called :effects () (fn (session op-name) (assert (> (io-call-count session op-name) 0) (str "Expected IO operation " op-name " to be called but it was not"))))
;; Assert an IO operation was never called
(define assert-no-io :effects () (fn (session op-name) (assert (= (io-call-count session op-name) 0) (str "Expected IO operation " op-name " not to be called but it was called " (io-call-count session op-name) " time(s)"))))
;; Assert exact call count for an operation
(define assert-io-count :effects () (fn (session op-name expected) (let ((actual (io-call-count session op-name))) (assert (= actual expected) (str "Expected " op-name " to be called " expected " time(s) but was called " actual " time(s)")))))
;; Assert args of the nth call match expected
(define assert-io-args :effects () (fn (session op-name n expected-args) (let ((actual (io-call-args session op-name n))) (assert (equal? actual expected-args) (str "Expected call " n " to " op-name " with args " (str expected-args) " but got " (str actual))))))
;; Assert result of the nth call matches expected
(define assert-io-result :effects () (fn (session op-name n expected) (let ((actual (io-call-result session op-name n))) (assert (equal? actual expected) (str "Expected call " n " to " op-name " to return " (str expected) " but got " (str actual))))))
;; Assert a state key has the expected value
(define assert-state :effects () (fn (session key expected) (let ((actual (harness-get session key))) (assert (equal? actual expected) (str "Expected state " key " to be " (str expected) " but got " (str actual))))))

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,4 @@
;; Registry of all valid HTML tag names
(define
HTML_TAGS
(list
@@ -141,6 +142,7 @@
"dialog"
"menu"))
;; Self-closing tags (br, img, hr, etc.)
(define
VOID_ELEMENTS
(list
@@ -159,6 +161,7 @@
"track"
"wbr"))
;; Attrs that are true/false (checked, disabled, etc.)
(define
BOOLEAN_ATTRS
(list
@@ -186,8 +189,10 @@
"reversed"
"selected"))
;; Extensible list of forms treated as definitions
(define *definition-form-extensions* (list))
;; Check if a symbol names a definition form
(define
definition-form?
:effects ()
@@ -203,6 +208,7 @@
(= name "defeffect")
(contains? *definition-form-extensions* name))))
;; Parse keyword attrs and children from element arg list
(define
parse-element-args
:effects (render)
@@ -233,6 +239,7 @@
args)
(list attrs children))))
;; Render attr dict to HTML attribute string
(define
render-attrs
:effects ()
@@ -255,6 +262,7 @@
:else (str " " key "=\"" (escape-attr (str val)) "\""))))
(keys attrs)))))
;; Evaluate cond expression (dispatches to scheme/clojure style)
(define
eval-cond
:effects ()
@@ -265,6 +273,7 @@
(eval-cond-scheme clauses env)
(eval-cond-clojure clauses env))))
;; Scheme-style cond: ((test body) ...)
(define
eval-cond-scheme
:effects ()
@@ -285,6 +294,7 @@
body
(eval-cond-scheme (rest clauses) env)))))))
;; Clojure-style cond: (test body test body ...)
(define
eval-cond-clojure
:effects ()
@@ -303,6 +313,7 @@
body
(eval-cond-clojure (slice clauses 2) env)))))))
;; Evaluate let binding pairs, extend env
(define
process-bindings
:effects (mutation)
@@ -324,6 +335,7 @@
bindings)
local)))
;; Check if an expression should be rendered vs evaluated
(define
is-render-expr?
:effects ()
@@ -350,6 +362,7 @@
(> (len expr) 1)
(= (type-of (nth expr 1)) "keyword")))))))))
;; Merge spread child attrs into parent element attrs
(define
merge-spread-attrs
:effects (mutation)
@@ -385,6 +398,7 @@
(dict-set! target key val)))))
(keys spread-dict))))
;; Escape special chars for HTML text content
(define
escape-html
(fn
@@ -397,4 +411,5 @@
(set! r (replace r "\"" "&quot;"))
r)))
;; Escape special chars for HTML attribute values
(define escape-attr (fn (s) (escape-html s)))

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,414 @@
(define tw-spacing-props {:ml "margin-left:{v}" :mr "margin-right:{v}" :mt "margin-top:{v}" :mb "margin-bottom:{v}" :pl "padding-left:{v}" :gap-y "row-gap:{v}" :m "margin:{v}" :gap-x "column-gap:{v}" :my "margin-top:{v};margin-bottom:{v}" :px "padding-left:{v};padding-right:{v}" :pb "padding-bottom:{v}" :pr "padding-right:{v}" :p "padding:{v}" :gap "gap:{v}" :py "padding-top:{v};padding-bottom:{v}" :pt "padding-top:{v}" :mx "margin-left:{v};margin-right:{v}"})
(define tw-displays {:flex "flex" :table "table" :grid "grid" :inline-block "inline-block" :table-row "table-row" :inline "inline" :hidden "none" :block "block" :contents "contents" :inline-flex "inline-flex" :inline-grid "inline-grid" :table-cell "table-cell"})
(define tw-max-widths {:xs "20rem" :3xl "48rem" :7xl "80rem" :sm "24rem" :xl "36rem" :full "100%" :md "28rem" :6xl "72rem" :prose "65ch" :max "max-content" :5xl "64rem" :min "min-content" :lg "32rem" :2xl "42rem" :4xl "56rem" :none "none" :screen "100vw" :fit "fit-content"})
(define tw-min-widths {:full "100%" :0 "0px" :max "max-content" :min "min-content" :fit "fit-content"})
(define
tw-resolve-layout
(fn
(token)
(let
((parts (split token "-"))
(head (first parts))
(rest (slice parts 1)))
(cond
(and (= (len parts) 1) (not (nil? (get tw-displays head))))
(str "display:" (get tw-displays head))
(and (= (len parts) 2) (not (nil? (get tw-displays token))))
(str "display:" (get tw-displays token))
(and (get tw-spacing-props head) (= (len rest) 1))
(let
((tmpl (get tw-spacing-props head))
(v (tw-spacing-value (first rest))))
(if (nil? v) nil (tw-template tmpl v)))
(and
(= (len rest) 2)
(get tw-spacing-props (str head "-" (first rest))))
(let
((tmpl (get tw-spacing-props (str head "-" (first rest))))
(v (tw-spacing-value (last rest))))
(if (nil? v) nil (tw-template tmpl v)))
(and
(= head "space")
(= (len rest) 2)
(or (= (first rest) "x") (= (first rest) "y")))
(let
((v (tw-spacing-value (last rest))) (dir (first rest)))
(if (nil? v) nil (if (= dir "x") {:suffix ">*+*" :css (str "margin-left:" v)} {:suffix ">*+*" :css (str "margin-top:" v)})))
(and (= head "flex") (empty? rest))
"display:flex"
(and (= head "flex") (= (len rest) 1))
(case
(first rest)
"row"
"flex-direction:row"
"col"
"flex-direction:column"
"wrap"
"flex-wrap:wrap"
"nowrap"
"flex-wrap:nowrap"
"1"
"flex:1 1 0%"
"auto"
"flex:1 1 auto"
"initial"
"flex:0 1 auto"
"none"
"flex:none"
:else nil)
(and (= head "flex") (= (len rest) 2))
(case
(join "-" rest)
"row-reverse"
"flex-direction:row-reverse"
"col-reverse"
"flex-direction:column-reverse"
"wrap-reverse"
"flex-wrap:wrap-reverse"
:else nil)
(= head "grow")
(if
(empty? rest)
"flex-grow:1"
(if (= (first rest) "0") "flex-grow:0" nil))
(= head "shrink")
(if
(empty? rest)
"flex-shrink:1"
(if (= (first rest) "0") "flex-shrink:0" nil))
(and (= head "basis") (= (len rest) 1))
(let
((val (first rest)))
(cond
(= val "auto")
"flex-basis:auto"
(= val "full")
"flex-basis:100%"
(= val "0")
"flex-basis:0px"
(contains? val "/")
(let
((frac (split val "/")))
(if
(= (len frac) 2)
(let
((num (parse-int (first frac) nil))
(den (parse-int (nth frac 1) nil)))
(if
(or (nil? num) (nil? den))
nil
(str "flex-basis:" (* (/ num den) 100) "%")))
nil))
:else (let
((n (parse-int val nil)))
(if (nil? n) nil (str "flex-basis:" (* n 0.25) "rem")))))
(and (= head "justify") (= (len rest) 1))
(case
(first rest)
"start"
"justify-content:flex-start"
"end"
"justify-content:flex-end"
"center"
"justify-content:center"
"between"
"justify-content:space-between"
"around"
"justify-content:space-around"
"evenly"
"justify-content:space-evenly"
"stretch"
"justify-content:stretch"
:else nil)
(and (= head "items") (= (len rest) 1))
(case
(first rest)
"start"
"align-items:flex-start"
"end"
"align-items:flex-end"
"center"
"align-items:center"
"baseline"
"align-items:baseline"
"stretch"
"align-items:stretch"
:else nil)
(and (= head "self") (= (len rest) 1))
(case
(first rest)
"auto"
"align-self:auto"
"start"
"align-self:flex-start"
"end"
"align-self:flex-end"
"center"
"align-self:center"
"stretch"
"align-self:stretch"
"baseline"
"align-self:baseline"
:else nil)
(and (= head "content") (= (len rest) 1))
(case
(first rest)
"start"
"align-content:flex-start"
"end"
"align-content:flex-end"
"center"
"align-content:center"
"between"
"align-content:space-between"
"around"
"align-content:space-around"
"evenly"
"align-content:space-evenly"
"stretch"
"align-content:stretch"
:else nil)
(and (= head "order") (= (len rest) 1))
(let
((val (first rest)))
(cond
(= val "first")
"order:-9999"
(= val "last")
"order:9999"
(= val "none")
"order:0"
:else (let
((n (parse-int val nil)))
(if (nil? n) nil (str "order:" n)))))
(and (= head "grid") (empty? rest))
"display:grid"
(and (= head "grid") (>= (len rest) 2) (= (first rest) "cols"))
(let
((val (join "-" (slice rest 1))))
(cond
(= val "none")
"grid-template-columns:none"
(= val "subgrid")
"grid-template-columns:subgrid"
:else (let
((n (parse-int val nil)))
(if
(nil? n)
nil
(str "grid-template-columns:repeat(" n ",minmax(0,1fr))")))))
(and (= head "grid") (>= (len rest) 2) (= (first rest) "rows"))
(let
((val (join "-" (slice rest 1))))
(cond
(= val "none")
"grid-template-rows:none"
(= val "subgrid")
"grid-template-rows:subgrid"
:else (let
((n (parse-int val nil)))
(if
(nil? n)
nil
(str "grid-template-rows:repeat(" n ",minmax(0,1fr))")))))
(and (= head "grid") (>= (len rest) 2) (= (first rest) "flow"))
(case
(nth rest 1)
"row"
"grid-auto-flow:row"
"col"
"grid-auto-flow:column"
"dense"
"grid-auto-flow:dense"
:else nil)
(and (= head "col") (>= (len rest) 2))
(let
((sub (first rest)) (val (join "-" (slice rest 1))))
(cond
(and (= sub "span") (= val "full"))
"grid-column:1 / -1"
(= sub "span")
(let
((n (parse-int val nil)))
(if (nil? n) nil (str "grid-column:span " n " / span " n)))
(= sub "start")
(str "grid-column-start:" val)
(= sub "end")
(str "grid-column-end:" val)
:else nil))
(and (= head "row") (>= (len rest) 2))
(let
((sub (first rest)) (val (join "-" (slice rest 1))))
(cond
(and (= sub "span") (= val "full"))
"grid-row:1 / -1"
(= sub "span")
(let
((n (parse-int val nil)))
(if (nil? n) nil (str "grid-row:span " n " / span " n)))
(= sub "start")
(str "grid-row-start:" val)
(= sub "end")
(str "grid-row-end:" val)
:else nil))
(and (= head "auto") (>= (len rest) 2))
(let
((sub (first rest)) (val (join "-" (slice rest 1))))
(cond
(and (= sub "cols") (= val "auto"))
"grid-auto-columns:auto"
(and (= sub "cols") (= val "min"))
"grid-auto-columns:min-content"
(and (= sub "cols") (= val "max"))
"grid-auto-columns:max-content"
(and (= sub "cols") (= val "fr"))
"grid-auto-columns:minmax(0,1fr)"
(and (= sub "rows") (= val "auto"))
"grid-auto-rows:auto"
(and (= sub "rows") (= val "min"))
"grid-auto-rows:min-content"
(and (= sub "rows") (= val "max"))
"grid-auto-rows:max-content"
(and (= sub "rows") (= val "fr"))
"grid-auto-rows:minmax(0,1fr)"
:else nil))
(and
(= (len parts) 1)
(or
(= head "relative")
(= head "absolute")
(= head "fixed")
(= head "sticky")
(= head "static")))
(str "position:" head)
(and
(or
(= head "top")
(= head "right")
(= head "bottom")
(= head "left"))
(= (len rest) 1))
(let
((v (tw-spacing-value (first rest))))
(if (nil? v) nil (str head ":" v)))
(and (= head "inset") (= (len rest) 1))
(let
((v (tw-spacing-value (first rest))))
(if (nil? v) nil (str "inset:" v)))
(and (= head "inset") (= (len rest) 2))
(let
((dir (first rest)) (v (tw-spacing-value (nth rest 1))))
(if
(nil? v)
nil
(case
dir
"x"
(str "left:" v ";right:" v)
"y"
(str "top:" v ";bottom:" v)
:else nil)))
(and (= head "z") (= (len rest) 1))
(if
(= (first rest) "auto")
"z-index:auto"
(let
((n (parse-int (first rest) nil)))
(if (nil? n) nil (str "z-index:" n))))
(and (or (= head "w") (= head "h")) (= (len rest) 1))
(let
((prop (if (= head "w") "width" "height")) (val (first rest)))
(cond
(= val "full")
(str prop ":100%")
(= val "screen")
(str prop (if (= head "w") ":100vw" ":100vh"))
(= val "auto")
(str prop ":auto")
(= val "min")
(str prop ":min-content")
(= val "max")
(str prop ":max-content")
(= val "fit")
(str prop ":fit-content")
(contains? val "/")
(let
((frac (split val "/")))
(if
(= (len frac) 2)
(let
((num (parse-int (first frac) nil))
(den (parse-int (nth frac 1) nil)))
(if
(or (nil? num) (nil? den))
nil
(str prop ":" (* (/ num den) 100) "%")))
nil))
:else (let
((n (parse-int val nil)))
(if (nil? n) nil (str prop ":" (* n 0.25) "rem")))))
(and (= head "max") (>= (len rest) 2) (= (first rest) "w"))
(let
((val-name (join "-" (slice rest 1)))
(val (get tw-max-widths val-name)))
(if (nil? val) nil (str "max-width:" val)))
(and (= head "max") (>= (len rest) 2) (= (first rest) "h"))
(let
((val (first (slice rest 1))))
(cond
(= val "full")
"max-height:100%"
(= val "screen")
"max-height:100vh"
(= val "none")
"max-height:none"
:else (let
((n (parse-int val nil)))
(if (nil? n) nil (str "max-height:" (* n 0.25) "rem")))))
(and (= head "min") (>= (len rest) 2) (= (first rest) "w"))
(let
((val-name (join "-" (slice rest 1)))
(val (get tw-min-widths val-name)))
(if (nil? val) nil (str "min-width:" val)))
(and (= head "min") (>= (len rest) 2) (= (first rest) "h"))
(let
((val (first (slice rest 1))))
(cond
(= val "0")
"min-height:0px"
(= val "full")
"min-height:100%"
(= val "screen")
"min-height:100vh"
:else nil))
(and (= head "overflow") (= (len rest) 1))
(str "overflow:" (first rest))
(and (= head "overflow") (= (len rest) 2))
(str "overflow-" (first rest) ":" (nth rest 1))
(and (= head "aspect") (= (len rest) 1))
(case
(first rest)
"auto"
"aspect-ratio:auto"
"square"
"aspect-ratio:1 / 1"
"video"
"aspect-ratio:16 / 9"
:else nil)
(and (= head "object") (= (len rest) 1))
(str "object-fit:" (first rest))
(and (= (len parts) 1) (= head "visible"))
"visibility:visible"
(and (= (len parts) 1) (= head "invisible"))
"visibility:hidden"
(and (= (len parts) 1) (= head "collapse"))
"visibility:collapse"
(and (= (len parts) 1) (= head "container"))
"width:100%;max-width:100%"
(and (= (len parts) 1) (= head "isolate"))
"isolation:isolate"
:else nil))))

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,213 @@
(define tw-sizes {:xs "font-size:0.75rem;line-height:1rem" :3xl "font-size:1.875rem;line-height:2.25rem" :7xl "font-size:4.5rem;line-height:1" :sm "font-size:0.875rem;line-height:1.25rem" :8xl "font-size:6rem;line-height:1" :xl "font-size:1.25rem;line-height:1.75rem" :6xl "font-size:3.75rem;line-height:1" :9xl "font-size:8rem;line-height:1" :5xl "font-size:3rem;line-height:1" :lg "font-size:1.125rem;line-height:1.75rem" :2xl "font-size:1.5rem;line-height:2rem" :base "font-size:1rem;line-height:1.5rem" :4xl "font-size:2.25rem;line-height:2.5rem"})
(define tw-weights {:light "300" :semibold "600" :bold "700" :extrabold "800" :black "900" :extralight "200" :thin "100" :medium "500" :normal "400"})
(define tw-families {:mono "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace" :sans "ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,\"Noto Sans\",sans-serif" :serif "ui-serif,Georgia,Cambria,\"Times New Roman\",Times,serif"})
(define tw-alignments {:center true :end true :left true :right true :start true :justify true})
(define tw-leading {:tight "1.25" :9 "2.25rem" :loose "2" :relaxed "1.625" :3 "0.75rem" :8 "2rem" :5 "1.25rem" :4 "1rem" :6 "1.5rem" :snug "1.375" :none "1" :normal "1.5" :7 "1.75rem" :10 "2.5rem"})
(define tw-tracking {:wide "0.025em" :tight "-0.025em" :tighter "-0.05em" :wider "0.05em" :widest "0.1em" :normal "0em"})
(define
tw-resolve-type
(fn
(token)
(let
((parts (split token "-"))
(head (first parts))
(rest (slice parts 1)))
(cond
(and
(= head "text")
(= (len rest) 1)
(not (nil? (get tw-sizes (first rest)))))
(get tw-sizes (first rest))
(and
(= head "text")
(= (len rest) 1)
(get tw-alignments (first rest)))
(str "text-align:" (first rest))
(and
(= (len parts) 1)
(or
(= head "uppercase")
(= head "lowercase")
(= head "capitalize")))
(str "text-transform:" head)
(and (= (len parts) 2) (= head "normal") (= (first rest) "case"))
"text-transform:none"
(and
(= head "font")
(= (len rest) 1)
(not (nil? (get tw-weights (first rest)))))
(str "font-weight:" (get tw-weights (first rest)))
(and
(= head "font")
(= (len rest) 1)
(not (nil? (get tw-families (first rest)))))
(str "font-family:" (get tw-families (first rest)))
(and (= (len parts) 1) (= head "italic"))
"font-style:italic"
(and (= (len parts) 2) (= head "not") (= (first rest) "italic"))
"font-style:normal"
(and (= head "leading") (= (len rest) 1))
(let
((val (get tw-leading (first rest))))
(if (nil? val) nil (str "line-height:" val)))
(and (= head "tracking") (= (len rest) 1))
(let
((val (get tw-tracking (first rest))))
(if (nil? val) nil (str "letter-spacing:" val)))
(and (= head "whitespace") (= (len rest) 1))
(case
(first rest)
"normal"
"white-space:normal"
"nowrap"
"white-space:nowrap"
"pre"
"white-space:pre"
"pre-line"
"white-space:pre-line"
"pre-wrap"
"white-space:pre-wrap"
"break-spaces"
"white-space:break-spaces"
:else nil)
(and (= head "whitespace") (= (len rest) 2))
(let
((val (join "-" rest)))
(case
val
"pre-line"
"white-space:pre-line"
"pre-wrap"
"white-space:pre-wrap"
"break-spaces"
"white-space:break-spaces"
:else nil))
(and (= head "break") (= (len rest) 1))
(case
(first rest)
"normal"
"overflow-wrap:normal;word-break:normal"
"words"
"overflow-wrap:break-word"
"all"
"word-break:break-all"
"keep"
"word-break:keep-all"
:else nil)
(and (= (len parts) 1) (= head "truncate"))
"overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
(and (= head "line") (= (len rest) 2) (= (first rest) "clamp"))
(let
((val (nth rest 1)))
(if
(= val "none")
"overflow:visible;display:block;-webkit-line-clamp:unset"
(let
((n (parse-int val nil)))
(if
(nil? n)
nil
(str
"overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:"
n)))))
(and (= head "indent") (= (len rest) 1))
(let
((v (tw-spacing-value (first rest))))
(if (nil? v) nil (str "text-indent:" v)))
(and (= head "align") (= (len rest) 1))
(case
(first rest)
"baseline"
"vertical-align:baseline"
"top"
"vertical-align:top"
"middle"
"vertical-align:middle"
"bottom"
"vertical-align:bottom"
"text-top"
"vertical-align:text-top"
"text-bottom"
"vertical-align:text-bottom"
"sub"
"vertical-align:sub"
"super"
"vertical-align:super"
:else nil)
(and (= head "align") (= (len rest) 2))
(let
((val (join "-" rest)))
(case
val
"text-top"
"vertical-align:text-top"
"text-bottom"
"vertical-align:text-bottom"
:else nil))
(and (= head "list") (= (len rest) 1))
(case
(first rest)
"none"
"list-style-type:none"
"disc"
"list-style-type:disc"
"decimal"
"list-style-type:decimal"
"inside"
"list-style-position:inside"
"outside"
"list-style-position:outside"
:else nil)
(and
(= head "text")
(= (len rest) 1)
(or
(= (first rest) "wrap")
(= (first rest) "nowrap")
(= (first rest) "balance")
(= (first rest) "pretty")))
(str "text-wrap:" (first rest))
(and (= head "hyphens") (= (len rest) 1))
(str "hyphens:" (first rest))
(and (= head "content") (= (len rest) 1) (= (first rest) "none"))
"content:none"
(and (= (len parts) 1) (= head "antialiased"))
"-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale"
(and
(= (len parts) 2)
(= head "subpixel")
(= (first rest) "antialiased"))
"-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto"
(and (= (len parts) 2) (= (first rest) "nums"))
(case
head
"tabular"
"font-variant-numeric:tabular-nums"
"proportional"
"font-variant-numeric:proportional-nums"
"lining"
"font-variant-numeric:lining-nums"
"oldstyle"
"font-variant-numeric:oldstyle-nums"
:else nil)
(and (= (len parts) 2) (= (first rest) "fractions"))
(case
head
"diagonal"
"font-variant-numeric:diagonal-fractions"
"stacked"
"font-variant-numeric:stacked-fractions"
:else nil)
(and (= (len parts) 2) (= head "normal") (= (first rest) "nums"))
"font-variant-numeric:normal"
(and (= (len parts) 1) (= head "ordinal"))
"font-variant-numeric:ordinal"
(and (= (len parts) 2) (= head "slashed") (= (first rest) "zero"))
"font-variant-numeric:slashed-zero"
:else nil))))

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1792,7 +1792,7 @@
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
}
(globalThis))
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-319ce79b",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-8ae21d0a",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-bd388764",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-d5ae75e7",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new