Add spread + collect primitives, rewrite ~cssx/tw as defcomp

New SX primitives for child-to-parent communication in the render tree:
- spread type: make-spread, spread?, spread-attrs — child injects attrs
  onto parent element (class joins with space, style with semicolon)
- collect!/collected/clear-collected! — render-time accumulation with
  dedup into named buckets

~cssx/tw is now a proper defcomp returning a spread value instead of a
macro wrapping children. ~cssx/flush reads collected "cssx" rules and
emits a single <style data-cssx> tag.

All four render adapters (html, async, dom, aser) handle spread values.
Both bootstraps (Python + JS) regenerated. Also fixes length→len in
cssx.sx (length was never a registered primitive).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 02:38:31 +00:00
parent c2efa192c5
commit 41097eeef9
15 changed files with 844 additions and 230 deletions

View File

@@ -48,6 +48,7 @@
"string" (escape-html expr)
"number" (escape-html (str expr))
"raw-html" (raw-html-content expr)
"spread" expr
"symbol" (let ((val (async-eval expr env ctx)))
(async-render val env ctx))
"keyword" (escape-html (keyword-name expr))
@@ -79,9 +80,10 @@
(= name "raw!")
(async-render-raw args env ctx)
;; Fragment
;; Fragment (spreads filtered — no parent element)
(= name "<>")
(join "" (async-map-render args env ctx))
(join "" (filter (fn (r) (not (spread? r)))
(async-map-render args env ctx)))
;; html: prefix
(starts-with? name "html:")
@@ -167,16 +169,24 @@
(let ((class-val (dict-get attrs "class")))
(when (and (not (nil? class-val)) (not (= class-val false)))
(css-class-collect! (str class-val))))
;; Build opening tag
(let ((opening (str "<" tag (render-attrs attrs) ">")))
(if (contains? VOID_ELEMENTS tag)
opening
(let ((token (if (or (= tag "svg") (= tag "math"))
(svg-context-set! true)
nil))
(child-html (join "" (async-map-render children env ctx))))
(when token (svg-context-reset! token))
(str opening child-html "</" tag ">")))))))
(if (contains? VOID_ELEMENTS tag)
(str "<" tag (render-attrs attrs) ">")
;; Render children, collecting spreads and content separately
(let ((token (if (or (= tag "svg") (= tag "math"))
(svg-context-set! true)
nil))
(content-parts (list)))
(for-each
(fn (c)
(let ((result (async-render c env ctx)))
(if (spread? result)
(merge-spread-attrs attrs (spread-attrs result))
(append! content-parts result))))
children)
(when token (svg-context-reset! token))
(str "<" tag (render-attrs attrs) ">"
(join "" content-parts)
"</" tag ">"))))))
;; --------------------------------------------------------------------------
@@ -221,10 +231,17 @@
(for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; Pre-render children to raw HTML (filter spreads — no parent element)
(when (component-has-children? comp)
(env-set! local "children"
(make-raw-html
(join "" (async-map-render children env ctx)))))
(let ((parts (list)))
(for-each
(fn (c)
(let ((r (async-render c env ctx)))
(when (not (spread? r))
(append! parts r))))
children)
(env-set! local "children"
(make-raw-html (join "" parts)))))
(async-render (component-body comp) local ctx)))))
@@ -242,10 +259,17 @@
(for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island))
;; Pre-render children (filter spreads — no parent element)
(when (component-has-children? island)
(env-set! local "children"
(make-raw-html
(join "" (async-map-render children env ctx)))))
(let ((parts (list)))
(for-each
(fn (c)
(let ((r (async-render c env ctx)))
(when (not (spread? r))
(append! parts r))))
children)
(env-set! local "children"
(make-raw-html (join "" parts)))))
(let ((body-html (async-render (component-body island) local ctx))
(state-json (serialize-island-state kwargs)))
(str "<span data-sx-island=\"" (escape-attr island-name) "\""
@@ -343,11 +367,14 @@
(async-render (nth expr 3) env ctx)
"")))
;; when
;; when — single body: pass through (spread propagates). Multi: join strings.
(= name "when")
(if (not (async-eval (nth expr 1) env ctx))
""
(join "" (async-map-render (slice expr 2) env ctx)))
(if (= (len expr) 3)
(async-render (nth expr 2) env ctx)
(let ((results (async-map-render (slice expr 2) env ctx)))
(join "" (filter (fn (r) (not (spread? r))) results)))))
;; cond — uses cond-scheme? (every? check) from eval.sx
(= name "cond")
@@ -360,43 +387,52 @@
(= name "case")
(async-render (async-eval expr env ctx) env ctx)
;; let / let*
;; let / let* — single body: pass through. Multi: join strings.
(or (= name "let") (= name "let*"))
(let ((local (async-process-bindings (nth expr 1) env ctx)))
(join "" (async-map-render (slice expr 2) local ctx)))
(if (= (len expr) 3)
(async-render (nth expr 2) local ctx)
(let ((results (async-map-render (slice expr 2) local ctx)))
(join "" (filter (fn (r) (not (spread? r))) results)))))
;; begin / do
;; begin / do — single body: pass through. Multi: join strings.
(or (= name "begin") (= name "do"))
(join "" (async-map-render (rest expr) env ctx))
(if (= (len expr) 2)
(async-render (nth expr 1) env ctx)
(let ((results (async-map-render (rest expr) env ctx)))
(join "" (filter (fn (r) (not (spread? r))) results))))
;; Definition forms
(definition-form? name)
(do (async-eval expr env ctx) "")
;; map
;; map — spreads filtered
(= name "map")
(let ((f (async-eval (nth expr 1) env ctx))
(coll (async-eval (nth expr 2) env ctx)))
(join ""
(async-map-fn-render f coll env ctx)))
(filter (fn (r) (not (spread? r)))
(async-map-fn-render f coll env ctx))))
;; map-indexed
;; map-indexed — spreads filtered
(= name "map-indexed")
(let ((f (async-eval (nth expr 1) env ctx))
(coll (async-eval (nth expr 2) env ctx)))
(join ""
(async-map-indexed-fn-render f coll env ctx)))
(filter (fn (r) (not (spread? r)))
(async-map-indexed-fn-render f coll env ctx))))
;; filter — eval fully then render
(= name "filter")
(async-render (async-eval expr env ctx) env ctx)
;; for-each (render variant)
;; for-each (render variant) — spreads filtered
(= name "for-each")
(let ((f (async-eval (nth expr 1) env ctx))
(coll (async-eval (nth expr 2) env ctx)))
(join ""
(async-map-fn-render f coll env ctx)))
(filter (fn (r) (not (spread? r)))
(async-map-fn-render f coll env ctx))))
;; Fallback
:else
@@ -565,6 +601,9 @@
"dict" (async-aser-dict expr env ctx)
;; Spread — pass through for client rendering
"spread" expr
"list"
(if (empty? expr)
(list)
@@ -1250,6 +1289,14 @@
;; (svg-context-reset! token) — reset SVG context
;; (css-class-collect! val) — collect CSS classes
;;
;; Spread + collect (from render.sx):
;; (spread? x) — check if spread value
;; (spread-attrs s) — extract attrs dict from spread
;; (merge-spread-attrs tgt src) — merge spread attrs onto target
;; (collect! bucket value) — add to render-time accumulator
;; (collected bucket) — read render-time accumulator
;; (clear-collected! bucket) — clear accumulator
;;
;; Raw HTML:
;; (is-raw-html? x) — check if raw HTML marker
;; (make-raw-html s) — wrap string as raw HTML

View File

@@ -44,6 +44,9 @@
;; Pre-rendered DOM node → pass through
"dom-node" expr
;; Spread → pass through (parent element handles it)
"spread" expr
;; Dict → empty
"dict" (create-fragment)
@@ -221,10 +224,34 @@
(dom-set-attr el attr-name (str attr-val)))))
(assoc state "skip" true "i" (inc (get state "i"))))
;; Positional arg → child
;; Positional arg → child (or spread → merge attrs onto element)
(do
(when (not (contains? VOID_ELEMENTS tag))
(dom-append el (render-to-dom arg env new-ns)))
(let ((child (render-to-dom arg env new-ns)))
(if (spread? child)
;; Spread: merge attrs onto parent element
(for-each
(fn ((key :as string))
(let ((val (dict-get (spread-attrs child) key)))
(if (= key "class")
;; Class: append to existing
(let ((existing (dom-get-attr el "class")))
(dom-set-attr el "class"
(if (and existing (not (= existing "")))
(str existing " " val)
val)))
(if (= key "style")
;; Style: append with semicolon
(let ((existing (dom-get-attr el "style")))
(dom-set-attr el "style"
(if (and existing (not (= existing "")))
(str existing ";" val)
val)))
;; Other attrs: overwrite
(dom-set-attr el key (str val))))))
(keys (spread-attrs child)))
;; Normal child: append to element
(dom-append el child))))
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)

