diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 3ef016b..f3d7cae 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-10T15:52:32Z"; + var SX_VERSION = "2026-03-10T20:59:39Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1245,10 +1245,10 @@ continue; } else { return NIL; } } }; return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { var name = symbolName(head); var args = rest(expr); - return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { var val = envGet(env, name); return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name))))); -})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))))); +})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))); })()); })()); }; @@ -1331,6 +1331,23 @@ continue; } else { return NIL; } } }; return (String("<") + String(lakeTag) + String(" data-sx-lake=\"") + String(escapeAttr(sxOr(lakeId, ""))) + String("\">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String("")); })(); }; + // render-html-marsh + var renderHtmlMarsh = function(args, env) { return (function() { + var marshId = NIL; + var marshTag = "div"; + var children = []; + 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() { + var kname = keywordName(arg); + var kval = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + (isSxTruthy((kname == "id")) ? (marshId = kval) : (isSxTruthy((kname == "tag")) ? (marshTag = kval) : (isSxTruthy((kname == "transform")) ? NIL : NIL))); + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (String("<") + String(marshTag) + String(" data-sx-marsh=\"") + String(escapeAttr(sxOr(marshId, ""))) + String("\">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String("")); +})(); }; + // render-html-island var renderHtmlIsland = function(island, args, env) { return (function() { var kwargs = {}; @@ -1382,11 +1399,11 @@ continue; } else { return NIL; } } }; var args = rest(expr); return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() { var name = symbolName(head); - return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? aserCall(name, args, env) : (isSxTruthy((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() { + return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? aserCall(name, args, env) : (isSxTruthy((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy((name == "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() { var f = trampoline(evalExpr(head, env)); var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && isSxTruthy(!isSxTruthy(isComponent(f))) && !isSxTruthy(isIsland(f)))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : (isSxTruthy(isIsland(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f)))))))); -})())))))); +})()))))))); })()); })(); }; @@ -1511,7 +1528,7 @@ return result; }, args); var MATH_NS = "http://www.w3.org/1998/Math/MathML"; // render-to-dom - var renderToDom = function(expr, env, ns) { return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return createTextNode((String(expr))); })(); }; + var renderToDom = function(expr, env, ns) { return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return (isSxTruthy(isSignal(expr)) ? (isSxTruthy(_islandScope) ? reactiveText(expr) : createTextNode((String(deref(expr))))) : createTextNode((String(expr)))); })(); }; // render-dom-list var renderDomList = function(expr, env, ns) { return (function() { @@ -1519,13 +1536,13 @@ return result; }, args); return (isSxTruthy((typeOf(head) == "symbol")) ? (function() { var name = symbolName(head); var args = rest(expr); - return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy((name == "lake")) ? renderDomLake(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderDomIsland(envGet(env, name), args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { + return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy((name == "lake")) ? renderDomLake(args, env, ns) : (isSxTruthy((name == "marsh")) ? renderDomMarsh(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderDomIsland(envGet(env, name), args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { var comp = envGet(env, name); return (isSxTruthy(isComponent(comp)) ? renderDomComponent(comp, args, env, ns) : renderDomUnknownComponent(name)); })() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy((name == "deref")) && _islandScope)) ? (function() { var sigOrVal = trampoline(evalExpr(first(args), env)); return (isSxTruthy(isSignal(sigOrVal)) ? reactiveText(sigOrVal) : createTextNode((String(deref(sigOrVal))))); -})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))); +})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))); })() : (isSxTruthy(sxOr(isLambda(head), (typeOf(head) == "list"))) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (function() { var frag = createFragment(); { var _c = expr; for (var _i = 0; _i < _c.length; _i++) { var x = _c[_i]; domAppend(frag, renderToDom(x, env, ns)); } } @@ -1705,7 +1722,7 @@ return result; }, args); return frag; })() : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() { var collExpr = nth(expr, 2); - return (isSxTruthy((isSxTruthy(_islandScope) && isSxTruthy((typeOf(collExpr) == "list")) && isSxTruthy((len(collExpr) > 1)) && (first(collExpr) == "deref"))) ? (function() { + return (isSxTruthy((isSxTruthy(_islandScope) && isSxTruthy((typeOf(collExpr) == "list")) && isSxTruthy((len(collExpr) > 1)) && isSxTruthy((typeOf(first(collExpr)) == "symbol")) && (symbolName(first(collExpr)) == "deref"))) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var sig = trampoline(evalExpr(nth(collExpr, 1), env)); return (isSxTruthy(isSignal(sig)) ? reactiveList(f, sig, env, ns) : (function() { @@ -1812,6 +1829,33 @@ return result; }, args); { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; domAppend(el, renderToDom(c, env, ns)); } } return el; })(); +})(); }; + + // render-dom-marsh + var renderDomMarsh = function(args, env, ns) { return (function() { + var marshId = NIL; + var marshTag = "div"; + var marshTransform = NIL; + var children = []; + 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() { + var kname = keywordName(arg); + var kval = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env)); + (isSxTruthy((kname == "id")) ? (marshId = kval) : (isSxTruthy((kname == "tag")) ? (marshTag = kval) : (isSxTruthy((kname == "transform")) ? (marshTransform = kval) : NIL))); + return assoc(state, "skip", true, "i", (get(state, "i") + 1)); +})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1))))); +})(); }, {["i"]: 0, ["skip"]: false}, args); + return (function() { + var el = domCreateElement(marshTag, NIL); + domSetAttr(el, "data-sx-marsh", sxOr(marshId, "")); + if (isSxTruthy(marshTransform)) { + domSetData(el, "sx-marsh-transform", marshTransform); +} + domSetData(el, "sx-marsh-env", env); + { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; domAppend(el, renderToDom(c, env, ns)); } } + return el; +})(); })(); }; // reactive-text @@ -1828,8 +1872,11 @@ return result; }, args); return domSetAttr(el, "data-sx-reactive-attrs", updated); })(); return effect(function() { return (function() { - var val = computeFn(); + var raw = computeFn(); + return (function() { + var val = (isSxTruthy(isSignal(raw)) ? deref(raw) : raw); return (isSxTruthy(sxOr(isNil(val), (val == false))) ? domRemoveAttr(el, attrName) : (isSxTruthy((val == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(val))))); +})(); })(); }); }; // reactive-fragment @@ -2142,20 +2189,74 @@ return (function() { var morphIslandChildren = function(oldIsland, newIsland) { return (function() { var oldLakes = domQueryAll(oldIsland, "[data-sx-lake]"); var newLakes = domQueryAll(newIsland, "[data-sx-lake]"); + var oldMarshes = domQueryAll(oldIsland, "[data-sx-marsh]"); + var newMarshes = domQueryAll(newIsland, "[data-sx-marsh]"); return (function() { var newLakeMap = {}; + var newMarshMap = {}; { var _c = newLakes; for (var _i = 0; _i < _c.length; _i++) { var lake = _c[_i]; (function() { var id = domGetAttr(lake, "data-sx-lake"); return (isSxTruthy(id) ? dictSet(newLakeMap, id, lake) : NIL); })(); } } - return forEach(function(oldLake) { return (function() { + { var _c = newMarshes; for (var _i = 0; _i < _c.length; _i++) { var marsh = _c[_i]; (function() { + var id = domGetAttr(marsh, "data-sx-marsh"); + return (isSxTruthy(id) ? dictSet(newMarshMap, id, marsh) : NIL); +})(); } } + { var _c = oldLakes; for (var _i = 0; _i < _c.length; _i++) { var oldLake = _c[_i]; (function() { var id = domGetAttr(oldLake, "data-sx-lake"); return (function() { var newLake = dictGet(newLakeMap, id); return (isSxTruthy(newLake) ? (syncAttrs(oldLake, newLake), morphChildren(oldLake, newLake)) : NIL); })(); -})(); }, oldLakes); +})(); } } + { var _c = oldMarshes; for (var _i = 0; _i < _c.length; _i++) { var oldMarsh = _c[_i]; (function() { + var id = domGetAttr(oldMarsh, "data-sx-marsh"); + return (function() { + var newMarsh = dictGet(newMarshMap, id); + return (isSxTruthy(newMarsh) ? morphMarsh(oldMarsh, newMarsh, oldIsland) : NIL); })(); +})(); } } + return processSignalUpdates(newIsland); +})(); +})(); }; + + // morph-marsh + var morphMarsh = function(oldMarsh, newMarsh, islandEl) { return (function() { + var transform = domGetData(oldMarsh, "sx-marsh-transform"); + var env = domGetData(oldMarsh, "sx-marsh-env"); + var newHtml = domInnerHtml(newMarsh); + return (isSxTruthy((isSxTruthy(env) && isSxTruthy(newHtml) && !isSxTruthy(isEmpty(newHtml)))) ? (function() { + var parsed = parse(newHtml); + return (function() { + var sxContent = (isSxTruthy(transform) ? invoke(transform, parsed) : parsed); + disposeMarshScope(oldMarsh); + return withMarshScope(oldMarsh, function() { return (function() { + var newDom = renderToDom(sxContent, env, NIL); + domRemoveChildrenAfter(oldMarsh, NIL); + return domAppend(oldMarsh, newDom); +})(); }); +})(); +})() : (syncAttrs(oldMarsh, newMarsh), morphChildren(oldMarsh, newMarsh))); +})(); }; + + // process-signal-updates + var processSignalUpdates = function(root) { return (function() { + var signalEls = domQueryAll(root, "[data-sx-signal]"); + return forEach(function(el) { return (function() { + var spec = domGetAttr(el, "data-sx-signal"); + return (isSxTruthy(spec) ? (function() { + var colonIdx = indexOf_(spec, ":"); + return (isSxTruthy((colonIdx > 0)) ? (function() { + var storeName = slice(spec, 0, colonIdx); + var rawValue = slice(spec, (colonIdx + 1)); + (function() { + var parsed = jsonParse(rawValue); + return reset_b(useStore(storeName), parsed); +})(); + return domRemoveAttr(el, "data-sx-signal"); +})() : NIL); +})() : NIL); +})(); }, signalEls); })(); }; // swap-dom-nodes @@ -2287,7 +2388,7 @@ return (function() { } (function() { var targetEl = resolveTarget(el); - return (isSxTruthy(targetEl) ? abortPreviousTarget(targetEl) : NIL); + return (isSxTruthy((isSxTruthy(targetEl) && !isSxTruthy(isIdentical(el, targetEl)))) ? abortPreviousTarget(targetEl) : NIL); })(); return (function() { var ctrl = newAbortController(); @@ -2320,7 +2421,7 @@ return (function() { domAddClass(el, "sx-request"); domSetAttr(el, "aria-busy", "true"); domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method}); - return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), handleRetry(el, verb, method, finalUrl, extraParams)) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); }); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); }); })(); })(); })(); @@ -2344,9 +2445,10 @@ return (function() { (isSxTruthy(contains(ct, "text/sx")) ? handleSxResponse(el, targetEl, text, swapStyle, useTransition) : handleHtmlResponse(el, targetEl, text, swapStyle, useTransition)); dispatchTriggerEvents(el, get(respHeaders, "trigger-swap")); handleHistory(el, url, respHeaders); - if (isSxTruthy(get(respHeaders, "trigger-settle"))) { - setTimeout_(function() { return dispatchTriggerEvents(el, get(respHeaders, "trigger-settle")); }, 20); + setTimeout_(function() { if (isSxTruthy(get(respHeaders, "trigger-settle"))) { + dispatchTriggerEvents(el, get(respHeaders, "trigger-settle")); } +return processSettleHooks(el); }, 20); return domDispatch(el, "sx:afterSwap", {["target"]: targetEl, ["swap"]: swapStyle}); })()))); })(); }; @@ -2457,6 +2559,15 @@ sxHydrate(root); sxHydrateIslands(root); return processElements(root); }; + // process-settle-hooks + var processSettleHooks = function(el) { return (function() { + var settleExpr = domGetAttr(el, "sx-on-settle"); + return (isSxTruthy((isSxTruthy(settleExpr) && !isSxTruthy(isEmpty(settleExpr)))) ? (function() { + var exprs = sxParse(settleExpr); + return forEach(function(expr) { return evalExpr(expr, envExtend({})); }, exprs); +})() : NIL); +})(); }; + // activate-scripts var activateScripts = function(root) { return (isSxTruthy(root) ? (function() { var scripts = domQueryAll(root, "script"); @@ -3276,6 +3387,19 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // register-in-scope var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; + // with-marsh-scope + var withMarshScope = function(marshEl, bodyFn) { return (function() { + var disposers = []; + withIslandScope(function(d) { return append_b(disposers, d); }, bodyFn); + return domSetData(marshEl, "sx-marsh-disposers", disposers); +})(); }; + + // dispose-marsh-scope + var disposeMarshScope = function(marshEl) { return (function() { + var disposers = domGetData(marshEl, "sx-marsh-disposers"); + return (isSxTruthy(disposers) ? (forEach(function(d) { return invoke(d); }, disposers), domSetData(marshEl, "sx-marsh-disposers", NIL)) : NIL); +})(); }; + // *store-registry* var _storeRegistry = {}; @@ -3526,6 +3650,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function domGetData(el, key) { return (el && el._sxData) ? (el._sxData[key] != null ? el._sxData[key] : nil) : nil; } + function domInnerHtml(el) { + return (el && el.innerHTML != null) ? el.innerHTML : ""; + } function jsonParse(s) { try { return JSON.parse(s); } catch(e) { return {}; } } @@ -4412,11 +4539,17 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { } function stripSxScripts(text) { - // Strip (without data-components). + // Strip (without data-components or data-init). // These contain extra component defs from streaming resolve chunks. + // data-init scripts are preserved for process-sx-scripts to evaluate as side effects. var SxObj = typeof Sx !== "undefined" ? Sx : null; - return text.replace(/]*type="text\/sx"[^>]*>([\s\S]*?)<\/script>/gi, - function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + return text.replace(/]*type="text\/sx"[^>]*>[\s\S]*?<\/script>/gi, + function(match) { + if (/data-init/.test(match)) return match; // preserve data-init scripts + var m = match.match(/]*>([\s\S]*?)<\/script>/i); + if (m && SxObj && SxObj.loadComponents) SxObj.loadComponents(m[1]); + return ""; + }); } function extractResponseCss(text) { @@ -4667,6 +4800,7 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_; if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue; if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml; + if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml; // ========================================================================= // Async IO: Promise-aware rendering for client-side IO primitives diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index 94a203b..beccab3 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -1340,6 +1340,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: if name == "lake": return await _aser_call(name, expr[1:], env, ctx) + # Marsh — serialize (reactive server-morphable slot within island) + if name == "marsh": + return await _aser_call(name, expr[1:], env, ctx) + # HTML tag — serialize (don't render to HTML) if name in HTML_TAGS: return await _aser_call(name, expr[1:], env, ctx) diff --git a/shared/sx/html.py b/shared/sx/html.py index 86a5b2f..2341bd9 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -501,6 +501,41 @@ def _render_lake(args: list, env: dict[str, Any]) -> str: return f'<{lake_tag} data-sx-lake="{_escape_attr(lake_id)}">{body}' +def _render_marsh(args: list, env: dict[str, Any]) -> str: + """Render a reactive server-morphable marsh slot. + + (marsh :id "name" :tag "div" :transform fn children...) + →
children
+ + Marshes are zones where reactivity and hypermedia interpenetrate. + Like lakes but content is parsed as SX on the client and re-evaluated + in the island's signal scope. :transform is consumed but not used + server-side (it's a client-side concern). + """ + marsh_id = "" + marsh_tag = "div" + children: list[Any] = [] + i = 0 + while i < len(args): + arg = args[i] + if isinstance(arg, Keyword) and i + 1 < len(args): + kname = arg.name + kval = _eval(args[i + 1], env) + if kname == "id": + marsh_id = str(kval) if kval is not None and kval is not NIL else "" + elif kname == "tag": + marsh_tag = str(kval) if kval is not None and kval is not NIL else "div" + elif kname == "transform": + pass # Client-side only; skip + i += 2 + else: + children.append(arg) + i += 1 + + body = "".join(_render(c, env) for c in children) + return f'<{marsh_tag} data-sx-marsh="{_escape_attr(marsh_id)}">{body}' + + def _render_list(expr: list, env: dict[str, Any]) -> str: """Render a list expression — could be an HTML element, special form, component call, or data list.""" @@ -530,6 +565,10 @@ def _render_list(expr: list, env: dict[str, Any]) -> str: if name == "lake": return _render_lake(expr[1:], env) + # --- marsh → reactive server-morphable slot within island -------- + if name == "marsh": + return _render_marsh(expr[1:], env) + # --- html: prefix → force tag rendering -------------------------- if name.startswith("html:"): return _render_element(name[5:], expr[1:], env) diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 4b66071..796e02b 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -52,8 +52,13 @@ (create-fragment) (render-dom-list expr env ns)) - ;; Fallback - :else (create-text-node (str expr))))) + ;; Signal → reactive text in island scope, deref outside + :else + (if (signal? expr) + (if *island-scope* + (reactive-text expr) + (create-text-node (str (deref expr)))) + (create-text-node (str expr)))))) ;; -------------------------------------------------------------------------- @@ -81,6 +86,10 @@ (= name "lake") (render-dom-lake args env ns) + ;; marsh — reactive server-morphable slot within an island + (= name "marsh") + (render-dom-marsh args env ns) + ;; html: prefix → force element rendering (starts-with? name "html:") (render-dom-element (slice name 5) args env ns) @@ -490,7 +499,8 @@ (if (and *island-scope* (= (type-of coll-expr) "list") (> (len coll-expr) 1) - (= (first coll-expr) "deref")) + (= (type-of (first coll-expr)) "symbol") + (= (symbol-name (first coll-expr)) "deref")) ;; Reactive path: pass signal to reactive-list (let ((f (trampoline (eval-expr (nth expr 1) env))) (sig (trampoline (eval-expr (nth coll-expr 1) env)))) @@ -698,6 +708,56 @@ el)))) +;; -------------------------------------------------------------------------- +;; render-dom-marsh — reactive server-morphable slot within an island +;; -------------------------------------------------------------------------- +;; +;; (marsh :id "name" :tag "div" :transform fn children...) +;; +;; Like a lake but reactive: during morph, new content is parsed as SX and +;; re-evaluated in the island's signal scope. The :transform function (if +;; present) reshapes server content before evaluation. +;; +;; Renders as
children
. +;; Stores the island env and transform on the element for morph retrieval. + +(define render-dom-marsh + (fn (args env ns) + (let ((marsh-id nil) + (marsh-tag "div") + (marsh-transform nil) + (children (list))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((kname (keyword-name arg)) + (kval (trampoline (eval-expr (nth args (inc (get state "i"))) env)))) + (cond + (= kname "id") (set! marsh-id kval) + (= kname "tag") (set! marsh-tag kval) + (= kname "transform") (set! marsh-transform kval)) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + (let ((el (dom-create-element marsh-tag nil))) + (dom-set-attr el "data-sx-marsh" (or marsh-id "")) + ;; Store transform function and island env for morph retrieval + (when marsh-transform + (dom-set-data el "sx-marsh-transform" marsh-transform)) + (dom-set-data el "sx-marsh-env" env) + (for-each + (fn (c) (dom-append el (render-to-dom c env ns))) + children) + el)))) + + ;; -------------------------------------------------------------------------- ;; Reactive DOM rendering helpers ;; -------------------------------------------------------------------------- @@ -726,14 +786,17 @@ (updated (if (empty? existing) attr-name (str existing "," attr-name)))) (dom-set-attr el "data-sx-reactive-attrs" updated)) (effect (fn () - (let ((val (compute-fn))) - (cond - (or (nil? val) (= val false)) - (dom-remove-attr el attr-name) - (= val true) - (dom-set-attr el attr-name "") - :else - (dom-set-attr el attr-name (str val)))))))) + (let ((raw (compute-fn))) + ;; If compute-fn returned a signal (e.g. from computed), deref it + ;; to get the actual value and track the dependency + (let ((val (if (signal? raw) (deref raw) raw))) + (cond + (or (nil? val) (= val false)) + (dom-remove-attr el attr-name) + (= val true) + (dom-set-attr el attr-name "") + :else + (dom-set-attr el attr-name (str val))))))))) ;; reactive-fragment — conditionally render a fragment based on a signal ;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island. diff --git a/shared/sx/ref/adapter-html.sx b/shared/sx/ref/adapter-html.sx index 546e15d..9627faa 100644 --- a/shared/sx/ref/adapter-html.sx +++ b/shared/sx/ref/adapter-html.sx @@ -85,6 +85,10 @@ (= name "lake") (render-html-lake args env) + ;; Marsh — reactive server-morphable slot within an island + (= name "marsh") + (render-html-marsh args env) + ;; HTML tag (contains? HTML_TAGS name) (render-html-element name args env) @@ -334,6 +338,46 @@ "")))) +;; -------------------------------------------------------------------------- +;; render-html-marsh — SSR rendering of a reactive server-morphable slot +;; -------------------------------------------------------------------------- +;; +;; (marsh :id "name" :tag "div" :transform fn children...) +;; →
children
+;; +;; Like a lake but reactive: during morph, new content is parsed as SX and +;; re-evaluated in the island's signal scope. Server renders children normally; +;; the :transform is a client-only concern. + +(define render-html-marsh + (fn (args env) + (let ((marsh-id nil) + (marsh-tag "div") + (children (list))) + (reduce + (fn (state arg) + (let ((skip (get state "skip"))) + (if skip + (assoc state "skip" false "i" (inc (get state "i"))) + (if (and (= (type-of arg) "keyword") + (< (inc (get state "i")) (len args))) + (let ((kname (keyword-name arg)) + (kval (trampoline (eval-expr (nth args (inc (get state "i"))) env)))) + (cond + (= kname "id") (set! marsh-id kval) + (= kname "tag") (set! marsh-tag kval) + (= kname "transform") nil) + (assoc state "skip" true "i" (inc (get state "i")))) + (do + (append! children arg) + (assoc state "i" (inc (get state "i")))))))) + (dict "i" 0 "skip" false) + args) + (str "<" marsh-tag " data-sx-marsh=\"" (escape-attr (or marsh-id "")) "\">" + (join "" (map (fn (c) (render-to-html c env)) children)) + "")))) + + ;; -------------------------------------------------------------------------- ;; render-html-island — SSR rendering of a reactive island ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/adapter-sx.sx b/shared/sx/ref/adapter-sx.sx index 8155302..4d0d3fd 100644 --- a/shared/sx/ref/adapter-sx.sx +++ b/shared/sx/ref/adapter-sx.sx @@ -70,6 +70,10 @@ (= name "lake") (aser-call name args env) + ;; Marsh — serialize (reactive server-morphable slot) + (= name "marsh") + (aser-call name args env) + ;; HTML tag — serialize (contains? HTML_TAGS name) (aser-call name args env) diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index e2aa348..66b1942 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -339,6 +339,7 @@ class JSEmitter: "dom-remove-children-after": "domRemoveChildrenAfter", "dom-set-data": "domSetData", "dom-get-data": "domGetData", + "dom-inner-html": "domInnerHtml", "json-parse": "jsonParse", "dict-has?": "dictHas", "has-key?": "dictHas", @@ -2966,6 +2967,9 @@ PLATFORM_DOM_JS = """ function domGetData(el, key) { return (el && el._sxData) ? (el._sxData[key] != null ? el._sxData[key] : nil) : nil; } + function domInnerHtml(el) { + return (el && el.innerHTML != null) ? el.innerHTML : ""; + } function jsonParse(s) { try { return JSON.parse(s); } catch(e) { return {}; } } @@ -3854,11 +3858,17 @@ PLATFORM_ORCHESTRATION_JS = """ } function stripSxScripts(text) { - // Strip (without data-components). + // Strip (without data-components or data-init). // These contain extra component defs from streaming resolve chunks. + // data-init scripts are preserved for process-sx-scripts to evaluate as side effects. var SxObj = typeof Sx !== "undefined" ? Sx : null; - return text.replace(/]*type="text\\/sx"[^>]*>([\\s\\S]*?)<\\/script>/gi, - function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + return text.replace(/]*type="text\\/sx"[^>]*>[\\s\\S]*?<\\/script>/gi, + function(match) { + if (/data-init/.test(match)) return match; // preserve data-init scripts + var m = match.match(/]*>([\\s\\S]*?)<\\/script>/i); + if (m && SxObj && SxObj.loadComponents) SxObj.loadComponents(m[1]); + return ""; + }); } function extractResponseCss(text) { @@ -4115,7 +4125,8 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False): if (typeof domMatches === "function") PRIMITIVES["dom-matches?"] = domMatches; if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_; if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue; - if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml;''') + if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml; + if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml;''') return "\n".join(lines) diff --git a/shared/sx/ref/engine.sx b/shared/sx/ref/engine.sx index 8da2e57..e626d56 100644 --- a/shared/sx/ref/engine.sx +++ b/shared/sx/ref/engine.sx @@ -474,16 +474,24 @@ (define morph-island-children (fn (old-island new-island) - ;; Find all lake slots in both old and new islands + ;; Find all lake and marsh slots in both old and new islands (let ((old-lakes (dom-query-all old-island "[data-sx-lake]")) - (new-lakes (dom-query-all new-island "[data-sx-lake]"))) - ;; Build ID→element map for new lakes - (let ((new-lake-map (dict))) + (new-lakes (dom-query-all new-island "[data-sx-lake]")) + (old-marshes (dom-query-all old-island "[data-sx-marsh]")) + (new-marshes (dom-query-all new-island "[data-sx-marsh]"))) + ;; Build ID→element maps for new lakes and marshes + (let ((new-lake-map (dict)) + (new-marsh-map (dict))) (for-each (fn (lake) (let ((id (dom-get-attr lake "data-sx-lake"))) (when id (dict-set! new-lake-map id lake)))) new-lakes) + (for-each + (fn (marsh) + (let ((id (dom-get-attr marsh "data-sx-marsh"))) + (when id (dict-set! new-marsh-map id marsh)))) + new-marshes) ;; Morph each old lake from its new counterpart (for-each (fn (old-lake) @@ -492,7 +500,76 @@ (when new-lake (sync-attrs old-lake new-lake) (morph-children old-lake new-lake))))) - old-lakes))))) + old-lakes) + ;; Morph each old marsh from its new counterpart + (for-each + (fn (old-marsh) + (let ((id (dom-get-attr old-marsh "data-sx-marsh"))) + (let ((new-marsh (dict-get new-marsh-map id))) + (when new-marsh + (morph-marsh old-marsh new-marsh old-island))))) + old-marshes) + ;; Process data-sx-signal attributes — server writes to named stores + (process-signal-updates new-island))))) + + +;; -------------------------------------------------------------------------- +;; morph-marsh — re-evaluate server content in island's reactive scope +;; -------------------------------------------------------------------------- +;; +;; Marshes are zones inside islands where server content is re-evaluated by +;; the island's reactive evaluator. During morph, the new content is parsed +;; as SX and rendered in the island's signal context. If the marsh has a +;; :transform function, it reshapes the content before evaluation. + +(define morph-marsh + (fn (old-marsh new-marsh island-el) + (let ((transform (dom-get-data old-marsh "sx-marsh-transform")) + (env (dom-get-data old-marsh "sx-marsh-env")) + (new-html (dom-inner-html new-marsh))) + (if (and env new-html (not (empty? new-html))) + ;; Parse new content as SX and re-evaluate in island scope + (let ((parsed (parse new-html))) + (let ((sx-content (if transform (invoke transform parsed) parsed))) + ;; Dispose old reactive bindings in this marsh + (dispose-marsh-scope old-marsh) + ;; Evaluate the SX in a new marsh scope — creates new reactive bindings + (with-marsh-scope old-marsh + (fn () + (let ((new-dom (render-to-dom sx-content env nil))) + ;; Replace marsh children + (dom-remove-children-after old-marsh nil) + (dom-append old-marsh new-dom)))))) + ;; Fallback: morph like a lake + (do + (sync-attrs old-marsh new-marsh) + (morph-children old-marsh new-marsh)))))) + + +;; -------------------------------------------------------------------------- +;; process-signal-updates — server responses write to named store signals +;; -------------------------------------------------------------------------- +;; +;; Elements with data-sx-signal="name:value" trigger signal writes. +;; After processing, the attribute is removed (consumed). +;; +;; Values are JSON-parsed: "7" → 7, "\"hello\"" → "hello", "true" → true. + +(define process-signal-updates + (fn (root) + (let ((signal-els (dom-query-all root "[data-sx-signal]"))) + (for-each + (fn (el) + (let ((spec (dom-get-attr el "data-sx-signal"))) + (when spec + (let ((colon-idx (index-of spec ":"))) + (when (> colon-idx 0) + (let ((store-name (slice spec 0 colon-idx)) + (raw-value (slice spec (+ colon-idx 1)))) + (let ((parsed (json-parse raw-value))) + (reset! (use-store store-name) parsed)) + (dom-remove-attr el "data-sx-signal"))))))) + signal-els)))) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index 6f14f6e..cb1bc60 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -113,10 +113,12 @@ (when (= sync "replace") (abort-previous el)) - ;; Abort any in-flight request targeting the same swap target. - ;; This ensures rapid navigation (click A then B) cancels A's fetch. + ;; Abort any in-flight request targeting the same swap target, + ;; but only when trigger and target are different elements. + ;; This ensures rapid navigation (click A then B) cancels A's fetch, + ;; while polling (element targets itself) doesn't abort its own requests. (let ((target-el (resolve-target el))) - (when target-el + (when (and target-el (not (identical? el target-el))) (abort-previous-target target-el))) (let ((ctrl (new-abort-controller))) @@ -178,7 +180,12 @@ (do (dom-dispatch el "sx:responseError" (dict "status" status "text" text)) - (handle-retry el verb method final-url extraParams)) + ;; If the error response has SX content, swap it in + ;; (e.g. 404 pages) instead of just retrying + (if (and text (> (len text) 0)) + (handle-fetch-success el final-url verb extraParams + get-header text) + (handle-retry el verb method final-url extraParams))) (do (dom-dispatch el "sx:afterRequest" (dict "status" status)) @@ -246,12 +253,16 @@ ;; History (handle-history el url resp-headers) - ;; Settle triggers (after small delay) - (when (get resp-headers "trigger-settle") - (set-timeout - (fn () (dispatch-trigger-events el - (get resp-headers "trigger-settle"))) - 20)) + ;; Settle phase (after small delay): triggers + sx-on-settle hooks + (set-timeout + (fn () + ;; Server-driven settle triggers + (when (get resp-headers "trigger-settle") + (dispatch-trigger-events el + (get resp-headers "trigger-settle"))) + ;; sx-on-settle: evaluate SX expression after swap settles + (process-settle-hooks el)) + 20) ;; Lifecycle event (dom-dispatch el "sx:afterSwap" @@ -452,6 +463,27 @@ (process-elements root))) +;; -------------------------------------------------------------------------- +;; sx-on-settle — post-swap SX evaluation +;; -------------------------------------------------------------------------- +;; +;; After a swap settles, evaluate the SX expression in the trigger element's +;; sx-on-settle attribute. The expression has access to all primitives +;; (including use-store, reset!, deref) so it can update reactive state +;; based on what the server returned. +;; +;; Example: (button :sx-get "/search" :sx-on-settle "(reset! (use-store \"count\") 0)") + +(define process-settle-hooks + (fn (el) + (let ((settle-expr (dom-get-attr el "sx-on-settle"))) + (when (and settle-expr (not (empty? settle-expr))) + (let ((exprs (sx-parse settle-expr))) + (for-each + (fn (expr) (eval-expr expr (env-extend (dict)))) + exprs)))))) + + (define activate-scripts (fn (root) ;; Re-activate scripts in swapped content. diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 40fb25a..1c37f2a 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -306,7 +306,47 @@ ;; ========================================================================== -;; 12. Named stores — page-level signal containers (L3) +;; 12. Marsh scopes — child scopes within islands +;; ========================================================================== +;; +;; Marshes are zones inside islands where server content is re-evaluated +;; in the island's reactive context. When a marsh is re-morphed with new +;; content, its old effects and computeds must be disposed WITHOUT disturbing +;; the island's own reactive graph. +;; +;; Scope hierarchy: island → marsh → effects/computeds +;; Disposing a marsh disposes its subscope. Disposing an island disposes +;; all its marshes. The signal graph is a tree, not a flat list. +;; +;; Platform interface required: +;; (dom-set-data el key val) → void — store JS value on element +;; (dom-get-data el key) → any — retrieve stored value + +(define with-marsh-scope + (fn (marsh-el body-fn) + ;; Execute body-fn collecting all disposables into a marsh-local list. + ;; Nested under the current island scope — if the island is disposed, + ;; the marsh is disposed too (because island scope collected the marsh's + ;; own dispose function). + (let ((disposers (list))) + (with-island-scope + (fn (d) (append! disposers d)) + body-fn) + ;; Store disposers on the marsh element for later cleanup + (dom-set-data marsh-el "sx-marsh-disposers" disposers)))) + +(define dispose-marsh-scope + (fn (marsh-el) + ;; Dispose all effects/computeds registered in this marsh's scope. + ;; Parent island scope and sibling marshes are unaffected. + (let ((disposers (dom-get-data marsh-el "sx-marsh-disposers"))) + (when disposers + (for-each (fn (d) (invoke d)) disposers) + (dom-set-data marsh-el "sx-marsh-disposers" nil))))) + + +;; ========================================================================== +;; 13. Named stores — page-level signal containers (L3) ;; ========================================================================== ;; ;; Stores persist across island creation/destruction. They live at page diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index d38815e..5c90438 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -1198,7 +1198,7 @@ RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', is_render_html_form = lambda name: contains_p(RENDER_HTML_FORMS, name) # render-list-to-html -render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_lake(args, env) if sx_truthy((name == 'lake')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (render_html_island(env_get(env, name), args, env) if sx_truthy((starts_with_p(name, '~') if not sx_truthy(starts_with_p(name, '~')) else (env_has(env, name) if not sx_truthy(env_has(env, name)) else is_island(env_get(env, name))))) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env))))))))))(rest(expr)))(symbol_name(head))))(first(expr))) +render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_lake(args, env) if sx_truthy((name == 'lake')) else (render_html_marsh(args, env) if sx_truthy((name == 'marsh')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (render_html_island(env_get(env, name), args, env) if sx_truthy((starts_with_p(name, '~') if not sx_truthy(starts_with_p(name, '~')) else (env_has(env, name) if not sx_truthy(env_has(env, name)) else is_island(env_get(env, name))))) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env)))))))))))(rest(expr)))(symbol_name(head))))(first(expr))) # dispatch-html-form dispatch_html_form = lambda name, expr, env: ((lambda cond_val: (render_to_html(nth(expr, 2), env) if sx_truthy(cond_val) else (render_to_html(nth(expr, 3), env) if sx_truthy((len(expr) > 3)) else '')))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'if')) else (('' if sx_truthy((not sx_truthy(trampoline(eval_expr(nth(expr, 1), env))))) else join('', map(lambda i: render_to_html(nth(expr, i), env), range(2, len(expr))))) if sx_truthy((name == 'when')) else ((lambda branch: (render_to_html(branch, env) if sx_truthy(branch) else ''))(eval_cond(rest(expr), env)) if sx_truthy((name == 'cond')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'case')) else ((lambda local: join('', map(lambda i: render_to_html(nth(expr, i), local), range(2, len(expr)))))(process_bindings(nth(expr, 1), env)) if sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))) else (join('', map(lambda i: render_to_html(nth(expr, i), env), range(1, len(expr)))) if sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))) else (_sx_begin(trampoline(eval_expr(expr, env)), '') if sx_truthy(is_definition_form(name)) else ((lambda f: (lambda coll: 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)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map')) else ((lambda f: (lambda coll: join('', map_indexed(lambda i, item: (render_lambda_html(f, [i, item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [i, item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map-indexed')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'filter')) else ((lambda f: (lambda coll: 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)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'for-each')) else render_value_to_html(trampoline(eval_expr(expr, env)), env)))))))))))) @@ -1221,6 +1221,15 @@ def render_html_lake(args, env): reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda kname: (lambda kval: _sx_begin((_sx_cell_set(_cells, 'lake_id', kval) if sx_truthy((kname == 'id')) else (_sx_cell_set(_cells, 'lake_tag', kval) if sx_truthy((kname == 'tag')) else NIL)), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))))(keyword_name(arg)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args) return sx_str('<', _cells['lake_tag'], ' data-sx-lake="', escape_attr((_cells['lake_id'] if sx_truthy(_cells['lake_id']) else '')), '">', join('', map(lambda c: render_to_html(c, env), children)), '') +# render-html-marsh +def render_html_marsh(args, env): + _cells = {} + _cells['marsh_id'] = NIL + _cells['marsh_tag'] = 'div' + children = [] + reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda kname: (lambda kval: _sx_begin((_sx_cell_set(_cells, 'marsh_id', kval) if sx_truthy((kname == 'id')) else (_sx_cell_set(_cells, 'marsh_tag', kval) if sx_truthy((kname == 'tag')) else (NIL if sx_truthy((kname == 'transform')) else NIL))), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))))(keyword_name(arg)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args) + return sx_str('<', _cells['marsh_tag'], ' data-sx-marsh="', escape_attr((_cells['marsh_id'] if sx_truthy(_cells['marsh_id']) else '')), '">', join('', map(lambda c: render_to_html(c, env), children)), '') + # render-html-island render_html_island = lambda island, args, env: (lambda kwargs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(kwargs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) 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), (lambda local: (lambda island_name: _sx_begin(for_each(lambda p: _sx_dict_set(local, p, (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)), component_params(island)), (_sx_dict_set(local, 'children', make_raw_html(join('', map(lambda c: render_to_html(c, env), children)))) if sx_truthy(component_has_children(island)) else NIL), (lambda body_html: (lambda state_json: sx_str('', body_html, ''))(serialize_island_state(kwargs)))(render_to_html(component_body(island), local))))(component_name(island)))(env_merge(component_closure(island), env))))([]))({}) @@ -1237,7 +1246,7 @@ render_to_sx = lambda expr, env: (lambda result: (result if sx_truthy((type_of(r aser = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else aser_list(expr, env))), (None, lambda: expr)]) # aser-list -aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy((name == 'lake')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env))))))))))(symbol_name(head))))(rest(expr)))(first(expr)) +aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy((name == 'lake')) else (aser_call(name, args, env) if sx_truthy((name == 'marsh')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))))(symbol_name(head))))(rest(expr)))(first(expr)) # aser-fragment aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(parts)) else sx_str('(<> ', join(' ', map(serialize, parts)), ')')))(filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda c: aser(c, env), children))) @@ -1410,6 +1419,12 @@ def with_island_scope(scope_fn, body_fn): # register-in-scope register_in_scope = lambda disposable: (_island_scope(disposable) if sx_truthy(_island_scope) else NIL) +# with-marsh-scope +with_marsh_scope = lambda marsh_el, body_fn: (lambda disposers: _sx_begin(with_island_scope(lambda d: _sx_append(disposers, d), body_fn), dom_set_data(marsh_el, 'sx-marsh-disposers', disposers)))([]) + +# dispose-marsh-scope +dispose_marsh_scope = lambda marsh_el: (lambda disposers: (_sx_begin(for_each(lambda d: invoke(d), disposers), dom_set_data(marsh_el, 'sx-marsh-disposers', NIL)) if sx_truthy(disposers) else NIL))(dom_get_data(marsh_el, 'sx-marsh-disposers')) + # *store-registry* _store_registry = {} diff --git a/shared/sx/templates/layout.sx b/shared/sx/templates/layout.sx index 2f96ad9..a537b21 100644 --- a/shared/sx/templates/layout.sx +++ b/shared/sx/templates/layout.sx @@ -1,15 +1,16 @@ (defcomp ~app-body (&key header-rows filter aside menu content) (div :class "max-w-screen-2xl mx-auto py-1 px-1" - (div :class "w-full" - (details :class "group/root p-2" :data-toggle-group "mobile-panels" - (summary - (header :class "z-50" - (div :id "root-header-summary" - :class "flex items-start gap-2 p-1 bg-sky-500" - (div :id "root-header-child" :class "flex flex-col w-full items-center" - (when header-rows header-rows))))) - (div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden" - (when menu menu)))) + (when header-rows + (div :class "w-full" + (details :class "group/root p-2" :data-toggle-group "mobile-panels" + (summary + (header :class "z-50" + (div :id "root-header-summary" + :class "flex items-start gap-2 p-1 bg-sky-500" + (div :id "root-header-child" :class "flex flex-col w-full items-center" + header-rows)))) + (div :id "root-menu" :sx-swap-oob "outerHTML" :class "md:hidden" + (when menu menu))))) (div :id "filter" (when filter filter)) (main :id "root-panel" :class "max-w-full" diff --git a/sx/app.py b/sx/app.py index 5bbc1a1..aaee202 100644 --- a/sx/app.py +++ b/sx/app.py @@ -95,6 +95,8 @@ def create_app() -> "Quart": ), } + app.url_map.strict_slashes = False + from sxc.pages import setup_sx_pages setup_sx_pages() @@ -104,6 +106,60 @@ def create_app() -> "Quart": from shared.sx.pages import auto_mount_pages auto_mount_pages(app, "sx") + @app.before_request + async def trailing_slash_redirect(): + from quart import request, redirect + path = request.path + if (path != "/" + and not path.endswith("/") + and request.method == "GET" + and not path.startswith(("/static/", "/internal/", "/auth/")) + and "/api/" not in path + and "." not in path.rsplit("/", 1)[-1]): + qs = request.query_string.decode() + target = path + "/" + ("?" + qs if qs else "") + return redirect(target, 301) + + @app.errorhandler(404) + async def sx_not_found(e): + from quart import request, make_response + from shared.browser.app.utils.htmx import is_htmx_request + from shared.sx.jinja_bridge import get_component_env, _get_request_context + from shared.sx.async_eval import async_eval_slot_to_sx + from shared.sx.types import Symbol, Keyword + from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response + from shared.sx.pages import get_page_helpers + from shared.sx.page import get_template_context + + path = request.path + content_ast = [ + Symbol("~sx-doc"), Keyword("path"), path, + [Symbol("~not-found-content"), Keyword("path"), path], + ] + + env = dict(get_component_env()) + env.update(get_page_helpers("sx")) + ctx = _get_request_context() + + try: + content_sx = await async_eval_slot_to_sx(content_ast, env, ctx) + except Exception: + from shared.browser.app.errors import _sx_error_page + html = _sx_error_page("404", "NOT FOUND", + image="/static/errors/404.gif") + return await make_response(html, 404) + + if is_htmx_request(): + return sx_response( + await oob_page_sx(content=content_sx), + status=404, + ) + else: + tctx = await get_template_context() + html = await full_page_sx(tctx, header_rows="", + content=content_sx) + return await make_response(html, 404) + return app diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index 61ca0f9..cc0b2f9 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -22,7 +22,7 @@ def register(url_prefix: str = "/") -> Blueprint: # Example API endpoints (for live demos) # ------------------------------------------------------------------ - @bp.get("/examples/api/click") + @bp.get("/hypermedia/examples/api/click") async def api_click(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -35,7 +35,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') @csrf_exempt - @bp.post("/examples/api/form") + @bp.post("/hypermedia/examples/api/form") async def api_form(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -51,7 +51,7 @@ def register(url_prefix: str = "/") -> Blueprint: _poll_count = {"n": 0} - @bp.get("/examples/api/poll") + @bp.get("/hypermedia/examples/api/poll") async def api_poll(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -66,7 +66,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') @csrf_exempt - @bp.delete("/examples/api/delete/") + @bp.delete("/hypermedia/examples/api/delete/") async def api_delete(item_id: str): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -78,7 +78,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("delete-comp", comp_text) return sx_response(f'(<> {oob_wire} {oob_comp})') - @bp.get("/examples/api/edit") + @bp.get("/hypermedia/examples/api/edit") async def api_edit_form(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -92,7 +92,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') @csrf_exempt - @bp.post("/examples/api/edit") + @bp.post("/hypermedia/examples/api/edit") async def api_edit_save(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -106,7 +106,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("edit-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/edit/cancel") + @bp.get("/hypermedia/examples/api/edit/cancel") async def api_edit_cancel(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -119,7 +119,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("edit-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/oob") + @bp.get("/hypermedia/examples/api/oob") async def api_oob(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -138,7 +138,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Lazy Loading --- - @bp.get("/examples/api/lazy") + @bp.get("/hypermedia/examples/api/lazy") async def api_lazy(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -152,7 +152,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Infinite Scroll --- - @bp.get("/examples/api/scroll") + @bp.get("/hypermedia/examples/api/scroll") async def api_scroll(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -166,7 +166,7 @@ def register(url_prefix: str = "/") -> Blueprint: if next_page <= 6: sentinel = ( f'(div :id "scroll-sentinel"' - f' :sx-get "/examples/api/scroll?page={next_page}"' + f' :sx-get "/hypermedia/examples/api/scroll?page={next_page}"' f' :sx-trigger "intersect once"' f' :sx-target "#scroll-items"' f' :sx-swap "beforeend"' @@ -188,7 +188,7 @@ def register(url_prefix: str = "/") -> Blueprint: _jobs: dict[str, int] = {} @csrf_exempt - @bp.post("/examples/api/progress/start") + @bp.post("/hypermedia/examples/api/progress/start") async def api_progress_start(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -201,7 +201,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("progress-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/progress/status") + @bp.get("/hypermedia/examples/api/progress/status") async def api_progress_status(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -218,7 +218,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Active Search --- - @bp.get("/examples/api/search") + @bp.get("/hypermedia/examples/api/search") async def api_search(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -241,7 +241,7 @@ def register(url_prefix: str = "/") -> Blueprint: _TAKEN_EMAILS = {"admin@example.com", "test@example.com", "user@example.com"} - @bp.get("/examples/api/validate") + @bp.get("/hypermedia/examples/api/validate") async def api_validate(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -267,7 +267,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') @csrf_exempt - @bp.post("/examples/api/validate/submit") + @bp.post("/hypermedia/examples/api/validate/submit") async def api_validate_submit(): from shared.sx.helpers import sx_response form = await request.form @@ -279,7 +279,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Value Select --- - @bp.get("/examples/api/values") + @bp.get("/hypermedia/examples/api/values") async def api_values(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -297,7 +297,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Reset on Submit --- @csrf_exempt - @bp.post("/examples/api/reset-submit") + @bp.post("/hypermedia/examples/api/reset-submit") async def api_reset_submit(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -323,7 +323,7 @@ def register(url_prefix: str = "/") -> Blueprint: _edit_rows[r["id"]] = dict(r) return _edit_rows - @bp.get("/examples/api/editrow/") + @bp.get("/hypermedia/examples/api/editrow/") async def api_editrow_form(row_id: str): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -338,7 +338,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') @csrf_exempt - @bp.post("/examples/api/editrow/") + @bp.post("/hypermedia/examples/api/editrow/") async def api_editrow_save(row_id: str): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -359,7 +359,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("editrow-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/editrow//cancel") + @bp.get("/hypermedia/examples/api/editrow//cancel") async def api_editrow_cancel(row_id: str): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -385,7 +385,7 @@ def register(url_prefix: str = "/") -> Blueprint: return _bulk_users @csrf_exempt - @bp.post("/examples/api/bulk") + @bp.post("/hypermedia/examples/api/bulk") async def api_bulk(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -415,7 +415,7 @@ def register(url_prefix: str = "/") -> Blueprint: _swap_count = {"n": 0} @csrf_exempt - @bp.post("/examples/api/swap-log") + @bp.post("/hypermedia/examples/api/swap-log") async def api_swap_log(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -435,7 +435,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Select Filter (dashboard) --- - @bp.get("/examples/api/dashboard") + @bp.get("/hypermedia/examples/api/dashboard") async def api_dashboard(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -480,7 +480,7 @@ def register(url_prefix: str = "/") -> Blueprint: ' (li "Wire format v2")))'), } - @bp.get("/examples/api/tabs/") + @bp.get("/hypermedia/examples/api/tabs/") async def api_tabs(tab: str): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -500,7 +500,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Animations --- - @bp.get("/examples/api/animate") + @bp.get("/hypermedia/examples/api/animate") async def api_animate(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -516,7 +516,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Dialogs --- - @bp.get("/examples/api/dialog") + @bp.get("/hypermedia/examples/api/dialog") async def api_dialog(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -527,7 +527,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("dialog-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/dialog/close") + @bp.get("/hypermedia/examples/api/dialog/close") async def api_dialog_close(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _full_wire_text @@ -543,7 +543,7 @@ def register(url_prefix: str = "/") -> Blueprint: "h": "Help panel opened", } - @bp.get("/examples/api/keyboard") + @bp.get("/hypermedia/examples/api/keyboard") async def api_keyboard(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -568,7 +568,7 @@ def register(url_prefix: str = "/") -> Blueprint: _profile.update(PROFILE_DEFAULT) return _profile - @bp.get("/examples/api/putpatch/edit-all") + @bp.get("/hypermedia/examples/api/putpatch/edit-all") async def api_pp_edit_all(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -581,7 +581,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') @csrf_exempt - @bp.put("/examples/api/putpatch") + @bp.put("/hypermedia/examples/api/putpatch") async def api_pp_put(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -597,7 +597,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("pp-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/putpatch/cancel") + @bp.get("/hypermedia/examples/api/putpatch/cancel") async def api_pp_cancel(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -612,7 +612,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- JSON Encoding --- @csrf_exempt - @bp.post("/examples/api/json-echo") + @bp.post("/hypermedia/examples/api/json-echo") async def api_json_echo(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -630,7 +630,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Vals & Headers --- - @bp.get("/examples/api/echo-vals") + @bp.get("/hypermedia/examples/api/echo-vals") async def api_echo_vals(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -644,7 +644,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob_comp = _oob_code("vals-comp", comp_text) return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})') - @bp.get("/examples/api/echo-headers") + @bp.get("/hypermedia/examples/api/echo-headers") async def api_echo_headers(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -659,7 +659,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Loading States --- - @bp.get("/examples/api/slow") + @bp.get("/hypermedia/examples/api/slow") async def api_slow(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -674,7 +674,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Request Abort (sync replace) --- - @bp.get("/examples/api/slow-search") + @bp.get("/hypermedia/examples/api/slow-search") async def api_slow_search(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -694,7 +694,7 @@ def register(url_prefix: str = "/") -> Blueprint: _flaky = {"n": 0} - @bp.get("/examples/api/flaky") + @bp.get("/hypermedia/examples/api/flaky") async def api_flaky(): from shared.sx.helpers import sx_response from sxc.pages.renders import _oob_code, _component_source_text, _full_wire_text @@ -718,7 +718,7 @@ def register(url_prefix: str = "/") -> Blueprint: from sxc.pages.renders import _oob_code return _oob_code(f"ref-wire-{wire_id}", sx_src) - @bp.get("/reference/api/time") + @bp.get("/hypermedia/reference/api/time") async def ref_time(): from shared.sx.helpers import sx_response now = datetime.now().strftime("%H:%M:%S") @@ -727,7 +727,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob})') @csrf_exempt - @bp.post("/reference/api/greet") + @bp.post("/hypermedia/reference/api/greet") async def ref_greet(): from shared.sx.helpers import sx_response form = await request.form @@ -737,7 +737,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob})') @csrf_exempt - @bp.put("/reference/api/status") + @bp.put("/hypermedia/reference/api/status") async def ref_status(): from shared.sx.helpers import sx_response form = await request.form @@ -747,7 +747,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob})') @csrf_exempt - @bp.patch("/reference/api/theme") + @bp.patch("/hypermedia/reference/api/theme") async def ref_theme(): from shared.sx.helpers import sx_response form = await request.form @@ -757,13 +757,13 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob})') @csrf_exempt - @bp.delete("/reference/api/item/") + @bp.delete("/hypermedia/reference/api/item/") async def ref_delete(item_id: str): from shared.sx.helpers import sx_response oob = _ref_wire("sx-delete", '""') return sx_response(f'(<> {oob})') - @bp.get("/reference/api/trigger-search") + @bp.get("/hypermedia/reference/api/trigger-search") async def ref_trigger_search(): from shared.sx.helpers import sx_response q = request.args.get("q", "") @@ -774,7 +774,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-trigger", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/swap-item") + @bp.get("/hypermedia/reference/api/swap-item") async def ref_swap_item(): from shared.sx.helpers import sx_response now = datetime.now().strftime("%H:%M:%S") @@ -782,7 +782,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-swap", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/oob") + @bp.get("/hypermedia/reference/api/oob") async def ref_oob(): from shared.sx.helpers import sx_response now = datetime.now().strftime("%H:%M:%S") @@ -794,7 +794,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-swap-oob", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/select-page") + @bp.get("/hypermedia/reference/api/select-page") async def ref_select_page(): from shared.sx.helpers import sx_response now = datetime.now().strftime("%H:%M:%S") @@ -808,7 +808,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-select", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/slow-echo") + @bp.get("/hypermedia/reference/api/slow-echo") async def ref_slow_echo(): from shared.sx.helpers import sx_response await asyncio.sleep(0.8) @@ -818,7 +818,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob})') @csrf_exempt - @bp.post("/reference/api/upload-name") + @bp.post("/hypermedia/reference/api/upload-name") async def ref_upload_name(): from shared.sx.helpers import sx_response files = await request.files @@ -828,7 +828,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-encoding", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/echo-headers") + @bp.get("/hypermedia/reference/api/echo-headers") async def ref_echo_headers(): from shared.sx.helpers import sx_response custom = [(k, v) for k, v in request.headers if k.lower().startswith("x-")] @@ -841,7 +841,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-headers", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/echo-vals") + @bp.get("/hypermedia/reference/api/echo-vals") async def ref_echo_vals_get(): from shared.sx.helpers import sx_response vals = list(request.args.items()) @@ -855,7 +855,7 @@ def register(url_prefix: str = "/") -> Blueprint: return sx_response(f'(<> {sx_src} {oob_include})') @csrf_exempt - @bp.post("/reference/api/echo-vals") + @bp.post("/hypermedia/reference/api/echo-vals") async def ref_echo_vals_post(): from shared.sx.helpers import sx_response form = await request.form @@ -871,7 +871,7 @@ def register(url_prefix: str = "/") -> Blueprint: _ref_flaky = {"n": 0} - @bp.get("/reference/api/flaky") + @bp.get("/hypermedia/reference/api/flaky") async def ref_flaky(): from shared.sx.helpers import sx_response _ref_flaky["n"] += 1 @@ -882,7 +882,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-retry", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/prompt-echo") + @bp.get("/hypermedia/reference/api/prompt-echo") async def ref_prompt_echo(): from shared.sx.helpers import sx_response name = request.headers.get("SX-Prompt", "anonymous") @@ -890,7 +890,7 @@ def register(url_prefix: str = "/") -> Blueprint: oob = _ref_wire("sx-prompt", sx_src) return sx_response(f'(<> {sx_src} {oob})') - @bp.get("/reference/api/sse-time") + @bp.get("/hypermedia/reference/api/sse-time") async def ref_sse_time(): async def generate(): for _ in range(30): # stream for 60 seconds max @@ -901,9 +901,137 @@ def register(url_prefix: str = "/") -> Blueprint: return Response(generate(), content_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + # --- Marsh demos --- + + _marsh_sale_idx = {"n": 0} + + @bp.get("/reactive/api/flash-sale") + async def api_marsh_flash_sale(): + from shared.sx.helpers import sx_response + prices = [14.99, 9.99, 24.99, 12.49, 7.99, 29.99, 4.99, 16.50] + _marsh_sale_idx["n"] = (_marsh_sale_idx["n"] + 1) % len(prices) + new_price = prices[_marsh_sale_idx["n"]] + now = datetime.now().strftime("%H:%M:%S") + sx_src = ( + f'(<>' + f' (div :class "space-y-2"' + f' (p :class "text-sm text-emerald-600 font-medium"' + f' "\u26A1 Flash sale! Price: ${new_price:.2f}")' + f' (p :class "text-xs text-stone-400" "at {now}"))' + f' (script :type "text/sx" :data-init ""' + f' "(reset! (use-store \\"demo-price\\") {new_price})"))' + ) + return sx_response(sx_src) + + # --- Demo 3: sx-on-settle endpoint --- + + _settle_counter = {"n": 0} + + @bp.get("/reactive/api/settle-data") + async def api_settle_data(): + from shared.sx.helpers import sx_response + _settle_counter["n"] += 1 + items = ["Widget", "Gadget", "Sprocket", "Gizmo", "Doohickey"] + item = items[_settle_counter["n"] % len(items)] + now = datetime.now().strftime("%H:%M:%S") + sx_src = ( + f'(div :class "space-y-1"' + f' (p :class "text-sm font-medium text-stone-700" "Fetched: {item}")' + f' (p :class "text-xs text-stone-400" "at {now}"))' + ) + return sx_response(sx_src) + + # --- Demo 4: signal-bound URL endpoints --- + + @bp.get("/reactive/api/search/products") + async def api_search_products(): + from shared.sx.helpers import sx_response + q = request.args.get("q", "") + items = ["Artisan Widget", "Premium Gadget", "Handcrafted Sprocket", + "Bespoke Gizmo", "Organic Doohickey"] + matches = [i for i in items if q.lower() in i.lower()] if q else items + rows = " ".join( + f'(li :class "text-sm text-stone-600" "{m}")' + for m in matches[:3] + ) + sx_src = ( + f'(div :class "space-y-1"' + f' (p :class "text-xs font-semibold text-violet-600 uppercase" "Products")' + f' (ul :class "list-disc pl-4" {rows})' + f' (p :class "text-xs text-stone-400" "{len(matches)} result(s)"))' + ) + return sx_response(sx_src) + + @bp.get("/reactive/api/search/events") + async def api_search_events(): + from shared.sx.helpers import sx_response + q = request.args.get("q", "") + items = ["Summer Workshop", "Craft Fair", "Open Studio", + "Artist Talk", "Gallery Opening"] + matches = [i for i in items if q.lower() in i.lower()] if q else items + rows = " ".join( + f'(li :class "text-sm text-stone-600" "{m}")' + for m in matches[:3] + ) + sx_src = ( + f'(div :class "space-y-1"' + f' (p :class "text-xs font-semibold text-emerald-600 uppercase" "Events")' + f' (ul :class "list-disc pl-4" {rows})' + f' (p :class "text-xs text-stone-400" "{len(matches)} result(s)"))' + ) + return sx_response(sx_src) + + @bp.get("/reactive/api/search/posts") + async def api_search_posts(): + from shared.sx.helpers import sx_response + q = request.args.get("q", "") + items = ["On Craft and Code", "The SX Manifesto", "Islands and Lakes", + "Reactive Marshes", "Self-Hosting Spec"] + matches = [i for i in items if q.lower() in i.lower()] if q else items + rows = " ".join( + f'(li :class "text-sm text-stone-600" "{m}")' + for m in matches[:3] + ) + sx_src = ( + f'(div :class "space-y-1"' + f' (p :class "text-xs font-semibold text-amber-600 uppercase" "Posts")' + f' (ul :class "list-disc pl-4" {rows})' + f' (p :class "text-xs text-stone-400" "{len(matches)} result(s)"))' + ) + return sx_response(sx_src) + + # --- Demo 5: marsh transform endpoint --- + + @bp.get("/reactive/api/catalog") + async def api_catalog(): + from shared.sx.helpers import sx_response + items = [ + ("Artisan Widget", "19.99", "Hand-crafted with care"), + ("Premium Gadget", "34.50", "Top-of-the-line quality"), + ("Vintage Sprocket", "12.99", "Classic design"), + ("Custom Gizmo", "27.00", "Made to order"), + ] + random.shuffle(items) + now = datetime.now().strftime("%H:%M:%S") + # Build an SX list literal for the data-init script. + # Inner quotes must be escaped since the whole expression lives + # inside an SX string literal (the script tag's text content). + items_sx = "(list " + " ".join( + f'(dict \\"name\\" \\"{n}\\" \\"price\\" \\"{p}\\" \\"desc\\" \\"{d}\\")' + for n, p, d in items + ) + ")" + sx_src = ( + f'(<>' + f' (p :class "text-sm text-emerald-600 font-medium"' + f' "Catalog loaded: {len(items)} items (shuffled at {now})")' + f' (script :type "text/sx" :data-init ""' + f' "(reset! (use-store \\"catalog-items\\") {items_sx})"))' + ) + return sx_response(sx_src) + # --- Header demos --- - @bp.get("/reference/api/trigger-event") + @bp.get("/hypermedia/reference/api/trigger-event") async def ref_trigger_event(): from shared.sx.helpers import sx_response now = datetime.now().strftime("%H:%M:%S") @@ -912,7 +1040,7 @@ def register(url_prefix: str = "/") -> Blueprint: resp.headers["SX-Trigger"] = "showNotice" return resp - @bp.get("/reference/api/retarget") + @bp.get("/hypermedia/reference/api/retarget") async def ref_retarget(): from shared.sx.helpers import sx_response now = datetime.now().strftime("%H:%M:%S") @@ -923,7 +1051,7 @@ def register(url_prefix: str = "/") -> Blueprint: # --- Event demos --- - @bp.get("/reference/api/error-500") + @bp.get("/hypermedia/reference/api/error-500") async def ref_error_500(): return Response("Server error", status=500, content_type="text/plain") diff --git a/sx/content/pages.py b/sx/content/pages.py index a4ea85b..0230b5a 100644 --- a/sx/content/pages.py +++ b/sx/content/pages.py @@ -20,10 +20,10 @@ DOCS_NAV = [ ] REFERENCE_NAV = [ - ("Attributes", "/reference/attributes"), - ("Headers", "/reference/headers"), - ("Events", "/reference/events"), - ("JS API", "/reference/js-api"), + ("Attributes", "/hypermedia/reference/attributes"), + ("Headers", "/hypermedia/reference/headers"), + ("Events", "/hypermedia/reference/events"), + ("JS API", "/hypermedia/reference/js-api"), ] PROTOCOLS_NAV = [ @@ -36,33 +36,33 @@ PROTOCOLS_NAV = [ ] EXAMPLES_NAV = [ - ("Click to Load", "/examples/click-to-load"), - ("Form Submission", "/examples/form-submission"), - ("Polling", "/examples/polling"), - ("Delete Row", "/examples/delete-row"), - ("Inline Edit", "/examples/inline-edit"), - ("OOB Swaps", "/examples/oob-swaps"), - ("Lazy Loading", "/examples/lazy-loading"), - ("Infinite Scroll", "/examples/infinite-scroll"), - ("Progress Bar", "/examples/progress-bar"), - ("Active Search", "/examples/active-search"), - ("Inline Validation", "/examples/inline-validation"), - ("Value Select", "/examples/value-select"), - ("Reset on Submit", "/examples/reset-on-submit"), - ("Edit Row", "/examples/edit-row"), - ("Bulk Update", "/examples/bulk-update"), - ("Swap Positions", "/examples/swap-positions"), - ("Select Filter", "/examples/select-filter"), - ("Tabs", "/examples/tabs"), - ("Animations", "/examples/animations"), - ("Dialogs", "/examples/dialogs"), - ("Keyboard Shortcuts", "/examples/keyboard-shortcuts"), - ("PUT / PATCH", "/examples/put-patch"), - ("JSON Encoding", "/examples/json-encoding"), - ("Vals & Headers", "/examples/vals-and-headers"), - ("Loading States", "/examples/loading-states"), - ("Request Abort", "/examples/sync-replace"), - ("Retry", "/examples/retry"), + ("Click to Load", "/hypermedia/examples/click-to-load"), + ("Form Submission", "/hypermedia/examples/form-submission"), + ("Polling", "/hypermedia/examples/polling"), + ("Delete Row", "/hypermedia/examples/delete-row"), + ("Inline Edit", "/hypermedia/examples/inline-edit"), + ("OOB Swaps", "/hypermedia/examples/oob-swaps"), + ("Lazy Loading", "/hypermedia/examples/lazy-loading"), + ("Infinite Scroll", "/hypermedia/examples/infinite-scroll"), + ("Progress Bar", "/hypermedia/examples/progress-bar"), + ("Active Search", "/hypermedia/examples/active-search"), + ("Inline Validation", "/hypermedia/examples/inline-validation"), + ("Value Select", "/hypermedia/examples/value-select"), + ("Reset on Submit", "/hypermedia/examples/reset-on-submit"), + ("Edit Row", "/hypermedia/examples/edit-row"), + ("Bulk Update", "/hypermedia/examples/bulk-update"), + ("Swap Positions", "/hypermedia/examples/swap-positions"), + ("Select Filter", "/hypermedia/examples/select-filter"), + ("Tabs", "/hypermedia/examples/tabs"), + ("Animations", "/hypermedia/examples/animations"), + ("Dialogs", "/hypermedia/examples/dialogs"), + ("Keyboard Shortcuts", "/hypermedia/examples/keyboard-shortcuts"), + ("PUT / PATCH", "/hypermedia/examples/put-patch"), + ("JSON Encoding", "/hypermedia/examples/json-encoding"), + ("Vals & Headers", "/hypermedia/examples/vals-and-headers"), + ("Loading States", "/hypermedia/examples/loading-states"), + ("Request Abort", "/hypermedia/examples/sync-replace"), + ("Retry", "/hypermedia/examples/retry"), ] ESSAYS_NAV = [ @@ -79,9 +79,9 @@ ESSAYS_NAV = [ MAIN_NAV = [ ("Docs", "/docs/introduction"), - ("Reference", "/reference/"), + ("Reference", "/hypermedia/reference/"), ("Protocols", "/protocols/wire-format"), - ("Examples", "/examples/click-to-load"), + ("Examples", "/hypermedia/examples/click-to-load"), ("Essays", "/essays/sx-sucks"), ] @@ -744,7 +744,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-get-demo", "example": ( - '(button :sx-get "/reference/api/time"\n' + '(button :sx-get "/hypermedia/reference/api/time"\n' ' :sx-target "#ref-get-result"\n' ' :sx-swap "innerHTML"\n' ' "Load server time")' @@ -764,7 +764,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-post-demo", "example": ( - '(form :sx-post "/reference/api/greet"\n' + '(form :sx-post "/hypermedia/reference/api/greet"\n' ' :sx-target "#ref-post-result"\n' ' :sx-swap "innerHTML"\n' ' (input :type "text" :name "name"\n' @@ -786,7 +786,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-put-demo", "example": ( - '(button :sx-put "/reference/api/status"\n' + '(button :sx-put "/hypermedia/reference/api/status"\n' ' :sx-target "#ref-put-view"\n' ' :sx-swap "innerHTML"\n' ' :sx-vals "{\\"status\\": \\"published\\"}"\n' @@ -807,7 +807,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-delete-demo", "example": ( - '(button :sx-delete "/reference/api/item/1"\n' + '(button :sx-delete "/hypermedia/reference/api/item/1"\n' ' :sx-target "#ref-del-1"\n' ' :sx-swap "delete"\n' ' "Remove")' @@ -826,7 +826,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-patch-demo", "example": ( - '(button :sx-patch "/reference/api/theme"\n' + '(button :sx-patch "/hypermedia/reference/api/theme"\n' ' :sx-vals "{\\"theme\\": \\"dark\\"}"\n' ' :sx-target "#ref-patch-val"\n' ' :sx-swap "innerHTML"\n' @@ -852,7 +852,7 @@ ATTR_DETAILS: dict[str, dict] = { "example": ( '(input :type "text" :name "q"\n' ' :placeholder "Type to search..."\n' - ' :sx-get "/reference/api/trigger-search"\n' + ' :sx-get "/hypermedia/reference/api/trigger-search"\n' ' :sx-trigger "input changed delay:300ms"\n' ' :sx-target "#ref-trigger-result"\n' ' :sx-swap "innerHTML")' @@ -874,12 +874,12 @@ ATTR_DETAILS: dict[str, dict] = { "demo": "ref-target-demo", "example": ( ';; Two buttons targeting different elements\n' - '(button :sx-get "/reference/api/time"\n' + '(button :sx-get "/hypermedia/reference/api/time"\n' ' :sx-target "#ref-target-a"\n' ' :sx-swap "innerHTML"\n' ' "Update Box A")\n' '\n' - '(button :sx-get "/reference/api/time"\n' + '(button :sx-get "/hypermedia/reference/api/time"\n' ' :sx-target "#ref-target-b"\n' ' :sx-swap "innerHTML"\n' ' "Update Box B")' @@ -894,13 +894,13 @@ ATTR_DETAILS: dict[str, dict] = { "demo": "ref-swap-demo", "example": ( ';; Append to the end of a list\n' - '(button :sx-get "/reference/api/swap-item"\n' + '(button :sx-get "/hypermedia/reference/api/swap-item"\n' ' :sx-target "#ref-swap-list"\n' ' :sx-swap "beforeend"\n' ' "beforeend")\n' '\n' ';; Prepend to the start\n' - '(button :sx-get "/reference/api/swap-item"\n' + '(button :sx-get "/hypermedia/reference/api/swap-item"\n' ' :sx-target "#ref-swap-list"\n' ' :sx-swap "afterbegin"\n' ' "afterbegin")' @@ -921,7 +921,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-oob-demo", "example": ( - '(button :sx-get "/reference/api/oob"\n' + '(button :sx-get "/hypermedia/reference/api/oob"\n' ' :sx-target "#ref-oob-main"\n' ' :sx-swap "innerHTML"\n' ' "Update both boxes")' @@ -944,7 +944,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-select-demo", "example": ( - '(button :sx-get "/reference/api/select-page"\n' + '(button :sx-get "/hypermedia/reference/api/select-page"\n' ' :sx-target "#ref-select-result"\n' ' :sx-select "#the-content"\n' ' :sx-swap "innerHTML"\n' @@ -968,7 +968,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-confirm-demo", "example": ( - '(button :sx-delete "/reference/api/item/confirm"\n' + '(button :sx-delete "/hypermedia/reference/api/item/confirm"\n' ' :sx-target "#ref-confirm-item"\n' ' :sx-swap "delete"\n' ' :sx-confirm "Are you sure you want to delete this file?"\n' @@ -983,8 +983,8 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-pushurl-demo", "example": ( - '(a :href "/reference/attributes/sx-get"\n' - ' :sx-get "/reference/attributes/sx-get"\n' + '(a :href "/hypermedia/reference/attributes/sx-get"\n' + ' :sx-get "/hypermedia/reference/attributes/sx-get"\n' ' :sx-target "#main-panel"\n' ' :sx-select "#main-panel"\n' ' :sx-swap "outerHTML"\n' @@ -1003,7 +1003,7 @@ ATTR_DETAILS: dict[str, dict] = { "example": ( '(input :type "text" :name "q"\n' ' :placeholder "Type quickly..."\n' - ' :sx-get "/reference/api/slow-echo"\n' + ' :sx-get "/hypermedia/reference/api/slow-echo"\n' ' :sx-trigger "input changed delay:100ms"\n' ' :sx-sync "replace"\n' ' :sx-target "#ref-sync-result"\n' @@ -1024,7 +1024,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-encoding-demo", "example": ( - '(form :sx-post "/reference/api/upload-name"\n' + '(form :sx-post "/hypermedia/reference/api/upload-name"\n' ' :sx-encoding "multipart/form-data"\n' ' :sx-target "#ref-encoding-result"\n' ' :sx-swap "innerHTML"\n' @@ -1044,7 +1044,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-headers-demo", "example": ( - '(button :sx-get "/reference/api/echo-headers"\n' + '(button :sx-get "/hypermedia/reference/api/echo-headers"\n' ' :sx-headers \'{"X-Custom-Token": "abc123", "X-Request-Source": "demo"}\'\n' ' :sx-target "#ref-headers-result"\n' ' :sx-swap "innerHTML"\n' @@ -1073,7 +1073,7 @@ ATTR_DETAILS: dict[str, dict] = { ' (option :value "books" "Books")\n' ' (option :value "tools" "Tools"))\n' '\n' - '(button :sx-get "/reference/api/echo-vals"\n' + '(button :sx-get "/hypermedia/reference/api/echo-vals"\n' ' :sx-include "#ref-inc-cat"\n' ' :sx-target "#ref-include-result"\n' ' :sx-swap "innerHTML"\n' @@ -1097,7 +1097,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-vals-demo", "example": ( - '(button :sx-post "/reference/api/echo-vals"\n' + '(button :sx-post "/hypermedia/reference/api/echo-vals"\n' ' :sx-vals \'{"source": "demo", "page": "3"}\'\n' ' :sx-target "#ref-vals-result"\n' ' :sx-swap "innerHTML"\n' @@ -1112,8 +1112,8 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-media-demo", "example": ( - '(a :href "/reference/attributes/sx-get"\n' - ' :sx-get "/reference/attributes/sx-get"\n' + '(a :href "/hypermedia/reference/attributes/sx-get"\n' + ' :sx-get "/hypermedia/reference/attributes/sx-get"\n' ' :sx-target "#main-panel"\n' ' :sx-select "#main-panel"\n' ' :sx-swap "outerHTML"\n' @@ -1133,7 +1133,7 @@ ATTR_DETAILS: dict[str, dict] = { ';; Left box: sx works normally\n' ';; Right box: sx-disable prevents any sx behavior\n' '(div :sx-disable "true"\n' - ' (button :sx-get "/reference/api/time"\n' + ' (button :sx-get "/hypermedia/reference/api/time"\n' ' :sx-target "#ref-dis-b"\n' ' :sx-swap "innerHTML"\n' ' "Load")\n' @@ -1166,7 +1166,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-retry-demo", "example": ( - '(button :sx-get "/reference/api/flaky"\n' + '(button :sx-get "/hypermedia/reference/api/flaky"\n' ' :sx-target "#ref-retry-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-retry "true"\n' @@ -1239,7 +1239,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-preload-demo", "example": ( - '(button :sx-get "/reference/api/time"\n' + '(button :sx-get "/hypermedia/reference/api/time"\n' ' :sx-target "#ref-preload-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-preload "mouseover"\n' @@ -1275,7 +1275,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-indicator-demo", "example": ( - '(button :sx-get "/reference/api/slow-echo"\n' + '(button :sx-get "/hypermedia/reference/api/slow-echo"\n' ' :sx-target "#ref-indicator-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-indicator "#ref-spinner"\n' @@ -1302,7 +1302,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-validate-demo", "example": ( - '(form :sx-post "/reference/api/greet"\n' + '(form :sx-post "/hypermedia/reference/api/greet"\n' ' :sx-target "#ref-validate-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-validate "true"\n' @@ -1340,7 +1340,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-optimistic-demo", "example": ( - '(button :sx-delete "/reference/api/item/opt1"\n' + '(button :sx-delete "/hypermedia/reference/api/item/opt1"\n' ' :sx-target "#ref-opt-item"\n' ' :sx-swap "delete"\n' ' :sx-optimistic "remove"\n' @@ -1362,7 +1362,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-replace-url-demo", "example": ( - '(button :sx-get "/reference/api/time"\n' + '(button :sx-get "/hypermedia/reference/api/time"\n' ' :sx-target "#ref-replurl-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-replace-url "true"\n' @@ -1378,7 +1378,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-disabled-elt-demo", "example": ( - '(button :sx-get "/reference/api/slow-echo"\n' + '(button :sx-get "/hypermedia/reference/api/slow-echo"\n' ' :sx-target "#ref-diselt-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-disabled-elt "this"\n' @@ -1394,7 +1394,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-prompt-demo", "example": ( - '(button :sx-get "/reference/api/prompt-echo"\n' + '(button :sx-get "/hypermedia/reference/api/prompt-echo"\n' ' :sx-target "#ref-prompt-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-prompt "Enter your name:"\n' @@ -1414,7 +1414,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-params-demo", "example": ( - '(form :sx-post "/reference/api/echo-vals"\n' + '(form :sx-post "/hypermedia/reference/api/echo-vals"\n' ' :sx-target "#ref-params-result"\n' ' :sx-swap "innerHTML"\n' ' :sx-params "name"\n' @@ -1433,7 +1433,7 @@ ATTR_DETAILS: dict[str, dict] = { ), "demo": "ref-sse-demo", "example": ( - '(div :sx-sse "/reference/api/sse-time"\n' + '(div :sx-sse "/hypermedia/reference/api/sse-time"\n' ' :sx-sse-swap "time"\n' ' :sx-target "#ref-sse-result"\n' ' :sx-swap "innerHTML"\n' diff --git a/sx/sx/examples-content.sx b/sx/sx/examples-content.sx index 4b38a10..33f2f54 100644 --- a/sx/sx/examples-content.sx +++ b/sx/sx/examples-content.sx @@ -8,8 +8,8 @@ :description "The simplest sx interaction: click a button, fetch content from the server, swap it in." :demo-description "Click the button to load server-rendered content." :demo (~click-to-load-demo) - :sx-code "(button\n :sx-get \"/examples/api/click\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")" - :handler-code "@bp.get(\"/examples/api/click\")\nasync def api_click():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~click-result :time \"{now}\")')" + :sx-code "(button\n :sx-get \"/hypermedia/examples/api/click\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/click\")\nasync def api_click():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~click-result :time \"{now}\")')" :comp-placeholder-id "click-comp" :wire-placeholder-id "click-wire" :wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response.")) @@ -20,8 +20,8 @@ :description "Forms with sx-post submit via AJAX and swap the response into a target." :demo-description "Enter a name and submit." :demo (~form-demo) - :sx-code "(form\n :sx-post \"/examples/api/form\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))" - :handler-code "@bp.post(\"/examples/api/form\")\nasync def api_form():\n form = await request.form\n name = form.get(\"name\", \"\")\n return sx_response(\n f'(~form-result :name \"{name}\")')" + :sx-code "(form\n :sx-post \"/hypermedia/examples/api/form\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))" + :handler-code "@bp.post(\"/hypermedia/examples/api/form\")\nasync def api_form():\n form = await request.form\n name = form.get(\"name\", \"\")\n return sx_response(\n f'(~form-result :name \"{name}\")')" :comp-placeholder-id "form-comp" :wire-placeholder-id "form-wire")) @@ -31,8 +31,8 @@ :description "Use sx-trigger with \"every\" to poll the server at regular intervals." :demo-description "This div polls the server every 2 seconds." :demo (~polling-demo) - :sx-code "(div\n :sx-get \"/examples/api/poll\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")" - :handler-code "@bp.get(\"/examples/api/poll\")\nasync def api_poll():\n poll_count[\"n\"] += 1\n now = datetime.now().strftime(\"%H:%M:%S\")\n count = min(poll_count[\"n\"], 10)\n return sx_response(\n f'(~poll-result :time \"{now}\" :count {count})')" + :sx-code "(div\n :sx-get \"/hypermedia/examples/api/poll\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/poll\")\nasync def api_poll():\n poll_count[\"n\"] += 1\n now = datetime.now().strftime(\"%H:%M:%S\")\n count = min(poll_count[\"n\"], 10)\n return sx_response(\n f'(~poll-result :time \"{now}\" :count {count})')" :comp-placeholder-id "poll-comp" :wire-placeholder-id "poll-wire" :wire-note "Updates every 2 seconds — watch the time and count change.")) @@ -49,7 +49,7 @@ (list "4" "Deploy to production") (list "5" "Add unit tests"))) :sx-code "(button\n :sx-delete \"/api/delete/1\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")" - :handler-code "@bp.delete(\"/examples/api/delete/\")\nasync def api_delete(item_id: str):\n # Empty response — outerHTML swap removes the row\n return Response(\"\", status=200,\n content_type=\"text/sx\")" + :handler-code "@bp.delete(\"/hypermedia/examples/api/delete/\")\nasync def api_delete(item_id: str):\n # Empty response — outerHTML swap removes the row\n return Response(\"\", status=200,\n content_type=\"text/sx\")" :comp-placeholder-id "delete-comp" :wire-placeholder-id "delete-wire" :wire-note "Empty body — outerHTML swap replaces the target element with nothing.")) @@ -61,7 +61,7 @@ :demo-description "Click edit, modify the text, save or cancel." :demo (~inline-edit-demo) :sx-code ";; View mode — shows text + edit button\n(~inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~inline-edit-form :value \"some text\")" - :handler-code "@bp.get(\"/examples/api/edit\")\nasync def api_edit_form():\n value = request.args.get(\"value\", \"\")\n return sx_response(\n f'(~inline-edit-form :value \"{value}\")')\n\n@bp.post(\"/examples/api/edit\")\nasync def api_edit_save():\n form = await request.form\n value = form.get(\"value\", \"\")\n return sx_response(\n f'(~inline-view :value \"{value}\")')" + :handler-code "@bp.get(\"/hypermedia/examples/api/edit\")\nasync def api_edit_form():\n value = request.args.get(\"value\", \"\")\n return sx_response(\n f'(~inline-edit-form :value \"{value}\")')\n\n@bp.post(\"/hypermedia/examples/api/edit\")\nasync def api_edit_save():\n form = await request.form\n value = form.get(\"value\", \"\")\n return sx_response(\n f'(~inline-view :value \"{value}\")')" :comp-placeholder-id "edit-comp" :comp-heading "Components" :handler-heading "Server handlers" @@ -73,8 +73,8 @@ :description "sx-swap-oob lets a single response update multiple elements anywhere in the DOM." :demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)." :demo (~oob-demo) - :sx-code ";; Button targets Box A\n(button\n :sx-get \"/examples/api/oob\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")" - :handler-code "@bp.get(\"/examples/api/oob\")\nasync def api_oob():\n now = datetime.now().strftime(\"%H:%M:%S\")\n return sx_response(\n f'(<>'\n f' (p \"Box A updated at {now}\")'\n f' (div :id \"oob-box-b\"'\n f' :sx-swap-oob \"innerHTML\"'\n f' (p \"Box B updated at {now}\")))')" + :sx-code ";; Button targets Box A\n(button\n :sx-get \"/hypermedia/examples/api/oob\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/oob\")\nasync def api_oob():\n now = datetime.now().strftime(\"%H:%M:%S\")\n return sx_response(\n f'(<>'\n f' (p \"Box A updated at {now}\")'\n f' (div :id \"oob-box-b\"'\n f' :sx-swap-oob \"innerHTML\"'\n f' (p \"Box B updated at {now}\")))')" :wire-placeholder-id "oob-wire" :wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID.")) @@ -84,8 +84,8 @@ :description "Use sx-trigger=\"load\" to fetch content as soon as the element enters the DOM. Great for deferring expensive content below the fold." :demo-description "Content loads automatically when the page renders." :demo (~lazy-loading-demo) - :sx-code "(div\n :sx-get \"/examples/api/lazy\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))" - :handler-code "@bp.get(\"/examples/api/lazy\")\nasync def api_lazy():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~lazy-result :time \"{now}\")')" + :sx-code "(div\n :sx-get \"/hypermedia/examples/api/lazy\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))" + :handler-code "@bp.get(\"/hypermedia/examples/api/lazy\")\nasync def api_lazy():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~lazy-result :time \"{now}\")')" :comp-placeholder-id "lazy-comp" :wire-placeholder-id "lazy-wire")) @@ -95,8 +95,8 @@ :description "A sentinel element at the bottom uses sx-trigger=\"intersect once\" to load the next page when scrolled into view. Each response appends items and a new sentinel." :demo-description "Scroll down in the container to load more items (5 pages total)." :demo (~infinite-scroll-demo) - :sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/examples/api/scroll?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")" - :handler-code "@bp.get(\"/examples/api/scroll\")\nasync def api_scroll():\n page = int(request.args.get(\"page\", 2))\n items = [f\"Item {i}\" for i in range(...)]\n # Include next sentinel if more pages\n return sx_response(items_sx + sentinel_sx)" + :sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/hypermedia/examples/api/scroll?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/scroll\")\nasync def api_scroll():\n page = int(request.args.get(\"page\", 2))\n items = [f\"Item {i}\" for i in range(...)]\n # Include next sentinel if more pages\n return sx_response(items_sx + sentinel_sx)" :comp-placeholder-id "scroll-comp" :wire-placeholder-id "scroll-wire")) @@ -106,8 +106,8 @@ :description "Start a server-side job, then poll for progress using sx-trigger=\"load delay:500ms\" on each response. The bar fills up and stops when complete." :demo-description "Click start to begin a simulated job." :demo (~progress-bar-demo) - :sx-code ";; Start the job\n(button\n :sx-post \"/examples/api/progress/start\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/api/progress/status?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")" - :handler-code "@bp.post(\"/examples/api/progress/start\")\nasync def api_progress_start():\n job_id = str(uuid4())[:8]\n _jobs[job_id] = 0\n return sx_response(\n f'(~progress-status :percent 0 :job-id \"{job_id}\")')" + :sx-code ";; Start the job\n(button\n :sx-post \"/hypermedia/examples/api/progress/start\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/api/progress/status?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")" + :handler-code "@bp.post(\"/hypermedia/examples/api/progress/start\")\nasync def api_progress_start():\n job_id = str(uuid4())[:8]\n _jobs[job_id] = 0\n return sx_response(\n f'(~progress-status :percent 0 :job-id \"{job_id}\")')" :comp-placeholder-id "progress-comp" :wire-placeholder-id "progress-wire")) @@ -117,8 +117,8 @@ :description "An input with sx-trigger=\"keyup delay:300ms changed\" debounces keystrokes and only fires when the value changes. The server filters a list of programming languages." :demo-description "Type to search through 20 programming languages." :demo (~active-search-demo) - :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/examples/api/search\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")" - :handler-code "@bp.get(\"/examples/api/search\")\nasync def api_search():\n q = request.args.get(\"q\", \"\").lower()\n results = [l for l in LANGUAGES if q in l.lower()]\n return sx_response(\n f'(~search-results :items (...) :query \"{q}\")')" + :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/hypermedia/examples/api/search\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/search\")\nasync def api_search():\n q = request.args.get(\"q\", \"\").lower()\n results = [l for l in LANGUAGES if q in l.lower()]\n return sx_response(\n f'(~search-results :items (...) :query \"{q}\")')" :comp-placeholder-id "search-comp" :wire-placeholder-id "search-wire")) @@ -128,8 +128,8 @@ :description "Validate an email field on blur. The server checks format and whether it is taken, returning green or red feedback inline." :demo-description "Enter an email and click away (blur) to validate." :demo (~inline-validation-demo) - :sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/examples/api/validate\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")" - :handler-code "@bp.get(\"/examples/api/validate\")\nasync def api_validate():\n email = request.args.get(\"email\", \"\")\n if \"@\" not in email:\n return sx_response('(~validation-error ...)')\n return sx_response('(~validation-ok ...)')" + :sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/hypermedia/examples/api/validate\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/validate\")\nasync def api_validate():\n email = request.args.get(\"email\", \"\")\n if \"@\" not in email:\n return sx_response('(~validation-error ...)')\n return sx_response('(~validation-ok ...)')" :comp-placeholder-id "validate-comp" :wire-placeholder-id "validate-wire")) @@ -139,8 +139,8 @@ :description "Two linked selects: pick a category and the second select updates with matching items via sx-get." :demo-description "Select a category to populate the item dropdown." :demo (~value-select-demo) - :sx-code "(select :name \"category\"\n :sx-get \"/examples/api/values\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))" - :handler-code "@bp.get(\"/examples/api/values\")\nasync def api_values():\n cat = request.args.get(\"category\", \"\")\n items = VALUE_SELECT_DATA.get(cat, [])\n return sx_response(\n f'(~value-options :items (list ...))')" + :sx-code "(select :name \"category\"\n :sx-get \"/hypermedia/examples/api/values\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))" + :handler-code "@bp.get(\"/hypermedia/examples/api/values\")\nasync def api_values():\n cat = request.args.get(\"category\", \"\")\n items = VALUE_SELECT_DATA.get(cat, [])\n return sx_response(\n f'(~value-options :items (list ...))')" :comp-placeholder-id "values-comp" :wire-placeholder-id "values-wire")) @@ -150,8 +150,8 @@ :description "Use sx-on:afterSwap=\"this.reset()\" to clear form inputs after a successful submission." :demo-description "Submit a message — the input resets after each send." :demo (~reset-on-submit-demo) - :sx-code "(form :id \"reset-form\"\n :sx-post \"/examples/api/reset-submit\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))" - :handler-code "@bp.post(\"/examples/api/reset-submit\")\nasync def api_reset_submit():\n form = await request.form\n msg = form.get(\"message\", \"\")\n return sx_response(\n f'(~reset-message :message \"{msg}\" :time \"...\")')" + :sx-code "(form :id \"reset-form\"\n :sx-post \"/hypermedia/examples/api/reset-submit\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))" + :handler-code "@bp.post(\"/hypermedia/examples/api/reset-submit\")\nasync def api_reset_submit():\n form = await request.form\n msg = form.get(\"message\", \"\")\n return sx_response(\n f'(~reset-message :message \"{msg}\" :time \"...\")')" :comp-placeholder-id "reset-comp" :wire-placeholder-id "reset-wire")) @@ -165,8 +165,8 @@ (list "2" "Widget B" "24.50" "89") (list "3" "Widget C" "12.00" "305") (list "4" "Widget D" "45.00" "67"))) - :sx-code "(button\n :sx-get \"/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")" - :handler-code "@bp.get(\"/examples/api/editrow/\")\nasync def api_editrow_form(id):\n row = EDIT_ROW_DATA[id]\n return sx_response(\n f'(~edit-row-form :id ... :name ...)')\n\n@bp.post(\"/examples/api/editrow/\")\nasync def api_editrow_save(id):\n form = await request.form\n return sx_response(\n f'(~edit-row-view :id ... :name ...)')" + :sx-code "(button\n :sx-get \"/hypermedia/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/hypermedia/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/editrow/\")\nasync def api_editrow_form(id):\n row = EDIT_ROW_DATA[id]\n return sx_response(\n f'(~edit-row-form :id ... :name ...)')\n\n@bp.post(\"/hypermedia/examples/api/editrow/\")\nasync def api_editrow_save(id):\n form = await request.form\n return sx_response(\n f'(~edit-row-view :id ... :name ...)')" :comp-placeholder-id "editrow-comp" :wire-placeholder-id "editrow-wire")) @@ -181,8 +181,8 @@ (list "3" "Carol Zhang" "carol@example.com" "active") (list "4" "Dan Okafor" "dan@example.com" "inactive") (list "5" "Eve Larsson" "eve@example.com" "active"))) - :sx-code "(button\n :sx-post \"/examples/api/bulk?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")" - :handler-code "@bp.post(\"/examples/api/bulk\")\nasync def api_bulk():\n action = request.args.get(\"action\")\n form = await request.form\n ids = form.getlist(\"ids\")\n # Update matching users\n return sx_response(updated_rows)" + :sx-code "(button\n :sx-post \"/hypermedia/examples/api/bulk?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")" + :handler-code "@bp.post(\"/hypermedia/examples/api/bulk\")\nasync def api_bulk():\n action = request.args.get(\"action\")\n form = await request.form\n ids = form.getlist(\"ids\")\n # Update matching users\n return sx_response(updated_rows)" :comp-placeholder-id "bulk-comp" :wire-placeholder-id "bulk-wire")) @@ -193,7 +193,7 @@ :demo-description "Try each button to see different swap behaviours." :demo (~swap-positions-demo) :sx-code ";; Append to end\n(button :sx-post \"/api/swap-log?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/api/swap-log?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/api/swap-log?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")" - :handler-code "@bp.post(\"/examples/api/swap-log\")\nasync def api_swap_log():\n mode = request.args.get(\"mode\")\n # OOB counter updates on every request\n oob = f'(span :id \"swap-counter\" :sx-swap-oob \"innerHTML\" \"Count: {n}\")'\n return sx_response(entry + oob)" + :handler-code "@bp.post(\"/hypermedia/examples/api/swap-log\")\nasync def api_swap_log():\n mode = request.args.get(\"mode\")\n # OOB counter updates on every request\n oob = f'(span :id \"swap-counter\" :sx-swap-oob \"innerHTML\" \"Count: {n}\")'\n return sx_response(entry + oob)" :wire-placeholder-id "swap-wire")) (defcomp ~example-select-filter () @@ -202,8 +202,8 @@ :description "sx-select lets the client pick a specific section from the server response by CSS selector. The server always returns the full dashboard — the client filters." :demo-description "Different buttons select different parts of the same server response." :demo (~select-filter-demo) - :sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")" - :handler-code "@bp.get(\"/examples/api/dashboard\")\nasync def api_dashboard():\n # Returns header + stats + footer\n # Client uses sx-select to pick sections\n return sx_response(\n '(<> (div :id \"dash-header\" ...) '\n ' (div :id \"dash-stats\" ...) '\n ' (div :id \"dash-footer\" ...))')" + :sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/hypermedia/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/hypermedia/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/dashboard\")\nasync def api_dashboard():\n # Returns header + stats + footer\n # Client uses sx-select to pick sections\n return sx_response(\n '(<> (div :id \"dash-header\" ...) '\n ' (div :id \"dash-stats\" ...) '\n ' (div :id \"dash-footer\" ...))')" :wire-placeholder-id "filter-wire")) (defcomp ~example-tabs () @@ -212,8 +212,8 @@ :description "Tab navigation using sx-push-url to update the browser URL. Back/forward buttons navigate between previously visited tabs." :demo-description "Click tabs to switch content. Watch the browser URL change." :demo (~tabs-demo) - :sx-code "(button\n :sx-get \"/examples/api/tabs/tab1\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/examples/tabs?tab=tab1\"\n \"Overview\")" - :handler-code "@bp.get(\"/examples/api/tabs/\")\nasync def api_tabs(tab: str):\n content = TAB_CONTENT[tab]\n return sx_response(content)" + :sx-code "(button\n :sx-get \"/hypermedia/examples/api/tabs/tab1\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/hypermedia/examples/tabs?tab=tab1\"\n \"Overview\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/tabs/\")\nasync def api_tabs(tab: str):\n content = TAB_CONTENT[tab]\n return sx_response(content)" :wire-placeholder-id "tabs-wire")) (defcomp ~example-animations () @@ -222,8 +222,8 @@ :description "CSS animations play on swap. The component injects a style tag with a keyframe animation and applies the class. Each click picks a random background colour." :demo-description "Click to swap in content with a fade-in animation." :demo (~animations-demo) - :sx-code "(button\n :sx-get \"/examples/api/animate\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))" - :handler-code "@bp.get(\"/examples/api/animate\")\nasync def api_animate():\n colors = [\"bg-violet-100\", \"bg-emerald-100\", ...]\n color = random.choice(colors)\n return sx_response(\n f'(~anim-result :color \"{color}\" :time \"{now}\")')" + :sx-code "(button\n :sx-get \"/hypermedia/examples/api/animate\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))" + :handler-code "@bp.get(\"/hypermedia/examples/api/animate\")\nasync def api_animate():\n colors = [\"bg-violet-100\", \"bg-emerald-100\", ...]\n color = random.choice(colors)\n return sx_response(\n f'(~anim-result :color \"{color}\" :time \"{now}\")')" :comp-placeholder-id "anim-comp" :wire-placeholder-id "anim-wire")) @@ -233,8 +233,8 @@ :description "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. Pure sx — no JavaScript library needed." :demo-description "Click to open a modal dialog." :demo (~dialogs-demo) - :sx-code "(button\n :sx-get \"/examples/api/dialog\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/examples/api/dialog/close\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")" - :handler-code "@bp.get(\"/examples/api/dialog\")\nasync def api_dialog():\n return sx_response(\n '(~dialog-modal :title \"Confirm\"'\n ' :message \"Are you sure?\")')\n\n@bp.get(\"/examples/api/dialog/close\")\nasync def api_dialog_close():\n return sx_response(\"\")" + :sx-code "(button\n :sx-get \"/hypermedia/examples/api/dialog\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/hypermedia/examples/api/dialog/close\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/dialog\")\nasync def api_dialog():\n return sx_response(\n '(~dialog-modal :title \"Confirm\"'\n ' :message \"Are you sure?\")')\n\n@bp.get(\"/hypermedia/examples/api/dialog/close\")\nasync def api_dialog_close():\n return sx_response(\"\")" :comp-placeholder-id "dialog-comp" :wire-placeholder-id "dialog-wire")) @@ -244,8 +244,8 @@ :description "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. The filter prevents firing when typing in inputs." :demo-description "Press s, n, or h on your keyboard." :demo (~keyboard-shortcuts-demo) - :sx-code "(div :id \"kbd-target\"\n :sx-get \"/examples/api/keyboard?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")" - :handler-code "@bp.get(\"/examples/api/keyboard\")\nasync def api_keyboard():\n key = request.args.get(\"key\", \"\")\n actions = {\"s\": \"Search\", \"n\": \"New item\", \"h\": \"Help\"}\n return sx_response(\n f'(~kbd-result :key \"{key}\" :action \"{actions[key]}\")')" + :sx-code "(div :id \"kbd-target\"\n :sx-get \"/hypermedia/examples/api/keyboard?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/keyboard\")\nasync def api_keyboard():\n key = request.args.get(\"key\", \"\")\n actions = {\"s\": \"Search\", \"n\": \"New item\", \"h\": \"Help\"}\n return sx_response(\n f'(~kbd-result :key \"{key}\" :action \"{actions[key]}\")')" :comp-placeholder-id "kbd-comp" :wire-placeholder-id "kbd-wire")) @@ -255,8 +255,8 @@ :description "sx-put replaces the entire resource. This example shows a profile card with an Edit All button that sends a PUT with all fields." :demo-description "Click Edit All to replace the full profile via PUT." :demo (~put-patch-demo :name "Ada Lovelace" :email "ada@example.com" :role "Engineer") - :sx-code ";; Replace entire resource\n(form :sx-put \"/examples/api/putpatch\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))" - :handler-code "@bp.put(\"/examples/api/putpatch\")\nasync def api_put():\n form = await request.form\n # Full replacement\n return sx_response('(~pp-view ...)')" + :sx-code ";; Replace entire resource\n(form :sx-put \"/hypermedia/examples/api/putpatch\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))" + :handler-code "@bp.put(\"/hypermedia/examples/api/putpatch\")\nasync def api_put():\n form = await request.form\n # Full replacement\n return sx_response('(~pp-view ...)')" :comp-placeholder-id "pp-comp" :wire-placeholder-id "pp-wire")) @@ -266,8 +266,8 @@ :description "Use sx-encoding=\"json\" to send form data as a JSON body instead of URL-encoded form data. The server echoes back what it received." :demo-description "Submit the form and see the JSON body the server received." :demo (~json-encoding-demo) - :sx-code "(form\n :sx-post \"/examples/api/json-echo\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))" - :handler-code "@bp.post(\"/examples/api/json-echo\")\nasync def api_json_echo():\n data = await request.get_json()\n body = json.dumps(data, indent=2)\n ct = request.content_type\n return sx_response(\n f'(~json-result :body \"{body}\" :content-type \"{ct}\")')" + :sx-code "(form\n :sx-post \"/hypermedia/examples/api/json-echo\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))" + :handler-code "@bp.post(\"/hypermedia/examples/api/json-echo\")\nasync def api_json_echo():\n data = await request.get_json()\n body = json.dumps(data, indent=2)\n ct = request.content_type\n return sx_response(\n f'(~json-result :body \"{body}\" :content-type \"{ct}\")')" :comp-placeholder-id "json-comp" :wire-placeholder-id "json-wire")) @@ -277,8 +277,8 @@ :description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received." :demo-description "Click each button to see what the server receives." :demo (~vals-headers-demo) - :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")" - :handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')" + :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/hypermedia/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/hypermedia/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/hypermedia/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')" :comp-placeholder-id "vals-comp" :wire-placeholder-id "vals-wire")) @@ -288,8 +288,8 @@ :description "sx.js adds the .sx-request CSS class to any element that has an active request. Use pure CSS to show spinners, disable buttons, or change opacity during loading." :demo-description "Click the button — it shows a spinner during the 2-second request." :demo (~loading-states-demo) - :sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/examples/api/slow\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")" - :handler-code "@bp.get(\"/examples/api/slow\")\nasync def api_slow():\n await asyncio.sleep(2)\n return sx_response(\n f'(~loading-result :time \"{now}\")')" + :sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/hypermedia/examples/api/slow\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/slow\")\nasync def api_slow():\n await asyncio.sleep(2)\n return sx_response(\n f'(~loading-result :time \"{now}\")')" :comp-placeholder-id "loading-comp" :wire-placeholder-id "loading-wire")) @@ -299,8 +299,8 @@ :description "sx-sync=\"replace\" aborts any in-flight request before sending a new one. This prevents stale responses from overwriting newer ones, even with random server delays." :demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays." :demo (~sync-replace-demo) - :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/examples/api/slow-search\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")" - :handler-code "@bp.get(\"/examples/api/slow-search\")\nasync def api_slow_search():\n delay = random.uniform(0.5, 2.0)\n await asyncio.sleep(delay)\n q = request.args.get(\"q\", \"\")\n return sx_response(\n f'(~sync-result :query \"{q}\" :delay \"{delay_ms}\")')" + :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/hypermedia/examples/api/slow-search\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/slow-search\")\nasync def api_slow_search():\n delay = random.uniform(0.5, 2.0)\n await asyncio.sleep(delay)\n q = request.args.get(\"q\", \"\")\n return sx_response(\n f'(~sync-result :query \"{q}\" :delay \"{delay_ms}\")')" :comp-placeholder-id "sync-comp" :wire-placeholder-id "sync-wire")) @@ -310,7 +310,7 @@ :description "sx-retry=\"exponential:1000:8000\" retries failed requests with exponential backoff starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd." :demo-description "Click the button — watch it retry automatically after failures." :demo (~retry-demo) - :sx-code "(button\n :sx-get \"/examples/api/flaky\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")" - :handler-code "@bp.get(\"/examples/api/flaky\")\nasync def api_flaky():\n _flaky[\"n\"] += 1\n if _flaky[\"n\"] % 3 != 0:\n return Response(\"\", status=503)\n return sx_response(\n f'(~retry-result :attempt {n} ...)')" + :sx-code "(button\n :sx-get \"/hypermedia/examples/api/flaky\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")" + :handler-code "@bp.get(\"/hypermedia/examples/api/flaky\")\nasync def api_flaky():\n _flaky[\"n\"] += 1\n if _flaky[\"n\"] % 3 != 0:\n return Response(\"\", status=503)\n return sx_response(\n f'(~retry-result :attempt {n} ...)')" :comp-placeholder-id "retry-comp" :wire-placeholder-id "retry-wire")) diff --git a/sx/sx/examples.sx b/sx/sx/examples.sx index 456ada1..22ea9e1 100644 --- a/sx/sx/examples.sx +++ b/sx/sx/examples.sx @@ -32,26 +32,26 @@ (p :class "text-stone-600 mb-6" "Complete reference for the sx client library.") (div :class "grid gap-4 sm:grid-cols-2" - (a :href "/reference/attributes" - :sx-get "/reference/attributes" :sx-target "#main-panel" :sx-select "#main-panel" + (a :href "/hypermedia/reference/attributes" + :sx-get "/hypermedia/reference/attributes" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline" (h3 :class "text-lg font-semibold text-violet-700 mb-1" "Attributes") (p :class "text-stone-600 text-sm" "All sx attributes — request verbs, behavior modifiers, and sx-unique features.")) - (a :href "/reference/headers" - :sx-get "/reference/headers" :sx-target "#main-panel" :sx-select "#main-panel" + (a :href "/hypermedia/reference/headers" + :sx-get "/hypermedia/reference/headers" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline" (h3 :class "text-lg font-semibold text-violet-700 mb-1" "Headers") (p :class "text-stone-600 text-sm" "Custom HTTP headers used to coordinate between the sx client and server.")) - (a :href "/reference/events" - :sx-get "/reference/events" :sx-target "#main-panel" :sx-select "#main-panel" + (a :href "/hypermedia/reference/events" + :sx-get "/hypermedia/reference/events" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline" (h3 :class "text-lg font-semibold text-violet-700 mb-1" "Events") (p :class "text-stone-600 text-sm" "DOM events fired during the sx request lifecycle.")) - (a :href "/reference/js-api" - :sx-get "/reference/js-api" :sx-target "#main-panel" :sx-select "#main-panel" + (a :href "/hypermedia/reference/js-api" + :sx-get "/hypermedia/reference/js-api" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline" (h3 :class "text-lg font-semibold text-violet-700 mb-1" "JS API") diff --git a/sx/sx/home.sx b/sx/sx/home.sx new file mode 100644 index 0000000..e69de29 diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 1b8f598..d9fb8cd 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -61,9 +61,13 @@ ;; Current section with prev/next siblings. ;; 3-column grid: prev is right-aligned, current centered, next left-aligned. ;; Current page is larger in the leaf (bottom) row. -(defcomp ~nav-sibling-row (&key node siblings is-leaf) +(defcomp ~nav-sibling-row (&key node siblings is-leaf level depth) (let* ((sibs (or siblings (list))) - (count (len sibs))) + (count (len sibs)) + ;; opacity = (n/x * 3/4) + 1/4 + (row-opacity (if (and level depth (> depth 0)) + (+ (* (/ level depth) 0.75) 0.25) + 1))) (when (> count 0) (let* ((idx (find-nav-index sibs node)) (prev-idx (mod (+ (- idx 1) count) count)) @@ -71,6 +75,7 @@ (prev-node (nth sibs prev-idx)) (next-node (nth sibs next-idx))) (div :class "max-w-3xl mx-auto px-4 py-2 grid grid-cols-3 items-center" + :style (str "opacity:" row-opacity ";transition:opacity 0.3s;") (a :href (get prev-node "href") :sx-get (get prev-node "href") :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" @@ -118,17 +123,27 @@ (defcomp ~sx-doc (&key path &rest children) :affinity :server (let* ((nav-state (resolve-nav-path sx-nav-tree (or path "/"))) (trail (or (get nav-state "trail") (list))) - (trail-len (len trail))) + (trail-len (len trail)) + ;; Total nav levels: logo (1) + trail rows + (depth (+ trail-len 1))) (<> (div :id "sx-nav" :class "mb-6" - (~sx-header :path (or path "/")) + ;; Logo opacity = (1/depth * 3/4) + 1/4 + ;; Wrapper is outside the island so the server morphs it directly + (div :id "logo-opacity" + :style (str "opacity:" (+ (* (/ 1 depth) 0.75) 0.25) ";" + "transition:opacity 0.3s;") + (~sx-header :path (or path "/"))) ;; Sibling arrows for EVERY level in the trail + ;; Trail row i is level (i+2) of depth — opacity = (i+2)/depth ;; Last row (leaf) gets is-leaf for larger current page title (map-indexed (fn (i crumb) (~nav-sibling-row :node (get crumb "node") :siblings (get crumb "siblings") - :is-leaf (= i (- trail-len 1)))) + :is-leaf (= i (- trail-len 1)) + :level (+ i 2) + :depth depth)) trail) ;; Children as button links (when (get nav-state "children") @@ -141,15 +156,13 @@ ;; --------------------------------------------------------------------------- (defcomp ~sx-docs-layout-full () - (~root-header-auto)) + nil) -;; OOB: just update root header. Nav is in content via ~sx-doc. (defcomp ~sx-docs-layout-oob () - (~root-header-auto true)) + nil) -;; Mobile: just root mobile nav. In-page nav is in content. (defcomp ~sx-docs-layout-mobile () - (~root-mobile-auto)) + nil) ;; --------------------------------------------------------------------------- ;; Standalone layouts (no root header — for sx-web.org) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index c0254b2..bb2c30f 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -12,10 +12,10 @@ (dict :label "Server Rendering" :href "/docs/server-rendering"))) (define reference-nav-items (list - (dict :label "Attributes" :href "/reference/attributes") - (dict :label "Headers" :href "/reference/headers") - (dict :label "Events" :href "/reference/events") - (dict :label "JS API" :href "/reference/js-api"))) + (dict :label "Attributes" :href "/hypermedia/reference/attributes") + (dict :label "Headers" :href "/hypermedia/reference/headers") + (dict :label "Events" :href "/hypermedia/reference/events") + (dict :label "JS API" :href "/hypermedia/reference/js-api"))) (define protocols-nav-items (list (dict :label "Wire Format" :href "/protocols/wire-format") @@ -26,33 +26,33 @@ (dict :label "Future" :href "/protocols/future"))) (define examples-nav-items (list - (dict :label "Click to Load" :href "/examples/click-to-load") - (dict :label "Form Submission" :href "/examples/form-submission") - (dict :label "Polling" :href "/examples/polling") - (dict :label "Delete Row" :href "/examples/delete-row") - (dict :label "Inline Edit" :href "/examples/inline-edit") - (dict :label "OOB Swaps" :href "/examples/oob-swaps") - (dict :label "Lazy Loading" :href "/examples/lazy-loading") - (dict :label "Infinite Scroll" :href "/examples/infinite-scroll") - (dict :label "Progress Bar" :href "/examples/progress-bar") - (dict :label "Active Search" :href "/examples/active-search") - (dict :label "Inline Validation" :href "/examples/inline-validation") - (dict :label "Value Select" :href "/examples/value-select") - (dict :label "Reset on Submit" :href "/examples/reset-on-submit") - (dict :label "Edit Row" :href "/examples/edit-row") - (dict :label "Bulk Update" :href "/examples/bulk-update") - (dict :label "Swap Positions" :href "/examples/swap-positions") - (dict :label "Select Filter" :href "/examples/select-filter") - (dict :label "Tabs" :href "/examples/tabs") - (dict :label "Animations" :href "/examples/animations") - (dict :label "Dialogs" :href "/examples/dialogs") - (dict :label "Keyboard Shortcuts" :href "/examples/keyboard-shortcuts") - (dict :label "PUT / PATCH" :href "/examples/put-patch") - (dict :label "JSON Encoding" :href "/examples/json-encoding") - (dict :label "Vals & Headers" :href "/examples/vals-and-headers") - (dict :label "Loading States" :href "/examples/loading-states") - (dict :label "Request Abort" :href "/examples/sync-replace") - (dict :label "Retry" :href "/examples/retry"))) + (dict :label "Click to Load" :href "/hypermedia/examples/click-to-load") + (dict :label "Form Submission" :href "/hypermedia/examples/form-submission") + (dict :label "Polling" :href "/hypermedia/examples/polling") + (dict :label "Delete Row" :href "/hypermedia/examples/delete-row") + (dict :label "Inline Edit" :href "/hypermedia/examples/inline-edit") + (dict :label "OOB Swaps" :href "/hypermedia/examples/oob-swaps") + (dict :label "Lazy Loading" :href "/hypermedia/examples/lazy-loading") + (dict :label "Infinite Scroll" :href "/hypermedia/examples/infinite-scroll") + (dict :label "Progress Bar" :href "/hypermedia/examples/progress-bar") + (dict :label "Active Search" :href "/hypermedia/examples/active-search") + (dict :label "Inline Validation" :href "/hypermedia/examples/inline-validation") + (dict :label "Value Select" :href "/hypermedia/examples/value-select") + (dict :label "Reset on Submit" :href "/hypermedia/examples/reset-on-submit") + (dict :label "Edit Row" :href "/hypermedia/examples/edit-row") + (dict :label "Bulk Update" :href "/hypermedia/examples/bulk-update") + (dict :label "Swap Positions" :href "/hypermedia/examples/swap-positions") + (dict :label "Select Filter" :href "/hypermedia/examples/select-filter") + (dict :label "Tabs" :href "/hypermedia/examples/tabs") + (dict :label "Animations" :href "/hypermedia/examples/animations") + (dict :label "Dialogs" :href "/hypermedia/examples/dialogs") + (dict :label "Keyboard Shortcuts" :href "/hypermedia/examples/keyboard-shortcuts") + (dict :label "PUT / PATCH" :href "/hypermedia/examples/put-patch") + (dict :label "JSON Encoding" :href "/hypermedia/examples/json-encoding") + (dict :label "Vals & Headers" :href "/hypermedia/examples/vals-and-headers") + (dict :label "Loading States" :href "/hypermedia/examples/loading-states") + (dict :label "Request Abort" :href "/hypermedia/examples/sync-replace") + (dict :label "Retry" :href "/hypermedia/examples/retry"))) (define cssx-nav-items (list (dict :label "Overview" :href "/cssx/") @@ -200,22 +200,26 @@ (dict :label "Async Eval Convergence" :href "/plans/async-eval-convergence" :summary "Eliminate hand-written evaluators — bootstrap async_eval.py from the spec via an async adapter layer. One spec, one truth, zero divergence.") (dict :label "WASM Bytecode VM" :href "/plans/wasm-bytecode-vm" - :summary "Compile SX to bytecode, run in a Rust/WASM VM. Compact wire format, no parse overhead, near-native speed, DOM via JS bindings."))) + :summary "Compile SX to bytecode, run in a Rust/WASM VM. Compact wire format, no parse overhead, near-native speed, DOM via JS bindings.") + (dict :label "Generative SX" :href "/plans/generative-sx" + :summary "Programs that write themselves as they run — self-compiling specs, runtime self-extension, generative testing, seed networks.") + (dict :label "Art DAG on SX" :href "/plans/art-dag-sx" + :summary "SX endpoints as portals into media processing environments — recipes as programs, split execution across GPU/cache/live boundaries, streaming AV output."))) (define reactive-islands-nav-items (list - (dict :label "Overview" :href "/reactive-islands/" + (dict :label "Overview" :href "/reactive/" :summary "Architecture, four levels (L0-L3), and current implementation status.") - (dict :label "Demo" :href "/reactive-islands/demo" + (dict :label "Demo" :href "/reactive/demo" :summary "Live demonstration of signals, computed, effects, batch, and defisland — all transpiled from spec.") - (dict :label "Event Bridge" :href "/reactive-islands/event-bridge" + (dict :label "Event Bridge" :href "/reactive/event-bridge" :summary "DOM events for htmx lake → island communication. Server-rendered buttons dispatch custom events that island effects listen for.") - (dict :label "Named Stores" :href "/reactive-islands/named-stores" + (dict :label "Named Stores" :href "/reactive/named-stores" :summary "Page-level signal containers via def-store/use-store — persist across island destruction/recreation.") - (dict :label "Plan" :href "/reactive-islands/plan" + (dict :label "Plan" :href "/reactive/plan" :summary "The full design document — rendering boundary, state flow, signal primitives, island lifecycle.") - (dict :label "Marshes" :href "/reactive-islands/marshes" + (dict :label "Marshes" :href "/reactive/marshes" :summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted.") - (dict :label "Phase 2" :href "/reactive-islands/phase2" + (dict :label "Phase 2" :href "/reactive/phase2" :summary "Input binding, keyed lists, reactive class/style, refs, portals, error boundaries, suspense, transitions."))) (define bootstrappers-nav-items (list @@ -322,19 +326,21 @@ (define sx-nav-tree {:label "sx" :href "/" :children (list + {:label "Reactive" :href "/reactive/" :children reactive-islands-nav-items} + {:label "Hypermedia" :href "/hypermedia/" + :children (list + {:label "Reference" :href "/hypermedia/reference/" :children reference-nav-items} + {:label "Examples" :href "/hypermedia/examples/" :children examples-nav-items})} {:label "Docs" :href "/docs/" :children docs-nav-items} {:label "CSSX" :href "/cssx/" :children cssx-nav-items} - {:label "Reference" :href "/reference/" :children reference-nav-items} {:label "Protocols" :href "/protocols/" :children protocols-nav-items} - {:label "Examples" :href "/examples/" :children examples-nav-items} {:label "Essays" :href "/essays/" :children essays-nav-items} {:label "Philosophy" :href "/philosophy/" :children philosophy-nav-items} {:label "Specs" :href "/specs/" :children specs-nav-items} {:label "Bootstrappers" :href "/bootstrappers/" :children bootstrappers-nav-items} {:label "Testing" :href "/testing/" :children testing-nav-items} {:label "Isomorphism" :href "/isomorphism/" :children isomorphism-nav-items} - {:label "Plans" :href "/plans/" :children plans-nav-items} - {:label "Reactive Islands" :href "/reactive-islands/" :children reactive-islands-nav-items})}) + {:label "Plans" :href "/plans/" :children plans-nav-items})}) ;; Resolve a URL path to a nav trail + children. ;; Returns {:trail [{:node N :siblings S} ...] :children [...] :depth N} diff --git a/sx/sx/not-found.sx b/sx/sx/not-found.sx new file mode 100644 index 0000000..484340c --- /dev/null +++ b/sx/sx/not-found.sx @@ -0,0 +1,20 @@ +;; 404 Not Found page content + +(defcomp ~not-found-content (&key path) + (div :class "max-w-3xl mx-auto px-4 py-12 text-center" + (h1 :style (cssx (:text (colour "stone" 800) (size "3xl") (weight "bold"))) + "404") + (p :class "mt-4" + :style (cssx (:text (colour "stone" 500) (size "lg"))) + "Page not found") + (when path + (p :class "mt-2" + :style (cssx (:text (colour "stone" 400) (size "sm") (family "mono"))) + path)) + (a :href "/" + :sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel" + :sx-swap "outerHTML" :sx-push-url "true" + :class "inline-block mt-6 px-4 py-2 rounded border transition-colors" + :style (cssx (:text (colour "violet" 700) (size "sm")) + (:border (colour "violet" 200))) + "Back to home"))) diff --git a/sx/sx/plans/art-dag-sx.sx b/sx/sx/plans/art-dag-sx.sx new file mode 100644 index 0000000..a033c5f --- /dev/null +++ b/sx/sx/plans/art-dag-sx.sx @@ -0,0 +1,127 @@ +;; --------------------------------------------------------------------------- +;; Art DAG on SX — SX endpoints as portals into media processing environments +;; --------------------------------------------------------------------------- + +(defcomp ~plan-art-dag-sx-content () + (~doc-page :title "Art DAG on SX" + + (p :class "text-stone-500 text-sm italic mb-8" + "An SX endpoint is a portal into an execution environment. The URL is the entry point; the boundary declaration determines what world you enter.") + + ;; ===================================================================== + ;; I. The endpoint as portal + ;; ===================================================================== + + (~doc-section :title "The endpoint as portal" :id "endpoint-as-portal" + (p "An SX endpoint isn't a static route handler. It's a portal into an execution environment. A blog endpoint has " (code "query-posts") ", " (code "render-markdown") ". An art-dag endpoint has " (code "fetch-recipe") ", " (code "resolve-cid") ", " (code "gpu-exec") ", " (code "encode-stream") ", " (code "open-feed") ". Same evaluator. Different primitives. Different capabilities.") + (p "The URL is the entry point. The boundary declaration determines what world you enter. When you hit " (code "/art/render/Qm...") ", the evaluator boots with media-processing primitives. When you hit " (code "/blog/post/hello") ", the evaluator boots with content primitives. The SX code looks the same either way " (em "- ") "function calls, let bindings, conditionals. But the set of primitives available changes everything about what the program can do.") + (p "This is the same principle as the browser/server split. The browser has " (code "render-to-dom") " and " (code "signal") ". The server has " (code "query-db") " and " (code "render-to-html") ". Neither is a restricted version of the other " (em "- ") "they are different environments with different capabilities. The art-dag environment is a third world: " (code "gpu-exec") ", " (code "resolve-cid") ", " (code "encode-stream") ". Same language. Different universe.")) + + ;; ===================================================================== + ;; II. Recipes as SX + ;; ===================================================================== + + (~doc-section :title "Recipes as SX" :id "recipes-as-sx" + (p "Art DAG recipes are already s-expression effect chains. Currently executed by L1 Celery workers via Python. The SX version: recipes " (em "are") " SX programs. They evaluate in an environment that has media processing primitives. A recipe doesn't \"call\" a GPU function " (em "- ") "it evaluates in an environment where " (code "gpu-exec") " is a primitive.") + (~doc-code :code (highlight ";; A recipe is an SX program in a media-processing environment\n(define composite-layers\n (fn (base-cid overlay-cid blend)\n (let ((base (resolve-cid base-cid))\n (overlay (resolve-cid overlay-cid)))\n (gpu-exec :op \"composite\"\n :layers (list base overlay)\n :blend blend\n :output :stream))))" "lisp")) + (p "This isn't a DSL embedded in Python. It's SX, the same language that renders pages and defines components. The recipe author uses " (code "let") ", " (code "fn") ", " (code "map") ", " (code "if") " " (em "- ") "the full language. The only difference is what primitives are available. " (code "resolve-cid") " fetches content-addressed data. " (code "gpu-exec") " dispatches GPU operations. These are primitives, not library calls. They exist in the environment the same way " (code "+") " and " (code "str") " exist.") + (p "The recipe is data. It's an s-expression. You can parse it, analyze it, transform it, serialize it, hash it, store it, transmit it. You can inspect a recipe's dependency graph the same way " (code "deps.sx") " inspects component dependencies. You can type-check a recipe the same way " (code "typed-sx") " type-checks components. The tools already exist. They just need a new set of primitives to reason about.")) + + ;; ===================================================================== + ;; III. Split execution + ;; ===================================================================== + + (~doc-section :title "Split execution" :id "split-execution" + (p "Some recipe steps run against cached data (fast, local). Others need GPU. Others need live input. The evaluator doesn't dispatch " (em "- ") "the boundary declarations do. " (code "(with-boundary (gpu-compute) ...)") " migrates to a GPU-capable host. " (code "(with-boundary (live-ingest) ...)") " opens WebRTC feeds. The recipe author doesn't manage infrastructure " (em "- ") "they declare capabilities, and execution flows to where they exist.") + (~doc-code :code (highlight "(define live-composite\n (fn (recipe-cid camera-count)\n (let ((recipe (resolve-cid recipe-cid)))\n ;; Phase 1: cached data (local, fast)\n (let ((base-layers (map resolve-cid (get recipe \"layers\"))))\n ;; Phase 2: GPU processing\n (with-boundary (gpu-compute)\n (let ((composed (gpu-exec :op \"composite\"\n :layers base-layers\n :blend (get recipe \"blend\"))))\n ;; Phase 3: live feeds\n (with-boundary (live-ingest)\n (let ((feeds (map (fn (i)\n (open-feed :protocol \"webrtc\"\n :label (str \"camera-\" i)))\n (range 0 camera-count))))\n ;; Phase 4: encode and stream\n (with-boundary (encoding)\n (encode-stream\n :sources (concat (list composed) feeds)\n :codec \"h264\"\n :output :stream))))))))))" "lisp")) + (p "Four phases. Four capability requirements. The program reads linearly " (em "- ") "resolve cached layers, composite on GPU, open live feeds, encode output. But execution migrates across hosts as needed. The " (code "with-boundary") " blocks are the seams. Everything inside a boundary block runs on a host that provides those capabilities. Everything outside runs wherever the program started.") + (p "This is the same mechanism described in the generative SX plan's environment migration section. " (code "with-boundary") " serializes the environment (" (code "env-snapshot") "), ships the pending expression to a capable host, and execution continues there. The recipe author writes a linear program. The runtime makes it distributed.")) + + ;; ===================================================================== + ;; IV. Content-addressed everything + ;; ===================================================================== + + (~doc-section :title "Content-addressed everything" :id "content-addressed" + (p "Recipes are CIDs. Effects are CIDs. Intermediate frames are content-addressed. The execution DAG is a content-addressed graph " (em "- ") "every step can be verified, cached, or replayed.") + (p "A composite of three layers with a specific blend mode always produces the same CID. Caching is automatic: if the CID exists locally, skip the computation. This is the Art DAG's existing model " (em "- ") "SHA3-256 hashes identify everything. SX makes it explicit: the recipe source itself is content-addressed. Two users who write the same recipe get the same CID. They're running the same program.") + (div :class "rounded border border-stone-200 bg-stone-50 p-4 my-4" + (p :class "text-stone-700 font-medium mb-2" "Content addressing as memoization") + (p :class "text-stone-600 text-sm" "Every function call with content-addressed inputs has a content-addressed output. " (code "(gpu-exec :op \"composite\" :layers (list CID-a CID-b) :blend \"multiply\")") " always produces the same result CID. The runtime can check: does this output CID exist? If yes, skip the computation. The entire execution DAG becomes a cache key. Rerunning a recipe that's already been computed is instantaneous " (em "- ") "every intermediate result already exists.")) + (p "The execution trace is also content-addressed. You can inspect exactly what happened: which CIDs were resolved, which GPU operations ran, which feeds were opened, what the final output was. The trace is the recipe's proof of work. It's immutable, verifiable, and shareable.")) + + ;; ===================================================================== + ;; V. Feed generation + ;; ===================================================================== + + (~doc-section :title "Feed generation" :id "feed-generation" + (p "The executing program creates new endpoints as a side effect. " (code "(open-feed ...)") " doesn't return data " (em "- ") "it returns a connectable endpoint. WebRTC peers, SSE streams, WebSocket channels. These are generative acts: the endpoint didn't exist before the recipe ran. The program grew its own input surface.") + (p "When the recipe completes or the island disposes, feeds are cleaned up via the disposal mechanism. An " (code "effect") " in island scope that opens a feed returns a cleanup function. When the island unmounts, the cleanup runs, the feed closes, the endpoint disappears. The lifecycle is automatic.") + (~doc-code :code (highlight ";; A feed is a connectable endpoint, not raw data\n(define create-camera-mosaic\n (fn (camera-ids)\n ;; Each open-feed returns a connectable URL, not bytes\n (let ((feeds (map (fn (id)\n (open-feed :protocol \"webrtc\"\n :label (str \"cam-\" id)\n :quality \"720p\"))\n camera-ids)))\n ;; The mosaic recipe composes feeds as inputs\n (gpu-exec :op \"mosaic\"\n :inputs feeds\n :layout \"grid\"\n :output (open-feed :protocol \"sse\"\n :label \"mosaic-output\"\n :format \"mjpeg\")))))" "lisp")) + (p "The output is itself a feed. A client connects to the mosaic output URL and receives composed frames. The feeds are the program's I/O surface " (em "- ") "they exist because the program created them, and they die when the program stops. No static route configuration. No service mesh. The program declares what it needs and creates what it produces.")) + + ;; ===================================================================== + ;; VI. The client boundary + ;; ===================================================================== + + (~doc-section :title "The client boundary" :id "client-boundary" + (p "The browser is just another execution environment with its own primitive set: " (code "render-to-dom") ", " (code "signal") ", " (code "deref") ", " (code "open-feed") " (as WebRTC consumer). A streaming art-dag response arrives as SX wire format. The client evaluates it in island scope " (em "- ") "signals bind to stream frames, " (code "reactive-list") " renders feed thumbnails, " (code "computed") " derives overlay parameters. The server pushes frames; the client renders them reactively. No special video player " (em "- ") "just signals and DOM.") + (~doc-code :code (highlight "(defisland ~live-canvas ()\n (let ((frames (signal nil))\n (feed-url (signal nil)))\n ;; Connect to stream when URL arrives\n (effect (fn ()\n (when (deref feed-url)\n (connect-stream (deref feed-url)\n :on-frame (fn (f) (reset! frames f))))))\n (div :class \"relative aspect-video bg-black rounded\"\n (when (deref frames)\n (canvas :width 1920 :height 1080\n :draw (fn (ctx)\n (draw-frame ctx (deref frames)))))\n (when (not (deref frames))\n (p :class \"absolute inset-0 flex items-center justify-center text-white/50\"\n \"Waiting for stream...\")))))" "lisp")) + (p "The island is reactive. When " (code "frames") " updates, only the canvas redraws. When " (code "feed-url") " updates, the effect reconnects. No polling loop. No WebSocket message handler parsing JSON. The stream is a signal source. The DOM is a signal consumer. The reactive graph connects them.") + (p "This is the same island architecture from the reactive islands plan " (em "- ") "signals, effects, computed, disposal. The only difference is the data source. Instead of an HTMX response mutating a signal, a WebRTC stream mutates a signal. The rendering pipeline doesn't know or care where the data comes from. It reacts.")) + + ;; ===================================================================== + ;; VII. L1/L2 integration + ;; ===================================================================== + + (~doc-section :title "L1/L2 integration" :id "l1-l2" + (p "L1 is the compute layer (Celery workers, GPU nodes). L2 is the registry (ActivityPub, recipe discovery). In SX terms: L1 hosts provide " (code "gpu-exec") ", " (code "encode-stream") ", " (code "resolve-cid-local") ". L2 hosts provide " (code "discover-recipe") ", " (code "publish-recipe") ", " (code "federate-activity") ".") + (p "An SX program that needs both crosses boundaries as needed " (em "- ") "fetch recipe metadata from L2, execute it on L1, publish results back to L2.") + (~doc-code :code (highlight ";; A full pipeline crossing L1 and L2 boundaries\n(define render-and-publish\n (fn (recipe-name output-label)\n ;; L2: discover the recipe\n (with-boundary (registry)\n (let ((recipe-cid (discover-recipe :name recipe-name\n :version \"latest\")))\n ;; L1: execute the recipe\n (with-boundary (gpu-compute)\n (let ((result-cid (gpu-exec :recipe (resolve-cid recipe-cid)\n :output :cid)))\n ;; L2: publish the result\n (with-boundary (registry)\n (publish-recipe\n :name output-label\n :input-cid recipe-cid\n :output-cid result-cid\n :activity \"Create\"))))))))" "lisp")) + (p "Three boundary crossings. L2 to find the recipe. L1 to execute it. L2 to publish the result. The program reads as a linear sequence of operations. The runtime handles the dispatch " (em "- ") "which host provides " (code "discover-recipe") ", which host provides " (code "gpu-exec") ", which host provides " (code "publish-recipe") ". The program author doesn't configure endpoints or manage connections. They declare capabilities.")) + + ;; ===================================================================== + ;; VIII. The primitive sets + ;; ===================================================================== + + (~doc-section :title "The primitive sets" :id "primitive-sets" + (p "Each execution environment provides its own set of primitives. The language is the same everywhere. The capabilities differ.") + (div :class "overflow-x-auto rounded border border-stone-200 mb-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Environment") + (th :class "px-3 py-2 font-medium text-stone-600" "Primitives") + (th :class "px-3 py-2 font-medium text-stone-600" "Runs on"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "Browser") + (td :class "px-3 py-2 font-mono text-xs text-stone-600" "render-to-dom, signal, deref, connect-stream") + (td :class "px-3 py-2 text-stone-600" "Client")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "App server") + (td :class "px-3 py-2 font-mono text-xs text-stone-600" "query-db, render-to-html, fetch-fragment") + (td :class "px-3 py-2 text-stone-600" "Quart service")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "L1 Worker") + (td :class "px-3 py-2 font-mono text-xs text-stone-600" "gpu-exec, resolve-cid, encode-stream, cache-put") + (td :class "px-3 py-2 text-stone-600" "Celery + GPU")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "L2 Registry") + (td :class "px-3 py-2 font-mono text-xs text-stone-600" "discover-recipe, publish-recipe, federate") + (td :class "px-3 py-2 text-stone-600" "FastAPI")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "Live Ingest") + (td :class "px-3 py-2 font-mono text-xs text-stone-600" "open-feed, capture-frame, transcode") + (td :class "px-3 py-2 text-stone-600" "WebRTC gateway")) + (tr + (td :class "px-3 py-2 font-medium text-stone-700" "IPFS Node") + (td :class "px-3 py-2 font-mono text-xs text-stone-600" "pin-cid, resolve-cid, dag-put, dag-get") + (td :class "px-3 py-2 text-stone-600" "Kubo"))))) + + (p "A pure SX program (no IO primitives) runs on all six. A program that calls " (code "gpu-exec") " runs on L1 workers. A program that calls " (code "render-to-dom") " runs in the browser. The boundary declaration is the type signature of the environment. It tells you where the program can execute.") + (p "Adding a new environment means declaring a new primitive set. A hypothetical audio-processing environment would provide " (code "mix-tracks") ", " (code "apply-effect") ", " (code "encode-audio") ". A program that uses those primitives runs wherever that environment is hosted. The language doesn't change. The evaluator doesn't change. Only the available primitives change.") + + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (p :class "text-violet-900 font-medium" "One language, many worlds") + (p :class "text-violet-800 text-sm" "The art-dag integration isn't a new feature bolted onto SX. It's a demonstration of what SX already is: a language where the execution environment is parameterized by its primitive set. The browser, the app server, the GPU worker, and the IPFS node all run the same evaluator. They differ only in what primitives they provide. The art-dag is just another world you can enter through an endpoint."))) + + )) diff --git a/sx/sx/plans/async-eval-convergence.sx b/sx/sx/plans/async-eval-convergence.sx new file mode 100644 index 0000000..8630f5f --- /dev/null +++ b/sx/sx/plans/async-eval-convergence.sx @@ -0,0 +1,123 @@ +;; --------------------------------------------------------------------------- +;; Async Evaluator Convergence — Bootstrap async_eval.py from Spec +;; --------------------------------------------------------------------------- + +(defcomp ~plan-async-eval-convergence-content () + (~doc-page :title "Async Evaluator Convergence" + + (~doc-section :title "The Problem" :id "problem" + (p "There are currently " (strong "three") " lambda call implementations that must be kept in sync:") + (ol :class "list-decimal list-inside space-y-2 mt-2" + (li (code "shared/sx/ref/eval.sx") " — the canonical spec, bootstrapped to " (code "sx-ref.js") " and " (code "sx_ref.py")) + (li (code "shared/sx/evaluator.py") " — hand-written synchronous Python evaluator") + (li (code "shared/sx/async_eval.py") " — hand-written asynchronous Python evaluator (the production server path)")) + (p "Every semantic change to the evaluator — lenient lambda arity, new special forms, calling convention tweaks — must be applied to all three. The spec is authoritative but " (code "async_eval.py") " is what actually serves pages. This is a maintenance hazard and a source of subtle divergence bugs.") + (p "The lenient arity change (lambda params pad missing args with nil instead of erroring) exposed this: the spec and sync evaluator were updated, but " (code "async_eval.py") " still had strict arity checking, causing production crashes.")) + + (~doc-section :title "Why async_eval.py Exists" :id "why" + (p "The async evaluator exists because SX page rendering needs to:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "Await IO primitives") " — page helpers like " (code "highlight") ", " (code "reference-data") ", " (code "component-source") " call Python async functions (DB queries, HTTP fetches). The spec evaluator is synchronous.") + (li (strong "Expand server-affinity components") " — " (code ":affinity :server") " components must be fully expanded server-side before serialising to SX wire format. This requires async eval of the component body.") + (li (strong "Handle the aser rendering mode") " — the " (code "_aser") " function evaluates control flow server-side but serialises HTML tags and component calls as SX source for the client. This hybrid eval/serialize mode isn't in the spec.")) + (p "None of these require " (em "different") " evaluation semantics — they require the " (em "same") " semantics with async IO at the boundary.")) + + ;; ----------------------------------------------------------------------- + ;; Architecture + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Target Architecture" :id "architecture" + (p "The goal is to " (strong "eliminate hand-written evaluator code entirely") ". All evaluation semantics come from the spec via bootstrapping. The host provides only:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "Platform primitives") " — type constructors, env operations, DOM/HTML primitives") + (li (strong "Async IO bridge") " — a thin wrapper that makes the bootstrapped evaluator await-compatible") + (li (strong "Rendering modes") " — aser/render-to-html dispatch, already mostly specced in " (code "render.sx"))) + (p "The bootstrapped " (code "sx_ref.py") " already has correct eval semantics. The question is how to make it async-aware without forking the spec.")) + + ;; ----------------------------------------------------------------------- + ;; Approach: Async Adapter Layer + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Approach: Async Adapter Layer" :id "approach" + (p "Rather than making the spec itself async (which would pollute it with Python-specific concerns), introduce a thin adapter layer between the bootstrapped evaluator and the IO boundary:") + + (h4 :class "font-semibold mt-4 mb-2" "Phase 1 — Async call hook") + (p "The bootstrapped evaluator calls primitives via " (code "apply(fn, args)") ". In the Python host, " (code "apply") " is a platform primitive. Replace it with an async-aware version:") + (~doc-code :code (highlight "(define apply-fn\n (fn (f args)\n ;; Platform provides: if f returns a coroutine, await it\n (apply-maybe-async f args)))" "lisp")) + (p "The bootstrapper emits " (code "apply_maybe_async") " as a Python " (code "async def") " that checks if the result is a coroutine and awaits it if so. Pure functions return immediately. IO primitives return coroutines that get awaited. " (strong "Zero overhead for pure calls") " — just an " (code "isinstance") " check.") + + (h4 :class "font-semibold mt-4 mb-2" "Phase 2 — Async trampoline") + (p "The spec's trampoline loop resolves thunks synchronously. The Python bootstrapper emits an " (code "async def trampoline") " variant that can await thunks whose bodies contain IO calls. The trampoline structure is identical — only the " (code "await") " keyword is added.") + (~doc-code :code (highlight "# Bootstrapper emits this for Python async target\nasync def trampoline(val):\n while isinstance(val, Thunk):\n val = await eval_expr(val.expr, val.env)\n return val" "python")) + + (h4 :class "font-semibold mt-4 mb-2" "Phase 3 — Aser as spec module") + (p "The " (code "_aser") " rendering mode (evaluate control flow, serialize HTML/components as SX source) should be specced as a module in " (code "render.sx") " alongside " (code "render-to-html") " and " (code "render-to-dom") ". It's currently hand-written Python because it predates the spec, but its logic is pure SX: walk the AST, eval certain forms, serialize others.") + (p "Once aser is specced, the bootstrapper emits it with the same async adapter — IO calls within aser bodies get awaited transparently.") + + (h4 :class "font-semibold mt-4 mb-2" "Phase 4 — Delete hand-written evaluators") + (p "With the async adapter + specced aser, " (code "evaluator.py") " and " (code "async_eval.py") " become dead code. Delete them. All evaluation flows through the bootstrapped " (code "sx_ref.py") " with async adapter.")) + + ;; ----------------------------------------------------------------------- + ;; What changes in the bootstrapper + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Bootstrapper Changes" :id "bootstrapper" + (p "The Python bootstrapper (" (code "bootstrap_py.py") ") gains a new emit mode: " (code "--async") ". This emits:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (code "async def eval_expr") " instead of " (code "def eval_expr")) + (li (code "async def trampoline") " with " (code "await") " on thunk eval") + (li (code "apply_maybe_async") " that awaits coroutine results") + (li "All higher-order forms (" (code "map") ", " (code "filter") ", " (code "reduce") ", etc.) as " (code "async def") " with " (code "await") " on callback invocations")) + (p "The JS bootstrapper is unaffected — the browser evaluator is synchronous (IO is handled by the SxEngine request pipeline, not inline eval).")) + + ;; ----------------------------------------------------------------------- + ;; Migration path + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Migration Path" :id "migration" + (div :class "overflow-x-auto rounded border border-stone-200 mb-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Step") + (th :class "px-3 py-2 font-medium text-stone-600" "What") + (th :class "px-3 py-2 font-medium text-stone-600" "Risk"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "1") + (td :class "px-3 py-2 text-stone-700" "Add async emit mode to bootstrap_py.py. Generate async_sx_ref.py alongside sx_ref.py.") + (td :class "px-3 py-2 text-stone-600" "Low — new file, nothing changes yet")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "2") + (td :class "px-3 py-2 text-stone-700" "Run async_sx_ref.py in parallel with async_eval.py, compare outputs on every page render.") + (td :class "px-3 py-2 text-stone-600" "Low — shadow mode, no user impact")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "3") + (td :class "px-3 py-2 text-stone-700" "Spec aser in render.sx. Bootstrap it. Shadow-compare with hand-written _aser.") + (td :class "px-3 py-2 text-stone-600" "Medium — aser has edge cases around OOB, fragments")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "4") + (td :class "px-3 py-2 text-stone-700" "Switch page rendering to async_sx_ref.py. Keep async_eval.py as fallback.") + (td :class "px-3 py-2 text-stone-600" "Medium — production path changes")) + (tr + (td :class "px-3 py-2 text-stone-700" "5") + (td :class "px-3 py-2 text-stone-700" "Delete evaluator.py and async_eval.py.") + (td :class "px-3 py-2 text-stone-600" "Low — once shadow confirms parity")))))) + + ;; ----------------------------------------------------------------------- + ;; Principles + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Principles" :id "principles" + (ul :class "list-disc list-inside space-y-2" + (li (strong "The spec is the single source of truth.") " All SX evaluation semantics live in .sx files. Host code implements platform primitives, not evaluation rules.") + (li (strong "Async is a host concern, not a language concern.") " The spec is synchronous. The Python bootstrapper emits async wrappers. The JS bootstrapper emits sync code. The spec doesn't know or care.") + (li (strong "Shadow-compare before switching.") " Every migration step runs both paths in parallel and asserts identical output. No big-bang cutover.") + (li (strong "Aser is just another render mode.") " It belongs in render.sx alongside render-to-html and render-to-dom. It's not special — it's the 'evaluate some, serialize the rest' mode."))) + + (~doc-section :title "Outcome" :id "outcome" + (p "After convergence:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li "One evaluator implementation (the spec), bootstrapped to every host") + (li "Semantic changes made once in .sx, automatically propagated") + (li "~2,000 lines of hand-written Python evaluator code deleted") + (li "The lenient-arity class of bug becomes impossible — it's fixed in the spec, done everywhere"))))) diff --git a/sx/sx/plans/generative-sx.sx b/sx/sx/plans/generative-sx.sx new file mode 100644 index 0000000..f02e4b0 --- /dev/null +++ b/sx/sx/plans/generative-sx.sx @@ -0,0 +1,337 @@ +;; --------------------------------------------------------------------------- +;; Generative SX — programs that write themselves as they run +;; --------------------------------------------------------------------------- + +(defcomp ~plan-generative-sx-content () + (~doc-page :title "Generative SX" + + (p :class "text-stone-500 text-sm italic mb-8" + "In the browser, SX is a program that modifies itself in response to external stimuli. Outside the browser, it becomes a program that writes itself as it runs.") + + ;; ===================================================================== + ;; I. The observation + ;; ===================================================================== + + (~doc-section :title "The observation" :id "observation" + (p "The marshes work made something visible. A server response arrives carrying " (code "(reset! (use-store \"price\") 14.99)") " inside a " (code "data-init") " script. The SX evaluator parses this string, evaluates it in its own environment, and mutates its own signal graph. The program accepted new source at runtime and changed itself.") + (p "This isn't metaprogramming. Macros expand at compile time — they transform source before evaluation. This is different: the program is " (em "already running") " when it receives new code, evaluates it, and continues with an extended state. The DOM is just the boundary. The signal graph is just the state. The mechanism is general:") + (ol :class "list-decimal list-inside space-y-2 text-stone-600" + (li "A running SX evaluator with an environment") + (li "New SX source arrives (from any external source)") + (li "The evaluator parses it and evaluates it " (em "in its own environment")) + (li "The environment grows — new definitions, new state, new capabilities") + (li "Evaluation continues with the extended environment")) + (p "In the browser, step 2 is an HTTP response. Step 5 is the next render cycle. But nothing about this mechanism requires a browser, a DOM, or HTTP. The evaluator doesn't know where the source came from. It just evaluates.")) + + ;; ===================================================================== + ;; II. What already exists + ;; ===================================================================== + + (~doc-section :title "What already exists" :id "exists" + (p "The pieces are already built. They just haven't been connected into the generative pattern.") + + (~doc-subsection :title "Homoiconicity" + (p "SX code is SX data. " (code "parse") " takes a string and returns a list. " (code "aser") " takes a list and returns a string. These round-trip perfectly. The program can read its own source as naturally as it reads a config file, because they're the same format.") + (~doc-code :code (highlight ";; Code is data\n(define source \"(+ 1 2)\")\n(define ast (parse source)) ;; → (list '+ 1 2)\n(define result (eval-expr ast env)) ;; → 3\n\n;; Data is code\n(define spec '(define greet (fn (name) (str \"Hello, \" name \"!\"))))\n(eval-expr spec env)\n(greet \"world\") ;; → \"Hello, world!\"" "lisp"))) + + (~doc-subsection :title "Runtime eval" + (p (code "eval-expr") " is available at runtime, not just boot. " (code "data-init") " scripts already use it. Any SX string can become running code at any point in the program's execution. This is not " (code "eval()") " bolted onto a language that doesn't want it — it's the " (em "primary mechanism") " of the language.")) + + (~doc-subsection :title "The environment model" + (p (code "env-extend") " creates a child scope. " (code "env-set!") " adds to the current scope. " (code "define") " creates new bindings. New definitions don't replace old ones — they extend the environment. The program grows monotonically. Every previous state is still reachable through scope chains.") + (p "This is the critical property. A generative program doesn't destroy what it was — it " (em "becomes more") ". Each generation includes everything before it plus what it just wrote.")) + + (~doc-subsection :title "The bootstrapper" + (p "The bootstrapper reads " (code "eval.sx") " — the evaluator's definition of itself — and emits JavaScript or Python that " (em "is") " that evaluator. The spec writes itself into a host language. This is already a generative program, frozen at build time: read source → transform → emit target. Generative SX unfreezes this. The transformation happens " (em "while the program runs") ", not before.")) + + (~doc-subsection :title "Content-addressed identity" + (p "From the Art DAG: all data identified by SHA3-256 hashes. If a program fragment is identified by its hash, then \"writing yourself\" means producing new hashes. The history is immutable. You can always go back. A generative program isn't a mutating blob — it's a DAG of versioned states."))) + + ;; ===================================================================== + ;; III. The generative pattern + ;; ===================================================================== + + (~doc-section :title "The generative pattern" :id "pattern" + (p "A generative SX program starts with a seed and grows by evaluating its own output.") + + (~doc-code :code (highlight ";; The core loop\n(define run\n (fn (env source)\n (let ((ast (parse source))\n (result (eval-expr ast env)))\n ;; result might be:\n ;; a value → done, return it\n ;; a string → new SX source, evaluate it (grow)\n ;; a list of defs → new definitions to add to env\n ;; a dict → {source: \"...\", env-patch: {...}} (grow + configure)\n (cond\n (string? result)\n (run env result) ;; evaluate the output\n (and (dict? result) (has-key? result \"source\"))\n (let ((patched (env-merge env (get result \"env-patch\"))))\n (run patched (get result \"source\")))\n :else\n result))))" "lisp")) + + (p "The program evaluates source. If the result is more source, it evaluates that too. Each iteration can extend the environment — add new functions, new macros, new primitives. The environment grows. The program becomes capable of things it couldn't do at the start.") + + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (p :class "text-violet-900 font-medium" "This is not eval-in-a-loop") + (p :class "text-violet-800 text-sm" "A REPL evaluates user input in a persistent environment. That's interactive, not generative. The generative pattern is different: the program itself decides what to evaluate next. No user in the loop. The output of one evaluation becomes the input to the next. The program writes itself.")) + + (~doc-subsection :title "Three modes" + (div :class "overflow-x-auto rounded border border-stone-200 mb-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Mode") + (th :class "px-3 py-2 font-medium text-stone-600" "Input") + (th :class "px-3 py-2 font-medium text-stone-600" "Output") + (th :class "px-3 py-2 font-medium text-stone-600" "Growth"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "Analytic") + (td :class "px-3 py-2 text-stone-600" "SX program + data") + (td :class "px-3 py-2 text-stone-600" "Analysis results") + (td :class "px-3 py-2 text-stone-600" "None — pure function")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-medium text-stone-700" "Synthetic") + (td :class "px-3 py-2 text-stone-600" "SX program") + (td :class "px-3 py-2 text-stone-600" "New SX source") + (td :class "px-3 py-2 text-stone-600" "Code generation — new defs emitted")) + (tr + (td :class "px-3 py-2 font-medium text-stone-700" "Generative") + (td :class "px-3 py-2 text-stone-600" "SX seed") + (td :class "px-3 py-2 text-stone-600" "Running program") + (td :class "px-3 py-2 text-stone-600" "Self-extending — output evaluated as input"))))))) + + ;; ===================================================================== + ;; IV. Concrete manifestations + ;; ===================================================================== + + (~doc-section :title "Concrete manifestations" :id "manifestations" + + (~doc-subsection :title "1. The spec that compiles itself" + (p "Currently: " (code "bootstrap_js.py") " (Python) reads " (code "eval.sx") " (SX) and emits " (code "sx-browser.js") " (JavaScript). Three languages. Two of them are hosts, one is the spec.") + (p "Generative version: " (code "eval.sx") " evaluates itself with a code-generation adapter. The evaluator walks its own AST and emits the target language. No Python bootstrapper. No JavaScript template. The spec " (em "is") " the compiler.") + (~doc-code :code (highlight ";; bootstrap.sx — the spec compiles itself\n;;\n;; Load the codegen adapter for the target\n(define emit (load-adapter target)) ;; target = \"js\" | \"py\" | \"go\" | ...\n;;\n;; Read the spec files\n(define spec-source (read-file \"eval.sx\"))\n(define spec-ast (parse spec-source))\n;;\n;; Walk the AST and emit target code\n;; The walker IS the evaluator — eval.sx evaluating eval.sx\n;; with emit instead of execute\n(define target-code\n (eval-expr spec-ast\n (env-extend codegen-env\n ;; Override define, fn, if, etc. to emit instead of execute\n (codegen-special-forms emit))))\n;;\n(write-file (str \"sx-ref.\" (target-extension target)) target-code)" "lisp")) + (p "This is the bootstrapper rewritten as a generative program. The spec reads itself, walks itself, and writes the output language. Adding a new target means writing a new " (code "load-adapter") " — a set of emitters for the SX special forms. The walker doesn't change. The spec doesn't change. Only the output format changes.") + (p "The current bootstrappers (" (code "bootstrap_js.py") ", " (code "bootstrap_py.py") ") would become the first two adapters. Future targets (Go, Rust, WASM) are additional adapters, written in SX and bootstrapped like everything else.")) + + (~doc-subsection :title "2. The program that discovers its dependencies" + (p (code "deps.sx") " analyzes component dependency graphs. It walks component ASTs, finds " (code "~name") " references, computes transitive closures. This is the analytic mode — SX analyzing SX.") + (p "The generative version: a program that discovers it needs a component, searches for its definition (local files, IPFS, a registry), loads it, evaluates it, and continues. The program grows its own capability set at runtime.") + (~doc-code :code (highlight ";; A program that discovers and loads what it needs\n(define render-page\n (fn (page-name)\n (let ((page-def (lookup-component page-name)))\n (when (nil? page-def)\n ;; Component not found locally — fetch from registry\n (let ((source (fetch-component-source page-name)))\n ;; Evaluate the definition — it joins the environment\n (eval-expr (parse source) env)\n ;; Now it exists\n (set! page-def (lookup-component page-name))))\n ;; Render with all dependencies resolved\n (render-to-html (list page-def)))))" "lisp")) + (p "This already happens in the browser. " (code "sx_response") " prepends missing component definitions as a " (code "data-components") " script block. The client evaluates them and they join the environment. The generative version makes this explicit: the program tells you what it needs, you give it source, it evaluates it, it grows.")) + + (~doc-subsection :title "3. The test suite that writes tests" + (p "Given a function's signature and a set of properties (" (code "prove.sx") " already has the property language), generate test cases that verify the properties. The program reads its own function definitions, generates SX expressions that test them, and evaluates those expressions.") + (~doc-code :code (highlight ";; Given: a function and properties about it\n(define-property string-reverse-involutory\n :forall ((s string?))\n :holds (= (reverse (reverse s)) s))\n\n;; Generate: test cases from the property\n;; The program reads the property, generates test source, evals it\n(define tests (generate-tests string-reverse-involutory))\n;; tests = list of (assert (= (reverse (reverse \"hello\")) \"hello\"))\n;; (assert (= (reverse (reverse \"\")) \"\"))\n;; (assert (= (reverse (reverse \"a\")) \"a\"))\n;; ... (random strings, edge cases)\n(for-each (fn (t) (eval-expr t env)) tests)" "lisp")) + (p "The program analyzed itself (read the property), generated new SX (the test cases), and evaluated it (ran the tests). Three modes — analytic, synthetic, generative — in sequence.")) + + (~doc-subsection :title "4. The server that extends its own API" + (p "An SX server receives a request it doesn't know how to handle. Instead of returning 404, it examines the request, generates a handler, evaluates it, and handles the request.") + (~doc-code :code (highlight ";; A route handler that generates new route handlers\n(define handle-unknown-route\n (fn (path params)\n ;; Analyze what was requested\n (let ((segments (split path \"/\"))\n (resource (nth segments 1))\n (action (nth segments 2)))\n ;; Check if a schema exists for this resource\n (let ((schema (lookup-schema resource)))\n (when schema\n ;; Generate a CRUD handler from the schema\n (let ((handler-source (generate-crud-handler resource action schema)))\n ;; Evaluate it — the handler now exists\n (eval-expr (parse handler-source) env)\n ;; Route future requests to the generated handler\n (register-route path (env-get env (str resource \"-\" action)))\n ;; Handle this request with the new handler\n ((env-get env (str resource \"-\" action)) params)))))))" "lisp")) + (p "This is not code generation in the Rails scaffolding sense — those generate files you then edit. This generates running code at runtime. The handler didn't exist. Now it does. The server grew.")) + + (~doc-subsection :title "5. The macro system that learns idioms" + (p "A generative macro system that detects repeated patterns in code and synthesizes macros to capture them. The program watches itself being written and abstracts its own patterns.") + (~doc-code :code (highlight ";; The program notices this pattern appearing repeatedly:\n;; (div :class \"card\" (h2 title) (p body) children...)\n;;\n;; It generates:\n(defmacro ~card (title body &rest children)\n (div :class \"card\"\n (h2 ,title)\n (p ,body)\n ,@children))\n;;\n;; And rewrites its own source to use the new macro.\n;; This is an SX program that:\n;; 1. Analyzed its own AST (found repeated subtrees)\n;; 2. Synthesized a macro (extracted the pattern)\n;; 3. Evaluated the macro definition (extended env)\n;; 4. Rewrote its own source (used the macro)\n;; Four generative steps." "lisp")) + (p "The connection to the Art DAG: each version of the source is content-addressed. The original (before macros) and the refactored (after macros) are both immutable nodes. The generative step is an edge in the DAG. You can always inspect what the program was before it rewrote itself."))) + + ;; ===================================================================== + ;; V. The seed + ;; ===================================================================== + + (~doc-section :title "The seed" :id "seed" + (p "A generative SX program starts with a seed. The seed must contain enough to bootstrap the generative loop: a parser, an evaluator, and the " (code "run") " function. Everything else is grown.") + + (~doc-code :code (highlight ";; seed.sx — the minimal generative program\n;;\n;; This file contains:\n;; - The SX parser (parse)\n;; - The SX evaluator (eval-expr)\n;; - The generative loop (run)\n;; - A source acquisition function (next-source)\n;;\n;; Everything else — primitives, rendering, networking, persistence —\n;; is loaded by the program as it discovers it needs them.\n\n(define run\n (fn (env)\n (let ((source (next-source env)))\n (when source\n (let ((result (eval-expr (parse source) env)))\n (run env))))))\n\n;; Start with a bare environment\n(run (env-extend (dict\n \"parse\" parse\n \"eval-expr\" eval-expr\n \"next-source\" initial-source-fn)))" "lisp")) + + (p "The seed is a quine that doesn't just reproduce itself — it " (em "extends") " itself. Each call to " (code "next-source") " returns new SX that the seed evaluates in its own environment. The environment grows. The seed's capabilities grow. But the seed itself never changes — it's the fixed point of the generative process.") + + (div :class "rounded border border-stone-200 bg-stone-50 p-4 my-4" + (p :class "text-stone-700 font-medium mb-2" "The minimal seed is the spec") + (p :class "text-stone-600 text-sm" (code "eval.sx") " + " (code "parser.sx") " + " (code "primitives.sx") " = a complete SX evaluator defined in SX. This is already the seed. The bootstrappers compile it to JavaScript and Python. A generative runtime compiles it to " (em "itself") " — the seed evaluates the seed and obtains a running evaluator that can evaluate anything, including more of itself."))) + + ;; ===================================================================== + ;; VI. Growth constraints + ;; ===================================================================== + + (~doc-section :title "Growth constraints" :id "constraints" + (p "Unconstrained self-modification is dangerous. A program that can rewrite any part of itself can rewrite its own safety checks. Generative SX needs growth constraints — rules about what the program can and cannot do to itself.") + + (~doc-subsection :title "The boundary" + (p "The boundary system (" (code "boundary.sx") ") already enforces this. Pure primitives can't do IO. IO primitives can't escape their declared capabilities. Components are classified as pure or IO-dependent. The boundary is checked at registration time — " (code "SX_BOUNDARY_STRICT=1") " means violations crash at startup.") + (p "For generative programs, the boundary extends: generated code is subject to the same constraints as hand-written code. A generative program can't synthesize an IO primitive — it can only compose existing ones. It can't bypass the boundary by generating code that accesses raw platform APIs. The sandbox applies to generated code exactly as it applies to original code.") + (p "This is the key safety property: " (strong "generative SX is sandboxed generative SX") ". The generated code runs in the same evaluator with the same restrictions. No escape hatches.")) + + (~doc-subsection :title "Content addressing as audit trail" + (p "Every piece of generated code is content-addressed. The SHA3-256 hash of the generated source is its identity. You can trace any piece of running code back to the generation step that produced it, the input that triggered that step, and the state of the environment at that point.") + (p "This makes generative programs auditable. \"Where did this function come from?\" has a definite answer: it was generated by " (em "this") " code, from " (em "this") " input, at " (em "this") " point in the generative sequence. The DAG of content hashes is the program's autobiography.")) + + (~doc-subsection :title "Monotonic growth" + (p "The environment model is append-only. " (code "define") " creates new bindings; it doesn't destroy old ones (inner scopes shadow, but the outer binding persists). " (code "env-extend") " creates a child — the parent is immutable.") + (p "A generative program can extend its environment but cannot shrink it. It can add new functions but cannot delete existing ones. It can shadow a function with a new definition but cannot destroy the original. This means the program's history is preserved in its scope chain — you can always inspect what it was before any given generation step.") + (p "Destructive operations (" (code "set!") ") are confined to mutable cells explicitly created for that purpose. The generative loop itself operates on immutable environments extended with each step."))) + + ;; ===================================================================== + ;; VII. Host properties + ;; ===================================================================== + + (~doc-section :title "Host properties" :id "host-properties" + (p "A generative SX program runs on a host — JavaScript, Python, Go, bare metal. The host must provide specific properties or the generative loop breaks. These aren't preferences. They're " (em "requirements") ". A host that violates any of them can't run generative SX correctly.") + + (~doc-subsection :title "Lossless parse/serialize round-trip" + (p "The host must implement " (code "parse") " and " (code "aser") " such that " (code "(aser (parse source))") " produces semantically equivalent source. Generated code passes through " (code "parse → transform → serialize → parse") " cycles. If the round-trip is lossy — if whitespace, keyword order, or nested structure is corrupted — the generative loop silently degrades. After enough iterations, the program isn't what it thinks it is.") + (p "This is homoiconicity at the implementation level, not just the language level. The host's data structures must faithfully represent the full AST, and the serializer must faithfully reproduce it.")) + + (~doc-subsection :title "Runtime eval with first-class environments" + (p (code "eval-in") " requires evaluating arbitrary expressions in arbitrary environments at runtime. The host must support:") + (ul :class "list-disc pl-5 space-y-1 text-stone-600" + (li "Creating new environments (" (code "env-extend") ")") + (li "Adding bindings to existing environments (" (code "env-set!") ")") + (li "Inspecting environment contents (" (code "env-snapshot") ")") + (li "Passing environments as values — storing them in variables, returning them from functions")) + (p "Environments aren't implementation detail in a generative program. They're the " (em "state") ". The running environment at generation step N is the complete description of what the program knows. The host must treat environments as first-class values, not hidden interpreter internals.")) + + (~doc-subsection :title "Monotonic environment growth" + (p "A generative program that can " (code "undefine") " things becomes unpredictable. If generation step N+1 removes a function that step N defined, step N+2 might reference the missing function and fail — or worse, silently bind to a different function in an outer scope.") + (p "The host must enforce that environments grow monotonically. New bindings append. Existing bindings in a given scope are immutable once set (or explicitly versioned). " (code "env-extend") " creates children; it never mutates the parent. This makes the generative loop convergent — each step strictly increases the program's capabilities, never decreases them.")) + + (~doc-subsection :title "Content-addressed storage" + (p "Every generated fragment gets a SHA3-256 identity. The host needs native or near-native hashing and a content-addressed store — an in-memory dict at minimum, IPFS at scale. This provides the audit trail: you can always answer \"where did this code come from?\" by walking the hash chain back to the generation step that produced it.") + (p "Without content addressing, generative programs are opaque. You can't diff two versions of a generated function. You can't roll back to a previous generation. You can't verify that two nodes in a seed network generated the same code from the same input. Content addressing makes the generative process " (em "inspectable") ".")) + + (~doc-subsection :title "Boundary enforcement on generated code" + (p "Generated code must pass through the same boundary validation as hand-written code. If " (code "write-file") " is a Tier 2 IO primitive, a generated expression can't call it unless the evaluation context permits Tier 2.") + (p "The host must enforce this " (em "at eval time") ", not just at definition time — because generated code didn't exist at definition time. Every call to " (code "eval-in") " must check the boundary. Every primitive invoked by generated code must verify its tier. There is no \"trusted generated code\" — all code is untrusted until the boundary clears it.")) + + (~doc-subsection :title "Correct quotation and splicing" + (p "Quasiquote (" (code "`") "), unquote (" (code ",") "), and unquote-splicing (" (code ",@") ") must work correctly for programmatic code construction. The host needs these as first-class operations, not string concatenation.") + (p "A generative program builds code by template:") + (~doc-code :code (highlight ";; The generative program builds new definitions by template\n(define gen-handler\n (fn (name params body)\n `(define ,name\n (fn ,params\n ,@body))))\n\n;; gen-handler produces an AST, not a string\n;; The AST can be inspected, transformed, hashed, then evaluated\n(eval-in (gen-handler 'greet '(name) '((str \"Hello, \" name))) env)" "lisp")) + (p "String concatenation would work — " (code "(str \"(define \" name \" ...)\")") " — but it's fragile, unstructured, and can't be inspected before evaluation. Quasiquote produces an AST. The generative program works with " (em "structure") ", not text.")) + + (~doc-subsection :title "Tail-call optimization" + (p "The generative loop is inherently recursive: eval produces source, which is eval'd, which may produce more source. Without TCO, the loop blows the stack after enough iterations. The trampoline/thunk mechanism in the spec handles this, but the host must implement it efficiently.") + (p "This is not optional. A generative program that can only recurse a few thousand times before crashing is not a generative program — it's a demo. The self-compiling spec (Phase 1) alone requires walking every node of " (code "eval.sx") ", which is thousands of recursive calls.")) + + (~doc-subsection :title "Deterministic evaluation order" + (p "If two hosts evaluate the same generative program and get different results because of evaluation order, the content hashes diverge. The programs are no longer equivalent. They can't federate (Phase 5), can't verify each other's output, can't share generated code.") + (p "The host must guarantee: dict iteration order is deterministic (insertion order). Argument evaluation is left-to-right. Effect sequencing follows definition order. No observable nondeterminism in pure evaluation. This is what makes generative programs " (em "reproducible") " — same seed, same input, same output, regardless of host.")) + + (~doc-subsection :title "Serializable state" + (p "For Phase 4 (self-extending server) and Phase 5 (seed network), a generative program needs to pause, serialize its state, and resume elsewhere. The host needs the ability to serialize an environment + pending expression as data.") + (p "This doesn't require first-class continuations (though those work). It requires that everything in the environment is serializable: functions serialize as their source AST, signals as their current value, environments as nested dicts. The " (code "env-snapshot") " primitive provides this. The host must ensure nothing in the environment is opaque — no host-language closures that can't be serialized, no hidden mutable state that isn't captured by the snapshot.")) + + (~doc-subsection :title "IO isolation" + (p "The generative primitives (" (code "read-file") ", " (code "write-file") ", " (code "list-files") ") are the " (em "only") " way generated code touches the outside world. The host must be able to intercept, log, and deny all IO. There is no escape hatch through FFI or native calls.") + (p "This is what makes generative programs auditable. If the host allows generated code to call raw " (code "fs.writeFileSync") " or " (code "os.system") ", the boundary is meaningless. The host must virtualize all IO through the declared primitives. Generated code that tries to escape the sandbox hits the boundary, not the OS.")) + + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (p :class "text-violet-900 font-medium" "The acid test") + (p :class "text-violet-800 text-sm" "SX already has properties 1–7 in the spec. They fall out of the language design. Properties 8–10 (deterministic evaluation, serializable state, IO isolation) are constraints on the " (em "host implementation") ", not the language. A host that violates 1–7 can't run the spec correctly. A host that violates 8–10 can run it but can't be trusted in a generative context.") + (p :class "text-violet-800 text-sm mt-2" "Phase 1 — the self-compiling spec — tests properties 1–7. If a host can compile the spec from the spec, it necessarily has them. The remaining three are operational guarantees verified by the seed network (Phase 5), where multiple hosts must agree on the same generative output."))) + + ;; ===================================================================== + ;; VIII. Environment migration + ;; ===================================================================== + + (~doc-section :title "Environment migration" :id "env-migration" + (p "SX endpoints tunnel into different execution environments with different primitive sets. A browser has " (code "render-to-dom") " but no " (code "gpu-exec") ". A render node has " (code "gpu-exec") " but no " (code "fetch-fragment") ". An ingest server has " (code "open-feed") " but neither. The boundary isn't just a restriction — it's a " (em "capability declaration") ". It tells you what an environment " (em "can do") ".") + + (~doc-subsection :title "Boundary as capability declaration" + (p "Every environment declares its boundary: the set of primitives it provides. SX source is portable across any environment that satisfies its primitive requirements. If a program only uses pure primitives (Tier 0), it runs anywhere. If it calls " (code "gpu-exec") ", it needs an environment that provides " (code "gpu-exec") ". The boundary is a type signature on the environment itself — not \"what can this code do\" but \"what must the host provide.\"") + (p "This inverts the usual framing. The boundary doesn't " (em "forbid") " — it " (em "requires") ". A generated program that calls " (code "encode-stream") " is declaring a hardware dependency. The boundary system doesn't block the call — it routes the program to a host that can satisfy it.")) + + (~doc-subsection :title "with-boundary as migration point" + (p "Execution migrates to where the primitives are. When the evaluator hits a " (code "with-boundary") " block requiring primitives the current host doesn't have, it serializes state (" (code "env-snapshot") "), ships the pending expression plus environment to a host that has them, and execution continues there. The block is the unit of migration, not individual primitive calls.") + (~doc-code :code (highlight "(with-boundary (media-processing encoding)\n (let ((frames (gpu-exec recipe cached-layers)))\n (encode-stream frames :codec \"h264\"\n :on-input-needed (fn (slot)\n (with-boundary (live-ingest)\n (open-feed :protocol \"webrtc\" :slot slot))))))" "lisp")) + (p "This program starts wherever it starts. When it hits " (code "(with-boundary (media-processing encoding) ...)") ", the evaluator checks: does the current host provide " (code "gpu-exec") " and " (code "encode-stream") "? If yes, evaluate in place. If no, snapshot the environment, serialize the pending expression, and dispatch to a host that does. Inside the encoding block, " (code ":on-input-needed") " triggers a nested migration — the " (code "(with-boundary (live-ingest) ...)") " block dispatches to an ingest server that provides " (code "open-feed") ".") + (p "The program doesn't know where it runs. It declares what it needs. The runtime figures out " (em "where") ".")) + + (~doc-subsection :title "Declaration, not discovery" + (p "Boundary requirements are declared at scope boundaries, not discovered at call time. This is the critical constraint. A generative program that synthesizes a " (code "with-boundary") " block is declaring — at generation time — what the block will need. The declaration is inspectable before execution. You can analyze a generated program's boundary requirements without running it.") + (p "This gives constraint checking on generated code. A generative loop that produces a " (code "with-boundary") " block must produce a valid boundary declaration. If the generated block calls " (code "gpu-exec") " but doesn't declare " (code "media-processing") ", the boundary checker rejects it — at generation time, not at runtime. The program must say what it needs before it needs it.")) + + (~doc-subsection :title "Nested migration" + (p "Nested " (code "with-boundary") " blocks are nested migrations. The program walks the capability graph, carrying its state, accumulating content-addressed history. Each migration is an edge in the DAG — the source environment, the target environment, the serialized state, the pending expression. All content-addressed. All auditable.") + (p "A three-level nesting — browser to render node to ingest server — is three migrations. The browser evaluates the outer expression, hits a " (code "with-boundary") " requiring GPU, migrates to the render node. The render node evaluates until it hits a " (code "with-boundary") " requiring live ingest, migrates to the ingest server. Each migration carries the accumulated environment. Each return ships results back up the chain.") + (p "The nesting depth is bounded by the capability graph. If there are four distinct environment types, the maximum nesting is four. In practice, most programs need one or two migrations. The deep nesting is there for generative programs that discover capabilities as they run.")) + + (~doc-subsection :title "Environment chaining" + (p "Split execution — cached layers on one host, GPU rendering on another, encoding on a third — is just environment chaining. The evaluator runs in one environment until it hits a primitive requiring a different one. The primitive " (em "is") " the dispatch.") + (p "This collapses the distinction between \"local function call\" and \"remote service invocation.\" From the SX program's perspective, " (code "gpu-exec") " is a primitive. Whether it runs on the local GPU or a remote render farm is an environment configuration detail, not a language-level concern. The " (code "with-boundary") " block declares the requirement. The runtime satisfies it. The program doesn't care how.") + (p "Environment chaining also explains the Art DAG's three-phase execution pattern (analyze, plan, execute). Each phase runs in a different environment with different primitives. The analyze phase needs " (code "content-hash") " and " (code "list-files") ". The plan phase needs " (code "env-snapshot") " and scheduling primitives. The execute phase needs " (code "gpu-exec") " and storage primitives. Three " (code "with-boundary") " blocks. Three environments. One program."))) + + ;; ===================================================================== + ;; IX. Implementation phases + ;; ===================================================================== + + (~doc-section :title "Implementation phases" :id "phases" + + (~doc-subsection :title "Phase 0: Generative primitives" + (p "Add the minimal set of primitives needed for a generative loop. These are IO primitives — they cross the boundary.") + (div :class "overflow-x-auto rounded border border-stone-200 mb-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Primitive") + (th :class "px-3 py-2 font-medium text-stone-600" "Signature") + (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-stone-700" "read-file") + (td :class "px-3 py-2 text-stone-600" "(path) → string") + (td :class "px-3 py-2 text-stone-600" "Read file contents as string")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-stone-700" "write-file") + (td :class "px-3 py-2 text-stone-600" "(path content) → nil") + (td :class "px-3 py-2 text-stone-600" "Write string to file")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-stone-700" "list-files") + (td :class "px-3 py-2 text-stone-600" "(path pattern) → list") + (td :class "px-3 py-2 text-stone-600" "Glob-match files in directory")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-stone-700" "eval-in") + (td :class "px-3 py-2 text-stone-600" "(source env) → any") + (td :class "px-3 py-2 text-stone-600" "Parse and evaluate SX source in given environment")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-stone-700" "env-snapshot") + (td :class "px-3 py-2 text-stone-600" "(env) → dict") + (td :class "px-3 py-2 text-stone-600" "Serialize environment to inspectable dict")) + (tr + (td :class "px-3 py-2 font-mono text-sm text-stone-700" "content-hash") + (td :class "px-3 py-2 text-stone-600" "(source) → string") + (td :class "px-3 py-2 text-stone-600" "SHA3-256 hash of source string"))))) + (p "These are the building blocks. The generative loop composes them. The primitives themselves are minimal — no networking, no databases, no UI. Just: read, write, evaluate, inspect, hash.")) + + (~doc-subsection :title "Phase 1: Self-compiling spec" + (p "Rewrite " (code "bootstrap_js.py") " as " (code "bootstrap.sx") ". The bootstrapper becomes an SX program that reads the spec files and emits target code.") + (ol :class "list-decimal list-inside space-y-2 text-stone-600" + (li "Write " (code "codegen-js.sx") " — JavaScript code generation adapter (emit JS from SX AST)") + (li "Write " (code "codegen-py.sx") " — Python code generation adapter") + (li "Write " (code "bootstrap.sx") " — the generative loop that loads a spec, loads an adapter, and emits") + (li "Verify: " (code "(bootstrap \"js\")") " produces identical output to " (code "bootstrap_js.py")) + (li "Retire the Python bootstrappers")) + (p "This is the first real generative program: SX reading SX and writing JavaScript. The same program, with a different adapter, writes Python. Or Go. Or WASM. The spec doesn't change. Only the adapter changes.")) + + (~doc-subsection :title "Phase 2: Generative deps" + (p "Rewrite " (code "deps.sx") " as a generative program. Instead of computing a static dependency graph, it runs continuously: watch for new component definitions, update the graph, re-emit optimized bundles.") + (p "This is the deps analyzer turned inside out. Instead of \"analyze all components, output a graph,\" it's \"when a new component appears, update the running graph.\" The dependency analysis is an ongoing computation, not a one-shot pass.")) + + (~doc-subsection :title "Phase 3: Generative testing" + (p "Connect " (code "prove.sx") " to the generative loop. When a new function is defined, automatically generate property tests, run them, report failures. When a function changes, regenerate and rerun only the affected tests.") + (p "The test suite is not a separate artifact — it's a side effect of the generative process. Every function that enters the environment is tested. The tests are generated from properties, not hand-written. The program verifies itself as it grows.")) + + (~doc-subsection :title "Phase 4: The self-extending server" + (p "An SX server with a generative core. New routes, handlers, and middleware can be added at runtime by evaluating SX source. The server's API surface is a living environment that grows with use.") + (p "Not a scripting layer bolted onto a framework — the server " (em "is") " a generative SX program. Its routes are SX definitions. Its middleware is SX functions. Adding a new endpoint means evaluating a new " (code "defhandler") " in the running environment.")) + + (~doc-subsection :title "Phase 5: The seed network" + (p "Multiple generative SX programs exchanging source. Each node runs a seed. When node A discovers a capability it lacks, it requests the source from node B. Node B's generated code is content-addressed — A can verify it, evaluate it, and grow.") + (p "This is SX-Activity applied to generative programs. The wire format is SX. The content is SX. The evaluation is SX. The programs share source, not data. They grow together."))) + + ;; ===================================================================== + ;; X. The strange loop + ;; ===================================================================== + + (~doc-section :title "The strange loop" :id "strange-loop" + (p "Hofstadter's strange loop: a hierarchy of levels where the top level reaches back down and affects the bottom level. In a generative SX program:") + (ul :class "list-disc pl-5 space-y-2 text-stone-600" + (li "The bottom level is the evaluator — it evaluates expressions") + (li "The middle level is the program — expressions that produce values") + (li "The top level is the generator — values that are new expressions") + (li "The loop closes: new expressions are evaluated by the evaluator")) + + (p "The program that writes itself is not a metaphor. It's a literal description of what happens when an SX expression evaluates to an SX string that is then parsed and evaluated. The output of evaluation becomes the input to evaluation. The program is both the writer and the written.") + + (p "The browser version constrains this to DOM mutations. The server version constrains it to request handling. The unconstrained version — a bare seed with " (code "next-source") " — is a strange loop in its purest form: an evaluator that evaluates what it generates, generating what it evaluates.") + + (div :class "rounded border border-stone-200 bg-stone-50 p-4 mt-6" + (p :class "text-stone-700 font-medium mb-2" "The practical consequence") + (p :class "text-stone-600 text-sm" "An SX development environment where the tools are written in SX, running inside SX, modifying SX. The editor understands the code because it " (em "is") " the code. The debugger inspects the environment because it " (em "shares") " the environment. The compiler reads the spec because the spec is in the same format as everything else. There is no impedance mismatch between any layer because there is only one layer."))) + + )) diff --git a/sx/sx/plans/runtime-slicing.sx b/sx/sx/plans/runtime-slicing.sx index 46c063c..087d99b 100644 --- a/sx/sx/plans/runtime-slicing.sx +++ b/sx/sx/plans/runtime-slicing.sx @@ -40,7 +40,7 @@ ;; ----------------------------------------------------------------------- (~doc-section :title "Tiers" :id "tiers" - (p "Four tiers, matching the " (a :href "/reactive-islands/plan" :class "text-violet-700 underline" "reactive islands") " levels:") + (p "Four tiers, matching the " (a :href "/reactive/plan" :class "text-violet-700 underline" "reactive islands") " levels:") (div :class "overflow-x-auto rounded border border-stone-200 mb-4" (table :class "w-full text-left text-sm" @@ -275,7 +275,7 @@ (ul :class "list-disc pl-5 text-stone-700 space-y-1" (li (a :href "/plans/environment-images" :class "text-violet-700 underline" "Environment Images") " — tiered images are smaller. An L0 image omits the parser, evaluator, and most primitives.") (li (a :href "/plans/content-addressed-components" :class "text-violet-700 underline" "Content-Addressed Components") " — component CID resolution is L3-only. L0 pages don't resolve components client-side.") - (li (a :href "/reactive-islands/plan" :class "text-violet-700 underline" "Reactive Islands") " — L2 tier is defined by island presence. The signal runtime is the L1→L2 delta.") + (li (a :href "/reactive/plan" :class "text-violet-700 underline" "Reactive Islands") " — L2 tier is defined by island presence. The signal runtime is the L1→L2 delta.") (li (a :href "/plans/isomorphic-architecture" :class "text-violet-700 underline" "Isomorphic Architecture") " — client-side page rendering is L3. Most pages don't need it.")) (div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2" diff --git a/sx/sx/plans/wasm-bytecode-vm.sx b/sx/sx/plans/wasm-bytecode-vm.sx new file mode 100644 index 0000000..d72bb47 --- /dev/null +++ b/sx/sx/plans/wasm-bytecode-vm.sx @@ -0,0 +1,260 @@ +;; --------------------------------------------------------------------------- +;; WASM Bytecode VM — Compile SX to bytecode, run in Rust/WASM +;; --------------------------------------------------------------------------- + +(defcomp ~plan-wasm-bytecode-vm-content () + (~doc-page :title "WASM Bytecode VM" + + (~doc-section :title "The Idea" :id "idea" + (p "Currently the client-side SX runtime is a tree-walking interpreter bootstrapped to JavaScript. The server sends " (strong "SX source text") " — component definitions, page content — and the browser parses and evaluates it.") + (p "The alternative: compile SX to a " (strong "compact bytecode format") ", ship bytecode to the browser, and execute it in a " (strong "WebAssembly VM written in Rust") ". The VM calls out to JavaScript for DOM operations and I/O via standard WASM↔JS bindings.") + (p "This fits naturally into the SX host architecture. Rust becomes another bootstrapper target. The spec compiles to Rust the same way it compiles to Python and JavaScript. The WASM module is the client-side expression of that Rust target.")) + + ;; ----------------------------------------------------------------------- + ;; Why + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Why" :id "why" + (ul :class "list-disc list-inside space-y-2" + (li (strong "Wire size") " — bytecode is far more compact than source text. No redundant whitespace, no comments, no repeated symbol names. A component bundle that's 40KB of SX source might be 8KB of bytecode.") + (li (strong "No parse overhead") " — the browser currently parses every SX source string (tokenize → AST → eval). Bytecode skips parsing entirely.") + (li (strong "Eval performance") " — a Rust VM with a tight dispatch loop is significantly faster than tree-walking in JavaScript. Matters for compute-heavy islands, large list rendering, complex CSSX calculations.") + (li (strong "Rust as a host target") " — an architectural goal. The spec should compile to every host. Rust/WASM proves the architecture is truly portable.") + (li (strong "Content-addressed bytecode") " — bytecode modules have deterministic content hashes (CIDs). Fits perfectly with the content-addressed components plan — fetch bytecode by CID from anywhere."))) + + ;; ----------------------------------------------------------------------- + ;; Architecture + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Architecture" :id "architecture" + (p "Three new layers, all specced in " (code ".sx") " and bootstrapped:") + + (h4 :class "font-semibold mt-4 mb-2" "1. Bytecode format — bytecode.sx") + (p "A spec for the bytecode instruction set. Stack-based VM (simpler than register-based, natural fit for s-expressions). Instructions:") + (~doc-code :code (highlight ";; Core instructions\nPUSH_CONST idx ;; push constant from pool\nPUSH_NIL ;; push nil\nPUSH_TRUE / PUSH_FALSE\nLOOKUP idx ;; look up symbol by index\nSET idx ;; define/set symbol\nCALL n ;; call top-of-stack with n args\nTAIL_CALL n ;; tail call (TCO)\nRETURN\nJUMP offset ;; unconditional jump\nJUMP_IF_FALSE offset ;; conditional jump\nMAKE_LAMBDA idx n_params ;; create closure\nMAKE_LIST n ;; collect n stack values into list\nMAKE_DICT n ;; collect 2n stack values into dict\nPOP ;; discard top\nDUP ;; duplicate top" "lisp")) + (p "Bytecode modules contain: a " (strong "constant pool") " (strings, numbers, symbols), a " (strong "code section") " (instruction bytes), and a " (strong "metadata section") " (source maps, component/island declarations for the host to register).") + + (h4 :class "font-semibold mt-4 mb-2" "2. Compiler — compile.sx") + (p "An SX-to-bytecode compiler, " (strong "written in SX") ". Takes parsed AST, emits bytecode modules. Handles:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "Macro expansion") " — all macros expanded at compile time. The VM never sees macros.") + (li (strong "Constant folding") " — pure expressions with known values computed at compile time.") + (li (strong "Closure analysis") " — determines free variables for each lambda, emits efficient capture instructions.") + (li (strong "Tail call detection") " — emits TAIL_CALL instead of CALL + RETURN for tail positions.") + (li (strong "Component metadata") " — defcomp/defisland declarations are extracted and stored in the module metadata, so the host can register them without evaluating the body.")) + (p "Bootstrapped to Python (server-side compilation) and Rust (if self-compilation is needed).") + + (h4 :class "font-semibold mt-4 mb-2" "3. VM — bootstrap_rs.py → Rust/WASM") + (p "A Rust implementation of the SX platform interface. The bootstrapper (" (code "bootstrap_rs.py") ") translates the spec to Rust source, which compiles to both:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "Native binary") " — for server-side evaluation (replaces Python evaluators entirely)") + (li (strong "WASM module") " — for browser-side evaluation (replaces sx-browser.js)"))) + + ;; ----------------------------------------------------------------------- + ;; DOM interop + ;; ----------------------------------------------------------------------- + + (~doc-section :title "DOM Interop" :id "dom-interop" + (p "The main engineering challenge. Every DOM operation crosses the WASM↔JS boundary. Two strategies:") + + (h4 :class "font-semibold mt-4 mb-2" "Strategy A: Direct calls") + (p "Each DOM operation (" (code "createElement") ", " (code "setAttribute") ", " (code "appendChild") ") is a separate WASM→JS call. Simple, works, but ~50ns overhead per call. For a page with 1,000 DOM operations, that's ~50μs — negligible.") + (~doc-code :code (highlight "// JS side — imported by WASM\nfunction domCreateElement(tag_ptr, tag_len) {\n const tag = readString(tag_ptr, tag_len);\n return storeHandle(document.createElement(tag));\n}\n\n// Rust side\nextern \"C\" { fn dom_create_element(tag: *const u8, len: u32) -> u32; }" "javascript")) + + (h4 :class "font-semibold mt-4 mb-2" "Strategy B: Command buffer") + (p "Batch DOM operations in WASM memory as a command buffer. Flush to JS in one call. JS walks the buffer and applies all operations. Fewer boundary crossings, but more complex.") + (~doc-code :code (highlight ";; Command buffer format (in shared WASM memory)\n;; [CREATE_ELEMENT, tag_idx, handle_out]\n;; [SET_ATTR, handle, key_idx, val_idx]\n;; [APPEND_CHILD, parent_handle, child_handle]\n;; [SET_TEXT, handle, text_idx]\n;; Then: (flush-dom-commands)" "lisp")) + (p "Strategy A is simpler and sufficient for SX workloads. Strategy B is an optimisation if profiling shows the boundary crossing matters. " (strong "Start with A, measure, switch to B only if needed."))) + + ;; ----------------------------------------------------------------------- + ;; String handling + ;; ----------------------------------------------------------------------- + + (~doc-section :title "String Handling" :id "strings" + (p "WASM has no native string type. Strings must cross the boundary via shared " (code "ArrayBuffer") " memory. Options:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "Copy on crossing") " — encode to UTF-8 in WASM linear memory, JS reads via " (code "TextDecoder") ". Simple, safe, ~1μs per string.") + (li (strong "String interning") " — the constant pool already interns all string literals. Assign each a numeric ID. DOM operations reference strings by ID. JS maintains a parallel string table. Strings never cross the boundary — only IDs do.") + (li (strong "Hybrid") " — intern constants (attribute names, tag names, class names), copy dynamic strings (computed CSS values, interpolated text).")) + (p "String interning is the right default — most DOM attribute values in SX are constant strings. Dynamic strings (like CSSX colour output) are the minority. The constant pool already has all the static strings; just share it with JS at init time.")) + + ;; ----------------------------------------------------------------------- + ;; Memory management + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Memory & Closures" :id "memory" + (p "SX values that the VM must manage:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "Closures") " — lambda captures free variables. Rust: " (code "Rc") " with captured env as " (code "Vec") ".") + (li (strong "Signals") " — reference-counted mutable cells with subscriber lists. Subscribers hold weak references to computed nodes to avoid cycles.") + (li (strong "Lists/Dicts") " — immutable by convention (SX doesn't mutate collections). Arena-allocate per evaluation, free the arena when done.") + (li (strong "DOM handles") " — opaque integers referencing JS-side DOM nodes. A handle table in JS maps handle IDs to actual DOM objects. Handles are freed when the island disposes.")) + (p "The " (code "with-island-scope") " pattern already models the cleanup boundary. In Rust: each island gets its own " (code "Arena") " + signal scope. When the island is removed from the DOM, drop the arena — all closures, signals, and DOM handles for that island are freed in one shot.")) + + ;; ----------------------------------------------------------------------- + ;; What gets compiled + ;; ----------------------------------------------------------------------- + + (~doc-section :title "What Gets Compiled" :id "compilation" + (p "Not everything needs bytecode. The compilation boundary follows the existing server/client split:") + (div :class "overflow-x-auto rounded border border-stone-200 mb-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Content") + (th :class "px-3 py-2 font-medium text-stone-600" "Format") + (th :class "px-3 py-2 font-medium text-stone-600" "Why"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Component definitions") + (td :class "px-3 py-2 text-stone-700" "Bytecode") + (td :class "px-3 py-2 text-stone-600" "Evaluated on every page load, benefits from fast dispatch")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Client library (@client files)") + (td :class "px-3 py-2 text-stone-700" "Bytecode") + (td :class "px-3 py-2 text-stone-600" "CSSX functions, colour computation — pure code that runs client-side")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Page content (SX wire responses)") + (td :class "px-3 py-2 text-stone-700" "SX source or bytecode") + (td :class "px-3 py-2 text-stone-600" "Wire responses are small, parse overhead minimal. Bytecode optional.")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Macros") + (td :class "px-3 py-2 text-stone-700" "Expanded at compile time") + (td :class "px-3 py-2 text-stone-600" "VM never sees macros — they're pure compile-time constructs")) + (tr + (td :class "px-3 py-2 text-stone-700" "Server-affinity components") + (td :class "px-3 py-2 text-stone-700" "Not compiled") + (td :class "px-3 py-2 text-stone-600" "Expanded server-side, never sent to client")))))) + + ;; ----------------------------------------------------------------------- + ;; Bytecode vs direct WASM compilation + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Bytecode VM vs Direct WASM Compilation" :id "vm-vs-direct" + (p "Two paths to WASM. The choice matters:") + (div :class "overflow-x-auto rounded border border-stone-200 mb-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "") + (th :class "px-3 py-2 font-medium text-stone-600" "Bytecode VM in WASM") + (th :class "px-3 py-2 font-medium text-stone-600" "Compile SX → WASM directly"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-semibold text-stone-700" "Complexity") + (td :class "px-3 py-2 text-stone-700" "Standard VM design — proven pattern") + (td :class "px-3 py-2 text-stone-700" "Full compiler backend (SSA, register alloc, WASM codegen)")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-semibold text-stone-700" "Dynamic loading") + (td :class "px-3 py-2 text-stone-700" "Trivial — load bytecode module, eval") + (td :class "px-3 py-2 text-stone-700" "Hard — must instantiate new WASM module per chunk")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-semibold text-stone-700" "eval / REPL") + (td :class "px-3 py-2 text-stone-700" "Works — compile + eval at runtime") + (td :class "px-3 py-2 text-stone-700" "Impossible without bundling a compiler")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-semibold text-stone-700" "Performance") + (td :class "px-3 py-2 text-stone-700" "Fast — WASM dispatch loop, no JS overhead") + (td :class "px-3 py-2 text-stone-700" "Fastest — native WASM speed, no dispatch")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-semibold text-stone-700" "Module size") + (td :class "px-3 py-2 text-stone-700" "One VM module (~100KB) + bytecode per page") + (td :class "px-3 py-2 text-stone-700" "Per-page WASM modules, each self-contained")) + (tr + (td :class "px-3 py-2 font-semibold text-stone-700" "Debugging") + (td :class "px-3 py-2 text-stone-700" "Source maps over bytecode") + (td :class "px-3 py-2 text-stone-700" "DWARF debug info in WASM"))))) + (p (strong "Bytecode VM is the right choice.") " SX needs dynamic loading (HTMX responses inject new components), runtime eval (islands, reactive updates), and incremental compilation (page-by-page). Direct WASM compilation is better for static, ahead-of-time scenarios — not for a live hypermedia system.")) + + ;; ----------------------------------------------------------------------- + ;; Dual target — same spec, runtime choice + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Dual Target: JS or WASM from the Same Spec" :id "dual-target" + (p "The key insight: this is " (strong "not a replacement") " for the JS evaluator. It's " (strong "another compilation target from the same spec") ". The existing bootstrapper pipeline already proves this pattern:") + (~doc-code :code (highlight "eval.sx ──→ bootstrap_js.py ──→ sx-ref.js (browser, JS eval)\n ──→ bootstrap_py.py ──→ sx_ref.py (server, Python eval)\n ──→ bootstrap_rs.py ──→ sx-vm.wasm (browser, WASM eval) ← new" "text")) + (p "All three outputs have identical semantics because they're compiled from the same source. The choice of which to use is a " (strong "deployment decision") ", not an architectural one:") + (ul :class "list-disc list-inside space-y-2 mt-2" + (li (strong "JS-only") " — current default. Works everywhere. Zero WASM dependency. Ship sx-browser.js + SX source text.") + (li (strong "WASM-only") " — maximum performance. Ship sx-vm.wasm + bytecode. Requires WASM support (99%+ of browsers).") + (li (strong "Progressive") " — try WASM, fall back to JS. Ship both. The server sends bytecode in a " (code "