diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index aff569d..3eeb539 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-13T11:02:33Z"; + var SX_VERSION = "2026-03-13T12:16:43Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1510,7 +1510,7 @@ continue; } else { return NIL; } } }; })(); }; // sx-serialize - var sxSerialize = function(val) { return (function() { var _m = typeOf(val); if (_m == "nil") return "nil"; if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "number") return (String(val)); if (_m == "string") return (String("\"") + String(escapeString(val)) + String("\"")); if (_m == "symbol") return symbolName(val); if (_m == "keyword") return (String(":") + String(keywordName(val))); if (_m == "list") return (String("(") + String(join(" ", map(sxSerialize, val))) + String(")")); if (_m == "dict") return sxSerializeDict(val); if (_m == "sx-expr") return sxExprSource(val); return (String(val)); })(); }; + var sxSerialize = function(val) { return (function() { var _m = typeOf(val); if (_m == "nil") return "nil"; if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "number") return (String(val)); if (_m == "string") return (String("\"") + String(escapeString(val)) + String("\"")); if (_m == "symbol") return symbolName(val); if (_m == "keyword") return (String(":") + String(keywordName(val))); if (_m == "list") return (String("(") + String(join(" ", map(sxSerialize, val))) + String(")")); if (_m == "dict") return sxSerializeDict(val); if (_m == "sx-expr") return sxExprSource(val); if (_m == "spread") return (String("(make-spread ") + String(sxSerializeDict(spreadAttrs(val))) + String(")")); return (String(val)); })(); }; // sx-serialize-dict var sxSerializeDict = function(d) { return (String("{") + String(join(" ", reduce(function(acc, key) { return concat(acc, [(String(":") + String(key)), sxSerialize(dictGet(d, key))]); }, [], keys(d)))) + String("}")); }; @@ -1786,7 +1786,11 @@ return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if })() : (function() { var val = aser(arg, env); if (isSxTruthy(!isSxTruthy(isNil(val)))) { - (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, val) : append_b(parts, serialize(val))); + (isSxTruthy(isSpread(val)) ? forEach(function(k) { return (function() { + var v = dictGet(spreadAttrs(val), k); + parts.push((String(":") + String(k))); + return append_b(parts, serialize(v)); +})(); }, keys(spreadAttrs(val))) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, val) : append_b(parts, serialize(val)))); } return (i = (i + 1)); })())); } } diff --git a/shared/sx/ref/adapter-async.sx b/shared/sx/ref/adapter-async.sx index 5b47d82..605fe20 100644 --- a/shared/sx/ref/adapter-async.sx +++ b/shared/sx/ref/adapter-async.sx @@ -775,6 +775,7 @@ (define-async async-aser-fragment :effects [render io] (fn ((children :as list) (env :as dict) ctx) + ;; Spreads are filtered — fragments have no parent element to merge into (let ((parts (list))) (for-each (fn (c) @@ -782,10 +783,10 @@ (if (= (type-of result) "list") (for-each (fn (item) - (when (not (nil? item)) + (when (and (not (nil? item)) (not (spread? item))) (append! parts (serialize item)))) result) - (when (not (nil? result)) + (when (and (not (nil? result)) (not (spread? result))) (append! parts (serialize result)))))) children) (if (empty? parts) @@ -885,13 +886,21 @@ (set! i (inc i))) (let ((result (async-aser arg env ctx))) (when (not (nil? result)) - (if (= (type-of result) "list") + (if (spread? result) + ;; Spread child — merge attrs as keyword args into parent element (for-each - (fn (item) - (when (not (nil? item)) - (append! parts (serialize item)))) - result) - (append! parts (serialize result)))) + (fn (k) + (let ((v (dict-get (spread-attrs result) k))) + (append! parts (str ":" k)) + (append! parts (serialize v)))) + (keys (spread-attrs result))) + (if (= (type-of result) "list") + (for-each + (fn (item) + (when (not (nil? item)) + (append! parts (serialize item)))) + result) + (append! parts (serialize result))))) (set! i (inc i)))))) args) (when token (svg-context-reset! token)) diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx index fdc1190..ac9e7a1 100644 --- a/shared/sx/ref/adapter-sx.sx +++ b/shared/sx/ref/adapter-sx.sx @@ -110,6 +110,7 @@ (fn ((children :as list) (env :as dict)) ;; Serialize (<> child1 child2 ...) to sx source string ;; Must flatten list results (e.g. from map/filter) to avoid nested parens + ;; Spreads are filtered — fragments have no parent element to merge into (let ((parts (list))) (for-each (fn (c) @@ -117,10 +118,10 @@ (if (= (type-of result) "list") (for-each (fn (item) - (when (not (nil? item)) + (when (and (not (nil? item)) (not (spread? item))) (append! parts (serialize item)))) result) - (when (not (nil? result)) + (when (and (not (nil? result)) (not (spread? result))) (append! parts (serialize result)))))) children) (if (empty? parts) @@ -151,13 +152,21 @@ (set! i (inc i))) (let ((val (aser arg env))) (when (not (nil? val)) - (if (= (type-of val) "list") + (if (spread? val) + ;; Spread child — merge attrs as keyword args into parent element (for-each - (fn (item) - (when (not (nil? item)) - (append! parts (serialize item)))) - val) - (append! parts (serialize val)))) + (fn (k) + (let ((v (dict-get (spread-attrs val) k))) + (append! parts (str ":" k)) + (append! parts (serialize v)))) + (keys (spread-attrs val))) + (if (= (type-of val) "list") + (for-each + (fn (item) + (when (not (nil? item)) + (append! parts (serialize item)))) + val) + (append! parts (serialize val))))) (set! i (inc i)))))) args) (str "(" (join " " parts) ")")))) diff --git a/shared/sx/ref/parser.sx b/shared/sx/ref/parser.sx index b224963..1b055ca 100644 --- a/shared/sx/ref/parser.sx +++ b/shared/sx/ref/parser.sx @@ -366,6 +366,7 @@ "list" (str "(" (join " " (map sx-serialize val)) ")") "dict" (sx-serialize-dict val) "sx-expr" (sx-expr-source val) + "spread" (str "(make-spread " (sx-serialize-dict (spread-attrs val)) ")") :else (str val)))) diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 367a9fb..747f98b 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -1,3 +1,5 @@ +# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift +# WARNING: eval.sx dispatches forms not in special-forms.sx: form?, provide """ sx_ref.py -- Generated from reference SX evaluator specification. @@ -2559,10 +2561,10 @@ def aser_fragment(children, env): result = aser(c, env) if sx_truthy((type_of(result) == 'list')): for item in result: - if sx_truthy((not sx_truthy(is_nil(item)))): + if sx_truthy(((not sx_truthy(is_nil(item))) if not sx_truthy((not sx_truthy(is_nil(item)))) else (not sx_truthy(is_spread(item))))): parts.append(serialize(item)) else: - if sx_truthy((not sx_truthy(is_nil(result)))): + if sx_truthy(((not sx_truthy(is_nil(result))) if not sx_truthy((not sx_truthy(is_nil(result)))) else (not sx_truthy(is_spread(result))))): parts.append(serialize(result)) if sx_truthy(empty_p(parts)): return '' @@ -2590,12 +2592,18 @@ def aser_call(name, args, env): else: val = aser(arg, env) if sx_truthy((not sx_truthy(is_nil(val)))): - if sx_truthy((type_of(val) == 'list')): - for item in val: - if sx_truthy((not sx_truthy(is_nil(item)))): - parts.append(serialize(item)) + if sx_truthy(is_spread(val)): + for k in keys(spread_attrs(val)): + v = dict_get(spread_attrs(val), k) + parts.append(sx_str(':', k)) + parts.append(serialize(v)) else: - parts.append(serialize(val)) + if sx_truthy((type_of(val) == 'list')): + for item in val: + if sx_truthy((not sx_truthy(is_nil(item)))): + parts.append(serialize(item)) + else: + parts.append(serialize(val)) _cells['i'] = (_cells['i'] + 1) return sx_str('(', join(' ', parts), ')') @@ -4140,10 +4148,10 @@ async def async_aser_fragment(children, env, ctx): result = (await async_aser(c, env, ctx)) if sx_truthy((type_of(result) == 'list')): for item in result: - if sx_truthy((not sx_truthy(is_nil(item)))): + if sx_truthy(((not sx_truthy(is_nil(item))) if not sx_truthy((not sx_truthy(is_nil(item)))) else (not sx_truthy(is_spread(item))))): parts.append(serialize(item)) else: - if sx_truthy((not sx_truthy(is_nil(result)))): + if sx_truthy(((not sx_truthy(is_nil(result))) if not sx_truthy((not sx_truthy(is_nil(result)))) else (not sx_truthy(is_spread(result))))): parts.append(serialize(result)) if sx_truthy(empty_p(parts)): return make_sx_expr('') @@ -4225,12 +4233,18 @@ async def async_aser_call(name, args, env, ctx): else: result = (await async_aser(arg, env, ctx)) if sx_truthy((not sx_truthy(is_nil(result)))): - if sx_truthy((type_of(result) == 'list')): - for item in result: - if sx_truthy((not sx_truthy(is_nil(item)))): - parts.append(serialize(item)) + if sx_truthy(is_spread(result)): + for k in keys(spread_attrs(result)): + v = dict_get(spread_attrs(result), k) + parts.append(sx_str(':', k)) + parts.append(serialize(v)) else: - parts.append(serialize(result)) + if sx_truthy((type_of(result) == 'list')): + for item in result: + if sx_truthy((not sx_truthy(is_nil(item)))): + parts.append(serialize(item)) + else: + parts.append(serialize(result)) _cells['i'] = (_cells['i'] + 1) if sx_truthy(token): svg_context_reset(token) diff --git a/shared/sx/ref/test-aser.sx b/shared/sx/ref/test-aser.sx index b70ae76..765b6e6 100644 --- a/shared/sx/ref/test-aser.sx +++ b/shared/sx/ref/test-aser.sx @@ -270,3 +270,21 @@ (deftest "or returns last falsy" (assert-equal "false" (render-sx "(or false false)")))) + + +;; -------------------------------------------------------------------------- +;; Spread serialization +;; -------------------------------------------------------------------------- + +(defsuite "aser-spreads" + (deftest "spread in element merges attrs" + (assert-equal "(div :class \"card\" \"hello\")" + (render-sx "(div (make-spread {:class \"card\"}) \"hello\")"))) + + (deftest "multiple spreads merge into element" + (assert-equal "(div :class \"card\" :style \"color:red\" \"hello\")" + (render-sx "(div (make-spread {:class \"card\"}) (make-spread {:style \"color:red\"}) \"hello\")"))) + + (deftest "spread in fragment is filtered" + (assert-equal "(<> \"hello\")" + (render-sx "(<> (make-spread {:class \"card\"}) \"hello\")"))))