From 11fdd1a84017d49644d5b37952a56750ae2a9ea6 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 13 Mar 2026 17:30:34 +0000 Subject: [PATCH] Unify scoped effects: scope as general primitive, provide as sugar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `scope` special form to eval.sx: (scope name body...) or (scope name :value v body...) — general dynamic scope primitive - `provide` becomes sugar: (provide name value body...) calls scope - Rename provide-push!/provide-pop! to scope-push!/scope-pop! throughout all adapters (async, dom, html, sx) and platform implementations - Update boundary.sx: Tier 5 now "Scoped effects" with scope-push!/ scope-pop! as primary, provide-push!/provide-pop! as aliases - Add scope form handling to async adapter and aser wire format - Update sx-browser.js, sx_ref.py (bootstrapped output) - Add scopes.sx docs page, update provide/spreads/demo docs - Update nav-data, page-functions, docs page definitions Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 174 ++++++++++++++++------- shared/sx/ref/adapter-async.sx | 64 +++++++-- shared/sx/ref/adapter-dom.sx | 34 ++++- shared/sx/ref/adapter-html.sx | 40 ++++-- shared/sx/ref/adapter-sx.sx | 32 ++++- shared/sx/ref/bootstrap_py.py | 2 + shared/sx/ref/boundary.sx | 34 +++-- shared/sx/ref/eval.sx | 35 ++++- shared/sx/ref/js.sx | 2 + shared/sx/ref/platform_js.py | 66 +++++---- shared/sx/ref/platform_py.py | 80 ++++++----- shared/sx/ref/py.sx | 2 + shared/sx/ref/render.sx | 12 +- shared/sx/ref/sx_ref.py | 213 ++++++++++++++++++++-------- shared/sx/ref/test-aser.sx | 43 ++++++ sx/sx/nav-data.sx | 6 +- sx/sx/page-functions.sx | 4 + sx/sx/plans/scoped-effects.sx | 43 +++--- sx/sx/provide.sx | 33 +++-- sx/sx/reactive-islands/demo.sx | 24 ++-- sx/sx/scopes.sx | 194 +++++++++++++++++++++++++ sx/sx/spreads.sx | 11 +- sx/sxc/pages/docs.sx | 6 + 23 files changed, 869 insertions(+), 285 deletions(-) create mode 100644 sx/sx/scopes.sx diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 7cfd36f..1fc8fec 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-13T15:35:20Z"; + var SX_VERSION = "2026-03-13T16:48:03Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -86,8 +86,7 @@ function SxSpread(attrs) { this.attrs = attrs || {}; } SxSpread.prototype._spread = true; - var _collectBuckets = {}; - var _provideStacks = {}; + var _scopeStacks = {}; function isSym(x) { return x != null && x._sym === true; } function isKw(x) { return x != null && x._kw === true; } @@ -151,44 +150,54 @@ 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 scopePush(name, value) { + if (!_scopeStacks[name]) _scopeStacks[name] = []; + _scopeStacks[name].push({value: value !== undefined ? value : NIL, emitted: [], dedup: false}); } - function sxCollected(bucket) { - return _collectBuckets[bucket] ? _collectBuckets[bucket].slice() : []; - } - function sxClearCollected(bucket) { - if (_collectBuckets[bucket]) _collectBuckets[bucket] = []; + function scopePop(name) { + if (_scopeStacks[name] && _scopeStacks[name].length) _scopeStacks[name].pop(); } + // Aliases — provide-push!/provide-pop! map to scope-push!/scope-pop! + var providePush = scopePush; + var providePop = scopePop; - 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 (_scopeStacks[name] && _scopeStacks[name].length) { + return _scopeStacks[name][_scopeStacks[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); + if (_scopeStacks[name] && _scopeStacks[name].length) { + var entry = _scopeStacks[name][_scopeStacks[name].length - 1]; + if (entry.dedup && entry.emitted.indexOf(value) !== -1) return NIL; + entry.emitted.push(value); } return NIL; } function sxEmitted(name) { - if (_provideStacks[name] && _provideStacks[name].length) { - return _provideStacks[name][_provideStacks[name].length - 1].emitted.slice(); + if (_scopeStacks[name] && _scopeStacks[name].length) { + return _scopeStacks[name][_scopeStacks[name].length - 1].emitted.slice(); } return []; } + function sxCollect(bucket, value) { + if (!_scopeStacks[bucket] || !_scopeStacks[bucket].length) { + if (!_scopeStacks[bucket]) _scopeStacks[bucket] = []; + _scopeStacks[bucket].push({value: NIL, emitted: [], dedup: true}); + } + var entry = _scopeStacks[bucket][_scopeStacks[bucket].length - 1]; + if (entry.emitted.indexOf(value) === -1) entry.emitted.push(value); + } + function sxCollected(bucket) { + return sxEmitted(bucket); + } + function sxClearCollected(bucket) { + if (_scopeStacks[bucket] && _scopeStacks[bucket].length) { + _scopeStacks[bucket][_scopeStacks[bucket].length - 1].emitted = []; + } + } function lambdaParams(f) { return f.params; } function lambdaBody(f) { return f.body; } @@ -517,14 +526,17 @@ }; - // stdlib.spread — spread + collect primitives + // stdlib.spread — spread + collect + scope 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 + // scope — unified render-time dynamic scope + PRIMITIVES["scope-push!"] = scopePush; + PRIMITIVES["scope-pop!"] = scopePop; + // provide-push!/provide-pop! — aliases for scope-push!/scope-pop! PRIMITIVES["provide-push!"] = providePush; PRIMITIVES["provide-pop!"] = providePop; PRIMITIVES["context"] = sxContext; @@ -796,10 +808,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 == "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() { + 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 == "scope")) ? sfScope(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))); })(); }; @@ -1204,6 +1216,22 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai callThunk(after, env); return result; })(); +})(); }; + + // sf-scope + var sfScope = function(args, env) { return (function() { + var name = trampoline(evalExpr(first(args), env)); + var rest = slice(args, 1); + var val = NIL; + var bodyExprs = NIL; + (isSxTruthy((isSxTruthy((len(rest) >= 2)) && isSxTruthy((typeOf(first(rest)) == "keyword")) && (keywordName(first(rest)) == "value"))) ? ((val = trampoline(evalExpr(nth(rest, 1), env))), (bodyExprs = slice(rest, 2))) : (bodyExprs = rest)); + scopePush(name, val); + return (function() { + var result = NIL; + { var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; result = trampoline(evalExpr(e, env)); } } + scopePop(name); + return result; +})(); })(); }; // sf-provide @@ -1212,9 +1240,9 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var val = trampoline(evalExpr(nth(args, 1), env)); var bodyExprs = slice(args, 2); var result = NIL; - providePush(name, val); + scopePush(name, val); { var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; result = trampoline(evalExpr(e, env)); } } - providePop(name); + scopePop(name); return result; })(); }; @@ -1527,7 +1555,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = 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 (sxEmit("element-attrs", spreadAttrs(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", "provide"]; + 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", "scope", "provide"]; // render-html-form? var isRenderHtmlForm = function(name) { return contains(RENDER_HTML_FORMS, name); }; @@ -1567,18 +1595,30 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = 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)); +})() : (isSxTruthy((name == "scope")) ? (function() { + var scopeName = trampoline(evalExpr(nth(expr, 1), env)); + var restArgs = slice(expr, 2); + var scopeVal = NIL; + var bodyExprs = NIL; + (isSxTruthy((isSxTruthy((len(restArgs) >= 2)) && isSxTruthy((typeOf(first(restArgs)) == "keyword")) && (keywordName(first(restArgs)) == "value"))) ? ((scopeVal = trampoline(evalExpr(nth(restArgs, 1), env))), (bodyExprs = slice(restArgs, 2))) : (bodyExprs = restArgs)); + scopePush(scopeName, scopeVal); + return (function() { + var result = (isSxTruthy((len(bodyExprs) == 1)) ? renderToHtml(first(bodyExprs), env) : join("", map(function(e) { return renderToHtml(e, env); }, bodyExprs))); + scopePop(scopeName); + return result; +})(); })() : (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); + scopePush(provName, provVal); return (function() { var result = (isSxTruthy((bodyCount == 1)) ? renderToHtml(nth(expr, bodyStart), env) : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(bodyStart, (bodyStart + bodyCount))))); - providePop(provName); + scopePop(provName); return result; })(); -})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))))))))); }; +})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))))))); }; // render-lambda-html var renderLambdaHtml = function(f, args, env) { return (function() { @@ -1615,10 +1655,10 @@ 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 (isSxTruthy(isVoid) ? (String("<") + String(tag) + String(renderAttrs(attrs)) + String(" />")) : (providePush("element-attrs", NIL), (function() { + return (isSxTruthy(isVoid) ? (String("<") + String(tag) + String(renderAttrs(attrs)) + String(" />")) : (scopePush("element-attrs", NIL), (function() { var content = join("", map(function(c) { return renderToHtml(c, env); }, children)); { var _c = sxEmitted("element-attrs"); for (var _i = 0; _i < _c.length; _i++) { var spreadDict = _c[_i]; mergeSpreadAttrs(attrs, spreadDict); } } - providePop("element-attrs"); + scopePop("element-attrs"); return (String("<") + String(tag) + String(renderAttrs(attrs)) + String(">") + String(content) + String("")); })())); })(); }; @@ -1639,11 +1679,11 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = })(); }, {["i"]: 0, ["skip"]: false}, args); return (function() { var lakeAttrs = {["data-sx-lake"]: sxOr(lakeId, "")}; - providePush("element-attrs", NIL); + scopePush("element-attrs", NIL); return (function() { var content = join("", map(function(c) { return renderToHtml(c, env); }, children)); { var _c = sxEmitted("element-attrs"); for (var _i = 0; _i < _c.length; _i++) { var spreadDict = _c[_i]; mergeSpreadAttrs(lakeAttrs, spreadDict); } } - providePop("element-attrs"); + scopePop("element-attrs"); return (String("<") + String(lakeTag) + String(renderAttrs(lakeAttrs)) + String(">") + String(content) + String("")); })(); })(); @@ -1665,11 +1705,11 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m = })(); }, {["i"]: 0, ["skip"]: false}, args); return (function() { var marshAttrs = {["data-sx-marsh"]: sxOr(marshId, "")}; - providePush("element-attrs", NIL); + scopePush("element-attrs", NIL); return (function() { var content = join("", map(function(c) { return renderToHtml(c, env); }, children)); { var _c = sxEmitted("element-attrs"); for (var _i = 0; _i < _c.length; _i++) { var spreadDict = _c[_i]; mergeSpreadAttrs(marshAttrs, spreadDict); } } - providePop("element-attrs"); + scopePop("element-attrs"); return (String("<") + String(marshTag) + String(renderAttrs(marshAttrs)) + String(">") + String(content) + String("")); })(); })(); @@ -1754,7 +1794,7 @@ return (function() { var childParts = []; var skip = false; var i = 0; - providePush("element-attrs", NIL); + scopePush("element-attrs", NIL); { var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (function() { var val = aser(nth(args, (i + 1)), env); if (isSxTruthy(!isSxTruthy(isNil(val)))) { @@ -1775,7 +1815,7 @@ return (function() { attrParts.push((String(":") + String(k))); return append_b(attrParts, serialize(v)); })(); } } } } - providePop("element-attrs"); + scopePop("element-attrs"); return (function() { var parts = concat([name], attrParts, childParts); return (String("(") + String(join(" ", parts)) + String(")")); @@ -1783,7 +1823,7 @@ return (function() { })(); }; // 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", "provide"]; + var SPECIAL_FORM_NAMES = ["if", "when", "cond", "case", "and", "or", "let", "let*", "lambda", "fn", "define", "defcomp", "defmacro", "defstyle", "defhandler", "defpage", "defquery", "defaction", "defrelation", "begin", "do", "quote", "quasiquote", "->", "set!", "letrec", "dynamic-wind", "defisland", "deftype", "defeffect", "scope", "provide"]; // HO_FORM_NAMES var HO_FORM_NAMES = ["map", "map-indexed", "filter", "reduce", "some", "every?", "for-each"]; @@ -1854,15 +1894,28 @@ 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) : (isSxTruthy((name == "provide")) ? (function() { +})() : (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 == "scope")) ? (function() { + var scopeName = trampoline(evalExpr(first(args), env)); + var restArgs = rest(args); + var scopeVal = NIL; + var bodyArgs = NIL; + (isSxTruthy((isSxTruthy((len(restArgs) >= 2)) && isSxTruthy((typeOf(first(restArgs)) == "keyword")) && (keywordName(first(restArgs)) == "value"))) ? ((scopeVal = trampoline(evalExpr(nth(restArgs, 1), env))), (bodyArgs = slice(restArgs, 2))) : (bodyArgs = restArgs)); + scopePush(scopeName, scopeVal); + return (function() { + var result = NIL; + { var _c = bodyArgs; for (var _i = 0; _i < _c.length; _i++) { var body = _c[_i]; result = aser(body, env); } } + scopePop(scopeName); + return result; +})(); +})() : (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); + scopePush(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); + scopePop(provName); return result; -})() : trampoline(evalExpr(expr, env)))))))))))))))); +})() : trampoline(evalExpr(expr, env))))))))))))))))); })(); }; // eval-case-aser @@ -1912,7 +1965,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var renderDomElement = function(tag, args, env, ns) { return (function() { var newNs = (isSxTruthy((tag == "svg")) ? SVG_NS : (isSxTruthy((tag == "math")) ? MATH_NS : ns)); var el = domCreateElement(tag, newNs); - providePush("element-attrs", NIL); + scopePush("element-attrs", NIL); reduce(function(state, arg) { return (function() { var skip = get(state, "skip"); return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() { @@ -1950,7 +2003,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return domSetAttr(el, "style", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(";") + String(val)) : val)); })() : domSetAttr(el, key, (String(val))))); })(); } } } } - providePop("element-attrs"); + scopePop("element-attrs"); return el; })(); }; @@ -2007,7 +2060,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", "provide"]; + 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", "scope", "provide"]; // render-dom-form? var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; @@ -2149,15 +2202,26 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return domAppend(frag, val); })(); } } return frag; +})() : (isSxTruthy((name == "scope")) ? (function() { + var scopeName = trampoline(evalExpr(nth(expr, 1), env)); + var restArgs = slice(expr, 2); + var scopeVal = NIL; + var bodyExprs = NIL; + var frag = createFragment(); + (isSxTruthy((isSxTruthy((len(restArgs) >= 2)) && isSxTruthy((typeOf(first(restArgs)) == "keyword")) && (keywordName(first(restArgs)) == "value"))) ? ((scopeVal = trampoline(evalExpr(nth(restArgs, 1), env))), (bodyExprs = slice(restArgs, 2))) : (bodyExprs = restArgs)); + scopePush(scopeName, scopeVal); + { var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; domAppend(frag, renderToDom(e, env, ns)); } } + scopePop(scopeName); + return frag; })() : (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); + scopePush(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); + scopePop(provName); return frag; -})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))))); }; +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))))); }; // render-lambda-dom var renderLambdaDom = function(f, args, env, ns) { return (function() { @@ -6622,6 +6686,8 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { collect: sxCollect, collected: sxCollected, clearCollected: sxClearCollected, + scopePush: scopePush, + scopePop: scopePop, providePush: providePush, providePop: providePop, context: sxContext, diff --git a/shared/sx/ref/adapter-async.sx b/shared/sx/ref/adapter-async.sx index 413c270..0a87d5f 100644 --- a/shared/sx/ref/adapter-async.sx +++ b/shared/sx/ref/adapter-async.sx @@ -175,14 +175,14 @@ (svg-context-set! true) nil)) (content-parts (list))) - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) (for-each (fn (c) (append! content-parts (async-render c env ctx))) children) (for-each (fn (spread-dict) (merge-spread-attrs attrs spread-dict)) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") (when token (svg-context-reset! token)) (str "<" tag (render-attrs attrs) ">" (join "" content-parts) @@ -335,7 +335,7 @@ (list "if" "when" "cond" "case" "let" "let*" "begin" "do" "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "deftype" "defeffect" - "map" "map-indexed" "filter" "for-each" "provide")) + "map" "map-indexed" "filter" "for-each" "scope" "provide")) (define async-render-form? :effects [] (fn ((name :as string)) @@ -419,17 +419,37 @@ (coll (async-eval (nth expr 2) env ctx))) (join "" (async-map-fn-render f coll env ctx))) - ;; provide — render-time dynamic scope + ;; scope — unified render-time dynamic scope + (= name "scope") + (let ((scope-name (async-eval (nth expr 1) env ctx)) + (rest-args (slice expr 2)) + (scope-val nil) + (body-exprs nil)) + ;; Check for :value keyword + (if (and (>= (len rest-args) 2) + (= (type-of (first rest-args)) "keyword") + (= (keyword-name (first rest-args)) "value")) + (do (set! scope-val (async-eval (nth rest-args 1) env ctx)) + (set! body-exprs (slice rest-args 2))) + (set! body-exprs rest-args)) + (scope-push! scope-name scope-val) + (let ((result (if (= (len body-exprs) 1) + (async-render (first body-exprs) env ctx) + (join "" (async-map-render body-exprs env ctx))))) + (scope-pop! scope-name) + result)) + + ;; provide — sugar for scope with value (= 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) + (scope-push! prov-name prov-val) (let ((result (if (= body-count 1) (async-render (nth expr body-start) env ctx) (join "" (async-map-render (slice expr body-start) env ctx))))) - (provide-pop! prov-name) + (scope-pop! prov-name) result)) ;; Fallback @@ -847,7 +867,7 @@ (skip false) (i 0)) ;; Provide scope for spread emit! - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) (for-each (fn (arg) (if skip @@ -890,7 +910,7 @@ (append! attr-parts (serialize v)))) (keys spread-dict))) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") (when token (svg-context-reset! token)) (let ((parts (concat (list name) attr-parts child-parts))) (make-sx-expr (str "(" (join " " parts) ")")))))) @@ -906,7 +926,7 @@ "define" "defcomp" "defmacro" "defstyle" "defhandler" "defpage" "defquery" "defaction" "begin" "do" "quote" "->" "set!" "defisland" - "deftype" "defeffect" "provide")) + "deftype" "defeffect" "scope" "provide")) (define ASYNC_ASER_HO_NAMES (list "map" "map-indexed" "filter" "for-each")) @@ -1044,15 +1064,35 @@ (= name "deftype") (= name "defeffect")) (do (async-eval expr env ctx) nil) - ;; provide — render-time dynamic scope + ;; scope — unified render-time dynamic scope + (= name "scope") + (let ((scope-name (async-eval (first args) env ctx)) + (rest-args (rest args)) + (scope-val nil) + (body-args nil)) + ;; Check for :value keyword + (if (and (>= (len rest-args) 2) + (= (type-of (first rest-args)) "keyword") + (= (keyword-name (first rest-args)) "value")) + (do (set! scope-val (async-eval (nth rest-args 1) env ctx)) + (set! body-args (slice rest-args 2))) + (set! body-args rest-args)) + (scope-push! scope-name scope-val) + (let ((result nil)) + (for-each (fn (body) (set! result (async-aser body env ctx))) + body-args) + (scope-pop! scope-name) + result)) + + ;; provide — sugar for scope with value (= 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) + (scope-push! prov-name prov-val) (for-each (fn (body) (set! result (async-aser body env ctx))) (slice args 2)) - (provide-pop! prov-name) + (scope-pop! prov-name) result) ;; Fallback diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 042f9b7..323b23e 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -181,7 +181,7 @@ (el (dom-create-element tag new-ns))) ;; Provide scope for spread emit! — deeply nested spreads emit here - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) ;; Process args: keywords → attrs, others → children (reduce @@ -269,7 +269,7 @@ (dom-set-attr el key (str val)))))) (keys spread-dict))) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") el))) @@ -381,7 +381,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" "provide")) + "error-boundary" "scope" "provide")) (define render-dom-form? :effects [] (fn ((name :as string)) @@ -637,17 +637,39 @@ coll) frag) - ;; provide — render-time dynamic scope + ;; scope — unified render-time dynamic scope + (= name "scope") + (let ((scope-name (trampoline (eval-expr (nth expr 1) env))) + (rest-args (slice expr 2)) + (scope-val nil) + (body-exprs nil) + (frag (create-fragment))) + ;; Check for :value keyword + (if (and (>= (len rest-args) 2) + (= (type-of (first rest-args)) "keyword") + (= (keyword-name (first rest-args)) "value")) + (do (set! scope-val (trampoline (eval-expr (nth rest-args 1) env))) + (set! body-exprs (slice rest-args 2))) + (set! body-exprs rest-args)) + (scope-push! scope-name scope-val) + (for-each + (fn (e) + (dom-append frag (render-to-dom e env ns))) + body-exprs) + (scope-pop! scope-name) + frag) + + ;; provide — sugar for scope with value (= 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) + (scope-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) + (scope-pop! prov-name) frag) ;; Fallback diff --git a/shared/sx/ref/adapter-html.sx b/shared/sx/ref/adapter-html.sx index bc5d833..843f2a6 100644 --- a/shared/sx/ref/adapter-html.sx +++ b/shared/sx/ref/adapter-html.sx @@ -56,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" "provide")) + "map" "map-indexed" "filter" "for-each" "scope" "provide")) (define render-html-form? :effects [] (fn ((name :as string)) @@ -229,18 +229,38 @@ (render-to-html (apply f (list item)) env))) coll))) - ;; provide — render-time dynamic scope + ;; scope — unified render-time dynamic scope + (= name "scope") + (let ((scope-name (trampoline (eval-expr (nth expr 1) env))) + (rest-args (slice expr 2)) + (scope-val nil) + (body-exprs nil)) + ;; Check for :value keyword + (if (and (>= (len rest-args) 2) + (= (type-of (first rest-args)) "keyword") + (= (keyword-name (first rest-args)) "value")) + (do (set! scope-val (trampoline (eval-expr (nth rest-args 1) env))) + (set! body-exprs (slice rest-args 2))) + (set! body-exprs rest-args)) + (scope-push! scope-name scope-val) + (let ((result (if (= (len body-exprs) 1) + (render-to-html (first body-exprs) env) + (join "" (map (fn (e) (render-to-html e env)) body-exprs))))) + (scope-pop! scope-name) + result)) + + ;; provide — sugar for scope with value (= 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) + (scope-push! prov-name prov-val) (let ((result (if (= body-count 1) (render-to-html (nth expr body-start) env) (join "" (map (fn (i) (render-to-html (nth expr i) env)) (range body-start (+ body-start body-count))))))) - (provide-pop! prov-name) + (scope-pop! prov-name) result)) ;; Fallback @@ -314,12 +334,12 @@ (str "<" tag (render-attrs attrs) " />") ;; Provide scope for spread emit! (do - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) (let ((content (join "" (map (fn (c) (render-to-html c env)) children)))) (for-each (fn (spread-dict) (merge-spread-attrs attrs spread-dict)) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") (str "<" tag (render-attrs attrs) ">" content ""))))))) @@ -359,12 +379,12 @@ args) ;; Provide scope for spread emit! (let ((lake-attrs (dict "data-sx-lake" (or lake-id "")))) - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) (let ((content (join "" (map (fn (c) (render-to-html c env)) children)))) (for-each (fn (spread-dict) (merge-spread-attrs lake-attrs spread-dict)) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") (str "<" lake-tag (render-attrs lake-attrs) ">" content "")))))) @@ -407,12 +427,12 @@ args) ;; Provide scope for spread emit! (let ((marsh-attrs (dict "data-sx-marsh" (or marsh-id "")))) - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) (let ((content (join "" (map (fn (c) (render-to-html c env)) children)))) (for-each (fn (spread-dict) (merge-spread-attrs marsh-attrs spread-dict)) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") (str "<" marsh-tag (render-attrs marsh-attrs) ">" content "")))))) diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx index 47f2738..85f4f6a 100644 --- a/shared/sx/ref/adapter-sx.sx +++ b/shared/sx/ref/adapter-sx.sx @@ -144,7 +144,7 @@ (skip false) (i 0)) ;; Provide scope for spread emit! - (provide-push! "element-attrs" nil) + (scope-push! "element-attrs" nil) (for-each (fn (arg) (if skip @@ -179,7 +179,7 @@ (append! attr-parts (serialize v)))) (keys spread-dict))) (emitted "element-attrs")) - (provide-pop! "element-attrs") + (scope-pop! "element-attrs") (let ((parts (concat (list name) attr-parts child-parts))) (str "(" (join " " parts) ")"))))) @@ -195,7 +195,7 @@ "defhandler" "defpage" "defquery" "defaction" "defrelation" "begin" "do" "quote" "quasiquote" "->" "set!" "letrec" "dynamic-wind" "defisland" - "deftype" "defeffect" "provide")) + "deftype" "defeffect" "scope" "provide")) (define HO_FORM_NAMES (list "map" "map-indexed" "filter" "reduce" @@ -333,15 +333,35 @@ (= name "deftype") (= name "defeffect")) (do (trampoline (eval-expr expr env)) nil) - ;; provide — render-time dynamic scope + ;; scope — unified render-time dynamic scope + (= name "scope") + (let ((scope-name (trampoline (eval-expr (first args) env))) + (rest-args (rest args)) + (scope-val nil) + (body-args nil)) + ;; Check for :value keyword + (if (and (>= (len rest-args) 2) + (= (type-of (first rest-args)) "keyword") + (= (keyword-name (first rest-args)) "value")) + (do (set! scope-val (trampoline (eval-expr (nth rest-args 1) env))) + (set! body-args (slice rest-args 2))) + (set! body-args rest-args)) + (scope-push! scope-name scope-val) + (let ((result nil)) + (for-each (fn (body) (set! result (aser body env))) + body-args) + (scope-pop! scope-name) + result)) + + ;; provide — sugar for scope with value (= 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) + (scope-push! prov-name prov-val) (for-each (fn (body) (set! result (aser body env))) (slice args 2)) - (provide-pop! prov-name) + (scope-pop! prov-name) result) ;; Everything else — evaluate normally diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index ea9f089..ad4d251 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -293,6 +293,8 @@ class PyEmitter: "collect!": "sx_collect", "collected": "sx_collected", "clear-collected!": "sx_clear_collected", + "scope-push!": "scope_push", + "scope-pop!": "scope_pop", "provide-push!": "provide_push", "provide-pop!": "provide_pop", "context": "sx_context", diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index 49a7761..ec86109 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -374,30 +374,44 @@ ;; -------------------------------------------------------------------------- -;; Tier 5: Dynamic scope — render-time provide/context/emit! +;; Tier 5: Scoped effects — unified render-time dynamic scope ;; -;; `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. +;; `scope` is the general primitive. `provide` is sugar for scope-with-value. +;; Both `provide` and `scope` are special forms in the evaluator. ;; -;; The platform must implement per-name stacks. Each entry has a value and -;; an emitted list. `provide-push!`/`provide-pop!` manage the stack. +;; The platform must implement per-name stacks. Each entry has a value, +;; an emitted list, and a dedup flag. `scope-push!`/`scope-pop!` manage +;; the stack. `provide-push!`/`provide-pop!` are aliases. +;; +;; `collect!`/`collected`/`clear-collected!` (Tier 4) are backed by scopes: +;; collect! lazily creates a root scope with dedup=true, then emits into it. ;; -------------------------------------------------------------------------- -(declare-tier :dynamic-scope :source "eval.sx") +(declare-tier :scoped-effects :source "eval.sx") + +(declare-spread-primitive "scope-push!" + :params (name value) + :returns "nil" + :effects [mutation] + :doc "Push a scope with name and value. General form — provide-push! is an alias.") + +(declare-spread-primitive "scope-pop!" + :params (name) + :returns "nil" + :effects [mutation] + :doc "Pop the most recent scope for name. General form — provide-pop! is an alias.") (declare-spread-primitive "provide-push!" :params (name value) :returns "nil" :effects [mutation] - :doc "Push a provider scope with name and value (platform internal).") + :doc "Alias for scope-push!. Push a scope with name and value.") (declare-spread-primitive "provide-pop!" :params (name) :returns "nil" :effects [mutation] - :doc "Pop the most recent provider scope for name (platform internal).") + :doc "Alias for scope-pop!. Pop the most recent scope for name.") (declare-spread-primitive "context" :params (name &rest default) diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index 3fb4dee..edd380e 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -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 "scope") (sf-scope args env) (= name "provide") (sf-provide args env) ;; Higher-order forms @@ -951,11 +952,35 @@ ;; -------------------------------------------------------------------------- -;; 6a2. provide — render-time dynamic scope +;; 6a2. scope — unified render-time dynamic scope primitive ;; -------------------------------------------------------------------------- ;; -;; (provide name value body...) — push a named scope with value and empty -;; accumulator, evaluate body, pop scope. Returns last body result. +;; (scope name body...) or (scope name :value v body...) +;; Push a named scope with optional value and empty accumulator, +;; evaluate body, pop scope. Returns last body result. +;; +;; `provide` is sugar: (provide name value body...) = (scope name :value value body...) + +(define sf-scope + (fn ((args :as list) (env :as dict)) + (let ((name (trampoline (eval-expr (first args) env))) + (rest (slice args 1)) + (val nil) + (body-exprs nil)) + ;; Check for :value keyword + (if (and (>= (len rest) 2) (= (type-of (first rest)) "keyword") (= (keyword-name (first rest)) "value")) + (do (set! val (trampoline (eval-expr (nth rest 1) env))) + (set! body-exprs (slice rest 2))) + (set! body-exprs rest)) + (scope-push! name val) + (let ((result nil)) + (for-each (fn (e) (set! result (trampoline (eval-expr e env)))) body-exprs) + (scope-pop! name) + result)))) + + +;; provide — sugar for scope with a value +;; (provide name value body...) → (scope name :value value body...) (define sf-provide (fn ((args :as list) (env :as dict)) @@ -963,9 +988,9 @@ (val (trampoline (eval-expr (nth args 1) env))) (body-exprs (slice args 2)) (result nil)) - (provide-push! name val) + (scope-push! name val) (for-each (fn (e) (set! result (trampoline (eval-expr e env)))) body-exprs) - (provide-pop! name) + (scope-pop! name) result))) diff --git a/shared/sx/ref/js.sx b/shared/sx/ref/js.sx index d5cd2bd..9bd79d4 100644 --- a/shared/sx/ref/js.sx +++ b/shared/sx/ref/js.sx @@ -527,6 +527,8 @@ "collect!" "sxCollect" "collected" "sxCollected" "clear-collected!" "sxClearCollected" + "scope-push!" "scopePush" + "scope-pop!" "scopePop" "provide-push!" "providePush" "provide-pop!" "providePop" "context" "sxContext" diff --git a/shared/sx/ref/platform_js.py b/shared/sx/ref/platform_js.py index 909be04..69ea298 100644 --- a/shared/sx/ref/platform_js.py +++ b/shared/sx/ref/platform_js.py @@ -883,8 +883,7 @@ PREAMBLE = '''\ function SxSpread(attrs) { this.attrs = attrs || {}; } SxSpread.prototype._spread = true; - var _collectBuckets = {}; - var _provideStacks = {}; + var _scopeStacks = {}; function isSym(x) { return x != null && x._sym === true; } function isKw(x) { return x != null && x._kw === true; } @@ -1098,14 +1097,17 @@ PRIMITIVES_JS_MODULES: dict[str, str] = { ''', "stdlib.spread": ''' - // stdlib.spread — spread + collect primitives + // stdlib.spread — spread + collect + scope 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 + // scope — unified render-time dynamic scope + PRIMITIVES["scope-push!"] = scopePush; + PRIMITIVES["scope-pop!"] = scopePop; + // provide-push!/provide-pop! — aliases for scope-push!/scope-pop! PRIMITIVES["provide-push!"] = providePush; PRIMITIVES["provide-pop!"] = providePop; PRIMITIVES["context"] = sxContext; @@ -1174,44 +1176,54 @@ PLATFORM_JS_PRE = ''' 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 scopePush(name, value) { + if (!_scopeStacks[name]) _scopeStacks[name] = []; + _scopeStacks[name].push({value: value !== undefined ? value : NIL, emitted: [], dedup: false}); } - function sxCollected(bucket) { - return _collectBuckets[bucket] ? _collectBuckets[bucket].slice() : []; - } - function sxClearCollected(bucket) { - if (_collectBuckets[bucket]) _collectBuckets[bucket] = []; + function scopePop(name) { + if (_scopeStacks[name] && _scopeStacks[name].length) _scopeStacks[name].pop(); } + // Aliases — provide-push!/provide-pop! map to scope-push!/scope-pop! + var providePush = scopePush; + var providePop = scopePop; - 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 (_scopeStacks[name] && _scopeStacks[name].length) { + return _scopeStacks[name][_scopeStacks[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); + if (_scopeStacks[name] && _scopeStacks[name].length) { + var entry = _scopeStacks[name][_scopeStacks[name].length - 1]; + if (entry.dedup && entry.emitted.indexOf(value) !== -1) return NIL; + entry.emitted.push(value); } return NIL; } function sxEmitted(name) { - if (_provideStacks[name] && _provideStacks[name].length) { - return _provideStacks[name][_provideStacks[name].length - 1].emitted.slice(); + if (_scopeStacks[name] && _scopeStacks[name].length) { + return _scopeStacks[name][_scopeStacks[name].length - 1].emitted.slice(); } return []; } + function sxCollect(bucket, value) { + if (!_scopeStacks[bucket] || !_scopeStacks[bucket].length) { + if (!_scopeStacks[bucket]) _scopeStacks[bucket] = []; + _scopeStacks[bucket].push({value: NIL, emitted: [], dedup: true}); + } + var entry = _scopeStacks[bucket][_scopeStacks[bucket].length - 1]; + if (entry.emitted.indexOf(value) === -1) entry.emitted.push(value); + } + function sxCollected(bucket) { + return sxEmitted(bucket); + } + function sxClearCollected(bucket) { + if (_scopeStacks[bucket] && _scopeStacks[bucket].length) { + _scopeStacks[bucket][_scopeStacks[bucket].length - 1].emitted = []; + } + } function lambdaParams(f) { return f.params; } function lambdaBody(f) { return f.body; } @@ -3244,6 +3256,8 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has api_lines.append(' collect: sxCollect,') api_lines.append(' collected: sxCollected,') api_lines.append(' clearCollected: sxClearCollected,') + api_lines.append(' scopePush: scopePush,') + api_lines.append(' scopePop: scopePop,') api_lines.append(' providePush: providePush,') api_lines.append(' providePop: providePop,') api_lines.append(' context: sxContext,') diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index 78be9fb..4768a00 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -91,51 +91,56 @@ class _Spread: self.attrs = dict(attrs) if attrs else {} -# Render-time accumulator buckets (per render pass) -_collect_buckets: dict[str, list] = {} +# Unified scope stacks — backing store for provide/context/emit!/collect! +# Each entry: {"value": v, "emitted": [], "dedup": bool} +_scope_stacks: dict[str, list[dict]] = {} def _collect_reset(): - """Reset all collect buckets (call at start of each render pass).""" - global _collect_buckets - _collect_buckets = {} + """Reset all scope stacks (call at start of each render pass).""" + global _scope_stacks + _scope_stacks = {} -# Render-time dynamic scope stacks (provide/context/emit!) -_provide_stacks: dict[str, list[dict]] = {} +def scope_push(name, value=None): + """Push a scope with name, value, and empty accumulator.""" + _scope_stacks.setdefault(name, []).append({"value": value, "emitted": [], "dedup": False}) -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 scope_pop(name): + """Pop the most recent scope for name.""" + if name in _scope_stacks and _scope_stacks[name]: + _scope_stacks[name].pop() -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() +# Aliases — provide-push!/provide-pop! map to scope-push!/scope-pop! +provide_push = scope_push +provide_pop = scope_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"] + """Read value from nearest enclosing scope. Error if no scope and no default.""" + if name in _scope_stacks and _scope_stacks[name]: + return _scope_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. No-op if no provider.""" - if name in _provide_stacks and _provide_stacks[name]: - _provide_stacks[name][-1]["emitted"].append(value) + """Append value to nearest enclosing scope's accumulator. Respects dedup flag.""" + if name in _scope_stacks and _scope_stacks[name]: + entry = _scope_stacks[name][-1] + if entry["dedup"] and value in entry["emitted"]: + return NIL + entry["emitted"].append(value) 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 list of values emitted into nearest matching scope.""" + if name in _scope_stacks and _scope_stacks[name]: + return list(_scope_stacks[name][-1]["emitted"]) return [] @@ -340,23 +345,23 @@ def spread_attrs(s): 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) + """Add value to named scope accumulator (deduplicated). Lazily creates root scope.""" + if bucket not in _scope_stacks or not _scope_stacks[bucket]: + _scope_stacks.setdefault(bucket, []).append({"value": None, "emitted": [], "dedup": True}) + entry = _scope_stacks[bucket][-1] + if value not in entry["emitted"]: + entry["emitted"].append(value) def sx_collected(bucket): - """Return all values in named render-time accumulator.""" - return list(_collect_buckets.get(bucket, [])) + """Return all values collected in named scope accumulator.""" + return sx_emitted(bucket) def sx_clear_collected(bucket): - """Clear a named render-time accumulator bucket.""" - if bucket in _collect_buckets: - _collect_buckets[bucket] = [] + """Clear nearest scope's accumulator for name.""" + if bucket in _scope_stacks and _scope_stacks[bucket]: + _scope_stacks[bucket][-1]["emitted"] = [] def lambda_params(f): @@ -974,14 +979,17 @@ PRIMITIVES["assert"] = lambda cond, msg="Assertion failed": (_ for _ in ()).thro ''', "stdlib.spread": ''' -# stdlib.spread — spread + collect primitives +# stdlib.spread — spread + collect + scope 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 +# scope — unified render-time dynamic scope +PRIMITIVES["scope-push!"] = scope_push +PRIMITIVES["scope-pop!"] = scope_pop +# provide-push!/provide-pop! — aliases for scope-push!/scope-pop! PRIMITIVES["provide-push!"] = provide_push PRIMITIVES["provide-pop!"] = provide_pop PRIMITIVES["context"] = sx_context diff --git a/shared/sx/ref/py.sx b/shared/sx/ref/py.sx index 9a28375..8582428 100644 --- a/shared/sx/ref/py.sx +++ b/shared/sx/ref/py.sx @@ -252,6 +252,8 @@ "collect!" "sx_collect" "collected" "sx_collected" "clear-collected!" "sx_clear_collected" + "scope-push!" "scope_push" + "scope-pop!" "scope_pop" "provide-push!" "provide_push" "provide-pop!" "provide_pop" "context" "sx_context" diff --git a/shared/sx/ref/render.sx b/shared/sx/ref/render.sx index 24cdaaf..0293da5 100644 --- a/shared/sx/ref/render.sx +++ b/shared/sx/ref/render.sx @@ -269,11 +269,13 @@ ;; (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) +;; Scoped effects (scope/provide/context/emit!): +;; (scope-push! name val) → void (general form) +;; (scope-pop! name) → void (general form) +;; (provide-push! name val) → alias for scope-push! +;; (provide-pop! name) → alias for scope-pop! +;; (context name &rest def) → value from nearest scope +;; (emit! name value) → void (append to scope accumulator) ;; (emitted name) → list of emitted values ;; ;; From parser.sx: diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 3b2c1a4..b23ee02 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -50,51 +50,56 @@ class _Spread: self.attrs = dict(attrs) if attrs else {} -# Render-time accumulator buckets (per render pass) -_collect_buckets: dict[str, list] = {} +# Unified scope stacks — backing store for provide/context/emit!/collect! +# Each entry: {"value": v, "emitted": [], "dedup": bool} +_scope_stacks: dict[str, list[dict]] = {} def _collect_reset(): - """Reset all collect buckets (call at start of each render pass).""" - global _collect_buckets - _collect_buckets = {} + """Reset all scope stacks (call at start of each render pass).""" + global _scope_stacks + _scope_stacks = {} -# Render-time dynamic scope stacks (provide/context/emit!) -_provide_stacks: dict[str, list[dict]] = {} +def scope_push(name, value=None): + """Push a scope with name, value, and empty accumulator.""" + _scope_stacks.setdefault(name, []).append({"value": value, "emitted": [], "dedup": False}) -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 scope_pop(name): + """Pop the most recent scope for name.""" + if name in _scope_stacks and _scope_stacks[name]: + _scope_stacks[name].pop() -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() +# Aliases — provide-push!/provide-pop! map to scope-push!/scope-pop! +provide_push = scope_push +provide_pop = scope_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"] + """Read value from nearest enclosing scope. Error if no scope and no default.""" + if name in _scope_stacks and _scope_stacks[name]: + return _scope_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. No-op if no provider.""" - if name in _provide_stacks and _provide_stacks[name]: - _provide_stacks[name][-1]["emitted"].append(value) + """Append value to nearest enclosing scope's accumulator. Respects dedup flag.""" + if name in _scope_stacks and _scope_stacks[name]: + entry = _scope_stacks[name][-1] + if entry["dedup"] and value in entry["emitted"]: + return NIL + entry["emitted"].append(value) 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 list of values emitted into nearest matching scope.""" + if name in _scope_stacks and _scope_stacks[name]: + return list(_scope_stacks[name][-1]["emitted"]) return [] @@ -299,23 +304,23 @@ def spread_attrs(s): 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) + """Add value to named scope accumulator (deduplicated). Lazily creates root scope.""" + if bucket not in _scope_stacks or not _scope_stacks[bucket]: + _scope_stacks.setdefault(bucket, []).append({"value": None, "emitted": [], "dedup": True}) + entry = _scope_stacks[bucket][-1] + if value not in entry["emitted"]: + entry["emitted"].append(value) def sx_collected(bucket): - """Return all values in named render-time accumulator.""" - return list(_collect_buckets.get(bucket, [])) + """Return all values collected in named scope accumulator.""" + return sx_emitted(bucket) def sx_clear_collected(bucket): - """Clear a named render-time accumulator bucket.""" - if bucket in _collect_buckets: - _collect_buckets[bucket] = [] + """Clear nearest scope's accumulator for name.""" + if bucket in _scope_stacks and _scope_stacks[bucket]: + _scope_stacks[bucket][-1]["emitted"] = [] def lambda_params(f): @@ -937,14 +942,17 @@ 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 +# stdlib.spread — spread + collect + scope 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 +# scope — unified render-time dynamic scope +PRIMITIVES["scope-push!"] = scope_push +PRIMITIVES["scope-pop!"] = scope_pop +# provide-push!/provide-pop! — aliases for scope-push!/scope-pop! PRIMITIVES["provide-push!"] = provide_push PRIMITIVES["provide-pop!"] = provide_pop PRIMITIVES["context"] = sx_context @@ -1394,6 +1402,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 == 'scope')): + return sf_scope(args, env) elif sx_truthy((name == 'provide')): return sf_provide(args, env) elif sx_truthy((name == 'map')): @@ -1887,6 +1897,25 @@ def sf_dynamic_wind(args, env): call_thunk(after, env) return result +# sf-scope +def sf_scope(args, env): + _cells = {} + name = trampoline(eval_expr(first(args), env)) + rest = slice(args, 1) + val = NIL + body_exprs = NIL + if sx_truthy(((len(rest) >= 2) if not sx_truthy((len(rest) >= 2)) else ((type_of(first(rest)) == 'keyword') if not sx_truthy((type_of(first(rest)) == 'keyword')) else (keyword_name(first(rest)) == 'value')))): + val = trampoline(eval_expr(nth(rest, 1), env)) + body_exprs = slice(rest, 2) + else: + body_exprs = rest + scope_push(name, val) + _cells['result'] = NIL + for e in body_exprs: + _cells['result'] = trampoline(eval_expr(e, env)) + scope_pop(name) + return _cells['result'] + # sf-provide def sf_provide(args, env): _cells = {} @@ -1894,10 +1923,10 @@ def sf_provide(args, env): val = trampoline(eval_expr(nth(args, 1), env)) body_exprs = slice(args, 2) _cells['result'] = NIL - provide_push(name, val) + scope_push(name, val) for e in body_exprs: _cells['result'] = trampoline(eval_expr(e, env)) - provide_pop(name) + scope_pop(name) return _cells['result'] # expand-macro @@ -2251,7 +2280,7 @@ def render_value_to_html(val, env): 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', 'provide'] +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', 'scope', 'provide'] # render-html-form? def is_render_html_form(name): @@ -2350,14 +2379,28 @@ def dispatch_html_form(name, expr, env): 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)) + elif sx_truthy((name == 'scope')): + scope_name = trampoline(eval_expr(nth(expr, 1), env)) + rest_args = slice(expr, 2) + scope_val = NIL + body_exprs = NIL + if sx_truthy(((len(rest_args) >= 2) if not sx_truthy((len(rest_args) >= 2)) else ((type_of(first(rest_args)) == 'keyword') if not sx_truthy((type_of(first(rest_args)) == 'keyword')) else (keyword_name(first(rest_args)) == 'value')))): + scope_val = trampoline(eval_expr(nth(rest_args, 1), env)) + body_exprs = slice(rest_args, 2) + else: + body_exprs = rest_args + scope_push(scope_name, scope_val) + result = (render_to_html(first(body_exprs), env) if sx_truthy((len(body_exprs) == 1)) else join('', map(lambda e: render_to_html(e, env), body_exprs))) + scope_pop(scope_name) + return result 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) + scope_push(prov_name, prov_val) result = (render_to_html(nth(expr, body_start), env) if sx_truthy((body_count == 1)) else join('', map(lambda i: render_to_html(nth(expr, i), env), range(body_start, (body_start + body_count))))) - provide_pop(prov_name) + scope_pop(prov_name) return result else: return render_value_to_html(trampoline(eval_expr(expr, env)), env) @@ -2389,11 +2432,11 @@ def render_html_element(tag, args, env): if sx_truthy(is_void): return sx_str('<', tag, render_attrs(attrs), ' />') else: - provide_push('element-attrs', NIL) + scope_push('element-attrs', NIL) content = join('', map(lambda c: render_to_html(c, env), children)) for spread_dict in sx_emitted('element-attrs'): merge_spread_attrs(attrs, spread_dict) - provide_pop('element-attrs') + scope_pop('element-attrs') return sx_str('<', tag, render_attrs(attrs), '>', content, '') # render-html-lake @@ -2404,11 +2447,11 @@ def render_html_lake(args, env): 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) lake_attrs = {'data-sx-lake': (_cells['lake_id'] if sx_truthy(_cells['lake_id']) else '')} - provide_push('element-attrs', NIL) + scope_push('element-attrs', NIL) content = join('', map(lambda c: render_to_html(c, env), children)) for spread_dict in sx_emitted('element-attrs'): merge_spread_attrs(lake_attrs, spread_dict) - provide_pop('element-attrs') + scope_pop('element-attrs') return sx_str('<', _cells['lake_tag'], render_attrs(lake_attrs), '>', content, '') # render-html-marsh @@ -2419,11 +2462,11 @@ def render_html_marsh(args, env): 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) marsh_attrs = {'data-sx-marsh': (_cells['marsh_id'] if sx_truthy(_cells['marsh_id']) else '')} - provide_push('element-attrs', NIL) + scope_push('element-attrs', NIL) content = join('', map(lambda c: render_to_html(c, env), children)) for spread_dict in sx_emitted('element-attrs'): merge_spread_attrs(marsh_attrs, spread_dict) - provide_pop('element-attrs') + scope_pop('element-attrs') return sx_str('<', _cells['marsh_tag'], render_attrs(marsh_attrs), '>', content, '') # render-html-island @@ -2529,7 +2572,7 @@ def aser_call(name, args, env): child_parts = [] _cells['skip'] = False _cells['i'] = 0 - provide_push('element-attrs', NIL) + scope_push('element-attrs', NIL) for arg in args: if sx_truthy(_cells['skip']): _cells['skip'] = False @@ -2557,12 +2600,12 @@ def aser_call(name, args, env): v = dict_get(spread_dict, k) attr_parts.append(sx_str(':', k)) attr_parts.append(serialize(v)) - provide_pop('element-attrs') + scope_pop('element-attrs') parts = concat([name], attr_parts, child_parts) 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', 'provide'] +SPECIAL_FORM_NAMES = ['if', 'when', 'cond', 'case', 'and', 'or', 'let', 'let*', 'lambda', 'fn', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'defpage', 'defquery', 'defaction', 'defrelation', 'begin', 'do', 'quote', 'quasiquote', '->', 'set!', 'letrec', 'dynamic-wind', 'defisland', 'deftype', 'defeffect', 'scope', 'provide'] # HO_FORM_NAMES HO_FORM_NAMES = ['map', 'map-indexed', 'filter', 'reduce', 'some', 'every?', 'for-each'] @@ -2659,14 +2702,30 @@ 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 == 'scope')): + scope_name = trampoline(eval_expr(first(args), env)) + rest_args = rest(args) + scope_val = NIL + body_args = NIL + if sx_truthy(((len(rest_args) >= 2) if not sx_truthy((len(rest_args) >= 2)) else ((type_of(first(rest_args)) == 'keyword') if not sx_truthy((type_of(first(rest_args)) == 'keyword')) else (keyword_name(first(rest_args)) == 'value')))): + scope_val = trampoline(eval_expr(nth(rest_args, 1), env)) + body_args = slice(rest_args, 2) + else: + body_args = rest_args + scope_push(scope_name, scope_val) + _cells['result'] = NIL + for body in body_args: + _cells['result'] = aser(body, env) + scope_pop(scope_name) + return _cells['result'] 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) + scope_push(prov_name, prov_val) for body in slice(args, 2): _cells['result'] = aser(body, env) - provide_pop(prov_name) + scope_pop(prov_name) return _cells['result'] else: return trampoline(eval_expr(expr, env)) @@ -3701,12 +3760,12 @@ async def async_render_element(tag, args, env, ctx): else: token = (svg_context_set(True) if sx_truthy(((tag == 'svg') if sx_truthy((tag == 'svg')) else (tag == 'math'))) else NIL) content_parts = [] - provide_push('element-attrs', NIL) + scope_push('element-attrs', NIL) for c in children: content_parts.append((await async_render(c, env, ctx))) for spread_dict in sx_emitted('element-attrs'): merge_spread_attrs(attrs, spread_dict) - provide_pop('element-attrs') + scope_pop('element-attrs') if sx_truthy(token): svg_context_reset(token) return sx_str('<', tag, render_attrs(attrs), '>', join('', content_parts), '') @@ -3798,7 +3857,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', 'provide'] +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', 'scope', 'provide'] # async-render-form? def async_render_form_p(name): @@ -3859,14 +3918,28 @@ async def dispatch_async_render_form(name, expr, env, ctx): 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))) + elif sx_truthy((name == 'scope')): + scope_name = (await async_eval(nth(expr, 1), env, ctx)) + rest_args = slice(expr, 2) + scope_val = NIL + body_exprs = NIL + if sx_truthy(((len(rest_args) >= 2) if not sx_truthy((len(rest_args) >= 2)) else ((type_of(first(rest_args)) == 'keyword') if not sx_truthy((type_of(first(rest_args)) == 'keyword')) else (keyword_name(first(rest_args)) == 'value')))): + scope_val = (await async_eval(nth(rest_args, 1), env, ctx)) + body_exprs = slice(rest_args, 2) + else: + body_exprs = rest_args + scope_push(scope_name, scope_val) + result = ((await async_render(first(body_exprs), env, ctx)) if sx_truthy((len(body_exprs) == 1)) else join('', (await async_map_render(body_exprs, env, ctx)))) + scope_pop(scope_name) + return result 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) + scope_push(prov_name, prov_val) result = ((await async_render(nth(expr, body_start), env, ctx)) if sx_truthy((body_count == 1)) else join('', (await async_map_render(slice(expr, body_start), env, ctx)))) - provide_pop(prov_name) + scope_pop(prov_name) return result else: return (await async_render((await async_eval(expr, env, ctx)), env, ctx)) @@ -4149,7 +4222,7 @@ async def async_aser_call(name, args, env, ctx): child_parts = [] _cells['skip'] = False _cells['i'] = 0 - provide_push('element-attrs', NIL) + scope_push('element-attrs', NIL) for arg in args: if sx_truthy(_cells['skip']): _cells['skip'] = False @@ -4188,14 +4261,14 @@ async def async_aser_call(name, args, env, ctx): v = dict_get(spread_dict, k) attr_parts.append(sx_str(':', k)) attr_parts.append(serialize(v)) - provide_pop('element-attrs') + scope_pop('element-attrs') if sx_truthy(token): svg_context_reset(token) parts = concat([name], attr_parts, child_parts) 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', 'provide'] +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', 'scope', 'provide'] # ASYNC_ASER_HO_NAMES ASYNC_ASER_HO_NAMES = ['map', 'map-indexed', 'filter', 'for-each'] @@ -4289,14 +4362,30 @@ 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 == 'scope')): + scope_name = (await async_eval(first(args), env, ctx)) + rest_args = rest(args) + scope_val = NIL + body_args = NIL + if sx_truthy(((len(rest_args) >= 2) if not sx_truthy((len(rest_args) >= 2)) else ((type_of(first(rest_args)) == 'keyword') if not sx_truthy((type_of(first(rest_args)) == 'keyword')) else (keyword_name(first(rest_args)) == 'value')))): + scope_val = (await async_eval(nth(rest_args, 1), env, ctx)) + body_args = slice(rest_args, 2) + else: + body_args = rest_args + scope_push(scope_name, scope_val) + _cells['result'] = NIL + for body in body_args: + _cells['result'] = (await async_aser(body, env, ctx)) + scope_pop(scope_name) + return _cells['result'] 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) + scope_push(prov_name, prov_val) for body in slice(args, 2): _cells['result'] = (await async_aser(body, env, ctx)) - provide_pop(prov_name) + scope_pop(prov_name) return _cells['result'] else: return (await async_eval(expr, env, ctx)) diff --git a/shared/sx/ref/test-aser.sx b/shared/sx/ref/test-aser.sx index 432a14e..c175c89 100644 --- a/shared/sx/ref/test-aser.sx +++ b/shared/sx/ref/test-aser.sx @@ -301,3 +301,46 @@ (deftest "spread in non-element context silently drops" (assert-equal "hello" (render-sx "(do (make-spread {:class \"card\"}) \"hello\")")))) + + +;; -------------------------------------------------------------------------- +;; Scope tests — unified scope primitive +;; -------------------------------------------------------------------------- + +(defsuite "scope" + + (deftest "scope with value and context" + (assert-equal "dark" + (render-sx "(scope \"sc-theme\" :value \"dark\" (context \"sc-theme\"))"))) + + (deftest "scope without value defaults to nil" + (assert-equal "" + (render-sx "(scope \"sc-nil\" (str (context \"sc-nil\")))"))) + + (deftest "scope with emit!/emitted" + (assert-equal "a,b" + (render-sx "(scope \"sc-emit\" (emit! \"sc-emit\" \"a\") (emit! \"sc-emit\" \"b\") (join \",\" (emitted \"sc-emit\")))"))) + + (deftest "provide is equivalent to scope with value" + (assert-equal "42" + (render-sx "(provide \"sc-prov\" 42 (str (context \"sc-prov\")))"))) + + (deftest "collect! works via scope (lazy root scope)" + (assert-equal "x,y" + (render-sx "(do (collect! \"sc-coll\" \"x\") (collect! \"sc-coll\" \"y\") (join \",\" (collected \"sc-coll\")))"))) + + (deftest "collect! deduplicates" + (assert-equal "a" + (render-sx "(do (collect! \"sc-dedup\" \"a\") (collect! \"sc-dedup\" \"a\") (join \",\" (collected \"sc-dedup\")))"))) + + (deftest "clear-collected! clears scope accumulator" + (assert-equal "" + (render-sx "(do (collect! \"sc-clear\" \"x\") (clear-collected! \"sc-clear\") (join \",\" (collected \"sc-clear\")))"))) + + (deftest "nested scope shadows outer" + (assert-equal "inner" + (render-sx "(scope \"sc-nest\" :value \"outer\" (scope \"sc-nest\" :value \"inner\" (context \"sc-nest\")))"))) + + (deftest "scope pops correctly after body" + (assert-equal "outer" + (render-sx "(scope \"sc-pop\" :value \"outer\" (scope \"sc-pop\" :value \"inner\" \"ignore\") (context \"sc-pop\"))")))) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index ab5f8e2..9490e42 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -374,10 +374,12 @@ :children (list {:label "Reference" :href "/sx/(geography.(hypermedia.(reference)))" :children reference-nav-items} {:label "Examples" :href "/sx/(geography.(hypermedia.(example)))" :children examples-nav-items})} + {:label "Scopes" :href "/sx/(geography.(scopes))" + :summary "The unified primitive beneath provide, collect!, spreads, and islands. Named scope with downward value, upward accumulation, and a dedup flag."} {:label "Provide / Emit!" :href "/sx/(geography.(provide))" - :summary "Render-time dynamic scope — the substrate beneath spreads, CSSX, and script collection. Downward context, upward accumulation, one mechanism."} + :summary "Sugar for scope-with-value. Render-time dynamic scope — the substrate beneath spreads, CSSX, and script collection."} {:label "Spreads" :href "/sx/(geography.(spreads))" - :summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on provide/emit!."} + :summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on scopes."} {:label "Marshes" :href "/sx/(geography.(marshes))" :summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."} {:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items})} diff --git a/sx/sx/page-functions.sx b/sx/sx/page-functions.sx index a4d9e3d..5d117cf 100644 --- a/sx/sx/page-functions.sx +++ b/sx/sx/page-functions.sx @@ -60,6 +60,10 @@ "phase2" '(~reactive-islands/phase2/reactive-islands-phase2-content) :else '(~reactive-islands/index/reactive-islands-index-content))))) +(define scopes + (fn (content) + (if (nil? content) '(~geography/scopes-content) content))) + (define spreads (fn (content) (if (nil? content) '(~geography/spreads-content) content))) diff --git a/sx/sx/plans/scoped-effects.sx b/sx/sx/plans/scoped-effects.sx index d326fac..d970622 100644 --- a/sx/sx/plans/scoped-effects.sx +++ b/sx/sx/plans/scoped-effects.sx @@ -340,24 +340,27 @@ (p "The path from current SX to the scope primitive follows the existing plan " "and adds two phases:") - (~docs/subsection :title "Phase 1: provide/context/emit! (immediate)" - (p "Already planned. Implement render-time dynamic scope. Four primitives: " + (~docs/subsection :title "Phase 1: provide/context/emit! ✓" + (p (strong "Complete. ") "Render-time dynamic scope. Four primitives: " (code "provide") " (special form), " (code "context") ", " (code "emit!") ", " - (code "emitted") ". Platform provides " (code "provide-push!/provide-pop!") ".") - (p "This is " (code "scope") " with " (code ":propagation :render") " only. " - "No change to islands or lakes. Pure addition.") - (p (strong "Delivers: ") "render-time context, scoped accumulation, " - "spread and collect reimplemented as sugar over provide/emit.")) + (code "emitted") ". Platform provides " (code "scope-push!/scope-pop!") ". " + "Spreads reimplemented on provide/emit!.") + (p "See " + (a :href "/sx/(geography.(provide))" :class "text-violet-600 hover:underline" "provide article") + " and " + (a :href "/sx/(geography.(spreads))" :class "text-violet-600 hover:underline" "spreads article") + ".")) - (~docs/subsection :title "Phase 2: scope as the common form (next)" - (p "Introduce " (code "scope") " as the general form. " - (code "provide") " becomes sugar for " (code "(scope ... :propagation :render)") ". " - (code "defisland") " becomes sugar for " (code "(scope ... :propagation :reactive)") ". " - (code "lake") " becomes sugar for " (code "(scope ... :propagation :morph)") ".") - (p "The sugar forms remain — nobody writes " (code "scope") " directly in page code. " - "But the evaluator, adapters, and bootstrappers all dispatch through one mechanism.") - (p (strong "Delivers: ") "unified internal representation, reactive context (the new cell), " - "simplified adapter code (one scope handler instead of three separate paths).")) + (~docs/subsection :title "Phase 2: scope as the common form ✓" + (p (strong "Complete. ") (code "scope") " is now the general form. " + (code "provide") " is sugar for " (code "(scope name :value v body...)") ". " + (code "collect!") " creates a lazy root scope with deduplication. " + "All adapters use " (code "scope-push!/scope-pop!") " directly.") + (p "The unified platform structure:") + (~docs/code :code (highlight "_scope_stacks = {} ;; {name: [{value, emitted: [], dedup: bool}]}" "python")) + (p "See " + (a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes article") + ".")) (~docs/subsection :title "Phase 3: effect handlers (future)" (p "Make propagation modes extensible. A " (code ":propagation") " value is a " @@ -437,8 +440,10 @@ "and composable. It's the last primitive SX needs.") (~docs/note - (p (strong "Status: ") "Phase 1 (" (code "provide/context/emit!") ") is specced and " - "ready to build. Phase 2 (" (code "scope") " unification) follows naturally once " - "provide is working. Phase 3 (extensible handlers) is the research frontier — " + (p (strong "Status: ") "Phase 1 (" (code "provide/context/emit!") ") and " + "Phase 2 (" (code "scope") " unification) are complete. " + "574 tests pass. All four adapters use " (code "scope-push!/scope-pop!") ". " + (code "collect!") " is backed by lazy scopes with dedup. " + "Phase 3 (extensible handlers) is the research frontier — " "it may turn out that three modes are sufficient, or it may turn out that " "user-defined modes unlock something unexpected."))))) diff --git a/sx/sx/provide.sx b/sx/sx/provide.sx index ba5e48e..666c84b 100644 --- a/sx/sx/provide.sx +++ b/sx/sx/provide.sx @@ -67,10 +67,12 @@ (~docs/page :title "Provide / Context / Emit!" (p :class "text-stone-500 text-sm italic mb-8" - "Render-time dynamic scope. " (code "provide") " creates a named scope with a value " - "and an accumulator. " (code "context") " reads the value downward. " + "Sugar for " (code "scope") " with a value. " (code "provide") " creates a named scope " + "with a value and an accumulator. " (code "context") " reads the value downward. " (code "emit!") " appends to the accumulator upward. " (code "emitted") " retrieves what was emitted. " - "This is the substrate that spreads, CSSX, and script collection are built on.") + "See " + (a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes") + " for the unified primitive.") ;; ===================================================================== ;; I. The four primitives @@ -215,25 +217,26 @@ ;; ===================================================================== (~docs/section :title "Platform implementation" :id "platform" - (p "Each platform (Python, JavaScript) must provide five operations. " - "The platform manages per-name stacks — each stack entry has a value and an " - "emitted list.") + (p (code "provide") " is sugar for " (code "scope") ". At the platform level, " + (code "provide-push!") " and " (code "provide-pop!") " are aliases for " + (code "scope-push!") " and " (code "scope-pop!") ". All operations work on a unified " + (code "_scope_stacks") " data structure.") (~docs/table :headers (list "Platform primitive" "Purpose") :rows (list - (list "provide-push!(name, value)" "Push a new scope with value and empty emitted list") - (list "provide-pop!(name)" "Pop the most recent scope") + (list "scope-push!(name, value)" "Push a new scope with value and empty accumulator") + (list "scope-pop!(name)" "Pop the most recent scope") (list "context(name, ...default)" "Read value from nearest scope (error if missing and no default)") - (list "emit!(name, value)" "Append to nearest scope's emitted list (tolerant: no-op if missing)") - (list "emitted(name)" "Return list of emitted values from nearest scope"))) + (list "emit!(name, value)" "Append to nearest scope's accumulator (tolerant: no-op if missing)") + (list "emitted(name)" "Return accumulated values from nearest scope"))) - (p (code "provide") " itself is a special form in " + (p (code "provide") " is a special form in " (a :href "/sx/(language.(spec.(explore.evaluator)))" :class "font-mono text-violet-600 hover:underline text-sm" "eval.sx") - " — it calls " (code "provide-push!") ", evaluates the body, " - "then calls " (code "provide-pop!") ". The five platform primitives are declared in " - (a :href "/sx/(language.(spec.(explore.boundary)))" :class "font-mono text-violet-600 hover:underline text-sm" "boundary.sx") - " (Tier 5: Dynamic scope).") + " — it calls " (code "scope-push!") ", evaluates the body, " + "then calls " (code "scope-pop!") ". See " + (a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes") + " for the full unified platform.") (~docs/note (p (strong "Spec explorer: ") "See the provide/emit! primitives in " diff --git a/sx/sx/reactive-islands/demo.sx b/sx/sx/reactive-islands/demo.sx index d7dc4d5..c92d8ea 100644 --- a/sx/sx/reactive-islands/demo.sx +++ b/sx/sx/reactive-islands/demo.sx @@ -11,73 +11,73 @@ (~docs/section :title "1. Signal + Computed + Effect" :id "demo-counter" (p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.") - (~reactive-islands/demo/counter :initial 0) + (~reactive-islands/index/demo-counter :initial 0) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"−\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp")) (p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing.")) (~docs/section :title "2. Temperature Converter" :id "demo-temperature" (p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.") - (~reactive-islands/demo/temperature) + (~reactive-islands/index/demo-temperature) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"−5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp")) (p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes.")) (~docs/section :title "3. Effect + Cleanup: Stopwatch" :id "demo-stopwatch" (p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.") - (~reactive-islands/demo/stopwatch) + (~reactive-islands/index/demo-stopwatch) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp")) (p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order.")) (~docs/section :title "4. Imperative Pattern" :id "demo-imperative" (p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".") - (~reactive-islands/demo/imperative) + (~reactive-islands/index/demo-imperative) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp")) (p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates.")) (~docs/section :title "5. Reactive List" :id "demo-reactive-list" (p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.") - (~reactive-islands/demo/reactive-list) + (~reactive-islands/index/demo-reactive-list) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp")) (p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass.")) (~docs/section :title "6. Input Binding" :id "demo-input-binding" (p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.") - (~reactive-islands/demo/input-binding) + (~reactive-islands/index/demo-input-binding) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp")) (p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump.")) (~docs/section :title "7. Portals" :id "demo-portal" (p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.") - (~reactive-islands/demo/portal) + (~reactive-islands/index/demo-portal) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp")) (p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup.")) (~docs/section :title "8. Error Boundaries" :id "demo-error-boundary" (p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.") - (~reactive-islands/demo/error-boundary) + (~reactive-islands/index/demo-error-boundary) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp")) (p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") ".")) (~docs/section :title "9. Refs — Imperative DOM Access" :id "demo-refs" (p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.") - (~reactive-islands/demo/refs) + (~reactive-islands/index/demo-refs) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp")) (p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") ".")) (~docs/section :title "10. Dynamic Class and Style" :id "demo-dynamic-class" (p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.") - (~reactive-islands/demo/dynamic-class) + (~reactive-islands/index/demo-dynamic-class) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp")) (p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string.")) (~docs/section :title "11. Resource + Suspense Pattern" :id "demo-resource" (p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.") - (~reactive-islands/demo/resource) + (~reactive-islands/index/demo-resource) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp")) (p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically.")) (~docs/section :title "12. Transition Pattern" :id "demo-transition" (p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.") - (~reactive-islands/demo/transition) + (~reactive-islands/index/demo-transition) (~docs/code :code (highlight "(defisland ~reactive-islands/demo/transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp")) (p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations.")) diff --git a/sx/sx/scopes.sx b/sx/sx/scopes.sx new file mode 100644 index 0000000..616276b --- /dev/null +++ b/sx/sx/scopes.sx @@ -0,0 +1,194 @@ +;; --------------------------------------------------------------------------- +;; Scopes — the unified primitive beneath provide, collect!, and spreads +;; --------------------------------------------------------------------------- + + +;; ---- Demo components ---- + +(defcomp ~geography/demo-scope-basic () + (div :class "space-y-2" + (scope "demo-theme" :value "violet" + (div :class "rounded-lg p-3 bg-violet-50 border border-violet-200" + (p :class "text-sm text-violet-800 font-semibold" + (str "Inside scope: theme = " (context "demo-theme"))) + (p :class "text-xs text-stone-500" "scope creates a named scope. context reads it."))) + (div :class "rounded-lg p-3 bg-stone-50 border border-stone-200" + (p :class "text-sm text-stone-600" "Outside scope: no context available.")))) + +(defcomp ~geography/demo-scope-emit () + (div :class "space-y-2" + (scope "demo-deps" + (div :class "rounded-lg p-3 bg-stone-50 border border-stone-200" + (p :class "text-sm text-stone-700" + (emit! "demo-deps" "lodash") + (emit! "demo-deps" "react") + "Components emit their dependencies upward.")) + (div :class "rounded-lg p-3 bg-violet-50 border border-violet-200" + (p :class "text-sm text-violet-800 font-semibold" "Emitted:") + (ul :class "text-xs text-stone-600 list-disc pl-5" + (map (fn (d) (li (code d))) (emitted "demo-deps"))))))) + +(defcomp ~geography/demo-scope-dedup () + (div :class "space-y-2" + (div :class "rounded-lg p-3 bg-stone-50 border border-stone-200" + (p :class "text-sm text-stone-700" + (collect! "demo-css-dedup" ".card { padding: 1rem }") + (collect! "demo-css-dedup" ".card { padding: 1rem }") + (collect! "demo-css-dedup" ".btn { color: blue }") + "Three collect! calls, two identical. Only unique values kept.")) + (div :class "rounded-lg p-3 bg-violet-50 border border-violet-200" + (p :class "text-sm text-violet-800 font-semibold" + (str "Collected: " (len (collected "demo-css-dedup")) " rules")) + (ul :class "text-xs text-stone-600 list-disc pl-5" + (map (fn (r) (li (code r))) (collected "demo-css-dedup")))))) + + +;; ---- Layout helper ---- + +(defcomp ~geography/scopes-demo-example (&key demo code) + (div :class "grid grid-cols-1 lg:grid-cols-2 gap-4 my-6 items-start" + (div :class "border border-dashed border-stone-300 rounded-lg p-4 bg-stone-50 min-h-[80px]" + demo) + (div :class "not-prose bg-stone-100 rounded-lg p-4 overflow-x-auto" + (pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code))))) + + +;; ---- Page content ---- + +(defcomp ~geography/scopes-content () + (~docs/page :title "Scopes" + + (p :class "text-stone-500 text-sm italic mb-8" + "The unified primitive. " (code "scope") " creates a named scope with an optional value " + "and an accumulator. " (code "provide") ", " (code "collect!") ", spreads, islands — " + "they all resolve to scope operations at the platform level.") + + ;; ===================================================================== + ;; I. The primitive + ;; ===================================================================== + + (~docs/section :title "The primitive" :id "primitive" + + (p (code "scope") " is a special form that pushes a named scope, evaluates its body, " + "then pops it. The scope has three properties: a name, a downward value, and an " + "upward accumulator.") + + (~docs/code :code (highlight "(scope name body...) ;; scope with no value\n(scope name :value v body...) ;; scope with downward value" "lisp")) + + (p "Within the body, " (code "context") " reads the value, " (code "emit!") " appends " + "to the accumulator, and " (code "emitted") " reads what was accumulated.") + + (~geography/scopes-demo-example + :demo (~geography/demo-scope-basic) + :code (highlight "(scope \"theme\" :value \"violet\"\n (context \"theme\")) ;; → \"violet\"\n\n;; Nested scopes shadow:\n(scope \"x\" :value \"outer\"\n (scope \"x\" :value \"inner\"\n (context \"x\")) ;; → \"inner\"\n (context \"x\")) ;; → \"outer\"" "lisp"))) + + ;; ===================================================================== + ;; II. Sugar forms + ;; ===================================================================== + + (~docs/section :title "Sugar forms" :id "sugar" + + (p "Nobody writes " (code "scope") " directly. The sugar forms are the API:") + + (~docs/table + :headers (list "Sugar" "Expands to" "Used for") + :rows (list + (list "provide" "(scope name :value v body...)" "Downward context passing") + (list "collect!" "Lazy root scope + dedup emit" "CSS rule accumulation") + (list "Spreads" "(scope \"element-attrs\" ...)" "Child-to-parent attrs (implicit)"))) + + (~docs/subsection :title "provide — scope with a value" + (p (code "(provide name value body...)") " is exactly " + (code "(scope name :value value body...)") ". It exists because " + "the two-arg form is the common case.") + (~docs/code :code (highlight ";; These are equivalent:\n(provide \"theme\" {:primary \"violet\"}\n (h1 \"hello\"))\n\n(scope \"theme\" :value {:primary \"violet\"}\n (h1 \"hello\"))" "lisp"))) + + (~docs/subsection :title "collect! — lazy root scope with dedup" + (p (code "collect!") " is the most interesting sugar. When called, if no scope exists " + "for that name, it lazily creates a root scope with deduplication enabled. " + "Then it emits into it.") + (~geography/scopes-demo-example + :demo (~geography/demo-scope-dedup) + :code (highlight ";; collect! creates a lazy root scope:\n(collect! \"css\" \".card { pad: 1rem }\")\n(collect! \"css\" \".card { pad: 1rem }\") ;; deduped!\n(collect! \"css\" \".btn { color: blue }\")\n(collected \"css\") ;; → 2 rules\n\n;; Equivalent to:\n(scope \"css\" ;; with dedup\n (emit! \"css\" ...)\n (emitted \"css\"))" "lisp")) + (p (code "collected") " is an alias for " (code "emitted") ". " + (code "clear-collected!") " clears the accumulator.")) + + (~docs/subsection :title "Spreads — implicit element scope" + (p "Every element rendering function wraps its children in " + (code "(scope-push! \"element-attrs\" nil)") ". Spread children " + (code "emit!") " their attrs into this scope. After rendering, the element " + "merges the emitted attrs.") + (p "See the " + (a :href "/sx/(geography.(spreads))" :class "text-violet-600 hover:underline" "spreads article") + " for the full mechanism."))) + + ;; ===================================================================== + ;; III. Accumulator: upward data flow + ;; ===================================================================== + + (~docs/section :title "Upward data flow" :id "upward" + + (~geography/scopes-demo-example + :demo (~geography/demo-scope-emit) + :code (highlight "(scope \"deps\"\n (emit! \"deps\" \"lodash\")\n (emit! \"deps\" \"react\")\n (emitted \"deps\")) ;; → (\"lodash\" \"react\")" "lisp")) + + (p "Accumulation always goes to the " (em "nearest") " enclosing scope with that name. " + "This is what makes nested elements work — a spread inside a nested " + (code "span") " emits to the " (code "span") "'s scope, not an outer " + (code "div") "'s scope.")) + + ;; ===================================================================== + ;; IV. Platform implementation + ;; ===================================================================== + + (~docs/section :title "Platform implementation" :id "platform" + + (p "Each platform (Python, JavaScript) maintains a single data structure:") + + (~docs/code :code (highlight "_scope_stacks = {} ;; {name: [{value, emitted: [], dedup: bool}]}" "python")) + + (p "Six operations on this structure:") + + (~docs/table + :headers (list "Operation" "Purpose") + :rows (list + (list "scope-push!(name, value)" "Push {value, emitted: [], dedup: false}") + (list "scope-pop!(name)" "Pop the most recent scope") + (list "context(name, default?)" "Read value from nearest scope") + (list "emit!(name, value)" "Append to nearest scope's accumulator (respects dedup)") + (list "emitted(name)" "Read accumulated values from nearest scope") + (list "collect!(name, value)" "Lazy push root scope with dedup, then emit"))) + + (p (code "provide-push!") " and " (code "provide-pop!") " are aliases for " + (code "scope-push!") " and " (code "scope-pop!") ". " + "All adapter code uses " (code "scope-push!") "/" (code "scope-pop!") " directly.")) + + ;; ===================================================================== + ;; V. Unification + ;; ===================================================================== + + (~docs/section :title "What scope unifies" :id "unification" + + (p "Before scopes, the platform had two separate mechanisms:") + + (~docs/code :code (highlight ";; Before: two mechanisms\n_provide_stacks = {} ;; {name: [{value, emitted: []}]}\n_collect_buckets = {} ;; {name: [values...]}\n\n;; After: one mechanism\n_scope_stacks = {} ;; {name: [{value, emitted: [], dedup: bool}]}" "python")) + + (p "The unification is not just code cleanup. It means:") + (ul :class "space-y-1" + (li (code "collect!") " can be nested inside " (code "provide") " scopes — " + "they share the same stack.") + (li "A component can " (code "emit!") " and " (code "collect!") " into the same scope — " + "they use the same accumulator.") + (li "The dedup flag is per-scope, not per-mechanism — a " (code "provide") " scope " + "has no dedup, a " (code "collect!") " root scope has dedup.")) + + (p "See the " + (a :href "/sx/(etc.(plan.scoped-effects))" :class "text-violet-600 hover:underline" "scoped effects plan") + " for the full design rationale and future phases (reactive scopes, morph scopes).") + + (~docs/note + (p (strong "Spec: ") "The " (code "scope") " special form is in " + (a :href "/sx/(language.(spec.(explore.evaluator)))" :class "font-mono text-violet-600 hover:underline text-sm" "eval.sx") + ". Platform primitives are declared in " + (a :href "/sx/(language.(spec.(explore.boundary)))" :class "font-mono text-violet-600 hover:underline text-sm" "boundary.sx") + " (Tier 5: Scoped effects)."))))) diff --git a/sx/sx/spreads.sx b/sx/sx/spreads.sx index 4d36744..da83cb2 100644 --- a/sx/sx/spreads.sx +++ b/sx/sx/spreads.sx @@ -89,8 +89,9 @@ (p :class "text-stone-500 text-sm italic mb-8" "A spread is a value that, when returned as a child of an element, " "injects attributes onto its parent instead of rendering as content. " - "Internally, spreads work through " (code "provide") "/" (code "emit!") " — " - "every element creates a provider scope, and spread children emit into it.") + "Internally, spreads work through " + (a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes") + " — every element creates a scope, and spread children emit into it.") ;; ===================================================================== ;; I. How it works @@ -125,9 +126,9 @@ ;; ===================================================================== (~docs/section :title "collect! — the other upward channel" :id "collect" - (p "Spreads use " (code "provide") "/" (code "emit!") " (scoped, no dedup). " - (code "collect!") "/" (code "collected") " is a separate upward channel — " - "global, with automatic deduplication. Used for CSS rule accumulation.") + (p "Spreads use " (code "scope") "/" (code "emit!") " (scoped, no dedup). " + (code "collect!") "/" (code "collected") " is also backed by scopes — " + "a lazy root scope with automatic deduplication. Used for CSS rule accumulation.") (~docs/code :code (highlight ";; Deep inside a component tree:\n(collect! \"cssx\" \".sx-bg-red-500{background:red}\")\n\n;; At the flush point (once, in the layout):\n(let ((rules (collected \"cssx\")))\n (clear-collected! \"cssx\")\n (raw! (str \"\")))" "lisp")) (p "Both are upward communication through the render tree, but with different " "semantics — " (code "emit!") " is scoped to the nearest provider, " diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 32c8511..92e1d11 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -617,6 +617,12 @@ ;; Provide / Emit! section (under Geography) ;; --------------------------------------------------------------------------- +(defpage scopes-index + :path "/geography/scopes/" + :auth :public + :layout :sx-docs + :content (~layouts/doc :path "/sx/(geography.(scopes))" (~geography/scopes-content))) + (defpage provide-index :path "/geography/provide/" :auth :public