Merge branch 'worktree-api-urls' into macros

This commit is contained in:
2026-03-13 02:58:39 +00:00
17 changed files with 1631 additions and 305 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) "\""
@@ -317,7 +341,7 @@
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"deftype" "defeffect"
"map" "map-indexed" "filter" "for-each"))
"map" "map-indexed" "filter" "for-each" "provide"))
(define async-render-form? :effects []
(fn ((name :as string))
@@ -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,66 @@
(= 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))))
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (async-eval (nth expr 1) env ctx))
(prov-val (async-eval (nth expr 2) env ctx))
(body-start 3)
(body-count (- (len expr) 3)))
(provide-push! prov-name prov-val)
(let ((result (if (= body-count 1)
(async-render (nth expr body-start) env ctx)
(let ((results (async-map-render (slice expr body-start) env ctx)))
(join "" (filter (fn (r) (not (spread? r))) results))))))
(provide-pop! prov-name)
result))
;; Fallback
:else
@@ -565,6 +615,9 @@
"dict" (async-aser-dict expr env ctx)
;; Spread — pass through for client rendering
"spread" expr
"list"
(if (empty? expr)
(list)
@@ -855,7 +908,7 @@
"define" "defcomp" "defmacro" "defstyle"
"defhandler" "defpage" "defquery" "defaction"
"begin" "do" "quote" "->" "set!" "defisland"
"deftype" "defeffect"))
"deftype" "defeffect" "provide"))
(define ASYNC_ASER_HO_NAMES
(list "map" "map-indexed" "filter" "for-each"))
@@ -993,6 +1046,17 @@
(= name "deftype") (= name "defeffect"))
(do (async-eval expr env ctx) nil)
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (async-eval (first args) env ctx))
(prov-val (async-eval (nth args 1) env ctx))
(result nil))
(provide-push! prov-name prov-val)
(for-each (fn (body) (set! result (async-aser body env ctx)))
(slice args 2))
(provide-pop! prov-name)
result)
;; Fallback
:else
(async-eval expr env ctx)))))
@@ -1250,6 +1314,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)
@@ -332,7 +359,7 @@
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"map" "map-indexed" "filter" "for-each" "portal"
"error-boundary"))
"error-boundary" "provide"))
(define render-dom-form? :effects []
(fn ((name :as string))
@@ -571,6 +598,19 @@
coll)
frag)
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (trampoline (eval-expr (nth expr 1) env)))
(prov-val (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(provide-push! prov-name prov-val)
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 3 (len expr)))
(provide-pop! prov-name)
frag)
;; Fallback
:else
(render-to-dom (trampoline (eval-expr expr env)) env ns))))

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)))))
@@ -53,7 +56,7 @@
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"deftype" "defeffect"
"map" "map-indexed" "filter" "for-each"))
"map" "map-indexed" "filter" "for-each" "provide"))
(define render-html-form? :effects []
(fn ((name :as string))
@@ -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,84 @@
(= 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))))
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (trampoline (eval-expr (nth expr 1) env)))
(prov-val (trampoline (eval-expr (nth expr 2) env)))
(body-start 3)
(body-count (- (len expr) 3)))
(provide-push! prov-name prov-val)
(let ((result (if (= body-count 1)
(render-to-html (nth expr body-start) env)
(join "" (filter (fn (r) (not (spread? r)))
(map (fn (i) (render-to-html (nth expr i) env))
(range body-start (+ body-start body-count))))))))
(provide-pop! prov-name)
result))
;; Fallback
:else
@@ -281,10 +307,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 +327,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 +375,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 +425,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 +487,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)))
@@ -171,7 +174,7 @@
"defhandler" "defpage" "defquery" "defaction" "defrelation"
"begin" "do" "quote" "quasiquote"
"->" "set!" "letrec" "dynamic-wind" "defisland"
"deftype" "defeffect"))
"deftype" "defeffect" "provide"))
(define HO_FORM_NAMES
(list "map" "map-indexed" "filter" "reduce"
@@ -309,6 +312,17 @@
(= name "deftype") (= name "defeffect"))
(do (trampoline (eval-expr expr env)) nil)
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (trampoline (eval-expr (first args) env)))
(prov-val (trampoline (eval-expr (nth args 1) env)))
(result nil))
(provide-push! prov-name prov-val)
(for-each (fn (body) (set! result (aser body env)))
(slice args 2))
(provide-pop! prov-name)
result)
;; Everything else — evaluate normally
:else
(trampoline (eval-expr expr env))))))

View File

@@ -285,6 +285,19 @@ 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",
"provide-push!": "provide_push",
"provide-pop!": "provide_pop",
"context": "sx_context",
"emit!": "sx_emit",
"emitted": "sx_emitted",
"is-raw-html?": "is_raw_html",
"async-coroutine?": "is_async_coroutine",
"async-await!": "async_await",

View File