View File

@@ -30,6 +30,8 @@
"keyword" (escape-html (keyword-name expr))
;; Raw HTML passthrough
"raw-html" (raw-html-content expr)
;; Spread — pass through as-is (parent element will merge attrs)
"spread" expr
;; Everything else — evaluate first
:else (render-value-to-html (trampoline (eval-expr expr env)) env))))
@@ -42,6 +44,7 @@
"boolean" (if val "true" "false")
"list" (render-list-to-html val env)
"raw-html" (raw-html-content val)
"spread" val
:else (escape-html (str val)))))
@@ -70,14 +73,16 @@
""
(let ((head (first expr)))
(if (not (= (type-of head) "symbol"))
;; Data list — render each item
(join "" (map (fn (x) (render-value-to-html x env)) expr))
;; Data list — render each item (spreads filtered — no parent element)
(join "" (filter (fn (x) (not (spread? x)))
(map (fn (x) (render-value-to-html x env)) expr)))
(let ((name (symbol-name head))
(args (rest expr)))
(cond
;; Fragment
;; Fragment (spreads filtered — no parent element)
(= name "<>")
(join "" (map (fn (x) (render-to-html x env)) args))
(join "" (filter (fn (x) (not (spread? x)))
(map (fn (x) (render-to-html x env)) args)))
;; Raw HTML passthrough
(= name "raw!")
@@ -147,14 +152,15 @@
(render-to-html (nth expr 3) env)
"")))
;; when
;; when — single body: pass through (spread propagates). Multi: join strings.
(= name "when")
(if (not (trampoline (eval-expr (nth expr 1) env)))
""
(join ""
(map
(fn (i) (render-to-html (nth expr i) env))
(range 2 (len expr)))))
(if (= (len expr) 3)
(render-to-html (nth expr 2) env)
(let ((results (map (fn (i) (render-to-html (nth expr i) env))
(range 2 (len expr)))))
(join "" (filter (fn (r) (not (spread? r))) results)))))
;; cond
(= name "cond")
@@ -167,64 +173,69 @@
(= name "case")
(render-to-html (trampoline (eval-expr expr env)) env)
;; let / let*
;; let / let* — single body: pass through. Multi: join strings.
(or (= name "let") (= name "let*"))
(let ((local (process-bindings (nth expr 1) env)))
(join ""
(map
(fn (i) (render-to-html (nth expr i) local))
(range 2 (len expr)))))
(if (= (len expr) 3)
(render-to-html (nth expr 2) local)
(let ((results (map (fn (i) (render-to-html (nth expr i) local))
(range 2 (len expr)))))
(join "" (filter (fn (r) (not (spread? r))) results)))))
;; begin / do
;; begin / do — single body: pass through. Multi: join strings.
(or (= name "begin") (= name "do"))
(join ""
(map
(fn (i) (render-to-html (nth expr i) env))
(range 1 (len expr))))
(if (= (len expr) 2)
(render-to-html (nth expr 1) env)
(let ((results (map (fn (i) (render-to-html (nth expr i) env))
(range 1 (len expr)))))
(join "" (filter (fn (r) (not (spread? r))) results))))
;; Definition forms — eval for side effects
(definition-form? name)
(do (trampoline (eval-expr expr env)) "")
;; map
;; map — spreads filtered (no parent element in list context)
(= name "map")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env))))
(join ""
(map
(fn (item)
(if (lambda? f)
(render-lambda-html f (list item) env)
(render-to-html (apply f (list item)) env)))
coll)))
(filter (fn (r) (not (spread? r)))
(map
(fn (item)
(if (lambda? f)
(render-lambda-html f (list item) env)
(render-to-html (apply f (list item)) env)))
coll))))
;; map-indexed
;; map-indexed — spreads filtered
(= name "map-indexed")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env))))
(join ""
(map-indexed
(fn (i item)
(if (lambda? f)
(render-lambda-html f (list i item) env)
(render-to-html (apply f (list i item)) env)))
coll)))
(filter (fn (r) (not (spread? r)))
(map-indexed
(fn (i item)
(if (lambda? f)
(render-lambda-html f (list i item) env)
(render-to-html (apply f (list i item)) env)))
coll))))
;; filter — evaluate fully then render
(= name "filter")
(render-to-html (trampoline (eval-expr expr env)) env)
;; for-each (render variant)
;; for-each (render variant) — spreads filtered
(= name "for-each")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env))))
(join ""
(map
(fn (item)
(if (lambda? f)
(render-lambda-html f (list item) env)
(render-to-html (apply f (list item)) env)))
coll)))
(filter (fn (r) (not (spread? r)))
(map
(fn (item)
(if (lambda? f)
(render-lambda-html f (list item) env)
(render-to-html (apply f (list item)) env)))
coll))))
;; Fallback
:else
@@ -281,10 +292,17 @@
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; If component accepts children, pre-render them to raw HTML
;; Spread values are filtered out (no parent element to merge onto)
(when (component-has-children? comp)
(env-set! local "children"
(make-raw-html
(join "" (map (fn (c) (render-to-html c env)) children)))))
(let ((parts (list)))
(for-each
(fn (c)
(let ((r (render-to-html c env)))
(when (not (spread? r))
(append! parts r))))
children)
(env-set! local "children"
(make-raw-html (join "" parts)))))
(render-to-html (component-body comp) local)))))
@@ -294,12 +312,19 @@
(attrs (first parsed))
(children (nth parsed 1))
(is-void (contains? VOID_ELEMENTS tag)))
(str "<" tag
(render-attrs attrs)
(if is-void
" />"
(str ">"
(join "" (map (fn (c) (render-to-html c env)) children))
(if is-void
(str "<" tag (render-attrs attrs) " />")
;; Render children, collecting spreads and content separately
(let ((content-parts (list)))
(for-each
(fn (c)
(let ((result (render-to-html c env)))
(if (spread? result)
(merge-spread-attrs attrs (spread-attrs result))
(append! content-parts result))))
children)
(str "<" tag (render-attrs attrs) ">"
(join "" content-parts)
"</" tag ">"))))))
@@ -335,9 +360,19 @@
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
(str "<" lake-tag " data-sx-lake=\"" (escape-attr (or lake-id "")) "\">"
(join "" (map (fn (c) (render-to-html c env)) children))
"</" lake-tag ">"))))
;; Render children, handling spreads
(let ((lake-attrs (dict "data-sx-lake" (or lake-id "")))
(content-parts (list)))
(for-each
(fn (c)
(let ((result (render-to-html c env)))
(if (spread? result)
(merge-spread-attrs lake-attrs (spread-attrs result))
(append! content-parts result))))
children)
(str "<" lake-tag (render-attrs lake-attrs) ">"
(join "" content-parts)
"</" lake-tag ">")))))
;; --------------------------------------------------------------------------
@@ -375,9 +410,19 @@
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
(str "<" marsh-tag " data-sx-marsh=\"" (escape-attr (or marsh-id "")) "\">"
(join "" (map (fn (c) (render-to-html c env)) children))
"</" marsh-tag ">"))))
;; Render children, handling spreads
(let ((marsh-attrs (dict "data-sx-marsh" (or marsh-id "")))
(content-parts (list)))
(for-each
(fn (c)
(let ((result (render-to-html c env)))
(if (spread? result)
(merge-spread-attrs marsh-attrs (spread-attrs result))
(append! content-parts result))))
children)
(str "<" marsh-tag (render-attrs marsh-attrs) ">"
(join "" content-parts)
"</" marsh-tag ">")))))
;; --------------------------------------------------------------------------
@@ -427,10 +472,17 @@
(component-params island))
;; If island accepts children, pre-render them to raw HTML
;; Spread values filtered out (no parent element)
(when (component-has-children? island)
(env-set! local "children"
(make-raw-html
(join "" (map (fn (c) (render-to-html c env)) children)))))
(let ((parts (list)))
(for-each
(fn (c)
(let ((r (render-to-html c env)))
(when (not (spread? r))
(append! parts r))))
children)
(env-set! local "children"
(make-raw-html (join "" parts)))))
;; Render the island body as HTML
(let ((body-html (render-to-html (component-body island) local))

View File

@@ -48,6 +48,9 @@
(list)
(aser-list expr env))
;; Spread — pass through for client rendering
"spread" expr
:else expr)))

