diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 2654502..2a17738 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -14,6 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); + var SX_VERSION = "2026-03-08T10:13:40Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -34,14 +35,38 @@ } Lambda.prototype._lambda = true; - function Component(name, params, hasChildren, body, closure) { + function Component(name, params, hasChildren, body, closure, affinity) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + this.affinity = affinity || "auto"; + } + Component.prototype._component = true; + + function Island(name, params, hasChildren, body, closure) { this.name = name; this.params = params; this.hasChildren = hasChildren; this.body = body; this.closure = closure || {}; } - Component.prototype._component = true; + Island.prototype._island = true; + + function SxSignal(value) { + this.value = value; + this.subscribers = []; + this.deps = []; + } + SxSignal.prototype._signal = true; + + function TrackingCtx(notifyFn) { + this.notifyFn = notifyFn; + this.deps = []; + } + + var _trackingContext = null; function Macro(params, restParam, body, closure, name) { this.params = params; @@ -91,6 +116,8 @@ if (x._thunk) return "thunk"; if (x._lambda) return "lambda"; if (x._component) return "component"; + if (x._island) return "island"; + if (x._signal) return "signal"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; if (typeof Node !== "undefined" && x instanceof Node) return "dom-node"; @@ -105,8 +132,8 @@ function makeKeyword(n) { return new Keyword(n); } function makeLambda(params, body, env) { return new Lambda(params, body, merge(env)); } - function makeComponent(name, params, hasChildren, body, env) { - return new Component(name, params, hasChildren, body, merge(env)); + function makeComponent(name, params, hasChildren, body, env, affinity) { + return new Component(name, params, hasChildren, body, merge(env), affinity); } function makeMacro(params, restParam, body, env, name) { return new Macro(params, restParam, body, merge(env), name); @@ -124,6 +151,7 @@ function componentClosure(c) { return c.closure; } function componentHasChildren(c) { return c.hasChildren; } function componentName(c) { return c.name; } + function componentAffinity(c) { return c.affinity || "auto"; } function macroParams(m) { return m.params; } function macroRestParam(m) { return m.restParam; } @@ -137,15 +165,53 @@ function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); } function isLambda(x) { return x != null && x._lambda === true; } function isComponent(x) { return x != null && x._component === true; } + function isIsland(x) { return x != null && x._island === true; } function isMacro(x) { return x != null && x._macro === true; } + function isIdentical(a, b) { return a === b; } + + // Island platform + function makeIsland(name, params, hasChildren, body, env) { + return new Island(name, params, hasChildren, body, merge(env)); + } + + // Signal platform + function makeSignal(value) { return new SxSignal(value); } + function isSignal(x) { return x != null && x._signal === true; } + function signalValue(s) { return s.value; } + function signalSetValue(s, v) { s.value = v; } + function signalSubscribers(s) { return s.subscribers.slice(); } + function signalAddSub(s, fn) { if (s.subscribers.indexOf(fn) < 0) s.subscribers.push(fn); } + function signalRemoveSub(s, fn) { var i = s.subscribers.indexOf(fn); if (i >= 0) s.subscribers.splice(i, 1); } + function signalDeps(s) { return s.deps.slice(); } + function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? deps.slice() : []; } + function setTrackingContext(ctx) { _trackingContext = ctx; } + function getTrackingContext() { return _trackingContext || NIL; } + function makeTrackingContext(notifyFn) { return new TrackingCtx(notifyFn); } + function trackingContextDeps(ctx) { return ctx ? ctx.deps : []; } + function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); } + function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; } + + // JSON / dict helpers for island state serialization + function jsonSerialize(obj) { + try { return JSON.stringify(obj); } catch(e) { return "{}"; } + } + function isEmptyDict(d) { + if (!d || typeof d !== "object") return true; + for (var k in d) if (d.hasOwnProperty(k)) return false; + return true; + } function envHas(env, name) { return name in env; } function envGet(env, name) { return env[name]; } function envSet(env, name, val) { env[name] = val; } - function envExtend(env) { return merge(env); } - function envMerge(base, overlay) { return merge(base, overlay); } + function envExtend(env) { return Object.create(env); } + function envMerge(base, overlay) { + var child = Object.create(base); + if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k]; + return child; + } - function dictSet(d, k, v) { d[k] = v; } + function dictSet(d, k, v) { d[k] = v; return v; } function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; } // Render-expression detection — lets the evaluator delegate to the active adapter. @@ -283,6 +349,7 @@ PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["append!"] = function(arr, x) { arr.push(x); return arr; }; PRIMITIVES["chunk-every"] = function(c, n) { var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; }; @@ -309,6 +376,7 @@ for (var i = 1; i < arguments.length; i++) delete out[arguments[i]]; return out; }; + PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; }; PRIMITIVES["into"] = function(target, coll) { 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]; } @@ -439,27 +507,7 @@ "map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1 }; } - // processBindings and evalCond — exposed for DOM adapter render forms - function processBindings(bindings, env) { - var local = merge(env); - for (var i = 0; i < bindings.length; i++) { - var pair = bindings[i]; - if (Array.isArray(pair) && pair.length >= 2) { - var name = isSym(pair[0]) ? pair[0].name : String(pair[0]); - local[name] = trampoline(evalExpr(pair[1], local)); - } - } - return local; - } - function evalCond(clauses, env) { - for (var i = 0; i < clauses.length; i += 2) { - var test = clauses[i]; - if (isSym(test) && test.name === ":else") return clauses[i + 1]; - if (isKw(test) && test.name === "else") return clauses[i + 1]; - if (isSxTruthy(trampoline(evalExpr(test, env)))) return clauses[i + 1]; - } - return null; - } + // processBindings and evalCond — now specced in render.sx, bootstrapped above function isDefinitionForm(name) { return name === "define" || name === "defcomp" || name === "defmacro" || @@ -479,90 +527,50 @@ } // ========================================================================= - // Platform: deps module — component dependency analysis + // Performance overrides — evaluator hot path // ========================================================================= - function componentDeps(c) { - return c.deps ? c.deps.slice() : []; - } - - function componentSetDeps(c, deps) { - c.deps = deps; - } - - function componentCssClasses(c) { - return c.cssClasses ? c.cssClasses.slice() : []; - } - - function envComponents(env) { - var names = []; - for (var k in env) { - var v = env[k]; - if (v && (v._component || v._macro)) names.push(k); - } - return names; - } - - function regexFindAll(pattern, source) { - var re = new RegExp(pattern, "g"); - var results = []; - var m; - while ((m = re.exec(source)) !== null) { - if (m[1] !== undefined) results.push(m[1]); - else results.push(m[0]); - } - return results; - } - - function scanCssClasses(source) { - var classes = {}; - var result = []; - var m; - var re1 = /:class\s+"([^"]*)"/g; - while ((m = re1.exec(source)) !== null) { - var parts = m[1].split(/\s+/); - for (var i = 0; i < parts.length; i++) { - if (parts[i] && !classes[parts[i]]) { - classes[parts[i]] = true; - result.push(parts[i]); - } + // Override parseKeywordArgs: imperative loop instead of reduce+assoc + parseKeywordArgs = function(rawArgs, env) { + var kwargs = {}; + var children = []; + for (var i = 0; i < rawArgs.length; i++) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env)); + i++; + } else { + children.push(trampoline(evalExpr(arg, env))); } } - var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g; - while ((m = re2.exec(source)) !== null) { - var re3 = /"([^"]*)"/g; - var m2; - while ((m2 = re3.exec(m[1])) !== null) { - var parts2 = m2[1].split(/\s+/); - for (var j = 0; j < parts2.length; j++) { - if (parts2[j] && !classes[parts2[j]]) { - classes[parts2[j]] = true; - result.push(parts2[j]); - } - } + return [kwargs, children]; + }; + + // Override callComponent: use prototype chain env, imperative kwarg binding + callComponent = function(comp, rawArgs, env) { + var kwargs = {}; + var children = []; + for (var i = 0; i < rawArgs.length; i++) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env)); + i++; + } else { + children.push(trampoline(evalExpr(arg, env))); } } - var re4 = /;;\s*@css\s+(.+)/g; - while ((m = re4.exec(source)) !== null) { - var parts3 = m[1].split(/\s+/); - for (var k = 0; k < parts3.length; k++) { - if (parts3[k] && !classes[parts3[k]]) { - classes[parts3[k]] = true; - result.push(parts3[k]); - } - } + var local = Object.create(componentClosure(comp)); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + var p = params[j]; + local[p] = p in kwargs ? kwargs[p] : NIL; } - return result; - } - - function componentIoRefs(c) { - return c.ioRefs ? c.ioRefs.slice() : []; - } - - function componentSetIoRefs(c, refs) { - c.ioRefs = refs; - } - + if (componentHasChildren(comp)) { + local["children"] = children; + } + return makeThunk(componentBody(comp), local); + }; // ========================================================================= // Platform interface — Parser @@ -601,12 +609,12 @@ var evalList = function(expr, env) { return (function() { var head = first(expr); var args = rest(expr); - return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { + return (isSxTruthy(!isSxTruthy(sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list")))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { var name = symbolName(head); - return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { + return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defisland")) ? sfDefisland(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { var mac = envGet(env, name); return makeThunk(expandMacro(mac, args, env), env); -})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))))))); +})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env))))))))))))))))))))))))))))))))))))))); })() : evalCall(head, args, env))); })(); }; @@ -614,7 +622,7 @@ var evalCall = function(head, args, env) { return (function() { var f = trampoline(evalExpr(head, env)); var evaluatedArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); - return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaluatedArgs) : (isSxTruthy(isLambda(f)) ? callLambda(f, evaluatedArgs, env) : (isSxTruthy(isComponent(f)) ? callComponent(f, args, env) : error((String("Not callable: ") + String(inspect(f))))))); + return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && isSxTruthy(!isSxTruthy(isComponent(f))) && !isSxTruthy(isIsland(f)))) ? apply(f, evaluatedArgs) : (isSxTruthy(isLambda(f)) ? callLambda(f, evaluatedArgs, env) : (isSxTruthy(isComponent(f)) ? callComponent(f, args, env) : (isSxTruthy(isIsland(f)) ? callComponent(f, args, env) : error((String("Not callable: ") + String(inspect(f)))))))); })(); }; // call-lambda @@ -653,13 +661,13 @@ // sf-if var sfIf = function(args, env) { return (function() { var condition = trampoline(evalExpr(first(args), env)); - return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? makeThunk(nth(args, 1), env) : (isSxTruthy((len(args) > 2)) ? makeThunk(nth(args, 2), env) : NIL)); + return (isSxTruthy((isSxTruthy(condition) && !isSxTruthy(isNil(condition)))) ? makeThunk(nth(args, 1), env) : (isSxTruthy((len(args) > 2)) ? makeThunk(nth(args, 2), env) : NIL)); })(); }; // sf-when var sfWhen = function(args, env) { return (function() { var condition = trampoline(evalExpr(first(args), env)); - return (isSxTruthy((isSxTruthy(condition) && !isNil(condition))) ? (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 1, (len(args) - 1))), makeThunk(last(args), env)) : NIL); + return (isSxTruthy((isSxTruthy(condition) && !isSxTruthy(isNil(condition)))) ? (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 1, (len(args) - 1))), makeThunk(last(args), env)) : NIL); })(); }; // sf-cond @@ -697,7 +705,7 @@ // sf-and var sfAnd = function(args, env) { return (isSxTruthy(isEmpty(args)) ? true : (function() { var val = trampoline(evalExpr(first(args), env)); - return (isSxTruthy(!val) ? val : (isSxTruthy((len(args) == 1)) ? val : sfAnd(rest(args), env))); + return (isSxTruthy(!isSxTruthy(val)) ? val : (isSxTruthy((len(args) == 1)) ? val : sfAnd(rest(args), env))); })()); }; // sf-or @@ -770,18 +778,32 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var sfDefcomp = function(args, env) { return (function() { var nameSym = first(args); var paramsRaw = nth(args, 1); - var body = nth(args, 2); + var body = last(args); var compName = stripPrefix(symbolName(nameSym), "~"); var parsed = parseCompParams(paramsRaw); var params = first(parsed); var hasChildren = nth(parsed, 1); + var affinity = defcompKwarg(args, "affinity", "auto"); return (function() { - var comp = makeComponent(compName, params, hasChildren, body, env); + var comp = makeComponent(compName, params, hasChildren, body, env, affinity); env[symbolName(nameSym)] = comp; return comp; })(); })(); }; + // defcomp-kwarg + var defcompKwarg = function(args, key, default_) { return (function() { + var end = (len(args) - 1); + var result = default_; + { var _c = range(2, end, 1); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(nth(args, i)) == "keyword")) && isSxTruthy((keywordName(nth(args, i)) == key)) && ((i + 1) < end)))) { + (function() { + var val = nth(args, (i + 1)); + return (result = (isSxTruthy((typeOf(val) == "keyword")) ? keywordName(val) : val)); +})(); +} } } + return result; +})(); }; + // parse-comp-params var parseCompParams = function(paramsExpr) { return (function() { var params = []; @@ -796,6 +818,22 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai return [params, hasChildren]; })(); }; + // sf-defisland + var sfDefisland = function(args, env) { return (function() { + var nameSym = first(args); + var paramsRaw = nth(args, 1); + var body = last(args); + var compName = stripPrefix(symbolName(nameSym), "~"); + var parsed = parseCompParams(paramsRaw); + var params = first(parsed); + var hasChildren = nth(parsed, 1); + return (function() { + var island = makeIsland(compName, params, hasChildren, body, env); + env[symbolName(nameSym)] = island; + return island; +})(); +})(); }; + // sf-defmacro var sfDefmacro = function(args, env) { return (function() { var nameSym = first(args); @@ -837,7 +875,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var sfQuasiquote = function(args, env) { return qqExpand(first(args), env); }; // qq-expand - var qqExpand = function(template, env) { return (isSxTruthy(!(typeOf(template) == "list")) ? template : (isSxTruthy(isEmpty(template)) ? [] : (function() { + var qqExpand = function(template, env) { return (isSxTruthy(!isSxTruthy((typeOf(template) == "list"))) ? template : (isSxTruthy(isEmpty(template)) ? [] : (function() { var head = first(template); return (isSxTruthy((isSxTruthy((typeOf(head) == "symbol")) && (symbolName(head) == "unquote"))) ? trampoline(evalExpr(nth(template, 1), env)) : reduce(function(result, item) { return (isSxTruthy((isSxTruthy((typeOf(item) == "list")) && isSxTruthy((len(item) == 2)) && isSxTruthy((typeOf(first(item)) == "symbol")) && (symbolName(first(item)) == "splice-unquote"))) ? (function() { var spliced = trampoline(evalExpr(nth(item, 1), env)); @@ -852,10 +890,10 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var f = trampoline(evalExpr(first(form), env)); var restArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, rest(form)); var allArgs = cons(result, restArgs); - return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? apply(f, allArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, allArgs, env)) : error((String("-> form not callable: ") + String(inspect(f)))))); + return (isSxTruthy((isSxTruthy(isCallable(f)) && !isSxTruthy(isLambda(f)))) ? apply(f, allArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, allArgs, env)) : error((String("-> form not callable: ") + String(inspect(f)))))); })() : (function() { var f = trampoline(evalExpr(form, env)); - return (isSxTruthy((isSxTruthy(isCallable(f)) && !isLambda(f))) ? f(result) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [result], env)) : error((String("-> form not callable: ") + String(inspect(f)))))); + return (isSxTruthy((isSxTruthy(isCallable(f)) && !isSxTruthy(isLambda(f)))) ? f(result) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [result], env)) : error((String("-> form not callable: ") + String(inspect(f)))))); })()); }, val, rest(args)); })(); }; @@ -986,7 +1024,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var BOOLEAN_ATTRS = ["async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected"]; // definition-form? - var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defhandler")); }; + var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defisland"), (name == "defmacro"), (name == "defstyle"), (name == "defhandler")); }; // parse-element-args var parseElementArgs = function(args, env) { return (function() { @@ -1006,9 +1044,39 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai // render-attrs var renderAttrs = function(attrs) { return join("", map(function(key) { return (function() { var val = dictGet(attrs, key); - return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))); + return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !isSxTruthy(val))) ? "" : (isSxTruthy(isNil(val)) ? "" : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))); })(); }, keys(attrs))); }; + // eval-cond + var evalCond = function(clauses, env) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isEmpty(clauses))) && isSxTruthy((typeOf(first(clauses)) == "list")) && (len(first(clauses)) == 2))) ? evalCondScheme(clauses, env) : evalCondClojure(clauses, env)); }; + + // eval-cond-scheme + var evalCondScheme = function(clauses, env) { return (isSxTruthy(isEmpty(clauses)) ? NIL : (function() { + var clause = first(clauses); + var test = first(clause); + var body = nth(clause, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))), (isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")))) ? body : (isSxTruthy(trampoline(evalExpr(test, env))) ? body : evalCondScheme(rest(clauses), env))); +})()); }; + + // eval-cond-clojure + var evalCondClojure = function(clauses, env) { return (isSxTruthy((len(clauses) < 2)) ? NIL : (function() { + var test = first(clauses); + var body = nth(clauses, 1); + return (isSxTruthy(sxOr((isSxTruthy((typeOf(test) == "keyword")) && (keywordName(test) == "else")), (isSxTruthy((typeOf(test) == "symbol")) && sxOr((symbolName(test) == "else"), (symbolName(test) == ":else"))))) ? body : (isSxTruthy(trampoline(evalExpr(test, env))) ? body : evalCondClojure(slice(clauses, 2), env))); +})()); }; + + // process-bindings + var processBindings = function(bindings, env) { return (function() { + var local = merge(env); + { var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(pair) == "list")) && (len(pair) >= 2)))) { + (function() { + var name = (isSxTruthy((typeOf(first(pair)) == "symbol")) ? symbolName(first(pair)) : (String(first(pair)))); + return envSet(local, name, trampoline(evalExpr(nth(pair, 1), local))); +})(); +} } } + return local; +})(); }; + // === Transpiled from parser === @@ -1016,10 +1084,10 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var sxParse = function(source) { return (function() { var pos = 0; var lenSrc = len(source); - var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !(nth(source, pos) == "\n")))) { pos = (pos + 1); + var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !isSxTruthy((nth(source, pos) == "\n"))))) { pos = (pos + 1); continue; } else { return NIL; } } }; var skipWs = function() { while(true) { if (isSxTruthy((pos < lenSrc))) { { var ch = nth(source, pos); -if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\\r")))) { pos = (pos + 1); +if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\r")))) { pos = (pos + 1); continue; } else if (isSxTruthy((ch == ";"))) { pos = (pos + 1); skipComment(); continue; } else { return NIL; } } } else { return NIL; } } }; @@ -1030,7 +1098,7 @@ return (function() { if (isSxTruthy((ch == "\""))) { pos = (pos + 1); return NIL; } else if (isSxTruthy((ch == "\\"))) { pos = (pos + 1); { var esc = nth(source, pos); -buf = (String(buf) + String((isSxTruthy((esc == "n")) ? "\n" : (isSxTruthy((esc == "t")) ? "\t" : (isSxTruthy((esc == "r")) ? "\\r" : esc))))); +buf = (String(buf) + String((isSxTruthy((esc == "n")) ? "\n" : (isSxTruthy((esc == "t")) ? "\t" : (isSxTruthy((esc == "r")) ? "\r" : esc))))); pos = (pos + 1); continue; } } else { buf = (String(buf) + String(ch)); pos = (pos + 1); @@ -1130,7 +1198,7 @@ continue; } else { return NIL; } } }; var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); return escapeHtml((String(val))); })(); }; // RENDER_HTML_FORMS - var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each"]; + var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each"]; // render-html-form? var isRenderHtmlForm = function(name) { return contains(RENDER_HTML_FORMS, name); }; @@ -1138,13 +1206,13 @@ continue; } else { return NIL; } } }; // render-list-to-html var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { var head = first(expr); - return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { + return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { var name = symbolName(head); var args = rest(expr); - return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(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) : error((String("Unknown component: ") + String(name))))); -})() : (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)))))))); })()); })()); }; @@ -1152,7 +1220,7 @@ continue; } else { return NIL; } } }; var dispatchHtmlForm = function(name, expr, env) { return (isSxTruthy((name == "if")) ? (function() { var condVal = trampoline(evalExpr(nth(expr, 1), env)); return (isSxTruthy(condVal) ? renderToHtml(nth(expr, 2), env) : (isSxTruthy((len(expr) > 3)) ? renderToHtml(nth(expr, 3), env) : "")); -})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() { +})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() { var branch = evalCond(rest(expr), env); return (isSxTruthy(branch) ? renderToHtml(branch, env) : ""); })() : (isSxTruthy((name == "case")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() { @@ -1210,6 +1278,36 @@ continue; } else { return NIL; } } }; return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String(""))))); })(); }; + // render-html-island + var renderHtmlIsland = function(island, args, env) { return (function() { + var kwargs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + kwargs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (function() { + var local = envMerge(componentClosure(island), env); + var islandName = componentName(island); + { var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } + if (isSxTruthy(componentHasChildren(island))) { + local["children"] = makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children))); +} + return (function() { + var bodyHtml = renderToHtml(componentBody(island), local); + var stateJson = serializeIslandState(kwargs); + return (String("
") + String(bodyHtml) + String("
")); +})(); +})(); +})(); }; + + // serialize-island-state + var serializeIslandState = function(kwargs) { return (isSxTruthy(isEmptyDict(kwargs)) ? NIL : jsonSerialize(kwargs)); }; + // === Transpiled from adapter-sx === @@ -1229,19 +1327,19 @@ continue; } else { return NIL; } } }; var aserList = function(expr, env) { return (function() { var head = first(expr); var args = rest(expr); - return (isSxTruthy(!(typeOf(head) == "symbol")) ? map(function(x) { return aser(x, env); }, expr) : (function() { + return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() { var name = symbolName(head); return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? 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() { var f = trampoline(evalExpr(head, env)); var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); - return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f))))))); + 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)))))))); })()))))); })()); })(); }; // aser-fragment var aserFragment = function(children, env) { return (function() { - var parts = filter(function(x) { return !isNil(x); }, map(function(c) { return aser(c, env); }, children)); + var parts = filter(function(x) { return !isSxTruthy(isNil(x)); }, map(function(c) { return aser(c, env); }, children)); return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", map(serialize, parts))) + String(")"))); })(); }; @@ -1252,14 +1350,14 @@ continue; } else { return NIL; } } }; var skip = get(state, "skip"); return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { var val = aser(nth(args, (get(state, "i") + 1)), env); - if (isSxTruthy(!isNil(val))) { + if (isSxTruthy(!isSxTruthy(isNil(val)))) { parts.push((String(":") + String(keywordName(arg)))); parts.push(serialize(val)); } return assoc(state, "skip", true, "i", (get(state, "i") + 1)); })() : (function() { var val = aser(arg, env); - if (isSxTruthy(!isNil(val))) { + if (isSxTruthy(!isSxTruthy(isNil(val)))) { parts.push(serialize(val)); } return assoc(state, "i", (get(state, "i") + 1)); @@ -1286,10 +1384,10 @@ continue; } else { return NIL; } } }; return (isSxTruthy((typeOf(head) == "symbol")) ? (function() { var name = symbolName(head); var args = rest(expr); - return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { + return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderDomIsland(envGet(env, name), args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { var comp = envGet(env, name); return (isSxTruthy(isComponent(comp)) ? renderDomComponent(comp, args, env, ns) : renderDomUnknownComponent(name)); -})() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))); +})() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))); })() : (isSxTruthy(sxOr(isLambda(head), (typeOf(head) == "list"))) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (function() { var frag = createFragment(); { var _c = expr; for (var _i = 0; _i < _c.length; _i++) { var x = _c[_i]; domAppend(frag, renderToDom(x, env, ns)); } } @@ -1308,7 +1406,7 @@ continue; } else { return NIL; } } }; var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))); return assoc(state, "skip", true, "i", (get(state, "i") + 1)); -})() : ((isSxTruthy(!contains(VOID_ELEMENTS, tag)) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1))))); +})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); return el; })(); }; @@ -1351,21 +1449,16 @@ continue; } else { return NIL; } } }; var frag = createFragment(); { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (function() { var val = trampoline(evalExpr(arg, env)); - return (isSxTruthy((typeOf(val) == "string")) ? domAppend(frag, domParseHtml(val)) : (isSxTruthy((typeOf(val) == "dom-node")) ? domAppend(frag, domClone(val)) : (isSxTruthy(!isNil(val)) ? domAppend(frag, createTextNode((String(val)))) : NIL))); + return (isSxTruthy((typeOf(val) == "string")) ? domAppend(frag, domParseHtml(val)) : (isSxTruthy((typeOf(val) == "dom-node")) ? domAppend(frag, domClone(val)) : (isSxTruthy(!isSxTruthy(isNil(val))) ? domAppend(frag, createTextNode((String(val)))) : NIL))); })(); } } return frag; })(); }; // render-dom-unknown-component - var renderDomUnknownComponent = function(name) { return (function() { - var el = domCreateElement("div", NIL); - domSetAttr(el, "style", "background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace"); - domAppend(el, createTextNode((String("Unknown component: ") + String(name)))); - return el; -})(); }; + var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); }; // RENDER_DOM_FORMS - var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each"]; + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each"]; // render-dom-form? var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; @@ -1374,7 +1467,7 @@ continue; } else { return NIL; } } }; var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (function() { var condVal = trampoline(evalExpr(nth(expr, 1), env)); return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment())); -})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? createFragment() : (function() { +})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? createFragment() : (function() { var frag = createFragment(); { var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } return frag; @@ -1426,6 +1519,88 @@ continue; } else { return NIL; } } }; return renderToDom(lambdaBody(f), local, ns); })(); }; + // render-dom-island + var renderDomIsland = function(island, args, env, ns) { return (function() { + var kwargs = {}; + var children = []; + reduce(function(state, arg) { return (function() { + var skip = get(state, "skip"); + return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { + var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + kwargs[keywordName(arg)] = val; + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (function() { + var local = envMerge(componentClosure(island), env); + var islandName = componentName(island); + { var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } + if (isSxTruthy(componentHasChildren(island))) { + (function() { + var childFrag = createFragment(); + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; domAppend(childFrag, renderToDom(c, env, ns)); } } + return envSet(local, "children", childFrag); +})(); +} + return (function() { + var container = domCreateElement("div", NIL); + var disposers = []; + domSetAttr(container, "data-sx-island", islandName); + return (function() { + var bodyDom = withIslandScope(function(disposable) { return append_b(disposers, disposable); }, function() { return renderToDom(componentBody(island), local, ns); }); + domAppend(container, bodyDom); + domSetData(container, "sx-disposers", disposers); + return container; +})(); +})(); +})(); +})(); }; + + // reactive-text + var reactiveText = function(sig) { return (function() { + var node = createTextNode((String(deref(sig)))); + effect(function() { return domSetTextContent(node, (String(deref(sig)))); }); + return node; +})(); }; + + // reactive-attr + var reactiveAttr = function(el, attrName, computeFn) { return effect(function() { return (function() { + var val = computeFn(); + return (isSxTruthy(sxOr(isNil(val), (val == false))) ? domRemoveAttr(el, attrName) : (isSxTruthy((val == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(val))))); +})(); }); }; + + // reactive-fragment + var reactiveFragment = function(testFn, renderFn, env, ns) { return (function() { + var marker = createComment("island-fragment"); + var currentNodes = []; + effect(function() { { var _c = currentNodes; for (var _i = 0; _i < _c.length; _i++) { var n = _c[_i]; domRemove(n); } } +currentNodes = []; +return (isSxTruthy(testFn()) ? (function() { + var frag = renderFn(); + currentNodes = domChildNodes(frag); + return domInsertAfter(marker, frag); +})() : NIL); }); + return marker; +})(); }; + + // reactive-list + var reactiveList = function(mapFn, itemsSig, env, ns) { return (function() { + var container = createFragment(); + var marker = createComment("island-list"); + domAppend(container, marker); + effect(function() { return (function() { + var parent = domParent(marker); + return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() { + var items = deref(itemsSig); + return forEach(function(item) { return (function() { + var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns)); + return domInsertAfter(marker, rendered); +})(); }, reverse(items)); +})()) : NIL); +})(); }); + return container; +})(); }; + // === Transpiled from engine === @@ -1441,7 +1616,7 @@ continue; } else { return NIL; } } }; // parse-trigger-spec var parseTriggerSpec = function(spec) { return (isSxTruthy(isNil(spec)) ? NIL : (function() { var rawParts = split(spec, ","); - return filter(function(x) { return !isNil(x); }, map(function(part) { return (function() { + return filter(function(x) { return !isSxTruthy(isNil(x)); }, map(function(part) { return (function() { var tokens = split(trim(part), " "); return (isSxTruthy(isEmpty(tokens)) ? NIL : (isSxTruthy((isSxTruthy((first(tokens) == "every")) && (len(tokens) >= 2))) ? {["event"]: "every", ["modifiers"]: {["interval"]: parseTime(nth(tokens, 1))}} : (function() { var mods = {}; @@ -1467,7 +1642,7 @@ continue; } else { return NIL; } } }; var targetSel = domGetAttr(el, "sx-target"); return (isSxTruthy(targetSel) ? dictSet(headers, "SX-Target", targetSel) : NIL); })(); - if (isSxTruthy(!isEmpty(loadedComponents))) { + if (isSxTruthy(!isSxTruthy(isEmpty(loadedComponents)))) { headers["SX-Components"] = join(",", loadedComponents); } if (isSxTruthy(cssHash)) { @@ -1484,7 +1659,7 @@ continue; } else { return NIL; } } }; })(); }; // process-response-headers - var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type")}; }; + var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type"), ["cache-invalidate"]: getHeader("SX-Cache-Invalidate"), ["cache-update"]: getHeader("SX-Cache-Update")}; }; // parse-swap-spec var parseSwapSpec = function(rawSwap, globalTransitions_p) { return (function() { @@ -1507,7 +1682,7 @@ continue; } else { return NIL; } } }; // filter-params var filterParams = function(paramsSpec, allParams) { return (isSxTruthy(isNil(paramsSpec)) ? allParams : (isSxTruthy((paramsSpec == "none")) ? [] : (isSxTruthy((paramsSpec == "*")) ? allParams : (isSxTruthy(startsWith(paramsSpec, "not ")) ? (function() { var excluded = map(trim, split(slice(paramsSpec, 4), ",")); - return filter(function(p) { return !contains(excluded, first(p)); }, allParams); + return filter(function(p) { return !isSxTruthy(contains(excluded, first(p))); }, allParams); })() : (function() { var allowed = map(trim, split(paramsSpec, ",")); return filter(function(p) { return contains(allowed, first(p)); }, allParams); @@ -1557,15 +1732,15 @@ continue; } else { return NIL; } } }; })(); }; // morph-node - var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!(domNodeType(oldNode) == domNodeType(newNode)), !(domNodeName(oldNode) == domNodeName(newNode)))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!(domTextContent(oldNode) == domTextContent(newNode))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!(isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); }; + var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); }; // sync-attrs var syncAttrs = function(oldEl, newEl) { { var _c = domAttrList(newEl); for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() { var name = first(attr); var val = nth(attr, 1); - return (isSxTruthy(!(domGetAttr(oldEl, name) == val)) ? domSetAttr(oldEl, name, val) : NIL); + return (isSxTruthy(!isSxTruthy((domGetAttr(oldEl, name) == val))) ? domSetAttr(oldEl, name, val) : NIL); })(); } } -return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr))) ? domRemoveAttr(oldEl, first(attr)) : NIL); }, domAttrList(oldEl)); }; +return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl, first(attr)))) ? domRemoveAttr(oldEl, first(attr)) : NIL); }, domAttrList(oldEl)); }; // morph-children var morphChildren = function(oldParent, newParent) { return (function() { @@ -1579,14 +1754,14 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr { var _c = newKids; for (var _i = 0; _i < _c.length; _i++) { var newChild = _c[_i]; (function() { var matchId = domId(newChild); var matchById = (isSxTruthy(matchId) ? dictGet(oldById, matchId) : NIL); - return (isSxTruthy((isSxTruthy(matchById) && !isNil(matchById))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !(matchById == nth(oldKids, oi)))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() { + return (isSxTruthy((isSxTruthy(matchById) && !isSxTruthy(isNil(matchById)))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !isSxTruthy((matchById == nth(oldKids, oi))))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() { var oldChild = nth(oldKids, oi); - return (isSxTruthy((isSxTruthy(domId(oldChild)) && !matchId)) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1)))); + return (isSxTruthy((isSxTruthy(domId(oldChild)) && !isSxTruthy(matchId))) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1)))); })() : domAppend(oldParent, domClone(newChild)))); })(); } } return forEach(function(i) { return (isSxTruthy((i >= oi)) ? (function() { var leftover = nth(oldKids, i); - return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!domHasAttr(leftover, "sx-preserve")) && !domHasAttr(leftover, "sx-ignore"))) ? domRemoveChild(oldParent, leftover) : NIL); + return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!isSxTruthy(domHasAttr(leftover, "sx-preserve"))) && !isSxTruthy(domHasAttr(leftover, "sx-ignore")))) ? domRemoveChild(oldParent, leftover) : NIL); })() : NIL); }, range(oi, len(oldKids))); })(); }; @@ -1631,7 +1806,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr var pushUrl = domGetAttr(el, "sx-push-url"); var replaceUrl = domGetAttr(el, "sx-replace-url"); var hdrReplace = get(respHeaders, "replace-url"); - return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !(pushUrl == "false"))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !(replaceUrl == "false"))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL))); + return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !isSxTruthy((pushUrl == "false")))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !isSxTruthy((replaceUrl == "false")))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL))); })(); }; // PRELOAD_TTL @@ -1655,11 +1830,11 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr // should-boost-link? var shouldBoostLink = function(link) { return (function() { var href = domGetAttr(link, "href"); - return (isSxTruthy(href) && isSxTruthy(!startsWith(href, "#")) && isSxTruthy(!startsWith(href, "javascript:")) && isSxTruthy(!startsWith(href, "mailto:")) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!domHasAttr(link, "sx-get")) && isSxTruthy(!domHasAttr(link, "sx-post")) && !domHasAttr(link, "sx-disable")); + return (isSxTruthy(href) && isSxTruthy(!isSxTruthy(startsWith(href, "#"))) && isSxTruthy(!isSxTruthy(startsWith(href, "javascript:"))) && isSxTruthy(!isSxTruthy(startsWith(href, "mailto:"))) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-get"))) && isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-post"))) && !isSxTruthy(domHasAttr(link, "sx-disable"))); })(); }; // should-boost-form? - var shouldBoostForm = function(form) { return (isSxTruthy(!domHasAttr(form, "sx-get")) && isSxTruthy(!domHasAttr(form, "sx-post")) && !domHasAttr(form, "sx-disable")); }; + var shouldBoostForm = function(form) { return (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-get"))) && isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-post"))) && !isSxTruthy(domHasAttr(form, "sx-disable"))); }; // parse-sse-swap var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; @@ -1678,7 +1853,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr var parsed = tryParseJson(headerVal); return (isSxTruthy(parsed) ? forEach(function(key) { return domDispatch(el, key, get(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() { var trimmed = trim(name); - return (isSxTruthy(!isEmpty(trimmed)) ? domDispatch(el, trimmed, {}) : NIL); + return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? domDispatch(el, trimmed, {}) : NIL); })(); }, split(headerVal, ","))); })() : NIL); }; @@ -1699,14 +1874,14 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr var url = get(info, "url"); return (isSxTruthy((function() { var media = domGetAttr(el, "sx-media"); - return (isSxTruthy(media) && !browserMediaMatches(media)); + return (isSxTruthy(media) && !isSxTruthy(browserMediaMatches(media))); })()) ? promiseResolve(NIL) : (isSxTruthy((function() { var confirmMsg = domGetAttr(el, "sx-confirm"); - return (isSxTruthy(confirmMsg) && !browserConfirm(confirmMsg)); + return (isSxTruthy(confirmMsg) && !isSxTruthy(browserConfirm(confirmMsg))); })()) ? promiseResolve(NIL) : (function() { var promptMsg = domGetAttr(el, "sx-prompt"); var promptVal = (isSxTruthy(promptMsg) ? browserPrompt(promptMsg) : NIL); - return (isSxTruthy((isSxTruthy(promptMsg) && isNil(promptVal))) ? promiseResolve(NIL) : (isSxTruthy(!validateForRequest(el)) ? promiseResolve(NIL) : doFetch(el, verb, verb, url, (isSxTruthy(promptVal) ? assoc(sxOr(extraParams, {}), "SX-Prompt", promptVal) : extraParams)))); + return (isSxTruthy((isSxTruthy(promptMsg) && isNil(promptVal))) ? promiseResolve(NIL) : (isSxTruthy(!isSxTruthy(validateForRequest(el))) ? promiseResolve(NIL) : doFetch(el, verb, verb, url, (isSxTruthy(promptVal) ? assoc(sxOr(extraParams, {}), "SX-Prompt", promptVal) : extraParams)))); })())); })()); })(); }; @@ -1744,7 +1919,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr domAddClass(el, "sx-request"); domSetAttr(el, "aria-busy", "true"); domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method}); - return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), handleRetry(el, verb, method, finalUrl, extraParams)) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isAbortError(err)) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); }); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), handleRetry(el, verb, method, finalUrl, extraParams)) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); }); })(); })(); })(); @@ -1758,6 +1933,7 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr return (isSxTruthy(newHash) ? (_cssHash = newHash) : NIL); })(); dispatchTriggerEvents(el, get(respHeaders, "trigger")); + processCacheDirectives(el, respHeaders, text); return (isSxTruthy(get(respHeaders, "redirect")) ? browserNavigate(get(respHeaders, "redirect")) : (isSxTruthy(get(respHeaders, "refresh")) ? browserReload() : (isSxTruthy(get(respHeaders, "location")) ? fetchLocation(get(respHeaders, "location")) : (function() { var targetEl = (isSxTruthy(get(respHeaders, "retarget")) ? domQuery(get(respHeaders, "retarget")) : resolveTarget(el)); var swapSpec = parseSwapSpec(sxOr(get(respHeaders, "reswap"), domGetAttr(el, "sx-swap")), domHasClass(domBody(), "sx-transitions")); @@ -1778,10 +1954,10 @@ return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr var handleSxResponse = function(el, target, text, swapStyle, useTransition) { return (function() { var cleaned = stripComponentScripts(text); return (function() { - var final = extractResponseCss(cleaned); + var final_ = extractResponseCss(cleaned); return (function() { - var trimmed = trim(final); - return (isSxTruthy(!isEmpty(trimmed)) ? (function() { + var trimmed = trim(final_); + return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (function() { var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); domAppend(container, rendered); @@ -1857,7 +2033,15 @@ return postSwap(target); }); return (isSxTruthy((val == lastVal)) ? (shouldFire = false) : (lastVal = val)); })(); } - return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, get(mods, "delay")))) : executeRequest(el, verbInfo, NIL))) : NIL); + return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (function() { + var liveInfo = sxOr(getVerbInfo(el), verbInfo); + var isGetLink = (isSxTruthy((eventName == "click")) && isSxTruthy((get(liveInfo, "method") == "GET")) && isSxTruthy(domHasAttr(el, "href")) && !isSxTruthy(get(mods, "delay"))); + var clientRouted = false; + if (isSxTruthy(isGetLink)) { + clientRouted = tryClientRoute(urlPathname(get(liveInfo, "url")), domGetAttr(el, "sx-target")); +} + return (isSxTruthy(clientRouted) ? (browserPushState(get(liveInfo, "url")), browserScrollTo(0, 0)) : ((isSxTruthy(isGetLink) ? logInfo((String("sx:route server fetch ") + String(get(liveInfo, "url")))) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, NIL, NIL); }, get(mods, "delay")))) : executeRequest(el, NIL, NIL)))); +})()) : NIL); })(); }, (isSxTruthy(get(mods, "once")) ? {["once"]: true} : NIL)) : NIL); })(); }; @@ -1870,7 +2054,7 @@ return processElements(root); }; // activate-scripts var activateScripts = function(root) { return (isSxTruthy(root) ? (function() { var scripts = domQueryAll(root, "script"); - return forEach(function(dead) { return (isSxTruthy((isSxTruthy(!domHasAttr(dead, "data-components")) && !domHasAttr(dead, "data-sx-activated"))) ? (function() { + return forEach(function(dead) { return (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(dead, "data-components"))) && !isSxTruthy(domHasAttr(dead, "data-sx-activated")))) ? (function() { var live = createScriptClone(dead); domSetAttr(live, "data-sx-activated", "true"); return domReplaceChild(domParent(dead), live, dead); @@ -1906,33 +2090,175 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\" var processBoosted = function(root) { return forEach(function(container) { return boostDescendants(container); }, domQueryAll(sxOr(root, domBody()), "[sx-boost]")); }; // boost-descendants - var boostDescendants = function(container) { { var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link)))) { + var boostDescendants = function(container) { return (function() { + var boostTarget = domGetAttr(container, "sx-boost"); + { var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(link, "boost"))) && shouldBoostLink(link)))) { markProcessed(link, "boost"); - if (isSxTruthy(!domHasAttr(link, "sx-target"))) { - domSetAttr(link, "sx-target", "#main-panel"); + if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) { + domSetAttr(link, "sx-target", boostTarget); } - if (isSxTruthy(!domHasAttr(link, "sx-swap"))) { + if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-swap")))) { domSetAttr(link, "sx-swap", "innerHTML"); } - if (isSxTruthy(!domHasAttr(link, "sx-push-url"))) { + if (isSxTruthy(!isSxTruthy(domHasAttr(link, "sx-push-url")))) { domSetAttr(link, "sx-push-url", "true"); } - bindBoostLink(link, domGetAttr(link, "href")); + bindClientRouteLink(link, domGetAttr(link, "href")); } } } -return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() { + return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isSxTruthy(isProcessed(form, "boost"))) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() { var method = upper(sxOr(domGetAttr(form, "method"), "GET")); var action = sxOr(domGetAttr(form, "action"), browserLocationHref()); - if (isSxTruthy(!domHasAttr(form, "sx-target"))) { - domSetAttr(form, "sx-target", "#main-panel"); + if (isSxTruthy((isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-target"))) && isSxTruthy(boostTarget) && !isSxTruthy((boostTarget == "true"))))) { + domSetAttr(form, "sx-target", boostTarget); } - if (isSxTruthy(!domHasAttr(form, "sx-swap"))) { + if (isSxTruthy(!isSxTruthy(domHasAttr(form, "sx-swap")))) { domSetAttr(form, "sx-swap", "innerHTML"); } return bindBoostForm(form, method, action); -})()) : NIL); }, domQueryAll(container, "form")); }; +})()) : NIL); }, domQueryAll(container, "form")); +})(); }; + + // _page-data-cache + var _pageDataCache = {}; + + // _page-data-cache-ttl + var _pageDataCacheTtl = 30000; + + // page-data-cache-key + var pageDataCacheKey = function(pageName, params) { return (function() { + var base = pageName; + return (isSxTruthy(sxOr(isNil(params), isEmpty(keys(params)))) ? base : (function() { + var parts = []; + { var _c = keys(params); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; parts.push((String(k) + String("=") + String(get(params, k)))); } } + return (String(base) + String(":") + String(join("&", parts))); +})()); +})(); }; + + // page-data-cache-get + var pageDataCacheGet = function(cacheKey) { return (function() { + var entry = get(_pageDataCache, cacheKey); + return (isSxTruthy(isNil(entry)) ? NIL : (isSxTruthy(((nowMs() - get(entry, "ts")) > _pageDataCacheTtl)) ? (dictSet(_pageDataCache, cacheKey, NIL), NIL) : get(entry, "data"))); +})(); }; + + // page-data-cache-set + var pageDataCacheSet = function(cacheKey, data) { return dictSet(_pageDataCache, cacheKey, {"data": data, "ts": nowMs()}); }; + + // invalidate-page-cache + var invalidatePageCache = function(pageName) { { var _c = keys(_pageDataCache); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; if (isSxTruthy(sxOr((k == pageName), startsWith(k, (String(pageName) + String(":")))))) { + _pageDataCache[k] = NIL; +} } } +swPostMessage({"type": "invalidate", "page": pageName}); +return logInfo((String("sx:cache invalidate ") + String(pageName))); }; + + // invalidate-all-page-cache + var invalidateAllPageCache = function() { _pageDataCache = {}; +swPostMessage({"type": "invalidate", "page": "*"}); +return logInfo("sx:cache invalidate *"); }; + + // update-page-cache + var updatePageCache = function(pageName, data) { return (function() { + var cacheKey = pageDataCacheKey(pageName, {}); + pageDataCacheSet(cacheKey, data); + return logInfo((String("sx:cache update ") + String(pageName))); +})(); }; + + // process-cache-directives + var processCacheDirectives = function(el, respHeaders, responseText) { (function() { + var elInvalidate = domGetAttr(el, "sx-cache-invalidate"); + return (isSxTruthy(elInvalidate) ? (isSxTruthy((elInvalidate == "*")) ? invalidateAllPageCache() : invalidatePageCache(elInvalidate)) : NIL); +})(); +(function() { + var hdrInvalidate = get(respHeaders, "cache-invalidate"); + return (isSxTruthy(hdrInvalidate) ? (isSxTruthy((hdrInvalidate == "*")) ? invalidateAllPageCache() : invalidatePageCache(hdrInvalidate)) : NIL); +})(); +return (function() { + var hdrUpdate = get(respHeaders, "cache-update"); + return (isSxTruthy(hdrUpdate) ? (function() { + var data = parseSxData(responseText); + return (isSxTruthy(data) ? updatePageCache(hdrUpdate, data) : NIL); +})() : NIL); +})(); }; + + // current-page-layout + var currentPageLayout = function() { return (function() { + var pathname = urlPathname(browserLocationHref()); + var match = findMatchingRoute(pathname, _pageRoutes); + return (isSxTruthy(isNil(match)) ? "" : sxOr(get(match, "layout"), "")); +})(); }; + + // swap-rendered-content + var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; + + // resolve-route-target + var resolveRouteTarget = function(targetSel) { return (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL); }; + + // deps-satisfied? + var depsSatisfied_p = function(match) { return (function() { + var deps = get(match, "deps"); + var loaded = loadedComponentNames(); + return (isSxTruthy(sxOr(isNil(deps), isEmpty(deps))) ? true : isEvery(function(dep) { return contains(loaded, dep); }, deps)); +})(); }; + + // try-client-route + var tryClientRoute = function(pathname, targetSel) { return (function() { + var match = findMatchingRoute(pathname, _pageRoutes); + return (isSxTruthy(isNil(match)) ? (logInfo((String("sx:route no match (") + String(len(_pageRoutes)) + String(" routes) ") + String(pathname))), false) : (function() { + var targetLayout = sxOr(get(match, "layout"), ""); + var curLayout = currentPageLayout(); + return (isSxTruthy(!isSxTruthy((targetLayout == curLayout))) ? (logInfo((String("sx:route server (layout: ") + String(curLayout) + String(" -> ") + String(targetLayout) + String(") ") + String(pathname))), false) : (function() { + var contentSrc = get(match, "content"); + var closure = sxOr(get(match, "closure"), {}); + var params = get(match, "params"); + var pageName = get(match, "name"); + return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() { + var target = resolveRouteTarget(targetSel); + return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (function() { + var ioDeps = get(match, "io-deps"); + var hasIo = (isSxTruthy(ioDeps) && !isSxTruthy(isEmpty(ioDeps))); + var renderPlan = get(match, "render-plan"); + if (isSxTruthy(renderPlan)) { + (function() { + var srv = sxOr(get(renderPlan, "server"), []); + var cli = sxOr(get(renderPlan, "client"), []); + return logInfo((String("sx:route plan ") + String(pageName) + String(" — ") + String(len(srv)) + String(" server, ") + String(len(cli)) + String(" client"))); +})(); +} + if (isSxTruthy(hasIo)) { + registerIoDeps(ioDeps); +} + return (isSxTruthy(get(match, "stream")) ? (logInfo((String("sx:route streaming ") + String(pathname))), fetchStreaming(target, pathname, buildRequestHeaders(target, loadedComponentNames(), _cssHash)), true) : (isSxTruthy(get(match, "has-data")) ? (function() { + var cacheKey = pageDataCacheKey(pageName, params); + var cached = pageDataCacheGet(cacheKey); + return (isSxTruthy(cached) ? (function() { + var env = merge(closure, params, cached); + return (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+cache+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() { + var rendered = tryEvalContent(contentSrc, env); + return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true)); +})()); +})() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, data); +return (function() { + var env = merge(closure, params, data); + return (isSxTruthy(hasIo) ? tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data+async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }) : (function() { + var rendered = tryEvalContent(contentSrc, env); + return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); +})()); +})(); }), true)); +})() : (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, merge(closure, params), function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() { + var env = merge(closure, params); + var rendered = tryEvalContent(contentSrc, env); + return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true)); +})()))); +})())); +})()); +})()); +})()); +})(); }; + + // bind-client-route-link + var bindClientRouteLink = function(link, href) { return bindClientRouteClick(link, href, function() { return bindBoostLink(link, href); }); }; // process-sse - var processSse = function(root) { return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), bindSse(el)) : NIL); }, domQueryAll(sxOr(root, domBody()), "[sx-sse]")); }; + var processSse = function(root) { return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "sse"))) ? (markProcessed(el, "sse"), bindSse(el)) : NIL); }, domQueryAll(sxOr(root, domBody()), "[sx-sse]")); }; // bind-sse var bindSse = function(el) { return (function() { @@ -1951,7 +2277,7 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form var swapStyle = get(swapSpec, "style"); var useTransition = get(swapSpec, "transition"); var trimmed = trim(data); - return (isSxTruthy(!isEmpty(trimmed)) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() { + return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() { var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); domAppend(container, rendered); @@ -1967,7 +2293,7 @@ return postSwap(target); })) : NIL); var body = nth(attr, 1); return (isSxTruthy(startsWith(name, "sx-on:")) ? (function() { var eventName = slice(name, 6); - return (isSxTruthy(!isProcessed(el, (String("on:") + String(eventName)))) ? (markProcessed(el, (String("on:") + String(eventName))), bindInlineHandler(el, eventName, body)) : NIL); + return (isSxTruthy(!isSxTruthy(isProcessed(el, (String("on:") + String(eventName))))) ? (markProcessed(el, (String("on:") + String(eventName))), bindInlineHandler(el, eventName, body)) : NIL); })() : NIL); })(); }, domAttrList(el)); }, domQueryAll(sxOr(root, domBody()), "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:load]")); }; @@ -1975,14 +2301,12 @@ return postSwap(target); })) : NIL); var bindPreloadFor = function(el) { return (function() { var preloadAttr = domGetAttr(el, "sx-preload"); return (isSxTruthy(preloadAttr) ? (function() { - var info = getVerbInfo(el); - return (isSxTruthy(info) ? (function() { - var url = get(info, "url"); - var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); var events = (isSxTruthy((preloadAttr == "mousedown")) ? ["mousedown", "touchstart"] : ["mouseover"]); var debounceMs = (isSxTruthy((preloadAttr == "mousedown")) ? 0 : 100); - return bindPreload(el, events, debounceMs, function() { return doPreload(url, headers); }); -})() : NIL); + return bindPreload(el, events, debounceMs, function() { return (function() { + var info = getVerbInfo(el); + return (isSxTruthy(info) ? doPreload(get(info, "url"), buildRequestHeaders(el, loadedComponentNames(), _cssHash)) : NIL); +})(); }); })() : NIL); })(); }; @@ -1995,7 +2319,7 @@ return postSwap(target); })) : NIL); // process-elements var processElements = function(root) { (function() { var els = domQueryAll(sxOr(root, domBody()), VERB_SELECTOR); - return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "verb")) ? (markProcessed(el, "verb"), processOne(el)) : NIL); }, els); + return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "verb"))) ? (markProcessed(el, "verb"), processOne(el)) : NIL); }, els); })(); processBoosted(root); processSse(root); @@ -2004,17 +2328,24 @@ return bindInlineHandlers(root); }; // process-one var processOne = function(el) { return (function() { var verbInfo = getVerbInfo(el); - return (isSxTruthy(verbInfo) ? (isSxTruthy(!domHasAttr(el, "sx-disable")) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL) : NIL); + return (isSxTruthy(verbInfo) ? (isSxTruthy(!isSxTruthy(domHasAttr(el, "sx-disable"))) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL) : NIL); })(); }; // handle-popstate var handlePopstate = function(scrollY) { return (function() { - var main = domQueryById("main-panel"); var url = browserLocationHref(); - return (isSxTruthy(main) ? (function() { - var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash); - return fetchAndRestore(main, url, headers, scrollY); + var boostEl = domQuery("[sx-boost]"); + var targetSel = (isSxTruthy(boostEl) ? (function() { + var attr = domGetAttr(boostEl, "sx-boost"); + return (isSxTruthy((isSxTruthy(attr) && !isSxTruthy((attr == "true")))) ? attr : NIL); })() : NIL); + var targetSel = sxOr(targetSel, "#main-panel"); + var target = domQuery(targetSel); + var pathname = urlPathname(url); + return (isSxTruthy(target) ? (isSxTruthy(tryClientRoute(pathname, targetSel)) ? browserScrollTo(0, scrollY) : (function() { + var headers = buildRequestHeaders(target, loadedComponentNames(), _cssHash); + return fetchAndRestore(target, url, headers, scrollY); +})()) : NIL); })(); }; // engine-init @@ -2053,12 +2384,27 @@ return bindInlineHandlers(root); }; processElements(el); return sxHydrateElements(el); })() : NIL); +})(); }; + + // resolve-suspense + var resolveSuspense = function(id, sx) { processSxScripts(NIL); +return (function() { + var el = domQuery((String("[data-suspense=\"") + String(id) + String("\"]"))); + return (isSxTruthy(el) ? (function() { + var exprs = parse(sx); + var env = getRenderEnv(NIL); + domSetTextContent(el, ""); + { var _c = exprs; for (var _i = 0; _i < _c.length; _i++) { var expr = _c[_i]; domAppend(el, renderToDom(expr, env, NIL)); } } + processElements(el); + sxHydrateElements(el); + return domDispatch(el, "sx:resolved", {"id": id}); +})() : logWarn((String("resolveSuspense: no element for id=") + String(id)))); })(); }; // sx-hydrate-elements var sxHydrateElements = function(root) { return (function() { var els = domQueryAll(sxOr(root, domBody()), "[data-sx]"); - return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "hydrated")) ? (markProcessed(el, "hydrated"), sxUpdateElement(el, NIL)) : NIL); }, els); + return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "hydrated"))) ? (markProcessed(el, "hydrated"), sxUpdateElement(el, NIL)) : NIL); }, els); })(); }; // sx-update-element @@ -2085,7 +2431,7 @@ return bindInlineHandlers(root); }; return (function() { var env = getRenderEnv(extraEnv); var comp = envGet(env, fullName); - return (isSxTruthy(!isComponent(comp)) ? error((String("Unknown component: ") + String(fullName))) : (function() { + return (isSxTruthy(!isSxTruthy(isComponent(comp))) ? error((String("Unknown component: ") + String(fullName))) : (function() { var callExpr = [makeSymbol(fullName)]; { var _c = keys(kwargs); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; callExpr.push(makeKeyword(toKebab(k))); callExpr.push(dictGet(kwargs, k)); } } @@ -2097,7 +2443,7 @@ callExpr.push(dictGet(kwargs, k)); } } // process-sx-scripts var processSxScripts = function(root) { return (function() { var scripts = querySxScripts(root); - return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "script")) ? (markProcessed(s, "script"), (function() { + return forEach(function(s) { return (isSxTruthy(!isSxTruthy(isProcessed(s, "script"))) ? (markProcessed(s, "script"), (function() { var text = domTextContent(s); return (isSxTruthy(domHasAttr(s, "data-components")) ? processComponentScript(s, text) : (isSxTruthy(sxOr(isNil(text), isEmpty(trim(text)))) ? NIL : (isSxTruthy(domHasAttr(s, "data-mount")) ? (function() { var mountSel = domGetAttr(s, "data-mount"); @@ -2110,8 +2456,8 @@ callExpr.push(dictGet(kwargs, k)); } } // process-component-script var processComponentScript = function(script, text) { return (function() { var hash = domGetAttr(script, "data-hash"); - return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? sxLoadComponents(text) : NIL) : (function() { - var hasInline = (isSxTruthy(text) && !isEmpty(trim(text))); + return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))))) ? sxLoadComponents(text) : NIL) : (function() { + var hasInline = (isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text)))); (function() { var cachedHash = localStorageGet("sx-components-hash"); return (isSxTruthy((cachedHash == hash)) ? (isSxTruthy(hasInline) ? (localStorageSet("sx-components-hash", hash), localStorageSet("sx-components-src", text), sxLoadComponents(text), logInfo("components: downloaded (cookie stale)")) : (function() { @@ -2123,121 +2469,238 @@ callExpr.push(dictGet(kwargs, k)); } } })()); })(); }; - // boot-init - var bootInit = function() { return (initCssTracking(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; + // _page-routes + var _pageRoutes = []; - - // === Transpiled from deps (component dependency analysis) === - - // scan-refs - var scanRefs = function(node) { return (function() { - var refs = []; - scanRefsWalk(node, refs); - return refs; -})(); }; - - // scan-refs-walk - var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { - var name = symbolName(node); - return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL); -})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); }; - - // transitive-deps-walk - var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() { - var val = envGet(env, n); - return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL)); -})()) : NIL); }; - - // transitive-deps - var transitiveDeps = function(name, env) { return (function() { - var seen = []; - var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); - transitiveDepsWalk(key, seen, env); - return filter(function(x) { return !(x == key); }, seen); -})(); }; - - // compute-all-deps - var computeAllDeps = function(env) { return forEach(function(name) { return (function() { - var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL); -})(); }, envComponents(env)); }; - - // scan-components-from-source - var scanComponentsFromSource = function(source) { return (function() { - var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source); - return map(function(m) { return (String("~") + String(m)); }, matches); -})(); }; - - // components-needed - var componentsNeeded = function(pageSource, env) { return (function() { - var direct = scanComponentsFromSource(pageSource); - var allNeeded = []; - { var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) { - allNeeded.push(name); -} -(function() { - var val = envGet(env, name); - return (function() { - var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env)); - return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps); + // process-page-scripts + var processPageScripts = function() { return (function() { + var scripts = queryPageScripts(); + logInfo((String("pages: found ") + String(len(scripts)) + String(" script tags"))); + { var _c = scripts; for (var _i = 0; _i < _c.length; _i++) { var s = _c[_i]; if (isSxTruthy(!isSxTruthy(isProcessed(s, "pages")))) { + markProcessed(s, "pages"); + (function() { + var text = domTextContent(s); + logInfo((String("pages: script text length=") + String((isSxTruthy(text) ? len(text) : 0)))); + return (isSxTruthy((isSxTruthy(text) && !isSxTruthy(isEmpty(trim(text))))) ? (function() { + var pages = parse(text); + logInfo((String("pages: parsed ") + String(len(pages)) + String(" entries"))); + return forEach(function(page) { return append_b(_pageRoutes, merge(page, {"parsed": parseRoutePattern(get(page, "path"))})); }, pages); +})() : logWarn("pages: script tag is empty")); })(); -})(); } } - return allNeeded; -})(); }; - - // page-component-bundle - var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); }; - - // page-css-classes - var pageCssClasses = function(pageSource, env) { return (function() { - var needed = componentsNeeded(pageSource, env); - var classes = []; - { var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() { - var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL); -})(); } } - { var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) { - classes.push(cls); } } } - return classes; + return logInfo((String("pages: ") + String(len(_pageRoutes)) + String(" routes loaded"))); })(); }; - // scan-io-refs-walk - var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { - var name = symbolName(node); - return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL); -})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); }; + // boot-init + var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; - // scan-io-refs - var scanIoRefs = function(node, ioNames) { return (function() { - var refs = []; - scanIoRefsWalk(node, ioNames, refs); - return refs; + + // === Transpiled from router (client-side route matching) === + + // split-path-segments + var splitPathSegments = function(path) { return (function() { + var trimmed = (isSxTruthy(startsWith(path, "/")) ? slice(path, 1) : path); + return (function() { + var trimmed2 = (isSxTruthy((isSxTruthy(!isSxTruthy(isEmpty(trimmed))) && endsWith(trimmed, "/"))) ? slice(trimmed, 0, (len(trimmed) - 1)) : trimmed); + return (isSxTruthy(isEmpty(trimmed2)) ? [] : split(trimmed2, "/")); +})(); })(); }; - // transitive-io-refs-walk - var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() { - var val = envGet(env, n); - return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL)); + // make-route-segment + var makeRouteSegment = function(seg) { return (isSxTruthy((isSxTruthy(startsWith(seg, "<")) && endsWith(seg, ">"))) ? (function() { + var paramName = slice(seg, 1, (len(seg) - 1)); + return (function() { + var d = {}; + d["type"] = "param"; + d["value"] = paramName; + return d; +})(); +})() : (function() { + var d = {}; + d["type"] = "literal"; + d["value"] = seg; + return d; +})()); }; + + // parse-route-pattern + var parseRoutePattern = function(pattern) { return (function() { + var segments = splitPathSegments(pattern); + return map(makeRouteSegment, segments); +})(); }; + + // match-route-segments + var matchRouteSegments = function(pathSegs, parsedSegs) { return (isSxTruthy(!isSxTruthy((len(pathSegs) == len(parsedSegs)))) ? NIL : (function() { + var params = {}; + var matched = true; + forEachIndexed(function(i, parsedSeg) { return (isSxTruthy(matched) ? (function() { + var pathSeg = nth(pathSegs, i); + var segType = get(parsedSeg, "type"); + return (isSxTruthy((segType == "literal")) ? (isSxTruthy(!isSxTruthy((pathSeg == get(parsedSeg, "value")))) ? (matched = false) : NIL) : (isSxTruthy((segType == "param")) ? dictSet(params, get(parsedSeg, "value"), pathSeg) : (matched = false))); +})() : NIL); }, parsedSegs); + return (isSxTruthy(matched) ? params : NIL); +})()); }; + + // match-route + var matchRoute = function(path, pattern) { return (function() { + var pathSegs = splitPathSegments(path); + var parsedSegs = parseRoutePattern(pattern); + return matchRouteSegments(pathSegs, parsedSegs); +})(); }; + + // find-matching-route + var findMatchingRoute = function(path, routes) { return (function() { + var pathSegs = splitPathSegments(path); + var result = NIL; + { var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) { + (function() { + var params = matchRouteSegments(pathSegs, get(route, "parsed")); + return (isSxTruthy(!isSxTruthy(isNil(params))) ? (function() { + var matched = merge(route, {}); + matched["params"] = params; + return (result = matched); +})() : NIL); +})(); +} } } + return result; +})(); }; + + + // === Transpiled from signals (reactive signal runtime) === + + // signal + var signal = function(initialValue) { return makeSignal(initialValue); }; + + // deref + var deref = function(s) { return (isSxTruthy(!isSxTruthy(isSignal(s))) ? s : (function() { + var ctx = getTrackingContext(); + if (isSxTruthy(ctx)) { + trackingContextAddDep(ctx, s); + signalAddSub(s, trackingContextNotifyFn(ctx)); +} + return signalValue(s); +})()); }; + + // reset! + var reset_b = function(s, value) { return (isSxTruthy(isSignal(s)) ? (function() { + var old = signalValue(s); + return (isSxTruthy(!isSxTruthy(isIdentical(old, value))) ? (signalSetValue(s, value), notifySubscribers(s)) : NIL); +})() : NIL); }; + + // swap! + var swap_b = function(s, f) { var args = Array.prototype.slice.call(arguments, 2); return (isSxTruthy(isSignal(s)) ? (function() { + var old = signalValue(s); + var newVal = apply(f, cons(old, args)); + return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? (signalSetValue(s, newVal), notifySubscribers(s)) : NIL); +})() : NIL); }; + + // computed + var computed = function(computeFn) { return (function() { + var s = makeSignal(NIL); + var deps = []; + var computeCtx = NIL; + return (function() { + var recompute = function() { { var _c = signalDeps(s); for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, recompute); } } +signalSetDeps(s, []); +return (function() { + var ctx = makeTrackingContext(recompute); + return (function() { + var prev = getTrackingContext(); + setTrackingContext(ctx); + return (function() { + var newVal = computeFn(); + setTrackingContext(prev); + signalSetDeps(s, trackingContextDeps(ctx)); + return (function() { + var old = signalValue(s); + signalSetValue(s, newVal); + return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? notifySubscribers(s) : NIL); +})(); +})(); +})(); +})(); }; + recompute(); + return s; +})(); +})(); }; + + // effect + var effect = function(effectFn) { return (function() { + var deps = []; + var disposed = false; + var cleanupFn = NIL; + return (function() { + var runEffect = function() { return (isSxTruthy(!isSxTruthy(disposed)) ? ((isSxTruthy(cleanupFn) ? cleanupFn() : NIL), forEach(function(dep) { return signalRemoveSub(dep, runEffect); }, deps), (deps = []), (function() { + var ctx = makeTrackingContext(runEffect); + return (function() { + var prev = getTrackingContext(); + setTrackingContext(ctx); + return (function() { + var result = effectFn(); + setTrackingContext(prev); + deps = trackingContextDeps(ctx); + return (isSxTruthy(isCallable(result)) ? (cleanupFn = result) : NIL); +})(); +})(); })()) : NIL); }; - - // transitive-io-refs - var transitiveIoRefs = function(name, env, ioNames) { return (function() { - var allRefs = []; - var seen = []; - var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); - transitiveIoRefsWalk(key, seen, allRefs, env, ioNames); - return allRefs; + runEffect(); + return function() { disposed = true; +if (isSxTruthy(cleanupFn)) { + cleanupFn(); +} +{ var _c = deps; for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, runEffect); } } +return (deps = []); }; +})(); })(); }; - // compute-all-io-refs - var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() { - var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL); -})(); }, envComponents(env)); }; + // *batch-depth* + var _batchDepth = NIL; - // component-pure? - var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); }; + // *batch-queue* + var _batchQueue = []; + + // batch + var batch = function(thunk) { _batchDepth = (_batchDepth + 1); +thunk(); +_batchDepth = (_batchDepth - 1); +return (isSxTruthy((_batchDepth == 0)) ? (function() { + var queue = _batchQueue; + _batchQueue = []; + return (function() { + var seen = []; + var pending = []; + { var _c = queue; for (var _i = 0; _i < _c.length; _i++) { var s = _c[_i]; { var _c = signalSubscribers(s); for (var _i = 0; _i < _c.length; _i++) { var sub = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(seen, sub)))) { + seen.push(sub); + pending.push(sub); +} } } } } + return forEach(function(sub) { return sub(); }, pending); +})(); +})() : NIL); }; + + // notify-subscribers + var notifySubscribers = function(s) { return (isSxTruthy((_batchDepth > 0)) ? (isSxTruthy(!isSxTruthy(contains(_batchQueue, s))) ? append_b(_batchQueue, s) : NIL) : flushSubscribers(s)); }; + + // flush-subscribers + var flushSubscribers = function(s) { return forEach(function(sub) { return sub(); }, signalSubscribers(s)); }; + + // dispose-computed + var disposeComputed = function(s) { return (isSxTruthy(isSignal(s)) ? (forEach(function(dep) { return signalRemoveSub(dep, NIL); }, signalDeps(s)), signalSetDeps(s, [])) : NIL); }; + + // *island-scope* + var _islandScope = NIL; + + // with-island-scope + var withIslandScope = function(scopeFn, bodyFn) { return (function() { + var prev = _islandScope; + _islandScope = scopeFn; + return (function() { + var result = bodyFn(); + _islandScope = prev; + return result; +})(); +})(); }; + + // register-in-scope + var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; // ========================================================================= @@ -2254,7 +2717,7 @@ callExpr.push(dictGet(kwargs, k)); } } function domCreateElement(tag, ns) { if (!_hasDom) return null; - if (ns) return document.createElementNS(ns, tag); + if (ns && ns !== NIL) return document.createElementNS(ns, tag); return document.createElement(tag); } @@ -2262,6 +2725,10 @@ callExpr.push(dictGet(kwargs, k)); } } return _hasDom ? document.createTextNode(s) : null; } + function createComment(s) { + return _hasDom ? document.createComment(s || "") : null; + } + function createFragment() { return _hasDom ? document.createDocumentFragment() : null; } @@ -2396,6 +2863,97 @@ callExpr.push(dictGet(kwargs, k)); } } function domTagName(el) { return el && el.tagName ? el.tagName : ""; } + // Island DOM helpers + function domRemove(node) { + if (node && node.parentNode) node.parentNode.removeChild(node); + } + function domChildNodes(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + function domRemoveChildrenAfter(marker) { + if (!marker || !marker.parentNode) return; + var parent = marker.parentNode; + while (marker.nextSibling) parent.removeChild(marker.nextSibling); + } + function domSetData(el, key, val) { + if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; } + } + + // ========================================================================= + // Performance overrides — replace transpiled spec with imperative JS + // ========================================================================= + + // Override renderDomComponent: imperative kwarg parsing, no reduce/assoc + renderDomComponent = function(comp, args, env, ns) { + // Parse keyword args imperatively + var kwargs = {}; + var children = []; + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + kwargs[arg.name] = trampoline(evalExpr(args[i + 1], env)); + i++; // skip value + } else { + children.push(arg); + } + } + // Build local env via prototype chain + var local = Object.create(componentClosure(comp)); + // Copy caller env own properties + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + // Bind params + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + var p = params[j]; + local[p] = p in kwargs ? kwargs[p] : NIL; + } + // Bind children + if (componentHasChildren(comp)) { + var childFrag = document.createDocumentFragment(); + for (var c = 0; c < children.length; c++) { + var rendered = renderToDom(children[c], env, ns); + if (rendered) childFrag.appendChild(rendered); + } + local["children"] = childFrag; + } + return renderToDom(componentBody(comp), local, ns); + }; + + // Override renderDomElement: imperative attr parsing, no reduce/assoc + renderDomElement = function(tag, args, env, ns) { + var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns; + var el = domCreateElement(tag, newNs); + var extraClasses = []; + var isVoid = contains(VOID_ELEMENTS, tag); + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var attrName = arg.name; + var attrVal = trampoline(evalExpr(args[i + 1], env)); + i++; // skip value + if (isNil(attrVal) || attrVal === false) continue; + if (contains(BOOLEAN_ATTRS, attrName)) { + if (isSxTruthy(attrVal)) el.setAttribute(attrName, ""); + } else if (attrVal === true) { + el.setAttribute(attrName, ""); + } else { + el.setAttribute(attrName, String(attrVal)); + } + } else { + if (!isVoid) { + var child = renderToDom(arg, env, newNs); + if (child) el.appendChild(child); + } + } + } + if (extraClasses.length) { + var existing = el.getAttribute("class") || ""; + el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" ")); + } + return el; + }; + // ========================================================================= // Platform interface — Engine pure logic (browser + node compatible) @@ -2616,6 +3174,134 @@ callExpr.push(dictGet(kwargs, k)); } } }).catch(function() { location.reload(); }); } + function fetchStreaming(target, url, headers) { + // Streaming fetch for multi-stream pages. + // First chunk = OOB SX swap (shell with skeletons). + // Subsequent chunks = __sxResolve script tags filling suspense slots. + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + if (!resp.ok || !resp.body) { + // Fallback: non-streaming + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(target, newMain || container); + postSwap(target); + } + }); + } + + var reader = resp.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ""; + var initialSwapDone = false; + // Regex to match __sxResolve script tags + var RESOLVE_START = ""; + + function processResolveScripts() { + // Strip and load any extra component defs before resolve scripts + buffer = stripSxScripts(buffer); + var idx; + while ((idx = buffer.indexOf(RESOLVE_START)) >= 0) { + var endIdx = buffer.indexOf(RESOLVE_END, idx); + if (endIdx < 0) break; // incomplete, wait for more data + var argsStr = buffer.substring(idx + RESOLVE_START.length, endIdx); + buffer = buffer.substring(endIdx + RESOLVE_END.length); + // argsStr is: "stream-id","sx source" + var commaIdx = argsStr.indexOf(","); + if (commaIdx >= 0) { + try { + var id = JSON.parse(argsStr.substring(0, commaIdx)); + var sx = JSON.parse(argsStr.substring(commaIdx + 1)); + if (typeof Sx !== "undefined" && Sx.resolveSuspense) { + Sx.resolveSuspense(id, sx); + } + } catch (e) { + console.error("[sx-ref] resolve parse error:", e); + } + } + } + } + + function pump() { + return reader.read().then(function(result) { + buffer += decoder.decode(result.value || new Uint8Array(), { stream: !result.done }); + + if (!initialSwapDone) { + // Look for the first resolve script — everything before it is OOB content + var scriptIdx = buffer.indexOf(" (without data-components). + // These contain extra component defs from streaming resolve chunks. + var SxObj = typeof Sx !== "undefined" ? Sx : null; + return text.replace(/]*type="text\/sx"[^>]*>([\s\S]*?)<\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + function extractResponseCss(text) { if (!_hasDom) return text; var target = document.getElementById("sx-css"); @@ -2997,37 +3834,6 @@ callExpr.push(dictGet(kwargs, k)); } } } - // ========================================================================= - // Platform interface — CSSX (style dictionary) - // ========================================================================= - - function fnv1aHash(input) { - var h = 0x811c9dc5; - for (var i = 0; i < input.length; i++) { - h ^= input.charCodeAt(i); - h = (h * 0x01000193) >>> 0; - } - return h.toString(16).padStart(8, "0").substring(0, 6); - } - - function compileRegex(pattern) { - try { return new RegExp(pattern); } catch (e) { return null; } - } - - function regexMatch(re, s) { - if (!re) return NIL; - var m = s.match(re); - return m ? Array.prototype.slice.call(m) : NIL; - } - - function regexReplaceGroups(tmpl, match) { - var result = tmpl; - for (var j = 1; j < match.length; j++) { - result = result.split("{" + (j - 1) + "}").join(match[j]); - } - return result; - } - // ========================================================================= // Platform interface — Boot (mount, hydrate, scripts, cookies) // ========================================================================= @@ -3084,6 +3890,12 @@ callExpr.push(dictGet(kwargs, k)); } } r.querySelectorAll('script[type="text/sx"]')); } + function queryPageScripts() { + if (!_hasDom) return []; + return Array.prototype.slice.call( + document.querySelectorAll('script[type="text/sx-pages"]')); + } + // --- localStorage --- function localStorageGet(key) { @@ -3130,6 +3942,10 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof console !== "undefined") console.log("[sx-ref] " + msg); } + function logWarn(msg) { + if (typeof console !== "undefined") console.warn("[sx-ref] " + msg); + } + function logParseError(label, text, err) { if (typeof console === "undefined") return; var msg = err && err.message ? err.message : String(err); @@ -3153,6 +3969,8 @@ callExpr.push(dictGet(kwargs, k)); } } } } + + // ========================================================================= // Post-transpilation fixups // ========================================================================= @@ -3171,84 +3989,647 @@ callExpr.push(dictGet(kwargs, k)); } } if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom; // ========================================================================= - // Extension: Delimited continuations (shift/reset) + // Async IO: Promise-aware rendering for client-side IO primitives // ========================================================================= + // + // IO primitives (query, current-user, etc.) return Promises on the client. + // asyncRenderToDom walks the component tree; when it encounters an IO + // primitive, it awaits the Promise and continues rendering. + // + // The sync evaluator/renderer is untouched. This is a separate async path + // used only when a page's component tree contains IO references. - function Continuation(fn) { this.fn = fn; } - Continuation.prototype._continuation = true; - Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); }; + var IO_PRIMITIVES = {}; - function ShiftSignal(kName, body, env) { - this.kName = kName; - this.body = body; - this.env = env; + function registerIoPrimitive(name, fn) { + IO_PRIMITIVES[name] = fn; } - PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; }; + function isPromise(x) { + return x != null && typeof x === "object" && typeof x.then === "function"; + } - var _resetResume = []; + // Async trampoline: resolves thunks, awaits Promises + function asyncTrampoline(val) { + if (isPromise(val)) return val.then(asyncTrampoline); + if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val))); + return val; + } - function sfReset(args, env) { - var body = args[0]; - try { - return trampoline(evalExpr(body, env)); - } catch (e) { - if (e instanceof ShiftSignal) { - var sig = e; - var cont = new Continuation(function(value) { - if (value === undefined) value = NIL; - _resetResume.push(value); - try { - return trampoline(evalExpr(body, env)); - } finally { - _resetResume.pop(); - } - }); - var sigEnv = merge(sig.env); - sigEnv[sig.kName] = cont; - return trampoline(evalExpr(sig.body, sigEnv)); + // Async eval: like trampoline(evalExpr(...)) but handles IO primitives + function asyncEval(expr, env) { + // Intercept IO primitive calls at the AST level + if (Array.isArray(expr) && expr.length > 0) { + var head = expr[0]; + if (head && head._sym) { + var name = head.name; + if (IO_PRIMITIVES[name]) { + // Evaluate args, then call the IO primitive + return asyncEvalIoCall(name, expr.slice(1), env); + } } - throw e; } + // Non-IO: use sync eval, but result might be a thunk + var result = evalExpr(expr, env); + return asyncTrampoline(result); } - function sfShift(args, env) { - if (_resetResume.length > 0) { - return _resetResume[_resetResume.length - 1]; + function asyncEvalIoCall(name, rawArgs, env) { + // Parse keyword args and positional args, evaluating each (may be async) + var kwargs = {}; + var args = []; + var promises = []; + var i = 0; + while (i < rawArgs.length) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + var kName = arg.name; + var kVal = asyncEval(rawArgs[i + 1], env); + if (isPromise(kVal)) { + (function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i += 2; + } else { + var aVal = asyncEval(arg, env); + if (isPromise(aVal)) { + (function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length); + args.push(null); // placeholder + } else { + args.push(aVal); + } + i++; + } } - var kName = symbolName(args[0]); - var body = args[1]; - throw new ShiftSignal(kName, body, env); + var ioFn = IO_PRIMITIVES[name]; + if (promises.length > 0) { + return Promise.all(promises).then(function() { return ioFn(args, kwargs); }); + } + return ioFn(args, kwargs); } - // Wrap evalList to intercept reset/shift - var _baseEvalList = evalList; - evalList = function(expr, env) { + // Async render-to-dom: returns Promise or Node + function asyncRenderToDom(expr, env, ns) { + // Literals + if (expr === NIL || expr === null || expr === undefined) return null; + if (expr === true || expr === false) return null; + if (typeof expr === "string") return document.createTextNode(expr); + if (typeof expr === "number") return document.createTextNode(String(expr)); + + // Symbol -> async eval then render + if (expr && expr._sym) { + var val = asyncEval(expr, env); + if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(val, env, ns); + } + + // Keyword + if (expr && expr._kw) return document.createTextNode(expr.name); + + // DocumentFragment / DOM nodes pass through + if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr; + + // Dict -> skip + if (expr && typeof expr === "object" && !Array.isArray(expr)) return null; + + // List + if (!Array.isArray(expr) || expr.length === 0) return null; + var head = expr[0]; - if (isSym(head)) { - var name = head.name; - if (name === "reset") return sfReset(expr.slice(1), env); - if (name === "shift") return sfShift(expr.slice(1), env); - } - return _baseEvalList(expr, env); - }; + if (!head) return null; - // Wrap aserSpecial to handle reset/shift in SX wire mode - if (typeof aserSpecial === "function") { - var _baseAserSpecial = aserSpecial; - aserSpecial = function(name, expr, env) { - if (name === "reset") return sfReset(expr.slice(1), env); - if (name === "shift") return sfShift(expr.slice(1), env); - return _baseAserSpecial(name, expr, env); - }; + // Symbol head + if (head._sym) { + var hname = head.name; + + // IO primitive + if (IO_PRIMITIVES[hname]) { + var ioResult = asyncEval(expr, env); + if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(ioResult, env, ns); + } + + // Fragment + if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns); + + // raw! + if (hname === "raw!") { + return asyncEvalRaw(expr.slice(1), env); + } + + // Special forms that need async handling + if (hname === "if") return asyncRenderIf(expr, env, ns); + if (hname === "when") return asyncRenderWhen(expr, env, ns); + if (hname === "cond") return asyncRenderCond(expr, env, ns); + if (hname === "case") return asyncRenderCase(expr, env, ns); + if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns); + if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns); + if (hname === "map") return asyncRenderMap(expr, env, ns); + if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns); + if (hname === "for-each") return asyncRenderMap(expr, env, ns); + + // define/defcomp/defmacro — eval for side effects + if (hname === "define" || hname === "defcomp" || hname === "defmacro" || + hname === "defstyle" || hname === "defhandler") { + trampoline(evalExpr(expr, env)); + return null; + } + + // quote + if (hname === "quote") return null; + + // lambda/fn + if (hname === "lambda" || hname === "fn") { + trampoline(evalExpr(expr, env)); + return null; + } + + // and/or — eval and render result + if (hname === "and" || hname === "or" || hname === "->") { + var aoResult = asyncEval(expr, env); + if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(aoResult, env, ns); + } + + // set! + if (hname === "set!") { + asyncEval(expr, env); + return null; + } + + // Component + if (hname.charAt(0) === "~") { + var comp = env[hname]; + if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns); + if (comp && comp._macro) { + var expanded = trampoline(expandMacro(comp, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + } + + // Macro + if (env[hname] && env[hname]._macro) { + var mac = env[hname]; + var expanded = trampoline(expandMacro(mac, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + + // HTML tag + if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // html: prefix + if (hname.indexOf("html:") === 0) { + return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns); + } + + // Custom element + if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // SVG context + if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns); + + // Fallback: eval and render + var fResult = asyncEval(expr, env); + if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(fResult, env, ns); + } + + // Non-symbol head: eval call + var cResult = asyncEval(expr, env); + if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(cResult, env, ns); } - // Wrap typeOf to recognize continuations - var _baseTypeOf = typeOf; - typeOf = function(x) { - if (x != null && x._continuation) return "continuation"; - return _baseTypeOf(x); - }; + function asyncRenderChildren(exprs, env, ns) { + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < exprs.length; i++) { + var result = asyncRenderToDom(exprs[i], env, ns); + if (isPromise(result)) { + // Insert placeholder, replace when resolved + var placeholder = document.createComment("async"); + frag.appendChild(placeholder); + (function(ph) { + pending.push(result.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length > 0) { + return Promise.all(pending).then(function() { return frag; }); + } + return frag; + } + + function asyncRenderElement(tag, args, env, ns) { + var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns; + var el = domCreateElement(tag, newNs); + var pending = []; + var isVoid = contains(VOID_ELEMENTS, tag); + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var attrName = arg.name; + var attrVal = asyncEval(args[i + 1], env); + i++; + if (isPromise(attrVal)) { + (function(an, av) { + pending.push(av.then(function(v) { + if (!isNil(v) && v !== false) { + if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); } + else if (v === true) el.setAttribute(an, ""); + else el.setAttribute(an, String(v)); + } + })); + })(attrName, attrVal); + } else { + if (!isNil(attrVal) && attrVal !== false) { + if (contains(BOOLEAN_ATTRS, attrName)) { + if (isSxTruthy(attrVal)) el.setAttribute(attrName, ""); + } else if (attrVal === true) { + el.setAttribute(attrName, ""); + } else { + el.setAttribute(attrName, String(attrVal)); + } + } + } + } else if (!isVoid) { + var child = asyncRenderToDom(arg, env, newNs); + if (isPromise(child)) { + var placeholder = document.createComment("async"); + el.appendChild(placeholder); + (function(ph) { + pending.push(child.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (child) { + el.appendChild(child); + } + } + } + if (pending.length > 0) return Promise.all(pending).then(function() { return el; }); + return el; + } + + function asyncRenderComponent(comp, args, env, ns) { + var kwargs = {}; + var children = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var kName = arg.name; + var kVal = asyncEval(args[i + 1], env); + if (isPromise(kVal)) { + (function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i++; + } else { + children.push(arg); + } + } + + function doRender() { + var local = Object.create(componentClosure(comp)); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL; + } + if (componentHasChildren(comp)) { + var childResult = asyncRenderChildren(children, env, ns); + if (isPromise(childResult)) { + return childResult.then(function(childFrag) { + local["children"] = childFrag; + return asyncRenderToDom(componentBody(comp), local, ns); + }); + } + local["children"] = childResult; + } + return asyncRenderToDom(componentBody(comp), local, ns); + } + + if (pending.length > 0) return Promise.all(pending).then(doRender); + return doRender(); + } + + function asyncRenderIf(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + }); + } + return isSxTruthy(cond) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + } + + function asyncRenderWhen(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + }); + } + return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + } + + function asyncRenderCond(expr, env, ns) { + var clauses = expr.slice(1); + function step(idx) { + if (idx >= clauses.length) return null; + var clause = clauses[idx]; + if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1); + var test = clause[0]; + if ((test && test._sym && (test.name === "else" || test.name === ":else")) || + (test && test._kw && test.name === "else")) { + return asyncRenderToDom(clause[1], env, ns); + } + var v = asyncEval(test, env); + if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); }); + return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); + } + return step(0); + } + + function asyncRenderCase(expr, env, ns) { + var matchVal = asyncEval(expr[1], env); + function doCase(mv) { + var clauses = expr.slice(2); + for (var i = 0; i < clauses.length - 1; i += 2) { + var test = clauses[i]; + if ((test && test._kw && test.name === "else") || + (test && test._sym && (test.name === "else" || test.name === ":else"))) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + var tv = trampoline(evalExpr(test, env)); + if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + } + return null; + } + if (isPromise(matchVal)) return matchVal.then(doCase); + return doCase(matchVal); + } + + function asyncRenderLet(expr, env, ns) { + var bindings = expr[1]; + var local = Object.create(env); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + function bindStep(idx) { + if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns); + // Nested pairs: ((a 1) (b 2)) + if (bindings.length > 0 && Array.isArray(bindings[0])) { + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var b = bindings[idx]; + var vname = b[0]._sym ? b[0].name : String(b[0]); + var val = asyncEval(b[1], local); + if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); }); + local[vname] = val; + return bindStep(idx + 1); + } + // Flat pairs: (a 1 b 2) + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]); + var vv = asyncEval(bindings[idx + 1], local); + if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); }); + local[vn] = vv; + return bindStep(idx + 2); + } + return bindStep(0); + } + + function asyncRenderMap(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f.closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f.params[0]] = item; + result = asyncRenderToDom(f.body, lenv, null); + } else if (typeof f === "function") { + var r = f(item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncRenderMapIndexed(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f.closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f.params[0]] = i; + lenv[f.params[1]] = item; + result = asyncRenderToDom(f.body, lenv, null); + } else if (typeof f === "function") { + var r = f(i, item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncEvalRaw(args, env) { + var parts = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var val = asyncEval(args[i], env); + if (isPromise(val)) { + (function(idx) { + pending.push(val.then(function(v) { parts[idx] = v; })); + })(parts.length); + parts.push(null); + } else { + parts.push(val); + } + } + function assemble() { + var html = ""; + for (var j = 0; j < parts.length; j++) { + var p = parts[j]; + if (p && p._rawHtml) html += p.html; + else if (typeof p === "string") html += p; + else if (p != null && !isNil(p)) html += String(p); + } + var el = document.createElement("span"); + el.innerHTML = html; + var frag = document.createDocumentFragment(); + while (el.firstChild) frag.appendChild(el.firstChild); + return frag; + } + if (pending.length) return Promise.all(pending).then(assemble); + return assemble(); + } + + // Async version of sxRenderWithEnv — returns Promise + function asyncSxRenderWithEnv(source, extraEnv) { + var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + var exprs = parse(source); + if (!_hasDom) return Promise.resolve(null); + return asyncRenderChildren(exprs, env, null); + } + + // IO proxy cache: key → { value, expires } + var _ioCache = {}; + var IO_CACHE_TTL = 300000; // 5 minutes + + // Register a server-proxied IO primitive: fetches from /sx/io/ + // Uses GET for short args, POST for long payloads (URL length safety). + // Results are cached client-side by (name + args) with a TTL. + function registerProxiedIo(name) { + registerIoPrimitive(name, function(args, kwargs) { + // Cache key: name + serialized args + var cacheKey = name; + for (var ci = 0; ci < args.length; ci++) cacheKey += "" + String(args[ci]); + for (var ck in kwargs) { + if (kwargs.hasOwnProperty(ck)) cacheKey += "" + ck + "=" + String(kwargs[ck]); + } + var cached = _ioCache[cacheKey]; + if (cached && cached.expires > Date.now()) return cached.value; + + var url = "/sx/io/" + encodeURIComponent(name); + var qs = []; + for (var i = 0; i < args.length; i++) { + qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i]))); + } + for (var k in kwargs) { + if (kwargs.hasOwnProperty(k)) { + qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k]))); + } + } + var queryStr = qs.join("&"); + var fetchOpts; + if (queryStr.length > 1500) { + // POST with JSON body for long payloads + var sArgs = []; + for (var j = 0; j < args.length; j++) sArgs.push(String(args[j])); + var sKwargs = {}; + for (var kk in kwargs) { + if (kwargs.hasOwnProperty(kk)) sKwargs[kk] = String(kwargs[kk]); + } + var postHeaders = { "SX-Request": "true", "Content-Type": "application/json" }; + var csrf = csrfToken(); + if (csrf && csrf !== NIL) postHeaders["X-CSRFToken"] = csrf; + fetchOpts = { + method: "POST", + headers: postHeaders, + body: JSON.stringify({ args: sArgs, kwargs: sKwargs }) + }; + } else { + if (queryStr) url += "?" + queryStr; + fetchOpts = { headers: { "SX-Request": "true" } }; + } + var result = fetch(url, fetchOpts) + .then(function(resp) { + if (!resp.ok) { + logWarn("sx:io " + name + " failed " + resp.status); + return NIL; + } + return resp.text(); + }) + .then(function(text) { + if (!text || text === "nil") return NIL; + try { + var exprs = parse(text); + var val = exprs.length === 1 ? exprs[0] : exprs; + _ioCache[cacheKey] = { value: val, expires: Date.now() + IO_CACHE_TTL }; + return val; + } catch (e) { + logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); + return NIL; + } + }) + .catch(function(e) { + logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e)); + return NIL; + }); + // Cache the in-flight promise too (dedup concurrent calls for same args) + _ioCache[cacheKey] = { value: result, expires: Date.now() + IO_CACHE_TTL }; + return result; + }); + } + + // Register IO deps as proxied primitives (idempotent, called per-page) + function registerIoDeps(names) { + if (!names || !names.length) return; + var registered = 0; + for (var i = 0; i < names.length; i++) { + var name = names[i]; + if (!IO_PRIMITIVES[name]) { + registerProxiedIo(name); + registered++; + } + } + if (registered > 0) { + logInfo("sx:io registered " + registered + " proxied primitives: " + names.join(", ")); + } + } // Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes) @@ -3306,6 +4687,12 @@ callExpr.push(dictGet(kwargs, k)); } } renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); }, renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null, parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null, + parseTime: typeof parseTime === "function" ? parseTime : null, + defaultTrigger: typeof defaultTrigger === "function" ? defaultTrigger : null, + parseSwapSpec: typeof parseSwapSpec === "function" ? parseSwapSpec : null, + parseRetrySpec: typeof parseRetrySpec === "function" ? parseRetrySpec : null, + nextRetryMs: typeof nextRetryMs === "function" ? nextRetryMs : null, + filterParams: typeof filterParams === "function" ? filterParams : null, morphNode: typeof morphNode === "function" ? morphNode : null, morphChildren: typeof morphChildren === "function" ? morphChildren : null, swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, @@ -3318,17 +4705,25 @@ callExpr.push(dictGet(kwargs, k)); } } update: typeof sxUpdateElement === "function" ? sxUpdateElement : null, renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null, getEnv: function() { return componentEnv; }, + resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null, init: typeof bootInit === "function" ? bootInit : null, - scanRefs: scanRefs, - transitiveDeps: transitiveDeps, - computeAllDeps: computeAllDeps, - componentsNeeded: componentsNeeded, - pageComponentBundle: pageComponentBundle, - pageCssClasses: pageCssClasses, - scanIoRefs: scanIoRefs, - transitiveIoRefs: transitiveIoRefs, - computeAllIoRefs: computeAllIoRefs, - componentPure_p: componentPure_p, + splitPathSegments: splitPathSegments, + parseRoutePattern: parseRoutePattern, + matchRoute: matchRoute, + findMatchingRoute: findMatchingRoute, + registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null, + registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null, + asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null, + asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null, + signal: signal, + deref: deref, + reset: reset_b, + swap: swap_b, + computed: computed, + effect: effect, + batch: batch, + isSignal: isSignal, + makeSignal: makeSignal, _version: "ref-2.0 (boot+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" }; @@ -3342,7 +4737,26 @@ callExpr.push(dictGet(kwargs, k)); } } // --- Auto-init --- if (typeof document !== "undefined") { - var _sxInit = function() { bootInit(); }; + var _sxInit = function() { + bootInit(); + // Process any suspense resolutions that arrived before init + if (global.__sxPending) { + for (var pi = 0; pi < global.__sxPending.length; pi++) { + resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx); + } + global.__sxPending = null; + } + // Set up direct resolution for future chunks + global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); }; + // Register service worker for offline data caching + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sx-sw.js", { scope: "/" }).then(function(reg) { + logInfo("sx:sw registered (scope: " + reg.scope + ")"); + }).catch(function(err) { + logWarn("sx:sw registration failed: " + (err && err.message ? err.message : err)); + }); + } + }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", _sxInit); } else { diff --git a/shared/sx/evaluator.py b/shared/sx/evaluator.py index 022e4ef..683fb6f 100644 --- a/shared/sx/evaluator.py +++ b/shared/sx/evaluator.py @@ -33,7 +33,7 @@ from __future__ import annotations from typing import Any -from .types import Component, Continuation, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal +from .types import Component, Continuation, HandlerDef, Island, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal from .primitives import _PRIMITIVES @@ -147,13 +147,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any: fn = _trampoline(_eval(head, env)) args = [_trampoline(_eval(a, env)) for a in expr[1:]] - if callable(fn) and not isinstance(fn, (Lambda, Component)): + if callable(fn) and not isinstance(fn, (Lambda, Component, Island)): return fn(*args) if isinstance(fn, Lambda): return _call_lambda(fn, args, env) - if isinstance(fn, Component): + if isinstance(fn, (Component, Island)): return _call_component(fn, expr[1:], env) raise EvalError(f"Not callable: {fn!r}") @@ -555,6 +555,51 @@ def _sf_defcomp(expr: list, env: dict) -> Component: return comp +def _sf_defisland(expr: list, env: dict) -> Island: + """``(defisland ~name (&key ...) body)``""" + if len(expr) < 4: + raise EvalError("defisland requires name, params, and body") + name_sym = expr[1] + if not isinstance(name_sym, Symbol): + raise EvalError(f"defisland name must be symbol, got {type(name_sym).__name__}") + comp_name = name_sym.name.lstrip("~") + + params_expr = expr[2] + if not isinstance(params_expr, list): + raise EvalError("defisland params must be a list") + + params: list[str] = [] + has_children = False + in_key = False + for p in params_expr: + if isinstance(p, Symbol): + if p.name == "&key": + in_key = True + continue + if p.name == "&rest": + has_children = True + continue + if in_key or has_children: + if not has_children: + params.append(p.name) + else: + params.append(p.name) + elif isinstance(p, str): + params.append(p) + + body = expr[-1] + + island = Island( + name=comp_name, + params=params, + has_children=has_children, + body=body, + closure=dict(env), + ) + env[name_sym.name] = island + return island + + def _defcomp_kwarg(expr: list, key: str, default: str) -> str: """Extract a keyword annotation from defcomp, e.g. :affinity :client.""" # Scan from index 3 to second-to-last for :key value pairs @@ -592,7 +637,7 @@ def _sf_thread_first(expr: list, env: dict) -> Any: else: fn = _trampoline(_eval(form, env)) args = [result] - if callable(fn) and not isinstance(fn, (Lambda, Component)): + if callable(fn) and not isinstance(fn, (Lambda, Component, Island)): result = fn(*args) elif isinstance(fn, Lambda): result = _trampoline(_call_lambda(fn, args, env)) @@ -1021,6 +1066,7 @@ _SPECIAL_FORMS: dict[str, Any] = { "define": _sf_define, "defstyle": _sf_defstyle, "defcomp": _sf_defcomp, + "defisland": _sf_defisland, "defrelation": _sf_defrelation, "begin": _sf_begin, "do": _sf_begin, diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index f338a49..15b0e00 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -102,6 +102,12 @@ (contains? HTML_TAGS name) (render-dom-element name args env ns) + ;; Island (~name) — reactive component + (and (starts-with? name "~") + (env-has? env name) + (island? (env-get env name))) + (render-dom-island (env-get env name) args env ns) + ;; Component (~name) (starts-with? name "~") (let ((comp (env-get env name))) @@ -284,7 +290,7 @@ (define RENDER_DOM_FORMS (list "if" "when" "cond" "case" "let" "let*" "begin" "do" - "define" "defcomp" "defmacro" "defstyle" "defhandler" + "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "map" "map-indexed" "filter" "for-each")) (define render-dom-form? @@ -414,6 +420,153 @@ (render-to-dom (lambda-body f) local ns)))) +;; -------------------------------------------------------------------------- +;; render-dom-island — render a reactive island +;; -------------------------------------------------------------------------- +;; +;; Islands render like components but wrapped in a reactive context. +;; The island container element gets data-sx-island and data-sx-state +;; attributes for identification and hydration. +;; +;; Inside the island body, deref calls create reactive DOM subscriptions: +;; - Text bindings: (deref sig) in text position → reactive text node +;; - Attribute bindings: (deref sig) in attr → reactive attribute +;; - Conditional fragments: (when (deref sig) ...) → reactive show/hide + +(define render-dom-island + (fn (island args env ns) + ;; Parse kwargs and children (same as component) + (let ((kwargs (dict)) + (children (list))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((val (trampoline + (eval-expr (nth args (inc (get state "i"))) env)))) + (dict-set! kwargs (keyword-name arg) val) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + + ;; Build island env: closure + caller env + params + (let ((local (env-merge (component-closure island) env)) + (island-name (component-name island))) + + ;; Bind params from kwargs + (for-each + (fn (p) + (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params island)) + + ;; If island accepts children, pre-render them to a fragment + (when (component-has-children? island) + (let ((child-frag (create-fragment))) + (for-each + (fn (c) (dom-append child-frag (render-to-dom c env ns))) + children) + (env-set! local "children" child-frag))) + + ;; Create the island container element + (let ((container (dom-create-element "div" nil)) + (disposers (list))) + + ;; Mark as island + (dom-set-attr container "data-sx-island" island-name) + + ;; Render island body inside a scope that tracks disposers + (let ((body-dom + (with-island-scope + (fn (disposable) (append! disposers disposable)) + (fn () (render-to-dom (component-body island) local ns))))) + (dom-append container body-dom) + + ;; Store disposers on the container for cleanup + (dom-set-data container "sx-disposers" disposers) + + container)))))) + + +;; -------------------------------------------------------------------------- +;; Reactive DOM rendering helpers +;; -------------------------------------------------------------------------- +;; +;; These functions create reactive bindings between signals and DOM nodes. +;; They are called by the platform's renderDOM when it detects deref +;; calls inside an island context. + +;; reactive-text — create a text node bound to a signal +;; Used when (deref sig) appears in a text position inside an island. +(define reactive-text + (fn (sig) + (let ((node (create-text-node (str (deref sig))))) + (effect (fn () + (dom-set-text-content node (str (deref sig))))) + node))) + +;; reactive-attr — bind an element attribute to a signal expression +;; Used when an attribute value contains (deref sig) inside an island. +(define reactive-attr + (fn (el attr-name compute-fn) + (effect (fn () + (let ((val (compute-fn))) + (cond + (or (nil? val) (= val false)) + (dom-remove-attr el attr-name) + (= val true) + (dom-set-attr el attr-name "") + :else + (dom-set-attr el attr-name (str val)))))))) + +;; reactive-fragment — conditionally render a fragment based on a signal +;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island. +(define reactive-fragment + (fn (test-fn render-fn env ns) + (let ((marker (create-comment "island-fragment")) + (current-nodes (list))) + (effect (fn () + ;; Remove previous nodes + (for-each (fn (n) (dom-remove n)) current-nodes) + (set! current-nodes (list)) + ;; If test passes, render and insert after marker + (when (test-fn) + (let ((frag (render-fn))) + (set! current-nodes (dom-child-nodes frag)) + (dom-insert-after marker frag))))) + marker))) + +;; reactive-list — render a keyed list bound to a signal +;; Used for (map fn (deref items)) inside an island. +(define reactive-list + (fn (map-fn items-sig env ns) + (let ((container (create-fragment)) + (marker (create-comment "island-list"))) + (dom-append container marker) + (effect (fn () + ;; Simple strategy: clear and re-render + ;; Future: keyed reconciliation + (let ((parent (dom-parent marker))) + (when parent + ;; Remove all nodes after marker until next sibling marker + (dom-remove-children-after marker) + ;; Render new items + (let ((items (deref items-sig))) + (for-each + (fn (item) + (let ((rendered (if (lambda? map-fn) + (render-lambda-dom map-fn (list item) env ns) + (render-to-dom (apply map-fn (list item)) env ns)))) + (dom-insert-after marker rendered))) + (reverse items))))))) + container))) + + ;; -------------------------------------------------------------------------- ;; Platform interface — DOM adapter ;; -------------------------------------------------------------------------- @@ -422,11 +575,20 @@ ;; (dom-create-element tag ns) → Element (ns=nil for HTML, string for SVG/MathML) ;; (create-text-node s) → Text node ;; (create-fragment) → DocumentFragment +;; (create-comment s) → Comment node ;; ;; Tree mutation: ;; (dom-append parent child) → void (appendChild) ;; (dom-set-attr el name val) → void (setAttribute) +;; (dom-remove-attr el name) → void (removeAttribute) ;; (dom-get-attr el name) → string or nil (getAttribute) +;; (dom-set-text-content n s) → void (set textContent) +;; (dom-remove node) → void (remove from parent) +;; (dom-insert-after ref node) → void (insert node after ref) +;; (dom-parent node) → parent Element or nil +;; (dom-child-nodes frag) → list of child nodes +;; (dom-remove-children-after m)→ void (remove all siblings after marker) +;; (dom-set-data el key val) → void (store arbitrary data on element) ;; ;; Content parsing: ;; (dom-parse-html s) → DocumentFragment from HTML string @@ -441,11 +603,15 @@ ;; From eval.sx: ;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond ;; env-has?, env-get, env-set!, env-merge -;; lambda?, component?, macro? +;; lambda?, component?, island?, macro? ;; lambda-closure, lambda-params, lambda-body ;; component-params, component-body, component-closure, ;; component-has-children?, component-name ;; +;; From signals.sx: +;; signal, deref, reset!, swap!, computed, effect, batch +;; signal?, with-island-scope +;; ;; Iteration: ;; (for-each-indexed fn coll) → call fn(index, item) for each element ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/adapter-html.sx b/shared/sx/ref/adapter-html.sx index a910035..cfe34a7 100644 --- a/shared/sx/ref/adapter-html.sx +++ b/shared/sx/ref/adapter-html.sx @@ -8,7 +8,7 @@ ;; parse-element-args, render-attrs, definition-form? ;; eval.sx — eval-expr, trampoline, expand-macro, process-bindings, ;; eval-cond, env-has?, env-get, env-set!, env-merge, -;; lambda?, component?, macro?, +;; lambda?, component?, island?, macro?, ;; lambda-closure, lambda-params, lambda-body ;; ========================================================================== @@ -50,7 +50,7 @@ (define RENDER_HTML_FORMS (list "if" "when" "cond" "case" "let" "let*" "begin" "do" - "define" "defcomp" "defmacro" "defstyle" "defhandler" + "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "map" "map-indexed" "filter" "for-each")) (define render-html-form? @@ -85,6 +85,12 @@ (contains? HTML_TAGS name) (render-html-element name args env) + ;; Island (~name) — reactive component, SSR with hydration markers + (and (starts-with? name "~") + (env-has? env name) + (island? (env-get env name))) + (render-html-island (env-get env name) args env) + ;; Component or macro call (~name) (starts-with? name "~") (let ((val (env-get env name))) @@ -287,6 +293,85 @@ "")))))) +;; -------------------------------------------------------------------------- +;; render-html-island — SSR rendering of a reactive island +;; -------------------------------------------------------------------------- +;; +;; Renders the island body as static HTML wrapped in a container element +;; with data-sx-island and data-sx-state attributes. The client hydrates +;; this by finding these elements and re-rendering with reactive context. +;; +;; On the server, signal/deref/reset!/swap! are simple passthrough: +;; (signal val) → returns val (no container needed server-side) +;; (deref s) → returns s (signal values are plain values server-side) +;; (reset! s v) → no-op +;; (swap! s f) → no-op + +(define render-html-island + (fn (island args env) + ;; Parse kwargs and children (same pattern as render-html-component) + (let ((kwargs (dict)) + (children (list))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((val (trampoline + (eval-expr (nth args (inc (get state "i"))) env)))) + (dict-set! kwargs (keyword-name arg) val) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + + ;; Build island env: closure + caller env + params + (let ((local (env-merge (component-closure island) env)) + (island-name (component-name island))) + + ;; Bind params from kwargs + (for-each + (fn (p) + (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params island)) + + ;; If island accepts children, pre-render them to raw HTML + (when (component-has-children? island) + (env-set! local "children" + (make-raw-html + (join "" (map (fn (c) (render-to-html c env)) children))))) + + ;; Render the island body as HTML + (let ((body-html (render-to-html (component-body island) local)) + (state-json (serialize-island-state kwargs))) + ;; Wrap in container with hydration attributes + (str "
" + body-html + "
")))))) + + +;; -------------------------------------------------------------------------- +;; serialize-island-state — serialize kwargs to JSON for hydration +;; -------------------------------------------------------------------------- +;; +;; Only serializes simple values (numbers, strings, booleans, nil, lists, dicts). +;; Functions, components, and other non-serializable values are skipped. + +(define serialize-island-state + (fn (kwargs) + (if (empty-dict? kwargs) + nil + (json-serialize kwargs)))) + + ;; -------------------------------------------------------------------------- ;; Platform interface — HTML adapter ;; -------------------------------------------------------------------------- @@ -297,7 +382,7 @@ ;; From eval.sx: ;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond ;; env-has?, env-get, env-set!, env-merge -;; lambda?, component?, macro? +;; lambda?, component?, island?, macro? ;; lambda-closure, lambda-params, lambda-body ;; component-params, component-body, component-closure, ;; component-has-children?, component-name @@ -305,6 +390,11 @@ ;; Raw HTML construction: ;; (make-raw-html s) → wrap string as raw HTML (not double-escaped) ;; +;; JSON serialization (for island state): +;; (json-serialize dict) → JSON string +;; (empty-dict? d) → boolean +;; (escape-attr s) → HTML attribute escape +;; ;; Iteration: ;; (for-each-indexed fn coll) → call fn(index, item) for each element ;; (map-indexed fn coll) → map fn(index, item) over each element diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx index b84106c..1af36ac 100644 --- a/shared/sx/ref/adapter-sx.sx +++ b/shared/sx/ref/adapter-sx.sx @@ -83,12 +83,14 @@ (let ((f (trampoline (eval-expr head env))) (evaled-args (map (fn (a) (trampoline (eval-expr a env))) args))) (cond - (and (callable? f) (not (lambda? f)) (not (component? f))) + (and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f))) (apply f evaled-args) (lambda? f) (trampoline (call-lambda f evaled-args env)) (component? f) (aser-call (str "~" (component-name f)) args env) + (island? f) + (aser-call (str "~" (component-name f)) args env) :else (error (str "Not callable: " (inspect f))))))))))) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 4ba7dfe..ef6fe27 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -139,6 +139,32 @@ class JSEmitter: "callable?": "isCallable", "lambda?": "isLambda", "component?": "isComponent", + "island?": "isIsland", + "make-island": "makeIsland", + "make-signal": "makeSignal", + "signal?": "isSignal", + "signal-value": "signalValue", + "signal-set-value!": "signalSetValue", + "signal-subscribers": "signalSubscribers", + "signal-add-sub!": "signalAddSub", + "signal-remove-sub!": "signalRemoveSub", + "signal-deps": "signalDeps", + "signal-set-deps!": "signalSetDeps", + "set-tracking-context!": "setTrackingContext", + "get-tracking-context": "getTrackingContext", + "make-tracking-context": "makeTrackingContext", + "tracking-context-deps": "trackingContextDeps", + "tracking-context-add-dep!": "trackingContextAddDep", + "tracking-context-notify-fn": "trackingContextNotifyFn", + "identical?": "isIdentical", + "notify-subscribers": "notifySubscribers", + "flush-subscribers": "flushSubscribers", + "dispose-computed": "disposeComputed", + "with-island-scope": "withIslandScope", + "register-in-scope": "registerInScope", + "*batch-depth*": "_batchDepth", + "*batch-queue*": "_batchQueue", + "*island-scope*": "_islandScope", "macro?": "isMacro", "primitive?": "isPrimitive", "get-primitive": "getPrimitive", @@ -166,6 +192,10 @@ class JSEmitter: "render-list-to-html": "renderListToHtml", "render-html-element": "renderHtmlElement", "render-html-component": "renderHtmlComponent", + "render-html-island": "renderHtmlIsland", + "serialize-island-state": "serializeIslandState", + "json-serialize": "jsonSerialize", + "empty-dict?": "isEmptyDict", "parse-element-args": "parseElementArgs", "render-attrs": "renderAttrs", "aser-list": "aserList", @@ -191,6 +221,7 @@ class JSEmitter: "sf-lambda": "sfLambda", "sf-define": "sfDefine", "sf-defcomp": "sfDefcomp", + "sf-defisland": "sfDefisland", "defcomp-kwarg": "defcompKwarg", "sf-defmacro": "sfDefmacro", "sf-begin": "sfBegin", @@ -240,6 +271,11 @@ class JSEmitter: "render-dom-form?": "isRenderDomForm", "dispatch-render-form": "dispatchRenderForm", "render-lambda-dom": "renderLambdaDom", + "render-dom-island": "renderDomIsland", + "reactive-text": "reactiveText", + "reactive-attr": "reactiveAttr", + "reactive-fragment": "reactiveFragment", + "reactive-list": "reactiveList", "dom-create-element": "domCreateElement", "dom-append": "domAppend", "dom-set-attr": "domSetAttr", @@ -281,6 +317,11 @@ class JSEmitter: "dom-query": "domQuery", "dom-query-all": "domQueryAll", "dom-tag-name": "domTagName", + "create-comment": "createComment", + "dom-remove": "domRemove", + "dom-child-nodes": "domChildNodes", + "dom-remove-children-after": "domRemoveChildrenAfter", + "dom-set-data": "domSetData", "dict-has?": "dictHas", "dict-delete!": "dictDelete", "process-bindings": "processBindings", @@ -605,17 +646,39 @@ class JSEmitter: params = expr[1] body = expr[2:] param_names = [] - for p in params: + rest_name = None + i = 0 + while i < len(params): + p = params[i] + if isinstance(p, Symbol) and p.name == "&rest": + # Next param is the rest parameter + if i + 1 < len(params): + rest_name = self._mangle(params[i + 1].name if isinstance(params[i + 1], Symbol) else str(params[i + 1])) + i += 2 + continue + else: + i += 1 + continue if isinstance(p, Symbol): param_names.append(self._mangle(p.name)) else: param_names.append(str(p)) + i += 1 params_str = ", ".join(param_names) + # Build rest-param preamble if needed + rest_preamble = "" + if rest_name: + n = len(param_names) + rest_preamble = f"var {rest_name} = Array.prototype.slice.call(arguments, {n}); " if len(body) == 1: body_js = self.emit(body[0]) + if rest_preamble: + return f"function({params_str}) {{ {rest_preamble}return {body_js}; }}" return f"function({params_str}) {{ return {body_js}; }}" # Multi-expression body: statements then return last parts = [] + if rest_preamble: + parts.append(rest_preamble.rstrip()) for b in body[:-1]: parts.append(self.emit_statement(b)) parts.append(f"return {self.emit(body[-1])};") @@ -1045,6 +1108,7 @@ ADAPTER_DEPS = { SPEC_MODULES = { "deps": ("deps.sx", "deps (component dependency analysis)"), "router": ("router.sx", "router (client-side route matching)"), + "signals": ("signals.sx", "signals (reactive signal runtime)"), } @@ -1833,6 +1897,9 @@ def compile_ref_to_js( if sm not in SPEC_MODULES: raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}") spec_mod_set.add(sm) + # dom adapter uses signal runtime for reactive islands + if "dom" in adapter_set and "signals" in SPEC_MODULES: + spec_mod_set.add("signals") # boot.sx uses parse-route-pattern from router.sx if "boot" in adapter_set: spec_mod_set.add("router") @@ -1877,6 +1944,7 @@ def compile_ref_to_js( has_orch = "orchestration" in adapter_set has_boot = "boot" in adapter_set has_parser = "parser" in adapter_set + has_signals = "signals" in spec_mod_set adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only" # Determine which primitive modules to include @@ -1925,7 +1993,7 @@ def compile_ref_to_js( parts.append(CONTINUATIONS_JS) if has_dom: parts.append(ASYNC_IO_JS) - parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router)) + parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals)) parts.append(EPILOGUE) from datetime import datetime, timezone build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -1984,6 +2052,29 @@ PREAMBLE = '''\ } Component.prototype._component = true; + function Island(name, params, hasChildren, body, closure) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + } + Island.prototype._island = true; + + function SxSignal(value) { + this.value = value; + this.subscribers = []; + this.deps = []; + } + SxSignal.prototype._signal = true; + + function TrackingCtx(notifyFn) { + this.notifyFn = notifyFn; + this.deps = []; + } + + var _trackingContext = null; + function Macro(params, restParam, body, closure, name) { this.params = params; this.restParam = restParam; @@ -2240,6 +2331,8 @@ PLATFORM_JS_PRE = ''' if (x._thunk) return "thunk"; if (x._lambda) return "lambda"; if (x._component) return "component"; + if (x._island) return "island"; + if (x._signal) return "signal"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; if (typeof Node !== "undefined" && x instanceof Node) return "dom-node"; @@ -2287,7 +2380,41 @@ PLATFORM_JS_PRE = ''' function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); } function isLambda(x) { return x != null && x._lambda === true; } function isComponent(x) { return x != null && x._component === true; } + function isIsland(x) { return x != null && x._island === true; } function isMacro(x) { return x != null && x._macro === true; } + function isIdentical(a, b) { return a === b; } + + // Island platform + function makeIsland(name, params, hasChildren, body, env) { + return new Island(name, params, hasChildren, body, merge(env)); + } + + // Signal platform + function makeSignal(value) { return new SxSignal(value); } + function isSignal(x) { return x != null && x._signal === true; } + function signalValue(s) { return s.value; } + function signalSetValue(s, v) { s.value = v; } + function signalSubscribers(s) { return s.subscribers.slice(); } + function signalAddSub(s, fn) { if (s.subscribers.indexOf(fn) < 0) s.subscribers.push(fn); } + function signalRemoveSub(s, fn) { var i = s.subscribers.indexOf(fn); if (i >= 0) s.subscribers.splice(i, 1); } + function signalDeps(s) { return s.deps.slice(); } + function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? deps.slice() : []; } + function setTrackingContext(ctx) { _trackingContext = ctx; } + function getTrackingContext() { return _trackingContext || NIL; } + function makeTrackingContext(notifyFn) { return new TrackingCtx(notifyFn); } + function trackingContextDeps(ctx) { return ctx ? ctx.deps : []; } + function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); } + function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; } + + // JSON / dict helpers for island state serialization + function jsonSerialize(obj) { + try { return JSON.stringify(obj); } catch(e) { return "{}"; } + } + function isEmptyDict(d) { + if (!d || typeof d !== "object") return true; + for (var k in d) if (d.hasOwnProperty(k)) return false; + return true; + } function envHas(env, name) { return name in env; } function envGet(env, name) { return env[name]; } @@ -2616,6 +2743,10 @@ PLATFORM_DOM_JS = """ return _hasDom ? document.createTextNode(s) : null; } + function createComment(s) { + return _hasDom ? document.createComment(s || "") : null; + } + function createFragment() { return _hasDom ? document.createDocumentFragment() : null; } @@ -2750,6 +2881,23 @@ PLATFORM_DOM_JS = """ function domTagName(el) { return el && el.tagName ? el.tagName : ""; } + // Island DOM helpers + function domRemove(node) { + if (node && node.parentNode) node.parentNode.removeChild(node); + } + function domChildNodes(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + function domRemoveChildrenAfter(marker) { + if (!marker || !marker.parentNode) return; + var parent = marker.parentNode; + while (marker.nextSibling) parent.removeChild(marker.nextSibling); + } + function domSetData(el, key, val) { + if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; } + } + // ========================================================================= // Performance overrides — replace transpiled spec with imperative JS // ========================================================================= @@ -3868,7 +4016,7 @@ def fixups_js(has_html, has_sx, has_dom): return "\n".join(lines) -def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False): +def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False, has_signals=False): # Parser: use compiled sxParse from parser.sx, or inline a minimal fallback if has_parser: parser = ''' @@ -4020,6 +4168,16 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has api_lines.append(' registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,') api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,') api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,') + if has_signals: + api_lines.append(' signal: signal,') + api_lines.append(' deref: deref,') + api_lines.append(' reset: reset_b,') + api_lines.append(' swap: swap_b,') + api_lines.append(' computed: computed,') + api_lines.append(' effect: effect,') + api_lines.append(' batch: batch,') + api_lines.append(' isSignal: isSignal,') + api_lines.append(' makeSignal: makeSignal,') api_lines.append(f' _version: "{version}"') api_lines.append(' };') api_lines.append('') diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index e10eded..34924f7 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -148,6 +148,32 @@ class PyEmitter: "callable?": "is_callable", "lambda?": "is_lambda", "component?": "is_component", + "island?": "is_island", + "make-island": "make_island", + "make-signal": "make_signal", + "signal?": "is_signal", + "signal-value": "signal_value", + "signal-set-value!": "signal_set_value", + "signal-subscribers": "signal_subscribers", + "signal-add-sub!": "signal_add_sub", + "signal-remove-sub!": "signal_remove_sub", + "signal-deps": "signal_deps", + "signal-set-deps!": "signal_set_deps", + "set-tracking-context!": "set_tracking_context", + "get-tracking-context": "get_tracking_context", + "make-tracking-context": "make_tracking_context", + "tracking-context-deps": "tracking_context_deps", + "tracking-context-add-dep!": "tracking_context_add_dep", + "tracking-context-notify-fn": "tracking_context_notify_fn", + "identical?": "is_identical", + "notify-subscribers": "notify_subscribers", + "flush-subscribers": "flush_subscribers", + "dispose-computed": "dispose_computed", + "with-island-scope": "with_island_scope", + "register-in-scope": "register_in_scope", + "*batch-depth*": "_batch_depth", + "*batch-queue*": "_batch_queue", + "*island-scope*": "_island_scope", "macro?": "is_macro", "primitive?": "is_primitive", "get-primitive": "get_primitive", @@ -232,6 +258,11 @@ class PyEmitter: "dispatch-html-form": "dispatch_html_form", "render-lambda-html": "render_lambda_html", "make-raw-html": "make_raw_html", + "render-html-island": "render_html_island", + "serialize-island-state": "serialize_island_state", + "json-serialize": "json_serialize", + "empty-dict?": "is_empty_dict", + "sf-defisland": "sf_defisland", # adapter-sx.sx "render-to-sx": "render_to_sx", "aser": "aser", @@ -379,11 +410,26 @@ class PyEmitter: params = expr[1] body = expr[2:] param_names = [] - for p in params: + rest_name = None + i = 0 + while i < len(params): + p = params[i] + if isinstance(p, Symbol) and p.name == "&rest": + # Next param is the rest parameter + if i + 1 < len(params): + rest_name = self._mangle(params[i + 1].name if isinstance(params[i + 1], Symbol) else str(params[i + 1])) + i += 2 + continue + else: + i += 1 + continue if isinstance(p, Symbol): param_names.append(self._mangle(p.name)) else: param_names.append(str(p)) + i += 1 + if rest_name: + param_names.append(f"*{rest_name}") params_str = ", ".join(param_names) if len(body) == 1: body_py = self.emit(body[0]) @@ -867,6 +913,7 @@ SPEC_MODULES = { "deps": ("deps.sx", "deps (component dependency analysis)"), "router": ("router.sx", "router (client-side route matching)"), "engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"), + "signals": ("signals.sx", "signals (reactive signal runtime)"), } @@ -1005,6 +1052,9 @@ def compile_ref_to_py( raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}") spec_mod_set.add(sm) has_deps = "deps" in spec_mod_set + # html adapter uses signal runtime for server-side island rendering + if "html" in adapter_set and "signals" in SPEC_MODULES: + spec_mod_set.add("signals") # Core files always included, then selected adapters, then spec modules sx_files = [ @@ -1092,7 +1142,7 @@ from typing import Any # ========================================================================= from shared.sx.types import ( - NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro, + NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro, HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal, ) from shared.sx.parser import SxExpr @@ -1197,6 +1247,10 @@ def type_of(x): return "lambda" if isinstance(x, Component): return "component" + if isinstance(x, Island): + return "island" + if isinstance(x, _Signal): + return "signal" if isinstance(x, Macro): return "macro" if isinstance(x, _RawHTML): @@ -1235,6 +1289,11 @@ def make_component(name, params, has_children, body, env, affinity="auto"): body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto") +def make_island(name, params, has_children, body, env): + return Island(name=name, params=list(params), has_children=has_children, + body=body, closure=dict(env)) + + def make_macro(params, rest_param, body, env, name=None): return Macro(params=list(params), rest_param=rest_param, body=body, closure=dict(env), name=name) @@ -1369,6 +1428,119 @@ def is_macro(x): return isinstance(x, Macro) +def is_island(x): + return isinstance(x, Island) + + +def is_identical(a, b): + return a is b + + +# ------------------------------------------------------------------------- +# Signal platform -- reactive state primitives +# ------------------------------------------------------------------------- + +class _Signal: + """Reactive signal container.""" + __slots__ = ("value", "subscribers", "deps") + def __init__(self, value): + self.value = value + self.subscribers = [] + self.deps = [] + + +class _TrackingContext: + """Context for discovering signal dependencies.""" + __slots__ = ("notify_fn", "deps") + def __init__(self, notify_fn): + self.notify_fn = notify_fn + self.deps = [] + + +_tracking_context = None + + +def make_signal(value): + return _Signal(value) + + +def is_signal(x): + return isinstance(x, _Signal) + + +def signal_value(s): + return s.value if isinstance(s, _Signal) else s + + +def signal_set_value(s, v): + if isinstance(s, _Signal): + s.value = v + + +def signal_subscribers(s): + return list(s.subscribers) if isinstance(s, _Signal) else [] + + +def signal_add_sub(s, fn): + if isinstance(s, _Signal) and fn not in s.subscribers: + s.subscribers.append(fn) + + +def signal_remove_sub(s, fn): + if isinstance(s, _Signal) and fn in s.subscribers: + s.subscribers.remove(fn) + + +def signal_deps(s): + return list(s.deps) if isinstance(s, _Signal) else [] + + +def signal_set_deps(s, deps): + if isinstance(s, _Signal): + s.deps = list(deps) if isinstance(deps, list) else [] + + +def set_tracking_context(ctx): + global _tracking_context + _tracking_context = ctx + + +def get_tracking_context(): + global _tracking_context + return _tracking_context if _tracking_context is not None else NIL + + +def make_tracking_context(notify_fn): + return _TrackingContext(notify_fn) + + +def tracking_context_deps(ctx): + return ctx.deps if isinstance(ctx, _TrackingContext) else [] + + +def tracking_context_add_dep(ctx, s): + if isinstance(ctx, _TrackingContext) and s not in ctx.deps: + ctx.deps.append(s) + + +def tracking_context_notify_fn(ctx): + return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL + + +def json_serialize(obj): + import json + try: + return json.dumps(obj) + except (TypeError, ValueError): + return "{}" + + +def is_empty_dict(d): + if not isinstance(d, dict): + return True + return len(d) == 0 + + def env_has(env, name): return name in env diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index fc8df3c..32c2d08 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -124,3 +124,55 @@ (define-boundary-types (list "number" "string" "boolean" "nil" "keyword" "list" "dict" "sx-source")) + + +;; -------------------------------------------------------------------------- +;; Tier 3: Signal primitives — reactive state for islands +;; +;; These are pure primitives (no IO) but are separated from primitives.sx +;; because they introduce a new type (signal) and depend on signals.sx. +;; -------------------------------------------------------------------------- + +(declare-tier :signals :source "signals.sx") + +(declare-signal-primitive "signal" + :params (initial-value) + :returns "signal" + :doc "Create a reactive signal container with an initial value.") + +(declare-signal-primitive "deref" + :params (signal) + :returns "any" + :doc "Read a signal's current value. In a reactive context (inside an island), + subscribes the current DOM binding to the signal. Outside reactive + context, just returns the value.") + +(declare-signal-primitive "reset!" + :params (signal value) + :returns "nil" + :doc "Set a signal to a new value. Notifies all subscribers.") + +(declare-signal-primitive "swap!" + :params (signal f &rest args) + :returns "nil" + :doc "Update a signal by applying f to its current value. (swap! s inc) + is equivalent to (reset! s (inc (deref s))) but atomic.") + +(declare-signal-primitive "computed" + :params (compute-fn) + :returns "signal" + :doc "Create a derived signal that recomputes when its dependencies change. + Dependencies are discovered automatically by tracking deref calls.") + +(declare-signal-primitive "effect" + :params (effect-fn) + :returns "lambda" + :doc "Run a side effect that re-runs when its signal dependencies change. + Returns a dispose function. If the effect function returns a function, + it is called as cleanup before the next run.") + +(declare-signal-primitive "batch" + :params (thunk) + :returns "any" + :doc "Group multiple signal writes. Subscribers are notified once at the end, + after all values have been updated.") diff --git a/shared/sx/ref/demo-signals.html b/shared/sx/ref/demo-signals.html new file mode 100644 index 0000000..3695762 --- /dev/null +++ b/shared/sx/ref/demo-signals.html @@ -0,0 +1,182 @@ + + + + + SX Reactive Islands Demo + + + +

