From c95e19dcf2927e4a87b498dbc846c6398f010bb2 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Mar 2026 14:30:12 +0000 Subject: [PATCH] Page helpers demo: defisland, map-in-children fix, _eval_slot ref evaluator - Add page-helpers-demo page with defisland ~demo-client-runner (pure SX, zero JS files) showing spec functions running on both server and client - Fix _aser_component children serialization: flatten list results from map instead of serialize(list) which wraps in parens creating ((div ...) ...) that re-parses as invalid function call. Fixed in adapter-async.sx spec and async_eval_ref.py - Switch _eval_slot to use async_eval_ref.py when SX_USE_REF=1 (was hardcoded to async_eval.py) - Add Island type support to async_eval_ref.py: import, SSR rendering, aser dispatch, thread-first, defisland in _ASER_FORMS - Add server affinity check: components with :affinity :server expand even when _expand_components is False - Add diagnostic _aser_stack context to EvalError messages - New spec files: adapter-async.sx, page-helpers.sx, platform_js.py - Bootstrappers: page-helpers module support, performance.now() timing - 0-arity lambda event handler fix in adapter-dom.sx Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 155 +- shared/sx/pages.py | 6 +- shared/sx/ref/adapter-async.sx | 1198 ++++++++++ shared/sx/ref/adapter-dom.sx | 7 +- shared/sx/ref/async_eval_ref.py | 85 +- shared/sx/ref/bootstrap_py.py | 2 + shared/sx/ref/page-helpers.sx | 368 ++++ shared/sx/ref/platform_js.py | 3163 +++++++++++++++++++++++++++ shared/sx/ref/platform_py.py | 1 + shared/sx/ref/run_js_sx.py | 7 +- shared/sx/ref/sx_ref.py | 557 ++--- sx/sx/boundary.sx | 5 + sx/sx/nav-data.sx | 3 +- sx/sx/page-helpers-demo.sx | 265 +++ sx/sxc/pages/docs.sx | 22 + sx/sxc/pages/helpers.py | 521 ++--- 16 files changed, 5584 insertions(+), 781 deletions(-) create mode 100644 shared/sx/ref/adapter-async.sx create mode 100644 shared/sx/ref/page-helpers.sx create mode 100644 shared/sx/ref/platform_js.py create mode 100644 sx/sx/page-helpers-demo.sx diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 1a36e68..69090b8 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-11T04:41:27Z"; + var SX_VERSION = "2026-03-11T13:57:48Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -974,8 +974,8 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var head = first(template); return (isSxTruthy((isSxTruthy((typeOf(head) == "symbol")) && (symbolName(head) == "unquote"))) ? trampoline(evalExpr(nth(template, 1), env)) : reduce(function(result, item) { return (isSxTruthy((isSxTruthy((typeOf(item) == "list")) && isSxTruthy((len(item) == 2)) && isSxTruthy((typeOf(first(item)) == "symbol")) && (symbolName(first(item)) == "splice-unquote"))) ? (function() { var spliced = trampoline(evalExpr(nth(item, 1), env)); - return (isSxTruthy((typeOf(spliced) == "list")) ? concat(result, spliced) : (isSxTruthy(isNil(spliced)) ? result : append(result, spliced))); -})() : append(result, qqExpand(item, env))); }, [], template)); + return (isSxTruthy((typeOf(spliced) == "list")) ? concat(result, spliced) : (isSxTruthy(isNil(spliced)) ? result : concat(result, [spliced]))); +})() : concat(result, [qqExpand(item, env)])); }, [], template)); })())); }; // sf-thread-first @@ -1658,7 +1658,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var attrExpr = nth(args, (get(state, "i") + 1)); (isSxTruthy(startsWith(attrName, "on-")) ? (function() { var attrVal = trampoline(evalExpr(attrExpr, env)); - return (isSxTruthy(isCallable(attrVal)) ? domListen(el, slice(attrName, 3), attrVal) : NIL); + return (isSxTruthy(isCallable(attrVal)) ? domListen(el, slice(attrName, 3), (isSxTruthy((isSxTruthy(isLambda(attrVal)) && (len(lambdaParams(attrVal)) == 0))) ? function(e) { return callLambda(attrVal, [], lambdaClosure(attrVal)); } : attrVal)) : NIL); })() : (isSxTruthy((attrName == "bind")) ? (function() { var attrVal = trampoline(evalExpr(attrExpr, env)); return (isSxTruthy(isSignal(attrVal)) ? bindInput(el, attrVal) : NIL); @@ -3433,6 +3433,125 @@ callExpr.push(dictGet(kwargs, k)); } } })(); }, keys(env)); }; + // === Transpiled from page-helpers (pure data transformation helpers) === + + // special-form-category-map + var specialFormCategoryMap = {"if": "Control Flow", "when": "Control Flow", "cond": "Control Flow", "case": "Control Flow", "and": "Control Flow", "or": "Control Flow", "let": "Binding", "let*": "Binding", "letrec": "Binding", "define": "Binding", "set!": "Binding", "lambda": "Functions & Components", "fn": "Functions & Components", "defcomp": "Functions & Components", "defmacro": "Functions & Components", "begin": "Sequencing & Threading", "do": "Sequencing & Threading", "->": "Sequencing & Threading", "quote": "Quoting", "quasiquote": "Quoting", "reset": "Continuations", "shift": "Continuations", "dynamic-wind": "Guards", "map": "Higher-Order Forms", "map-indexed": "Higher-Order Forms", "filter": "Higher-Order Forms", "reduce": "Higher-Order Forms", "some": "Higher-Order Forms", "every?": "Higher-Order Forms", "for-each": "Higher-Order Forms", "defstyle": "Domain Definitions", "defhandler": "Domain Definitions", "defpage": "Domain Definitions", "defquery": "Domain Definitions", "defaction": "Domain Definitions"}; + + // extract-define-kwargs + var extractDefineKwargs = function(expr) { return (function() { + var result = {}; + var items = slice(expr, 2); + var n = len(items); + { var _c = range(0, n); for (var _i = 0; _i < _c.length; _i++) { var idx = _c[_i]; if (isSxTruthy((isSxTruthy(((idx + 1) < n)) && (typeOf(nth(items, idx)) == "keyword")))) { + (function() { + var key = keywordName(nth(items, idx)); + var val = nth(items, (idx + 1)); + return dictSet(result, key, (isSxTruthy((typeOf(val) == "list")) ? (String("(") + String(join(" ", map(serialize, val))) + String(")")) : (String(val)))); +})(); +} } } + return result; +})(); }; + + // categorize-special-forms + var categorizeSpecialForms = function(parsedExprs) { return (function() { + var categories = {}; + { var _c = parsedExprs; for (var _i = 0; _i < _c.length; _i++) { var expr = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(expr) == "list")) && isSxTruthy((len(expr) >= 2)) && isSxTruthy((typeOf(first(expr)) == "symbol")) && (symbolName(first(expr)) == "define-special-form")))) { + (function() { + var name = nth(expr, 1); + var kwargs = extractDefineKwargs(expr); + var category = sxOr(get(specialFormCategoryMap, name), "Other"); + if (isSxTruthy(!isSxTruthy(dictHas(categories, category)))) { + categories[category] = []; +} + return append_b(get(categories, category), {"name": name, "syntax": sxOr(get(kwargs, "syntax"), ""), "doc": sxOr(get(kwargs, "doc"), ""), "tail-position": sxOr(get(kwargs, "tail-position"), ""), "example": sxOr(get(kwargs, "example"), "")}); +})(); +} } } + return categories; +})(); }; + + // build-ref-items-with-href + var buildRefItemsWithHref = function(items, basePath, detailKeys, nFields) { return map(function(item) { return (isSxTruthy((nFields == 3)) ? (function() { + var name = nth(item, 0); + var field2 = nth(item, 1); + var field3 = nth(item, 2); + return {"name": name, "desc": field2, "exists": field3, "href": (isSxTruthy((isSxTruthy(field3) && some(function(k) { return (k == name); }, detailKeys))) ? (String(basePath) + String(name)) : NIL)}; +})() : (function() { + var name = nth(item, 0); + var desc = nth(item, 1); + return {"name": name, "desc": desc, "href": (isSxTruthy(some(function(k) { return (k == name); }, detailKeys)) ? (String(basePath) + String(name)) : NIL)}; +})()); }, items); }; + + // build-reference-data + var buildReferenceData = function(slug, rawData, detailKeys) { return (function() { var _m = slug; if (_m == "attributes") return {"req-attrs": buildRefItemsWithHref(get(rawData, "req-attrs"), "/hypermedia/reference/attributes/", detailKeys, 3), "beh-attrs": buildRefItemsWithHref(get(rawData, "beh-attrs"), "/hypermedia/reference/attributes/", detailKeys, 3), "uniq-attrs": buildRefItemsWithHref(get(rawData, "uniq-attrs"), "/hypermedia/reference/attributes/", detailKeys, 3)}; if (_m == "headers") return {"req-headers": buildRefItemsWithHref(get(rawData, "req-headers"), "/hypermedia/reference/headers/", detailKeys, 3), "resp-headers": buildRefItemsWithHref(get(rawData, "resp-headers"), "/hypermedia/reference/headers/", detailKeys, 3)}; if (_m == "events") return {"events-list": buildRefItemsWithHref(get(rawData, "events-list"), "/hypermedia/reference/events/", detailKeys, 2)}; if (_m == "js-api") return {"js-api-list": map(function(item) { return {"name": nth(item, 0), "desc": nth(item, 1)}; }, get(rawData, "js-api-list"))}; return {"req-attrs": buildRefItemsWithHref(get(rawData, "req-attrs"), "/hypermedia/reference/attributes/", detailKeys, 3), "beh-attrs": buildRefItemsWithHref(get(rawData, "beh-attrs"), "/hypermedia/reference/attributes/", detailKeys, 3), "uniq-attrs": buildRefItemsWithHref(get(rawData, "uniq-attrs"), "/hypermedia/reference/attributes/", detailKeys, 3)}; })(); }; + + // build-attr-detail + var buildAttrDetail = function(slug, detail) { return (isSxTruthy(isNil(detail)) ? {"attr-not-found": true} : {"attr-not-found": NIL, "attr-title": slug, "attr-description": get(detail, "description"), "attr-example": get(detail, "example"), "attr-handler": get(detail, "handler"), "attr-demo": get(detail, "demo"), "attr-wire-id": (isSxTruthy(dictHas(detail, "handler")) ? (String("ref-wire-") + String(replace_(replace_(slug, ":", "-"), "*", "star"))) : NIL)}); }; + + // build-header-detail + var buildHeaderDetail = function(slug, detail) { return (isSxTruthy(isNil(detail)) ? {"header-not-found": true} : {"header-not-found": NIL, "header-title": slug, "header-direction": get(detail, "direction"), "header-description": get(detail, "description"), "header-example": get(detail, "example"), "header-demo": get(detail, "demo")}); }; + + // build-event-detail + var buildEventDetail = function(slug, detail) { return (isSxTruthy(isNil(detail)) ? {"event-not-found": true} : {"event-not-found": NIL, "event-title": slug, "event-description": get(detail, "description"), "event-example": get(detail, "example"), "event-demo": get(detail, "demo")}); }; + + // build-component-source + var buildComponentSource = function(compData) { return (function() { + var compType = get(compData, "type"); + var name = get(compData, "name"); + var params = get(compData, "params"); + var hasChildren = get(compData, "has-children"); + var bodySx = get(compData, "body-sx"); + var affinity = get(compData, "affinity"); + return (isSxTruthy((compType == "not-found")) ? (String(";; component ") + String(name) + String(" not found")) : (function() { + var paramStrs = (isSxTruthy(isEmpty(params)) ? (isSxTruthy(hasChildren) ? ["&rest", "children"] : []) : (isSxTruthy(hasChildren) ? append(cons("&key", params), ["&rest", "children"]) : cons("&key", params))); + var paramsSx = (String("(") + String(join(" ", paramStrs)) + String(")")); + var formName = (isSxTruthy((compType == "island")) ? "defisland" : "defcomp"); + var affinityStr = (isSxTruthy((isSxTruthy((compType == "component")) && isSxTruthy(!isSxTruthy(isNil(affinity))) && !isSxTruthy((affinity == "auto")))) ? (String(" :affinity ") + String(affinity)) : ""); + return (String("(") + String(formName) + String(" ") + String(name) + String(" ") + String(paramsSx) + String(affinityStr) + String("\n ") + String(bodySx) + String(")")); +})()); +})(); }; + + // build-bundle-analysis + var buildBundleAnalysis = function(pagesRaw, componentsRaw, totalComponents, totalMacros, pureCount, ioCount) { return (function() { + var pagesData = []; + { var _c = pagesRaw; for (var _i = 0; _i < _c.length; _i++) { var page = _c[_i]; (function() { + var neededNames = get(page, "needed-names"); + var n = len(neededNames); + var pct = (isSxTruthy((totalComponents > 0)) ? round(((n / totalComponents) * 100)) : 0); + var savings = (100 - pct); + var pureInPage = 0; + var ioInPage = 0; + var pageIoRefs = []; + var compDetails = []; + { var _c = neededNames; for (var _i = 0; _i < _c.length; _i++) { var compName = _c[_i]; (function() { + var info = get(componentsRaw, compName); + return (isSxTruthy(!isSxTruthy(isNil(info))) ? ((isSxTruthy(get(info, "is-pure")) ? (pureInPage = (pureInPage + 1)) : ((ioInPage = (ioInPage + 1)), forEach(function(ref) { return (isSxTruthy(!isSxTruthy(some(function(r) { return (r == ref); }, pageIoRefs))) ? append_b(pageIoRefs, ref) : NIL); }, sxOr(get(info, "io-refs"), [])))), append_b(compDetails, {"name": compName, "is-pure": get(info, "is-pure"), "affinity": get(info, "affinity"), "render-target": get(info, "render-target"), "io-refs": sxOr(get(info, "io-refs"), []), "deps": sxOr(get(info, "deps"), []), "source": get(info, "source")})) : NIL); +})(); } } + return append_b(pagesData, {"name": get(page, "name"), "path": get(page, "path"), "direct": get(page, "direct"), "needed": n, "pct": pct, "savings": savings, "io-refs": len(pageIoRefs), "pure-in-page": pureInPage, "io-in-page": ioInPage, "components": compDetails}); +})(); } } + return {"pages": pagesData, "total-components": totalComponents, "total-macros": totalMacros, "pure-count": pureCount, "io-count": ioCount}; +})(); }; + + // build-routing-analysis + var buildRoutingAnalysis = function(pagesRaw) { return (function() { + var pagesData = []; + var clientCount = 0; + var serverCount = 0; + { var _c = pagesRaw; for (var _i = 0; _i < _c.length; _i++) { var page = _c[_i]; (function() { + var hasData = get(page, "has-data"); + var contentSrc = sxOr(get(page, "content-src"), ""); + var mode = NIL; + var reason = ""; + (isSxTruthy(hasData) ? ((mode = "server"), (reason = "Has :data expression — needs server IO"), (serverCount = (serverCount + 1))) : (isSxTruthy(isEmpty(contentSrc)) ? ((mode = "server"), (reason = "No content expression"), (serverCount = (serverCount + 1))) : ((mode = "client"), (clientCount = (clientCount + 1))))); + return append_b(pagesData, {"name": get(page, "name"), "path": get(page, "path"), "mode": mode, "has-data": hasData, "content-expr": (isSxTruthy((len(contentSrc) > 80)) ? (String(slice(contentSrc, 0, 80)) + String("...")) : contentSrc), "reason": reason}); +})(); } } + return {"pages": pagesData, "total-pages": (clientCount + serverCount), "client-count": clientCount, "server-count": serverCount}; +})(); }; + + // build-affinity-analysis + var buildAffinityAnalysis = function(demoComponents, pagePlans) { return {"components": demoComponents, "page-plans": pagePlans}; }; + + // === Transpiled from router (client-side route matching) === // split-path-segments @@ -3947,7 +4066,7 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { } } - function nowMs() { return Date.now(); } + function nowMs() { return (typeof performance !== "undefined") ? performance.now() : Date.now(); } function parseHeaderValue(s) { if (!s) return null; @@ -5060,6 +5179,10 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { 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; + if (typeof domTextContent === "function") PRIMITIVES["dom-text-content"] = domTextContent; + if (typeof jsonParse === "function") PRIMITIVES["json-parse"] = jsonParse; + if (typeof nowMs === "function") PRIMITIVES["now-ms"] = nowMs; + PRIMITIVES["sx-parse"] = sxParse; // Expose deps module functions as primitives so runtime-evaluated SX code // (e.g. test-deps.sx in browser) can call them @@ -5090,6 +5213,19 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { PRIMITIVES["render-target"] = renderTarget; PRIMITIVES["page-render-plan"] = pageRenderPlan; + // Expose page-helper functions as primitives + PRIMITIVES["categorize-special-forms"] = categorizeSpecialForms; + PRIMITIVES["extract-define-kwargs"] = extractDefineKwargs; + PRIMITIVES["build-reference-data"] = buildReferenceData; + PRIMITIVES["build-ref-items-with-href"] = buildRefItemsWithHref; + PRIMITIVES["build-attr-detail"] = buildAttrDetail; + PRIMITIVES["build-header-detail"] = buildHeaderDetail; + PRIMITIVES["build-event-detail"] = buildEventDetail; + PRIMITIVES["build-component-source"] = buildComponentSource; + PRIMITIVES["build-bundle-analysis"] = buildBundleAnalysis; + PRIMITIVES["build-routing-analysis"] = buildRoutingAnalysis; + PRIMITIVES["build-affinity-analysis"] = buildAffinityAnalysis; + // ========================================================================= // Async IO: Promise-aware rendering for client-side IO primitives // ========================================================================= @@ -5823,6 +5959,15 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { transitiveIoRefs: transitiveIoRefs, computeAllIoRefs: computeAllIoRefs, componentPure_p: componentPure_p, + categorizeSpecialForms: categorizeSpecialForms, + buildReferenceData: buildReferenceData, + buildAttrDetail: buildAttrDetail, + buildHeaderDetail: buildHeaderDetail, + buildEventDetail: buildEventDetail, + buildComponentSource: buildComponentSource, + buildBundleAnalysis: buildBundleAnalysis, + buildRoutingAnalysis: buildRoutingAnalysis, + buildAffinityAnalysis: buildAffinityAnalysis, splitPathSegments: splitPathSegments, parseRoutePattern: parseRoutePattern, matchRoute: matchRoute, diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 51dc76d..2050a5b 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -170,7 +170,11 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str: Expands component calls (so IO in the body executes) but serializes the result as SX wire format, not HTML. """ - from .async_eval import async_eval_slot_to_sx + import os + if os.environ.get("SX_USE_REF") == "1": + from .ref.async_eval_ref import async_eval_slot_to_sx + else: + from .async_eval import async_eval_slot_to_sx return await async_eval_slot_to_sx(expr, env, ctx) diff --git a/shared/sx/ref/adapter-async.sx b/shared/sx/ref/adapter-async.sx new file mode 100644 index 0000000..f57dfe3 --- /dev/null +++ b/shared/sx/ref/adapter-async.sx @@ -0,0 +1,1198 @@ +;; ========================================================================== +;; adapter-async.sx — Async rendering and serialization adapter +;; +;; Async versions of adapter-html.sx (render) and adapter-sx.sx (aser) +;; for use with I/O-capable server environments (Python async, JS promises). +;; +;; Structurally identical to the sync adapters but uses async primitives: +;; async-eval — evaluate with I/O interception (platform primitive) +;; async-render — defined here, async HTML rendering +;; async-aser — defined here, async SX wire format +;; +;; All functions in this file are emitted as async by the bootstrapper. +;; Calls to other async functions receive await automatically. +;; +;; Depends on: +;; eval.sx — cond-scheme?, eval-cond-scheme, eval-cond-clojure, +;; expand-macro, env-merge, lambda?, component?, island?, +;; macro?, lambda-closure, lambda-params, lambda-body +;; render.sx — HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, +;; render-attrs, definition-form?, process-bindings, eval-cond +;; +;; Platform primitives (provided by host): +;; (async-eval expr env ctx) — evaluate with I/O interception +;; (io-primitive? name) — check if name is I/O primitive +;; (execute-io name args kw ctx) — execute an I/O primitive +;; (expand-components?) — context var: expand components in aser? +;; (svg-context?) — context var: in SVG rendering context? +;; (svg-context-set! val) — set SVG context +;; (svg-context-reset! token) — reset SVG context +;; (css-class-collect! val) — collect CSS classes for bundling +;; (is-raw-html? x) — check if value is raw HTML marker +;; (raw-html-content x) — extract HTML string from marker +;; (make-raw-html s) — wrap string as raw HTML +;; (async-coroutine? x) — check if value is a coroutine +;; (async-await! x) — await a coroutine value +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Async HTML renderer +;; -------------------------------------------------------------------------- + +(define-async async-render + (fn (expr env ctx) + (case (type-of expr) + "nil" "" + "boolean" "" + "string" (escape-html expr) + "number" (escape-html (str expr)) + "raw-html" (raw-html-content expr) + "symbol" (let ((val (async-eval expr env ctx))) + (async-render val env ctx)) + "keyword" (escape-html (keyword-name expr)) + "list" (if (empty? expr) "" (async-render-list expr env ctx)) + "dict" "" + :else (escape-html (str expr))))) + + +(define-async async-render-list + (fn (expr env ctx) + (let ((head (first expr))) + (if (not (= (type-of head) "symbol")) + ;; Non-symbol head — data list, render each item + (if (or (lambda? head) (= (type-of head) "list")) + ;; Lambda/list call — eval then render + (async-render (async-eval expr env ctx) env ctx) + ;; Data list + (join "" (async-map-render expr env ctx))) + + ;; Symbol head — dispatch + (let ((name (symbol-name head)) + (args (rest expr))) + (cond + ;; I/O primitive + (io-primitive? name) + (async-render (async-eval expr env ctx) env ctx) + + ;; raw! + (= name "raw!") + (async-render-raw args env ctx) + + ;; Fragment + (= name "<>") + (join "" (async-map-render args env ctx)) + + ;; html: prefix + (starts-with? name "html:") + (async-render-element (slice name 5) args env ctx) + + ;; Render-aware special form (but check HTML tag + keyword first) + (async-render-form? name) + (if (and (contains? HTML_TAGS name) + (or (and (> (len expr) 1) (= (type-of (nth expr 1)) "keyword")) + (svg-context?))) + (async-render-element name args env ctx) + (dispatch-async-render-form name expr env ctx)) + + ;; Macro + (and (env-has? env name) (macro? (env-get env name))) + (async-render + (trampoline (expand-macro (env-get env name) args env)) + env ctx) + + ;; HTML tag + (contains? HTML_TAGS name) + (async-render-element name args env ctx) + + ;; Island (~name) + (and (starts-with? name "~") + (env-has? env name) + (island? (env-get env name))) + (async-render-island (env-get env name) args env ctx) + + ;; Component (~name) + (starts-with? name "~") + (let ((val (if (env-has? env name) (env-get env name) nil))) + (cond + (component? val) (async-render-component val args env ctx) + (macro? val) (async-render (trampoline (expand-macro val args env)) env ctx) + :else (async-render (async-eval expr env ctx) env ctx))) + + ;; Custom element (has - and keyword arg) + (and (> (index-of name "-") 0) + (> (len expr) 1) + (= (type-of (nth expr 1)) "keyword")) + (async-render-element name args env ctx) + + ;; SVG context + (svg-context?) + (async-render-element name args env ctx) + + ;; Fallback — eval then render + :else + (async-render (async-eval expr env ctx) env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-render-raw — handle (raw! ...) in async context +;; -------------------------------------------------------------------------- + +(define-async async-render-raw + (fn (args env ctx) + (let ((parts (list))) + (for-each + (fn (arg) + (let ((val (async-eval arg env ctx))) + (cond + (is-raw-html? val) (append! parts (raw-html-content val)) + (= (type-of val) "string") (append! parts val) + (and (not (nil? val)) (not (= val false))) + (append! parts (str val))))) + args) + (join "" parts)))) + + +;; -------------------------------------------------------------------------- +;; async-render-element — render an HTML element with async arg evaluation +;; -------------------------------------------------------------------------- + +(define-async async-render-element + (fn (tag args env ctx) + (let ((attrs (dict)) + (children (list))) + ;; Parse keyword attrs and children + (async-parse-element-args args attrs children env ctx) + ;; Collect CSS classes + (let ((class-val (dict-get attrs "class"))) + (when (and (not (nil? class-val)) (not (= class-val false))) + (css-class-collect! (str class-val)))) + ;; Build opening tag + (let ((opening (str "<" tag (render-attrs attrs) ">"))) + (if (contains? VOID_ELEMENTS tag) + opening + (let ((token (if (or (= tag "svg") (= tag "math")) + (svg-context-set! true) + nil)) + (child-html (join "" (async-map-render children env ctx)))) + (when token (svg-context-reset! token)) + (str opening child-html ""))))))) + + +;; -------------------------------------------------------------------------- +;; async-parse-element-args — parse :key val pairs + children, async eval +;; -------------------------------------------------------------------------- +;; Uses for-each + mutable state instead of reduce, because the bootstrapper +;; compiles inline for-each lambdas as for loops (which can contain await). + +(define-async async-parse-element-args + (fn (args attrs children env ctx) + (let ((skip false) + (i 0)) + (for-each + (fn (arg) + (if skip + (do (set! skip false) + (set! i (inc i))) + (if (and (= (type-of arg) "keyword") + (< (inc i) (len args))) + (let ((val (async-eval (nth args (inc i)) env ctx))) + (dict-set! attrs (keyword-name arg) val) + (set! skip true) + (set! i (inc i))) + (do + (append! children arg) + (set! i (inc i)))))) + args)))) + + +;; -------------------------------------------------------------------------- +;; async-render-component — expand and render a component asynchronously +;; -------------------------------------------------------------------------- + +(define-async async-render-component + (fn (comp args env ctx) + (let ((kwargs (dict)) + (children (list))) + ;; Parse keyword args and children + (async-parse-kw-args args kwargs children env ctx) + ;; Build env: closure + caller env + params + (let ((local (env-merge (component-closure comp) env))) + (for-each + (fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params comp)) + (when (component-has-children? comp) + (env-set! local "children" + (make-raw-html + (join "" (async-map-render children env ctx))))) + (async-render (component-body comp) local ctx))))) + + +;; -------------------------------------------------------------------------- +;; async-render-island — SSR render of reactive island with hydration markers +;; -------------------------------------------------------------------------- + +(define-async async-render-island + (fn (island args env ctx) + (let ((kwargs (dict)) + (children (list))) + (async-parse-kw-args args kwargs children env ctx) + (let ((local (env-merge (component-closure island) env)) + (island-name (component-name island))) + (for-each + (fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params island)) + (when (component-has-children? island) + (env-set! local "children" + (make-raw-html + (join "" (async-map-render children env ctx))))) + (let ((body-html (async-render (component-body island) local ctx)) + (state-json (serialize-island-state kwargs))) + (str "" + body-html + "")))))) + + +;; -------------------------------------------------------------------------- +;; async-render-lambda — render lambda body in HTML context +;; -------------------------------------------------------------------------- + +(define-async async-render-lambda + (fn (f args env ctx) + (let ((local (env-merge (lambda-closure f) env))) + (for-each-indexed + (fn (i p) (env-set! local p (nth args i))) + (lambda-params f)) + (async-render (lambda-body f) local ctx)))) + + +;; -------------------------------------------------------------------------- +;; async-parse-kw-args — parse keyword args and children with async eval +;; -------------------------------------------------------------------------- + +(define-async async-parse-kw-args + (fn (args kwargs children env ctx) + (let ((skip false) + (i 0)) + (for-each + (fn (arg) + (if skip + (do (set! skip false) + (set! i (inc i))) + (if (and (= (type-of arg) "keyword") + (< (inc i) (len args))) + (let ((val (async-eval (nth args (inc i)) env ctx))) + (dict-set! kwargs (keyword-name arg) val) + (set! skip true) + (set! i (inc i))) + (do + (append! children arg) + (set! i (inc i)))))) + args)))) + + +;; -------------------------------------------------------------------------- +;; async-map-render — map async-render over a list, return list of strings +;; -------------------------------------------------------------------------- +;; Bootstrapper emits this as: [await async_render(x, env, ctx) for x in exprs] + +(define-async async-map-render + (fn (exprs env ctx) + (let ((results (list))) + (for-each + (fn (x) (append! results (async-render x env ctx))) + exprs) + results))) + + +;; -------------------------------------------------------------------------- +;; Render-aware form classification +;; -------------------------------------------------------------------------- + +(define ASYNC_RENDER_FORMS + (list "if" "when" "cond" "case" "let" "let*" "begin" "do" + "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" + "map" "map-indexed" "filter" "for-each")) + +(define async-render-form? + (fn (name) + (contains? ASYNC_RENDER_FORMS name))) + + +;; -------------------------------------------------------------------------- +;; dispatch-async-render-form — async special form rendering for HTML output +;; -------------------------------------------------------------------------- +;; +;; Uses cond-scheme? from eval.sx (the FIXED version with every? check) +;; and eval-cond from render.sx for correct scheme/clojure classification. + +(define-async dispatch-async-render-form + (fn (name expr env ctx) + (cond + ;; if + (= name "if") + (let ((cond-val (async-eval (nth expr 1) env ctx))) + (if cond-val + (async-render (nth expr 2) env ctx) + (if (> (len expr) 3) + (async-render (nth expr 3) env ctx) + ""))) + + ;; when + (= name "when") + (if (not (async-eval (nth expr 1) env ctx)) + "" + (join "" (async-map-render (slice expr 2) env ctx))) + + ;; cond — uses cond-scheme? (every? check) from eval.sx + (= name "cond") + (let ((clauses (rest expr))) + (if (cond-scheme? clauses) + (async-render-cond-scheme clauses env ctx) + (async-render-cond-clojure clauses env ctx))) + + ;; case + (= name "case") + (async-render (async-eval expr env ctx) env ctx) + + ;; let / let* + (or (= name "let") (= name "let*")) + (let ((local (async-process-bindings (nth expr 1) env ctx))) + (join "" (async-map-render (slice expr 2) local ctx))) + + ;; begin / do + (or (= name "begin") (= name "do")) + (join "" (async-map-render (rest expr) env ctx)) + + ;; Definition forms + (definition-form? name) + (do (async-eval expr env ctx) "") + + ;; map + (= name "map") + (let ((f (async-eval (nth expr 1) env ctx)) + (coll (async-eval (nth expr 2) env ctx))) + (join "" + (async-map-fn-render f coll env ctx))) + + ;; map-indexed + (= name "map-indexed") + (let ((f (async-eval (nth expr 1) env ctx)) + (coll (async-eval (nth expr 2) env ctx))) + (join "" + (async-map-indexed-fn-render f coll env ctx))) + + ;; filter — eval fully then render + (= name "filter") + (async-render (async-eval expr env ctx) env ctx) + + ;; for-each (render variant) + (= name "for-each") + (let ((f (async-eval (nth expr 1) env ctx)) + (coll (async-eval (nth expr 2) env ctx))) + (join "" + (async-map-fn-render f coll env ctx))) + + ;; Fallback + :else + (async-render (async-eval expr env ctx) env ctx)))) + + +;; -------------------------------------------------------------------------- +;; async-render-cond-scheme — scheme-style cond for render mode +;; -------------------------------------------------------------------------- + +(define-async async-render-cond-scheme + (fn (clauses env ctx) + (if (empty? clauses) + "" + (let ((clause (first clauses)) + (test (first clause)) + (body (nth clause 1))) + (if (or (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else"))) + (and (= (type-of test) "keyword") + (= (keyword-name test) "else"))) + (async-render body env ctx) + (if (async-eval test env ctx) + (async-render body env ctx) + (async-render-cond-scheme (rest clauses) env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-render-cond-clojure — clojure-style cond for render mode +;; -------------------------------------------------------------------------- + +(define-async async-render-cond-clojure + (fn (clauses env ctx) + (if (< (len clauses) 2) + "" + (let ((test (first clauses)) + (body (nth clauses 1))) + (if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) + (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else")))) + (async-render body env ctx) + (if (async-eval test env ctx) + (async-render body env ctx) + (async-render-cond-clojure (slice clauses 2) env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-process-bindings — evaluate let-bindings asynchronously +;; -------------------------------------------------------------------------- + +(define-async async-process-bindings + (fn (bindings env ctx) + (let ((local (merge env))) + (if (and (= (type-of bindings) "list") (not (empty? bindings))) + (if (= (type-of (first bindings)) "list") + ;; Scheme-style: ((name val) ...) + (for-each + (fn (pair) + (when (and (= (type-of pair) "list") (>= (len pair) 2)) + (let ((name (if (= (type-of (first pair)) "symbol") + (symbol-name (first pair)) + (str (first pair))))) + (env-set! local name (async-eval (nth pair 1) local ctx))))) + bindings) + ;; Clojure-style: (name val name val ...) + (async-process-bindings-flat bindings local ctx))) + local))) + + +(define-async async-process-bindings-flat + (fn (bindings local ctx) + (let ((skip false) + (i 0)) + (for-each + (fn (item) + (if skip + (do (set! skip false) + (set! i (inc i))) + (do + (let ((name (if (= (type-of item) "symbol") + (symbol-name item) + (str item)))) + (when (< (inc i) (len bindings)) + (env-set! local name + (async-eval (nth bindings (inc i)) local ctx)))) + (set! skip true) + (set! i (inc i))))) + bindings)))) + + +;; -------------------------------------------------------------------------- +;; async-map-fn-render — map a lambda/callable over collection for render +;; -------------------------------------------------------------------------- + +(define-async async-map-fn-render + (fn (f coll env ctx) + (let ((results (list))) + (for-each + (fn (item) + (if (lambda? f) + (append! results (async-render-lambda f (list item) env ctx)) + (let ((r (async-invoke f item))) + (append! results (async-render r env ctx))))) + coll) + results))) + + +;; -------------------------------------------------------------------------- +;; async-map-indexed-fn-render — map-indexed variant for render +;; -------------------------------------------------------------------------- + +(define-async async-map-indexed-fn-render + (fn (f coll env ctx) + (let ((results (list)) + (i 0)) + (for-each + (fn (item) + (if (lambda? f) + (append! results (async-render-lambda f (list i item) env ctx)) + (let ((r (async-invoke f i item))) + (append! results (async-render r env ctx)))) + (set! i (inc i))) + coll) + results))) + + +;; -------------------------------------------------------------------------- +;; async-invoke — call a native callable, await if coroutine +;; -------------------------------------------------------------------------- + +(define-async async-invoke + (fn (f &rest args) + (let ((r (apply f args))) + (if (async-coroutine? r) + (async-await! r) + r)))) + + +;; ========================================================================== +;; Async SX wire format (aser) +;; ========================================================================== + +(define-async async-aser + (fn (expr env ctx) + (case (type-of expr) + "number" expr + "string" expr + "boolean" expr + "nil" nil + + "symbol" + (let ((name (symbol-name expr))) + (cond + (env-has? env name) (env-get env name) + (primitive? name) (get-primitive name) + (= name "true") true + (= name "false") false + (= name "nil") nil + :else (error (str "Undefined symbol: " name)))) + + "keyword" (keyword-name expr) + + "dict" (async-aser-dict expr env ctx) + + "list" + (if (empty? expr) + (list) + (async-aser-list expr env ctx)) + + :else expr))) + + +(define-async async-aser-dict + (fn (expr env ctx) + (let ((result (dict))) + (for-each + (fn (key) + (dict-set! result key (async-aser (dict-get expr key) env ctx))) + (keys expr)) + result))) + + +;; -------------------------------------------------------------------------- +;; async-aser-list — dispatch on list head for aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-list + (fn (expr env ctx) + (let ((head (first expr)) + (args (rest expr))) + (if (not (= (type-of head) "symbol")) + ;; Non-symbol head + (if (or (lambda? head) (= (type-of head) "list")) + ;; Function/list call — eval fully + (async-aser-eval-call head args env ctx) + ;; Data list — aser each + (async-aser-map-list expr env ctx)) + + ;; Symbol head — dispatch + (let ((name (symbol-name head))) + (cond + ;; I/O primitive + (io-primitive? name) + (async-eval expr env ctx) + + ;; Fragment + (= name "<>") + (async-aser-fragment args env ctx) + + ;; raw! + (= name "raw!") + (async-aser-call "raw!" args env ctx) + + ;; html: prefix + (starts-with? name "html:") + (async-aser-call (slice name 5) args env ctx) + + ;; Component call (~name) + (starts-with? name "~") + (let ((val (if (env-has? env name) (env-get env name) nil))) + (cond + (macro? val) + (async-aser (trampoline (expand-macro val args env)) env ctx) + (and (component? val) + (or (expand-components?) + (= (component-affinity val) "server"))) + (async-aser-component val args env ctx) + :else + (async-aser-call name args env ctx))) + + ;; Special/HO forms + (or (async-aser-form? name)) + (if (and (contains? HTML_TAGS name) + (or (and (> (len expr) 1) (= (type-of (nth expr 1)) "keyword")) + (svg-context?))) + (async-aser-call name args env ctx) + (dispatch-async-aser-form name expr env ctx)) + + ;; HTML tag + (contains? HTML_TAGS name) + (async-aser-call name args env ctx) + + ;; Macro + (and (env-has? env name) (macro? (env-get env name))) + (async-aser (trampoline (expand-macro (env-get env name) args env)) env ctx) + + ;; Custom element + (and (> (index-of name "-") 0) + (> (len expr) 1) + (= (type-of (nth expr 1)) "keyword")) + (async-aser-call name args env ctx) + + ;; SVG context + (svg-context?) + (async-aser-call name args env ctx) + + ;; Fallback — function/lambda call + :else + (async-aser-eval-call head args env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-aser-eval-call — evaluate a function call fully in aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-eval-call + (fn (head args env ctx) + (let ((f (async-eval head env ctx)) + (evaled-args (async-eval-args args env ctx))) + (cond + (and (callable? f) (not (lambda? f)) (not (component? f))) + (async-invoke f evaled-args) + (lambda? f) + (let ((local (env-merge (lambda-closure f) env))) + (for-each-indexed + (fn (i p) (env-set! local p (nth evaled-args i))) + (lambda-params f)) + (async-aser (lambda-body f) local ctx)) + (component? f) + (async-aser-call (str "~" (component-name f)) args env ctx) + (island? f) + (async-aser-call (str "~" (component-name f)) args env ctx) + :else + (error (str "Not callable: " (inspect f))))))) + + +;; -------------------------------------------------------------------------- +;; async-eval-args — evaluate a list of args asynchronously +;; -------------------------------------------------------------------------- + +(define-async async-eval-args + (fn (args env ctx) + (let ((results (list))) + (for-each + (fn (a) (append! results (async-eval a env ctx))) + args) + results))) + + +;; -------------------------------------------------------------------------- +;; async-aser-map-list — aser each element of a list +;; -------------------------------------------------------------------------- + +(define-async async-aser-map-list + (fn (exprs env ctx) + (let ((results (list))) + (for-each + (fn (x) (append! results (async-aser x env ctx))) + exprs) + results))) + + +;; -------------------------------------------------------------------------- +;; async-aser-fragment — serialize (<> child1 child2 ...) in aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-fragment + (fn (children env ctx) + (let ((parts (list))) + (for-each + (fn (c) + (let ((result (async-aser c env ctx))) + (if (= (type-of result) "list") + (for-each + (fn (item) + (when (not (nil? item)) + (append! parts (serialize item)))) + result) + (when (not (nil? result)) + (append! parts (serialize result)))))) + children) + (if (empty? parts) + (make-sx-expr "") + (make-sx-expr (str "(<> " (join " " parts) ")")))))) + + +;; -------------------------------------------------------------------------- +;; async-aser-component — expand component server-side in aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-component + (fn (comp args env ctx) + (let ((kwargs (dict)) + (children (list))) + (async-parse-aser-kw-args args kwargs children env ctx) + (let ((local (env-merge (component-closure comp) env))) + (for-each + (fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params comp)) + (when (component-has-children? comp) + (let ((child-parts (list))) + (for-each + (fn (c) + (let ((result (async-aser c env ctx))) + (if (list? result) + (for-each + (fn (item) + (when (not (nil? item)) + (append! child-parts (serialize item)))) + result) + (when (not (nil? result)) + (append! child-parts (serialize result)))))) + children) + (env-set! local "children" + (make-sx-expr (str "(<> " (join " " child-parts) ")"))))) + (async-aser (component-body comp) local ctx))))) + + +;; -------------------------------------------------------------------------- +;; async-parse-aser-kw-args — parse keyword args for aser mode +;; -------------------------------------------------------------------------- + +(define-async async-parse-aser-kw-args + (fn (args kwargs children env ctx) + (let ((skip false) + (i 0)) + (for-each + (fn (arg) + (if skip + (do (set! skip false) + (set! i (inc i))) + (if (and (= (type-of arg) "keyword") + (< (inc i) (len args))) + (let ((val (async-aser (nth args (inc i)) env ctx))) + (dict-set! kwargs (keyword-name arg) val) + (set! skip true) + (set! i (inc i))) + (do + (append! children arg) + (set! i (inc i)))))) + args)))) + + +;; -------------------------------------------------------------------------- +;; async-aser-call — serialize an SX call (tag or component) in aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-call + (fn (name args env ctx) + (let ((token (if (or (= name "svg") (= name "math")) + (svg-context-set! true) + nil)) + (parts (list name)) + (skip false) + (i 0)) + (for-each + (fn (arg) + (if skip + (do (set! skip false) + (set! i (inc i))) + (if (and (= (type-of arg) "keyword") + (< (inc i) (len args))) + (let ((val (async-aser (nth args (inc i)) env ctx))) + (when (not (nil? val)) + (append! parts (str ":" (keyword-name arg))) + (if (= (type-of val) "list") + (let ((live (filter (fn (v) (not (nil? v))) val))) + (if (empty? live) + (append! parts "nil") + (let ((items (map serialize live))) + (if (some (fn (v) (sx-expr? v)) live) + (append! parts (str "(<> " (join " " items) ")")) + (append! parts (str "(list " (join " " items) ")")))))) + (append! parts (serialize val)))) + (set! skip true) + (set! i (inc i))) + (let ((result (async-aser arg env ctx))) + (when (not (nil? result)) + (if (= (type-of result) "list") + (for-each + (fn (item) + (when (not (nil? item)) + (append! parts (serialize item)))) + result) + (append! parts (serialize result)))) + (set! i (inc i)))))) + args) + (when token (svg-context-reset! token)) + (make-sx-expr (str "(" (join " " parts) ")"))))) + + +;; -------------------------------------------------------------------------- +;; Aser form classification +;; -------------------------------------------------------------------------- + +(define ASYNC_ASER_FORM_NAMES + (list "if" "when" "cond" "case" "and" "or" + "let" "let*" "lambda" "fn" + "define" "defcomp" "defmacro" "defstyle" + "defhandler" "defpage" "defquery" "defaction" + "begin" "do" "quote" "->" "set!" "defisland")) + +(define ASYNC_ASER_HO_NAMES + (list "map" "map-indexed" "filter" "for-each")) + +(define async-aser-form? + (fn (name) + (or (contains? ASYNC_ASER_FORM_NAMES name) + (contains? ASYNC_ASER_HO_NAMES name)))) + + +;; -------------------------------------------------------------------------- +;; dispatch-async-aser-form — evaluate special/HO forms in aser mode +;; -------------------------------------------------------------------------- +;; +;; Uses cond-scheme? from eval.sx (the FIXED version with every? check). + +(define-async dispatch-async-aser-form + (fn (name expr env ctx) + (let ((args (rest expr))) + (cond + ;; if + (= name "if") + (let ((cond-val (async-eval (first args) env ctx))) + (if cond-val + (async-aser (nth args 1) env ctx) + (if (> (len args) 2) + (async-aser (nth args 2) env ctx) + nil))) + + ;; when + (= name "when") + (if (not (async-eval (first args) env ctx)) + nil + (let ((result nil)) + (for-each + (fn (body) (set! result (async-aser body env ctx))) + (rest args)) + result)) + + ;; cond — uses cond-scheme? (every? check) + (= name "cond") + (if (cond-scheme? args) + (async-aser-cond-scheme args env ctx) + (async-aser-cond-clojure args env ctx)) + + ;; case + (= name "case") + (let ((match-val (async-eval (first args) env ctx))) + (async-aser-case-loop match-val (rest args) env ctx)) + + ;; let / let* + (or (= name "let") (= name "let*")) + (let ((local (async-process-bindings (first args) env ctx)) + (result nil)) + (for-each + (fn (body) (set! result (async-aser body local ctx))) + (rest args)) + result) + + ;; begin / do + (or (= name "begin") (= name "do")) + (let ((result nil)) + (for-each + (fn (body) (set! result (async-aser body env ctx))) + args) + result) + + ;; and — short-circuit via flag to avoid 'some' with async lambda + (= name "and") + (let ((result true) + (stop false)) + (for-each (fn (arg) + (when (not stop) + (set! result (async-eval arg env ctx)) + (when (not result) + (set! stop true)))) + args) + result) + + ;; or — short-circuit via flag to avoid 'some' with async lambda + (= name "or") + (let ((result false) + (stop false)) + (for-each (fn (arg) + (when (not stop) + (set! result (async-eval arg env ctx)) + (when result + (set! stop true)))) + args) + result) + + ;; lambda / fn + (or (= name "lambda") (= name "fn")) + (sf-lambda args env) + + ;; quote + (= name "quote") + (if (empty? args) nil (first args)) + + ;; -> thread-first + (= name "->") + (async-aser-thread-first args env ctx) + + ;; set! + (= name "set!") + (let ((value (async-eval (nth args 1) env ctx))) + (env-set! env (symbol-name (first args)) value) + value) + + ;; map + (= name "map") + (async-aser-ho-map args env ctx) + + ;; map-indexed + (= name "map-indexed") + (async-aser-ho-map-indexed args env ctx) + + ;; filter + (= name "filter") + (async-eval expr env ctx) + + ;; for-each + (= name "for-each") + (async-aser-ho-for-each args env ctx) + + ;; defisland — evaluate AND serialize + (= name "defisland") + (do (async-eval expr env ctx) + (serialize expr)) + + ;; Definition forms — evaluate for side effects + (or (= name "define") (= name "defcomp") (= name "defmacro") + (= name "defstyle") (= name "defhandler") (= name "defpage") + (= name "defquery") (= name "defaction")) + (do (async-eval expr env ctx) nil) + + ;; Fallback + :else + (async-eval expr env ctx))))) + + +;; -------------------------------------------------------------------------- +;; async-aser-cond-scheme — scheme-style cond for aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-cond-scheme + (fn (clauses env ctx) + (if (empty? clauses) + nil + (let ((clause (first clauses)) + (test (first clause)) + (body (nth clause 1))) + (if (or (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else"))) + (and (= (type-of test) "keyword") + (= (keyword-name test) "else"))) + (async-aser body env ctx) + (if (async-eval test env ctx) + (async-aser body env ctx) + (async-aser-cond-scheme (rest clauses) env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-aser-cond-clojure — clojure-style cond for aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-cond-clojure + (fn (clauses env ctx) + (if (< (len clauses) 2) + nil + (let ((test (first clauses)) + (body (nth clauses 1))) + (if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) + (and (= (type-of test) "symbol") + (or (= (symbol-name test) "else") + (= (symbol-name test) ":else")))) + (async-aser body env ctx) + (if (async-eval test env ctx) + (async-aser body env ctx) + (async-aser-cond-clojure (slice clauses 2) env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-aser-case-loop — case dispatch for aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-case-loop + (fn (match-val clauses env ctx) + (if (< (len clauses) 2) + nil + (let ((test (first clauses)) + (body (nth clauses 1))) + (if (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) + (and (= (type-of test) "symbol") + (or (= (symbol-name test) ":else") + (= (symbol-name test) "else")))) + (async-aser body env ctx) + (if (= match-val (async-eval test env ctx)) + (async-aser body env ctx) + (async-aser-case-loop match-val (slice clauses 2) env ctx))))))) + + +;; -------------------------------------------------------------------------- +;; async-aser-thread-first — -> form in aser mode +;; -------------------------------------------------------------------------- + +(define-async async-aser-thread-first + (fn (args env ctx) + (let ((result (async-eval (first args) env ctx))) + (for-each + (fn (form) + (if (= (type-of form) "list") + (let ((f (async-eval (first form) env ctx)) + (fn-args (cons result + (async-eval-args (rest form) env ctx)))) + (set! result (async-invoke-or-lambda f fn-args env ctx))) + (let ((f (async-eval form env ctx))) + (set! result (async-invoke-or-lambda f (list result) env ctx))))) + (rest args)) + result))) + + +;; -------------------------------------------------------------------------- +;; async-invoke-or-lambda — invoke a callable or lambda with args +;; -------------------------------------------------------------------------- + +(define-async async-invoke-or-lambda + (fn (f args env ctx) + (cond + (and (callable? f) (not (lambda? f)) (not (component? f))) + (let ((r (apply f args))) + (if (async-coroutine? r) + (async-await! r) + r)) + (lambda? f) + (let ((local (env-merge (lambda-closure f) env))) + (for-each-indexed + (fn (i p) (env-set! local p (nth args i))) + (lambda-params f)) + (async-eval (lambda-body f) local ctx)) + :else + (error (str "-> form not callable: " (inspect f)))))) + + +;; -------------------------------------------------------------------------- +;; Async aser HO forms (map, map-indexed, for-each) +;; -------------------------------------------------------------------------- + +(define-async async-aser-ho-map + (fn (args env ctx) + (let ((f (async-eval (first args) env ctx)) + (coll (async-eval (nth args 1) env ctx)) + (results (list))) + (for-each + (fn (item) + (if (lambda? f) + (let ((local (env-merge (lambda-closure f) env))) + (env-set! local (first (lambda-params f)) item) + (append! results (async-aser (lambda-body f) local ctx))) + (append! results (async-invoke f item)))) + coll) + results))) + + +(define-async async-aser-ho-map-indexed + (fn (args env ctx) + (let ((f (async-eval (first args) env ctx)) + (coll (async-eval (nth args 1) env ctx)) + (results (list)) + (i 0)) + (for-each + (fn (item) + (if (lambda? f) + (let ((local (env-merge (lambda-closure f) env))) + (env-set! local (first (lambda-params f)) i) + (env-set! local (nth (lambda-params f) 1) item) + (append! results (async-aser (lambda-body f) local ctx))) + (append! results (async-invoke f i item))) + (set! i (inc i))) + coll) + results))) + + +(define-async async-aser-ho-for-each + (fn (args env ctx) + (let ((f (async-eval (first args) env ctx)) + (coll (async-eval (nth args 1) env ctx)) + (results (list))) + (for-each + (fn (item) + (if (lambda? f) + (let ((local (env-merge (lambda-closure f) env))) + (env-set! local (first (lambda-params f)) item) + (append! results (async-aser (lambda-body f) local ctx))) + (append! results (async-invoke f item)))) + coll) + results))) + + +;; -------------------------------------------------------------------------- +;; Platform interface — async adapter +;; -------------------------------------------------------------------------- +;; +;; Async evaluation (provided by platform): +;; (async-eval expr env ctx) — evaluate with I/O interception +;; (execute-io name args kw ctx) — execute I/O primitive +;; (io-primitive? name) — check if name is I/O primitive +;; +;; From eval.sx: +;; cond-scheme?, eval-cond-scheme, eval-cond-clojure +;; eval-expr, trampoline, expand-macro, sf-lambda +;; env-has?, env-get, env-set!, env-merge +;; lambda?, component?, island?, macro?, callable? +;; lambda-closure, lambda-params, lambda-body +;; component-params, component-body, component-closure, +;; component-has-children?, component-name +;; inspect +;; +;; From render.sx: +;; HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS +;; render-attrs, definition-form?, cond-scheme? +;; escape-html, escape-attr, raw-html-content +;; +;; From adapter-html.sx: +;; serialize-island-state +;; +;; Context management (platform): +;; (expand-components?) — check if component expansion is enabled +;; (svg-context?) — check if in SVG context +;; (svg-context-set! val) — set SVG context (returns reset token) +;; (svg-context-reset! token) — reset SVG context +;; (css-class-collect! val) — collect CSS classes +;; +;; Raw HTML: +;; (is-raw-html? x) — check if raw HTML marker +;; (make-raw-html s) — wrap string as raw HTML +;; (raw-html-content x) — unwrap raw HTML +;; +;; SxExpr: +;; (make-sx-expr s) — wrap as SxExpr (wire format string) +;; (sx-expr? x) — check if SxExpr +;; +;; Async primitives: +;; (async-coroutine? x) — check if value is a coroutine +;; (async-await! x) — await a coroutine +;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index e659b99..3e84f4f 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -186,10 +186,15 @@ (attr-expr (nth args (inc (get state "i"))))) (cond ;; Event handler: evaluate eagerly, bind listener + ;; If handler is a 0-arity lambda, wrap to ignore the event arg (starts-with? attr-name "on-") (let ((attr-val (trampoline (eval-expr attr-expr env)))) (when (callable? attr-val) - (dom-listen el (slice attr-name 3) attr-val))) + (dom-listen el (slice attr-name 3) + (if (and (lambda? attr-val) + (= (len (lambda-params attr-val)) 0)) + (fn (e) (call-lambda attr-val (list) (lambda-closure attr-val))) + attr-val)))) ;; Two-way input binding: :bind signal (= attr-name "bind") (let ((attr-val (trampoline (eval-expr attr-expr env)))) diff --git a/shared/sx/ref/async_eval_ref.py b/shared/sx/ref/async_eval_ref.py index af9c903..eebe2b8 100644 --- a/shared/sx/ref/async_eval_ref.py +++ b/shared/sx/ref/async_eval_ref.py @@ -26,7 +26,7 @@ import contextvars import inspect from typing import Any -from ..types import Component, Keyword, Lambda, Macro, NIL, Symbol +from ..types import Component, Island, Keyword, Lambda, Macro, NIL, Symbol from ..parser import SxExpr, serialize from ..primitives_io import IO_PRIMITIVES, RequestContext, execute_io from ..html import ( @@ -210,9 +210,11 @@ async def _arender_list(expr, env, ctx): if name in HTML_TAGS: return await _arender_element(name, expr[1:], env, ctx) - # Component + # Component / Island if name.startswith("~"): val = env.get(name) + if isinstance(val, Island): + return sx_ref.render_html_island(val, expr[1:], env) if isinstance(val, Component): return await _arender_component(val, expr[1:], env, ctx) @@ -455,6 +457,7 @@ _ASYNC_RENDER_FORMS = { "defcomp": _arsf_define, "defmacro": _arsf_define, "defhandler": _arsf_define, + "defisland": _arsf_define, "map": _arsf_map, "map-indexed": _arsf_map_indexed, "filter": _arsf_filter, @@ -505,6 +508,8 @@ async def _eval_slot_inner(expr, env, ctx): if isinstance(result, str): return SxExpr(result) return SxExpr(serialize(result)) + elif isinstance(comp, Island): + pass # Islands serialize as SX for client hydration result = await _aser(expr, env, ctx) result = await _maybe_expand_component_result(result, env, ctx) if isinstance(result, SxExpr): @@ -530,6 +535,9 @@ async def _maybe_expand_component_result(result, env, ctx): return result +_aser_stack: list[str] = [] # diagnostic: track expression context + + async def _aser(expr, env, ctx): """Evaluate for SX wire format — serialize rendering forms, evaluate control flow.""" if isinstance(expr, (int, float, bool)): @@ -553,7 +561,8 @@ async def _aser(expr, env, ctx): return False if name == "nil": return NIL - raise EvalError(f"Undefined symbol: {name}") + ctx_info = " → ".join(_aser_stack[-5:]) if _aser_stack else "(top)" + raise EvalError(f"Undefined symbol: {name} [aser context: {ctx_info}]") if isinstance(expr, Keyword): return expr.name @@ -590,7 +599,7 @@ async def _aser(expr, env, ctx): if name.startswith("html:"): return await _aser_call(name[5:], expr[1:], env, ctx) - # Component call + # Component / Island call if name.startswith("~"): val = env.get(name) if isinstance(val, Macro): @@ -598,7 +607,10 @@ async def _aser(expr, env, ctx): sx_ref.expand_macro(val, expr[1:], env) ) return await _aser(expanded, env, ctx) - if isinstance(val, Component) and _expand_components.get(): + if isinstance(val, Component) and ( + _expand_components.get() + or getattr(val, "render_target", None) == "server" + ): return await _aser_component(val, expr[1:], env, ctx) return await _aser_call(name, expr[1:], env, ctx) @@ -633,11 +645,11 @@ async def _aser(expr, env, ctx): if _svg_context.get(False): return await _aser_call(name, expr[1:], env, ctx) - # Function/lambda call + # Function/lambda call — fallback: evaluate head as callable fn = await async_eval(head, env, ctx) args = [await async_eval(a, env, ctx) for a in expr[1:]] - if callable(fn) and not isinstance(fn, (Lambda, Component)): + if callable(fn) and not isinstance(fn, (Lambda, Component, Island)): result = fn(*args) if inspect.iscoroutine(result): return await result @@ -650,7 +662,9 @@ async def _aser(expr, env, ctx): return await _aser(fn.body, local, ctx) if isinstance(fn, Component): return await _aser_call(f"~{fn.name}", expr[1:], env, ctx) - raise EvalError(f"Not callable: {fn!r}") + if isinstance(fn, Island): + return await _aser_call(f"~{fn.name}", expr[1:], env, ctx) + raise EvalError(f"Not callable in aser: {fn!r} (expr head: {head!r})") async def _aser_fragment(children, env, ctx): @@ -669,28 +683,41 @@ async def _aser_fragment(children, env, ctx): async def _aser_component(comp, args, env, ctx): - kwargs = {} - children = [] - i = 0 - while i < len(args): - arg = args[i] - if isinstance(arg, Keyword) and i + 1 < len(args): - kwargs[arg.name] = await _aser(args[i + 1], env, ctx) - i += 2 - else: - children.append(arg) - i += 1 - local = dict(comp.closure) - local.update(env) - for p in comp.params: - local[p] = kwargs.get(p, NIL) - if comp.has_children: - child_parts = [serialize(await _aser(c, env, ctx)) for c in children] - local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")") - return await _aser(comp.body, local, ctx) + _aser_stack.append(f"~{comp.name}") + try: + kwargs = {} + children = [] + i = 0 + while i < len(args): + arg = args[i] + if isinstance(arg, Keyword) and i + 1 < len(args): + kwargs[arg.name] = await _aser(args[i + 1], env, ctx) + i += 2 + else: + children.append(arg) + i += 1 + local = dict(comp.closure) + local.update(env) + for p in comp.params: + local[p] = kwargs.get(p, NIL) + if comp.has_children: + child_parts = [] + for c in children: + result = await _aser(c, env, ctx) + if isinstance(result, list): + for item in result: + if item is not NIL and item is not None: + child_parts.append(serialize(item)) + elif result is not NIL and result is not None: + child_parts.append(serialize(result)) + local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")") + return await _aser(comp.body, local, ctx) + finally: + _aser_stack.pop() async def _aser_call(name, args, env, ctx): + _aser_stack.append(name) token = None if name in ("svg", "math"): token = _svg_context.set(True) @@ -730,6 +757,7 @@ async def _aser_call(name, args, env, ctx): _merge_class_into_parts(parts, extra_class) return SxExpr("(" + " ".join(parts) + ")") finally: + _aser_stack.pop() if token is not None: _svg_context.reset(token) @@ -885,7 +913,7 @@ async def _assf_thread_first(expr, env, ctx): else: fn = await async_eval(form, env, ctx) fn_args = [result] - if callable(fn) and not isinstance(fn, (Lambda, Component)): + if callable(fn) and not isinstance(fn, (Lambda, Component, Island)): result = fn(*fn_args) if inspect.iscoroutine(result): result = await result @@ -981,6 +1009,7 @@ _ASER_FORMS = { "defcomp": _assf_define, "defmacro": _assf_define, "defhandler": _assf_define, + "defisland": _assf_define, "begin": _assf_begin, "do": _assf_begin, "quote": _assf_quote, diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 20c2383..875b381 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -1196,6 +1196,8 @@ def compile_ref_to_py( spec_mod_set.add("deps") if "signals" in SPEC_MODULES: spec_mod_set.add("signals") + if "page-helpers" in SPEC_MODULES: + spec_mod_set.add("page-helpers") has_deps = "deps" in spec_mod_set # Core files always included, then selected adapters, then spec modules diff --git a/shared/sx/ref/page-helpers.sx b/shared/sx/ref/page-helpers.sx new file mode 100644 index 0000000..69e2ba1 --- /dev/null +++ b/shared/sx/ref/page-helpers.sx @@ -0,0 +1,368 @@ +;; ========================================================================== +;; page-helpers.sx — Pure data-transformation page helpers +;; +;; These functions take raw data (from Python I/O edge) and return +;; structured dicts for page rendering. No I/O — pure transformations +;; only. Bootstrapped to every host. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; categorize-special-forms +;; +;; Parses define-special-form declarations from special-forms.sx AST, +;; categorizes each form by name lookup, returns dict of category → forms. +;; -------------------------------------------------------------------------- + +(define special-form-category-map + {"if" "Control Flow" "when" "Control Flow" "cond" "Control Flow" + "case" "Control Flow" "and" "Control Flow" "or" "Control Flow" + "let" "Binding" "let*" "Binding" "letrec" "Binding" + "define" "Binding" "set!" "Binding" + "lambda" "Functions & Components" "fn" "Functions & Components" + "defcomp" "Functions & Components" "defmacro" "Functions & Components" + "begin" "Sequencing & Threading" "do" "Sequencing & Threading" + "->" "Sequencing & Threading" + "quote" "Quoting" "quasiquote" "Quoting" + "reset" "Continuations" "shift" "Continuations" + "dynamic-wind" "Guards" + "map" "Higher-Order Forms" "map-indexed" "Higher-Order Forms" + "filter" "Higher-Order Forms" "reduce" "Higher-Order Forms" + "some" "Higher-Order Forms" "every?" "Higher-Order Forms" + "for-each" "Higher-Order Forms" + "defstyle" "Domain Definitions" + "defhandler" "Domain Definitions" "defpage" "Domain Definitions" + "defquery" "Domain Definitions" "defaction" "Domain Definitions"}) + + +(define extract-define-kwargs + (fn (expr) + ;; Extract keyword args from a define-special-form expression. + ;; Returns dict of keyword-name → string value. + ;; Walks items pairwise: when item[i] is a keyword, item[i+1] is its value. + (let ((result {}) + (items (slice expr 2)) + (n (len items))) + (for-each + (fn (idx) + (when (and (< (+ idx 1) n) + (= (type-of (nth items idx)) "keyword")) + (let ((key (keyword-name (nth items idx))) + (val (nth items (+ idx 1)))) + (dict-set! result key + (if (= (type-of val) "list") + (str "(" (join " " (map serialize val)) ")") + (str val)))))) + (range 0 n)) + result))) + + +(define categorize-special-forms + (fn (parsed-exprs) + ;; parsed-exprs: result of parse-all on special-forms.sx + ;; Returns dict of category-name → list of form dicts. + (let ((categories {})) + (for-each + (fn (expr) + (when (and (= (type-of expr) "list") + (>= (len expr) 2) + (= (type-of (first expr)) "symbol") + (= (symbol-name (first expr)) "define-special-form")) + (let ((name (nth expr 1)) + (kwargs (extract-define-kwargs expr)) + (category (or (get special-form-category-map name) "Other"))) + (when (not (has-key? categories category)) + (dict-set! categories category (list))) + (append! (get categories category) + {"name" name + "syntax" (or (get kwargs "syntax") "") + "doc" (or (get kwargs "doc") "") + "tail-position" (or (get kwargs "tail-position") "") + "example" (or (get kwargs "example") "")})))) + parsed-exprs) + categories))) + + +;; -------------------------------------------------------------------------- +;; build-reference-data +;; +;; Takes a slug and raw reference data, returns structured dict for rendering. +;; -------------------------------------------------------------------------- + +(define build-ref-items-with-href + (fn (items base-path detail-keys n-fields) + ;; items: list of lists (tuples), each with n-fields elements + ;; base-path: e.g. "/hypermedia/reference/attributes/" + ;; detail-keys: list of strings (keys that have detail pages) + ;; n-fields: 2 or 3 (number of fields per tuple) + (map + (fn (item) + (if (= n-fields 3) + ;; [name, desc/value, exists/desc] + (let ((name (nth item 0)) + (field2 (nth item 1)) + (field3 (nth item 2))) + {"name" name + "desc" field2 + "exists" field3 + "href" (if (and field3 (some (fn (k) (= k name)) detail-keys)) + (str base-path name) + nil)}) + ;; [name, desc] + (let ((name (nth item 0)) + (desc (nth item 1))) + {"name" name + "desc" desc + "href" (if (some (fn (k) (= k name)) detail-keys) + (str base-path name) + nil)}))) + items))) + + +(define build-reference-data + (fn (slug raw-data detail-keys) + ;; slug: "attributes", "headers", "events", "js-api" + ;; raw-data: dict with the raw data lists for this slug + ;; detail-keys: list of names that have detail pages + (case slug + "attributes" + {"req-attrs" (build-ref-items-with-href + (get raw-data "req-attrs") + "/hypermedia/reference/attributes/" detail-keys 3) + "beh-attrs" (build-ref-items-with-href + (get raw-data "beh-attrs") + "/hypermedia/reference/attributes/" detail-keys 3) + "uniq-attrs" (build-ref-items-with-href + (get raw-data "uniq-attrs") + "/hypermedia/reference/attributes/" detail-keys 3)} + + "headers" + {"req-headers" (build-ref-items-with-href + (get raw-data "req-headers") + "/hypermedia/reference/headers/" detail-keys 3) + "resp-headers" (build-ref-items-with-href + (get raw-data "resp-headers") + "/hypermedia/reference/headers/" detail-keys 3)} + + "events" + {"events-list" (build-ref-items-with-href + (get raw-data "events-list") + "/hypermedia/reference/events/" detail-keys 2)} + + "js-api" + {"js-api-list" (map (fn (item) {"name" (nth item 0) "desc" (nth item 1)}) + (get raw-data "js-api-list"))} + + ;; default: attributes + :else + {"req-attrs" (build-ref-items-with-href + (get raw-data "req-attrs") + "/hypermedia/reference/attributes/" detail-keys 3) + "beh-attrs" (build-ref-items-with-href + (get raw-data "beh-attrs") + "/hypermedia/reference/attributes/" detail-keys 3) + "uniq-attrs" (build-ref-items-with-href + (get raw-data "uniq-attrs") + "/hypermedia/reference/attributes/" detail-keys 3)}))) + + +;; -------------------------------------------------------------------------- +;; build-attr-detail / build-header-detail / build-event-detail +;; +;; Lookup a slug in a detail dict, reshape for page rendering. +;; -------------------------------------------------------------------------- + +(define build-attr-detail + (fn (slug detail) + ;; detail: dict with "description", "example", "handler", "demo" keys or nil + (if (nil? detail) + {"attr-not-found" true} + {"attr-not-found" nil + "attr-title" slug + "attr-description" (get detail "description") + "attr-example" (get detail "example") + "attr-handler" (get detail "handler") + "attr-demo" (get detail "demo") + "attr-wire-id" (if (has-key? detail "handler") + (str "ref-wire-" + (replace (replace slug ":" "-") "*" "star")) + nil)}))) + + +(define build-header-detail + (fn (slug detail) + (if (nil? detail) + {"header-not-found" true} + {"header-not-found" nil + "header-title" slug + "header-direction" (get detail "direction") + "header-description" (get detail "description") + "header-example" (get detail "example") + "header-demo" (get detail "demo")}))) + + +(define build-event-detail + (fn (slug detail) + (if (nil? detail) + {"event-not-found" true} + {"event-not-found" nil + "event-title" slug + "event-description" (get detail "description") + "event-example" (get detail "example") + "event-demo" (get detail "demo")}))) + + +;; -------------------------------------------------------------------------- +;; build-component-source +;; +;; Reconstruct defcomp/defisland source from component metadata. +;; -------------------------------------------------------------------------- + +(define build-component-source + (fn (comp-data) + ;; comp-data: dict with "type", "name", "params", "has-children", "body-sx", "affinity" + (let ((comp-type (get comp-data "type")) + (name (get comp-data "name")) + (params (get comp-data "params")) + (has-children (get comp-data "has-children")) + (body-sx (get comp-data "body-sx")) + (affinity (get comp-data "affinity"))) + (if (= comp-type "not-found") + (str ";; component " name " not found") + (let ((param-strs (if (empty? params) + (if has-children + (list "&rest" "children") + (list)) + (if has-children + (append (cons "&key" params) (list "&rest" "children")) + (cons "&key" params)))) + (params-sx (str "(" (join " " param-strs) ")")) + (form-name (if (= comp-type "island") "defisland" "defcomp")) + (affinity-str (if (and (= comp-type "component") + (not (nil? affinity)) + (not (= affinity "auto"))) + (str " :affinity " affinity) + ""))) + (str "(" form-name " " name " " params-sx affinity-str "\n " body-sx ")")))))) + + +;; -------------------------------------------------------------------------- +;; build-bundle-analysis +;; +;; Compute per-page bundle stats from pre-extracted component data. +;; -------------------------------------------------------------------------- + +(define build-bundle-analysis + (fn (pages-raw components-raw total-components total-macros pure-count io-count) + ;; pages-raw: list of {:name :path :direct :needed-names} + ;; components-raw: dict of name → {:is-pure :affinity :render-target :io-refs :deps :source} + (let ((pages-data (list))) + (for-each + (fn (page) + (let ((needed-names (get page "needed-names")) + (n (len needed-names)) + (pct (if (> total-components 0) + (round (* (/ n total-components) 100)) + 0)) + (savings (- 100 pct)) + (pure-in-page 0) + (io-in-page 0) + (page-io-refs (list)) + (comp-details (list))) + ;; Walk needed components + (for-each + (fn (comp-name) + (let ((info (get components-raw comp-name))) + (when (not (nil? info)) + (if (get info "is-pure") + (set! pure-in-page (+ pure-in-page 1)) + (do + (set! io-in-page (+ io-in-page 1)) + (for-each + (fn (ref) (when (not (some (fn (r) (= r ref)) page-io-refs)) + (append! page-io-refs ref))) + (or (get info "io-refs") (list))))) + (append! comp-details + {"name" comp-name + "is-pure" (get info "is-pure") + "affinity" (get info "affinity") + "render-target" (get info "render-target") + "io-refs" (or (get info "io-refs") (list)) + "deps" (or (get info "deps") (list)) + "source" (get info "source")})))) + needed-names) + (append! pages-data + {"name" (get page "name") + "path" (get page "path") + "direct" (get page "direct") + "needed" n + "pct" pct + "savings" savings + "io-refs" (len page-io-refs) + "pure-in-page" pure-in-page + "io-in-page" io-in-page + "components" comp-details}))) + pages-raw) + {"pages" pages-data + "total-components" total-components + "total-macros" total-macros + "pure-count" pure-count + "io-count" io-count}))) + + +;; -------------------------------------------------------------------------- +;; build-routing-analysis +;; +;; Classify pages by routing mode (client vs server). +;; -------------------------------------------------------------------------- + +(define build-routing-analysis + (fn (pages-raw) + ;; pages-raw: list of {:name :path :has-data :content-src} + (let ((pages-data (list)) + (client-count 0) + (server-count 0)) + (for-each + (fn (page) + (let ((has-data (get page "has-data")) + (content-src (or (get page "content-src") "")) + (mode nil) + (reason "")) + (cond + has-data + (do (set! mode "server") + (set! reason "Has :data expression — needs server IO") + (set! server-count (+ server-count 1))) + (empty? content-src) + (do (set! mode "server") + (set! reason "No content expression") + (set! server-count (+ server-count 1))) + :else + (do (set! mode "client") + (set! client-count (+ client-count 1)))) + (append! pages-data + {"name" (get page "name") + "path" (get page "path") + "mode" mode + "has-data" has-data + "content-expr" (if (> (len content-src) 80) + (str (slice content-src 0 80) "...") + content-src) + "reason" reason}))) + pages-raw) + {"pages" pages-data + "total-pages" (+ client-count server-count) + "client-count" client-count + "server-count" server-count}))) + + +;; -------------------------------------------------------------------------- +;; build-affinity-analysis +;; +;; Package component affinity info + page render plans for display. +;; -------------------------------------------------------------------------- + +(define build-affinity-analysis + (fn (demo-components page-plans) + {"components" demo-components + "page-plans" page-plans})) diff --git a/shared/sx/ref/platform_js.py b/shared/sx/ref/platform_js.py new file mode 100644 index 0000000..9dd2881 --- /dev/null +++ b/shared/sx/ref/platform_js.py @@ -0,0 +1,3163 @@ +""" +JS platform constants and functions for the SX bootstrap compiler. + +This module contains all platform-specific JS code (string constants, helper +functions, and configuration dicts) shared by bootstrap_js.py and run_js_sx.py. +The JSEmitter class, compile_ref_to_js function, and main entry point remain +in bootstrap_js.py. +""" +from __future__ import annotations + +from shared.sx.parser import parse_all +from shared.sx.types import Symbol + + +def extract_defines(source: str) -> list[tuple[str, list]]: + """Parse .sx source, return list of (name, define-expr) for top-level defines.""" + exprs = parse_all(source) + defines = [] + for expr in exprs: + if isinstance(expr, list) and expr and isinstance(expr[0], Symbol): + if expr[0].name == "define": + name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1]) + defines.append((name, expr)) + return defines + +ADAPTER_FILES = { + "parser": ("parser.sx", "parser"), + "html": ("adapter-html.sx", "adapter-html"), + "sx": ("adapter-sx.sx", "adapter-sx"), + "dom": ("adapter-dom.sx", "adapter-dom"), + "engine": ("engine.sx", "engine"), + "orchestration": ("orchestration.sx","orchestration"), + "boot": ("boot.sx", "boot"), +} + +# Dependencies +ADAPTER_DEPS = { + "engine": ["dom"], + "orchestration": ["engine", "dom"], + "boot": ["dom", "engine", "orchestration", "parser"], + "parser": [], +} + +SPEC_MODULES = { + "deps": ("deps.sx", "deps (component dependency analysis)"), + "router": ("router.sx", "router (client-side route matching)"), + "signals": ("signals.sx", "signals (reactive signal runtime)"), + "page-helpers": ("page-helpers.sx", "page-helpers (pure data transformation helpers)"), +} + + +EXTENSION_NAMES = {"continuations"} +CONTINUATIONS_JS = ''' + // ========================================================================= + // Extension: Delimited continuations (shift/reset) + // ========================================================================= + + function Continuation(fn) { this.fn = fn; } + Continuation.prototype._continuation = true; + Continuation.prototype.call = function(value) { return this.fn(value !== undefined ? value : NIL); }; + + function ShiftSignal(kName, body, env) { + this.kName = kName; + this.body = body; + this.env = env; + } + + PRIMITIVES["continuation?"] = function(x) { return x != null && x._continuation === true; }; + + var _resetResume = []; + + function sfReset(args, env) { + var body = args[0]; + try { + return trampoline(evalExpr(body, env)); + } catch (e) { + if (e instanceof ShiftSignal) { + var sig = e; + var cont = new Continuation(function(value) { + if (value === undefined) value = NIL; + _resetResume.push(value); + try { + return trampoline(evalExpr(body, env)); + } finally { + _resetResume.pop(); + } + }); + var sigEnv = merge(sig.env); + sigEnv[sig.kName] = cont; + return trampoline(evalExpr(sig.body, sigEnv)); + } + throw e; + } + } + + function sfShift(args, env) { + if (_resetResume.length > 0) { + return _resetResume[_resetResume.length - 1]; + } + var kName = symbolName(args[0]); + var body = args[1]; + throw new ShiftSignal(kName, body, env); + } + + // Wrap evalList to intercept reset/shift + var _baseEvalList = evalList; + evalList = function(expr, env) { + var head = expr[0]; + if (isSym(head)) { + var name = head.name; + if (name === "reset") return sfReset(expr.slice(1), env); + if (name === "shift") return sfShift(expr.slice(1), env); + } + return _baseEvalList(expr, env); + }; + + // Wrap aserSpecial to handle reset/shift in SX wire mode + if (typeof aserSpecial === "function") { + var _baseAserSpecial = aserSpecial; + aserSpecial = function(name, expr, env) { + if (name === "reset") return sfReset(expr.slice(1), env); + if (name === "shift") return sfShift(expr.slice(1), env); + return _baseAserSpecial(name, expr, env); + }; + } + + // Wrap typeOf to recognize continuations + var _baseTypeOf = typeOf; + typeOf = function(x) { + if (x != null && x._continuation) return "continuation"; + return _baseTypeOf(x); + }; +''' + +ASYNC_IO_JS = ''' + // ========================================================================= + // Async IO: Promise-aware rendering for client-side IO primitives + // ========================================================================= + // + // IO primitives (query, current-user, etc.) return Promises on the client. + // asyncRenderToDom walks the component tree; when it encounters an IO + // primitive, it awaits the Promise and continues rendering. + // + // The sync evaluator/renderer is untouched. This is a separate async path + // used only when a page's component tree contains IO references. + + var IO_PRIMITIVES = {}; + + function registerIoPrimitive(name, fn) { + IO_PRIMITIVES[name] = fn; + } + + function isPromise(x) { + return x != null && typeof x === "object" && typeof x.then === "function"; + } + + // Async trampoline: resolves thunks, awaits Promises + function asyncTrampoline(val) { + if (isPromise(val)) return val.then(asyncTrampoline); + if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val))); + return val; + } + + // Async eval: like trampoline(evalExpr(...)) but handles IO primitives + function asyncEval(expr, env) { + // Intercept IO primitive calls at the AST level + if (Array.isArray(expr) && expr.length > 0) { + var head = expr[0]; + if (head && head._sym) { + var name = head.name; + if (IO_PRIMITIVES[name]) { + // Evaluate args, then call the IO primitive + return asyncEvalIoCall(name, expr.slice(1), env); + } + } + } + // Non-IO: use sync eval, but result might be a thunk + var result = evalExpr(expr, env); + return asyncTrampoline(result); + } + + function asyncEvalIoCall(name, rawArgs, env) { + // Parse keyword args and positional args, evaluating each (may be async) + var kwargs = {}; + var args = []; + var promises = []; + var i = 0; + while (i < rawArgs.length) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + var kName = arg.name; + var kVal = asyncEval(rawArgs[i + 1], env); + if (isPromise(kVal)) { + (function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i += 2; + } else { + var aVal = asyncEval(arg, env); + if (isPromise(aVal)) { + (function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length); + args.push(null); // placeholder + } else { + args.push(aVal); + } + i++; + } + } + var ioFn = IO_PRIMITIVES[name]; + if (promises.length > 0) { + return Promise.all(promises).then(function() { return ioFn(args, kwargs); }); + } + return ioFn(args, kwargs); + } + + // Async render-to-dom: returns Promise or Node + function asyncRenderToDom(expr, env, ns) { + // Literals + if (expr === NIL || expr === null || expr === undefined) return null; + if (expr === true || expr === false) return null; + if (typeof expr === "string") return document.createTextNode(expr); + if (typeof expr === "number") return document.createTextNode(String(expr)); + + // Symbol -> async eval then render + if (expr && expr._sym) { + var val = asyncEval(expr, env); + if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(val, env, ns); + } + + // Keyword + if (expr && expr._kw) return document.createTextNode(expr.name); + + // DocumentFragment / DOM nodes pass through + if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr; + + // Dict -> skip + if (expr && typeof expr === "object" && !Array.isArray(expr)) return null; + + // List + if (!Array.isArray(expr) || expr.length === 0) return null; + + var head = expr[0]; + if (!head) return null; + + // Symbol head + if (head._sym) { + var hname = head.name; + + // IO primitive + if (IO_PRIMITIVES[hname]) { + var ioResult = asyncEval(expr, env); + if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(ioResult, env, ns); + } + + // Fragment + if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns); + + // raw! + if (hname === "raw!") { + return asyncEvalRaw(expr.slice(1), env); + } + + // Special forms that need async handling + if (hname === "if") return asyncRenderIf(expr, env, ns); + if (hname === "when") return asyncRenderWhen(expr, env, ns); + if (hname === "cond") return asyncRenderCond(expr, env, ns); + if (hname === "case") return asyncRenderCase(expr, env, ns); + if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns); + if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns); + if (hname === "map") return asyncRenderMap(expr, env, ns); + if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns); + if (hname === "for-each") return asyncRenderMap(expr, env, ns); + + // define/defcomp/defmacro — eval for side effects + if (hname === "define" || hname === "defcomp" || hname === "defmacro" || + hname === "defstyle" || hname === "defhandler") { + trampoline(evalExpr(expr, env)); + return null; + } + + // quote + if (hname === "quote") return null; + + // lambda/fn + if (hname === "lambda" || hname === "fn") { + trampoline(evalExpr(expr, env)); + return null; + } + + // and/or — eval and render result + if (hname === "and" || hname === "or" || hname === "->") { + var aoResult = asyncEval(expr, env); + if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(aoResult, env, ns); + } + + // set! + if (hname === "set!") { + asyncEval(expr, env); + return null; + } + + // Component or Island + if (hname.charAt(0) === "~") { + var comp = env[hname]; + if (comp && comp._island) return renderDomIsland(comp, expr.slice(1), env, ns); + if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns); + if (comp && comp._macro) { + var expanded = trampoline(expandMacro(comp, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + } + + // Macro + if (env[hname] && env[hname]._macro) { + var mac = env[hname]; + var expanded = trampoline(expandMacro(mac, expr.slice(1), env)); + return asyncRenderToDom(expanded, env, ns); + } + + // HTML tag + if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // html: prefix + if (hname.indexOf("html:") === 0) { + return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns); + } + + // Custom element + if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) { + return asyncRenderElement(hname, expr.slice(1), env, ns); + } + + // SVG context + if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns); + + // Fallback: eval and render + var fResult = asyncEval(expr, env); + if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(fResult, env, ns); + } + + // Non-symbol head: eval call + var cResult = asyncEval(expr, env); + if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); }); + return asyncRenderToDom(cResult, env, ns); + } + + function asyncRenderChildren(exprs, env, ns) { + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < exprs.length; i++) { + var result = asyncRenderToDom(exprs[i], env, ns); + if (isPromise(result)) { + // Insert placeholder, replace when resolved + var placeholder = document.createComment("async"); + frag.appendChild(placeholder); + (function(ph) { + pending.push(result.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length > 0) { + return Promise.all(pending).then(function() { return frag; }); + } + return frag; + } + + function asyncRenderElement(tag, args, env, ns) { + var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns; + var el = domCreateElement(tag, newNs); + var pending = []; + var isVoid = contains(VOID_ELEMENTS, tag); + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var attrName = arg.name; + var attrVal = asyncEval(args[i + 1], env); + i++; + if (isPromise(attrVal)) { + (function(an, av) { + pending.push(av.then(function(v) { + if (!isNil(v) && v !== false) { + if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); } + else if (v === true) el.setAttribute(an, ""); + else el.setAttribute(an, String(v)); + } + })); + })(attrName, attrVal); + } else { + if (!isNil(attrVal) && attrVal !== false) { + if (contains(BOOLEAN_ATTRS, attrName)) { + if (isSxTruthy(attrVal)) el.setAttribute(attrName, ""); + } else if (attrVal === true) { + el.setAttribute(attrName, ""); + } else { + el.setAttribute(attrName, String(attrVal)); + } + } + } + } else if (!isVoid) { + var child = asyncRenderToDom(arg, env, newNs); + if (isPromise(child)) { + var placeholder = document.createComment("async"); + el.appendChild(placeholder); + (function(ph) { + pending.push(child.then(function(node) { + if (node) ph.parentNode.replaceChild(node, ph); + else ph.parentNode.removeChild(ph); + })); + })(placeholder); + } else if (child) { + el.appendChild(child); + } + } + } + if (pending.length > 0) return Promise.all(pending).then(function() { return el; }); + return el; + } + + function asyncRenderComponent(comp, args, env, ns) { + var kwargs = {}; + var children = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var arg = args[i]; + if (arg && arg._kw && (i + 1) < args.length) { + var kName = arg.name; + var kVal = asyncEval(args[i + 1], env); + if (isPromise(kVal)) { + (function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName); + } else { + kwargs[kName] = kVal; + } + i++; + } else { + children.push(arg); + } + } + + function doRender() { + var local = Object.create(componentClosure(comp)); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL; + } + if (componentHasChildren(comp)) { + var childResult = asyncRenderChildren(children, env, ns); + if (isPromise(childResult)) { + return childResult.then(function(childFrag) { + local["children"] = childFrag; + return asyncRenderToDom(componentBody(comp), local, ns); + }); + } + local["children"] = childResult; + } + return asyncRenderToDom(componentBody(comp), local, ns); + } + + if (pending.length > 0) return Promise.all(pending).then(doRender); + return doRender(); + } + + function asyncRenderIf(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + }); + } + return isSxTruthy(cond) + ? asyncRenderToDom(expr[2], env, ns) + : (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null); + } + + function asyncRenderWhen(expr, env, ns) { + var cond = asyncEval(expr[1], env); + if (isPromise(cond)) { + return cond.then(function(v) { + return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + }); + } + return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null; + } + + function asyncRenderCond(expr, env, ns) { + var clauses = expr.slice(1); + function step(idx) { + if (idx >= clauses.length) return null; + var clause = clauses[idx]; + if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1); + var test = clause[0]; + if ((test && test._sym && (test.name === "else" || test.name === ":else")) || + (test && test._kw && test.name === "else")) { + return asyncRenderToDom(clause[1], env, ns); + } + var v = asyncEval(test, env); + if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); }); + return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); + } + return step(0); + } + + function asyncRenderCase(expr, env, ns) { + var matchVal = asyncEval(expr[1], env); + function doCase(mv) { + var clauses = expr.slice(2); + for (var i = 0; i < clauses.length - 1; i += 2) { + var test = clauses[i]; + if ((test && test._kw && test.name === "else") || + (test && test._sym && (test.name === "else" || test.name === ":else"))) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + var tv = trampoline(evalExpr(test, env)); + if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) { + return asyncRenderToDom(clauses[i + 1], env, ns); + } + } + return null; + } + if (isPromise(matchVal)) return matchVal.then(doCase); + return doCase(matchVal); + } + + function asyncRenderLet(expr, env, ns) { + var bindings = expr[1]; + var local = Object.create(env); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + function bindStep(idx) { + if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns); + // Nested pairs: ((a 1) (b 2)) + if (bindings.length > 0 && Array.isArray(bindings[0])) { + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var b = bindings[idx]; + var vname = b[0]._sym ? b[0].name : String(b[0]); + var val = asyncEval(b[1], local); + if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); }); + local[vname] = val; + return bindStep(idx + 1); + } + // Flat pairs: (a 1 b 2) + if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns); + var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]); + var vv = asyncEval(bindings[idx + 1], local); + if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); }); + local[vn] = vv; + return bindStep(idx + 2); + } + return bindStep(0); + } + + function asyncRenderMap(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f.closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f.params[0]] = item; + result = asyncRenderToDom(f.body, lenv, null); + } else if (typeof f === "function") { + var r = f(item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncRenderMapIndexed(expr, env, ns) { + var fn = asyncEval(expr[1], env); + var coll = asyncEval(expr[2], env); + function doMap(f, c) { + if (!Array.isArray(c)) return null; + var frag = document.createDocumentFragment(); + var pending = []; + for (var i = 0; i < c.length; i++) { + var item = c[i]; + var result; + if (f && f._lambda) { + var lenv = Object.create(f.closure || env); + for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k]; + lenv[f.params[0]] = i; + lenv[f.params[1]] = item; + result = asyncRenderToDom(f.body, lenv, null); + } else if (typeof f === "function") { + var r = f(i, item); + result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null); + } else { + result = asyncRenderToDom(item, env, null); + } + if (isPromise(result)) { + var ph = document.createComment("async"); + frag.appendChild(ph); + (function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph); + } else if (result) { + frag.appendChild(result); + } + } + if (pending.length) return Promise.all(pending).then(function() { return frag; }); + return frag; + } + if (isPromise(fn) || isPromise(coll)) { + return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)]) + .then(function(r) { return doMap(r[0], r[1]); }); + } + return doMap(fn, coll); + } + + function asyncEvalRaw(args, env) { + var parts = []; + var pending = []; + for (var i = 0; i < args.length; i++) { + var val = asyncEval(args[i], env); + if (isPromise(val)) { + (function(idx) { + pending.push(val.then(function(v) { parts[idx] = v; })); + })(parts.length); + parts.push(null); + } else { + parts.push(val); + } + } + function assemble() { + var html = ""; + for (var j = 0; j < parts.length; j++) { + var p = parts[j]; + if (p && p._rawHtml) html += p.html; + else if (typeof p === "string") html += p; + else if (p != null && !isNil(p)) html += String(p); + } + var el = document.createElement("span"); + el.innerHTML = html; + var frag = document.createDocumentFragment(); + while (el.firstChild) frag.appendChild(el.firstChild); + return frag; + } + if (pending.length) return Promise.all(pending).then(assemble); + return assemble(); + } + + // Async version of sxRenderWithEnv — returns Promise + function asyncSxRenderWithEnv(source, extraEnv) { + var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + var exprs = parse(source); + if (!_hasDom) return Promise.resolve(null); + return asyncRenderChildren(exprs, env, null); + } + + // IO proxy cache: key → { value, expires } + var _ioCache = {}; + var IO_CACHE_TTL = 300000; // 5 minutes + + // Register a server-proxied IO primitive: fetches from /sx/io/ + // Uses GET for short args, POST for long payloads (URL length safety). + // Results are cached client-side by (name + args) with a TTL. + function registerProxiedIo(name) { + registerIoPrimitive(name, function(args, kwargs) { + // Cache key: name + serialized args + var cacheKey = name; + for (var ci = 0; ci < args.length; ci++) cacheKey += "\0" + String(args[ci]); + for (var ck in kwargs) { + if (kwargs.hasOwnProperty(ck)) cacheKey += "\0" + ck + "=" + String(kwargs[ck]); + } + var cached = _ioCache[cacheKey]; + if (cached && cached.expires > Date.now()) return cached.value; + + var url = "/sx/io/" + encodeURIComponent(name); + var qs = []; + for (var i = 0; i < args.length; i++) { + qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i]))); + } + for (var k in kwargs) { + if (kwargs.hasOwnProperty(k)) { + qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k]))); + } + } + var queryStr = qs.join("&"); + var fetchOpts; + if (queryStr.length > 1500) { + // POST with JSON body for long payloads + var sArgs = []; + for (var j = 0; j < args.length; j++) sArgs.push(String(args[j])); + var sKwargs = {}; + for (var kk in kwargs) { + if (kwargs.hasOwnProperty(kk)) sKwargs[kk] = String(kwargs[kk]); + } + var postHeaders = { "SX-Request": "true", "Content-Type": "application/json" }; + var csrf = csrfToken(); + if (csrf && csrf !== NIL) postHeaders["X-CSRFToken"] = csrf; + fetchOpts = { + method: "POST", + headers: postHeaders, + body: JSON.stringify({ args: sArgs, kwargs: sKwargs }) + }; + } else { + if (queryStr) url += "?" + queryStr; + fetchOpts = { headers: { "SX-Request": "true" } }; + } + var result = fetch(url, fetchOpts) + .then(function(resp) { + if (!resp.ok) { + logWarn("sx:io " + name + " failed " + resp.status); + return NIL; + } + return resp.text(); + }) + .then(function(text) { + if (!text || text === "nil") return NIL; + try { + var exprs = parse(text); + var val = exprs.length === 1 ? exprs[0] : exprs; + _ioCache[cacheKey] = { value: val, expires: Date.now() + IO_CACHE_TTL }; + return val; + } catch (e) { + logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e)); + return NIL; + } + }) + .catch(function(e) { + logWarn("sx:io " + name + " network error: " + (e && e.message ? e.message : e)); + return NIL; + }); + // Cache the in-flight promise too (dedup concurrent calls for same args) + _ioCache[cacheKey] = { value: result, expires: Date.now() + IO_CACHE_TTL }; + return result; + }); + } + + // Register IO deps as proxied primitives (idempotent, called per-page) + function registerIoDeps(names) { + if (!names || !names.length) return; + var registered = 0; + for (var i = 0; i < names.length; i++) { + var name = names[i]; + if (!IO_PRIMITIVES[name]) { + registerProxiedIo(name); + registered++; + } + } + if (registered > 0) { + logInfo("sx:io registered " + registered + " proxied primitives: " + names.join(", ")); + } + } +''' + +PREAMBLE = '''\ +/** + * sx-ref.js — Generated from reference SX evaluator specification. + * + * Bootstrap-compiled from shared/sx/ref/{eval,render,primitives}.sx + * Compare against hand-written sx.js for correctness verification. + * + * DO NOT EDIT — regenerate with: python bootstrap_js.py + */ +;(function(global) { + "use strict"; + + // ========================================================================= + // Types + // ========================================================================= + + var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); + var SX_VERSION = "BUILD_TIMESTAMP"; + + function isNil(x) { return x === NIL || x === null || x === undefined; } + function isSxTruthy(x) { return x !== false && !isNil(x); } + + function Symbol(name) { this.name = name; } + Symbol.prototype.toString = function() { return this.name; }; + Symbol.prototype._sym = true; + + function Keyword(name) { this.name = name; } + Keyword.prototype.toString = function() { return ":" + this.name; }; + Keyword.prototype._kw = true; + + function Lambda(params, body, closure, name) { + this.params = params; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Lambda.prototype._lambda = true; + + function Component(name, params, hasChildren, body, closure, affinity) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + this.affinity = affinity || "auto"; + } + Component.prototype._component = true; + + function Island(name, params, hasChildren, body, closure) { + this.name = name; + this.params = params; + this.hasChildren = hasChildren; + this.body = body; + this.closure = closure || {}; + } + Island.prototype._island = true; + + function SxSignal(value) { + this.value = value; + this.subscribers = []; + this.deps = []; + } + SxSignal.prototype._signal = true; + + function TrackingCtx(notifyFn) { + this.notifyFn = notifyFn; + this.deps = []; + } + + var _trackingContext = null; + + function Macro(params, restParam, body, closure, name) { + this.params = params; + this.restParam = restParam; + this.body = body; + this.closure = closure || {}; + this.name = name || null; + } + Macro.prototype._macro = true; + + function Thunk(expr, env) { this.expr = expr; this.env = env; } + Thunk.prototype._thunk = true; + + function RawHTML(html) { this.html = html; } + RawHTML.prototype._raw = true; + + function isSym(x) { return x != null && x._sym === true; } + function isKw(x) { return x != null && x._kw === true; } + + function merge() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { + var d = arguments[i]; + if (d) for (var k in d) out[k] = d[k]; + } + return out; + } + + function sxOr() { + for (var i = 0; i < arguments.length; i++) { + if (isSxTruthy(arguments[i])) return arguments[i]; + } + return arguments.length ? arguments[arguments.length - 1] : false; + }''' +PRIMITIVES_JS_MODULES: dict[str, str] = { + "core.arithmetic": ''' + // core.arithmetic + PRIMITIVES["+"] = function() { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; }; + PRIMITIVES["-"] = function(a, b) { return arguments.length === 1 ? -a : a - b; }; + PRIMITIVES["*"] = function() { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; }; + PRIMITIVES["/"] = function(a, b) { return a / b; }; + PRIMITIVES["mod"] = function(a, b) { return a % b; }; + PRIMITIVES["inc"] = function(n) { return n + 1; }; + PRIMITIVES["dec"] = function(n) { return n - 1; }; + PRIMITIVES["abs"] = Math.abs; + PRIMITIVES["floor"] = Math.floor; + PRIMITIVES["ceil"] = Math.ceil; + PRIMITIVES["round"] = function(x, n) { + if (n === undefined || n === 0) return Math.round(x); + var f = Math.pow(10, n); return Math.round(x * f) / f; + }; + PRIMITIVES["min"] = Math.min; + PRIMITIVES["max"] = Math.max; + PRIMITIVES["sqrt"] = Math.sqrt; + PRIMITIVES["pow"] = Math.pow; + PRIMITIVES["clamp"] = function(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }; +''', + + "core.comparison": ''' + // core.comparison + PRIMITIVES["="] = function(a, b) { return a === b; }; + PRIMITIVES["!="] = function(a, b) { return a !== b; }; + PRIMITIVES["<"] = function(a, b) { return a < b; }; + PRIMITIVES[">"] = function(a, b) { return a > b; }; + PRIMITIVES["<="] = function(a, b) { return a <= b; }; + PRIMITIVES[">="] = function(a, b) { return a >= b; }; +''', + + "core.logic": ''' + // core.logic + PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); }; +''', + + "core.predicates": ''' + // core.predicates + PRIMITIVES["nil?"] = isNil; + PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; + PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; + PRIMITIVES["list?"] = Array.isArray; + PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; + PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; + PRIMITIVES["contains?"] = function(c, k) { + if (typeof c === "string") return c.indexOf(String(k)) !== -1; + if (Array.isArray(c)) return c.indexOf(k) !== -1; + return k in c; + }; + PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; + PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; + PRIMITIVES["zero?"] = function(n) { return n === 0; }; + PRIMITIVES["boolean?"] = function(x) { return x === true || x === false; }; + PRIMITIVES["component-affinity"] = componentAffinity; +''', + + "core.strings": ''' + // core.strings + PRIMITIVES["str"] = function() { + var p = []; + for (var i = 0; i < arguments.length; i++) { + var v = arguments[i]; if (isNil(v)) continue; p.push(String(v)); + } + return p.join(""); + }; + PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); }; + PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); }; + PRIMITIVES["trim"] = function(s) { return String(s).trim(); }; + PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); }; + PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); }; + PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); }; + PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); }; + PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; }; + PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; }; + PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); }; + PRIMITIVES["substring"] = function(s, a, b) { return String(s).substring(a, b); }; + PRIMITIVES["string-length"] = function(s) { return String(s).length; }; + PRIMITIVES["string-contains?"] = function(s, sub) { return String(s).indexOf(String(sub)) !== -1; }; + PRIMITIVES["concat"] = function() { + var out = []; + for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]); + return out; + }; +''', + + "core.collections": ''' + // core.collections + PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; + PRIMITIVES["dict"] = function() { + var d = {}; + for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1]; + return d; + }; + PRIMITIVES["range"] = function(a, b, step) { + var r = []; step = step || 1; + for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i); + return r; + }; + PRIMITIVES["get"] = function(c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); }; + PRIMITIVES["len"] = function(c) { return Array.isArray(c) ? c.length : typeof c === "string" ? c.length : Object.keys(c).length; }; + PRIMITIVES["first"] = function(c) { return c && c.length > 0 ? c[0] : NIL; }; + PRIMITIVES["last"] = function(c) { return c && c.length > 0 ? c[c.length - 1] : NIL; }; + PRIMITIVES["rest"] = function(c) { return c ? c.slice(1) : []; }; + PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; }; + PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); }; + PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); }; + PRIMITIVES["append!"] = function(arr, x) { arr.push(x); return arr; }; + PRIMITIVES["chunk-every"] = function(c, n) { + var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r; + }; + PRIMITIVES["zip-pairs"] = function(c) { + var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r; + }; + PRIMITIVES["reverse"] = function(c) { return Array.isArray(c) ? c.slice().reverse() : String(c).split("").reverse().join(""); }; + PRIMITIVES["flatten"] = function(c) { + var out = []; + function walk(a) { for (var i = 0; i < a.length; i++) Array.isArray(a[i]) ? walk(a[i]) : out.push(a[i]); } + walk(c || []); return out; + }; +''', + + "core.dict": ''' + // core.dict + PRIMITIVES["keys"] = function(d) { return Object.keys(d || {}); }; + PRIMITIVES["vals"] = function(d) { var r = []; for (var k in d) r.push(d[k]); return r; }; + PRIMITIVES["merge"] = function() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; } + return out; + }; + PRIMITIVES["assoc"] = function(d) { + var out = {}; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1]; + return out; + }; + PRIMITIVES["dissoc"] = function(d) { + var out = {}; for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length; i++) delete out[arguments[i]]; + return out; + }; + PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; }; + PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; }; + PRIMITIVES["into"] = function(target, coll) { + if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll); + var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; } + return r; + }; +''', + + "stdlib.format": ''' + // stdlib.format + PRIMITIVES["format-decimal"] = function(v, p) { return Number(v).toFixed(p || 2); }; + PRIMITIVES["parse-int"] = function(v, d) { var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; }; + PRIMITIVES["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; +''', + + "stdlib.text": ''' + // stdlib.text + PRIMITIVES["pluralize"] = function(n, s, p) { + if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s"); + return n == 1 ? "" : "s"; + }; + PRIMITIVES["escape"] = function(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'"); + }; + PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); }; +''', + + "stdlib.debug": ''' + // stdlib.debug + PRIMITIVES["assert"] = function(cond, msg) { + if (!isSxTruthy(cond)) throw new Error("Assertion error: " + (msg || "Assertion failed")); + return true; + }; +''', +} +# Modules to include by default (all) +_ALL_JS_MODULES = list(PRIMITIVES_JS_MODULES.keys()) + +def _assemble_primitives_js(modules: list[str] | None = None) -> str: + """Assemble JS primitive code from selected modules. + + If modules is None, all modules are included. + Core modules are always included regardless of the list. + """ + if modules is None: + modules = _ALL_JS_MODULES + parts = [] + for mod in modules: + if mod in PRIMITIVES_JS_MODULES: + parts.append(PRIMITIVES_JS_MODULES[mod]) + return "\n".join(parts) + +PLATFORM_JS_PRE = ''' + // ========================================================================= + // Platform interface — JS implementation + // ========================================================================= + + function typeOf(x) { + if (isNil(x)) return "nil"; + if (typeof x === "number") return "number"; + if (typeof x === "string") return "string"; + if (typeof x === "boolean") return "boolean"; + if (x._sym) return "symbol"; + if (x._kw) return "keyword"; + if (x._thunk) return "thunk"; + if (x._lambda) return "lambda"; + if (x._component) return "component"; + if (x._island) return "island"; + if (x._signal) return "signal"; + if (x._macro) return "macro"; + if (x._raw) return "raw-html"; + if (typeof Node !== "undefined" && x instanceof Node) return "dom-node"; + if (Array.isArray(x)) return "list"; + if (typeof x === "object") return "dict"; + return "unknown"; + } + + function symbolName(s) { return s.name; } + function keywordName(k) { return k.name; } + function makeSymbol(n) { return new Symbol(n); } + function makeKeyword(n) { return new Keyword(n); } + + function makeLambda(params, body, env) { return new Lambda(params, body, merge(env)); } + function makeComponent(name, params, hasChildren, body, env, affinity) { + return new Component(name, params, hasChildren, body, merge(env), affinity); + } + function makeMacro(params, restParam, body, env, name) { + return new Macro(params, restParam, body, merge(env), name); + } + function makeThunk(expr, env) { return new Thunk(expr, env); } + + function lambdaParams(f) { return f.params; } + function lambdaBody(f) { return f.body; } + function lambdaClosure(f) { return f.closure; } + function lambdaName(f) { return f.name; } + function setLambdaName(f, n) { f.name = n; } + + function componentParams(c) { return c.params; } + function componentBody(c) { return c.body; } + function componentClosure(c) { return c.closure; } + function componentHasChildren(c) { return c.hasChildren; } + function componentName(c) { return c.name; } + function componentAffinity(c) { return c.affinity || "auto"; } + + function macroParams(m) { return m.params; } + function macroRestParam(m) { return m.restParam; } + function macroBody(m) { return m.body; } + function macroClosure(m) { return m.closure; } + + function isThunk(x) { return x != null && x._thunk === true; } + function thunkExpr(t) { return t.expr; } + function thunkEnv(t) { return t.env; } + + function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); } + function isLambda(x) { return x != null && x._lambda === true; } + function isComponent(x) { return x != null && x._component === true; } + function isIsland(x) { return x != null && x._island === true; } + function isMacro(x) { return x != null && x._macro === true; } + function isIdentical(a, b) { return a === b; } + + // Island platform + function makeIsland(name, params, hasChildren, body, env) { + return new Island(name, params, hasChildren, body, merge(env)); + } + + // Signal platform + function makeSignal(value) { return new SxSignal(value); } + function isSignal(x) { return x != null && x._signal === true; } + function signalValue(s) { return s.value; } + function signalSetValue(s, v) { s.value = v; } + function signalSubscribers(s) { return s.subscribers.slice(); } + function signalAddSub(s, fn) { if (s.subscribers.indexOf(fn) < 0) s.subscribers.push(fn); } + function signalRemoveSub(s, fn) { var i = s.subscribers.indexOf(fn); if (i >= 0) s.subscribers.splice(i, 1); } + function signalDeps(s) { return s.deps.slice(); } + function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? deps.slice() : []; } + function setTrackingContext(ctx) { _trackingContext = ctx; } + function getTrackingContext() { return _trackingContext || NIL; } + function makeTrackingContext(notifyFn) { return new TrackingCtx(notifyFn); } + function trackingContextDeps(ctx) { return ctx ? ctx.deps : []; } + function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); } + function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; } + + // invoke — call any callable (native fn or SX lambda) with args. + // Transpiled code emits direct calls f(args) which fail on SX lambdas + // from runtime-evaluated island bodies. invoke dispatches correctly. + function invoke() { + var f = arguments[0]; + var args = Array.prototype.slice.call(arguments, 1); + if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f))); + if (typeof f === 'function') return f.apply(null, args); + return NIL; + } + + // JSON / dict helpers for island state serialization + function jsonSerialize(obj) { + try { return JSON.stringify(obj); } catch(e) { return "{}"; } + } + function isEmptyDict(d) { + if (!d || typeof d !== "object") return true; + for (var k in d) if (d.hasOwnProperty(k)) return false; + return true; + } + + function envHas(env, name) { return name in env; } + function envGet(env, name) { return env[name]; } + function envSet(env, name, val) { env[name] = val; } + function envExtend(env) { return Object.create(env); } + function envMerge(base, overlay) { + var child = Object.create(base); + if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k]; + return child; + } + + function dictSet(d, k, v) { d[k] = v; return v; } + function dictGet(d, k) { var v = d[k]; return v !== undefined ? v : NIL; } + + // Render-expression detection — lets the evaluator delegate to the active adapter. + // Matches HTML tags, SVG tags, <>, raw!, ~components, html: prefix, custom elements. + // Placeholder — overridden by transpiled version from render.sx + function isRenderExpr(expr) { return false; } + + // Render dispatch — call the active adapter's render function. + // Set by each adapter when loaded; defaults to identity (no rendering). + var _renderExprFn = null; + + // Render mode flag — set by render-to-html/aser, checked by eval-list. + // When false, render expressions fall through to evalCall. + var _renderMode = false; + function renderActiveP() { return _renderMode; } + function setRenderActiveB(val) { _renderMode = !!val; } + + function renderExpr(expr, env) { + if (_renderExprFn) return _renderExprFn(expr, env); + // No adapter loaded — fall through to evalCall + return evalCall(first(expr), rest(expr), env); + } + + function stripPrefix(s, prefix) { + return s.indexOf(prefix) === 0 ? s.slice(prefix.length) : s; + } + + function error(msg) { throw new Error(msg); } + function inspect(x) { return JSON.stringify(x); } + +''' + + +PLATFORM_JS_POST = ''' + function isPrimitive(name) { return name in PRIMITIVES; } + function getPrimitive(name) { return PRIMITIVES[name]; } + + // Higher-order helpers used by the transpiled code + function map(fn, coll) { return coll.map(fn); } + function mapIndexed(fn, coll) { return coll.map(function(item, i) { return fn(i, item); }); } + function filter(fn, coll) { return coll.filter(function(x) { return isSxTruthy(fn(x)); }); } + function reduce(fn, init, coll) { + var acc = init; + for (var i = 0; i < coll.length; i++) acc = fn(acc, coll[i]); + return acc; + } + function some(fn, coll) { + for (var i = 0; i < coll.length; i++) { var r = fn(coll[i]); if (isSxTruthy(r)) return r; } + return NIL; + } + function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function isEvery(fn, coll) { + for (var i = 0; i < coll.length; i++) { if (!isSxTruthy(fn(coll[i]))) return false; } + return true; + } + function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } + + // List primitives used directly by transpiled code + var len = PRIMITIVES["len"]; + var first = PRIMITIVES["first"]; + var last = PRIMITIVES["last"]; + var rest = PRIMITIVES["rest"]; + var nth = PRIMITIVES["nth"]; + var cons = PRIMITIVES["cons"]; + var append = PRIMITIVES["append"]; + var isEmpty = PRIMITIVES["empty?"]; + var contains = PRIMITIVES["contains?"]; + var startsWith = PRIMITIVES["starts-with?"]; + var slice = PRIMITIVES["slice"]; + var concat = PRIMITIVES["concat"]; + var str = PRIMITIVES["str"]; + var join = PRIMITIVES["join"]; + var keys = PRIMITIVES["keys"]; + var get = PRIMITIVES["get"]; + var assoc = PRIMITIVES["assoc"]; + var range = PRIMITIVES["range"]; + function zip(a, b) { var r = []; for (var i = 0; i < Math.min(a.length, b.length); i++) r.push([a[i], b[i]]); return r; } + function append_b(arr, x) { arr.push(x); return arr; } + var apply = function(f, args) { + if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f))); + return f.apply(null, args); + }; + + // Additional primitive aliases used by adapter/engine transpiled code + var split = PRIMITIVES["split"]; + var trim = PRIMITIVES["trim"]; + var upper = PRIMITIVES["upper"]; + var lower = PRIMITIVES["lower"]; + var replace_ = function(s, old, nw) { return s.split(old).join(nw); }; + var endsWith = PRIMITIVES["ends-with?"]; + var parseInt_ = PRIMITIVES["parse-int"]; + var dict_fn = PRIMITIVES["dict"]; + + // HTML rendering helpers + function escapeHtml(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + } + function escapeAttr(s) { return escapeHtml(s); } + function rawHtmlContent(r) { return r.html; } + function makeRawHtml(s) { return { _raw: true, html: s }; } + function sxExprSource(x) { return x && x.source ? x.source : String(x); } + + // Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx + function serialize(val) { return String(val); } + function isSpecialForm(n) { return false; } + function isHoForm(n) { return false; } + + // processBindings and evalCond — now specced in render.sx, bootstrapped above + + function isDefinitionForm(name) { + return name === "define" || name === "defcomp" || name === "defmacro" || + name === "defstyle" || name === "defhandler"; + } + + function indexOf_(s, ch) { + return typeof s === "string" ? s.indexOf(ch) : -1; + } + + function dictHas(d, k) { return d != null && k in d; } + function dictDelete(d, k) { delete d[k]; } + + function forEachIndexed(fn, coll) { + for (var i = 0; i < coll.length; i++) fn(i, coll[i]); + return NIL; + } + + // ========================================================================= + // Performance overrides — evaluator hot path + // ========================================================================= + + // Override parseKeywordArgs: imperative loop instead of reduce+assoc + parseKeywordArgs = function(rawArgs, env) { + var kwargs = {}; + var children = []; + for (var i = 0; i < rawArgs.length; i++) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env)); + i++; + } else { + children.push(trampoline(evalExpr(arg, env))); + } + } + return [kwargs, children]; + }; + + // Override callComponent: use prototype chain env, imperative kwarg binding + callComponent = function(comp, rawArgs, env) { + var kwargs = {}; + var children = []; + for (var i = 0; i < rawArgs.length; i++) { + var arg = rawArgs[i]; + if (arg && arg._kw && (i + 1) < rawArgs.length) { + kwargs[arg.name] = trampoline(evalExpr(rawArgs[i + 1], env)); + i++; + } else { + children.push(trampoline(evalExpr(arg, env))); + } + } + var local = Object.create(componentClosure(comp)); + for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k]; + var params = componentParams(comp); + for (var j = 0; j < params.length; j++) { + var p = params[j]; + local[p] = p in kwargs ? kwargs[p] : NIL; + } + if (componentHasChildren(comp)) { + local["children"] = children; + } + return makeThunk(componentBody(comp), local); + };''' + + +PLATFORM_DEPS_JS = ''' + // ========================================================================= + // Platform: deps module — component dependency analysis + // ========================================================================= + + function componentDeps(c) { + return c.deps ? c.deps.slice() : []; + } + + function componentSetDeps(c, deps) { + c.deps = deps; + } + + function componentCssClasses(c) { + return c.cssClasses ? c.cssClasses.slice() : []; + } + + function envComponents(env) { + var names = []; + for (var k in env) { + var v = env[k]; + if (v && (v._component || v._macro)) names.push(k); + } + return names; + } + + function regexFindAll(pattern, source) { + var re = new RegExp(pattern, "g"); + var results = []; + var m; + while ((m = re.exec(source)) !== null) { + if (m[1] !== undefined) results.push(m[1]); + else results.push(m[0]); + } + return results; + } + + function scanCssClasses(source) { + var classes = {}; + var result = []; + var m; + var re1 = /:class\\s+"([^"]*)"/g; + while ((m = re1.exec(source)) !== null) { + var parts = m[1].split(/\\s+/); + for (var i = 0; i < parts.length; i++) { + if (parts[i] && !classes[parts[i]]) { + classes[parts[i]] = true; + result.push(parts[i]); + } + } + } + var re2 = /:class\\s+\\(str\\s+((?:"[^"]*"\\s*)+)\\)/g; + while ((m = re2.exec(source)) !== null) { + var re3 = /"([^"]*)"/g; + var m2; + while ((m2 = re3.exec(m[1])) !== null) { + var parts2 = m2[1].split(/\\s+/); + for (var j = 0; j < parts2.length; j++) { + if (parts2[j] && !classes[parts2[j]]) { + classes[parts2[j]] = true; + result.push(parts2[j]); + } + } + } + } + var re4 = /;;\\s*@css\\s+(.+)/g; + while ((m = re4.exec(source)) !== null) { + var parts3 = m[1].split(/\\s+/); + for (var k = 0; k < parts3.length; k++) { + if (parts3[k] && !classes[parts3[k]]) { + classes[parts3[k]] = true; + result.push(parts3[k]); + } + } + } + return result; + } + + function componentIoRefs(c) { + return c.ioRefs ? c.ioRefs.slice() : []; + } + + function componentSetIoRefs(c, refs) { + c.ioRefs = refs; + } +''' + + +PLATFORM_PARSER_JS = r""" + // ========================================================================= + // Platform interface — Parser + // ========================================================================= + // Character classification derived from the grammar: + // ident-start → [a-zA-Z_~*+\-><=/!?&] + // ident-char → ident-start + [0-9.:\/\[\]#,] + + var _identStartRe = /[a-zA-Z_~*+\-><=/!?&]/; + var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]/; + + function isIdentStart(ch) { return _identStartRe.test(ch); } + function isIdentChar(ch) { return _identCharRe.test(ch); } + function parseNumber(s) { return Number(s); } + function escapeString(s) { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t"); + } + function sxExprSource(e) { return typeof e === "string" ? e : String(e); } +""" + + +PLATFORM_DOM_JS = """ + // ========================================================================= + // Platform interface — DOM adapter (browser-only) + // ========================================================================= + + var _hasDom = typeof document !== "undefined"; + + // Register DOM adapter as the render dispatch target for the evaluator. + _renderExprFn = function(expr, env) { return renderToDom(expr, env, null); }; + _renderMode = true; // Browser always evaluates in render context. + + var SVG_NS = "http://www.w3.org/2000/svg"; + var MATH_NS = "http://www.w3.org/1998/Math/MathML"; + + function domCreateElement(tag, ns) { + if (!_hasDom) return null; + if (ns && ns !== NIL) return document.createElementNS(ns, tag); + return document.createElement(tag); + } + + function createTextNode(s) { + return _hasDom ? document.createTextNode(s) : null; + } + + function createComment(s) { + return _hasDom ? document.createComment(s || "") : null; + } + + function createFragment() { + return _hasDom ? document.createDocumentFragment() : null; + } + + function domAppend(parent, child) { + if (parent && child) parent.appendChild(child); + } + + function domPrepend(parent, child) { + if (parent && child) parent.insertBefore(child, parent.firstChild); + } + + function domSetAttr(el, name, val) { + if (el && el.setAttribute) el.setAttribute(name, val); + } + + function domGetAttr(el, name) { + if (!el || !el.getAttribute) return NIL; + var v = el.getAttribute(name); + return v === null ? NIL : v; + } + + function domRemoveAttr(el, name) { + if (el && el.removeAttribute) el.removeAttribute(name); + } + + function domHasAttr(el, name) { + return !!(el && el.hasAttribute && el.hasAttribute(name)); + } + + function domParseHtml(html) { + if (!_hasDom) return null; + var tpl = document.createElement("template"); + tpl.innerHTML = html; + return tpl.content; + } + + function domClone(node) { + return node && node.cloneNode ? node.cloneNode(true) : node; + } + + function domParent(el) { return el ? el.parentNode : null; } + function domId(el) { return el && el.id ? el.id : NIL; } + function domNodeType(el) { return el ? el.nodeType : 0; } + function domNodeName(el) { return el ? el.nodeName : ""; } + function domTextContent(el) { return el ? el.textContent || el.nodeValue || "" : ""; } + function domSetTextContent(el, s) { if (el) { if (el.nodeType === 3 || el.nodeType === 8) el.nodeValue = s; else el.textContent = s; } } + function domIsFragment(el) { return el ? el.nodeType === 11 : false; } + function domIsChildOf(child, parent) { return !!(parent && child && child.parentNode === parent); } + function domIsActiveElement(el) { return _hasDom && el === document.activeElement; } + function domIsInputElement(el) { + if (!el || !el.tagName) return false; + var t = el.tagName; + return t === "INPUT" || t === "TEXTAREA" || t === "SELECT"; + } + function domFirstChild(el) { return el ? el.firstChild : null; } + function domNextSibling(el) { return el ? el.nextSibling : null; } + + function domChildList(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + + function domAttrList(el) { + if (!el || !el.attributes) return []; + var r = []; + for (var i = 0; i < el.attributes.length; i++) { + r.push([el.attributes[i].name, el.attributes[i].value]); + } + return r; + } + + function domInsertBefore(parent, node, ref) { + if (parent && node) parent.insertBefore(node, ref || null); + } + + function domInsertAfter(ref, node) { + if (ref && ref.parentNode && node) { + ref.parentNode.insertBefore(node, ref.nextSibling); + } + } + + function domRemoveChild(parent, child) { + if (parent && child && child.parentNode === parent) parent.removeChild(child); + } + + function domReplaceChild(parent, newChild, oldChild) { + if (parent && newChild && oldChild) parent.replaceChild(newChild, oldChild); + } + + function domSetInnerHtml(el, html) { + if (el) el.innerHTML = html; + } + + function domInsertAdjacentHtml(el, pos, html) { + if (el && el.insertAdjacentHTML) el.insertAdjacentHTML(pos, html); + } + + function domGetStyle(el, prop) { + return el && el.style ? el.style[prop] || "" : ""; + } + + function domSetStyle(el, prop, val) { + if (el && el.style) el.style[prop] = val; + } + + function domGetProp(el, name) { return el ? el[name] : NIL; } + function domSetProp(el, name, val) { if (el) el[name] = val; } + + function domAddClass(el, cls) { + if (el && el.classList) el.classList.add(cls); + } + + function domRemoveClass(el, cls) { + if (el && el.classList) el.classList.remove(cls); + } + + function domDispatch(el, name, detail) { + if (!_hasDom || !el) return false; + var evt = new CustomEvent(name, { bubbles: true, cancelable: true, detail: detail || {} }); + return el.dispatchEvent(evt); + } + + function domListen(el, name, handler) { + if (!_hasDom || !el) return function() {}; + // Wrap SX lambdas from runtime-evaluated island code into native fns + var wrapped = isLambda(handler) + ? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } } + : handler; + if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); + el.addEventListener(name, wrapped); + return function() { el.removeEventListener(name, wrapped); }; + } + + function eventDetail(e) { + return (e && e.detail != null) ? e.detail : nil; + } + + function domQuery(sel) { + return _hasDom ? document.querySelector(sel) : null; + } + + function domEnsureElement(sel) { + if (!_hasDom) return null; + var el = document.querySelector(sel); + if (el) return el; + // Parse #id selector → create div with that id, append to body + if (sel.charAt(0) === '#') { + el = document.createElement('div'); + el.id = sel.slice(1); + document.body.appendChild(el); + return el; + } + return null; + } + + function domQueryAll(root, sel) { + if (!root || !root.querySelectorAll) return []; + return Array.prototype.slice.call(root.querySelectorAll(sel)); + } + + function domTagName(el) { return el && el.tagName ? el.tagName : ""; } + + // Island DOM helpers + function domRemove(node) { + if (node && node.parentNode) node.parentNode.removeChild(node); + } + function domChildNodes(el) { + if (!el || !el.childNodes) return []; + return Array.prototype.slice.call(el.childNodes); + } + function domRemoveChildrenAfter(marker) { + if (!marker || !marker.parentNode) return; + var parent = marker.parentNode; + while (marker.nextSibling) parent.removeChild(marker.nextSibling); + } + function domSetData(el, key, val) { + if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; } + } + 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 {}; } + } + + // renderDomComponent and renderDomElement are transpiled from + // adapter-dom.sx — no imperative overrides needed. +""" + + +PLATFORM_ENGINE_PURE_JS = """ + // ========================================================================= + // Platform interface — Engine pure logic (browser + node compatible) + // ========================================================================= + + function browserLocationHref() { + return typeof location !== "undefined" ? location.href : ""; + } + + function browserSameOrigin(url) { + try { return new URL(url, location.href).origin === location.origin; } + catch (e) { return true; } + } + + function browserPushState(url) { + if (typeof history !== "undefined") { + try { history.pushState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserReplaceState(url) { + if (typeof history !== "undefined") { + try { history.replaceState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function nowMs() { return (typeof performance !== "undefined") ? performance.now() : Date.now(); } + + function parseHeaderValue(s) { + if (!s) return null; + try { + if (s.charAt(0) === "{" && s.charAt(1) === ":") return parse(s); + return JSON.parse(s); + } catch (e) { return null; } + } +""" + + +PLATFORM_ORCHESTRATION_JS = """ + // ========================================================================= + // Platform interface — Orchestration (browser-only) + // ========================================================================= + + // --- Browser/Network --- + + function browserNavigate(url) { + if (typeof location !== "undefined") location.assign(url); + } + + function browserReload() { + if (typeof location !== "undefined") location.reload(); + } + + function browserScrollTo(x, y) { + if (typeof window !== "undefined") window.scrollTo(x, y); + } + + function browserMediaMatches(query) { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + } + + function browserConfirm(msg) { + if (typeof window === "undefined") return false; + return window.confirm(msg); + } + + function browserPrompt(msg) { + if (typeof window === "undefined") return NIL; + var r = window.prompt(msg); + return r === null ? NIL : r; + } + + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseThen(p, onResolve, onReject) { + if (!p || !p.then) return p; + return onReject ? p.then(onResolve, onReject) : p.then(onResolve); + } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + function promiseDelayed(ms, value) { + return new Promise(function(resolve) { + setTimeout(function() { resolve(value); }, ms); + }); + } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + var _targetControllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPreviousTarget(el) { + if (_targetControllers) { + var prev = _targetControllers.get(el); + if (prev) prev.abort(); + } + } + + function trackControllerTarget(el, ctrl) { + if (_targetControllers) _targetControllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function _wrapSxFn(fn) { + if (fn && fn._lambda) { + return function() { return trampoline(callLambda(fn, [], lambdaClosure(fn))); }; + } + return fn; + } + function setTimeout_(fn, ms) { return setTimeout(_wrapSxFn(fn), ms || 0); } + function setInterval_(fn, ms) { return setInterval(_wrapSxFn(fn), ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function clearInterval_(id) { clearInterval(id); } + function requestAnimationFrame_(fn) { + var cb = _wrapSxFn(fn); + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(cb); + else setTimeout(cb, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = (config.preloaded && config.preloaded !== NIL) + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchStreaming(target, url, headers) { + // Streaming fetch for multi-stream pages. + // First chunk = OOB SX swap (shell with skeletons). + // Subsequent chunks = __sxResolve script tags filling suspense slots. + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + if (!resp.ok || !resp.body) { + // Fallback: non-streaming + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(target, newMain || container); + postSwap(target); + } + }); + } + + var reader = resp.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ""; + var initialSwapDone = false; + // Regex to match __sxResolve script tags + var RESOLVE_START = ""; + + function processResolveScripts() { + // Strip and load any extra component defs before resolve scripts + buffer = stripSxScripts(buffer); + var idx; + while ((idx = buffer.indexOf(RESOLVE_START)) >= 0) { + var endIdx = buffer.indexOf(RESOLVE_END, idx); + if (endIdx < 0) break; // incomplete, wait for more data + var argsStr = buffer.substring(idx + RESOLVE_START.length, endIdx); + buffer = buffer.substring(endIdx + RESOLVE_END.length); + // argsStr is: "stream-id","sx source" + var commaIdx = argsStr.indexOf(","); + if (commaIdx >= 0) { + try { + var id = JSON.parse(argsStr.substring(0, commaIdx)); + var sx = JSON.parse(argsStr.substring(commaIdx + 1)); + if (typeof Sx !== "undefined" && Sx.resolveSuspense) { + Sx.resolveSuspense(id, sx); + } + } catch (e) { + console.error("[sx-ref] resolve parse error:", e); + } + } + } + } + + function pump() { + return reader.read().then(function(result) { + buffer += decoder.decode(result.value || new Uint8Array(), { stream: !result.done }); + + if (!initialSwapDone) { + // Look for the first resolve script — everything before it is OOB content + var scriptIdx = buffer.indexOf(" (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(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) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\\s\\S]*?)<\\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } +""" + + +PLATFORM_BOOT_JS = """ + // ========================================================================= + // Platform interface — Boot (mount, hydrate, scripts, cookies) + // ========================================================================= + + function resolveMountTarget(target) { + if (typeof target === "string") return _hasDom ? document.querySelector(target) : null; + return target; + } + + function sxRenderWithEnv(source, extraEnv) { + var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + var exprs = parse(source); + if (!_hasDom) return null; + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) { + var node = renderToDom(exprs[i], env, null); + if (node) frag.appendChild(node); + } + return frag; + } + + function getRenderEnv(extraEnv) { + return extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + } + + function mergeEnvs(base, newEnv) { + return newEnv ? merge(componentEnv, base, newEnv) : merge(componentEnv, base); + } + + function sxLoadComponents(text) { + try { + var exprs = parse(text); + for (var i = 0; i < exprs.length; i++) trampoline(evalExpr(exprs[i], componentEnv)); + } catch (err) { + logParseError("loadComponents", text, err); + throw err; + } + } + + function setDocumentTitle(s) { + if (_hasDom) document.title = s || ""; + } + + function removeHeadElement(sel) { + if (!_hasDom) return; + var old = document.head.querySelector(sel); + if (old) old.parentNode.removeChild(old); + } + + function querySxScripts(root) { + if (!_hasDom) return []; + var r = (root && root !== NIL) ? root : document; + return Array.prototype.slice.call( + r.querySelectorAll('script[type="text/sx"]')); + } + + function queryPageScripts() { + if (!_hasDom) return []; + return Array.prototype.slice.call( + document.querySelectorAll('script[type="text/sx-pages"]')); + } + + // --- localStorage --- + + function localStorageGet(key) { + try { var v = localStorage.getItem(key); return v === null ? NIL : v; } + catch (e) { return NIL; } + } + + function localStorageSet(key, val) { + try { localStorage.setItem(key, val); } catch (e) {} + } + + function localStorageRemove(key) { + try { localStorage.removeItem(key); } catch (e) {} + } + + // --- Cookies --- + + function setSxCompCookie(hash) { + if (_hasDom) document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax"; + } + + function clearSxCompCookie() { + if (_hasDom) document.cookie = "sx-comp-hash=;path=/;max-age=0;SameSite=Lax"; + } + + // --- Env helpers --- + + function parseEnvAttr(el) { + var attr = el && el.getAttribute ? el.getAttribute("data-sx-env") : null; + if (!attr) return {}; + try { return JSON.parse(attr); } catch (e) { return {}; } + } + + function storeEnvAttr(el, base, newEnv) { + var merged = merge(base, newEnv); + if (el && el.setAttribute) el.setAttribute("data-sx-env", JSON.stringify(merged)); + } + + function toKebab(s) { return s.replace(/_/g, "-"); } + + // --- Logging --- + + function logInfo(msg) { + if (typeof console !== "undefined") console.log("[sx-ref] " + msg); + } + + function logWarn(msg) { + if (typeof console !== "undefined") console.warn("[sx-ref] " + msg); + } + + function logParseError(label, text, err) { + if (typeof console === "undefined") return; + var msg = err && err.message ? err.message : String(err); + var colMatch = msg.match(/col (\\d+)/); + var lineMatch = msg.match(/line (\\d+)/); + if (colMatch && text) { + var errLine = lineMatch ? parseInt(lineMatch[1]) : 1; + var errCol = parseInt(colMatch[1]); + var lines = text.split("\\n"); + var pos = 0; + for (var i = 0; i < errLine - 1 && i < lines.length; i++) pos += lines[i].length + 1; + pos += errCol; + var ws = 80; + var start = Math.max(0, pos - ws); + var end = Math.min(text.length, pos + ws); + console.error("[sx-ref] " + label + ":", msg, + "\\n around error (pos ~" + pos + "):", + "\\n \\u00ab" + text.substring(start, pos) + "\\u26d4" + text.substring(pos, end) + "\\u00bb"); + } else { + console.error("[sx-ref] " + label + ":", msg); + } + } + +""" + + +def fixups_js(has_html, has_sx, has_dom, has_signals=False, has_deps=False, has_page_helpers=False): + lines = [''' + // ========================================================================= + // Post-transpilation fixups + // ========================================================================= + // The reference spec's call-lambda only handles Lambda objects, but HO forms + // (map, reduce, etc.) may receive native primitives. Wrap to handle both. + var _rawCallLambda = callLambda; + callLambda = function(f, args, callerEnv) { + if (typeof f === "function") return f.apply(null, args); + return _rawCallLambda(f, args, callerEnv); + }; + + // Expose render functions as primitives so SX code can call them'''] + if has_html: + lines.append(' if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml;') + if has_sx: + lines.append(' if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx;') + lines.append(' if (typeof aser === "function") PRIMITIVES["aser"] = aser;') + if has_dom: + lines.append(' if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;') + if has_signals: + lines.append(''' + // Expose signal functions as primitives so runtime-evaluated SX code + // (e.g. island bodies from .sx files) can call them + PRIMITIVES["signal"] = signal; + PRIMITIVES["signal?"] = isSignal; + PRIMITIVES["deref"] = deref; + PRIMITIVES["reset!"] = reset_b; + PRIMITIVES["swap!"] = swap_b; + PRIMITIVES["computed"] = computed; + PRIMITIVES["effect"] = effect; + PRIMITIVES["batch"] = batch; + // Timer primitives for island code + PRIMITIVES["set-interval"] = setInterval_; + PRIMITIVES["clear-interval"] = clearInterval_; + // Reactive DOM helpers for island code + PRIMITIVES["reactive-text"] = reactiveText; + PRIMITIVES["create-text-node"] = createTextNode; + PRIMITIVES["dom-set-text-content"] = domSetTextContent; + PRIMITIVES["dom-listen"] = domListen; + PRIMITIVES["dom-dispatch"] = domDispatch; + PRIMITIVES["event-detail"] = eventDetail; + PRIMITIVES["resource"] = resource; + PRIMITIVES["promise-delayed"] = promiseDelayed; + PRIMITIVES["promise-then"] = promiseThen; + PRIMITIVES["def-store"] = defStore; + PRIMITIVES["use-store"] = useStore; + PRIMITIVES["emit-event"] = emitEvent; + PRIMITIVES["on-event"] = onEvent; + PRIMITIVES["bridge-event"] = bridgeEvent; + // DOM primitives for island code + PRIMITIVES["dom-focus"] = domFocus; + PRIMITIVES["dom-tag-name"] = domTagName; + PRIMITIVES["dom-get-prop"] = domGetProp; + PRIMITIVES["stop-propagation"] = stopPropagation_; + PRIMITIVES["error-message"] = errorMessage; + PRIMITIVES["schedule-idle"] = scheduleIdle; + PRIMITIVES["invoke"] = invoke; + PRIMITIVES["error"] = function(msg) { throw new Error(msg); }; + PRIMITIVES["filter"] = filter; + // DOM primitives for sx-on:* handlers and data-init scripts + if (typeof domBody === "function") PRIMITIVES["dom-body"] = domBody; + if (typeof domQuery === "function") PRIMITIVES["dom-query"] = domQuery; + if (typeof domQueryAll === "function") PRIMITIVES["dom-query-all"] = domQueryAll; + if (typeof domQueryById === "function") PRIMITIVES["dom-query-by-id"] = domQueryById; + if (typeof domSetAttr === "function") PRIMITIVES["dom-set-attr"] = domSetAttr; + if (typeof domGetAttr === "function") PRIMITIVES["dom-get-attr"] = domGetAttr; + if (typeof domRemoveAttr === "function") PRIMITIVES["dom-remove-attr"] = domRemoveAttr; + if (typeof domHasAttr === "function") PRIMITIVES["dom-has-attr?"] = domHasAttr; + if (typeof domAddClass === "function") PRIMITIVES["dom-add-class"] = domAddClass; + if (typeof domRemoveClass === "function") PRIMITIVES["dom-remove-class"] = domRemoveClass; + if (typeof domHasClass === "function") PRIMITIVES["dom-has-class?"] = domHasClass; + if (typeof domClosest === "function") PRIMITIVES["dom-closest"] = domClosest; + 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 domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml; + if (typeof domTextContent === "function") PRIMITIVES["dom-text-content"] = domTextContent; + if (typeof jsonParse === "function") PRIMITIVES["json-parse"] = jsonParse; + if (typeof nowMs === "function") PRIMITIVES["now-ms"] = nowMs; + PRIMITIVES["sx-parse"] = sxParse;''') + if has_deps: + lines.append(''' + // Expose deps module functions as primitives so runtime-evaluated SX code + // (e.g. test-deps.sx in browser) can call them + // Platform functions (from PLATFORM_DEPS_JS) + PRIMITIVES["component-deps"] = componentDeps; + PRIMITIVES["component-set-deps!"] = componentSetDeps; + PRIMITIVES["component-css-classes"] = componentCssClasses; + PRIMITIVES["env-components"] = envComponents; + PRIMITIVES["regex-find-all"] = regexFindAll; + PRIMITIVES["scan-css-classes"] = scanCssClasses; + // Transpiled functions (from deps.sx) + PRIMITIVES["scan-refs"] = scanRefs; + PRIMITIVES["scan-refs-walk"] = scanRefsWalk; + PRIMITIVES["transitive-deps"] = transitiveDeps; + PRIMITIVES["transitive-deps-walk"] = transitiveDepsWalk; + PRIMITIVES["compute-all-deps"] = computeAllDeps; + PRIMITIVES["scan-components-from-source"] = scanComponentsFromSource; + PRIMITIVES["components-needed"] = componentsNeeded; + PRIMITIVES["page-component-bundle"] = pageComponentBundle; + PRIMITIVES["page-css-classes"] = pageCssClasses; + PRIMITIVES["scan-io-refs-walk"] = scanIoRefsWalk; + PRIMITIVES["scan-io-refs"] = scanIoRefs; + PRIMITIVES["transitive-io-refs-walk"] = transitiveIoRefsWalk; + PRIMITIVES["transitive-io-refs"] = transitiveIoRefs; + PRIMITIVES["compute-all-io-refs"] = computeAllIoRefs; + PRIMITIVES["component-io-refs-cached"] = componentIoRefsCached; + PRIMITIVES["component-pure?"] = componentPure_p; + PRIMITIVES["render-target"] = renderTarget; + PRIMITIVES["page-render-plan"] = pageRenderPlan;''') + if has_page_helpers: + lines.append(''' + // Expose page-helper functions as primitives + PRIMITIVES["categorize-special-forms"] = categorizeSpecialForms; + PRIMITIVES["extract-define-kwargs"] = extractDefineKwargs; + PRIMITIVES["build-reference-data"] = buildReferenceData; + PRIMITIVES["build-ref-items-with-href"] = buildRefItemsWithHref; + PRIMITIVES["build-attr-detail"] = buildAttrDetail; + PRIMITIVES["build-header-detail"] = buildHeaderDetail; + PRIMITIVES["build-event-detail"] = buildEventDetail; + PRIMITIVES["build-component-source"] = buildComponentSource; + PRIMITIVES["build-bundle-analysis"] = buildBundleAnalysis; + PRIMITIVES["build-routing-analysis"] = buildRoutingAnalysis; + PRIMITIVES["build-affinity-analysis"] = buildAffinityAnalysis;''') + return "\n".join(lines) + + +def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False, has_signals=False, has_page_helpers=False): + # Parser: use compiled sxParse from parser.sx, or inline a minimal fallback + if has_parser: + parser = ''' + // Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes) + var parse = sxParse;''' + else: + parser = r''' + // Minimal fallback parser (no parser adapter) + function parse(text) { + throw new Error("Parser adapter not included — cannot parse SX source at runtime"); + }''' + + # Public API — conditional on adapters + api_lines = [parser, ''' + // ========================================================================= + // Public API + // ========================================================================= + + var componentEnv = {}; + + function loadComponents(source) { + var exprs = parse(source); + for (var i = 0; i < exprs.length; i++) { + trampoline(evalExpr(exprs[i], componentEnv)); + } + }'''] + + # render() — auto-dispatches based on available adapters + if has_html and has_dom: + api_lines.append(''' + function render(source) { + if (!_hasDom) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + } + var exprs = parse(source); + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null)); + return frag; + }''') + elif has_dom: + api_lines.append(''' + function render(source) { + var exprs = parse(source); + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null)); + return frag; + }''') + elif has_html: + api_lines.append(''' + function render(source) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + }''') + else: + api_lines.append(''' + function render(source) { + var exprs = parse(source); + var results = []; + for (var i = 0; i < exprs.length; i++) results.push(trampoline(evalExpr(exprs[i], merge(componentEnv)))); + return results.length === 1 ? results[0] : results; + }''') + + # renderToString helper + if has_html: + api_lines.append(''' + function renderToString(source) { + var exprs = parse(source); + var parts = []; + for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv))); + return parts.join(""); + }''') + + # Build Sx object + version = f"ref-2.0 ({adapter_label}, bootstrap-compiled)" + api_lines.append(f''' + var Sx = {{ + VERSION: "ref-2.0", + parse: parse, + parseAll: parse, + eval: function(expr, env) {{ return trampoline(evalExpr(expr, env || merge(componentEnv))); }}, + loadComponents: loadComponents, + render: render,{"" if has_html else ""} + {"renderToString: renderToString," if has_html else ""} + serialize: serialize, + NIL: NIL, + Symbol: Symbol, + Keyword: Keyword, + isTruthy: isSxTruthy, + isNil: isNil, + componentEnv: componentEnv,''') + + if has_html: + api_lines.append(' renderToHtml: function(expr, env) { return renderToHtml(expr, env || merge(componentEnv)); },') + if has_sx: + api_lines.append(' renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); },') + if has_dom: + api_lines.append(' renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,') + if has_engine: + api_lines.append(' parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,') + api_lines.append(' parseTime: typeof parseTime === "function" ? parseTime : null,') + api_lines.append(' defaultTrigger: typeof defaultTrigger === "function" ? defaultTrigger : null,') + api_lines.append(' parseSwapSpec: typeof parseSwapSpec === "function" ? parseSwapSpec : null,') + api_lines.append(' parseRetrySpec: typeof parseRetrySpec === "function" ? parseRetrySpec : null,') + api_lines.append(' nextRetryMs: typeof nextRetryMs === "function" ? nextRetryMs : null,') + api_lines.append(' filterParams: typeof filterParams === "function" ? filterParams : null,') + api_lines.append(' morphNode: typeof morphNode === "function" ? morphNode : null,') + api_lines.append(' morphChildren: typeof morphChildren === "function" ? morphChildren : null,') + api_lines.append(' swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,') + if has_orch: + api_lines.append(' process: typeof processElements === "function" ? processElements : null,') + api_lines.append(' executeRequest: typeof executeRequest === "function" ? executeRequest : null,') + api_lines.append(' postSwap: typeof postSwap === "function" ? postSwap : null,') + if has_boot: + api_lines.append(' processScripts: typeof processSxScripts === "function" ? processSxScripts : null,') + api_lines.append(' mount: typeof sxMount === "function" ? sxMount : null,') + api_lines.append(' hydrate: typeof sxHydrateElements === "function" ? sxHydrateElements : null,') + api_lines.append(' update: typeof sxUpdateElement === "function" ? sxUpdateElement : null,') + api_lines.append(' renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,') + api_lines.append(' getEnv: function() { return componentEnv; },') + api_lines.append(' resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,') + api_lines.append(' hydrateIslands: typeof sxHydrateIslands === "function" ? sxHydrateIslands : null,') + api_lines.append(' disposeIsland: typeof disposeIsland === "function" ? disposeIsland : null,') + api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,') + elif has_orch: + api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,') + if has_deps: + api_lines.append(' scanRefs: scanRefs,') + api_lines.append(' scanComponentsFromSource: scanComponentsFromSource,') + api_lines.append(' transitiveDeps: transitiveDeps,') + api_lines.append(' computeAllDeps: computeAllDeps,') + api_lines.append(' componentsNeeded: componentsNeeded,') + api_lines.append(' pageComponentBundle: pageComponentBundle,') + api_lines.append(' pageCssClasses: pageCssClasses,') + api_lines.append(' scanIoRefs: scanIoRefs,') + api_lines.append(' transitiveIoRefs: transitiveIoRefs,') + api_lines.append(' computeAllIoRefs: computeAllIoRefs,') + api_lines.append(' componentPure_p: componentPure_p,') + if has_page_helpers: + api_lines.append(' categorizeSpecialForms: categorizeSpecialForms,') + api_lines.append(' buildReferenceData: buildReferenceData,') + api_lines.append(' buildAttrDetail: buildAttrDetail,') + api_lines.append(' buildHeaderDetail: buildHeaderDetail,') + api_lines.append(' buildEventDetail: buildEventDetail,') + api_lines.append(' buildComponentSource: buildComponentSource,') + api_lines.append(' buildBundleAnalysis: buildBundleAnalysis,') + api_lines.append(' buildRoutingAnalysis: buildRoutingAnalysis,') + api_lines.append(' buildAffinityAnalysis: buildAffinityAnalysis,') + if has_router: + api_lines.append(' splitPathSegments: splitPathSegments,') + api_lines.append(' parseRoutePattern: parseRoutePattern,') + api_lines.append(' matchRoute: matchRoute,') + api_lines.append(' findMatchingRoute: findMatchingRoute,') + + if has_dom: + api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,') + api_lines.append(' registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,') + api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,') + api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,') + if has_signals: + api_lines.append(' signal: signal,') + api_lines.append(' deref: deref,') + api_lines.append(' reset: reset_b,') + api_lines.append(' swap: swap_b,') + api_lines.append(' computed: computed,') + api_lines.append(' effect: effect,') + api_lines.append(' batch: batch,') + api_lines.append(' isSignal: isSignal,') + api_lines.append(' makeSignal: makeSignal,') + api_lines.append(' defStore: defStore,') + api_lines.append(' useStore: useStore,') + api_lines.append(' clearStores: clearStores,') + api_lines.append(' emitEvent: emitEvent,') + api_lines.append(' onEvent: onEvent,') + api_lines.append(' bridgeEvent: bridgeEvent,') + api_lines.append(f' _version: "{version}"') + api_lines.append(' };') + api_lines.append('') + if has_orch: + api_lines.append(''' + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + }''') + if has_boot: + api_lines.append(''' + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxInit = function() { + bootInit(); + // Process any suspense resolutions that arrived before init + if (global.__sxPending) { + for (var pi = 0; pi < global.__sxPending.length; pi++) { + resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx); + } + global.__sxPending = null; + } + // Set up direct resolution for future chunks + global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); }; + // Register service worker for offline data caching + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sx-sw.js", { scope: "/" }).then(function(reg) { + logInfo("sx:sw registered (scope: " + reg.scope + ")"); + }).catch(function(err) { + logWarn("sx:sw registration failed: " + (err && err.message ? err.message : err)); + }); + } + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxInit); + } else { + _sxInit(); + } + }''') + elif has_orch: + api_lines.append(''' + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxInit = function() { engineInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxInit); + } else { + _sxInit(); + } + }''') + + api_lines.append(' if (typeof module !== "undefined" && module.exports) module.exports = Sx;') + api_lines.append(' else global.Sx = Sx;') + + return "\n".join(api_lines) + + +EPILOGUE = ''' +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);''' diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index 74fafae..de9794f 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -1428,6 +1428,7 @@ SPEC_MODULES = { "router": ("router.sx", "router (client-side route matching)"), "engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"), "signals": ("signals.sx", "signals (reactive signal runtime)"), + "page-helpers": ("page-helpers.sx", "page-helpers (pure data transformation helpers)"), } EXTENSION_NAMES = {"continuations"} diff --git a/shared/sx/ref/run_js_sx.py b/shared/sx/ref/run_js_sx.py index 9fd1e14..a5a92ed 100644 --- a/shared/sx/ref/run_js_sx.py +++ b/shared/sx/ref/run_js_sx.py @@ -103,8 +103,11 @@ def compile_ref_to_js( if "boot" in adapter_set: spec_mod_set.add("router") spec_mod_set.add("deps") + if "page-helpers" in SPEC_MODULES: + spec_mod_set.add("page-helpers") has_deps = "deps" in spec_mod_set has_router = "router" in spec_mod_set + has_page_helpers = "page-helpers" in spec_mod_set # Resolve extensions ext_set = set() @@ -198,12 +201,12 @@ def compile_ref_to_js( if name in adapter_set and name in adapter_platform: parts.append(adapter_platform[name]) - parts.append(fixups_js(has_html, has_sx, has_dom, has_signals, has_deps)) + parts.append(fixups_js(has_html, has_sx, has_dom, has_signals, has_deps, has_page_helpers)) if has_continuations: parts.append(CONTINUATIONS_JS) if has_dom: parts.append(ASYNC_IO_JS) - parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals)) + parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals, has_page_helpers)) parts.append(EPILOGUE) build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 020daa3..f5de87a 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -2342,452 +2342,147 @@ def env_components(env): return filter(lambda k: (lambda v: (is_component(v) if sx_truthy(is_component(v)) else is_macro(v)))(env_get(env, k)), keys(env)) -# === Transpiled from engine (fetch/swap/trigger pure logic) === +# === Transpiled from page-helpers (pure data transformation helpers) === -# ENGINE_VERBS -ENGINE_VERBS = ['get', 'post', 'put', 'delete', 'patch'] +# special-form-category-map +special_form_category_map = {'if': 'Control Flow', 'when': 'Control Flow', 'cond': 'Control Flow', 'case': 'Control Flow', 'and': 'Control Flow', 'or': 'Control Flow', 'let': 'Binding', 'let*': 'Binding', 'letrec': 'Binding', 'define': 'Binding', 'set!': 'Binding', 'lambda': 'Functions & Components', 'fn': 'Functions & Components', 'defcomp': 'Functions & Components', 'defmacro': 'Functions & Components', 'begin': 'Sequencing & Threading', 'do': 'Sequencing & Threading', '->': 'Sequencing & Threading', 'quote': 'Quoting', 'quasiquote': 'Quoting', 'reset': 'Continuations', 'shift': 'Continuations', 'dynamic-wind': 'Guards', 'map': 'Higher-Order Forms', 'map-indexed': 'Higher-Order Forms', 'filter': 'Higher-Order Forms', 'reduce': 'Higher-Order Forms', 'some': 'Higher-Order Forms', 'every?': 'Higher-Order Forms', 'for-each': 'Higher-Order Forms', 'defstyle': 'Domain Definitions', 'defhandler': 'Domain Definitions', 'defpage': 'Domain Definitions', 'defquery': 'Domain Definitions', 'defaction': 'Domain Definitions'} -# DEFAULT_SWAP -DEFAULT_SWAP = 'outerHTML' +# extract-define-kwargs +def extract_define_kwargs(expr): + result = {} + items = slice(expr, 2) + n = len(items) + for idx in range(0, n): + if sx_truthy((((idx + 1) < n) if not sx_truthy(((idx + 1) < n)) else (type_of(nth(items, idx)) == 'keyword'))): + key = keyword_name(nth(items, idx)) + val = nth(items, (idx + 1)) + result[key] = (sx_str('(', join(' ', map(serialize, val)), ')') if sx_truthy((type_of(val) == 'list')) else sx_str(val)) + return result -# parse-time -def parse_time(s): - if sx_truthy(is_nil(s)): - return 0 - elif sx_truthy(ends_with_p(s, 'ms')): - return parse_int(s, 0) - elif sx_truthy(ends_with_p(s, 's')): - return (parse_int(replace(s, 's', ''), 0) * 1000) +# categorize-special-forms +def categorize_special_forms(parsed_exprs): + categories = {} + for expr in parsed_exprs: + if sx_truthy(((type_of(expr) == 'list') if not sx_truthy((type_of(expr) == 'list')) else ((len(expr) >= 2) if not sx_truthy((len(expr) >= 2)) else ((type_of(first(expr)) == 'symbol') if not sx_truthy((type_of(first(expr)) == 'symbol')) else (symbol_name(first(expr)) == 'define-special-form'))))): + name = nth(expr, 1) + kwargs = extract_define_kwargs(expr) + category = (get(special_form_category_map, name) if sx_truthy(get(special_form_category_map, name)) else 'Other') + if sx_truthy((not sx_truthy(has_key_p(categories, category)))): + categories[category] = [] + get(categories, category).append({'name': name, 'syntax': (get(kwargs, 'syntax') if sx_truthy(get(kwargs, 'syntax')) else ''), 'doc': (get(kwargs, 'doc') if sx_truthy(get(kwargs, 'doc')) else ''), 'tail-position': (get(kwargs, 'tail-position') if sx_truthy(get(kwargs, 'tail-position')) else ''), 'example': (get(kwargs, 'example') if sx_truthy(get(kwargs, 'example')) else '')}) + return categories + +# build-ref-items-with-href +def build_ref_items_with_href(items, base_path, detail_keys, n_fields): + return map(lambda item: ((lambda name: (lambda field2: (lambda field3: {'name': name, 'desc': field2, 'exists': field3, 'href': (sx_str(base_path, name) if sx_truthy((field3 if not sx_truthy(field3) else some(lambda k: (k == name), detail_keys))) else NIL)})(nth(item, 2)))(nth(item, 1)))(nth(item, 0)) if sx_truthy((n_fields == 3)) else (lambda name: (lambda desc: {'name': name, 'desc': desc, 'href': (sx_str(base_path, name) if sx_truthy(some(lambda k: (k == name), detail_keys)) else NIL)})(nth(item, 1)))(nth(item, 0))), items) + +# build-reference-data +def build_reference_data(slug, raw_data, detail_keys): + _match = slug + if _match == 'attributes': + return {'req-attrs': build_ref_items_with_href(get(raw_data, 'req-attrs'), '/hypermedia/reference/attributes/', detail_keys, 3), 'beh-attrs': build_ref_items_with_href(get(raw_data, 'beh-attrs'), '/hypermedia/reference/attributes/', detail_keys, 3), 'uniq-attrs': build_ref_items_with_href(get(raw_data, 'uniq-attrs'), '/hypermedia/reference/attributes/', detail_keys, 3)} + elif _match == 'headers': + return {'req-headers': build_ref_items_with_href(get(raw_data, 'req-headers'), '/hypermedia/reference/headers/', detail_keys, 3), 'resp-headers': build_ref_items_with_href(get(raw_data, 'resp-headers'), '/hypermedia/reference/headers/', detail_keys, 3)} + elif _match == 'events': + return {'events-list': build_ref_items_with_href(get(raw_data, 'events-list'), '/hypermedia/reference/events/', detail_keys, 2)} + elif _match == 'js-api': + return {'js-api-list': map(lambda item: {'name': nth(item, 0), 'desc': nth(item, 1)}, get(raw_data, 'js-api-list'))} else: - return parse_int(s, 0) + return {'req-attrs': build_ref_items_with_href(get(raw_data, 'req-attrs'), '/hypermedia/reference/attributes/', detail_keys, 3), 'beh-attrs': build_ref_items_with_href(get(raw_data, 'beh-attrs'), '/hypermedia/reference/attributes/', detail_keys, 3), 'uniq-attrs': build_ref_items_with_href(get(raw_data, 'uniq-attrs'), '/hypermedia/reference/attributes/', detail_keys, 3)} -# parse-trigger-spec -def parse_trigger_spec(spec): - if sx_truthy(is_nil(spec)): - return NIL +# build-attr-detail +def build_attr_detail(slug, detail): + if sx_truthy(is_nil(detail)): + return {'attr-not-found': True} else: - raw_parts = split(spec, ',') - return filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda part: (lambda tokens: (NIL if sx_truthy(empty_p(tokens)) else ({'event': 'every', 'modifiers': {'interval': parse_time(nth(tokens, 1))}} if sx_truthy(((first(tokens) == 'every') if not sx_truthy((first(tokens) == 'every')) else (len(tokens) >= 2))) else (lambda mods: _sx_begin(for_each(lambda tok: (_sx_dict_set(mods, 'once', True) if sx_truthy((tok == 'once')) else (_sx_dict_set(mods, 'changed', True) if sx_truthy((tok == 'changed')) else (_sx_dict_set(mods, 'delay', parse_time(slice(tok, 6))) if sx_truthy(starts_with_p(tok, 'delay:')) else (_sx_dict_set(mods, 'from', slice(tok, 5)) if sx_truthy(starts_with_p(tok, 'from:')) else NIL)))), rest(tokens)), {'event': first(tokens), 'modifiers': mods}))({}))))(split(trim(part), ' ')), raw_parts)) + return {'attr-not-found': NIL, 'attr-title': slug, 'attr-description': get(detail, 'description'), 'attr-example': get(detail, 'example'), 'attr-handler': get(detail, 'handler'), 'attr-demo': get(detail, 'demo'), 'attr-wire-id': (sx_str('ref-wire-', replace(replace(slug, ':', '-'), '*', 'star')) if sx_truthy(has_key_p(detail, 'handler')) else NIL)} -# default-trigger -def default_trigger(tag_name): - if sx_truthy((tag_name == 'FORM')): - return [{'event': 'submit', 'modifiers': {}}] - elif sx_truthy(((tag_name == 'INPUT') if sx_truthy((tag_name == 'INPUT')) else ((tag_name == 'SELECT') if sx_truthy((tag_name == 'SELECT')) else (tag_name == 'TEXTAREA')))): - return [{'event': 'change', 'modifiers': {}}] +# build-header-detail +def build_header_detail(slug, detail): + if sx_truthy(is_nil(detail)): + return {'header-not-found': True} else: - return [{'event': 'click', 'modifiers': {}}] + return {'header-not-found': NIL, 'header-title': slug, 'header-direction': get(detail, 'direction'), 'header-description': get(detail, 'description'), 'header-example': get(detail, 'example'), 'header-demo': get(detail, 'demo')} -# get-verb-info -def get_verb_info(el): - return some(lambda verb: (lambda url: ({'method': upper(verb), 'url': url} if sx_truthy(url) else NIL))(dom_get_attr(el, sx_str('sx-', verb))), ENGINE_VERBS) +# build-event-detail +def build_event_detail(slug, detail): + if sx_truthy(is_nil(detail)): + return {'event-not-found': True} + else: + return {'event-not-found': NIL, 'event-title': slug, 'event-description': get(detail, 'description'), 'event-example': get(detail, 'example'), 'event-demo': get(detail, 'demo')} -# build-request-headers -def build_request_headers(el, loaded_components, css_hash): - headers = {'SX-Request': 'true', 'SX-Current-URL': browser_location_href()} - target_sel = dom_get_attr(el, 'sx-target') - if sx_truthy(target_sel): - headers['SX-Target'] = target_sel - if sx_truthy((not sx_truthy(empty_p(loaded_components)))): - headers['SX-Components'] = join(',', loaded_components) - if sx_truthy(css_hash): - headers['SX-Css'] = css_hash - extra_h = dom_get_attr(el, 'sx-headers') - if sx_truthy(extra_h): - parsed = parse_header_value(extra_h) - if sx_truthy(parsed): - for key in keys(parsed): - headers[key] = sx_str(get(parsed, key)) - return headers +# build-component-source +def build_component_source(comp_data): + comp_type = get(comp_data, 'type') + name = get(comp_data, 'name') + params = get(comp_data, 'params') + has_children = get(comp_data, 'has-children') + body_sx = get(comp_data, 'body-sx') + affinity = get(comp_data, 'affinity') + if sx_truthy((comp_type == 'not-found')): + return sx_str(';; component ', name, ' not found') + else: + param_strs = ((['&rest', 'children'] if sx_truthy(has_children) else []) if sx_truthy(empty_p(params)) else (append(cons('&key', params), ['&rest', 'children']) if sx_truthy(has_children) else cons('&key', params))) + params_sx = sx_str('(', join(' ', param_strs), ')') + form_name = ('defisland' if sx_truthy((comp_type == 'island')) else 'defcomp') + affinity_str = (sx_str(' :affinity ', affinity) if sx_truthy(((comp_type == 'component') if not sx_truthy((comp_type == 'component')) else ((not sx_truthy(is_nil(affinity))) if not sx_truthy((not sx_truthy(is_nil(affinity)))) else (not sx_truthy((affinity == 'auto')))))) else '') + return sx_str('(', form_name, ' ', name, ' ', params_sx, affinity_str, '\n ', body_sx, ')') -# process-response-headers -def process_response_headers(get_header): - return {'redirect': get_header('SX-Redirect'), 'refresh': get_header('SX-Refresh'), 'trigger': get_header('SX-Trigger'), 'retarget': get_header('SX-Retarget'), 'reswap': get_header('SX-Reswap'), 'location': get_header('SX-Location'), 'replace-url': get_header('SX-Replace-Url'), 'css-hash': get_header('SX-Css-Hash'), 'trigger-swap': get_header('SX-Trigger-After-Swap'), 'trigger-settle': get_header('SX-Trigger-After-Settle'), 'content-type': get_header('Content-Type'), 'cache-invalidate': get_header('SX-Cache-Invalidate'), 'cache-update': get_header('SX-Cache-Update')} - -# parse-swap-spec -def parse_swap_spec(raw_swap, global_transitions_p): +# build-bundle-analysis +def build_bundle_analysis(pages_raw, components_raw, total_components, total_macros, pure_count, io_count): _cells = {} - parts = split((raw_swap if sx_truthy(raw_swap) else DEFAULT_SWAP), ' ') - style = first(parts) - _cells['use_transition'] = global_transitions_p - for p in rest(parts): - if sx_truthy((p == 'transition:true')): - _cells['use_transition'] = True - elif sx_truthy((p == 'transition:false')): - _cells['use_transition'] = False - return {'style': style, 'transition': _cells['use_transition']} + pages_data = [] + for page in pages_raw: + needed_names = get(page, 'needed-names') + n = len(needed_names) + pct = (round(((n / total_components) * 100)) if sx_truthy((total_components > 0)) else 0) + savings = (100 - pct) + _cells['pure_in_page'] = 0 + _cells['io_in_page'] = 0 + page_io_refs = [] + comp_details = [] + for comp_name in needed_names: + info = get(components_raw, comp_name) + if sx_truthy((not sx_truthy(is_nil(info)))): + if sx_truthy(get(info, 'is-pure')): + _cells['pure_in_page'] = (_cells['pure_in_page'] + 1) + else: + _cells['io_in_page'] = (_cells['io_in_page'] + 1) + for ref in (get(info, 'io-refs') if sx_truthy(get(info, 'io-refs')) else []): + if sx_truthy((not sx_truthy(some(lambda r: (r == ref), page_io_refs)))): + page_io_refs.append(ref) + comp_details.append({'name': comp_name, 'is-pure': get(info, 'is-pure'), 'affinity': get(info, 'affinity'), 'render-target': get(info, 'render-target'), 'io-refs': (get(info, 'io-refs') if sx_truthy(get(info, 'io-refs')) else []), 'deps': (get(info, 'deps') if sx_truthy(get(info, 'deps')) else []), 'source': get(info, 'source')}) + pages_data.append({'name': get(page, 'name'), 'path': get(page, 'path'), 'direct': get(page, 'direct'), 'needed': n, 'pct': pct, 'savings': savings, 'io-refs': len(page_io_refs), 'pure-in-page': _cells['pure_in_page'], 'io-in-page': _cells['io_in_page'], 'components': comp_details}) + return {'pages': pages_data, 'total-components': total_components, 'total-macros': total_macros, 'pure-count': pure_count, 'io-count': io_count} -# parse-retry-spec -def parse_retry_spec(retry_attr): - if sx_truthy(is_nil(retry_attr)): - return NIL - else: - parts = split(retry_attr, ':') - return {'strategy': first(parts), 'start-ms': parse_int(nth(parts, 1), 1000), 'cap-ms': parse_int(nth(parts, 2), 30000)} - -# next-retry-ms -def next_retry_ms(current_ms, cap_ms): - return min((current_ms * 2), cap_ms) - -# filter-params -def filter_params(params_spec, all_params): - if sx_truthy(is_nil(params_spec)): - return all_params - elif sx_truthy((params_spec == 'none')): - return [] - elif sx_truthy((params_spec == '*')): - return all_params - elif sx_truthy(starts_with_p(params_spec, 'not ')): - excluded = map(trim, split(slice(params_spec, 4), ',')) - return filter(lambda p: (not sx_truthy(contains_p(excluded, first(p)))), all_params) - else: - allowed = map(trim, split(params_spec, ',')) - return filter(lambda p: contains_p(allowed, first(p)), all_params) - -# resolve-target -def resolve_target(el): - sel = dom_get_attr(el, 'sx-target') - if sx_truthy((is_nil(sel) if sx_truthy(is_nil(sel)) else (sel == 'this'))): - return el - elif sx_truthy((sel == 'closest')): - return dom_parent(el) - else: - return dom_query(sel) - -# apply-optimistic -def apply_optimistic(el): - directive = dom_get_attr(el, 'sx-optimistic') - if sx_truthy(is_nil(directive)): - return NIL - else: - target = (resolve_target(el) if sx_truthy(resolve_target(el)) else el) - state = {'target': target, 'directive': directive} - (_sx_begin(_sx_dict_set(state, 'opacity', dom_get_style(target, 'opacity')), dom_set_style(target, 'opacity', '0'), dom_set_style(target, 'pointer-events', 'none')) if sx_truthy((directive == 'remove')) else (_sx_begin(_sx_dict_set(state, 'disabled', dom_get_prop(target, 'disabled')), dom_set_prop(target, 'disabled', True)) if sx_truthy((directive == 'disable')) else ((lambda cls: _sx_begin(_sx_dict_set(state, 'add-class', cls), dom_add_class(target, cls)))(slice(directive, 10)) if sx_truthy(starts_with_p(directive, 'add-class:')) else NIL))) - return state - -# revert-optimistic -def revert_optimistic(state): - if sx_truthy(state): - target = get(state, 'target') - directive = get(state, 'directive') - if sx_truthy((directive == 'remove')): - dom_set_style(target, 'opacity', (get(state, 'opacity') if sx_truthy(get(state, 'opacity')) else '')) - return dom_set_style(target, 'pointer-events', '') - elif sx_truthy((directive == 'disable')): - return dom_set_prop(target, 'disabled', (get(state, 'disabled') if sx_truthy(get(state, 'disabled')) else False)) - elif sx_truthy(get(state, 'add-class')): - return dom_remove_class(target, get(state, 'add-class')) - return NIL - return NIL - -# find-oob-swaps -def find_oob_swaps(container): - results = [] - for attr in ['sx-swap-oob', 'hx-swap-oob']: - oob_els = dom_query_all(container, sx_str('[', attr, ']')) - for oob in oob_els: - swap_type = (dom_get_attr(oob, attr) if sx_truthy(dom_get_attr(oob, attr)) else 'outerHTML') - target_id = dom_id(oob) - dom_remove_attr(oob, attr) - if sx_truthy(target_id): - results.append({'element': oob, 'swap-type': swap_type, 'target-id': target_id}) - return results - -# morph-node -def morph_node(old_node, new_node): - if sx_truthy((dom_has_attr_p(old_node, 'sx-preserve') if sx_truthy(dom_has_attr_p(old_node, 'sx-preserve')) else dom_has_attr_p(old_node, 'sx-ignore'))): - return NIL - elif sx_truthy((dom_has_attr_p(old_node, 'data-sx-island') if not sx_truthy(dom_has_attr_p(old_node, 'data-sx-island')) else (is_processed_p(old_node, 'island-hydrated') if not sx_truthy(is_processed_p(old_node, 'island-hydrated')) else (dom_has_attr_p(new_node, 'data-sx-island') if not sx_truthy(dom_has_attr_p(new_node, 'data-sx-island')) else (dom_get_attr(old_node, 'data-sx-island') == dom_get_attr(new_node, 'data-sx-island')))))): - return morph_island_children(old_node, new_node) - elif sx_truthy(((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node)))) if sx_truthy((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node))))) else (not sx_truthy((dom_node_name(old_node) == dom_node_name(new_node)))))): - return dom_replace_child(dom_parent(old_node), dom_clone(new_node), old_node) - elif sx_truthy(((dom_node_type(old_node) == 3) if sx_truthy((dom_node_type(old_node) == 3)) else (dom_node_type(old_node) == 8))): - if sx_truthy((not sx_truthy((dom_text_content(old_node) == dom_text_content(new_node))))): - return dom_set_text_content(old_node, dom_text_content(new_node)) - return NIL - elif sx_truthy((dom_node_type(old_node) == 1)): - sync_attrs(old_node, new_node) - if sx_truthy((not sx_truthy((dom_is_active_element_p(old_node) if not sx_truthy(dom_is_active_element_p(old_node)) else dom_is_input_element_p(old_node))))): - return morph_children(old_node, new_node) - return NIL - return NIL - -# sync-attrs -def sync_attrs(old_el, new_el): - ra_str = (dom_get_attr(old_el, 'data-sx-reactive-attrs') if sx_truthy(dom_get_attr(old_el, 'data-sx-reactive-attrs')) else '') - reactive_attrs = ([] if sx_truthy(empty_p(ra_str)) else split(ra_str, ',')) - for attr in dom_attr_list(new_el): - name = first(attr) - val = nth(attr, 1) - if sx_truthy(((not sx_truthy((dom_get_attr(old_el, name) == val))) if not sx_truthy((not sx_truthy((dom_get_attr(old_el, name) == val)))) else (not sx_truthy(contains_p(reactive_attrs, name))))): - dom_set_attr(old_el, name, val) - return for_each(lambda attr: (lambda aname: (dom_remove_attr(old_el, aname) if sx_truthy(((not sx_truthy(dom_has_attr_p(new_el, aname))) if not sx_truthy((not sx_truthy(dom_has_attr_p(new_el, aname)))) else ((not sx_truthy(contains_p(reactive_attrs, aname))) if not sx_truthy((not sx_truthy(contains_p(reactive_attrs, aname)))) else (not sx_truthy((aname == 'data-sx-reactive-attrs')))))) else NIL))(first(attr)), dom_attr_list(old_el)) - -# morph-children -def morph_children(old_parent, new_parent): +# build-routing-analysis +def build_routing_analysis(pages_raw): _cells = {} - old_kids = dom_child_list(old_parent) - new_kids = dom_child_list(new_parent) - old_by_id = reduce(lambda acc, kid: (lambda id_: (_sx_begin(_sx_dict_set(acc, id_, kid), acc) if sx_truthy(id_) else acc))(dom_id(kid)), {}, old_kids) - _cells['oi'] = 0 - for new_child in new_kids: - match_id = dom_id(new_child) - match_by_id = (dict_get(old_by_id, match_id) if sx_truthy(match_id) else NIL) - if sx_truthy((match_by_id if not sx_truthy(match_by_id) else (not sx_truthy(is_nil(match_by_id))))): - if sx_truthy(((_cells['oi'] < len(old_kids)) if not sx_truthy((_cells['oi'] < len(old_kids))) else (not sx_truthy((match_by_id == nth(old_kids, _cells['oi'])))))): - dom_insert_before(old_parent, match_by_id, (nth(old_kids, _cells['oi']) if sx_truthy((_cells['oi'] < len(old_kids))) else NIL)) - morph_node(match_by_id, new_child) - _cells['oi'] = (_cells['oi'] + 1) - elif sx_truthy((_cells['oi'] < len(old_kids))): - old_child = nth(old_kids, _cells['oi']) - if sx_truthy((dom_id(old_child) if not sx_truthy(dom_id(old_child)) else (not sx_truthy(match_id)))): - dom_insert_before(old_parent, dom_clone(new_child), old_child) - else: - morph_node(old_child, new_child) - _cells['oi'] = (_cells['oi'] + 1) + pages_data = [] + _cells['client_count'] = 0 + _cells['server_count'] = 0 + for page in pages_raw: + has_data = get(page, 'has-data') + content_src = (get(page, 'content-src') if sx_truthy(get(page, 'content-src')) else '') + _cells['mode'] = NIL + _cells['reason'] = '' + if sx_truthy(has_data): + _cells['mode'] = 'server' + _cells['reason'] = 'Has :data expression — needs server IO' + _cells['server_count'] = (_cells['server_count'] + 1) + elif sx_truthy(empty_p(content_src)): + _cells['mode'] = 'server' + _cells['reason'] = 'No content expression' + _cells['server_count'] = (_cells['server_count'] + 1) else: - dom_append(old_parent, dom_clone(new_child)) - return for_each(lambda i: ((lambda leftover: (dom_remove_child(old_parent, leftover) if sx_truthy((dom_is_child_of_p(leftover, old_parent) if not sx_truthy(dom_is_child_of_p(leftover, old_parent)) else ((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve')))) else (not sx_truthy(dom_has_attr_p(leftover, 'sx-ignore')))))) else NIL))(nth(old_kids, i)) if sx_truthy((i >= _cells['oi'])) else NIL), range(_cells['oi'], len(old_kids))) + _cells['mode'] = 'client' + _cells['client_count'] = (_cells['client_count'] + 1) + pages_data.append({'name': get(page, 'name'), 'path': get(page, 'path'), 'mode': _cells['mode'], 'has-data': has_data, 'content-expr': (sx_str(slice(content_src, 0, 80), '...') if sx_truthy((len(content_src) > 80)) else content_src), 'reason': _cells['reason']}) + return {'pages': pages_data, 'total-pages': (_cells['client_count'] + _cells['server_count']), 'client-count': _cells['client_count'], 'server-count': _cells['server_count']} -# morph-island-children -def morph_island_children(old_island, new_island): - old_lakes = dom_query_all(old_island, '[data-sx-lake]') - 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]') - new_lake_map = {} - new_marsh_map = {} - for lake in new_lakes: - id_ = dom_get_attr(lake, 'data-sx-lake') - if sx_truthy(id_): - new_lake_map[id_] = lake - for marsh in new_marshes: - id_ = dom_get_attr(marsh, 'data-sx-marsh') - if sx_truthy(id_): - new_marsh_map[id_] = marsh - for old_lake in old_lakes: - id_ = dom_get_attr(old_lake, 'data-sx-lake') - new_lake = dict_get(new_lake_map, id_) - if sx_truthy(new_lake): - sync_attrs(old_lake, new_lake) - morph_children(old_lake, new_lake) - for old_marsh in old_marshes: - id_ = dom_get_attr(old_marsh, 'data-sx-marsh') - new_marsh = dict_get(new_marsh_map, id_) - if sx_truthy(new_marsh): - morph_marsh(old_marsh, new_marsh, old_island) - return process_signal_updates(new_island) - -# morph-marsh -def morph_marsh(old_marsh, new_marsh, island_el): - 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 sx_truthy((env if not sx_truthy(env) else (new_html if not sx_truthy(new_html) else (not sx_truthy(empty_p(new_html)))))): - parsed = parse(new_html) - sx_content = (invoke(transform, parsed) if sx_truthy(transform) else parsed) - dispose_marsh_scope(old_marsh) - return with_marsh_scope(old_marsh, lambda : (lambda new_dom: _sx_begin(dom_remove_children_after(old_marsh, NIL), dom_append(old_marsh, new_dom)))(render_to_dom(sx_content, env, NIL))) - else: - sync_attrs(old_marsh, new_marsh) - return morph_children(old_marsh, new_marsh) - -# process-signal-updates -def process_signal_updates(root): - signal_els = dom_query_all(root, '[data-sx-signal]') - return for_each(lambda el: (lambda spec: ((lambda colon_idx: ((lambda store_name: (lambda raw_value: _sx_begin((lambda parsed: reset_b(use_store(store_name), parsed))(json_parse(raw_value)), dom_remove_attr(el, 'data-sx-signal')))(slice(spec, (colon_idx + 1))))(slice(spec, 0, colon_idx)) if sx_truthy((colon_idx > 0)) else NIL))(index_of(spec, ':')) if sx_truthy(spec) else NIL))(dom_get_attr(el, 'data-sx-signal')), signal_els) - -# swap-dom-nodes -def swap_dom_nodes(target, new_nodes, strategy): - _match = strategy - if _match == 'innerHTML': - if sx_truthy(dom_is_fragment_p(new_nodes)): - return morph_children(target, new_nodes) - else: - wrapper = dom_create_element('div', NIL) - dom_append(wrapper, new_nodes) - return morph_children(target, wrapper) - elif _match == 'outerHTML': - parent = dom_parent(target) - ((lambda fc: (_sx_begin(morph_node(target, fc), (lambda sib: insert_remaining_siblings(parent, target, sib))(dom_next_sibling(fc))) if sx_truthy(fc) else dom_remove_child(parent, target)))(dom_first_child(new_nodes)) if sx_truthy(dom_is_fragment_p(new_nodes)) else morph_node(target, new_nodes)) - return parent - elif _match == 'afterend': - return dom_insert_after(target, new_nodes) - elif _match == 'beforeend': - return dom_append(target, new_nodes) - elif _match == 'afterbegin': - return dom_prepend(target, new_nodes) - elif _match == 'beforebegin': - return dom_insert_before(dom_parent(target), new_nodes, target) - elif _match == 'delete': - return dom_remove_child(dom_parent(target), target) - elif _match == 'none': - return NIL - else: - if sx_truthy(dom_is_fragment_p(new_nodes)): - return morph_children(target, new_nodes) - else: - wrapper = dom_create_element('div', NIL) - dom_append(wrapper, new_nodes) - return morph_children(target, wrapper) - -# insert-remaining-siblings -def insert_remaining_siblings(parent, ref_node, sib): - if sx_truthy(sib): - next = dom_next_sibling(sib) - dom_insert_after(ref_node, sib) - return insert_remaining_siblings(parent, sib, next) - return NIL - -# swap-html-string -def swap_html_string(target, html, strategy): - _match = strategy - if _match == 'innerHTML': - return dom_set_inner_html(target, html) - elif _match == 'outerHTML': - parent = dom_parent(target) - dom_insert_adjacent_html(target, 'afterend', html) - dom_remove_child(parent, target) - return parent - elif _match == 'afterend': - return dom_insert_adjacent_html(target, 'afterend', html) - elif _match == 'beforeend': - return dom_insert_adjacent_html(target, 'beforeend', html) - elif _match == 'afterbegin': - return dom_insert_adjacent_html(target, 'afterbegin', html) - elif _match == 'beforebegin': - return dom_insert_adjacent_html(target, 'beforebegin', html) - elif _match == 'delete': - return dom_remove_child(dom_parent(target), target) - elif _match == 'none': - return NIL - else: - return dom_set_inner_html(target, html) - -# handle-history -def handle_history(el, url, resp_headers): - push_url = dom_get_attr(el, 'sx-push-url') - replace_url = dom_get_attr(el, 'sx-replace-url') - hdr_replace = get(resp_headers, 'replace-url') - if sx_truthy(hdr_replace): - return browser_replace_state(hdr_replace) - elif sx_truthy((push_url if not sx_truthy(push_url) else (not sx_truthy((push_url == 'false'))))): - return browser_push_state((url if sx_truthy((push_url == 'true')) else push_url)) - elif sx_truthy((replace_url if not sx_truthy(replace_url) else (not sx_truthy((replace_url == 'false'))))): - return browser_replace_state((url if sx_truthy((replace_url == 'true')) else replace_url)) - return NIL - -# PRELOAD_TTL -PRELOAD_TTL = 30000 - -# preload-cache-get -def preload_cache_get(cache, url): - entry = dict_get(cache, url) - if sx_truthy(is_nil(entry)): - return NIL - else: - if sx_truthy(((now_ms() - get(entry, 'timestamp')) > PRELOAD_TTL)): - dict_delete(cache, url) - return NIL - else: - dict_delete(cache, url) - return entry - -# preload-cache-set -def preload_cache_set(cache, url, text, content_type): - return _sx_dict_set(cache, url, {'text': text, 'content-type': content_type, 'timestamp': now_ms()}) - -# classify-trigger -def classify_trigger(trigger): - event = get(trigger, 'event') - if sx_truthy((event == 'every')): - return 'poll' - elif sx_truthy((event == 'intersect')): - return 'intersect' - elif sx_truthy((event == 'load')): - return 'load' - elif sx_truthy((event == 'revealed')): - return 'revealed' - else: - return 'event' - -# should-boost-link? -def should_boost_link_p(link): - href = dom_get_attr(link, 'href') - return (href if not sx_truthy(href) else ((not sx_truthy(starts_with_p(href, '#'))) if not sx_truthy((not sx_truthy(starts_with_p(href, '#')))) else ((not sx_truthy(starts_with_p(href, 'javascript:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'javascript:')))) else ((not sx_truthy(starts_with_p(href, 'mailto:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'mailto:')))) else (browser_same_origin_p(href) if not sx_truthy(browser_same_origin_p(href)) else ((not sx_truthy(dom_has_attr_p(link, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(link, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(link, 'sx-disable')))))))))) - -# should-boost-form? -def should_boost_form_p(form): - return ((not sx_truthy(dom_has_attr_p(form, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(form, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(form, 'sx-disable'))))) - -# parse-sse-swap -def parse_sse_swap(el): - return (dom_get_attr(el, 'sx-sse-swap') if sx_truthy(dom_get_attr(el, 'sx-sse-swap')) else 'message') - - -# === Transpiled from router (client-side route matching) === - -# split-path-segments -def split_path_segments(path): - trimmed = (slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path) - trimmed2 = (slice(trimmed, 0, (len(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed) - if sx_truthy(empty_p(trimmed2)): - return [] - else: - return split(trimmed2, '/') - -# make-route-segment -def make_route_segment(seg): - if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))): - param_name = slice(seg, 1, (len(seg) - 1)) - d = {} - d['type'] = 'param' - d['value'] = param_name - return d - else: - d = {} - d['type'] = 'literal' - d['value'] = seg - return d - -# parse-route-pattern -def parse_route_pattern(pattern): - segments = split_path_segments(pattern) - return map(make_route_segment, segments) - -# match-route-segments -def match_route_segments(path_segs, parsed_segs): - _cells = {} - if sx_truthy((not sx_truthy((len(path_segs) == len(parsed_segs))))): - return NIL - else: - params = {} - _cells['matched'] = True - for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs) - if sx_truthy(_cells['matched']): - return params - else: - return NIL - -# match-route -def match_route(path, pattern): - path_segs = split_path_segments(path) - parsed_segs = parse_route_pattern(pattern) - return match_route_segments(path_segs, parsed_segs) - -# find-matching-route -def find_matching_route(path, routes): - _cells = {} - path_segs = split_path_segments(path) - _cells['result'] = NIL - for route in routes: - if sx_truthy(is_nil(_cells['result'])): - params = match_route_segments(path_segs, get(route, 'parsed')) - if sx_truthy((not sx_truthy(is_nil(params)))): - matched = merge(route, {}) - matched['params'] = params - _cells['result'] = matched - return _cells['result'] +# build-affinity-analysis +def build_affinity_analysis(demo_components, page_plans): + return {'components': demo_components, 'page-plans': page_plans} # === Transpiled from signals (reactive signal runtime) === @@ -3058,4 +2753,4 @@ def render(expr, env=None): def make_env(**kwargs): """Create an environment with initial bindings.""" - return _Env(dict(kwargs)) + return _Env(dict(kwargs)) \ No newline at end of file diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index 0269f11..d39c940 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -104,3 +104,8 @@ :params () :returns "dict" :service "sx") + +(define-page-helper "page-helpers-demo-data" + :params () + :returns "dict" + :service "sx") diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index bb2c30f..1d2dfa7 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -227,7 +227,8 @@ (dict :label "JavaScript" :href "/bootstrappers/javascript") (dict :label "Python" :href "/bootstrappers/python") (dict :label "Self-Hosting (py.sx)" :href "/bootstrappers/self-hosting") - (dict :label "Self-Hosting JS (js.sx)" :href "/bootstrappers/self-hosting-js"))) + (dict :label "Self-Hosting JS (js.sx)" :href "/bootstrappers/self-hosting-js") + (dict :label "Page Helpers" :href "/bootstrappers/page-helpers"))) ;; Spec file registry — canonical metadata for spec viewer pages. ;; Python only handles file I/O (read-spec-file); all metadata lives here. diff --git a/sx/sx/page-helpers-demo.sx b/sx/sx/page-helpers-demo.sx new file mode 100644 index 0000000..f4adce1 --- /dev/null +++ b/sx/sx/page-helpers-demo.sx @@ -0,0 +1,265 @@ +;; page-helpers-demo.sx — Demo: same SX spec functions on server and client +;; +;; Shows page-helpers.sx functions running on Python (server-side, via sx_ref.py) +;; and JavaScript (client-side, via sx-browser.js) with identical results. +;; Server renders with render-to-html. Client runs as a defisland — pure SX, +;; no JavaScript file. The button click triggers spec functions via signals. + +;; --------------------------------------------------------------------------- +;; Shared card component — used by both server and client results +;; --------------------------------------------------------------------------- + +(defcomp ~demo-result-card (&key title ms desc theme &rest children) + (let ((border (if (= theme "blue") "border-blue-200 bg-blue-50/30" "border-stone-200")) + (title-c (if (= theme "blue") "text-blue-700" "text-stone-700")) + (badge-c (if (= theme "blue") "text-blue-400" "text-stone-400")) + (desc-c (if (= theme "blue") "text-blue-500" "text-stone-500")) + (body-c (if (= theme "blue") "text-blue-600" "text-stone-600"))) + (div :class (str "rounded-lg border p-4 " border) + (h4 :class (str "font-semibold text-sm mb-1 " title-c) + title " " + (span :class (str "text-xs " badge-c) (str ms "ms"))) + (p :class (str "text-xs mb-2 " desc-c) desc) + (div :class (str "text-xs space-y-0.5 " body-c) + children)))) + + +;; --------------------------------------------------------------------------- +;; Client-side island — runs spec functions in the browser on button click +;; --------------------------------------------------------------------------- + +(defisland ~demo-client-runner (&key sf-source attr-detail req-attrs attr-keys) + (let ((results (signal nil)) + (running (signal false)) + (run-demo (fn (e) + (reset! running true) + (let* ((t0 (now-ms)) + + ;; 1. categorize-special-forms + (t1 (now-ms)) + (sf-exprs (sx-parse sf-source)) + (sf-result (categorize-special-forms sf-exprs)) + (sf-ms (- (now-ms) t1)) + (sf-cats {}) + (sf-total 0) + + ;; 2. build-reference-data + (t2 (now-ms)) + (ref-result (build-reference-data "attributes" + {"req-attrs" req-attrs "beh-attrs" (list) "uniq-attrs" (list)} + attr-keys)) + (ref-ms (- (now-ms) t2)) + (ref-sample (slice (or (get ref-result "req-attrs") (list)) 0 3)) + + ;; 3. build-attr-detail + (t3 (now-ms)) + (attr-result (build-attr-detail "sx-get" attr-detail)) + (attr-ms (- (now-ms) t3)) + + ;; 4. build-component-source + (t4 (now-ms)) + (comp-result (build-component-source + {"type" "component" "name" "~demo-card" + "params" (list "title" "subtitle") + "has-children" true + "body-sx" "(div :class \"card\"\n (h2 title)\n (when subtitle (p subtitle))\n children)" + "affinity" "auto"})) + (comp-ms (- (now-ms) t4)) + + ;; 5. build-routing-analysis + (t5 (now-ms)) + (routing-result (build-routing-analysis (list + {"name" "home" "path" "/" "has-data" false "content-src" "(~home-content)"} + {"name" "dashboard" "path" "/dash" "has-data" true "content-src" "(~dashboard)"} + {"name" "about" "path" "/about" "has-data" false "content-src" "(~about-content)"} + {"name" "settings" "path" "/settings" "has-data" true "content-src" "(~settings)"}))) + (routing-ms (- (now-ms) t5)) + + (total-ms (- (now-ms) t0))) + + ;; Post-process sf-result: count forms per category + (for-each (fn (k) + (let ((count (len (get sf-result k)))) + (set! sf-cats (assoc sf-cats k count)) + (set! sf-total (+ sf-total count)))) + (keys sf-result)) + + (reset! results + {"sf-cats" sf-cats "sf-total" sf-total "sf-ms" sf-ms + "ref-sample" ref-sample "ref-ms" ref-ms + "attr-result" attr-result "attr-ms" attr-ms + "comp-result" comp-result "comp-ms" comp-ms + "routing-result" routing-result "routing-ms" routing-ms + "total-ms" total-ms}))))) + + (<> + (button + :class (if (deref running) + "px-4 py-2 rounded-md bg-blue-600 text-white font-medium text-sm cursor-default mb-4" + "px-4 py-2 rounded-md bg-violet-600 text-white font-medium text-sm hover:bg-violet-700 transition-colors mb-4") + :on-click run-demo + (if (deref running) + (str "Done (" (get (deref results) "total-ms") "ms total)") + "Run in Browser")) + + (when (deref results) + (let ((r (deref results))) + (div :class "grid grid-cols-1 md:grid-cols-2 gap-4" + + (~demo-result-card + :title "categorize-special-forms" + :ms (get r "sf-ms") :theme "blue" + :desc "Parses special-forms.sx and classifies each form by category (control flow, binding, quoting, etc)." + (p :class "text-sm mb-1" + (str (get r "sf-total") " forms in " + (len (keys (get r "sf-cats"))) " categories")) + (map (fn (k) + (div (str k ": " (get (get r "sf-cats") k)))) + (keys (get r "sf-cats")))) + + (~demo-result-card + :title "build-reference-data" + :ms (get r "ref-ms") :theme "blue" + :desc "Takes raw attribute tuples and generates reference table rows with documentation hrefs." + (p :class "text-sm mb-1" + (str (len (get r "ref-sample")) " attributes with detail page links")) + (map (fn (item) + (div (str (get item "name") " → " + (or (get item "href") "no detail page")))) + (get r "ref-sample"))) + + (~demo-result-card + :title "build-attr-detail" + :ms (get r "attr-ms") :theme "blue" + :desc "Builds a detail page data structure for a single attribute (sx-get): title, wire ID, handler status." + (div (str "title: " (get (get r "attr-result") "attr-title"))) + (div (str "wire-id: " (or (get (get r "attr-result") "attr-wire-id") "none"))) + (div (str "has handler: " (if (get (get r "attr-result") "attr-handler") "yes" "no")))) + + (~demo-result-card + :title "build-component-source" + :ms (get r "comp-ms") :theme "blue" + :desc "Reconstructs a defcomp source definition from a component metadata dict (name, params, body)." + (pre :class "bg-blue-50 p-2 rounded overflow-x-auto" + (get r "comp-result"))) + + (div :class "rounded-lg border border-blue-200 bg-blue-50/30 p-4 md:col-span-2" + (h4 :class "font-semibold text-blue-700 text-sm mb-1" + "build-routing-analysis " + (span :class "text-xs text-blue-400" (str (get r "routing-ms") "ms"))) + (p :class "text-xs text-blue-500 mb-2" + "Classifies pages as client-routable or server-only based on whether they have data dependencies.") + (div :class "text-xs text-blue-600" + (p :class "text-sm mb-1" + (str (get (get r "routing-result") "total-pages") " pages: " + (get (get r "routing-result") "client-count") " client-routable, " + (get (get r "routing-result") "server-count") " server-only")) + (div :class "space-y-0.5" + (map (fn (pg) + (div (str (get pg "name") " → " (get pg "mode") + (when (not (empty? (get pg "reason"))) + (str " (" (get pg "reason") ")"))))) + (get (get r "routing-result") "pages"))))))))))) + + +;; --------------------------------------------------------------------------- +;; Main page component — server-rendered content + client island +;; --------------------------------------------------------------------------- + +(defcomp ~page-helpers-demo-content (&key + sf-categories sf-total sf-ms + ref-sample ref-ms + attr-result attr-ms + comp-source comp-ms + routing-result routing-ms + server-total-ms + sf-source + attr-detail req-attrs attr-keys) + + (div :class "max-w-3xl mx-auto px-4" + (div :class "mb-8" + (h2 :class "text-2xl font-bold text-stone-800 mb-2" "Bootstrapped Page Helpers") + (p :class "text-stone-600 mb-4" + "These functions are defined once in " + (code :class "text-violet-700" "page-helpers.sx") + " and bootstrapped to both Python (" + (code :class "text-violet-700" "sx_ref.py") + ") and JavaScript (" + (code :class "text-violet-700" "sx-browser.js") + "). The server ran them in Python during this page load. Click the button below to run the identical functions client-side in the browser — same spec, same inputs, same results.")) + + ;; Server results + (div :class "mb-8" + (h3 :class "text-lg font-semibold text-stone-700 mb-3" + "Server Results " + (span :class "text-sm font-normal text-stone-500" + (str "(Python, " server-total-ms "ms total)"))) + + (div :class "grid grid-cols-1 md:grid-cols-2 gap-4" + + (~demo-result-card + :title "categorize-special-forms" + :ms sf-ms :theme "stone" + :desc "Parses special-forms.sx and classifies each form by category (control flow, binding, quoting, etc)." + (p :class "text-sm mb-1" + (str sf-total " forms in " + (len (keys sf-categories)) " categories")) + (map (fn (k) + (div (str k ": " (get sf-categories k)))) + (keys sf-categories))) + + (~demo-result-card + :title "build-reference-data" + :ms ref-ms :theme "stone" + :desc "Takes raw attribute tuples and generates reference table rows with documentation hrefs." + (p :class "text-sm mb-1" + (str (len ref-sample) " attributes with detail page links")) + (map (fn (item) + (div (str (get item "name") " → " + (or (get item "href") "no detail page")))) + ref-sample)) + + (~demo-result-card + :title "build-attr-detail" + :ms attr-ms :theme "stone" + :desc "Builds a detail page data structure for a single attribute (sx-get): title, wire ID, handler status." + (div (str "title: " (get attr-result "attr-title"))) + (div (str "wire-id: " (or (get attr-result "attr-wire-id") "none"))) + (div (str "has handler: " (if (get attr-result "attr-handler") "yes" "no")))) + + (~demo-result-card + :title "build-component-source" + :ms comp-ms :theme "stone" + :desc "Reconstructs a defcomp source definition from a component metadata dict (name, params, body)." + (pre :class "bg-stone-50 p-2 rounded overflow-x-auto" + comp-source)) + + (div :class "rounded-lg border border-stone-200 p-4 md:col-span-2" + (h4 :class "font-semibold text-stone-700 text-sm mb-1" + "build-routing-analysis " + (span :class "text-xs text-stone-400" (str routing-ms "ms"))) + (p :class "text-xs text-stone-500 mb-2" + "Classifies pages as client-routable or server-only based on whether they have data dependencies.") + (div :class "text-xs text-stone-600" + (p :class "text-sm mb-1" + (str (get routing-result "total-pages") " pages: " + (get routing-result "client-count") " client-routable, " + (get routing-result "server-count") " server-only")) + (div :class "space-y-0.5" + (map (fn (pg) + (div (str (get pg "name") " → " (get pg "mode") + (when (not (empty? (get pg "reason"))) + (str " (" (get pg "reason") ")"))))) + (get routing-result "pages"))))))) + + ;; Client execution area — pure SX island, no JavaScript file + (div :class "mb-8" + (h3 :class "text-lg font-semibold text-stone-700 mb-3" + "Client Results " + (span :class "text-sm font-normal text-stone-500" "(JavaScript, sx-browser.js)")) + + (~demo-client-runner + :sf-source sf-source + :attr-detail attr-detail + :req-attrs req-attrs + :attr-keys attr-keys)))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index c938f93..b6402e2 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -553,6 +553,28 @@ "phase2" (~reactive-islands-phase2-content) :else (~reactive-islands-index-content)))) +;; --------------------------------------------------------------------------- +;; Bootstrapped page helpers demo +;; --------------------------------------------------------------------------- + +(defpage page-helpers-demo + :path "/bootstrappers/page-helpers" + :auth :public + :layout :sx-docs + :data (page-helpers-demo-data) + :content (~sx-doc :path "/bootstrappers/page-helpers" + (~page-helpers-demo-content + :sf-categories sf-categories :sf-total sf-total :sf-ms sf-ms + :ref-sample ref-sample :ref-ms ref-ms + :attr-result attr-result :attr-ms attr-ms + :comp-source comp-source :comp-ms comp-ms + :routing-result routing-result :routing-ms routing-ms + :server-total-ms server-total-ms + :sf-source sf-source + :attr-detail attr-detail + :req-attrs req-attrs + :attr-keys attr-keys))) + ;; --------------------------------------------------------------------------- ;; Testing section ;; --------------------------------------------------------------------------- diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index f8c6dee..a9c8cbb 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -33,6 +33,7 @@ def _register_sx_helpers() -> None: "action:add-demo-item": _add_demo_item, "offline-demo-data": _offline_demo_data, "prove-data": _prove_data, + "page-helpers-demo-data": _page_helpers_demo_data, }) @@ -41,26 +42,29 @@ def _component_source(name: str) -> str: from shared.sx.jinja_bridge import get_component_env from shared.sx.parser import serialize from shared.sx.types import Component, Island + from shared.sx.ref.sx_ref import build_component_source comp = get_component_env().get(name) if isinstance(comp, Island): - param_strs = (["&key"] + list(comp.params)) if comp.params else [] - if comp.has_children: - param_strs.extend(["&rest", "children"]) - params_sx = "(" + " ".join(param_strs) + ")" - body_sx = serialize(comp.body, pretty=True) - return f"(defisland {name} {params_sx}\n {body_sx})" + return build_component_source({ + "type": "island", "name": name, + "params": list(comp.params) if comp.params else [], + "has-children": comp.has_children, + "body-sx": serialize(comp.body, pretty=True), + "affinity": None, + }) if not isinstance(comp, Component): - return f";; component {name} not found" - param_strs = ["&key"] + list(comp.params) - if comp.has_children: - param_strs.extend(["&rest", "children"]) - params_sx = "(" + " ".join(param_strs) + ")" - body_sx = serialize(comp.body, pretty=True) - affinity = "" - if comp.render_target == "server": - affinity = " :affinity :server" - return f"(defcomp {name} {params_sx}{affinity}\n {body_sx})" + return build_component_source({ + "type": "not-found", "name": name, + "params": [], "has-children": False, "body-sx": "", "affinity": None, + }) + return build_component_source({ + "type": "component", "name": name, + "params": list(comp.params), + "has-children": comp.has_children, + "body-sx": serialize(comp.body, pretty=True), + "affinity": comp.affinity, + }) def _primitives_data() -> dict: @@ -70,168 +74,57 @@ def _primitives_data() -> dict: def _special_forms_data() -> dict: - """Parse special-forms.sx and return categorized form data. - - Returns a dict of category → list of form dicts, each with: - name, syntax, doc, tail_position, example - """ + """Parse special-forms.sx and return categorized form data.""" import os - from shared.sx.parser import parse_all, serialize - from shared.sx.types import Symbol, Keyword + from shared.sx.parser import parse_all + from shared.sx.ref.sx_ref import categorize_special_forms - ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref") - if not os.path.isdir(ref_dir): - ref_dir = "/app/shared/sx/ref" + ref_dir = _ref_dir() spec_path = os.path.join(ref_dir, "special-forms.sx") with open(spec_path) as f: exprs = parse_all(f.read()) - - # Categories inferred from comment sections in the file. - # We assign forms to categories based on their order in the spec. - categories: dict[str, list[dict]] = {} - current_category = "Other" - - # Map form names to categories - category_map = { - "if": "Control Flow", "when": "Control Flow", "cond": "Control Flow", - "case": "Control Flow", "and": "Control Flow", "or": "Control Flow", - "let": "Binding", "let*": "Binding", "letrec": "Binding", - "define": "Binding", "set!": "Binding", - "lambda": "Functions & Components", "fn": "Functions & Components", - "defcomp": "Functions & Components", "defmacro": "Functions & Components", - "begin": "Sequencing & Threading", "do": "Sequencing & Threading", - "->": "Sequencing & Threading", - "quote": "Quoting", "quasiquote": "Quoting", - "reset": "Continuations", "shift": "Continuations", - "dynamic-wind": "Guards", - "map": "Higher-Order Forms", "map-indexed": "Higher-Order Forms", - "filter": "Higher-Order Forms", "reduce": "Higher-Order Forms", - "some": "Higher-Order Forms", "every?": "Higher-Order Forms", - "for-each": "Higher-Order Forms", - "defstyle": "Domain Definitions", - "defhandler": "Domain Definitions", "defpage": "Domain Definitions", - "defquery": "Domain Definitions", "defaction": "Domain Definitions", - } - - for expr in exprs: - if not isinstance(expr, list) or len(expr) < 2: - continue - head = expr[0] - if not isinstance(head, Symbol) or head.name != "define-special-form": - continue - - name = expr[1] - # Extract keyword args - kwargs: dict[str, str] = {} - i = 2 - while i < len(expr) - 1: - if isinstance(expr[i], Keyword): - key = expr[i].name - val = expr[i + 1] - if isinstance(val, list): - # For :syntax, avoid quote sugar (quasiquote → `x) - items = [serialize(item) for item in val] - kwargs[key] = "(" + " ".join(items) + ")" - else: - kwargs[key] = str(val) - i += 2 - else: - i += 1 - - category = category_map.get(name, "Other") - if category not in categories: - categories[category] = [] - categories[category].append({ - "name": name, - "syntax": kwargs.get("syntax", ""), - "doc": kwargs.get("doc", ""), - "tail-position": kwargs.get("tail-position", ""), - "example": kwargs.get("example", ""), - }) - - return categories + return categorize_special_forms(exprs) def _reference_data(slug: str) -> dict: - """Return reference table data for a given slug. - - Returns a dict whose keys become SX env bindings: - - attributes: req-attrs, beh-attrs, uniq-attrs - - headers: req-headers, resp-headers - - events: events-list - - js-api: js-api-list - """ + """Return reference table data for a given slug.""" from content.pages import ( REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS, REQUEST_HEADERS, RESPONSE_HEADERS, EVENTS, JS_API, ATTR_DETAILS, HEADER_DETAILS, ) + from shared.sx.ref.sx_ref import build_reference_data + # Build raw data dict and detail keys based on slug if slug == "attributes": - return { - "req-attrs": [ - {"name": a, "desc": d, "exists": e, - "href": f"/hypermedia/reference/attributes/{a}" if e and a in ATTR_DETAILS else None} - for a, d, e in REQUEST_ATTRS - ], - "beh-attrs": [ - {"name": a, "desc": d, "exists": e, - "href": f"/hypermedia/reference/attributes/{a}" if e and a in ATTR_DETAILS else None} - for a, d, e in BEHAVIOR_ATTRS - ], - "uniq-attrs": [ - {"name": a, "desc": d, "exists": e, - "href": f"/hypermedia/reference/attributes/{a}" if e and a in ATTR_DETAILS else None} - for a, d, e in SX_UNIQUE_ATTRS - ], + raw = { + "req-attrs": [list(t) for t in REQUEST_ATTRS], + "beh-attrs": [list(t) for t in BEHAVIOR_ATTRS], + "uniq-attrs": [list(t) for t in SX_UNIQUE_ATTRS], } + detail_keys = list(ATTR_DETAILS.keys()) elif slug == "headers": - return { - "req-headers": [ - {"name": n, "value": v, "desc": d, - "href": f"/hypermedia/reference/headers/{n}" if n in HEADER_DETAILS else None} - for n, v, d in REQUEST_HEADERS - ], - "resp-headers": [ - {"name": n, "value": v, "desc": d, - "href": f"/hypermedia/reference/headers/{n}" if n in HEADER_DETAILS else None} - for n, v, d in RESPONSE_HEADERS - ], + raw = { + "req-headers": [list(t) for t in REQUEST_HEADERS], + "resp-headers": [list(t) for t in RESPONSE_HEADERS], } + detail_keys = list(HEADER_DETAILS.keys()) elif slug == "events": from content.pages import EVENT_DETAILS - return { - "events-list": [ - {"name": n, "desc": d, - "href": f"/hypermedia/reference/events/{n}" if n in EVENT_DETAILS else None} - for n, d in EVENTS - ], - } + raw = {"events-list": [list(t) for t in EVENTS]} + detail_keys = list(EVENT_DETAILS.keys()) elif slug == "js-api": - return { - "js-api-list": [ - {"name": n, "desc": d} - for n, d in JS_API - ], + raw = {"js-api-list": [list(t) for t in JS_API]} + detail_keys = [] + else: + raw = { + "req-attrs": [list(t) for t in REQUEST_ATTRS], + "beh-attrs": [list(t) for t in BEHAVIOR_ATTRS], + "uniq-attrs": [list(t) for t in SX_UNIQUE_ATTRS], } - # Default — return attrs data for fallback - return { - "req-attrs": [ - {"name": a, "desc": d, "exists": e, - "href": f"/hypermedia/reference/attributes/{a}" if e and a in ATTR_DETAILS else None} - for a, d, e in REQUEST_ATTRS - ], - "beh-attrs": [ - {"name": a, "desc": d, "exists": e, - "href": f"/hypermedia/reference/attributes/{a}" if e and a in ATTR_DETAILS else None} - for a, d, e in BEHAVIOR_ATTRS - ], - "uniq-attrs": [ - {"name": a, "desc": d, "exists": e, - "href": f"/hypermedia/reference/attributes/{a}" if e and a in ATTR_DETAILS else None} - for a, d, e in SX_UNIQUE_ATTRS - ], - } + detail_keys = list(ATTR_DETAILS.keys()) + + return build_reference_data(slug, raw, detail_keys) def _read_spec_file(filename: str) -> str: @@ -425,6 +318,7 @@ def _js_self_hosting_data(ref_dir: str) -> dict: return { "bootstrapper-not-found": None, "js-sx-source": js_sx_source, + "defines-matched": str(total), "defines-total": str(total), "js-sx-lines": str(len(js_sx_source.splitlines())), "verification-status": status, @@ -438,6 +332,7 @@ def _bundle_analyzer_data() -> dict: from shared.sx.deps import components_needed, scan_components_from_sx from shared.sx.parser import serialize from shared.sx.types import Component, Macro + from shared.sx.ref.sx_ref import build_bundle_analysis env = get_component_env() total_components = sum(1 for v in env.values() if isinstance(v, Component)) @@ -445,68 +340,47 @@ def _bundle_analyzer_data() -> dict: pure_count = sum(1 for v in env.values() if isinstance(v, Component) and v.is_pure) io_count = total_components - pure_count - pages_data = [] + # Extract raw data at I/O edge — Python accesses Component objects, serializes bodies + pages_raw = [] + components_raw: dict[str, dict] = {} for name, page_def in sorted(get_all_pages("sx").items()): content_sx = serialize(page_def.content_expr) direct = scan_components_from_sx(content_sx) - needed = components_needed(content_sx, env) - n = len(needed) - pct = round(n / total_components * 100) if total_components else 0 - savings = 100 - pct + needed = sorted(components_needed(content_sx, env)) - # IO classification + component details for this page - pure_in_page = 0 - io_in_page = 0 - page_io_refs: set[str] = set() - comp_details = [] - for comp_name in sorted(needed): - val = env.get(comp_name) - if isinstance(val, Component): - is_pure = val.is_pure - if is_pure: - pure_in_page += 1 - else: - io_in_page += 1 - page_io_refs.update(val.io_refs) - # Reconstruct defcomp source - param_strs = ["&key"] + list(val.params) - if val.has_children: - param_strs.extend(["&rest", "children"]) - params_sx = "(" + " ".join(param_strs) + ")" - body_sx = serialize(val.body, pretty=True) - source = f"(defcomp ~{val.name} {params_sx}\n {body_sx})" - comp_details.append({ - "name": comp_name, - "is-pure": is_pure, - "affinity": val.affinity, - "render-target": val.render_target, - "io-refs": sorted(val.io_refs), - "deps": sorted(val.deps), - "source": source, - }) + for comp_name in needed: + if comp_name not in components_raw: + val = env.get(comp_name) + if isinstance(val, Component): + param_strs = ["&key"] + list(val.params) + if val.has_children: + param_strs.extend(["&rest", "children"]) + params_sx = "(" + " ".join(param_strs) + ")" + body_sx = serialize(val.body, pretty=True) + components_raw[comp_name] = { + "is-pure": val.is_pure, + "affinity": val.affinity, + "render-target": val.render_target, + "io-refs": sorted(val.io_refs), + "deps": sorted(val.deps), + "source": f"(defcomp ~{val.name} {params_sx}\n {body_sx})", + } - pages_data.append({ + pages_raw.append({ "name": name, "path": page_def.path, "direct": len(direct), - "needed": n, - "pct": pct, - "savings": savings, - "io-refs": len(page_io_refs), - "pure-in-page": pure_in_page, - "io-in-page": io_in_page, - "components": comp_details, + "needed-names": needed, }) - pages_data.sort(key=lambda p: p["needed"], reverse=True) - - return { - "pages": pages_data, - "total-components": total_components, - "total-macros": total_macros, - "pure-count": pure_count, - "io-count": io_count, - } + # Pure data transformation in SX spec + result = build_bundle_analysis( + pages_raw, components_raw, + total_components, total_macros, pure_count, io_count, + ) + # Sort pages by needed count (descending) — SX has no sort primitive + result["pages"] = sorted(result["pages"], key=lambda p: p["needed"], reverse=True) + return result def _routing_analyzer_data() -> dict: @@ -514,12 +388,11 @@ def _routing_analyzer_data() -> dict: from shared.sx.pages import get_all_pages from shared.sx.parser import serialize as sx_serialize from shared.sx.helpers import _sx_literal + from shared.sx.ref.sx_ref import build_routing_analysis - pages_data = [] - full_content: list[tuple[str, str, bool]] = [] # (name, full_content, has_data) - client_count = 0 - server_count = 0 - + # I/O edge: extract page data from page registry + pages_raw = [] + full_content: list[tuple[str, str, bool]] = [] for name, page_def in sorted(get_all_pages("sx").items()): has_data = page_def.data_expr is not None content_src = "" @@ -528,37 +401,21 @@ def _routing_analyzer_data() -> dict: content_src = sx_serialize(page_def.content_expr) except Exception: pass - + pages_raw.append({ + "name": name, "path": page_def.path, + "has-data": has_data, "content-src": content_src, + }) full_content.append((name, content_src, has_data)) - # Determine routing mode and reason - if has_data: - mode = "server" - reason = "Has :data expression — needs server IO" - server_count += 1 - elif not content_src: - mode = "server" - reason = "No content expression" - server_count += 1 - else: - mode = "client" - reason = "" - client_count += 1 + # Pure classification in SX spec + result = build_routing_analysis(pages_raw) + # Sort: client pages first, then server (SX has no sort primitive) + result["pages"] = sorted( + result["pages"], + key=lambda p: (0 if p["mode"] == "client" else 1, p["name"]), + ) - pages_data.append({ - "name": name, - "path": page_def.path, - "mode": mode, - "has-data": has_data, - "content-expr": content_src[:80] + ("..." if len(content_src) > 80 else ""), - "reason": reason, - }) - - # Sort: client pages first, then server - pages_data.sort(key=lambda p: (0 if p["mode"] == "client" else 1, p["name"])) - - # Build a sample of the SX page registry format (use full content, first 3) - total = client_count + server_count + # Build registry sample (uses _sx_literal which is Python string escaping) sample_entries = [] sorted_full = sorted(full_content, key=lambda x: x[0]) for name, csrc, hd in sorted_full[:3]: @@ -574,86 +431,50 @@ def _routing_analyzer_data() -> dict: + "\n :closure {}}" ) sample_entries.append(entry) - registry_sample = "\n\n".join(sample_entries) + result["registry-sample"] = "\n\n".join(sample_entries) - return { - "pages": pages_data, - "total-pages": total, - "client-count": client_count, - "server-count": server_count, - "registry-sample": registry_sample, - } + return result def _attr_detail_data(slug: str) -> dict: - """Return attribute detail data for a specific attribute slug. - - Returns a dict whose keys become SX env bindings: - - attr-title, attr-description, attr-example, attr-handler - - attr-demo (component call or None) - - attr-wire-id (wire placeholder id or None) - - attr-not-found (truthy if not found) - """ + """Return attribute detail data for a specific attribute slug.""" from content.pages import ATTR_DETAILS from shared.sx.helpers import sx_call + from shared.sx.ref.sx_ref import build_attr_detail detail = ATTR_DETAILS.get(slug) - if not detail: - return {"attr-not-found": True} - - demo_name = detail.get("demo") - wire_id = None - if "handler" in detail: - wire_id = f"ref-wire-{slug.replace(':', '-').replace('*', 'star')}" - - return { - "attr-not-found": None, - "attr-title": slug, - "attr-description": detail["description"], - "attr-example": detail["example"], - "attr-handler": detail.get("handler"), - "attr-demo": sx_call(demo_name) if demo_name else None, - "attr-wire-id": wire_id, - } + result = build_attr_detail(slug, detail) + # Convert demo name to sx_call if present + demo_name = result.get("attr-demo") + if demo_name: + result["attr-demo"] = sx_call(demo_name) + return result def _header_detail_data(slug: str) -> dict: """Return header detail data for a specific header slug.""" from content.pages import HEADER_DETAILS from shared.sx.helpers import sx_call + from shared.sx.ref.sx_ref import build_header_detail - detail = HEADER_DETAILS.get(slug) - if not detail: - return {"header-not-found": True} - - demo_name = detail.get("demo") - return { - "header-not-found": None, - "header-title": slug, - "header-direction": detail["direction"], - "header-description": detail["description"], - "header-example": detail.get("example"), - "header-demo": sx_call(demo_name) if demo_name else None, - } + result = build_header_detail(slug, HEADER_DETAILS.get(slug)) + demo_name = result.get("header-demo") + if demo_name: + result["header-demo"] = sx_call(demo_name) + return result def _event_detail_data(slug: str) -> dict: """Return event detail data for a specific event slug.""" from content.pages import EVENT_DETAILS from shared.sx.helpers import sx_call + from shared.sx.ref.sx_ref import build_event_detail - detail = EVENT_DETAILS.get(slug) - if not detail: - return {"event-not-found": True} - - demo_name = detail.get("demo") - return { - "event-not-found": None, - "event-title": slug, - "event-description": detail["description"], - "event-example": detail.get("example"), - "event-demo": sx_call(demo_name) if demo_name else None, - } + result = build_event_detail(slug, EVENT_DETAILS.get(slug)) + demo_name = result.get("event-demo") + if demo_name: + result["event-demo"] = sx_call(demo_name) + return result def _run_spec_tests() -> dict: @@ -1089,35 +910,30 @@ def _affinity_demo_data() -> dict: from shared.sx.jinja_bridge import get_component_env from shared.sx.types import Component from shared.sx.pages import get_all_pages + from shared.sx.ref.sx_ref import build_affinity_analysis + # I/O edge: extract component data and page render plans env = get_component_env() demo_names = [ - "~aff-demo-auto", - "~aff-demo-client", - "~aff-demo-server", - "~aff-demo-io-auto", - "~aff-demo-io-client", + "~aff-demo-auto", "~aff-demo-client", "~aff-demo-server", + "~aff-demo-io-auto", "~aff-demo-io-client", ] components = [] for name in demo_names: val = env.get(name) if isinstance(val, Component): components.append({ - "name": name, - "affinity": val.affinity, + "name": name, "affinity": val.affinity, "render-target": val.render_target, - "io-refs": sorted(val.io_refs), - "is-pure": val.is_pure, + "io-refs": sorted(val.io_refs), "is-pure": val.is_pure, }) - # Collect render plans from all sx service pages page_plans = [] for page_def in get_all_pages("sx").values(): plan = page_def.render_plan if plan: page_plans.append({ - "name": page_def.name, - "path": page_def.path, + "name": page_def.name, "path": page_def.path, "server-count": len(plan.get("server", [])), "client-count": len(plan.get("client", [])), "server": plan.get("server", []), @@ -1125,7 +941,7 @@ def _affinity_demo_data() -> dict: "io-deps": plan.get("io-deps", []), }) - return {"components": components, "page-plans": page_plans} + return build_affinity_analysis(components, page_plans) def _optimistic_demo_data() -> dict: @@ -1271,3 +1087,84 @@ def _offline_demo_data() -> dict: ], "server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"), } + + +def _page_helpers_demo_data() -> dict: + """Run page-helpers.sx functions server-side, return results for comparison with client.""" + import os + import time + from shared.sx.parser import parse_all + from shared.sx.ref.sx_ref import ( + categorize_special_forms, build_reference_data, + build_attr_detail, build_component_source, + build_routing_analysis, + ) + + ref_dir = _ref_dir() + results = {} + + # 1. categorize-special-forms + t0 = time.monotonic() + with open(os.path.join(ref_dir, "special-forms.sx")) as f: + sf_exprs = parse_all(f.read()) + sf_result = categorize_special_forms(sf_exprs) + sf_ms = round((time.monotonic() - t0) * 1000, 1) + sf_summary = {cat: len(forms) for cat, forms in sf_result.items()} + results["sf-categories"] = sf_summary + results["sf-total"] = sum(sf_summary.values()) + results["sf-ms"] = sf_ms + + # 2. build-reference-data + from content.pages import REQUEST_ATTRS, ATTR_DETAILS + t1 = time.monotonic() + ref_result = build_reference_data("attributes", { + "req-attrs": [list(t) for t in REQUEST_ATTRS[:5]], + "beh-attrs": [], "uniq-attrs": [], + }, list(ATTR_DETAILS.keys())) + ref_ms = round((time.monotonic() - t1) * 1000, 1) + results["ref-sample"] = ref_result.get("req-attrs", [])[:3] + results["ref-ms"] = ref_ms + + # 3. build-attr-detail + t2 = time.monotonic() + detail = ATTR_DETAILS.get("sx-get") + attr_result = build_attr_detail("sx-get", detail) + attr_ms = round((time.monotonic() - t2) * 1000, 1) + results["attr-result"] = attr_result + results["attr-ms"] = attr_ms + + # 4. build-component-source + t3 = time.monotonic() + comp_result = build_component_source({ + "type": "component", "name": "~demo-card", + "params": ["title", "subtitle"], + "has-children": True, + "body-sx": "(div :class \"card\"\n (h2 title)\n (when subtitle (p subtitle))\n children)", + "affinity": "auto", + }) + comp_ms = round((time.monotonic() - t3) * 1000, 1) + results["comp-source"] = comp_result + results["comp-ms"] = comp_ms + + # 5. build-routing-analysis + t4 = time.monotonic() + routing_result = build_routing_analysis([ + {"name": "home", "path": "/", "has-data": False, "content-src": "(~home-content)"}, + {"name": "dashboard", "path": "/dash", "has-data": True, "content-src": "(~dashboard)"}, + {"name": "about", "path": "/about", "has-data": False, "content-src": "(~about-content)"}, + {"name": "settings", "path": "/settings", "has-data": True, "content-src": "(~settings)"}, + ]) + routing_ms = round((time.monotonic() - t4) * 1000, 1) + results["routing-result"] = routing_result + results["routing-ms"] = routing_ms + + # Total + results["server-total-ms"] = round(sf_ms + ref_ms + attr_ms + comp_ms + routing_ms, 1) + + # Pass raw inputs for client-side island (serialized as data-sx-state) + results["sf-source"] = open(os.path.join(ref_dir, "special-forms.sx")).read() + results["attr-detail"] = detail + results["req-attrs"] = [list(t) for t in REQUEST_ATTRS[:5]] + results["attr-keys"] = list(ATTR_DETAILS.keys()) + + return results