View File

@@ -285,6 +285,14 @@ class PyEmitter:
"svg-context-set!": "svg_context_set",
"svg-context-reset!": "svg_context_reset",
"css-class-collect!": "css_class_collect",
# spread + collect primitives
"make-spread": "make_spread",
"spread?": "is_spread",
"spread-attrs": "spread_attrs",
"merge-spread-attrs": "merge_spread_attrs",
"collect!": "sx_collect",
"collected": "sx_collected",
"clear-collected!": "sx_clear_collected",
"is-raw-html?": "is_raw_html",
"async-coroutine?": "is_async_coroutine",
"async-await!": "async_await",

View File

@@ -313,3 +313,61 @@
:effects [mutation]
:doc "Group multiple signal writes. Subscribers are notified once at the end,
after all values have been updated.")
;; --------------------------------------------------------------------------
;; Tier 4: Spread + Collect — render-time attribute injection and accumulation
;;
;; `spread` is a new type: a dict of attributes that, when returned as a child
;; of an HTML element, merges its attrs onto the parent element rather than
;; rendering as content. This enables components like `~cssx/tw` to inject
;; classes and styles onto their parent from inside the child list.
;;
;; `collect!` / `collected` are render-time accumulators. Values are collected
;; into named buckets (with deduplication) during rendering and retrieved at
;; flush points (e.g. a single <style> tag for all collected CSS rules).
;; --------------------------------------------------------------------------
(declare-tier :spread :source "render.sx")
(declare-spread-primitive "make-spread"
:params (attrs)
:returns "spread"
:effects []
:doc "Create a spread value from an attrs dict. When this value appears as
a child of an HTML element, its attrs are merged onto the parent
element (class values joined, others overwritten).")
(declare-spread-primitive "spread?"
:params (x)
:returns "boolean"
:effects []
:doc "Test whether a value is a spread.")
(declare-spread-primitive "spread-attrs"
:params (s)
:returns "dict"
:effects []
:doc "Extract the attrs dict from a spread value.")
(declare-spread-primitive "collect!"
:params (bucket value)
:returns "nil"
:effects [mutation]
:doc "Add value to a named render-time accumulator bucket. Values are
deduplicated (no duplicates added). Buckets persist for the duration
of the current render pass.")
(declare-spread-primitive "collected"
:params (bucket)
:returns "list"
:effects []
:doc "Return all values collected in the named bucket during the current
render pass. Returns an empty list if the bucket doesn't exist.")
(declare-spread-primitive "clear-collected!"
:params (bucket)
:returns "nil"
:effects [mutation]
:doc "Clear a named render-time accumulator bucket. Used at flush points
after emitting collected values (e.g. after writing a <style> tag).")

View File

@@ -1064,6 +1064,7 @@
;; (type-of x) → "number" | "string" | "boolean" | "nil"
;; | "symbol" | "keyword" | "list" | "dict"
;; | "lambda" | "component" | "macro" | "thunk"
;; | "spread"
;; (symbol-name sym) → string
;; (keyword-name kw) → string
;;
@@ -1089,6 +1090,10 @@
;; (island? x) → boolean
;; ;; Islands reuse component accessors: component-params, component-body, etc.
;;
;; (make-spread attrs) → Spread (attrs dict injected onto parent element)
;; (spread? x) → boolean
;; (spread-attrs s) → dict
;;
;; (macro-params m) → list of strings
;; (macro-rest-param m) → string or nil
;; (macro-body m) → expr
@@ -1132,4 +1137,9 @@
;; (push-wind! before after) → void (push wind record onto stack)
;; (pop-wind!) → void (pop wind record from stack)
;; (call-thunk f env) → value (call a zero-arg function)
;;
;; Render-time accumulators:
;; (collect! bucket value) → void (add to named bucket, deduplicated)
;; (collected bucket) → list (all values in bucket)
;; (clear-collected! bucket) → void (empty the bucket)
;; --------------------------------------------------------------------------

View File

@@ -520,6 +520,13 @@
"match-route-segments" "matchRouteSegments"
"match-route" "matchRoute"
"find-matching-route" "findMatchingRoute"
"make-spread" "makeSpread"
"spread?" "isSpread"
"spread-attrs" "spreadAttrs"
"merge-spread-attrs" "mergeSpreadAttrs"
"collect!" "sxCollect"
"collected" "sxCollected"
"clear-collected!" "sxClearCollected"
})

View File

@@ -864,6 +864,11 @@ PREAMBLE = '''\
function RawHTML(html) { this.html = html; }
RawHTML.prototype._raw = true;
function SxSpread(attrs) { this.attrs = attrs || {}; }
SxSpread.prototype._spread = true;
var _collectBuckets = {};
function isSym(x) { return x != null && x._sym === true; }
function isKw(x) { return x != null && x._kw === true; }
@@ -1073,6 +1078,16 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
return true;
};
''',
"stdlib.spread": '''
// stdlib.spread — spread + collect primitives
PRIMITIVES["make-spread"] = makeSpread;
PRIMITIVES["spread?"] = isSpread;
PRIMITIVES["spread-attrs"] = spreadAttrs;
PRIMITIVES["collect!"] = sxCollect;
PRIMITIVES["collected"] = sxCollected;
PRIMITIVES["clear-collected!"] = sxClearCollected;
''',
}
# Modules to include by default (all)
_ALL_JS_MODULES = list(PRIMITIVES_JS_MODULES.keys())
@@ -1108,6 +1123,7 @@ PLATFORM_JS_PRE = '''
if (x._component) return "component";
if (x._island) return "island";
if (x._signal) return "signal";
if (x._spread) return "spread";
if (x._macro) return "macro";
if (x._raw) return "raw-html";
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
@@ -1130,6 +1146,22 @@ PLATFORM_JS_PRE = '''
}
function makeThunk(expr, env) { return new Thunk(expr, env); }
function makeSpread(attrs) { return new SxSpread(attrs || {}); }
function isSpread(x) { return x != null && x._spread === true; }
function spreadAttrs(s) { return s && s._spread ? s.attrs : {}; }
function sxCollect(bucket, value) {
if (!_collectBuckets[bucket]) _collectBuckets[bucket] = [];
var items = _collectBuckets[bucket];
if (items.indexOf(value) === -1) items.push(value);
}
function sxCollected(bucket) {
return _collectBuckets[bucket] ? _collectBuckets[bucket].slice() : [];
}
function sxClearCollected(bucket) {
if (_collectBuckets[bucket]) _collectBuckets[bucket] = [];
}
function lambdaParams(f) { return f.params; }
function lambdaBody(f) { return f.body; }
function lambdaClosure(f) { return f.closure; }
@@ -3154,6 +3186,12 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has
api_lines.append(' emitEvent: emitEvent,')
api_lines.append(' onEvent: onEvent,')
api_lines.append(' bridgeEvent: bridgeEvent,')
api_lines.append(' makeSpread: makeSpread,')
api_lines.append(' isSpread: isSpread,')
api_lines.append(' spreadAttrs: spreadAttrs,')
api_lines.append(' collect: sxCollect,')
api_lines.append(' collected: sxCollected,')
api_lines.append(' clearCollected: sxClearCollected,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
api_lines.append('')

View File

@@ -84,6 +84,23 @@ class _RawHTML:
self.html = html
class _Spread:
"""Attribute injection value — merges attrs onto parent element."""
__slots__ = ("attrs",)
def __init__(self, attrs: dict):
self.attrs = dict(attrs) if attrs else {}
# Render-time accumulator buckets (per render pass)
_collect_buckets: dict[str, list] = {}
def _collect_reset():
"""Reset all collect buckets (call at start of each render pass)."""
global _collect_buckets
_collect_buckets = {}
def sx_truthy(x):
"""SX truthiness: everything is truthy except False, None, and NIL."""
if x is False:
@@ -167,6 +184,8 @@ def type_of(x):
return "island"
if isinstance(x, _Signal):
return "signal"
if isinstance(x, _Spread):
return "spread"
if isinstance(x, Macro):
return "macro"
if isinstance(x, _RawHTML):
@@ -270,6 +289,38 @@ def make_thunk(expr, env):
return _Thunk(expr, env)
def make_spread(attrs):
return _Spread(attrs if isinstance(attrs, dict) else {})
def is_spread(x):
return isinstance(x, _Spread)
def spread_attrs(s):
return s.attrs if isinstance(s, _Spread) else {}
def sx_collect(bucket, value):
"""Add value to named render-time accumulator (deduplicated)."""
if bucket not in _collect_buckets:
_collect_buckets[bucket] = []
items = _collect_buckets[bucket]
if value not in items:
items.append(value)
def sx_collected(bucket):
"""Return all values in named render-time accumulator."""
return list(_collect_buckets.get(bucket, []))
def sx_clear_collected(bucket):
"""Clear a named render-time accumulator bucket."""
if bucket in _collect_buckets:
_collect_buckets[bucket] = []
def lambda_params(f):
return f.params
@@ -881,6 +932,16 @@ def _strip_tags(s):
"stdlib.debug": '''
# stdlib.debug
PRIMITIVES["assert"] = lambda cond, msg="Assertion failed": (_ for _ in ()).throw(RuntimeError(f"Assertion error: {msg}")) if not sx_truthy(cond) else True
''',
"stdlib.spread": '''
# stdlib.spread — spread + collect primitives
PRIMITIVES["make-spread"] = make_spread
PRIMITIVES["spread?"] = is_spread
PRIMITIVES["spread-attrs"] = spread_attrs
PRIMITIVES["collect!"] = sx_collect
PRIMITIVES["collected"] = sx_collected
PRIMITIVES["clear-collected!"] = sx_clear_collected
''',
}