SX Reactive Islands

+

Signals transpiled from signals.sx spec via bootstrap_js.py

+ + +
+

1. Signal: Counter

+
+ + 0 + +
+
+

signal + computed + effect

+
+ + +
+

2. Batch: Two signals, one notification

+
+ first: 0 + second: 0 + +
+
+ + +
+

batch coalesces writes: 2 updates, 1 re-render

+
+ + +
+

3. Effect: Auto-tracking + Cleanup

+
+ + +
+
+

effect returns cleanup fn; dispose stops tracking

+
+ + +
+

4. Computed chain: base → doubled → quadrupled

+
+ + base: 1 + +
+
+ doubled:   + quadrupled: +
+

Three-level computed dependency graph, auto-propagation

+
+ + + + + diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index 560b104..1479dd1 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -35,12 +35,14 @@ ;; lambda — closure: {params, body, closure-env, name?} ;; macro — AST transformer: {params, rest-param, body, closure-env} ;; component — UI component: {name, params, has-children, body, closure-env} +;; island — reactive component: like component but with island flag ;; thunk — deferred eval for TCO: {expr, env} ;; ;; Each target must provide: ;; (type-of x) → one of the strings above ;; (make-lambda ...) → platform Lambda value ;; (make-component ..) → platform Component value +;; (make-island ...) → platform Island value (component + island flag) ;; (make-macro ...) → platform Macro value ;; (make-thunk ...) → platform Thunk value ;; @@ -141,6 +143,7 @@ (= name "fn") (sf-lambda args env) (= name "define") (sf-define args env) (= name "defcomp") (sf-defcomp args env) + (= name "defisland") (sf-defisland args env) (= name "defmacro") (sf-defmacro args env) (= name "defstyle") (sf-defstyle args env) (= name "defhandler") (sf-defhandler args env) @@ -192,7 +195,7 @@ (evaluated-args (map (fn (a) (trampoline (eval-expr a env))) args))) (cond ;; Native callable (primitive function) - (and (callable? f) (not (lambda? f)) (not (component? f))) + (and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f))) (apply f evaluated-args) ;; Lambda @@ -203,6 +206,10 @@ (component? f) (call-component f args env) + ;; Island (reactive component) — same calling convention + (island? f) + (call-component f args env) + :else (error (str "Not callable: " (inspect f))))))) @@ -543,6 +550,24 @@ (list params has-children)))) +(define sf-defisland + (fn (args env) + ;; (defisland ~name (params) body) + ;; Like defcomp but creates an island (reactive component). + ;; Islands have the same calling convention as components but + ;; render with a reactive context on the client. + (let ((name-sym (first args)) + (params-raw (nth args 1)) + (body (last args)) + (comp-name (strip-prefix (symbol-name name-sym) "~")) + (parsed (parse-comp-params params-raw)) + (params (first parsed)) + (has-children (nth parsed 1))) + (let ((island (make-island comp-name params has-children body env))) + (env-set! env (symbol-name name-sym) island) + island)))) + + (define sf-defmacro (fn (args env) (let ((name-sym (first args)) @@ -903,6 +928,11 @@ ;; (component-closure c) → env ;; (component-has-children? c) → boolean ;; (component-affinity c) → "auto" | "client" | "server" +;; +;; (make-island name params has-children body env) → Island +;; (island? x) → boolean +;; ;; Islands reuse component accessors: component-params, component-body, etc. +;; ;; (macro-params m) → list of strings ;; (macro-rest-param m) → string or nil ;; (macro-body m) → expr @@ -915,6 +945,7 @@ ;; (callable? x) → boolean (native function or lambda) ;; (lambda? x) → boolean ;; (component? x) → boolean +;; (island? x) → boolean ;; (macro? x) → boolean ;; (primitive? name) → boolean (is name a registered primitive?) ;; (get-primitive name) → function diff --git a/shared/sx/ref/render.sx b/shared/sx/ref/render.sx index 83436ad..ec69479 100644 --- a/shared/sx/ref/render.sx +++ b/shared/sx/ref/render.sx @@ -73,8 +73,8 @@ (define definition-form? (fn (name) - (or (= name "define") (= name "defcomp") (= name "defmacro") - (= name "defstyle") (= name "defhandler")))) + (or (= name "define") (= name "defcomp") (= name "defisland") + (= name "defmacro") (= name "defstyle") (= name "defhandler")))) (define parse-element-args diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx new file mode 100644 index 0000000..4d6d1c1 --- /dev/null +++ b/shared/sx/ref/signals.sx @@ -0,0 +1,290 @@ +;; ========================================================================== +;; signals.sx — Reactive signal runtime specification +;; +;; Defines the signal primitive: a container for a value that notifies +;; subscribers when it changes. Signals are the reactive state primitive +;; for SX islands. +;; +;; Signals are pure computation — no DOM, no IO. The reactive rendering +;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server +;; adapter (adapter-html.sx) reads signal values without subscribing. +;; +;; Platform interface required: +;; (make-signal value) → Signal — create signal container +;; (signal? x) → boolean — type predicate +;; (signal-value s) → any — read current value (no tracking) +;; (signal-set-value! s v) → void — write value (no notification) +;; (signal-subscribers s) → list — list of subscriber fns +;; (signal-add-sub! s fn) → void — add subscriber +;; (signal-remove-sub! s fn) → void — remove subscriber +;; (signal-deps s) → list — dependency list (for computed) +;; (signal-set-deps! s deps) → void — set dependency list +;; +;; Global state required: +;; *tracking-context* → nil | Effect/Computed currently evaluating +;; (set-tracking-context! c) → void +;; (get-tracking-context) → context or nil +;; +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; 1. signal — create a reactive container +;; -------------------------------------------------------------------------- + +(define signal + (fn (initial-value) + (make-signal initial-value))) + + +;; -------------------------------------------------------------------------- +;; 2. deref — read signal value, subscribe current reactive context +;; -------------------------------------------------------------------------- +;; +;; In a reactive context (inside effect or computed), deref registers the +;; signal as a dependency. Outside reactive context, deref just returns +;; the current value — no subscription, no overhead. + +(define deref + (fn (s) + (if (not (signal? s)) + s ;; non-signal values pass through + (let ((ctx (get-tracking-context))) + (when ctx + ;; Register this signal as a dependency of the current context + (tracking-context-add-dep! ctx s) + ;; Subscribe the context to this signal + (signal-add-sub! s (tracking-context-notify-fn ctx))) + (signal-value s))))) + + +;; -------------------------------------------------------------------------- +;; 3. reset! — write a new value, notify subscribers +;; -------------------------------------------------------------------------- + +(define reset! + (fn (s value) + (when (signal? s) + (let ((old (signal-value s))) + (when (not (identical? old value)) + (signal-set-value! s value) + (notify-subscribers s)))))) + + +;; -------------------------------------------------------------------------- +;; 4. swap! — update signal via function +;; -------------------------------------------------------------------------- + +(define swap! + (fn (s f &rest args) + (when (signal? s) + (let ((old (signal-value s)) + (new-val (apply f (cons old args)))) + (when (not (identical? old new-val)) + (signal-set-value! s new-val) + (notify-subscribers s)))))) + + +;; -------------------------------------------------------------------------- +;; 5. computed — derived signal with automatic dependency tracking +;; -------------------------------------------------------------------------- +;; +;; A computed signal wraps a zero-arg function. It re-evaluates when any +;; of its dependencies change. The dependency set is discovered automatically +;; by tracking deref calls during evaluation. + +(define computed + (fn (compute-fn) + (let ((s (make-signal nil)) + (deps (list)) + (compute-ctx nil)) + + ;; The notify function — called when a dependency changes + (let ((recompute + (fn () + ;; Unsubscribe from old deps + (for-each + (fn (dep) (signal-remove-sub! dep recompute)) + (signal-deps s)) + (signal-set-deps! s (list)) + + ;; Create tracking context for this computed + (let ((ctx (make-tracking-context recompute))) + (let ((prev (get-tracking-context))) + (set-tracking-context! ctx) + (let ((new-val (compute-fn))) + (set-tracking-context! prev) + ;; Save discovered deps + (signal-set-deps! s (tracking-context-deps ctx)) + ;; Update value + notify downstream + (let ((old (signal-value s))) + (signal-set-value! s new-val) + (when (not (identical? old new-val)) + (notify-subscribers s))))))))) + + ;; Initial computation + (recompute) + s)))) + + +;; -------------------------------------------------------------------------- +;; 6. effect — side effect that runs when dependencies change +;; -------------------------------------------------------------------------- +;; +;; Like computed, but doesn't produce a signal value. Returns a dispose +;; function that tears down the effect. + +(define effect + (fn (effect-fn) + (let ((deps (list)) + (disposed false) + (cleanup-fn nil)) + + (let ((run-effect + (fn () + (when (not disposed) + ;; Run previous cleanup if any + (when cleanup-fn (cleanup-fn)) + + ;; Unsubscribe from old deps + (for-each + (fn (dep) (signal-remove-sub! dep run-effect)) + deps) + (set! deps (list)) + + ;; Track new deps + (let ((ctx (make-tracking-context run-effect))) + (let ((prev (get-tracking-context))) + (set-tracking-context! ctx) + (let ((result (effect-fn))) + (set-tracking-context! prev) + (set! deps (tracking-context-deps ctx)) + ;; If effect returns a function, it's the cleanup + (when (callable? result) + (set! cleanup-fn result))))))))) + + ;; Initial run + (run-effect) + + ;; Return dispose function + (fn () + (set! disposed true) + (when cleanup-fn (cleanup-fn)) + (for-each + (fn (dep) (signal-remove-sub! dep run-effect)) + deps) + (set! deps (list))))))) + + +;; -------------------------------------------------------------------------- +;; 7. batch — group multiple signal writes into one notification pass +;; -------------------------------------------------------------------------- +;; +;; During a batch, signal writes are deferred. Subscribers are notified +;; once at the end, after all values have been updated. + +(define *batch-depth* 0) +(define *batch-queue* (list)) + +(define batch + (fn (thunk) + (set! *batch-depth* (+ *batch-depth* 1)) + (thunk) + (set! *batch-depth* (- *batch-depth* 1)) + (when (= *batch-depth* 0) + (let ((queue *batch-queue*)) + (set! *batch-queue* (list)) + ;; Collect unique subscribers across all queued signals, + ;; then notify each exactly once. + (let ((seen (list)) + (pending (list))) + (for-each + (fn (s) + (for-each + (fn (sub) + (when (not (contains? seen sub)) + (append! seen sub) + (append! pending sub))) + (signal-subscribers s))) + queue) + (for-each (fn (sub) (sub)) pending)))))) + + +;; -------------------------------------------------------------------------- +;; 8. notify-subscribers — internal notification dispatch +;; -------------------------------------------------------------------------- +;; +;; If inside a batch, queues the signal. Otherwise, notifies immediately. + +(define notify-subscribers + (fn (s) + (if (> *batch-depth* 0) + (when (not (contains? *batch-queue* s)) + (append! *batch-queue* s)) + (flush-subscribers s)))) + +(define flush-subscribers + (fn (s) + (for-each + (fn (sub) (sub)) + (signal-subscribers s)))) + + +;; -------------------------------------------------------------------------- +;; 9. Tracking context +;; -------------------------------------------------------------------------- +;; +;; A tracking context is an ephemeral object created during effect/computed +;; evaluation to discover signal dependencies. Platform must provide: +;; +;; (make-tracking-context notify-fn) → context +;; (tracking-context-deps ctx) → list of signals +;; (tracking-context-add-dep! ctx s) → void (adds s to ctx's dep list) +;; (tracking-context-notify-fn ctx) → the notify function +;; +;; These are platform primitives because the context is mutable state +;; that must be efficient (often a Set in the host language). + + +;; -------------------------------------------------------------------------- +;; 10. dispose — tear down a computed signal +;; -------------------------------------------------------------------------- +;; +;; For computed signals, unsubscribe from all dependencies. +;; For effects, the dispose function is returned by effect itself. + +(define dispose-computed + (fn (s) + (when (signal? s) + (for-each + (fn (dep) (signal-remove-sub! dep nil)) + (signal-deps s)) + (signal-set-deps! s (list))))) + + +;; -------------------------------------------------------------------------- +;; 11. Island scope — automatic cleanup of signals within an island +;; -------------------------------------------------------------------------- +;; +;; When an island is created, all signals, effects, and computeds created +;; within it are tracked. When the island is removed from the DOM, they +;; are all disposed. + +(define *island-scope* nil) + +(define with-island-scope + (fn (scope-fn body-fn) + (let ((prev *island-scope*)) + (set! *island-scope* scope-fn) + (let ((result (body-fn))) + (set! *island-scope* prev) + result)))) + +;; Hook into signal/effect/computed creation for scope tracking. +;; The platform's make-signal should call (register-in-scope s) if +;; *island-scope* is non-nil. + +(define register-in-scope + (fn (disposable) + (when *island-scope* + (*island-scope* disposable)))) diff --git a/shared/sx/ref/special-forms.sx b/shared/sx/ref/special-forms.sx index 90e6909..05bede7 100644 --- a/shared/sx/ref/special-forms.sx +++ b/shared/sx/ref/special-forms.sx @@ -182,6 +182,23 @@ (when subtitle (p subtitle)) children))") +(define-special-form "defisland" + :syntax (defisland ~name (&key param1 param2 &rest children) body) + :doc "Define a reactive island. Islands have the same calling convention + as components (defcomp) but create a reactive boundary. Inside an + island, signals are tracked — deref subscribes DOM nodes to signals, + and signal changes update only the affected nodes. + + On the server, islands render as static HTML wrapped in a + data-sx-island container with serialized initial state. On the + client, islands hydrate into reactive contexts." + :tail-position "body" + :example "(defisland ~counter (&key initial) + (let ((count (signal (or initial 0)))) + (div :class \"counter\" + (span (deref count)) + (button :on-click (fn (e) (swap! count inc)) \"+\"))))") + (define-special-form "defmacro" :syntax (defmacro name (params ...) body) :doc "Define a macro. Macros receive their arguments unevaluated (as raw diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 3bddab6..3f3e719 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -18,7 +18,7 @@ from typing import Any # ========================================================================= from shared.sx.types import ( - NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro, + NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro, HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal, ) from shared.sx.parser import SxExpr @@ -122,6 +122,10 @@ def type_of(x): return "lambda" if isinstance(x, Component): return "component" + if isinstance(x, Island): + return "island" + if isinstance(x, _Signal): + return "signal" if isinstance(x, Macro): return "macro" if isinstance(x, _RawHTML): @@ -160,6 +164,11 @@ def make_component(name, params, has_children, body, env, affinity="auto"): body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto") +def make_island(name, params, has_children, body, env): + return Island(name=name, params=list(params), has_children=has_children, + body=body, closure=dict(env)) + + def make_macro(params, rest_param, body, env, name=None): return Macro(params=list(params), rest_param=rest_param, body=body, closure=dict(env), name=name) @@ -294,6 +303,119 @@ def is_macro(x): return isinstance(x, Macro) +def is_island(x): + return isinstance(x, Island) + + +def is_identical(a, b): + return a is b + + +# ------------------------------------------------------------------------- +# Signal platform -- reactive state primitives +# ------------------------------------------------------------------------- + +class _Signal: + """Reactive signal container.""" + __slots__ = ("value", "subscribers", "deps") + def __init__(self, value): + self.value = value + self.subscribers = [] + self.deps = [] + + +class _TrackingContext: + """Context for discovering signal dependencies.""" + __slots__ = ("notify_fn", "deps") + def __init__(self, notify_fn): + self.notify_fn = notify_fn + self.deps = [] + + +_tracking_context = None + + +def make_signal(value): + return _Signal(value) + + +def is_signal(x): + return isinstance(x, _Signal) + + +def signal_value(s): + return s.value if isinstance(s, _Signal) else s + + +def signal_set_value(s, v): + if isinstance(s, _Signal): + s.value = v + + +def signal_subscribers(s): + return list(s.subscribers) if isinstance(s, _Signal) else [] + + +def signal_add_sub(s, fn): + if isinstance(s, _Signal) and fn not in s.subscribers: + s.subscribers.append(fn) + + +def signal_remove_sub(s, fn): + if isinstance(s, _Signal) and fn in s.subscribers: + s.subscribers.remove(fn) + + +def signal_deps(s): + return list(s.deps) if isinstance(s, _Signal) else [] + + +def signal_set_deps(s, deps): + if isinstance(s, _Signal): + s.deps = list(deps) if isinstance(deps, list) else [] + + +def set_tracking_context(ctx): + global _tracking_context + _tracking_context = ctx + + +def get_tracking_context(): + global _tracking_context + return _tracking_context if _tracking_context is not None else NIL + + +def make_tracking_context(notify_fn): + return _TrackingContext(notify_fn) + + +def tracking_context_deps(ctx): + return ctx.deps if isinstance(ctx, _TrackingContext) else [] + + +def tracking_context_add_dep(ctx, s): + if isinstance(ctx, _TrackingContext) and s not in ctx.deps: + ctx.deps.append(s) + + +def tracking_context_notify_fn(ctx): + return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL + + +def json_serialize(obj): + import json + try: + return json.dumps(obj) + except (TypeError, ValueError): + return "{}" + + +def is_empty_dict(d): + if not isinstance(d, dict): + return True + return len(d) == 0 + + def env_has(env, name): return name in env @@ -890,54 +1012,6 @@ has_key_p = PRIMITIVES["has-key?"] dissoc = PRIMITIVES["dissoc"] -# ========================================================================= -# Platform: deps module — component dependency analysis -# ========================================================================= - -import re as _re - -def component_deps(c): - """Return cached deps list for a component (may be empty).""" - return list(c.deps) if hasattr(c, "deps") and c.deps else [] - -def component_set_deps(c, deps): - """Cache deps on a component.""" - c.deps = set(deps) if not isinstance(deps, set) else deps - -def component_css_classes(c): - """Return pre-scanned CSS class list for a component.""" - return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else [] - -def env_components(env): - """Return list of component/macro names in an environment.""" - return [k for k, v in env.items() - if isinstance(v, (Component, Macro))] - -def regex_find_all(pattern, source): - """Return list of capture group 1 matches.""" - return [m.group(1) for m in _re.finditer(pattern, source)] - -def scan_css_classes(source): - """Extract CSS class strings from SX source.""" - classes = set() - for m in _re.finditer(r':class\s+"([^"]*)"', source): - classes.update(m.group(1).split()) - for m in _re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source): - for s in _re.findall(r'"([^"]*)"', m.group(1)): - classes.update(s.split()) - for m in _re.finditer(r';;\s*@css\s+(.+)', source): - classes.update(m.group(1).split()) - return list(classes) - -def component_io_refs(c): - """Return cached IO refs list for a component (may be empty).""" - return list(c.io_refs) if hasattr(c, "io_refs") and c.io_refs else [] - -def component_set_io_refs(c, refs): - """Cache IO refs on a component.""" - c.io_refs = set(refs) if not isinstance(refs, set) else refs - - # === Transpiled from eval === # trampoline @@ -947,10 +1021,10 @@ trampoline = lambda val: (lambda result: (trampoline(eval_expr(thunk_expr(result eval_expr = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('dict', lambda: map_dict(lambda k, v: trampoline(eval_expr(v, env)), expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else eval_list(expr, env))), (None, lambda: expr)]) # eval-list -eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_letrec(args, env) if sx_truthy((name == 'letrec')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (sf_dynamic_wind(args, env) if sx_truthy((name == 'dynamic-wind')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env)))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr)) +eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_letrec(args, env) if sx_truthy((name == 'letrec')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defisland(args, env) if sx_truthy((name == 'defisland')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (sf_dynamic_wind(args, env) if sx_truthy((name == 'dynamic-wind')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env))))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr)) # eval-call -eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env))) +eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else (call_component(f, args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env))) # call-lambda call_lambda = lambda f, args, caller_env: (lambda params: (lambda local: (error(sx_str((lambda_name(f) if sx_truthy(lambda_name(f)) else 'lambda'), ' expects ', len(params), ' args, got ', len(args))) if sx_truthy((len(args) != len(params))) else _sx_begin(for_each(lambda pair: _sx_dict_set(local, first(pair), nth(pair, 1)), zip(params, args)), make_thunk(lambda_body(f), local))))(env_merge(lambda_closure(f), caller_env)))(lambda_params(f)) @@ -1040,6 +1114,9 @@ def parse_comp_params(params_expr): params.append(name) return [params, _cells['has_children']] +# sf-defisland +sf_defisland = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda comp_name: (lambda parsed: (lambda params: (lambda has_children: (lambda island: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), island), island))(make_island(comp_name, params, has_children, body, env)))(nth(parsed, 1)))(first(parsed)))(parse_comp_params(params_raw)))(strip_prefix(symbol_name(name_sym), '~')))(last(args)))(nth(args, 1)))(first(args)) + # sf-defmacro sf_defmacro = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda parsed: (lambda params: (lambda rest_param: (lambda mac: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), mac), mac))(make_macro(params, rest_param, body, env, symbol_name(name_sym))))(nth(parsed, 1)))(first(parsed)))(parse_macro_params(params_raw)))(nth(args, 2)))(nth(args, 1)))(first(args)) @@ -1164,7 +1241,7 @@ VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'li BOOLEAN_ATTRS = ['async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert', 'ismap', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected'] # definition-form? -is_definition_form = lambda name: ((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else (name == 'defhandler'))))) +is_definition_form = lambda name: ((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defisland') if sx_truthy((name == 'defisland')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else (name == 'defhandler')))))) # parse-element-args parse_element_args = lambda args, env: (lambda attrs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(attrs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), [attrs, children]))([]))({}) @@ -1194,13 +1271,13 @@ render_to_html = lambda expr, env: _sx_case(type_of(expr), [('nil', lambda: ''), render_value_to_html = lambda val, env: _sx_case(type_of(val), [('nil', lambda: ''), ('string', lambda: escape_html(val)), ('number', lambda: sx_str(val)), ('boolean', lambda: ('true' if sx_truthy(val) else 'false')), ('list', lambda: render_list_to_html(val, env)), ('raw-html', lambda: raw_html_content(val)), (None, lambda: escape_html(sx_str(val)))]) # RENDER_HTML_FORMS -RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'map', 'map-indexed', 'filter', 'for-each'] +RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'map', 'map-indexed', 'filter', 'for-each'] # render-html-form? is_render_html_form = lambda name: contains_p(RENDER_HTML_FORMS, name) # render-list-to-html -render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env))))))))(rest(expr)))(symbol_name(head))))(first(expr))) +render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (render_html_island(env_get(env, name), args, env) if sx_truthy((starts_with_p(name, '~') if not sx_truthy(starts_with_p(name, '~')) else (env_has(env, name) if not sx_truthy(env_has(env, name)) else is_island(env_get(env, name))))) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env)))))))))(rest(expr)))(symbol_name(head))))(first(expr))) # dispatch-html-form dispatch_html_form = lambda name, expr, env: ((lambda cond_val: (render_to_html(nth(expr, 2), env) if sx_truthy(cond_val) else (render_to_html(nth(expr, 3), env) if sx_truthy((len(expr) > 3)) else '')))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'if')) else (('' if sx_truthy((not sx_truthy(trampoline(eval_expr(nth(expr, 1), env))))) else join('', map(lambda i: render_to_html(nth(expr, i), env), range(2, len(expr))))) if sx_truthy((name == 'when')) else ((lambda branch: (render_to_html(branch, env) if sx_truthy(branch) else ''))(eval_cond(rest(expr), env)) if sx_truthy((name == 'cond')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'case')) else ((lambda local: join('', map(lambda i: render_to_html(nth(expr, i), local), range(2, len(expr)))))(process_bindings(nth(expr, 1), env)) if sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))) else (join('', map(lambda i: render_to_html(nth(expr, i), env), range(1, len(expr)))) if sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))) else (_sx_begin(trampoline(eval_expr(expr, env)), '') if sx_truthy(is_definition_form(name)) else ((lambda f: (lambda coll: join('', map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map')) else ((lambda f: (lambda coll: join('', map_indexed(lambda i, item: (render_lambda_html(f, [i, item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [i, item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map-indexed')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'filter')) else ((lambda f: (lambda coll: join('', map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'for-each')) else render_value_to_html(trampoline(eval_expr(expr, env)), env)))))))))))) @@ -1214,6 +1291,12 @@ render_html_component = lambda comp, args, env: (lambda kwargs: (lambda children # render-html-element render_html_element = lambda tag, args, env: (lambda parsed: (lambda attrs: (lambda children: (lambda is_void: sx_str('<', tag, render_attrs(attrs), (' />' if sx_truthy(is_void) else sx_str('>', join('', map(lambda c: render_to_html(c, env), children)), ''))))(contains_p(VOID_ELEMENTS, tag)))(nth(parsed, 1)))(first(parsed)))(parse_element_args(args, env)) +# render-html-island +render_html_island = lambda island, args, env: (lambda kwargs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(kwargs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), (lambda local: (lambda island_name: _sx_begin(for_each(lambda p: _sx_dict_set(local, p, (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)), component_params(island)), (_sx_dict_set(local, 'children', make_raw_html(join('', map(lambda c: render_to_html(c, env), children)))) if sx_truthy(component_has_children(island)) else NIL), (lambda body_html: (lambda state_json: sx_str('
', body_html, '
'))(serialize_island_state(kwargs)))(render_to_html(component_body(island), local))))(component_name(island)))(env_merge(component_closure(island), env))))([]))({}) + +# serialize-island-state +serialize_island_state = lambda kwargs: (NIL if sx_truthy(is_empty_dict(kwargs)) else json_serialize(kwargs)) + # === Transpiled from adapter-sx === @@ -1224,7 +1307,7 @@ render_to_sx = lambda expr, env: (lambda result: (result if sx_truthy((type_of(r aser = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else aser_list(expr, env))), (None, lambda: expr)]) # aser-list -aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr)) +aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr)) # aser-fragment aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(parts)) else sx_str('(<> ', join(' ', map(serialize, parts)), ')')))(filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda c: aser(c, env), children))) @@ -1233,61 +1316,77 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name]) -# === Transpiled from deps (component dependency analysis) === +# === Transpiled from signals (reactive signal runtime) === -# scan-refs -scan_refs = lambda node: (lambda refs: _sx_begin(scan_refs_walk(node, refs), refs))([]) +# signal +signal = lambda initial_value: make_signal(initial_value) -# scan-refs-walk -scan_refs_walk = lambda node, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(starts_with_p(name, '~')) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_refs_walk(item, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_refs_walk(dict_get(node, key), refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL))) +# deref +deref = lambda s: (s if sx_truthy((not sx_truthy(is_signal(s)))) else (lambda ctx: _sx_begin((_sx_begin(tracking_context_add_dep(ctx, s), signal_add_sub(s, tracking_context_notify_fn(ctx))) if sx_truthy(ctx) else NIL), signal_value(s)))(get_tracking_context())) -# transitive-deps-walk -transitive_deps_walk = lambda n, seen, env: (_sx_begin(_sx_append(seen, n), (lambda val: (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(component_body(val))) if sx_truthy((type_of(val) == 'component')) else (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(macro_body(val))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL) +# reset! +reset_b = lambda s, value: ((lambda old: (_sx_begin(signal_set_value(s, value), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, value)))) else NIL))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL) -# transitive-deps -transitive_deps = lambda name, env: (lambda seen: (lambda key: _sx_begin(transitive_deps_walk(key, seen, env), filter(lambda x: (not sx_truthy((x == key))), seen)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([]) +# swap! +swap_b = lambda s, f, *args: ((lambda old: (lambda new_val: (_sx_begin(signal_set_value(s, new_val), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL))(apply(f, cons(old, args))))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL) -# compute-all-deps -compute_all_deps = lambda env: for_each(lambda name: (lambda val: (component_set_deps(val, transitive_deps(name, env)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env)) +# computed +computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), s))(_sx_fn(lambda : ( + for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)), + signal_set_deps(s, []), + (lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda new_val: _sx_begin(set_tracking_context(prev), signal_set_deps(s, tracking_context_deps(ctx)), (lambda old: _sx_begin(signal_set_value(s, new_val), (notify_subscribers(s) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL)))(signal_value(s))))(compute_fn())))(get_tracking_context()))(make_tracking_context(recompute)) +)[-1])))(NIL))([]))(make_signal(NIL)) -# scan-components-from-source -scan_components_from_source = lambda source: (lambda matches: map(lambda m: sx_str('~', m), matches))(regex_find_all('\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)', source)) +# effect +def effect(effect_fn): + _cells = {} + _cells['deps'] = [] + _cells['disposed'] = False + _cells['cleanup_fn'] = NIL + run_effect = lambda : (_sx_begin((cleanup_fn() if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []), (lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda result: _sx_begin(set_tracking_context(prev), _sx_cell_set(_cells, 'deps', tracking_context_deps(ctx)), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(effect_fn())))(get_tracking_context()))(make_tracking_context(run_effect))) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL) + run_effect() + return _sx_fn(lambda : ( + _sx_cell_set(_cells, 'disposed', True), + (cleanup_fn() if sx_truthy(_cells['cleanup_fn']) else NIL), + for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), + _sx_cell_set(_cells, 'deps', []) +)[-1]) -# components-needed -components_needed = lambda page_source, env: (lambda direct: (lambda all_needed: _sx_begin(for_each(_sx_fn(lambda name: ( - (_sx_append(all_needed, name) if sx_truthy((not sx_truthy(contains_p(all_needed, name)))) else NIL), - (lambda val: (lambda deps: for_each(lambda dep: (_sx_append(all_needed, dep) if sx_truthy((not sx_truthy(contains_p(all_needed, dep)))) else NIL), deps))((component_deps(val) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else (not sx_truthy(empty_p(component_deps(val)))))) else transitive_deps(name, env))))(env_get(env, name)) -)[-1]), direct), all_needed))([]))(scan_components_from_source(page_source)) +# *batch-depth* +_batch_depth = 0 -# page-component-bundle -page_component_bundle = lambda page_source, env: components_needed(page_source, env) +# *batch-queue* +_batch_queue = [] -# page-css-classes -page_css_classes = lambda page_source, env: (lambda needed: (lambda classes: _sx_begin(for_each(lambda name: (lambda val: (for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), component_css_classes(val)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), needed), for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), scan_css_classes(page_source)), classes))([]))(components_needed(page_source, env)) +# batch +def batch(thunk): + _batch_depth = (_batch_depth + 1) + thunk() + _batch_depth = (_batch_depth - 1) + return ((lambda queue: _sx_begin(_sx_cell_set(_cells, '_batch_queue', []), (lambda seen: (lambda pending: _sx_begin(for_each(lambda s: for_each(lambda sub: (_sx_begin(_sx_append(seen, sub), _sx_append(pending, sub)) if sx_truthy((not sx_truthy(contains_p(seen, sub)))) else NIL), signal_subscribers(s)), queue), for_each(lambda sub: sub(), pending)))([]))([])))(_batch_queue) if sx_truthy((_batch_depth == 0)) else NIL) -# scan-io-refs-walk -scan_io_refs_walk = lambda node, io_names, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(contains_p(io_names, name)) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_io_refs_walk(item, io_names, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_io_refs_walk(dict_get(node, key), io_names, refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL))) +# notify-subscribers +notify_subscribers = lambda s: ((_sx_append(_batch_queue, s) if sx_truthy((not sx_truthy(contains_p(_batch_queue, s)))) else NIL) if sx_truthy((_batch_depth > 0)) else flush_subscribers(s)) -# scan-io-refs -scan_io_refs = lambda node, io_names: (lambda refs: _sx_begin(scan_io_refs_walk(node, io_names, refs), refs))([]) +# flush-subscribers +flush_subscribers = lambda s: for_each(lambda sub: sub(), signal_subscribers(s)) -# transitive-io-refs-walk -transitive_io_refs_walk = lambda n, seen, all_refs, env, io_names: (_sx_begin(_sx_append(seen, n), (lambda val: (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(component_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(component_body(val)))) if sx_truthy((type_of(val) == 'component')) else (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(macro_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(macro_body(val)))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL) +# dispose-computed +dispose_computed = lambda s: (_sx_begin(for_each(lambda dep: signal_remove_sub(dep, NIL), signal_deps(s)), signal_set_deps(s, [])) if sx_truthy(is_signal(s)) else NIL) -# transitive-io-refs -transitive_io_refs = lambda name, env, io_names: (lambda all_refs: (lambda seen: (lambda key: _sx_begin(transitive_io_refs_walk(key, seen, all_refs, env, io_names), all_refs))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([]))([]) +# *island-scope* +_island_scope = NIL -# compute-all-io-refs -compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (component_set_io_refs(val, transitive_io_refs(name, env, io_names)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env)) +# with-island-scope +def with_island_scope(scope_fn, body_fn): + prev = _island_scope + _island_scope = scope_fn + result = body_fn() + _island_scope = prev + return result -# component-pure? -component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names)) - -# render-target -render_target = lambda name, env, io_names: (lambda key: (lambda val: ('server' if sx_truthy((not sx_truthy((type_of(val) == 'component')))) else (lambda affinity: ('server' if sx_truthy((affinity == 'server')) else ('client' if sx_truthy((affinity == 'client')) else ('server' if sx_truthy((not sx_truthy(component_pure_p(name, env, io_names)))) else 'client'))))(component_affinity(val))))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))) - -# page-render-plan -page_render_plan = lambda page_source, env, io_names: (lambda needed: (lambda comp_targets: (lambda server_list: (lambda client_list: (lambda io_deps: _sx_begin(for_each(lambda name: (lambda target: _sx_begin(_sx_dict_set(comp_targets, name, target), (_sx_begin(_sx_append(server_list, name), for_each(lambda io_ref: (_sx_append(io_deps, io_ref) if sx_truthy((not sx_truthy(contains_p(io_deps, io_ref)))) else NIL), transitive_io_refs(name, env, io_names))) if sx_truthy((target == 'server')) else _sx_append(client_list, name))))(render_target(name, env, io_names)), needed), {'components': comp_targets, 'server': server_list, 'client': client_list, 'io-deps': io_deps}))([]))([]))([]))({}))(components_needed(page_source, env)) +# register-in-scope +register_in_scope = lambda disposable: (_island_scope(disposable) if sx_truthy(_island_scope) else NIL) # ========================================================================= diff --git a/shared/sx/ref/test-signals.sx b/shared/sx/ref/test-signals.sx new file mode 100644 index 0000000..3ae5695 --- /dev/null +++ b/shared/sx/ref/test-signals.sx @@ -0,0 +1,173 @@ +;; ========================================================================== +;; test-signals.sx — Tests for signals and reactive islands +;; +;; Requires: test-framework.sx loaded first. +;; Modules tested: signals.sx, eval.sx (defisland) +;; +;; Note: Multi-expression lambda bodies are wrapped in (do ...) for +;; compatibility with the hand-written evaluator which only supports +;; single-expression lambda bodies. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Signal creation and basic read/write +;; -------------------------------------------------------------------------- + +(defsuite "signal basics" + (deftest "signal creates a reactive container" + (let ((s (signal 42))) + (assert-true (signal? s)) + (assert-equal 42 (deref s)))) + + (deftest "deref on non-signal passes through" + (assert-equal 5 (deref 5)) + (assert-equal "hello" (deref "hello")) + (assert-nil (deref nil))) + + (deftest "reset! changes value" + (let ((s (signal 0))) + (reset! s 10) + (assert-equal 10 (deref s)))) + + (deftest "reset! does not notify when value unchanged" + (let ((s (signal 5)) + (count (signal 0))) + (effect (fn () (do (deref s) (swap! count inc)))) + ;; Effect runs once on creation → count=1 + (let ((c1 (deref count))) + (reset! s 5) ;; same value — no notification + (assert-equal c1 (deref count))))) + + (deftest "swap! applies function to current value" + (let ((s (signal 10))) + (swap! s inc) + (assert-equal 11 (deref s)))) + + (deftest "swap! passes extra args" + (let ((s (signal 10))) + (swap! s + 5) + (assert-equal 15 (deref s))))) + + +;; -------------------------------------------------------------------------- +;; Computed signals +;; -------------------------------------------------------------------------- + +(defsuite "computed" + (deftest "computed derives initial value" + (let ((a (signal 3)) + (b (signal 4)) + (sum (computed (fn () (+ (deref a) (deref b)))))) + (assert-equal 7 (deref sum)))) + + (deftest "computed updates when dependency changes" + (let ((a (signal 2)) + (doubled (computed (fn () (* 2 (deref a)))))) + (assert-equal 4 (deref doubled)) + (reset! a 5) + (assert-equal 10 (deref doubled)))) + + (deftest "computed chains" + (let ((base (signal 1)) + (doubled (computed (fn () (* 2 (deref base))))) + (quadrupled (computed (fn () (* 2 (deref doubled)))))) + (assert-equal 4 (deref quadrupled)) + (reset! base 3) + (assert-equal 12 (deref quadrupled))))) + + +;; -------------------------------------------------------------------------- +;; Effects +;; -------------------------------------------------------------------------- + +(defsuite "effects" + (deftest "effect runs immediately" + (let ((ran (signal false))) + (effect (fn () (reset! ran true))) + (assert-true (deref ran)))) + + (deftest "effect re-runs when dependency changes" + (let ((source (signal "a")) + (log (signal (list)))) + (effect (fn () + (swap! log (fn (l) (append l (deref source)))))) + ;; Initial run logs "a" + (assert-equal (list "a") (deref log)) + ;; Change triggers re-run + (reset! source "b") + (assert-equal (list "a" "b") (deref log)))) + + (deftest "effect dispose stops tracking" + (let ((source (signal 0)) + (count (signal 0))) + (let ((dispose (effect (fn () (do + (deref source) + (swap! count inc)))))) + ;; Effect ran once + (assert-equal 1 (deref count)) + ;; Trigger + (reset! source 1) + (assert-equal 2 (deref count)) + ;; Dispose + (dispose) + ;; Should NOT trigger + (reset! source 2) + (assert-equal 2 (deref count))))) + + (deftest "effect cleanup runs before re-run" + (let ((source (signal 0)) + (cleanups (signal 0))) + (effect (fn () (do + (deref source) + (fn () (swap! cleanups inc))))) ;; return cleanup fn + ;; No cleanup yet (first run) + (assert-equal 0 (deref cleanups)) + ;; Change triggers cleanup of previous run + (reset! source 1) + (assert-equal 1 (deref cleanups))))) + + +;; -------------------------------------------------------------------------- +;; Batch +;; -------------------------------------------------------------------------- + +(defsuite "batch" + (deftest "batch defers notifications" + (let ((a (signal 0)) + (b (signal 0)) + (run-count (signal 0))) + (effect (fn () (do + (deref a) (deref b) + (swap! run-count inc)))) + ;; Initial run + (assert-equal 1 (deref run-count)) + ;; Without batch: 2 writes → 2 effect runs + ;; With batch: 2 writes → 1 effect run + (batch (fn () (do + (reset! a 1) + (reset! b 2)))) + ;; Should be 2 (initial + 1 batched), not 3 + (assert-equal 2 (deref run-count))))) + + +;; -------------------------------------------------------------------------- +;; defisland +;; -------------------------------------------------------------------------- + +(defsuite "defisland" + (deftest "defisland creates an island" + (defisland ~test-island (&key value) + (list "island" value)) + (assert-true (island? ~test-island))) + + (deftest "island is callable like component" + (defisland ~greeting (&key name) + (str "Hello, " name "!")) + (assert-equal "Hello, World!" (~greeting :name "World"))) + + (deftest "island accepts children" + (defisland ~wrapper (&rest children) + (list "wrap" children)) + (assert-equal (list "wrap" (list "a" "b")) + (~wrapper "a" "b")))) diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index 37b5983..fd88522 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -21,7 +21,7 @@ sys.path.insert(0, _PROJECT) from shared.sx.parser import parse_all from shared.sx.evaluator import _eval, _trampoline, _call_lambda -from shared.sx.types import Symbol, Keyword, Lambda, NIL +from shared.sx.types import Symbol, Keyword, Lambda, NIL, Component, Island # --- Test state --- suite_stack: list[str] = [] @@ -134,16 +134,134 @@ def render_html(sx_source): return result +# --- Signal platform primitives --- +# Implements the signal runtime platform interface for testing signals.sx + +class Signal: + """A reactive signal container.""" + __slots__ = ("value", "subscribers", "deps") + + def __init__(self, value): + self.value = value + self.subscribers = [] # list of callables + self.deps = [] # list of Signal (for computed) + + +class TrackingContext: + """Tracks signal dependencies during effect/computed evaluation.""" + __slots__ = ("notify_fn", "deps") + + def __init__(self, notify_fn): + self.notify_fn = notify_fn + self.deps = [] + + +_tracking_context = [None] # mutable cell + + +def _make_signal(value): + s = Signal(value) + return s + + +def _signal_p(x): + return isinstance(x, Signal) + + +def _signal_value(s): + return s.value + + +def _signal_set_value(s, v): + s.value = v + return NIL + + +def _signal_subscribers(s): + return list(s.subscribers) + + +def _signal_add_sub(s, fn): + if fn not in s.subscribers: + s.subscribers.append(fn) + return NIL + + +def _signal_remove_sub(s, fn): + if fn in s.subscribers: + s.subscribers.remove(fn) + return NIL + + +def _signal_deps(s): + return list(s.deps) + + +def _signal_set_deps(s, deps): + s.deps = list(deps) + return NIL + + +def _set_tracking_context(ctx): + _tracking_context[0] = ctx + return NIL + + +def _get_tracking_context(): + return _tracking_context[0] or NIL + + +def _make_tracking_context(notify_fn): + return TrackingContext(notify_fn) + + +def _tracking_context_deps(ctx): + if isinstance(ctx, TrackingContext): + return ctx.deps + return [] + + +def _tracking_context_add_dep(ctx, s): + if isinstance(ctx, TrackingContext) and s not in ctx.deps: + ctx.deps.append(s) + return NIL + + +def _tracking_context_notify_fn(ctx): + if isinstance(ctx, TrackingContext): + return ctx.notify_fn + return NIL + + +def _identical(a, b): + return a is b + + +def _island_p(x): + return isinstance(x, Island) + + +def _make_island(name, params, has_children, body, closure): + return Island( + name=name, + params=list(params), + has_children=has_children, + body=body, + closure=dict(closure) if isinstance(closure, dict) else {}, + ) + + # --- Spec registry --- SPECS = { - "eval": {"file": "test-eval.sx", "needs": []}, - "parser": {"file": "test-parser.sx", "needs": ["sx-parse"]}, - "router": {"file": "test-router.sx", "needs": []}, - "render": {"file": "test-render.sx", "needs": ["render-html"]}, - "deps": {"file": "test-deps.sx", "needs": []}, - "engine": {"file": "test-engine.sx", "needs": []}, + "eval": {"file": "test-eval.sx", "needs": []}, + "parser": {"file": "test-parser.sx", "needs": ["sx-parse"]}, + "router": {"file": "test-router.sx", "needs": []}, + "render": {"file": "test-render.sx", "needs": ["render-html"]}, + "deps": {"file": "test-deps.sx", "needs": []}, + "engine": {"file": "test-engine.sx", "needs": []}, "orchestration": {"file": "test-orchestration.sx", "needs": []}, + "signals": {"file": "test-signals.sx", "needs": ["make-signal"]}, } REF_DIR = os.path.join(_HERE, "..", "ref") @@ -187,6 +305,31 @@ env = { "inc": lambda n: n + 1, # Component accessor for affinity (Phase 7) "component-affinity": lambda c: getattr(c, 'affinity', 'auto'), + # Signal platform primitives + "make-signal": _make_signal, + "signal?": _signal_p, + "signal-value": _signal_value, + "signal-set-value!": _signal_set_value, + "signal-subscribers": _signal_subscribers, + "signal-add-sub!": _signal_add_sub, + "signal-remove-sub!": _signal_remove_sub, + "signal-deps": _signal_deps, + "signal-set-deps!": _signal_set_deps, + "set-tracking-context!": _set_tracking_context, + "get-tracking-context": _get_tracking_context, + "make-tracking-context": _make_tracking_context, + "tracking-context-deps": _tracking_context_deps, + "tracking-context-add-dep!": _tracking_context_add_dep, + "tracking-context-notify-fn": _tracking_context_notify_fn, + "identical?": _identical, + # Island platform primitives + "island?": _island_p, + "make-island": _make_island, + "component-name": lambda c: getattr(c, 'name', ''), + "component-params": lambda c: list(getattr(c, 'params', [])), + "component-body": lambda c: getattr(c, 'body', NIL), + "component-closure": lambda c: dict(getattr(c, 'closure', {})), + "component-has-children?": lambda c: getattr(c, 'has_children', False), } @@ -412,6 +555,171 @@ def _load_forms_from_bootstrap(env): eval_file("forms.sx", env) +def _load_signals(env): + """Load signals.sx spec — defines signal, deref, reset!, swap!, etc. + + The hand-written evaluator doesn't support &rest in define/fn, + so we override swap! with a native implementation after loading. + """ + # callable? is needed by effect (to check if return value is cleanup fn) + env["callable?"] = lambda x: callable(x) or isinstance(x, Lambda) + eval_file("signals.sx", env) + + # Override signal functions that need to call Lambda subscribers. + # The hand-written evaluator's Lambda objects can't be called directly + # from Python — they need _call_lambda. So we provide native versions + # of functions that bridge native→Lambda calls. + + def _call_sx_fn(fn, args): + """Call an SX function (Lambda or native) from Python.""" + if isinstance(fn, Lambda): + return _trampoline(_call_lambda(fn, list(args), env)) + if callable(fn): + return fn(*args) + return NIL + + def _flush_subscribers(s): + for sub in list(s.subscribers): + _call_sx_fn(sub, []) + return NIL + + def _notify_subscribers(s): + batch_depth = env.get("*batch-depth*", 0) + if batch_depth and batch_depth > 0: + batch_queue = env.get("*batch-queue*", []) + if s not in batch_queue: + batch_queue.append(s) + return NIL + _flush_subscribers(s) + return NIL + env["notify-subscribers"] = _notify_subscribers + env["flush-subscribers"] = _flush_subscribers + + def _reset_bang(s, value): + if not isinstance(s, Signal): + return NIL + old = s.value + if old is not value: + s.value = value + _notify_subscribers(s) + return NIL + env["reset!"] = _reset_bang + + def _swap_bang(s, f, *args): + if not isinstance(s, Signal): + return NIL + old = s.value + all_args = [old] + list(args) + new_val = _call_sx_fn(f, all_args) + if old is not new_val: + s.value = new_val + _notify_subscribers(s) + return NIL + env["swap!"] = _swap_bang + + def _computed(compute_fn): + s = Signal(NIL) + + def recompute(): + # Unsubscribe from old deps + for dep in s.deps: + if recompute in dep.subscribers: + dep.subscribers.remove(recompute) + s.deps = [] + + # Create tracking context + ctx = TrackingContext(recompute) + prev = _tracking_context[0] + _tracking_context[0] = ctx + + new_val = _call_sx_fn(compute_fn, []) + + _tracking_context[0] = prev + s.deps = list(ctx.deps) + + old = s.value + s.value = new_val + if old is not new_val: + _flush_subscribers(s) + + recompute() + return s + env["computed"] = _computed + + def _effect(effect_fn): + deps = [] + disposed = [False] + cleanup_fn = [None] + + def run_effect(): + if disposed[0]: + return NIL + # Run previous cleanup + if cleanup_fn[0]: + _call_sx_fn(cleanup_fn[0], []) + cleanup_fn[0] = None + + # Unsubscribe from old deps + for dep in deps: + if run_effect in dep.subscribers: + dep.subscribers.remove(run_effect) + deps.clear() + + # Track new deps + ctx = TrackingContext(run_effect) + prev = _tracking_context[0] + _tracking_context[0] = ctx + + result = _call_sx_fn(effect_fn, []) + + _tracking_context[0] = prev + deps.extend(ctx.deps) + + # If effect returns a callable, it's cleanup + if callable(result) or isinstance(result, Lambda): + cleanup_fn[0] = result + + return NIL + + run_effect() + + def dispose(): + disposed[0] = True + if cleanup_fn[0]: + _call_sx_fn(cleanup_fn[0], []) + for dep in deps: + if run_effect in dep.subscribers: + dep.subscribers.remove(run_effect) + deps.clear() + return NIL + + return dispose + env["effect"] = _effect + + def _batch(thunk): + depth = env.get("*batch-depth*", 0) + env["*batch-depth*"] = depth + 1 + _call_sx_fn(thunk, []) + env["*batch-depth*"] = env["*batch-depth*"] - 1 + if env["*batch-depth*"] == 0: + queue = env.get("*batch-queue*", []) + env["*batch-queue*"] = [] + # Collect unique subscribers across all queued signals + seen = set() + pending = [] + for s in queue: + for sub in s.subscribers: + sub_id = id(sub) + if sub_id not in seen: + seen.add(sub_id) + pending.append(sub) + # Notify each unique subscriber exactly once + for sub in pending: + _call_sx_fn(sub, []) + return NIL + env["batch"] = _batch + + def main(): global passed, failed, test_num @@ -457,6 +765,8 @@ def main(): _load_engine_from_bootstrap(env) if spec_name == "orchestration": _load_orchestration(env) + if spec_name == "signals": + _load_signals(env) print(f"# --- {spec_name} ---") eval_file(spec["file"], env) diff --git a/shared/sx/types.py b/shared/sx/types.py index f923e32..2b8d53d 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -189,6 +189,31 @@ class Component: return f"" +# --------------------------------------------------------------------------- +# Island +# --------------------------------------------------------------------------- + +@dataclass +class Island: + """A reactive UI component defined via ``(defisland ~name (&key ...) body)``. + + Islands are like components but create a reactive boundary. Inside an + island, signals are tracked — deref subscribes DOM nodes to signals. + On the server, islands render as static HTML with hydration attributes. + """ + name: str + params: list[str] + has_children: bool + body: Any + closure: dict[str, Any] = field(default_factory=dict) + css_classes: set[str] = field(default_factory=set) + deps: set[str] = field(default_factory=set) + io_refs: set[str] = field(default_factory=set) + + def __repr__(self): + return f"" + + # --------------------------------------------------------------------------- # HandlerDef # --------------------------------------------------------------------------- @@ -355,4 +380,4 @@ class _ShiftSignal(BaseException): # --------------------------------------------------------------------------- # An s-expression value after evaluation -SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None +SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Island | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None