diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 4e2e83d..9b78077 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-18T19:18:46Z"; + var SX_VERSION = "2026-03-18T20:14:01Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -2673,7 +2673,10 @@ PRIMITIVES["aser"] = aser; var args = rest(expr); 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((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy((name == "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() { + return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + var comp = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : NIL); + return (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && (componentAffinity(comp) == "server"))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env)); +})() : (isSxTruthy((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy((name == "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() { var f = trampoline(evalExpr(head, env)); var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && isSxTruthy(!isSxTruthy(isComponent(f))) && !isSxTruthy(isIsland(f)))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : (isSxTruthy(isIsland(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f)))))))); @@ -2728,6 +2731,24 @@ PRIMITIVES["aser-fragment"] = aserFragment; })(); }; PRIMITIVES["aser-call"] = aserCall; + // aser-expand-component + var aserExpandComponent = function(comp, args, env) { return (function() { + var params = componentParams(comp); + var local = envMerge(env, componentClosure(comp)); + var i = 0; + var skip = false; + var children = []; + { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (envBind(local, keywordName(arg), trampoline(evalExpr(nth(args, (i + 1)), env))), (skip = true), (i = (i + 1))) : (append_b(children, arg), (i = (i + 1))))); } } + if (isSxTruthy(componentHasChildren(comp))) { + (function() { + var aseredChildren = map(function(c) { return aser(c, env); }, children); + return envBind(local, "children", (isSxTruthy((len(aseredChildren) == 1)) ? first(aseredChildren) : aseredChildren)); +})(); +} + return aser(componentBody(comp), local); +})(); }; +PRIMITIVES["aser-expand-component"] = aserExpandComponent; + // SPECIAL_FORM_NAMES var SPECIAL_FORM_NAMES = ["if", "when", "cond", "case", "and", "or", "let", "let*", "lambda", "fn", "define", "defcomp", "defmacro", "defstyle", "defhandler", "defpage", "defquery", "defaction", "defrelation", "begin", "do", "quote", "quasiquote", "->", "set!", "letrec", "dynamic-wind", "defisland", "deftype", "defeffect", "scope", "provide"]; PRIMITIVES["SPECIAL_FORM_NAMES"] = SPECIAL_FORM_NAMES; diff --git a/spec/evaluator.sx b/spec/evaluator.sx index 4b0563b..e659ad1 100644 --- a/spec/evaluator.sx +++ b/spec/evaluator.sx @@ -2257,7 +2257,10 @@ (env-bind! local "children" children)) (make-cek-state (component-body f) local kont)) - :else (error (str "Not callable: " (inspect f)))))) + :else (error (str "Not callable: " (inspect f) + (when raw-args + (str " in (" (inspect (first raw-args)) " ...)"))))))) + ;; -------------------------------------------------------------------------- diff --git a/sx/sxc/pages/sx_router.py b/sx/sxc/pages/sx_router.py index cd549dd..c44cd6c 100644 --- a/sx/sxc/pages/sx_router.py +++ b/sx/sxc/pages/sx_router.py @@ -199,9 +199,9 @@ async def eval_sx_url(raw_path: str) -> Any: logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True) return None - # Return response + # Return response — Python wraps in page shell (CSS, scripts, headers) if is_htmx_request(): - return sx_response(await oob_page_sx(content=content_sx)) + return sx_response(content_sx) else: tctx = await get_template_context() html = await full_page_sx(tctx, header_rows="", content=content_sx) diff --git a/web/adapter-sx.sx b/web/adapter-sx.sx index e5f41db..89173ec 100644 --- a/web/adapter-sx.sx +++ b/web/adapter-sx.sx @@ -71,9 +71,13 @@ (= name "<>") (aser-fragment args env) - ;; Component call — serialize WITHOUT expanding + ;; Component call — expand server-affinity, serialize others (starts-with? name "~") - (aser-call name args env) + (let ((comp (if (env-has? env name) (env-get env name) nil))) + (if (and comp (component? comp) + (= (component-affinity comp) "server")) + (aser-expand-component comp args env) + (aser-call name args env))) ;; Lake — serialize (server-morphable slot) (= name "lake") @@ -213,6 +217,53 @@ (str "(" (join " " parts) ")"))))) +;; -------------------------------------------------------------------------- +;; Server-affinity component expansion +;; -------------------------------------------------------------------------- +;; +;; When a component has :affinity :server, the aser expands it inline: +;; bind keyword args + children, then aser the body. +;; This is the aser equivalent of render-to-html's component expansion. + +(define aser-expand-component :effects [render] + (fn ((comp :as any) (args :as list) (env :as dict)) + (let ((params (component-params comp)) + (local (env-merge env (component-closure comp))) + (i 0) + (skip false) + (children (list))) + ;; Parse keyword args and positional children from args + ;; Keyword values are eval'd (they're data). Children are NOT eval'd + ;; (they may contain HTML tags that only the aser can handle). + (for-each + (fn (arg) + (if skip + (do (set! skip false) (set! i (inc i))) + (if (and (= (type-of arg) "keyword") + (< (inc i) (len args))) + ;; Keyword arg: bind name = eval'd next arg + (do + (env-bind! local (keyword-name arg) + (trampoline (eval-expr (nth args (inc i)) env))) + (set! skip true) + (set! i (inc i))) + ;; Positional child: keep as unevaluated AST for aser + (do + (append! children arg) + (set! i (inc i)))))) + args) + ;; Bind &rest children — aser each child first, then bind the result + (when (component-has-children comp) + (let ((asered-children + (map (fn (c) (aser c env)) children))) + (env-bind! local "children" + (if (= (len asered-children) 1) + (first asered-children) + asered-children)))) + ;; Aser the body in the merged env + (aser (component-body comp) local)))) + + ;; -------------------------------------------------------------------------- ;; Form classification ;; --------------------------------------------------------------------------