View File

@@ -245,6 +245,13 @@
"match-route-segments" "match_route_segments"
"match-route" "match_route"
"find-matching-route" "find_matching_route"
"make-spread" "make_spread"
"spread?" "is_spread"
"spread-attrs" "spread_attrs"
"merge-spread-attrs" "merge_spread_attrs"
"collect!" "sx_collect"
"collected" "sx_collected"
"clear-collected!" "sx_clear_collected"
})

View File

@@ -213,6 +213,43 @@
(= (type-of (nth expr 1)) "keyword")))))))))
;; --------------------------------------------------------------------------
;; Spread — attribute injection from children into parent elements
;; --------------------------------------------------------------------------
;;
;; A spread value is a dict of attributes that, when returned as a child
;; of an HTML element, merges its attrs onto the parent element.
;; This enables components to inject classes/styles/data-attrs onto their
;; parent without the parent knowing about the specific attrs.
;;
;; merge-spread-attrs: merge a spread's attrs into an element's attrs dict.
;; Class values are joined (space-separated); others overwrite.
;; Mutates the target attrs dict in place.
(define merge-spread-attrs :effects [mutation]
(fn ((target :as dict) (spread-dict :as dict))
(for-each
(fn ((key :as string))
(let ((val (dict-get spread-dict key)))
(if (= key "class")
;; Class: join existing + new with space
(let ((existing (dict-get target "class")))
(dict-set! target "class"
(if (and existing (not (= existing "")))
(str existing " " val)
val)))
;; Style: join with semicolons
(if (= key "style")
(let ((existing (dict-get target "style")))
(dict-set! target "style"
(if (and existing (not (= existing "")))
(str existing ";" val)
val)))
;; Everything else: overwrite
(dict-set! target key val)))))
(keys spread-dict))))
;; --------------------------------------------------------------------------
;; Platform interface (shared across adapters)
;; --------------------------------------------------------------------------
@@ -222,6 +259,16 @@
;; (escape-attr s) → attribute-value-escaped string
;; (raw-html-content r) → unwrap RawHTML marker to string
;;
;; Spread (render-time attribute injection):
;; (make-spread attrs) → Spread value
;; (spread? x) → boolean
;; (spread-attrs s) → dict
;;
;; Render-time accumulators:
;; (collect! bucket value) → void
;; (collected bucket) → list
;; (clear-collected! bucket) → void
;;
;; From parser.sx:
;; (sx-serialize val) → SX source string (aliased as serialize above)
;; --------------------------------------------------------------------------

View File