@@ -313,3 +313,109 @@
: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).")
;; --------------------------------------------------------------------------
;; Tier 5: Dynamic scope — render-time provide/context/emit!
;;
;; `provide` is a special form (not a primitive) that creates a named scope
;; with a value and an empty accumulator. `context` reads the value from the
;; nearest enclosing provider. `emit!` appends to the accumulator, `emitted`
;; reads the accumulated values.
;;
;; The platform must implement per-name stacks. Each entry has a value and
;; an emitted list. `provide-push!`/`provide-pop!` manage the stack.
;; --------------------------------------------------------------------------
(declare-tier :dynamic-scope :source "eval.sx")
(declare-spread-primitive "provide-push!"
:params (name value)
:returns "nil"
:effects [mutation]
:doc "Push a provider scope with name and value (platform internal).")
(declare-spread-primitive "provide-pop!"
:params (name)
:returns "nil"
:effects [mutation]
:doc "Pop the most recent provider scope for name (platform internal).")
(declare-spread-primitive "context"
:params (name &rest default)
:returns "any"
:effects []
:doc "Read value from nearest enclosing provide with matching name.
Errors if no provider and no default given.")
(declare-spread-primitive "emit!"
:params (name value)
:returns "nil"
:effects [mutation]
:doc "Append value to nearest enclosing provide's accumulator.
Errors if no matching provider. No deduplication.")
(declare-spread-primitive "emitted"
:params (name)
:returns "list"
:effects []
:doc "Return list of values emitted into nearest matching provider.
Empty list if no provider.")

View File

@@ -162,6 +162,7 @@
(= name "reset") (sf-reset args env)
(= name "shift") (sf-shift args env)
(= name "dynamic-wind") (sf-dynamic-wind args env)
(= name "provide") (sf-provide args env)
;; Higher-order forms
(= name "map") (ho-map args env)
@@ -949,6 +950,25 @@
result))))
;; --------------------------------------------------------------------------
;; 6a2. provide — render-time dynamic scope
;; --------------------------------------------------------------------------
;;
;; (provide name value body...) — push a named scope with value and empty
;; accumulator, evaluate body, pop scope. Returns last body result.
(define sf-provide
(fn ((args :as list) (env :as dict))
(let ((name (trampoline (eval-expr (first args) env)))
(val (trampoline (eval-expr (nth args 1) env)))
(body-exprs (slice args 2))
(result nil))
(provide-push! name val)
(for-each (fn (e) (set! result (trampoline (eval-expr e env)))) body-exprs)
(provide-pop! name)
result)))
;; --------------------------------------------------------------------------
;; 6b. Macro expansion
;; --------------------------------------------------------------------------
@@ -1064,6 +1084,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 +1110,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 +1157,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,18 @@
"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"
"provide-push!" "providePush"
"provide-pop!" "providePop"
"context" "sxContext"
"emit!" "sxEmit"
"emitted" "sxEmitted"
})

View File

