Unify scoped effects: scope as general primitive, provide as sugar
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m54s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:30:34 +00:00
parent 6ca46bb295
commit 11fdd1a840
23 changed files with 869 additions and 285 deletions

View File

@@ -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("</") + String(tag) + 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("</") + String(lakeTag) + 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("</") + String(marshTag) + 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,

View File

@@ -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

View File

@@ -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

View File

@@ -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
"</" tag ">")))))))
@@ -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
"</" lake-tag ">"))))))
@@ -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
"</" marsh-tag ">"))))))

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -162,6 +162,7 @@
(= name "reset") (sf-reset args env)
(= name "shift") (sf-shift args env)
(= name "dynamic-wind") (sf-dynamic-wind args env)
(= name "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)))

View File

@@ -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"

View File

@@ -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,')

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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, '</', tag, '>')
# 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, '</', _cells['lake_tag'], '>')
# 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, '</', _cells['marsh_tag'], '>')
# 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), '</', tag, '>')
@@ -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))

View File

@@ -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\"))"))))

View File

@@ -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})}

View File

@@ -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)))

View File

@@ -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.")))))

View File

@@ -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 "

View File

@@ -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."))

194
sx/sx/scopes.sx Normal file
View File

@@ -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).")))))

View File

@@ -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 \"<style>\" (join \"\" rules) \"</style>\")))" "lisp"))
(p "Both are upward communication through the render tree, but with different "
"semantics — " (code "emit!") " is scoped to the nearest provider, "

View File

@@ -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