@@ -43,6 +43,23 @@ class _RawHTML:
self.html = html
class _Spread:
"""Attribute injection value — merges attrs onto parent element."""
__slots__ = ("attrs",)
def __init__(self, attrs: dict):
self.attrs = dict(attrs) if attrs else {}
# Render-time accumulator buckets (per render pass)
_collect_buckets: dict[str, list] = {}
def _collect_reset():
"""Reset all collect buckets (call at start of each render pass)."""
global _collect_buckets
_collect_buckets = {}
def sx_truthy(x):
"""SX truthiness: everything is truthy except False, None, and NIL."""
if x is False:
@@ -126,6 +143,8 @@ def type_of(x):
return "island"
if isinstance(x, _Signal):
return "signal"
if isinstance(x, _Spread):
return "spread"
if isinstance(x, Macro):
return "macro"
if isinstance(x, _RawHTML):
@@ -229,6 +248,38 @@ def make_thunk(expr, env):
return _Thunk(expr, env)
def make_spread(attrs):
return _Spread(attrs if isinstance(attrs, dict) else {})
def is_spread(x):
return isinstance(x, _Spread)
def spread_attrs(s):
return s.attrs if isinstance(s, _Spread) else {}
def sx_collect(bucket, value):
"""Add value to named render-time accumulator (deduplicated)."""
if bucket not in _collect_buckets:
_collect_buckets[bucket] = []
items = _collect_buckets[bucket]
if value not in items:
items.append(value)
def sx_collected(bucket):
"""Return all values in named render-time accumulator."""
return list(_collect_buckets.get(bucket, []))
def sx_clear_collected(bucket):
"""Clear a named render-time accumulator bucket."""
if bucket in _collect_buckets:
_collect_buckets[bucket] = []
def lambda_params(f):
return f.params
@@ -847,6 +898,15 @@ def _strip_tags(s):
PRIMITIVES["assert"] = lambda cond, msg="Assertion failed": (_ for _ in ()).throw(RuntimeError(f"Assertion error: {msg}")) if not sx_truthy(cond) else True
# stdlib.spread — spread + collect primitives
PRIMITIVES["make-spread"] = make_spread
PRIMITIVES["spread?"] = is_spread
PRIMITIVES["spread-attrs"] = spread_attrs
PRIMITIVES["collect!"] = sx_collect
PRIMITIVES["collected"] = sx_collected
PRIMITIVES["clear-collected!"] = sx_clear_collected
def is_primitive(name):
if name in PRIMITIVES:
return True
@@ -2056,6 +2116,21 @@ def is_render_expr(expr):
n = symbol_name(h)
return ((n == '<>') if sx_truthy((n == '<>')) else ((n == 'raw!') if sx_truthy((n == 'raw!')) else (starts_with_p(n, '~') if sx_truthy(starts_with_p(n, '~')) else (starts_with_p(n, 'html:') if sx_truthy(starts_with_p(n, 'html:')) else (contains_p(HTML_TAGS, n) if sx_truthy(contains_p(HTML_TAGS, n)) else ((index_of(n, '-') > 0) if not sx_truthy((index_of(n, '-') > 0)) else ((len(expr) > 1) if not sx_truthy((len(expr) > 1)) else (type_of(nth(expr, 1)) == 'keyword'))))))))
# merge-spread-attrs
def merge_spread_attrs(target, spread_dict):
for key in keys(spread_dict):
val = dict_get(spread_dict, key)
if sx_truthy((key == 'class')):
existing = dict_get(target, 'class')
target['class'] = (sx_str(existing, ' ', val) if sx_truthy((existing if not sx_truthy(existing) else (not sx_truthy((existing == ''))))) else val)
else:
if sx_truthy((key == 'style')):
existing = dict_get(target, 'style')
target['style'] = (sx_str(existing, ';', val) if sx_truthy((existing if not sx_truthy(existing) else (not sx_truthy((existing == ''))))) else val)
else:
target[key] = val
return NIL
# === Transpiled from adapter-html ===
@@ -2085,6 +2160,8 @@ def render_to_html(expr, env):
return escape_html(keyword_name(expr))
elif _match == 'raw-html':
return raw_html_content(expr)
elif _match == 'spread':
return expr
else:
return render_value_to_html(trampoline(eval_expr(expr, env)), env)
@@ -2106,6 +2183,8 @@ def render_value_to_html(val, env):
return render_list_to_html(val, env)
elif _match == 'raw-html':
return raw_html_content(val)
elif _match == 'spread':
return val
else:
return escape_html(sx_str(val))
@@ -2123,12 +2202,12 @@ def render_list_to_html(expr, env):
else:
head = first(expr)
if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))):
return join('', map(lambda x: render_value_to_html(x, env), expr))
return join('', filter(lambda x: (not sx_truthy(is_spread(x))), map(lambda x: render_value_to_html(x, env), expr)))
else:
name = symbol_name(head)
args = rest(expr)
if sx_truthy((name == '<>')):
return join('', map(lambda x: render_to_html(x, env), args))
return join('', filter(lambda x: (not sx_truthy(is_spread(x))), map(lambda x: render_to_html(x, env), args)))
elif sx_truthy((name == 'raw!')):
return join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args))
elif sx_truthy((name == 'lake')):
@@ -2169,7 +2248,11 @@ def dispatch_html_form(name, expr, env):
if sx_truthy((not sx_truthy(trampoline(eval_expr(nth(expr, 1), env))))):
return ''
else:
return join('', map(lambda i: render_to_html(nth(expr, i), env), range(2, len(expr))))
if sx_truthy((len(expr) == 3)):
return render_to_html(nth(expr, 2), env)
else:
results = map(lambda i: render_to_html(nth(expr, i), env), range(2, len(expr)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), results))
elif sx_truthy((name == 'cond')):
branch = eval_cond(rest(expr), env)
if sx_truthy(branch):
@@ -2180,26 +2263,34 @@ def dispatch_html_form(name, expr, env):
return render_to_html(trampoline(eval_expr(expr, env)), env)
elif sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))):
local = process_bindings(nth(expr, 1), env)
return join('', map(lambda i: render_to_html(nth(expr, i), local), range(2, len(expr))))
if sx_truthy((len(expr) == 3)):
return render_to_html(nth(expr, 2), local)
else:
results = map(lambda i: render_to_html(nth(expr, i), local), range(2, len(expr)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), results))
elif sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))):
return join('', map(lambda i: render_to_html(nth(expr, i), env), range(1, len(expr))))
if sx_truthy((len(expr) == 2)):
return render_to_html(nth(expr, 1), env)
else:
results = map(lambda i: render_to_html(nth(expr, i), env), range(1, len(expr)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), results))
elif sx_truthy(is_definition_form(name)):
trampoline(eval_expr(expr, env))
return ''
elif sx_truthy((name == 'map')):
f = trampoline(eval_expr(nth(expr, 1), env))
coll = trampoline(eval_expr(nth(expr, 2), env))
return 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))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))
elif sx_truthy((name == 'map-indexed')):
f = trampoline(eval_expr(nth(expr, 1), env))
coll = trampoline(eval_expr(nth(expr, 2), env))
return 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))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), 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)))
elif sx_truthy((name == 'filter')):
return render_to_html(trampoline(eval_expr(expr, env)), env)
elif sx_truthy((name == 'for-each')):
f = trampoline(eval_expr(nth(expr, 1), env))
coll = trampoline(eval_expr(nth(expr, 2), env))
return 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))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))
else:
return render_value_to_html(trampoline(eval_expr(expr, env)), env)
@@ -2218,7 +2309,12 @@ def render_html_component(comp, args, env):
for p in component_params(comp):
local[p] = (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)
if sx_truthy(component_has_children(comp)):
local['children'] = make_raw_html(join('', map(lambda c: render_to_html(c, env), children)))
parts = []
for c in children:
r = render_to_html(c, env)
if sx_truthy((not sx_truthy(is_spread(r)))):
parts.append(r)
local['children'] = make_raw_html(join('', parts))
return render_to_html(component_body(comp), local)
# render-html-element
@@ -2227,7 +2323,17 @@ def render_html_element(tag, args, env):
attrs = first(parsed)
children = nth(parsed, 1)
is_void = contains_p(VOID_ELEMENTS, tag)
return sx_str('<', tag, render_attrs(attrs), (' />' if sx_truthy(is_void) else sx_str('>', join('', map(lambda c: render_to_html(c, env), children)), '</', tag, '>')))
if sx_truthy(is_void):
return sx_str('<', tag, render_attrs(attrs), ' />')
else:
content_parts = []
for c in children:
result = render_to_html(c, env)
if sx_truthy(is_spread(result)):
merge_spread_attrs(attrs, spread_attrs(result))
else:
content_parts.append(result)
return sx_str('<', tag, render_attrs(attrs), '>', join('', content_parts), '</', tag, '>')
# render-html-lake
def render_html_lake(args, env):
@@ -2236,7 +2342,15 @@ def render_html_lake(args, env):
_cells['lake_tag'] = 'div'
children = []
reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda kname: (lambda kval: _sx_begin((_sx_cell_set(_cells, 'lake_id', kval) if sx_truthy((kname == 'id')) else (_sx_cell_set(_cells, 'lake_tag', kval) if sx_truthy((kname == 'tag')) else NIL)), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))))(keyword_name(arg)) 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)
return sx_str('<', _cells['lake_tag'], ' data-sx-lake="', escape_attr((_cells['lake_id'] if sx_truthy(_cells['lake_id']) else '')), '">', join('', map(lambda c: render_to_html(c, env), children)), '</', _cells['lake_tag'], '>')
lake_attrs = {'data-sx-lake': (_cells['lake_id'] if sx_truthy(_cells['lake_id']) else '')}
content_parts = []
for c in children:
result = render_to_html(c, env)
if sx_truthy(is_spread(result)):
merge_spread_attrs(lake_attrs, spread_attrs(result))
else:
content_parts.append(result)
return sx_str('<', _cells['lake_tag'], render_attrs(lake_attrs), '>', join('', content_parts), '</', _cells['lake_tag'], '>')
# render-html-marsh
def render_html_marsh(args, env):
@@ -2245,7 +2359,15 @@ def render_html_marsh(args, env):
_cells['marsh_tag'] = 'div'
children = []
reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda kname: (lambda kval: _sx_begin((_sx_cell_set(_cells, 'marsh_id', kval) if sx_truthy((kname == 'id')) else (_sx_cell_set(_cells, 'marsh_tag', kval) if sx_truthy((kname == 'tag')) else (NIL if sx_truthy((kname == 'transform')) else NIL))), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))))(keyword_name(arg)) 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)
return sx_str('<', _cells['marsh_tag'], ' data-sx-marsh="', escape_attr((_cells['marsh_id'] if sx_truthy(_cells['marsh_id']) else '')), '">', join('', map(lambda c: render_to_html(c, env), children)), '</', _cells['marsh_tag'], '>')
marsh_attrs = {'data-sx-marsh': (_cells['marsh_id'] if sx_truthy(_cells['marsh_id']) else '')}
content_parts = []
for c in children:
result = render_to_html(c, env)
if sx_truthy(is_spread(result)):
merge_spread_attrs(marsh_attrs, spread_attrs(result))
else:
content_parts.append(result)
return sx_str('<', _cells['marsh_tag'], render_attrs(marsh_attrs), '>', join('', content_parts), '</', _cells['marsh_tag'], '>')
# render-html-island
def render_html_island(island, args, env):
@@ -2257,7 +2379,12 @@ def render_html_island(island, args, env):
for p in component_params(island):
local[p] = (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)
if sx_truthy(component_has_children(island)):
local['children'] = make_raw_html(join('', map(lambda c: render_to_html(c, env), children)))
parts = []
for c in children:
r = render_to_html(c, env)
if sx_truthy((not sx_truthy(is_spread(r)))):
parts.append(r)
local['children'] = make_raw_html(join('', parts))
body_html = render_to_html(component_body(island), local)
state_sx = serialize_island_state(kwargs)
return sx_str('<span data-sx-island="', escape_attr(island_name), '"', (sx_str(' data-sx-state="', escape_attr(state_sx), '"') if sx_truthy(state_sx) else ''), '>', body_html, '</span>')
@@ -2313,6 +2440,8 @@ def aser(expr, env):
return []
else:
return aser_list(expr, env)
elif _match == 'spread':
return expr
else:
return expr
@@ -3441,6 +3570,8 @@ async def async_render(expr, env, ctx):
return escape_html(sx_str(expr))
elif _match == 'raw-html':
return raw_html_content(expr)
elif _match == 'spread':
return expr
elif _match == 'symbol':
val = (await async_eval(expr, env, ctx))
return (await async_render(val, env, ctx))
@@ -3472,7 +3603,7 @@ async def async_render_list(expr, env, ctx):
elif sx_truthy((name == 'raw!')):
return (await async_render_raw(args, env, ctx))
elif sx_truthy((name == '<>')):
return join('', (await async_map_render(args, env, ctx)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), (await async_map_render(args, env, ctx))))
elif sx_truthy(starts_with_p(name, 'html:')):
return (await async_render_element(slice(name, 5), args, env, ctx))
elif sx_truthy(async_render_form_p(name)):
@@ -3522,15 +3653,20 @@ async def async_render_element(tag, args, env, ctx):
class_val = dict_get(attrs, 'class')
if sx_truthy(((not sx_truthy(is_nil(class_val))) if not sx_truthy((not sx_truthy(is_nil(class_val)))) else (not sx_truthy((class_val == False))))):
css_class_collect(sx_str(class_val))
opening = sx_str('<', tag, render_attrs(attrs), '>')
if sx_truthy(contains_p(VOID_ELEMENTS, tag)):
return opening
return sx_str('<', tag, render_attrs(attrs), '>')
else:
token = (svg_context_set(True) if sx_truthy(((tag == 'svg') if sx_truthy((tag == 'svg')) else (tag == 'math'))) else NIL)
child_html = join('', (await async_map_render(children, env, ctx)))
content_parts = []
for c in children:
result = (await async_render(c, env, ctx))
if sx_truthy(is_spread(result)):
merge_spread_attrs(attrs, spread_attrs(result))
else:
content_parts.append(result)
if sx_truthy(token):
svg_context_reset(token)
return sx_str(opening, child_html, '</', tag, '>')
return sx_str('<', tag, render_attrs(attrs), '>', join('', content_parts), '</', tag, '>')
# async-parse-element-args
async def async_parse_element_args(args, attrs, children, env, ctx):
@@ -3561,7 +3697,12 @@ async def async_render_component(comp, args, env, ctx):
for p in component_params(comp):
local[p] = (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)
if sx_truthy(component_has_children(comp)):
local['children'] = make_raw_html(join('', (await async_map_render(children, env, ctx))))
parts = []
for c in children:
r = (await async_render(c, env, ctx))
if sx_truthy((not sx_truthy(is_spread(r)))):
parts.append(r)
local['children'] = make_raw_html(join('', parts))
return (await async_render(component_body(comp), local, ctx))
# async-render-island
@@ -3574,7 +3715,12 @@ async def async_render_island(island, args, env, ctx):
for p in component_params(island):
local[p] = (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)
if sx_truthy(component_has_children(island)):
local['children'] = make_raw_html(join('', (await async_map_render(children, env, ctx))))
parts = []
for c in children:
r = (await async_render(c, env, ctx))
if sx_truthy((not sx_truthy(is_spread(r)))):
parts.append(r)
local['children'] = make_raw_html(join('', parts))
body_html = (await async_render(component_body(island), local, ctx))
state_json = serialize_island_state(kwargs)
return sx_str('<span data-sx-island="', escape_attr(island_name), '"', (sx_str(' data-sx-state="', escape_attr(state_json), '"') if sx_truthy(state_json) else ''), '>', body_html, '</span>')
@@ -3634,7 +3780,11 @@ async def dispatch_async_render_form(name, expr, env, ctx):
if sx_truthy((not sx_truthy((await async_eval(nth(expr, 1), env, ctx))))):
return ''
else:
return join('', (await async_map_render(slice(expr, 2), env, ctx)))
if sx_truthy((len(expr) == 3)):
return (await async_render(nth(expr, 2), env, ctx))
else:
results = (await async_map_render(slice(expr, 2), env, ctx))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), results))
elif sx_truthy((name == 'cond')):
clauses = rest(expr)
if sx_truthy(cond_scheme_p(clauses)):
@@ -3645,26 +3795,34 @@ async def dispatch_async_render_form(name, expr, env, ctx):
return (await async_render((await async_eval(expr, env, ctx)), env, ctx))
elif sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))):
local = (await async_process_bindings(nth(expr, 1), env, ctx))
return join('', (await async_map_render(slice(expr, 2), local, ctx)))
if sx_truthy((len(expr) == 3)):
return (await async_render(nth(expr, 2), local, ctx))
else:
results = (await async_map_render(slice(expr, 2), local, ctx))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), results))
elif sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))):
return join('', (await async_map_render(rest(expr), env, ctx)))
if sx_truthy((len(expr) == 2)):
return (await async_render(nth(expr, 1), env, ctx))
else:
results = (await async_map_render(rest(expr), env, ctx))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), results))
elif sx_truthy(is_definition_form(name)):
(await async_eval(expr, env, ctx))
return ''
elif sx_truthy((name == 'map')):
f = (await async_eval(nth(expr, 1), env, ctx))
coll = (await async_eval(nth(expr, 2), env, ctx))
return join('', (await async_map_fn_render(f, coll, env, ctx)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), (await async_map_fn_render(f, coll, env, ctx))))
elif sx_truthy((name == 'map-indexed')):
f = (await async_eval(nth(expr, 1), env, ctx))
coll = (await async_eval(nth(expr, 2), env, ctx))
return join('', (await async_map_indexed_fn_render(f, coll, env, ctx)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), (await async_map_indexed_fn_render(f, coll, env, ctx))))
elif sx_truthy((name == 'filter')):
return (await async_render((await async_eval(expr, env, ctx)), env, ctx))
elif sx_truthy((name == 'for-each')):
f = (await async_eval(nth(expr, 1), env, ctx))
coll = (await async_eval(nth(expr, 2), env, ctx))
return join('', (await async_map_fn_render(f, coll, env, ctx)))
return join('', filter(lambda r: (not sx_truthy(is_spread(r))), (await async_map_fn_render(f, coll, env, ctx))))
else:
return (await async_render((await async_eval(expr, env, ctx)), env, ctx))
@@ -3791,6 +3949,8 @@ async def async_aser(expr, env, ctx):
return keyword_name(expr)
elif _match == 'dict':
return (await async_aser_dict(expr, env, ctx))
elif _match == 'spread':
return expr
elif _match == 'list':
if sx_truthy(empty_p(expr)):
return []