@@ -864,6 +864,12 @@ PREAMBLE = '''\
function RawHTML(html) { this.html = html; }
RawHTML.prototype._raw = true;
function SxSpread(attrs) { this.attrs = attrs || {}; }
SxSpread.prototype._spread = true;
var _collectBuckets = {};
var _provideStacks = {};
function isSym(x) { return x != null && x._sym === true; }
function isKw(x) { return x != null && x._kw === true; }
@@ -1073,6 +1079,22 @@ 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;
// provide/context/emit! — render-time dynamic scope
PRIMITIVES["provide-push!"] = providePush;
PRIMITIVES["provide-pop!"] = providePop;
PRIMITIVES["context"] = sxContext;
PRIMITIVES["emit!"] = sxEmit;
PRIMITIVES["emitted"] = sxEmitted;
''',
}
# Modules to include by default (all)
_ALL_JS_MODULES = list(PRIMITIVES_JS_MODULES.keys())
@@ -1108,6 +1130,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 +1153,51 @@ 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 providePush(name, value) {
if (!_provideStacks[name]) _provideStacks[name] = [];
_provideStacks[name].push({value: value !== undefined ? value : NIL, emitted: []});
}
function providePop(name) {
if (_provideStacks[name] && _provideStacks[name].length) _provideStacks[name].pop();
}
function sxContext(name) {
if (_provideStacks[name] && _provideStacks[name].length) {
return _provideStacks[name][_provideStacks[name].length - 1].value;
}
if (arguments.length > 1) return arguments[1];
throw new Error("No provider for: " + name);
}
function sxEmit(name, value) {
if (_provideStacks[name] && _provideStacks[name].length) {
_provideStacks[name][_provideStacks[name].length - 1].emitted.push(value);
} else {
throw new Error("No provider for emit!: " + name);
}
return NIL;
}
function sxEmitted(name) {
if (_provideStacks[name] && _provideStacks[name].length) {
return _provideStacks[name][_provideStacks[name].length - 1].emitted.slice();
}
return [];
}
function lambdaParams(f) { return f.params; }
function lambdaBody(f) { return f.body; }
function lambdaClosure(f) { return f.closure; }
@@ -3154,6 +3222,17 @@ 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(' providePush: providePush,')
api_lines.append(' providePop: providePop,')
api_lines.append(' context: sxContext,')
api_lines.append(' emit: sxEmit,')
api_lines.append(' emitted: sxEmitted,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
api_lines.append('')

View File

@@ -84,6 +84,63 @@ 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 = {}
# Render-time dynamic scope stacks (provide/context/emit!)
_provide_stacks: dict[str, list[dict]] = {}
def provide_push(name, value=None):
"""Push a provider scope with name, value, and empty emitted list."""
_provide_stacks.setdefault(name, []).append({"value": value, "emitted": []})
def provide_pop(name):
"""Pop the most recent provider scope for name."""
if name in _provide_stacks and _provide_stacks[name]:
_provide_stacks[name].pop()
def sx_context(name, *default):
"""Read value from nearest enclosing provider. Error if no provider and no default."""
if name in _provide_stacks and _provide_stacks[name]:
return _provide_stacks[name][-1]["value"]
if default:
return default[0]
raise RuntimeError(f"No provider for: {name}")
def sx_emit(name, value):
"""Append value to nearest enclosing provider's accumulator. Error if no provider."""
if name in _provide_stacks and _provide_stacks[name]:
_provide_stacks[name][-1]["emitted"].append(value)
else:
raise RuntimeError(f"No provider for emit!: {name}")
return NIL
def sx_emitted(name):
"""Return list of values emitted into nearest matching provider."""
if name in _provide_stacks and _provide_stacks[name]:
return list(_provide_stacks[name][-1]["emitted"])
return []
def sx_truthy(x):
"""SX truthiness: everything is truthy except False, None, and NIL."""
if x is False:
@@ -167,6 +224,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 +329,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 +972,22 @@ 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
# provide/context/emit! — render-time dynamic scope
PRIMITIVES["provide-push!"] = provide_push
PRIMITIVES["provide-pop!"] = provide_pop
PRIMITIVES["context"] = sx_context
PRIMITIVES["emit!"] = sx_emit
PRIMITIVES["emitted"] = sx_emitted
''',
}

View File

@@ -245,6 +245,18 @@
"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"
"provide-push!" "provide_push"
"provide-pop!" "provide_pop"
"context" "sx_context"
"emit!" "sx_emit"
"emitted" "sx_emitted"
})

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,23 @@
;; (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
;;
;; Dynamic scope (provide/context/emit!):
;; (provide-push! name val) → void
;; (provide-pop! name) → void
;; (context name &rest def) → value from nearest provider
;; (emit! name value) → void (append to provider accumulator)
;; (emitted name) → list of emitted values
;;
;; From parser.sx:
;; (sx-serialize val) → SX source string (aliased as serialize above)
;; --------------------------------------------------------------------------

View File

@@ -43,6 +43,63 @@ 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 = {}
# Render-time dynamic scope stacks (provide/context/emit!)
_provide_stacks: dict[str, list[dict]] = {}
def provide_push(name, value=None):
"""Push a provider scope with name, value, and empty emitted list."""
_provide_stacks.setdefault(name, []).append({"value": value, "emitted": []})
def provide_pop(name):
"""Pop the most recent provider scope for name."""
if name in _provide_stacks and _provide_stacks[name]:
_provide_stacks[name].pop()
def sx_context(name, *default):
"""Read value from nearest enclosing provider. Error if no provider and no default."""
if name in _provide_stacks and _provide_stacks[name]:
return _provide_stacks[name][-1]["value"]
if default:
return default[0]
raise RuntimeError(f"No provider for: {name}")
def sx_emit(name, value):
"""Append value to nearest enclosing provider's accumulator. Error if no provider."""
if name in _provide_stacks and _provide_stacks[name]:
_provide_stacks[name][-1]["emitted"].append(value)
else:
raise RuntimeError(f"No provider for emit!: {name}")
return NIL
def sx_emitted(name):
"""Return list of values emitted into nearest matching provider."""
if name in _provide_stacks and _provide_stacks[name]:
return list(_provide_stacks[name][-1]["emitted"])
return []
def sx_truthy(x):
"""SX truthiness: everything is truthy except False, None, and NIL."""
if x is False:
@@ -126,6 +183,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 +288,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 +938,21 @@ 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
# provide/context/emit! — render-time dynamic scope
PRIMITIVES["provide-push!"] = provide_push
PRIMITIVES["provide-pop!"] = provide_pop
PRIMITIVES["context"] = sx_context
PRIMITIVES["emit!"] = sx_emit
PRIMITIVES["emitted"] = sx_emitted
def is_primitive(name):
if name in PRIMITIVES:
return True
@@ -1289,6 +1395,8 @@ def eval_list(expr, env):
return sf_shift(args, env)
elif sx_truthy((name == 'dynamic-wind')):
return sf_dynamic_wind(args, env)
elif sx_truthy((name == 'provide')):
return sf_provide(args, env)
elif sx_truthy((name == 'map')):
return ho_map(args, env)
elif sx_truthy((name == 'map-indexed')):
@@ -1780,6 +1888,19 @@ def sf_dynamic_wind(args, env):
call_thunk(after, env)
return result
# sf-provide
def sf_provide(args, env):
_cells = {}
name = trampoline(eval_expr(first(args), env))
val = trampoline(eval_expr(nth(args, 1), env))
body_exprs = slice(args, 2)
_cells['result'] = NIL
provide_push(name, val)
for e in body_exprs:
_cells['result'] = trampoline(eval_expr(e, env))
provide_pop(name)
return _cells['result']
# expand-macro
def expand_macro(mac, raw_args, env):
local = env_merge(macro_closure(mac), env)
@@ -2056,6 +2177,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 +2221,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,11 +2244,13 @@ 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))
# RENDER_HTML_FORMS
RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'deftype', 'defeffect', 'map', 'map-indexed', 'filter', 'for-each']
RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'deftype', 'defeffect', 'map', 'map-indexed', 'filter', 'for-each', 'provide']
# render-html-form?
def is_render_html_form(name):
@@ -2123,12 +2263,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 +2309,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 +2324,43 @@ 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)))
elif sx_truthy((name == 'provide')):
prov_name = trampoline(eval_expr(nth(expr, 1), env))
prov_val = trampoline(eval_expr(nth(expr, 2), env))
body_start = 3
body_count = (len(expr) - 3)
provide_push(prov_name, prov_val)
result = (render_to_html(nth(expr, body_start), env) if sx_truthy((body_count == 1)) else join('', filter(lambda r: (not sx_truthy(is_spread(r))), map(lambda i: render_to_html(nth(expr, i), env), range(body_start, (body_start + body_count))))))
provide_pop(prov_name)
return result
else:
return render_value_to_html(trampoline(eval_expr(expr, env)), env)
@@ -2218,7 +2379,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 +2393,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 +2412,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 +2429,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 +2449,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 +2510,8 @@ def aser(expr, env):
return []
else:
return aser_list(expr, env)
elif _match == 'spread':
return expr
else:
return expr
@@ -2400,7 +2599,7 @@ def aser_call(name, args, env):
return sx_str('(', join(' ', parts), ')')
# SPECIAL_FORM_NAMES
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']
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', 'provide']
# HO_FORM_NAMES
HO_FORM_NAMES = ['map', 'map-indexed', 'filter', 'reduce', 'some', 'every?', 'for-each']
@@ -2497,6 +2696,15 @@ def aser_special(name, expr, env):
elif sx_truthy(((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') if sx_truthy((name == 'defhandler')) else ((name == 'defpage') if sx_truthy((name == 'defpage')) else ((name == 'defquery') if sx_truthy((name == 'defquery')) else ((name == 'defaction') if sx_truthy((name == 'defaction')) else ((name == 'defrelation') if sx_truthy((name == 'defrelation')) else ((name == 'deftype') if sx_truthy((name == 'deftype')) else (name == 'defeffect')))))))))))):
trampoline(eval_expr(expr, env))
return NIL
elif sx_truthy((name == 'provide')):
prov_name = trampoline(eval_expr(first(args), env))
prov_val = trampoline(eval_expr(nth(args, 1), env))
_cells['result'] = NIL
provide_push(prov_name, prov_val)
for body in slice(args, 2):
_cells['result'] = aser(body, env)
provide_pop(prov_name)
return _cells['result']
else:
return trampoline(eval_expr(expr, env))
@@ -3441,6 +3649,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 +3682,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 +3732,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 +3776,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 +3794,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>')
@@ -3613,7 +3838,7 @@ async def async_map_render(exprs, env, ctx):
return results
# ASYNC_RENDER_FORMS
ASYNC_RENDER_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'deftype', 'defeffect', 'map', 'map-indexed', 'filter', 'for-each']
ASYNC_RENDER_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'deftype', 'defeffect', 'map', 'map-indexed', 'filter', 'for-each', 'provide']
# async-render-form?
def async_render_form_p(name):
@@ -3634,7 +3859,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 +3874,43 @@ 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))))
elif sx_truthy((name == 'provide')):
prov_name = (await async_eval(nth(expr, 1), env, ctx))
prov_val = (await async_eval(nth(expr, 2), env, ctx))
body_start = 3
body_count = (len(expr) - 3)
provide_push(prov_name, prov_val)
result = ((await async_render(nth(expr, body_start), env, ctx)) if sx_truthy((body_count == 1)) else (lambda results: join('', filter(lambda r: (not sx_truthy(is_spread(r))), results)))((await async_map_render(slice(expr, body_start), env, ctx))))
provide_pop(prov_name)
return result
else:
return (await async_render((await async_eval(expr, env, ctx)), env, ctx))
@@ -3791,6 +4037,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 []
@@ -3988,7 +4236,7 @@ async def async_aser_call(name, args, env, ctx):
return make_sx_expr(sx_str('(', join(' ', parts), ')'))
# ASYNC_ASER_FORM_NAMES
ASYNC_ASER_FORM_NAMES = ['if', 'when', 'cond', 'case', 'and', 'or', 'let', 'let*', 'lambda', 'fn', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'defpage', 'defquery', 'defaction', 'begin', 'do', 'quote', '->', 'set!', 'defisland', 'deftype', 'defeffect']
ASYNC_ASER_FORM_NAMES = ['if', 'when', 'cond', 'case', 'and', 'or', 'let', 'let*', 'lambda', 'fn', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'defpage', 'defquery', 'defaction', 'begin', 'do', 'quote', '->', 'set!', 'defisland', 'deftype', 'defeffect', 'provide']
# ASYNC_ASER_HO_NAMES
ASYNC_ASER_HO_NAMES = ['map', 'map-indexed', 'filter', 'for-each']
@@ -4082,6 +4330,15 @@ async def dispatch_async_aser_form(name, expr, env, ctx):
elif sx_truthy(((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') if sx_truthy((name == 'defhandler')) else ((name == 'defpage') if sx_truthy((name == 'defpage')) else ((name == 'defquery') if sx_truthy((name == 'defquery')) else ((name == 'defaction') if sx_truthy((name == 'defaction')) else ((name == 'deftype') if sx_truthy((name == 'deftype')) else (name == 'defeffect'))))))))))):
(await async_eval(expr, env, ctx))
return NIL
elif sx_truthy((name == 'provide')):
prov_name = (await async_eval(first(args), env, ctx))
prov_val = (await async_eval(nth(args, 1), env, ctx))
_cells['result'] = NIL
provide_push(prov_name, prov_val)
for body in slice(args, 2):
_cells['result'] = (await async_aser(body, env, ctx))
provide_pop(prov_name)
return _cells['result']
else:
return (await async_eval(expr, env, ctx))

View File

@@ -1,68 +1,30 @@
;; @client — send all define forms to browser for client-side use.
;; CSSX — computed CSS from s-expressions.
;;
;; Generic mechanism: cssx is a macro that groups CSS property declarations.
;; The vocabulary (property mappings, value functions) is pluggable — the
;; Tailwind-inspired defaults below are just one possible style system.
;; Tailwind-style utility component using spread + collect primitives.
;; Use as a child of any element — injects classes onto the parent:
;;
;; Usage:
;; (cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono"))
;; (:bg (colour "stone" 50)))
;; (div (~cssx/tw "bg-yellow-199 text-violet-700 p-4 font-bold")
;; "content")
;;
;; Each group is (:keyword value ...modifiers):
;; - keyword maps to a CSS property via cssx-properties dict
;; - value is the CSS value for that property
;; - modifiers are extra CSS declaration strings, concatenated in
;; (button (~cssx/tw "hover:bg-rose-500 md:text-xl")
;; "click me")
;;
;; Single group:
;; (cssx (:text (colour "violet" 699)))
;; 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.
;;
;; Modifiers without a colour:
;; (cssx (:text nil (size "4xl") (weight "bold")))
;; Reusable style variables:
;; (define fancy (~cssx/tw "font-bold text-violet-700 text-4xl"))
;; (div fancy "styled content")
;;
;; Unknown keywords pass through as raw CSS property names:
;; (cssx (:outline (colour "red" 500))) → "outline:hsl(0,72%,53%);"
;;
;; Standalone modifiers work outside cssx too:
;; :style (size "4xl")
;; :style (str (weight "bold") (family "mono"))
;; This is one instance of the CSSX component pattern — other styling
;; components are possible with different vocabulary.
;; =========================================================================
;; Layer 1: Generic mechanism — cssx macro + cssxgroup function
;; Colour data — hue/saturation bases + shade-to-lightness curve
;; =========================================================================
;; Property keyword → CSS property name. Extend this dict for new mappings.
(define cssx-properties
{"text" "color"
"bg" "background-color"
"border" "border-color"})
;; Evaluate one property group: (:text value modifier1 modifier2 ...)
;; If value is nil, only modifiers are emitted (no property declaration).
;; NOTE: name must NOT contain hyphens — the evaluator's isRenderExpr check
;; treats (hyphenated-name :keyword ...) as a custom HTML element.
(define cssxgroup
(fn (prop value b c d e)
(let ((css-prop (or (get cssx-properties prop) prop)))
(str (if (nil? value) "" (str css-prop ":" value ";"))
(or b "") (or c "") (or d "") (or e "")))))
;; cssx macro — takes one or more property groups, expands to (str ...).
;; (cssx (:text val ...) (:bg val ...))
;; → (str (cssxgroup :text val ...) (cssxgroup :bg val ...))
(defmacro cssx (&rest groups)
`(str ,@(map (fn (g) (cons 'cssxgroup g)) groups)))
;; =========================================================================
;; Layer 2: Value vocabulary — colour, size, weight, family
;; These are independent functions. Use inside cssx groups or standalone.
;; Replace or extend with any style system.
;; =========================================================================
;; ---------------------------------------------------------------------------
;; Colour — compute CSS colour value from name + shade
;; ---------------------------------------------------------------------------
(define colour-bases
{"violet" {"h" 263 "s" 70}
"purple" {"h" 271 "s" 81}
@@ -84,7 +46,9 @@
"slate" {"h" 215 "s" 16}
"gray" {"h" 220 "s" 9}
"zinc" {"h" 240 "s" 5}
"neutral" {"h" 0 "s" 0}})
"neutral" {"h" 0 "s" 0}
"white" {"h" 0 "s" 0}
"black" {"h" 0 "s" 0}})
(define lerp (fn (a b t) (+ a (* t (- b a)))))
@@ -114,29 +78,50 @@
(l (shade-to-lightness shade)))
(str "hsl(" h "," s "%," (round l) "%)"))))))
;; ---------------------------------------------------------------------------
;; Font sizes — named size → font-size + line-height (Tailwind v3 scale)
;; ---------------------------------------------------------------------------
;; =========================================================================
;; Lookup tables — all the value vocabularies
;; =========================================================================
;; Colour property shorthands → CSS property name
(define cssx-colour-props
{"bg" "background-color"
"text" "color"
"border" "border-color"})
;; Spacing property shorthands → CSS declaration template ({v} = computed value)
(define cssx-spacing-props
{"p" "padding:{v}"
"px" "padding-left:{v};padding-right:{v}"
"py" "padding-top:{v};padding-bottom:{v}"
"pt" "padding-top:{v}"
"pb" "padding-bottom:{v}"
"pl" "padding-left:{v}"
"pr" "padding-right:{v}"
"m" "margin:{v}"
"mx" "margin-left:{v};margin-right:{v}"
"my" "margin-top:{v};margin-bottom:{v}"
"mt" "margin-top:{v}"
"mb" "margin-bottom:{v}"
"ml" "margin-left:{v}"
"mr" "margin-right:{v}"})
;; Named font sizes (Tailwind v3 scale)
(define cssx-sizes
{"xs" "font-size:0.75rem;line-height:1rem;"
"sm" "font-size:0.875rem;line-height:1.25rem;"
"base" "font-size:1rem;line-height:1.5rem;"
"lg" "font-size:1.125rem;line-height:1.75rem;"
"xl" "font-size:1.25rem;line-height:1.75rem;"
"2xl" "font-size:1.5rem;line-height:2rem;"
"3xl" "font-size:1.875rem;line-height:2.25rem;"
"4xl" "font-size:2.25rem;line-height:2.5rem;"
"5xl" "font-size:3rem;line-height:1;"
"6xl" "font-size:3.75rem;line-height:1;"
"7xl" "font-size:4.5rem;line-height:1;"
"8xl" "font-size:6rem;line-height:1;"
"9xl" "font-size:8rem;line-height:1;"})
;; ---------------------------------------------------------------------------
;; Font weights — named weight → numeric value
;; ---------------------------------------------------------------------------
{"xs" "font-size:0.75rem;line-height:1rem"
"sm" "font-size:0.875rem;line-height:1.25rem"
"base" "font-size:1rem;line-height:1.5rem"
"lg" "font-size:1.125rem;line-height:1.75rem"
"xl" "font-size:1.25rem;line-height:1.75rem"
"2xl" "font-size:1.5rem;line-height:2rem"
"3xl" "font-size:1.875rem;line-height:2.25rem"
"4xl" "font-size:2.25rem;line-height:2.5rem"
"5xl" "font-size:3rem;line-height:1"
"6xl" "font-size:3.75rem;line-height:1"
"7xl" "font-size:4.5rem;line-height:1"
"8xl" "font-size:6rem;line-height:1"
"9xl" "font-size:8rem;line-height:1"})
;; Named font weights
(define cssx-weights
{"thin" "100"
"extralight" "200"
@@ -148,67 +133,25 @@
"extrabold" "800"
"black" "900"})
;; ---------------------------------------------------------------------------
;; Font families — named family → CSS font stack
;; ---------------------------------------------------------------------------
;; Named font families
(define cssx-families
{"sans" "ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,\"Noto Sans\",sans-serif"
"serif" "ui-serif,Georgia,Cambria,\"Times New Roman\",Times,serif"
"mono" "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace"})
;; ---------------------------------------------------------------------------
;; Standalone modifier functions — return CSS declaration strings
;; Each returns a complete CSS declaration string. Use inside cssx groups
;; or standalone on :style with str.
;; ---------------------------------------------------------------------------
;; Text alignment keywords
(define cssx-alignments
{"left" true "center" true "right" true "justify" true})
;; -- Typography --
(define size
(fn (s) (or (get cssx-sizes s) (str "font-size:" s ";"))))
(define weight
(fn (w)
(let ((v (get cssx-weights w)))
(str "font-weight:" (or v w) ";"))))
(define family
(fn (f)
(let ((v (get cssx-families f)))
(str "font-family:" (or v f) ";"))))
(define align
(fn (a) (str "text-align:" a ";")))
(define decoration
(fn (d) (str "text-decoration:" d ";")))
;; -- Spacing (Tailwind scale: 1 unit = 0.25rem) --
(define spacing (fn (n) (str (* n 0.25) "rem")))
(define p (fn (n) (str "padding:" (spacing n) ";")))
(define px (fn (n) (str "padding-left:" (spacing n) ";padding-right:" (spacing n) ";")))
(define py (fn (n) (str "padding-top:" (spacing n) ";padding-bottom:" (spacing n) ";")))
(define pt (fn (n) (str "padding-top:" (spacing n) ";")))
(define pb (fn (n) (str "padding-bottom:" (spacing n) ";")))
(define pl (fn (n) (str "padding-left:" (spacing n) ";")))
(define pr (fn (n) (str "padding-right:" (spacing n) ";")))
(define m (fn (n) (str "margin:" (spacing n) ";")))
(define mx (fn (n) (str "margin-left:" (spacing n) ";margin-right:" (spacing n) ";")))
(define my (fn (n) (str "margin-top:" (spacing n) ";margin-bottom:" (spacing n) ";")))
(define mt (fn (n) (str "margin-top:" (spacing n) ";")))
(define mb (fn (n) (str "margin-bottom:" (spacing n) ";")))
(define ml (fn (n) (str "margin-left:" (spacing n) ";")))
(define mr (fn (n) (str "margin-right:" (spacing n) ";")))
(define mx-auto (fn () "margin-left:auto;margin-right:auto;"))
;; -- Display & layout --
(define display (fn (d) (str "display:" d ";")))
(define max-w (fn (w) (str "max-width:" w ";")))
;; Display keywords → CSS value
(define cssx-displays
{"block" "block"
"inline" "inline"
"inline-block" "inline-block"
"flex" "flex"
"inline-flex" "inline-flex"
"grid" "grid"
"hidden" "none"})
;; Named max-widths (Tailwind scale)
(define cssx-max-widths
@@ -216,4 +159,342 @@
"lg" "32rem" "xl" "36rem" "2xl" "42rem"
"3xl" "48rem" "4xl" "56rem" "5xl" "64rem"
"6xl" "72rem" "7xl" "80rem"
"full" "100%" "none" "none"})
"full" "100%" "none" "none"
"prose" "65ch" "screen" "100vw"})
;; Responsive breakpoints (mobile-first min-width)
(define cssx-breakpoints
{"sm" "640px"
"md" "768px"
"lg" "1024px"
"xl" "1280px"
"2xl" "1536px"})
;; Pseudo-class states
(define cssx-states
{"hover" ":hover"
"focus" ":focus"
"active" ":active"
"focus-within" ":focus-within"
"focus-visible" ":focus-visible"
"first" ":first-child"
"last" ":last-child"})
;; =========================================================================
;; Utility resolver — token string → CSS declarations
;; =========================================================================
;; Spacing value: number → rem, "auto" → "auto", "px" → "1px"
(define cssx-spacing-value
(fn (v)
(cond
(= v "auto") "auto"
(= v "px") "1px"
(= v "0") "0px"
true (let ((n (parse-int v nil)))
(if (nil? n) nil
(str (* n 0.25) "rem"))))))
;; Replace {v} in a template string with a value
(define cssx-template
(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) (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) (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.
(define cssx-resolve
(fn (token)
(let ((parts (split token "-")))
(if (empty? parts) nil
(let ((head (first parts))
(rest (slice parts 1)))
(cond
;; ---------------------------------------------------------
;; Colour utilities: bg-{colour}-{shade}, text-{colour}-{shade}, border-{colour}-{shade}
;; ---------------------------------------------------------
(and (get cssx-colour-props head)
(>= (len rest) 2)
(not (nil? (parse-int (last rest) nil)))
(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 (- (len rest) 1))))
(shade (parse-int (last rest) 0)))
(str css-prop ":" (colour cname shade)))
;; ---------------------------------------------------------
;; Text size: text-{size-name} (e.g. text-xl, text-2xl)
;; ---------------------------------------------------------
(and (= head "text")
(= (len rest) 1)
(not (nil? (get cssx-sizes (first rest)))))
(get cssx-sizes (first rest))
;; Also handle text-2xl etc where rest might be ("2xl") — covered above
;; But also "text-sm" etc — covered above since rest is ("sm")
;; ---------------------------------------------------------
;; Text alignment: text-left, text-center, text-right, text-justify
;; ---------------------------------------------------------
(and (= head "text")
(= (len rest) 1)
(get cssx-alignments (first rest)))
(str "text-align:" (first rest))
;; ---------------------------------------------------------
;; Font weight: font-bold, font-semibold, etc.
;; ---------------------------------------------------------
(and (= head "font")
(= (len rest) 1)
(not (nil? (get cssx-weights (first rest)))))
(str "font-weight:" (get cssx-weights (first rest)))
;; ---------------------------------------------------------
;; Font family: font-sans, font-serif, font-mono
;; ---------------------------------------------------------
(and (= head "font")
(= (len rest) 1)
(not (nil? (get cssx-families (first rest)))))
(str "font-family:" (get cssx-families (first rest)))
;; ---------------------------------------------------------
;; Spacing: p-4, px-2, mt-8, mx-auto, etc.
;; ---------------------------------------------------------
(and (get cssx-spacing-props head)
(= (len rest) 1))
(let ((tmpl (get cssx-spacing-props head))
(v (cssx-spacing-value (first rest))))
(if (nil? v) nil (cssx-template tmpl v)))
;; ---------------------------------------------------------
;; Display: block, flex, grid, hidden, inline, inline-block
;; ---------------------------------------------------------
(and (= (len parts) 1)
(not (nil? (get cssx-displays head))))
(str "display:" (get cssx-displays head))
;; Inline-block, inline-flex (multi-word)
(and (= (len parts) 2)
(not (nil? (get cssx-displays token))))
(str "display:" (get cssx-displays token))
;; ---------------------------------------------------------
;; Max-width: max-w-xl, max-w-3xl, max-w-prose
;; ---------------------------------------------------------
(and (= head "max")
(>= (len rest) 2)
(= (first rest) "w"))
(let ((val-name (join "-" (slice rest 1)))
(val (get cssx-max-widths val-name)))
(if (nil? val) nil (str "max-width:" val)))
;; ---------------------------------------------------------
;; Rounded: rounded, rounded-lg, rounded-full, etc.
;; ---------------------------------------------------------
(= head "rounded")
(cond
(empty? rest) "border-radius:0.25rem"
(= (first rest) "none") "border-radius:0"
(= (first rest) "sm") "border-radius:0.125rem"
(= (first rest) "md") "border-radius:0.375rem"
(= (first rest) "lg") "border-radius:0.5rem"
(= (first rest) "xl") "border-radius:0.75rem"
(= (first rest) "2xl") "border-radius:1rem"
(= (first rest) "3xl") "border-radius:1.5rem"
(= (first rest) "full") "border-radius:9999px"
true nil)
;; ---------------------------------------------------------
;; Opacity: opacity-{n} (0-100)
;; ---------------------------------------------------------
(and (= head "opacity")
(= (len rest) 1))
(let ((n (parse-int (first rest) nil)))
(if (nil? n) nil (str "opacity:" (/ n 100))))
;; ---------------------------------------------------------
;; Width/height: w-{n}, h-{n}, w-full, h-full, h-screen
;; ---------------------------------------------------------
(and (or (= head "w") (= head "h"))
(= (len rest) 1))
(let ((prop (if (= head "w") "width" "height"))
(val (first rest)))
(cond
(= val "full") (str prop ":100%")
(= val "screen") (str prop (if (= head "w") ":100vw" ":100vh"))
(= val "auto") (str prop ":auto")
(= val "min") (str prop ":min-content")
(= val "max") (str prop ":max-content")
(= val "fit") (str prop ":fit-content")
true (let ((n (parse-int val nil)))
(if (nil? n) nil
(str prop ":" (* n 0.25) "rem")))))
;; ---------------------------------------------------------
;; Gap: gap-{n}
;; ---------------------------------------------------------
(and (= head "gap")
(= (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 (= (len parts) 1)
(or (= head "underline") (= head "overline") (= head "line-through")))
(str "text-decoration-line:" head)
(and (= (len parts) 2) (= head "no") (= (first rest) "underline"))
"text-decoration-line:none"
;; ---------------------------------------------------------
;; Cursor: cursor-pointer, cursor-default, etc.
;; ---------------------------------------------------------
(and (= head "cursor") (= (len rest) 1))
(str "cursor:" (first rest))
;; ---------------------------------------------------------
;; Overflow: overflow-hidden, overflow-auto, etc.
;; ---------------------------------------------------------
(and (= head "overflow") (= (len rest) 1))
(str "overflow:" (first rest))
;; ---------------------------------------------------------
;; Transition: transition, transition-colors, etc.
;; ---------------------------------------------------------
(and (= head "transition") (empty? rest))
"transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(0.4,0,0.2,1);transition-duration:150ms"
(and (= head "transition") (= (first rest) "colors"))
"transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(0.4,0,0.2,1);transition-duration:150ms"
;; ---------------------------------------------------------
;; Fallback: unrecognised → nil (skip)
;; ---------------------------------------------------------
true nil))))))
;; =========================================================================
;; Token processor — full token with optional state/bp prefixes
;; =========================================================================
;; Process one token string → {:cls "sx-..." :rule ".sx-...{...}"} or nil
;; Token format: [bp:]?[state:]?utility
;; Examples: "bg-yellow-199", "hover:bg-rose-500", "md:hover:text-xl"
(define cssx-process-token
(fn (token)
(let ((colon-parts (split token ":"))
(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
;; 2 parts: modifier:utility (could be bp or state)
;; 3 parts: bp:state:utility
(cond
(= n 1)
(do (set! base (first colon-parts)))
(= n 2)
(let ((prefix (first colon-parts)))
(set! base (last colon-parts))
(if (not (nil? (get cssx-breakpoints prefix)))
(set! bp prefix)
(set! state prefix)))
(>= n 3)
(do
(set! bp (first colon-parts))
(set! state (nth colon-parts 1))
(set! base (last colon-parts))))
;; Resolve the base utility to CSS declarations
(let ((css (cssx-resolve base)))
(if (nil? css) nil
(let ((cls (str "sx-" (join "-" (split token ":"))))
(pseudo (if (nil? state) ""
(or (get cssx-states state) (str ":" state))))
(decl (str "." cls pseudo "{" css "}")))
(if (nil? bp)
{"cls" cls "rule" decl}
(let ((min-w (or (get cssx-breakpoints bp) bp)))
{"cls" cls
"rule" (str "@media(min-width:" min-w "){" decl "}")})))))))))
;; =========================================================================
;; tw — Tailwind-style inline style string from utility tokens
;;
;; Same token format as ~cssx/tw, returns a CSS declaration string for :style.
;; Cannot do hover/responsive (use ~cssx/tw for those).
;;
;; Usage:
;; (div :style (tw "bg-yellow-199 text-violet-700 p-4 font-bold")
;; "content")
;; =========================================================================
(define tw
(fn (tokens-str)
(let ((tokens (split (or tokens-str "") " "))
(parts (list)))
(for-each (fn (tok)
(when (not (= tok ""))
(let ((css (cssx-resolve tok)))
(when (not (nil? css))
(append! parts (str css ";"))))))
tokens)
(join "" parts))))
;; =========================================================================
;; ~cssx/tw — spread component that injects JIT classes onto parent element
;;
;; Usage — as a child of any element:
;; (div (~cssx/tw "bg-yellow-199 text-violet-700 p-4 font-bold")
;; (h1 "styled content"))
;;
;; (button (~cssx/tw "hover:bg-rose-500 focus:border-blue-400")
;; "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.
;;
;; Reusable as variables:
;; (define important (~cssx/tw "font-bold text-4xl"))
;; (div important "the queen is dead")
;;
;; Multiple spreads merge naturally:
;; (div (~cssx/tw "bg-red-500") (~cssx/tw "p-4") "content")
;; =========================================================================
(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 "")}))))
;; =========================================================================
;; ~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>")))))