diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index a3145846..5d452905 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-12T22:55:39Z"; + var SX_VERSION = "2026-03-13T02:54:01Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -83,6 +83,12 @@ 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; } @@ -118,6 +124,7 @@ 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"; @@ -140,6 +147,51 @@ } 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; } @@ -466,6 +518,21 @@ }; + // 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; + + function isPrimitive(name) { return name in PRIMITIVES; } function getPrimitive(name) { return PRIMITIVES[name]; } @@ -729,10 +796,10 @@ var args = rest(expr); return (isSxTruthy(!isSxTruthy(sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list")))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { var name = symbolName(head); - return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defisland")) ? sfDefisland(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "deftype")) ? sfDeftype(args, env) : (isSxTruthy((name == "defeffect")) ? sfDefeffect(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { + return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defisland")) ? sfDefisland(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "deftype")) ? sfDeftype(args, env) : (isSxTruthy((name == "defeffect")) ? sfDefeffect(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "provide")) ? sfProvide(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { var mac = envGet(env, name); return makeThunk(expandMacro(mac, args, env), env); -})() : (isSxTruthy((isSxTruthy(renderActiveP()) && isRenderExpr(expr))) ? renderExpr(expr, env) : evalCall(head, args, env))))))))))))))))))))))))))))))))))))))))); +})() : (isSxTruthy((isSxTruthy(renderActiveP()) && isRenderExpr(expr))) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))))))))))); })() : evalCall(head, args, env))); })(); }; @@ -1139,6 +1206,18 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai })(); })(); }; + // sf-provide + var sfProvide = function(args, env) { return (function() { + var name = trampoline(evalExpr(first(args), env)); + var val = trampoline(evalExpr(nth(args, 1), env)); + var bodyExprs = slice(args, 2); + var result = NIL; + providePush(name, val); + { var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; result = trampoline(evalExpr(e, env)); } } + providePop(name); + return result; +})(); }; + // expand-macro var expandMacro = function(mac, rawArgs, env) { return (function() { var local = envMerge(macroClosure(mac), env); @@ -1277,6 +1356,18 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai })()); })()); }; + // merge-spread-attrs + var mergeSpreadAttrs = function(target, spreadDict) { return forEach(function(key) { return (function() { + var val = dictGet(spreadDict, key); + return (isSxTruthy((key == "class")) ? (function() { + var existing = dictGet(target, "class"); + return dictSet(target, "class", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(" ") + String(val)) : val)); +})() : (isSxTruthy((key == "style")) ? (function() { + var existing = dictGet(target, "style"); + return dictSet(target, "style", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(";") + String(val)) : val)); +})() : dictSet(target, key, val))); +})(); }, keys(spreadDict)); }; + // === Transpiled from parser === @@ -1419,13 +1510,13 @@ continue; } else { return NIL; } } }; // render-to-html var renderToHtml = function(expr, env) { setRenderActiveB(true); -return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); if (_m == "raw-html") return rawHtmlContent(expr); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); }; +return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); if (_m == "raw-html") return rawHtmlContent(expr); if (_m == "spread") return expr; return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); }; // render-value-to-html - var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); return escapeHtml((String(val))); })(); }; + var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "spread") return val; return escapeHtml((String(val))); })(); }; // RENDER_HTML_FORMS - var 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"]; + var 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? var isRenderHtmlForm = function(name) { return contains(RENDER_HTML_FORMS, name); }; @@ -1433,10 +1524,10 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = // render-list-to-html var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { var head = first(expr); - return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { + return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", filter(function(x) { return !isSxTruthy(isSpread(x)); }, map(function(x) { return renderValueToHtml(x, env); }, expr))) : (function() { var name = symbolName(head); var args = rest(expr); - return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + return (isSxTruthy((name == "<>")) ? join("", filter(function(x) { return !isSxTruthy(isSpread(x)); }, map(function(x) { return renderToHtml(x, env); }, args))) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { var val = envGet(env, name); return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name))))); })() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))); @@ -1447,25 +1538,45 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = var dispatchHtmlForm = function(name, expr, env) { return (isSxTruthy((name == "if")) ? (function() { var condVal = trampoline(evalExpr(nth(expr, 1), env)); return (isSxTruthy(condVal) ? renderToHtml(nth(expr, 2), env) : (isSxTruthy((len(expr) > 3)) ? renderToHtml(nth(expr, 3), env) : "")); -})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() { +})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? "" : (isSxTruthy((len(expr) == 3)) ? renderToHtml(nth(expr, 2), env) : (function() { + var results = map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))); + return join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, results)); +})())) : (isSxTruthy((name == "cond")) ? (function() { var branch = evalCond(rest(expr), env); return (isSxTruthy(branch) ? renderToHtml(branch, env) : ""); })() : (isSxTruthy((name == "case")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() { var local = processBindings(nth(expr, 1), env); - return join("", map(function(i) { return renderToHtml(nth(expr, i), local); }, range(2, len(expr)))); -})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(1, len(expr)))) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((name == "map")) ? (function() { + return (isSxTruthy((len(expr) == 3)) ? renderToHtml(nth(expr, 2), local) : (function() { + var results = map(function(i) { return renderToHtml(nth(expr, i), local); }, range(2, len(expr))); + return join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, results)); +})()); +})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? (isSxTruthy((len(expr) == 2)) ? renderToHtml(nth(expr, 1), env) : (function() { + var results = map(function(i) { return renderToHtml(nth(expr, i), env); }, range(1, len(expr))); + return join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, results)); +})()) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((name == "map")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); - return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll)); + return join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll))); })() : (isSxTruthy((name == "map-indexed")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); - return join("", mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [i, item], env) : renderToHtml(apply(f, [i, item]), env)); }, coll)); + return join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [i, item], env) : renderToHtml(apply(f, [i, item]), env)); }, coll))); })() : (isSxTruthy((name == "filter")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy((name == "for-each")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); - return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll)); -})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))))); }; + return join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll))); +})() : (isSxTruthy((name == "provide")) ? (function() { + var provName = trampoline(evalExpr(nth(expr, 1), env)); + var provVal = trampoline(evalExpr(nth(expr, 2), env)); + var bodyStart = 3; + var bodyCount = (len(expr) - 3); + providePush(provName, provVal); + return (function() { + var result = (isSxTruthy((bodyCount == 1)) ? renderToHtml(nth(expr, bodyStart), env) : join("", filter(function(r) { return !isSxTruthy(isSpread(r)); }, map(function(i) { return renderToHtml(nth(expr, i), env); }, range(bodyStart, (bodyStart + bodyCount)))))); + providePop(provName); + return result; +})(); +})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))))))))); }; // render-lambda-html var renderLambdaHtml = function(f, args, env) { return (function() { @@ -1490,7 +1601,14 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = var local = envMerge(componentClosure(comp), env); { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } } if (isSxTruthy(componentHasChildren(comp))) { - envSet(local, "children", makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children)))); + (function() { + var parts = []; + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { + var r = renderToHtml(c, env); + return (isSxTruthy(!isSxTruthy(isSpread(r))) ? append_b(parts, r) : NIL); +})(); } } + return envSet(local, "children", makeRawHtml(join("", parts))); +})(); } return renderToHtml(componentBody(comp), local); })(); @@ -1502,7 +1620,14 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = var attrs = first(parsed); var children = nth(parsed, 1); var isVoid = contains(VOID_ELEMENTS, tag); - return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String(""))))); + return (isSxTruthy(isVoid) ? (String("<") + String(tag) + String(renderAttrs(attrs)) + String(" />")) : (function() { + var contentParts = []; + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { + var result = renderToHtml(c, env); + return (isSxTruthy(isSpread(result)) ? mergeSpreadAttrs(attrs, spreadAttrs(result)) : append_b(contentParts, result)); +})(); } } + return (String("<") + String(tag) + String(renderAttrs(attrs)) + String(">") + String(join("", contentParts)) + String("")); +})()); })(); }; // render-html-lake @@ -1519,7 +1644,15 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = return assoc(state, "skip", true, "i", (get(state, "i") + 1)); })() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); - return (String("<") + String(lakeTag) + String(" data-sx-lake=\"") + String(escapeAttr(sxOr(lakeId, ""))) + String("\">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String("")); + return (function() { + var lakeAttrs = {["data-sx-lake"]: sxOr(lakeId, "")}; + var contentParts = []; + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { + var result = renderToHtml(c, env); + return (isSxTruthy(isSpread(result)) ? mergeSpreadAttrs(lakeAttrs, spreadAttrs(result)) : append_b(contentParts, result)); +})(); } } + return (String("<") + String(lakeTag) + String(renderAttrs(lakeAttrs)) + String(">") + String(join("", contentParts)) + String("")); +})(); })(); }; // render-html-marsh @@ -1536,7 +1669,15 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = return assoc(state, "skip", true, "i", (get(state, "i") + 1)); })() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); - return (String("<") + String(marshTag) + String(" data-sx-marsh=\"") + String(escapeAttr(sxOr(marshId, ""))) + String("\">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String("")); + return (function() { + var marshAttrs = {["data-sx-marsh"]: sxOr(marshId, "")}; + var contentParts = []; + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { + var result = renderToHtml(c, env); + return (isSxTruthy(isSpread(result)) ? mergeSpreadAttrs(marshAttrs, spreadAttrs(result)) : append_b(contentParts, result)); +})(); } } + return (String("<") + String(marshTag) + String(renderAttrs(marshAttrs)) + String(">") + String(join("", contentParts)) + String("")); +})(); })(); }; // render-html-island @@ -1556,7 +1697,14 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = var islandName = componentName(island); { var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } } if (isSxTruthy(componentHasChildren(island))) { - envSet(local, "children", makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children)))); + (function() { + var parts = []; + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { + var r = renderToHtml(c, env); + return (isSxTruthy(!isSxTruthy(isSpread(r))) ? append_b(parts, r) : NIL); +})(); } } + return envSet(local, "children", makeRawHtml(join("", parts))); +})(); } return (function() { var bodyHtml = renderToHtml(componentBody(island), local); @@ -1583,7 +1731,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { var name = symbolName(expr); return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); -})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); return expr; })(); }; +})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return expr; return expr; })(); }; // aser-list var aserList = function(expr, env) { return (function() { @@ -1633,7 +1781,7 @@ return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if })(); }; // SPECIAL_FORM_NAMES - var SPECIAL_FORM_NAMES = ["if", "when", "cond", "case", "and", "or", "let", "let*", "lambda", "fn", "define", "defcomp", "defmacro", "defstyle", "defhandler", "defpage", "defquery", "defaction", "defrelation", "begin", "do", "quote", "quasiquote", "->", "set!", "letrec", "dynamic-wind", "defisland", "deftype", "defeffect"]; + var SPECIAL_FORM_NAMES = ["if", "when", "cond", "case", "and", "or", "let", "let*", "lambda", "fn", "define", "defcomp", "defmacro", "defstyle", "defhandler", "defpage", "defquery", "defaction", "defrelation", "begin", "do", "quote", "quasiquote", "->", "set!", "letrec", "dynamic-wind", "defisland", "deftype", "defeffect", "provide"]; // HO_FORM_NAMES var HO_FORM_NAMES = ["map", "map-indexed", "filter", "reduce", "some", "every?", "for-each"]; @@ -1704,7 +1852,15 @@ return result; }, args); return append_b(results, aser(lambdaBody(f), local)); })() : invoke(f, item)); } } return (isSxTruthy(isEmpty(results)) ? NIL : results); -})() : (isSxTruthy((name == "defisland")) ? (trampoline(evalExpr(expr, env)), serialize(expr)) : (isSxTruthy(sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defhandler"), (name == "defpage"), (name == "defquery"), (name == "defaction"), (name == "defrelation"), (name == "deftype"), (name == "defeffect"))) ? (trampoline(evalExpr(expr, env)), NIL) : trampoline(evalExpr(expr, env))))))))))))))); +})() : (isSxTruthy((name == "defisland")) ? (trampoline(evalExpr(expr, env)), serialize(expr)) : (isSxTruthy(sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defhandler"), (name == "defpage"), (name == "defquery"), (name == "defaction"), (name == "defrelation"), (name == "deftype"), (name == "defeffect"))) ? (trampoline(evalExpr(expr, env)), NIL) : (isSxTruthy((name == "provide")) ? (function() { + var provName = trampoline(evalExpr(first(args), env)); + var provVal = trampoline(evalExpr(nth(args, 1), env)); + var result = NIL; + providePush(provName, provVal); + { var _c = slice(args, 2); for (var _i = 0; _i < _c.length; _i++) { var body = _c[_i]; result = aser(body, env); } } + providePop(provName); + return result; +})() : trampoline(evalExpr(expr, env)))))))))))))))); })(); }; // eval-case-aser @@ -1725,7 +1881,7 @@ return result; }, args); // render-to-dom var renderToDom = function(expr, env, ns) { setRenderActiveB(true); -return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return (isSxTruthy(isSignal(expr)) ? (isSxTruthy(_islandScope) ? reactiveText(expr) : createTextNode((String(deref(expr))))) : createTextNode((String(expr)))); })(); }; +return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "spread") return expr; if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return (isSxTruthy(isSignal(expr)) ? (isSxTruthy(_islandScope) ? reactiveText(expr) : createTextNode((String(deref(expr))))) : createTextNode((String(expr)))); })(); }; // render-dom-list var renderDomList = function(expr, env, ns) { return (function() { @@ -1773,7 +1929,19 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))); })()))))); return assoc(state, "skip", true, "i", (get(state, "i") + 1)); -})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1))))); +})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? (function() { + var child = renderToDom(arg, env, newNs); + return (isSxTruthy(isSpread(child)) ? forEach(function(key) { return (function() { + var val = dictGet(spreadAttrs(child), key); + return (isSxTruthy((key == "class")) ? (function() { + var existing = domGetAttr(el, "class"); + return domSetAttr(el, "class", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(" ") + String(val)) : val)); +})() : (isSxTruthy((key == "style")) ? (function() { + var existing = domGetAttr(el, "style"); + return domSetAttr(el, "style", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(";") + String(val)) : val)); +})() : domSetAttr(el, key, (String(val))))); +})(); }, keys(spreadAttrs(child))) : domAppend(el, child)); +})() : NIL), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); return el; })(); }; @@ -1825,7 +1993,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); }; // RENDER_DOM_FORMS - var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary"]; + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary", "provide"]; // render-dom-form? var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; @@ -1959,7 +2127,15 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return domAppend(frag, val); })(); } } return frag; -})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))); }; +})() : (isSxTruthy((name == "provide")) ? (function() { + var provName = trampoline(evalExpr(nth(expr, 1), env)); + var provVal = trampoline(evalExpr(nth(expr, 2), env)); + var frag = createFragment(); + providePush(provName, provVal); + { var _c = range(3, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } + providePop(provName); + return frag; +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))))); }; // render-lambda-dom var renderLambdaDom = function(f, args, env, ns) { return (function() { @@ -6346,6 +6522,17 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { emitEvent: emitEvent, onEvent: onEvent, bridgeEvent: bridgeEvent, + makeSpread: makeSpread, + isSpread: isSpread, + spreadAttrs: spreadAttrs, + collect: sxCollect, + collected: sxCollected, + clearCollected: sxClearCollected, + providePush: providePush, + providePop: providePop, + context: sxContext, + emit: sxEmit, + emitted: sxEmitted, _version: "ref-2.0 (boot+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" }; diff --git a/shared/sx/ref/adapter-async.sx b/shared/sx/ref/adapter-async.sx index 09221113..5b47d823 100644 --- a/shared/sx/ref/adapter-async.sx +++ b/shared/sx/ref/adapter-async.sx @@ -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 ""))))))) + (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) + "")))))) ;; -------------------------------------------------------------------------- @@ -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 "" "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 diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 8c00e92d..5c956a85 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -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)))) diff --git a/shared/sx/ref/adapter-html.sx b/shared/sx/ref/adapter-html.sx index 368dd89a..3ad5f9b7 100644 --- a/shared/sx/ref/adapter-html.sx +++ b/shared/sx/ref/adapter-html.sx @@ -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) "")))))) @@ -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)) - "")))) + ;; 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) + ""))))) ;; -------------------------------------------------------------------------- @@ -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)) - "")))) + ;; 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) + ""))))) ;; -------------------------------------------------------------------------- @@ -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)) diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx index f55da200..fdc11901 100644 --- a/shared/sx/ref/adapter-sx.sx +++ b/shared/sx/ref/adapter-sx.sx @@ -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)))))) diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index bd77a4bf..ea9f089f 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -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", diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index 95edfd8c..49a7761c 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -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 "))))) diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 90850201..45675dbc 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -5,7 +5,7 @@ ;; Nav components — logo header, sibling arrows, children links ;; --------------------------------------------------------------------------- -;; CSSX replaces Tailwind text-*/bg-*/font-* classes — computed via cssx.sx +;; Styling via cssx-style utility tokens (cssx.sx) — same format as ~cssx/tw ;; Logo + tagline + copyright — always shown at top of page area. ;; The header itself is an island so the "reactive" word can cycle colours @@ -21,23 +21,21 @@ (shade (signal 500)) (current-family (computed (fn () (nth families (mod (deref idx) (len families))))))) - (div :style (str (display "block") (max-w (get cssx-max-widths "3xl")) - (mx-auto) (px 4) (pt 8) (pb 4) (align "center")) + (div :style (tw "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center") ;; Logo — only this navigates home (a :href "/sx/" :sx-get "/sx/" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" - :style (str (display "block") (decoration "none")) + :style (tw "block no-underline") (lake :id "logo" - (span :style (str (display "block") (mb 2) - (cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono")))) + (span :style (tw "block mb-2 text-violet-699 text-4xl font-bold font-mono") "()"))) ;; Tagline — clicking "reactive" cycles colour. - (p :style (str (mb 1) (cssx (:text (colour "stone" 500) (size "lg")))) + (p :style (tw "mb-1 text-stone-500 text-lg") "The framework-free " (span - :style (str (cssx (:text (colour (deref current-family) (deref shade)) - (weight "bold"))) + :style (str "color:" (colour (deref current-family) (deref shade)) ";" + (tw "font-bold") "cursor:pointer;transition:color 0.3s,font-weight 0.3s;") :on-click (fn (e) (batch (fn () @@ -47,11 +45,10 @@ " hypermedium") ;; Lake: server morphs copyright on navigation without disturbing signals. (lake :id "copyright" - (p :style (cssx (:text (colour "stone" 400) (size "xs"))) + (p :style (tw "text-stone-400 text-xs") "© Giles Bradshaw 2026" (when path - (span :style (str (cssx (:text (colour "stone" 300) (size "xs"))) - "margin-left:0.5em;") + (span :style (str (tw "text-stone-300 text-xs") "margin-left:0.5em;") (str "· " path)))))))) @@ -80,7 +77,7 @@ :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-right" - :style (cssx (:text (colour "stone" 500) (size "sm"))) + :style (tw "text-stone-500 text-sm") (str "← " (get prev-node "label"))) (a :href (get node "href") :sx-get (get node "href") :sx-target "#main-panel" @@ -88,15 +85,15 @@ :sx-push-url "true" :class "text-center px-4" :style (if is-leaf - (cssx (:text (colour "violet" 700) (size "2xl") (weight "bold"))) - (cssx (:text (colour "violet" 700) (size "lg") (weight "semibold")))) + (tw "text-violet-700 text-2xl font-bold") + (tw "text-violet-700 text-lg font-semibold")) (get node "label")) (a :href (get next-node "href") :sx-get (get next-node "href") :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-left" - :style (cssx (:text (colour "stone" 500) (size "sm"))) + :style (tw "text-stone-500 text-sm") (str (get next-node "label") " →"))))))) ;; Children links — shown as clearly clickable buttons. @@ -109,8 +106,7 @@ :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "px-3 py-1.5 rounded border transition-colors" - :style (cssx (:text (colour "violet" 700) (size "sm")) - (:border (colour "violet" 200))) + :style (tw "text-violet-700 text-sm border-violet-200") (get item "label"))) items)))) diff --git a/sx/sx/not-found.sx b/sx/sx/not-found.sx index 6a3b9d3f..6292feb4 100644 --- a/sx/sx/not-found.sx +++ b/sx/sx/not-found.sx @@ -2,19 +2,18 @@ (defcomp ~not-found/content (&key (path :as string?)) (div :class "max-w-3xl mx-auto px-4 py-12 text-center" - (h1 :style (cssx (:text (colour "stone" 800) (size "3xl") (weight "bold"))) + (h1 :style (tw "text-stone-800 text-3xl font-bold") "404") (p :class "mt-4" - :style (cssx (:text (colour "stone" 500) (size "lg"))) + :style (tw "text-stone-500 text-lg") "Page not found") (when path (p :class "mt-2" - :style (cssx (:text (colour "stone" 400) (size "sm") (family "mono"))) + :style (tw "text-stone-400 text-sm font-mono") path)) (a :href "/sx/" :sx-get "/sx/" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "inline-block mt-6 px-4 py-2 rounded border transition-colors" - :style (cssx (:text (colour "violet" 700) (size "sm")) - (:border (colour "violet" 200))) + :style (tw "text-violet-700 text-sm border-violet-200") "Back to home")))