View File

@@ -1,15 +1,23 @@
;; @client — send all define forms to browser for client-side use.
;; CSSX — computed CSS from s-expressions.
;;
;; Tailwind-style utility component. Write styling as utility tokens:
;; Tailwind-style utility component using spread + collect primitives.
;; Use as a child of any element — injects classes onto the parent:
;;
;; (~cssx/tw "bg-yellow-199 text-violet-700 p-4 font-bold"
;; (div "content"))
;; (div (~cssx/tw "bg-yellow-199 text-violet-700 p-4 font-bold")
;; "content")
;;
;; (~cssx/tw "hover:bg-rose-500 md:text-xl"
;; (button "click me"))
;; (button (~cssx/tw "hover:bg-rose-500 md:text-xl")
;; "click me")
;;
;; Each token becomes a deterministic class + JIT CSS rule.
;; Rules are collected into the "cssx" bucket, flushed once by ~cssx/flush.
;; No wrapper elements, no per-element <style> tags.
;;
;; Reusable style variables:
;; (define fancy (~cssx/tw "font-bold text-violet-700 text-4xl"))
;; (div fancy "styled content")
;;
;; Each token becomes a deterministic class + JIT <style> rule.
;; This is one instance of the CSSX component pattern — other styling
;; components are possible with different vocabulary.
@@ -192,11 +200,11 @@
(fn (tmpl v)
(let ((i (index-of tmpl "{v}")))
(if (< i 0) tmpl
(let ((result (str (substring tmpl 0 i) v (substring tmpl (+ i 3) (length tmpl)))))
(let ((result (str (substring tmpl 0 i) v (substring tmpl (+ i 3) (len tmpl)))))
;; Handle templates with multiple {v} (e.g. padding-left:{v};padding-right:{v})
(let ((j (index-of result "{v}")))
(if (< j 0) result
(str (substring result 0 j) v (substring result (+ j 3) (length result))))))))))
(str (substring result 0 j) v (substring result (+ j 3) (len result))))))))))
;; Resolve a base utility token (no state/bp prefix) → CSS declaration string or nil.
;; Tries matchers in order: colour, text-size, text-align, font, spacing, display, max-w, rounded, opacity.
@@ -211,11 +219,11 @@
;; Colour utilities: bg-{colour}-{shade}, text-{colour}-{shade}, border-{colour}-{shade}
;; ---------------------------------------------------------
(and (get cssx-colour-props head)
(>= (length rest) 2)
(>= (len rest) 2)
(not (nil? (parse-int (last rest) nil)))
(not (nil? (get colour-bases (join "-" (slice rest 0 (- (length rest) 1)))))))
(not (nil? (get colour-bases (join "-" (slice rest 0 (- (len rest) 1)))))))
(let ((css-prop (get cssx-colour-props head))
(cname (join "-" (slice rest 0 (- (length rest) 1))))
(cname (join "-" (slice rest 0 (- (len rest) 1))))
(shade (parse-int (last rest) 0)))
(str css-prop ":" (colour cname shade)))
@@ -223,7 +231,7 @@
;; Text size: text-{size-name} (e.g. text-xl, text-2xl)
;; ---------------------------------------------------------
(and (= head "text")
(= (length rest) 1)
(= (len rest) 1)
(not (nil? (get cssx-sizes (first rest)))))
(get cssx-sizes (first rest))
@@ -234,7 +242,7 @@
;; Text alignment: text-left, text-center, text-right, text-justify
;; ---------------------------------------------------------
(and (= head "text")
(= (length rest) 1)
(= (len rest) 1)
(get cssx-alignments (first rest)))
(str "text-align:" (first rest))
@@ -242,7 +250,7 @@
;; Font weight: font-bold, font-semibold, etc.
;; ---------------------------------------------------------
(and (= head "font")
(= (length rest) 1)
(= (len rest) 1)
(not (nil? (get cssx-weights (first rest)))))
(str "font-weight:" (get cssx-weights (first rest)))
@@ -250,7 +258,7 @@
;; Font family: font-sans, font-serif, font-mono
;; ---------------------------------------------------------
(and (= head "font")
(= (length rest) 1)
(= (len rest) 1)
(not (nil? (get cssx-families (first rest)))))
(str "font-family:" (get cssx-families (first rest)))
@@ -258,7 +266,7 @@
;; Spacing: p-4, px-2, mt-8, mx-auto, etc.
;; ---------------------------------------------------------
(and (get cssx-spacing-props head)
(= (length rest) 1))
(= (len rest) 1))
(let ((tmpl (get cssx-spacing-props head))
(v (cssx-spacing-value (first rest))))
(if (nil? v) nil (cssx-template tmpl v)))
@@ -266,12 +274,12 @@
;; ---------------------------------------------------------
;; Display: block, flex, grid, hidden, inline, inline-block
;; ---------------------------------------------------------
(and (= (length parts) 1)
(and (= (len parts) 1)
(not (nil? (get cssx-displays head))))
(str "display:" (get cssx-displays head))
;; Inline-block, inline-flex (multi-word)
(and (= (length parts) 2)
(and (= (len parts) 2)
(not (nil? (get cssx-displays token))))
(str "display:" (get cssx-displays token))
@@ -279,7 +287,7 @@
;; Max-width: max-w-xl, max-w-3xl, max-w-prose
;; ---------------------------------------------------------
(and (= head "max")
(>= (length rest) 2)
(>= (len rest) 2)
(= (first rest) "w"))
(let ((val-name (join "-" (slice rest 1)))
(val (get cssx-max-widths val-name)))
@@ -305,7 +313,7 @@
;; Opacity: opacity-{n} (0-100)
;; ---------------------------------------------------------
(and (= head "opacity")
(= (length rest) 1))
(= (len rest) 1))
(let ((n (parse-int (first rest) nil)))
(if (nil? n) nil (str "opacity:" (/ n 100))))
@@ -313,7 +321,7 @@
;; Width/height: w-{n}, h-{n}, w-full, h-full, h-screen
;; ---------------------------------------------------------
(and (or (= head "w") (= head "h"))
(= (length rest) 1))
(= (len rest) 1))
(let ((prop (if (= head "w") "width" "height"))
(val (first rest)))
(cond
@@ -331,30 +339,30 @@
;; Gap: gap-{n}
;; ---------------------------------------------------------
(and (= head "gap")
(= (length rest) 1))
(= (len rest) 1))
(let ((v (cssx-spacing-value (first rest))))
(if (nil? v) nil (str "gap:" v)))
;; ---------------------------------------------------------
;; Text decoration: underline, no-underline, line-through
;; ---------------------------------------------------------
(and (= (length parts) 1)
(and (= (len parts) 1)
(or (= head "underline") (= head "overline") (= head "line-through")))
(str "text-decoration-line:" head)
(and (= (length parts) 2) (= head "no") (= (first rest) "underline"))
(and (= (len parts) 2) (= head "no") (= (first rest) "underline"))
"text-decoration-line:none"
;; ---------------------------------------------------------
;; Cursor: cursor-pointer, cursor-default, etc.
;; ---------------------------------------------------------
(and (= head "cursor") (= (length rest) 1))
(and (= head "cursor") (= (len rest) 1))
(str "cursor:" (first rest))
;; ---------------------------------------------------------
;; Overflow: overflow-hidden, overflow-auto, etc.
;; ---------------------------------------------------------
(and (= head "overflow") (= (length rest) 1))
(and (= head "overflow") (= (len rest) 1))
(str "overflow:" (first rest))
;; ---------------------------------------------------------
@@ -381,7 +389,7 @@
(define cssx-process-token
(fn (token)
(let ((colon-parts (split token ":"))
(n (length colon-parts)))
(n (len colon-parts)))
;; Extract state, bp, and base utility from colon-separated parts
(let ((bp nil) (state nil) (base nil))
;; 1 part: just utility
@@ -439,80 +447,54 @@
(join "" parts))))
;; =========================================================================
;; ~cssx/tw — macro that injects JIT classes onto the first child element
;; ~cssx/tw — spread component that injects JIT classes onto parent element
;;
;; Usage:
;; (~cssx/tw "bg-yellow-199"
;; (p "sunny"))
;; Usage — as a child of any element:
;; (div (~cssx/tw "bg-yellow-199 text-violet-700 p-4 font-bold")
;; (h1 "styled content"))
;;
;; (~cssx/tw "bg-yellow-199 text-violet-700 p-4 font-bold rounded-lg"
;; (div (h1 "styled content")))
;; (button (~cssx/tw "hover:bg-rose-500 focus:border-blue-400")
;; "interactive")
;;
;; (~cssx/tw "hover:bg-rose-500 focus:border-blue-400"
;; (button "interactive"))
;; Returns a spread value that merges :class and :data-tw onto the parent
;; element. Collects CSS rules into the "cssx" bucket for a single global
;; <style> flush. No wrapper element, no per-element <style> tags.
;;
;; (~cssx/tw "md:text-xl lg:p-8"
;; (section "responsive"))
;; Reusable as variables:
;; (define important (~cssx/tw "font-bold text-4xl"))
;; (div important "the queen is dead")
;;
;; Parses tokens at macro-expansion time, injects :class onto the first
;; child element (merging with any existing :class), and prepends a
;; <style> tag with the JIT CSS rules. No wrapper element.
;; Multiple spreads merge naturally:
;; (div (~cssx/tw "bg-red-500") (~cssx/tw "p-4") "content")
;; =========================================================================
;; Merge :class into an element's arg list.
;; If element already has :class, prepend our classes to its value.
;; If not, inject :class after the tag name.
(define cssx-inject-class
(fn (element cls-str)
(let ((tag (first element))
(args (slice element 1)))
(cons tag (cssx-merge-class-args args cls-str false)))))
(defcomp ~cssx/tw (tokens)
(let ((token-list (filter (fn (t) (not (= t "")))
(split (or tokens "") " ")))
(results (map cssx-process-token token-list))
(valid (filter (fn (r) (not (nil? r))) results))
(classes (map (fn (r) (get r "cls")) valid))
(rules (map (fn (r) (get r "rule")) valid))
(_ (for-each (fn (rule) (collect! "cssx" rule)) rules)))
;; Return spread: injects class + data-tw onto parent element
(if (empty? classes)
nil
(make-spread {"class" (join " " classes)
"data-tw" (or tokens "")}))))
;; Walk arg list: find :class keyword, merge value. If not found, inject at end.
(define cssx-merge-class-args
(fn (args cls-str found)
(if (empty? args)
;; End of args — if no :class was found, inject one
(if found (list) (list :class cls-str))
(let ((head (first args))
(tail (slice args 1)))
(if (and (not found)
(= (type-of head) "keyword")
(= (keyword-name head) "class"))
;; Found :class — merge with next arg (the value)
(if (empty? tail)
;; :class with no value — replace with ours
(append (list :class cls-str) (list))
;; :class with value — prepend our classes
(append (list :class (str cls-str " " (first tail)))
(cssx-merge-class-args (slice tail 1) cls-str true)))
;; Not :class — keep and continue
(cons head (cssx-merge-class-args tail cls-str found)))))))
(defmacro ~cssx/tw (tokens &rest children)
(let ((token-list (filter (fn (t) (not (= t ""))) (split tokens " ")))
(classes (list))
(rules (list)))
;; Process each token
(for-each (fn (tok)
(let ((r (cssx-process-token tok)))
(when (not (nil? r))
(append! classes (get r "cls"))
(append! rules (get r "rule")))))
token-list)
(let ((cls-str (join " " classes))
(rules-str (join "" rules))
(first-child (first children))
(rest-children (slice children 1)))
(if (empty? classes)
;; No resolved tokens — pass through unchanged
(if (= (length children) 1) first-child `(<> ,@children))
;; Inject class onto first child element
(if (and (list? first-child) (not (empty? first-child)))
;; First child is an element — inject :class, prepend <style>
(let ((injected (cssx-inject-class first-child cls-str)))
(if (empty? rest-children)
`(<> (style ,rules-str) ,injected)
`(<> (style ,rules-str) ,injected ,@rest-children)))
;; First child isn't an element — wrap everything in div
`(<> (style ,rules-str) (div :class ,cls-str ,@children)))))))
;; =========================================================================
;; ~cssx/flush — emit collected CSS rules as a single <style> tag
;;
;; Place once in the page (typically in the layout, before </body>).
;; Emits all accumulated CSSX rules and clears the bucket.
;;
;; Usage:
;; (~cssx/flush)
;; =========================================================================
(defcomp ~cssx/flush ()
(let ((rules (collected "cssx")))
(clear-collected! "cssx")
(when (not (empty? rules))
(raw! (str "<style data-cssx>" (join "" rules) "</style>")))))