diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index d24b459..279e950 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-07T21:45:27Z"; + var SX_VERSION = "2026-03-07T23:47:29Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -35,12 +35,13 @@ } Lambda.prototype._lambda = true; - function Component(name, params, hasChildren, body, closure) { + 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; @@ -116,8 +117,8 @@ 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) { - return new Component(name, params, hasChildren, 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); @@ -135,6 +136,7 @@ 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; } @@ -771,18 +773,32 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var sfDefcomp = function(args, env) { return (function() { var nameSym = first(args); var paramsRaw = nth(args, 1); - var body = nth(args, 2); + var body = last(args); var compName = stripPrefix(symbolName(nameSym), "~"); var parsed = parseCompParams(paramsRaw); var params = first(parsed); var hasChildren = nth(parsed, 1); + var affinity = defcompKwarg(args, "affinity", "auto"); return (function() { - var comp = makeComponent(compName, params, hasChildren, body, env); + var comp = makeComponent(compName, params, hasChildren, body, env, affinity); env[symbolName(nameSym)] = comp; return comp; })(); })(); }; + // defcomp-kwarg + var defcompKwarg = function(args, key, default_) { return (function() { + var end = (len(args) - 1); + var result = default_; + { var _c = range(2, end, 1); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(nth(args, i)) == "keyword")) && isSxTruthy((keywordName(nth(args, i)) == key)) && ((i + 1) < end)))) { + (function() { + var val = nth(args, (i + 1)); + return (result = (isSxTruthy((typeOf(val) == "keyword")) ? keywordName(val) : val)); +})(); +} } } + return result; +})(); }; + // parse-comp-params var parseCompParams = function(paramsExpr) { return (function() { var params = []; @@ -1057,7 +1073,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !isSxTruthy((nth(source, pos) == "\n"))))) { pos = (pos + 1); continue; } else { return NIL; } } }; var skipWs = function() { while(true) { if (isSxTruthy((pos < lenSrc))) { { var ch = nth(source, pos); -if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\\r")))) { pos = (pos + 1); +if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\r")))) { pos = (pos + 1); continue; } else if (isSxTruthy((ch == ";"))) { pos = (pos + 1); skipComment(); continue; } else { return NIL; } } } else { return NIL; } } }; @@ -1068,7 +1084,7 @@ return (function() { if (isSxTruthy((ch == "\""))) { pos = (pos + 1); return NIL; } else if (isSxTruthy((ch == "\\"))) { pos = (pos + 1); { var esc = nth(source, pos); -buf = (String(buf) + String((isSxTruthy((esc == "n")) ? "\n" : (isSxTruthy((esc == "t")) ? "\t" : (isSxTruthy((esc == "r")) ? "\\r" : esc))))); +buf = (String(buf) + String((isSxTruthy((esc == "n")) ? "\n" : (isSxTruthy((esc == "t")) ? "\t" : (isSxTruthy((esc == "r")) ? "\r" : esc))))); pos = (pos + 1); continue; } } else { buf = (String(buf) + String(ch)); pos = (pos + 1); @@ -1818,9 +1834,9 @@ return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl, var handleSxResponse = function(el, target, text, swapStyle, useTransition) { return (function() { var cleaned = stripComponentScripts(text); return (function() { - var final = extractResponseCss(cleaned); + var final_ = extractResponseCss(cleaned); return (function() { - var trimmed = trim(final); + var trimmed = trim(final_); return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (function() { var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); @@ -2265,7 +2281,7 @@ return (_styleCache = {}); }; // resolve-style var resolveStyle = function(atoms) { return (function() { - var key = join("\\0", atoms); + var key = join("\0", atoms); return (function() { var cached = dictGet(_styleCache, key); return (isSxTruthy(!isSxTruthy(isNil(cached))) ? cached : (function() { diff --git a/shared/sx/async_eval.py b/shared/sx/async_eval.py index de0950d..1a3a20d 100644 --- a/shared/sx/async_eval.py +++ b/shared/sx/async_eval.py @@ -1333,7 +1333,7 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any: expanded = _expand_macro(val, expr[1:], env) return await _aser(expanded, env, ctx) if isinstance(val, Component): - if _expand_components.get() or not val.is_pure: + if _expand_components.get() or val.render_target == "server": return await _aser_component(val, expr[1:], env, ctx) return await _aser_call(name, expr[1:], env, ctx) diff --git a/shared/sx/evaluator.py b/shared/sx/evaluator.py index 1f4a377..41f8190 100644 --- a/shared/sx/evaluator.py +++ b/shared/sx/evaluator.py @@ -565,7 +565,7 @@ def _sf_defkeyframes(expr: list, env: dict) -> Any: def _sf_defcomp(expr: list, env: dict) -> Component: - """``(defcomp ~name (&key param1 param2 &rest children) body)``""" + """``(defcomp ~name (&key ...) [:affinity :client|:server] body)``""" if len(expr) < 4: raise EvalError("defcomp requires name, params, and body") name_sym = expr[1] @@ -593,21 +593,38 @@ def _sf_defcomp(expr: list, env: dict) -> Component: params.append(p.name) else: params.append(p.name) - # Skip children param name after &rest elif isinstance(p, str): params.append(p) + # Body is always last element; keyword annotations between params and body + body = expr[-1] + affinity = _defcomp_kwarg(expr, "affinity", "auto") + comp = Component( name=comp_name, params=params, has_children=has_children, - body=expr[3], + body=body, closure=dict(env), + affinity=affinity, ) env[name_sym.name] = comp return comp +def _defcomp_kwarg(expr: list, key: str, default: str) -> str: + """Extract a keyword annotation from defcomp, e.g. :affinity :client.""" + # Scan from index 3 to second-to-last for :key value pairs + for i in range(3, len(expr) - 1): + item = expr[i] + if isinstance(item, Keyword) and item.name == key: + val = expr[i + 1] + if isinstance(val, Keyword): + return val.name + return str(val) + return default + + def _sf_begin(expr: list, env: dict) -> Any: if len(expr) < 2: return NIL diff --git a/shared/sx/parser.py b/shared/sx/parser.py index 4017ec4..201ba36 100644 --- a/shared/sx/parser.py +++ b/shared/sx/parser.py @@ -62,7 +62,7 @@ class SxExpr(str): # Errors # --------------------------------------------------------------------------- -_ESCAPE_MAP = {"n": "\n", "t": "\t", '"': '"', "\\": "\\", "/": "/"} +_ESCAPE_MAP = {"n": "\n", "t": "\t", "r": "\r", "0": "\0", '"': '"', "\\": "\\", "/": "/"} def _unescape_string(s: str) -> str: @@ -359,7 +359,9 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str: expr.replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") + .replace("\r", "\\r") .replace("\t", "\\t") + .replace("\0", "\\0") .replace(" str: expr.html.replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\0", "\\0") .replace(" 1: result = parts[0] + "".join(p.capitalize() for p in parts[1:]) + # Escape JS reserved words + if result in _JS_RESERVED: + result = result + "_" return result # --- List emission --- @@ -1018,7 +1039,7 @@ class JSEmitter: return str(expr) def _js_string(self, s: str) -> str: - return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + '"' + return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t").replace("\0", "\\0") + '"' # --------------------------------------------------------------------------- @@ -1995,12 +2016,13 @@ PREAMBLE = '''\ } Lambda.prototype._lambda = true; - function Component(name, params, hasChildren, body, closure) { + 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; @@ -2308,8 +2330,8 @@ PLATFORM_JS_PRE = ''' 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) { - return new Component(name, params, hasChildren, 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); @@ -2327,6 +2349,7 @@ PLATFORM_JS_PRE = ''' 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; } diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 375d42b..eecc85f 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -31,6 +31,19 @@ from shared.sx.types import Symbol, Keyword, NIL as SX_NIL # SX -> Python transpiler # --------------------------------------------------------------------------- +# Python reserved words — SX names that collide get _ suffix +# Excludes names we intentionally shadow (list, dict, range, filter, map) +_PY_RESERVED = frozenset({ + "False", "None", "True", "and", "as", "assert", "async", "await", + "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", + "while", "with", "yield", + # builtins we don't want to shadow + "default", "type", "id", "input", "open", "print", "set", "super", +}) + + class PyEmitter: """Transpile an SX AST node to Python source code.""" @@ -124,6 +137,7 @@ class PyEmitter: "component-closure": "component_closure", "component-has-children?": "component_has_children", "component-name": "component_name", + "component-affinity": "component_affinity", "macro-params": "macro_params", "macro-rest-param": "macro_rest_param", "macro-body": "macro_body", @@ -182,6 +196,7 @@ class PyEmitter: "sf-lambda": "sf_lambda", "sf-define": "sf_define", "sf-defcomp": "sf_defcomp", + "defcomp-kwarg": "defcomp_kwarg", "sf-defmacro": "sf_defmacro", "sf-begin": "sf_begin", "sf-quote": "sf_quote", @@ -262,6 +277,7 @@ class PyEmitter: "transitive-io-refs": "transitive_io_refs", "compute-all-io-refs": "compute_all_io_refs", "component-pure?": "component_pure_p", + "render-target": "render_target", # router.sx "split-path-segments": "split_path_segments", "make-route-segment": "make_route_segment", @@ -281,9 +297,9 @@ class PyEmitter: result = result[:-1] + "_b" # Kebab to snake_case result = result.replace("-", "_") - # Avoid Python keyword conflicts - if result in ("list", "dict", "range", "filter"): - result = result # keep as-is, these are our SX aliases + # Escape Python reserved words + if result in _PY_RESERVED: + result = result + "_" return result # --- List emission --- @@ -1220,9 +1236,9 @@ def make_lambda(params, body, env): return Lambda(params=list(params), body=body, closure=dict(env)) -def make_component(name, params, has_children, body, env): +def make_component(name, params, has_children, body, env, affinity="auto"): return Component(name=name, params=list(params), has_children=has_children, - body=body, closure=dict(env)) + body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto") def make_macro(params, rest_param, body, env, name=None): @@ -1311,6 +1327,10 @@ def component_name(c): return c.name +def component_affinity(c): + return getattr(c, 'affinity', 'auto') + + def macro_params(m): return m.params diff --git a/shared/sx/ref/deps.sx b/shared/sx/ref/deps.sx index 78c5546..ba09f76 100644 --- a/shared/sx/ref/deps.sx +++ b/shared/sx/ref/deps.sx @@ -314,19 +314,47 @@ (empty? (transitive-io-refs name env io-names)))) +;; -------------------------------------------------------------------------- +;; 5. Render target — boundary decision per component +;; -------------------------------------------------------------------------- +;; Combines IO analysis with affinity annotations to decide where a +;; component should render: +;; +;; :affinity :server → always "server" (auth-sensitive, secrets) +;; :affinity :client → "client" even if IO-dependent (IO proxy) +;; :affinity :auto → "server" if IO-dependent, "client" if pure +;; +;; Returns: "server" | "client" + +(define render-target + (fn (name env io-names) + (let ((key (if (starts-with? name "~") name (str "~" name)))) + (let ((val (env-get env key))) + (if (not (= (type-of val) "component")) + "server" + (let ((affinity (component-affinity val))) + (cond + (= affinity "server") "server" + (= affinity "client") "client" + ;; auto: decide from IO analysis + (not (component-pure? name env io-names)) "server" + :else "client"))))))) + + ;; -------------------------------------------------------------------------- ;; Host obligation: selective expansion in async partial evaluation ;; -------------------------------------------------------------------------- -;; The spec classifies components as pure or IO-dependent. Each host's -;; async partial evaluator (the server-side rendering path that bridges -;; sync evaluation with async IO) must use this classification: +;; The spec classifies components as pure or IO-dependent and provides +;; per-component render-target decisions. Each host's async partial +;; evaluator (the server-side rendering path that bridges sync evaluation +;; with async IO) must use this classification: ;; -;; IO-dependent component → expand server-side (IO must resolve) -;; Pure component → serialize for client (can render anywhere) +;; render-target "server" → expand server-side (IO must resolve) +;; render-target "client" → serialize for client (can render anywhere) ;; Layout slot context → expand all (server needs full HTML) ;; -;; The spec provides the data (component-io-refs, component-pure?). -;; The host provides the async runtime that acts on it. +;; The spec provides: component-io-refs, component-pure?, render-target, +;; component-affinity. The host provides the async runtime that acts on it. ;; This is not SX semantics — it is host infrastructure. Every host ;; with a server-side async evaluator implements the same rule. ;; -------------------------------------------------------------------------- @@ -349,6 +377,7 @@ ;; (component-css-classes c)→ pre-scanned CSS class list ;; (component-io-refs c) → cached IO ref list (may be empty) ;; (component-set-io-refs! c r)→ cache IO refs on component +;; (component-affinity c) → "auto" | "client" | "server" ;; (macro-body m) → AST body of macro ;; (env-components env) → list of component names in env ;; (regex-find-all pat src) → list of capture group matches diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index 10956a9..60d229d 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -491,17 +491,37 @@ (define sf-defcomp (fn (args env) + ;; (defcomp ~name (params) [:affinity :client|:server] body) + ;; Body is always the last element. Optional keyword annotations + ;; may appear between the params list and the body. (let ((name-sym (first args)) (params-raw (nth args 1)) - (body (nth args 2)) + (body (last args)) (comp-name (strip-prefix (symbol-name name-sym) "~")) (parsed (parse-comp-params params-raw)) (params (first parsed)) - (has-children (nth parsed 1))) - (let ((comp (make-component comp-name params has-children body env))) + (has-children (nth parsed 1)) + (affinity (defcomp-kwarg args "affinity" "auto"))) + (let ((comp (make-component comp-name params has-children body env affinity))) (env-set! env (symbol-name name-sym) comp) comp)))) +(define defcomp-kwarg + (fn (args key default) + ;; Search for :key value between params (index 2) and body (last). + (let ((end (- (len args) 1)) + (result default)) + (for-each + (fn (i) + (when (and (= (type-of (nth args i)) "keyword") + (= (keyword-name (nth args i)) key) + (< (+ i 1) end)) + (let ((val (nth args (+ i 1)))) + (set! result (if (= (type-of val) "keyword") + (keyword-name val) val))))) + (range 2 end 1)) + result))) + (define parse-comp-params (fn (params-expr) ;; Parse (&key param1 param2 &children) → (params has-children) @@ -879,7 +899,7 @@ ;; ;; Constructors: ;; (make-lambda params body env) → Lambda -;; (make-component name params has-children body env) → Component +;; (make-component name params has-children body env affinity) → Component ;; (make-macro params rest-param body env name) → Macro ;; (make-thunk expr env) → Thunk ;; @@ -893,6 +913,7 @@ ;; (component-body c) → expr ;; (component-closure c) → env ;; (component-has-children? c) → boolean +;; (component-affinity c) → "auto" | "client" | "server" ;; (macro-params m) → list of strings ;; (macro-rest-param m) → string or nil ;; (macro-body m) → expr diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 9101817..3fcc7d9 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -157,9 +157,9 @@ def make_lambda(params, body, env): return Lambda(params=list(params), body=body, closure=dict(env)) -def make_component(name, params, has_children, body, env): +def make_component(name, params, has_children, body, env, affinity="auto"): return Component(name=name, params=list(params), has_children=has_children, - body=body, closure=dict(env)) + body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto") def make_macro(params, rest_param, body, env, name=None): @@ -248,6 +248,10 @@ def component_name(c): return c.name +def component_affinity(c): + return getattr(c, 'affinity', 'auto') + + def macro_params(m): return m.params @@ -1012,7 +1016,18 @@ sf_lambda = lambda args, env: (lambda params_expr: (lambda body: (lambda param_n sf_define = lambda args, env: (lambda name_sym: (lambda value: _sx_begin((_sx_set_attr(value, 'name', symbol_name(name_sym)) if sx_truthy((is_lambda(value) if not sx_truthy(is_lambda(value)) else is_nil(lambda_name(value)))) else NIL), _sx_dict_set(env, symbol_name(name_sym), value), value))(trampoline(eval_expr(nth(args, 1), env))))(first(args)) # sf-defcomp -sf_defcomp = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda comp_name: (lambda parsed: (lambda params: (lambda has_children: (lambda comp: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), comp), comp))(make_component(comp_name, params, has_children, body, env)))(nth(parsed, 1)))(first(parsed)))(parse_comp_params(params_raw)))(strip_prefix(symbol_name(name_sym), '~')))(nth(args, 2)))(nth(args, 1)))(first(args)) +sf_defcomp = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda comp_name: (lambda parsed: (lambda params: (lambda has_children: (lambda affinity: (lambda comp: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), comp), comp))(make_component(comp_name, params, has_children, body, env, affinity)))(defcomp_kwarg(args, 'affinity', 'auto')))(nth(parsed, 1)))(first(parsed)))(parse_comp_params(params_raw)))(strip_prefix(symbol_name(name_sym), '~')))(last(args)))(nth(args, 1)))(first(args)) + +# defcomp-kwarg +def defcomp_kwarg(args, key, default_): + _cells = {} + end = (len(args) - 1) + _cells['result'] = default_ + for i in range(2, end, 1): + if sx_truthy(((type_of(nth(args, i)) == 'keyword') if not sx_truthy((type_of(nth(args, i)) == 'keyword')) else ((keyword_name(nth(args, i)) == key) if not sx_truthy((keyword_name(nth(args, i)) == key)) else ((i + 1) < end)))): + val = nth(args, (i + 1)) + _cells['result'] = (keyword_name(val) if sx_truthy((type_of(val) == 'keyword')) else val) + return _cells['result'] # parse-comp-params def parse_comp_params(params_expr): @@ -1283,168 +1298,8 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: ( # component-pure? component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names)) - -# === Transpiled from engine (fetch/swap/trigger pure logic) === - -# ENGINE_VERBS -ENGINE_VERBS = ['get', 'post', 'put', 'delete', 'patch'] - -# DEFAULT_SWAP -DEFAULT_SWAP = 'outerHTML' - -# parse-time -parse_time = lambda s: (0 if sx_truthy(is_nil(s)) else (parse_int(s, 0) if sx_truthy(ends_with_p(s, 'ms')) else ((parse_int(replace(s, 's', ''), 0) * 1000) if sx_truthy(ends_with_p(s, 's')) else parse_int(s, 0)))) - -# parse-trigger-spec -parse_trigger_spec = lambda spec: (NIL if sx_truthy(is_nil(spec)) else (lambda raw_parts: 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)))(split(spec, ','))) - -# default-trigger -default_trigger = lambda tag_name: ([{'event': 'submit', 'modifiers': {}}] if sx_truthy((tag_name == 'FORM')) else ([{'event': 'change', 'modifiers': {}}] if 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')))) else [{'event': 'click', 'modifiers': {}}])) - -# get-verb-info -get_verb_info = lambda el: 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-request-headers -build_request_headers = lambda el, loaded_components, css_hash: (lambda headers: _sx_begin((lambda target_sel: (_sx_dict_set(headers, 'SX-Target', target_sel) if sx_truthy(target_sel) else NIL))(dom_get_attr(el, 'sx-target')), (_sx_dict_set(headers, 'SX-Components', join(',', loaded_components)) if sx_truthy((not sx_truthy(empty_p(loaded_components)))) else NIL), (_sx_dict_set(headers, 'SX-Css', css_hash) if sx_truthy(css_hash) else NIL), (lambda extra_h: ((lambda parsed: (for_each(lambda key: _sx_dict_set(headers, key, sx_str(get(parsed, key))), keys(parsed)) if sx_truthy(parsed) else NIL))(parse_header_value(extra_h)) if sx_truthy(extra_h) else NIL))(dom_get_attr(el, 'sx-headers')), headers))({'SX-Request': 'true', 'SX-Current-URL': browser_location_href()}) - -# process-response-headers -process_response_headers = lambda get_header: {'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')} - -# parse-swap-spec -def parse_swap_spec(raw_swap, global_transitions_p): - _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']} - -# parse-retry-spec -parse_retry_spec = lambda retry_attr: (NIL if sx_truthy(is_nil(retry_attr)) else (lambda parts: {'strategy': first(parts), 'start-ms': parse_int(nth(parts, 1), 1000), 'cap-ms': parse_int(nth(parts, 2), 30000)})(split(retry_attr, ':'))) - -# next-retry-ms -next_retry_ms = lambda current_ms, cap_ms: min((current_ms * 2), cap_ms) - -# filter-params -filter_params = lambda params_spec, all_params: (all_params if sx_truthy(is_nil(params_spec)) else ([] if sx_truthy((params_spec == 'none')) else (all_params if sx_truthy((params_spec == '*')) else ((lambda excluded: filter(lambda p: (not sx_truthy(contains_p(excluded, first(p)))), all_params))(map(trim, split(slice(params_spec, 4), ','))) if sx_truthy(starts_with_p(params_spec, 'not ')) else (lambda allowed: filter(lambda p: contains_p(allowed, first(p)), all_params))(map(trim, split(params_spec, ','))))))) - -# resolve-target -resolve_target = lambda el: (lambda sel: (el if sx_truthy((is_nil(sel) if sx_truthy(is_nil(sel)) else (sel == 'this'))) else (dom_parent(el) if sx_truthy((sel == 'closest')) else dom_query(sel))))(dom_get_attr(el, 'sx-target')) - -# apply-optimistic -apply_optimistic = lambda el: (lambda directive: (NIL if sx_truthy(is_nil(directive)) else (lambda target: (lambda state: _sx_begin((_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))), state))({'target': target, 'directive': directive}))((resolve_target(el) if sx_truthy(resolve_target(el)) else el))))(dom_get_attr(el, 'sx-optimistic')) - -# revert-optimistic -revert_optimistic = lambda state: ((lambda target: (lambda directive: (_sx_begin(dom_set_style(target, 'opacity', (get(state, 'opacity') if sx_truthy(get(state, 'opacity')) else '')), dom_set_style(target, 'pointer-events', '')) if sx_truthy((directive == 'remove')) else (dom_set_prop(target, 'disabled', (get(state, 'disabled') if sx_truthy(get(state, 'disabled')) else False)) if sx_truthy((directive == 'disable')) else (dom_remove_class(target, get(state, 'add-class')) if sx_truthy(get(state, 'add-class')) else NIL))))(get(state, 'directive')))(get(state, 'target')) if sx_truthy(state) else NIL) - -# find-oob-swaps -find_oob_swaps = lambda container: (lambda results: _sx_begin(for_each(lambda attr: (lambda oob_els: for_each(lambda oob: (lambda swap_type: (lambda target_id: _sx_begin(dom_remove_attr(oob, attr), (_sx_append(results, {'element': oob, 'swap-type': swap_type, 'target-id': target_id}) if sx_truthy(target_id) else NIL)))(dom_id(oob)))((dom_get_attr(oob, attr) if sx_truthy(dom_get_attr(oob, attr)) else 'outerHTML')), oob_els))(dom_query_all(container, sx_str('[', attr, ']'))), ['sx-swap-oob', 'hx-swap-oob']), results))([]) - -# morph-node -morph_node = lambda old_node, new_node: (NIL 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'))) else (dom_replace_child(dom_parent(old_node), dom_clone(new_node), old_node) if 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)))))) else ((dom_set_text_content(old_node, dom_text_content(new_node)) if sx_truthy((not sx_truthy((dom_text_content(old_node) == dom_text_content(new_node))))) else NIL) if sx_truthy(((dom_node_type(old_node) == 3) if sx_truthy((dom_node_type(old_node) == 3)) else (dom_node_type(old_node) == 8))) else (_sx_begin(sync_attrs(old_node, new_node), (morph_children(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))))) else NIL)) if sx_truthy((dom_node_type(old_node) == 1)) else NIL)))) - -# sync-attrs -sync_attrs = _sx_fn(lambda old_el, new_el: ( - for_each(lambda attr: (lambda name: (lambda val: (dom_set_attr(old_el, name, val) if sx_truthy((not sx_truthy((dom_get_attr(old_el, name) == val)))) else NIL))(nth(attr, 1)))(first(attr)), dom_attr_list(new_el)), - for_each(lambda attr: (dom_remove_attr(old_el, first(attr)) if sx_truthy((not sx_truthy(dom_has_attr_p(new_el, first(attr))))) else NIL), dom_attr_list(old_el)) -)[-1]) - -# morph-children -def morph_children(old_parent, new_parent): - _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) - 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))) - -# swap-dom-nodes -swap_dom_nodes = lambda target, new_nodes, strategy: _sx_case(strategy, [('innerHTML', lambda: (morph_children(target, new_nodes) if sx_truthy(dom_is_fragment_p(new_nodes)) else (lambda wrapper: _sx_begin(dom_append(wrapper, new_nodes), morph_children(target, wrapper)))(dom_create_element('div', NIL)))), ('outerHTML', lambda: (lambda parent: _sx_begin(((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)), parent))(dom_parent(target))), ('afterend', lambda: dom_insert_after(target, new_nodes)), ('beforeend', lambda: dom_append(target, new_nodes)), ('afterbegin', lambda: dom_prepend(target, new_nodes)), ('beforebegin', lambda: dom_insert_before(dom_parent(target), new_nodes, target)), ('delete', lambda: dom_remove_child(dom_parent(target), target)), ('none', lambda: NIL), (None, lambda: (morph_children(target, new_nodes) if sx_truthy(dom_is_fragment_p(new_nodes)) else (lambda wrapper: _sx_begin(dom_append(wrapper, new_nodes), morph_children(target, wrapper)))(dom_create_element('div', NIL))))]) - -# insert-remaining-siblings -insert_remaining_siblings = lambda parent, ref_node, sib: ((lambda next: _sx_begin(dom_insert_after(ref_node, sib), insert_remaining_siblings(parent, sib, next)))(dom_next_sibling(sib)) if sx_truthy(sib) else NIL) - -# swap-html-string -swap_html_string = lambda target, html, strategy: _sx_case(strategy, [('innerHTML', lambda: dom_set_inner_html(target, html)), ('outerHTML', lambda: (lambda parent: _sx_begin(dom_insert_adjacent_html(target, 'afterend', html), dom_remove_child(parent, target), parent))(dom_parent(target))), ('afterend', lambda: dom_insert_adjacent_html(target, 'afterend', html)), ('beforeend', lambda: dom_insert_adjacent_html(target, 'beforeend', html)), ('afterbegin', lambda: dom_insert_adjacent_html(target, 'afterbegin', html)), ('beforebegin', lambda: dom_insert_adjacent_html(target, 'beforebegin', html)), ('delete', lambda: dom_remove_child(dom_parent(target), target)), ('none', lambda: NIL), (None, lambda: dom_set_inner_html(target, html))]) - -# handle-history -handle_history = lambda el, url, resp_headers: (lambda push_url: (lambda replace_url: (lambda hdr_replace: (browser_replace_state(hdr_replace) if sx_truthy(hdr_replace) else (browser_push_state((url if sx_truthy((push_url == 'true')) else push_url)) if sx_truthy((push_url if not sx_truthy(push_url) else (not sx_truthy((push_url == 'false'))))) else (browser_replace_state((url if sx_truthy((replace_url == 'true')) else replace_url)) if sx_truthy((replace_url if not sx_truthy(replace_url) else (not sx_truthy((replace_url == 'false'))))) else NIL))))(get(resp_headers, 'replace-url')))(dom_get_attr(el, 'sx-replace-url')))(dom_get_attr(el, 'sx-push-url')) - -# PRELOAD_TTL -PRELOAD_TTL = 30000 - -# preload-cache-get -preload_cache_get = lambda cache, url: (lambda entry: (NIL if sx_truthy(is_nil(entry)) else (_sx_begin(dict_delete(cache, url), NIL) if sx_truthy(((now_ms() - get(entry, 'timestamp')) > PRELOAD_TTL)) else _sx_begin(dict_delete(cache, url), entry))))(dict_get(cache, url)) - -# preload-cache-set -preload_cache_set = lambda cache, url, text, content_type: _sx_dict_set(cache, url, {'text': text, 'content-type': content_type, 'timestamp': now_ms()}) - -# classify-trigger -classify_trigger = lambda trigger: (lambda event: ('poll' if sx_truthy((event == 'every')) else ('intersect' if sx_truthy((event == 'intersect')) else ('load' if sx_truthy((event == 'load')) else ('revealed' if sx_truthy((event == 'revealed')) else 'event')))))(get(trigger, 'event')) - -# should-boost-link? -should_boost_link_p = lambda link: (lambda href: (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')))))))))))(dom_get_attr(link, 'href')) - -# should-boost-form? -should_boost_form_p = lambda form: ((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 -parse_sse_swap = lambda el: (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 -split_path_segments = lambda path: (lambda trimmed: (lambda trimmed2: ([] if sx_truthy(empty_p(trimmed2)) else split(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)))((slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path)) - -# make-route-segment -make_route_segment = lambda seg: ((lambda param_name: (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'param'), _sx_dict_set(d, 'value', param_name), d))({}))(slice(seg, 1, (len(seg) - 1))) if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))) else (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'literal'), _sx_dict_set(d, 'value', seg), d))({})) - -# parse-route-pattern -parse_route_pattern = lambda pattern: (lambda segments: map(make_route_segment, segments))(split_path_segments(pattern)) - -# match-route-segments -def match_route_segments(path_segs, parsed_segs): - _cells = {} - return (NIL if sx_truthy((not sx_truthy((len(path_segs) == len(parsed_segs))))) else (lambda params: _sx_begin(_sx_cell_set(_cells, 'matched', True), _sx_begin(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), (params if sx_truthy(_cells['matched']) else NIL))))({})) - -# match-route -match_route = lambda path, pattern: (lambda path_segs: (lambda parsed_segs: match_route_segments(path_segs, parsed_segs))(parse_route_pattern(pattern)))(split_path_segments(path)) - -# 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'] +# render-target +render_target = lambda name, env, io_names: (lambda key: (lambda val: ('server' if sx_truthy((not sx_truthy((type_of(val) == 'component')))) else (lambda affinity: ('server' if sx_truthy((affinity == 'server')) else ('client' if sx_truthy((affinity == 'client')) else ('server' if sx_truthy((not sx_truthy(component_pure_p(name, env, io_names)))) else 'client'))))(component_affinity(val))))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))) # ========================================================================= diff --git a/shared/sx/ref/test-deps.sx b/shared/sx/ref/test-deps.sx index b67b508..ba9dad6 100644 --- a/shared/sx/ref/test-deps.sx +++ b/shared/sx/ref/test-deps.sx @@ -223,3 +223,43 @@ (deftest "leaf component is pure" (assert-true (component-pure? "~dep-leaf" (test-env) (list "fetch-data"))))) + + +;; -------------------------------------------------------------------------- +;; 6. render-target — boundary decision with affinity +;; -------------------------------------------------------------------------- + +;; Components with explicit affinity annotations +(defcomp ~dep-force-client (&key x) + :affinity :client + (div (fetch-data "/api") x)) + +(defcomp ~dep-force-server (&key x) + :affinity :server + (div x)) + +(defcomp ~dep-auto-pure (&key x) + (div x)) + +(defcomp ~dep-auto-io (&key x) + (div (fetch-data "/api"))) + +(defsuite "render-target" + + (deftest "pure auto component targets client" + (assert-equal "client" (render-target "~dep-auto-pure" (test-env) (list "fetch-data")))) + + (deftest "IO auto component targets server" + (assert-equal "server" (render-target "~dep-auto-io" (test-env) (list "fetch-data")))) + + (deftest "affinity client overrides IO to client" + (assert-equal "client" (render-target "~dep-force-client" (test-env) (list "fetch-data")))) + + (deftest "affinity server overrides pure to server" + (assert-equal "server" (render-target "~dep-force-server" (test-env) (list "fetch-data")))) + + (deftest "leaf component targets client" + (assert-equal "client" (render-target "~dep-leaf" (test-env) (list "fetch-data")))) + + (deftest "unknown name targets server" + (assert-equal "server" (render-target "~nonexistent" (test-env) (list "fetch-data"))))) diff --git a/shared/sx/ref/test-eval.sx b/shared/sx/ref/test-eval.sx index 9cd95a2..0be8988 100644 --- a/shared/sx/ref/test-eval.sx +++ b/shared/sx/ref/test-eval.sx @@ -396,7 +396,32 @@ (deftest "component with default via or" (defcomp ~label (&key text) (span (or text "default"))) - (assert-true (not (nil? ~label))))) + (assert-true (not (nil? ~label)))) + + (deftest "defcomp default affinity is auto" + (defcomp ~aff-default (&key x) + (div x)) + (assert-equal "auto" (component-affinity ~aff-default))) + + (deftest "defcomp affinity client" + (defcomp ~aff-client (&key x) + :affinity :client + (div x)) + (assert-equal "client" (component-affinity ~aff-client))) + + (deftest "defcomp affinity server" + (defcomp ~aff-server (&key x) + :affinity :server + (div x)) + (assert-equal "server" (component-affinity ~aff-server))) + + (deftest "defcomp affinity preserves body" + (defcomp ~aff-body (&key val) + :affinity :client + (span val)) + ;; Component should still render correctly + (assert-equal "client" (component-affinity ~aff-body)) + (assert-true (not (nil? ~aff-body))))) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index 12e7c93..455cc0f 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -184,6 +184,8 @@ env = { "dict-get": "_deferred", "append!": "_deferred", "inc": lambda n: n + 1, + # Component accessor for affinity (Phase 7) + "component-affinity": lambda c: getattr(c, 'affinity', 'auto'), } @@ -286,6 +288,7 @@ def _load_deps_from_bootstrap(env): transitive_io_refs, compute_all_io_refs, component_pure_p, + render_target, ) env["scan-refs"] = scan_refs env["scan-components-from-source"] = scan_components_from_source @@ -298,6 +301,7 @@ def _load_deps_from_bootstrap(env): env["transitive-io-refs"] = transitive_io_refs env["compute-all-io-refs"] = compute_all_io_refs env["component-pure?"] = component_pure_p + env["render-target"] = render_target env["test-env"] = lambda: env except ImportError: eval_file("deps.sx", env) diff --git a/shared/sx/types.py b/shared/sx/types.py index 0670374..594d569 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -169,12 +169,22 @@ class Component: css_classes: set[str] = field(default_factory=set) # pre-scanned :class values deps: set[str] = field(default_factory=set) # transitive component deps (~names) io_refs: set[str] = field(default_factory=set) # transitive IO primitive refs + affinity: str = "auto" # "auto" | "client" | "server" @property def is_pure(self) -> bool: """True if this component has no transitive IO dependencies.""" return not self.io_refs + @property + def render_target(self) -> str: + """Where this component should render: 'server' or 'client'.""" + if self.affinity == "server": + return "server" + if self.affinity == "client": + return "client" + return "server" if self.io_refs else "client" + def __repr__(self): return f"" diff --git a/sx/sx/affinity-demo.sx b/sx/sx/affinity-demo.sx new file mode 100644 index 0000000..659fe6f --- /dev/null +++ b/sx/sx/affinity-demo.sx @@ -0,0 +1,177 @@ +;; Affinity demo — Phase 7a render boundary annotations. +;; +;; Demonstrates :affinity annotations on defcomp and how they influence +;; the server/client render boundary decision. Components declare where +;; they prefer to render; the runtime combines this with IO analysis. + +;; --- Demo components with different affinities --- + +(defcomp ~aff-demo-auto (&key label) + (div :class "rounded border border-stone-200 bg-white p-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block w-2 h-2 rounded-full bg-stone-400") + (span :class "text-sm font-mono text-stone-500" ":affinity :auto")) + (p :class "text-stone-800" (or label "Pure component — no IO calls. Auto-detected as client-renderable.")))) + +(defcomp ~aff-demo-client (&key label) + :affinity :client + (div :class "rounded border border-blue-200 bg-blue-50 p-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block w-2 h-2 rounded-full bg-blue-400") + (span :class "text-sm font-mono text-blue-600" ":affinity :client")) + (p :class "text-blue-800" (or label "Explicitly client-rendered — even IO calls would be proxied.")))) + +(defcomp ~aff-demo-server (&key label) + :affinity :server + (div :class "rounded border border-amber-200 bg-amber-50 p-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block w-2 h-2 rounded-full bg-amber-400") + (span :class "text-sm font-mono text-amber-600" ":affinity :server")) + (p :class "text-amber-800" (or label "Always server-rendered — auth-sensitive or secret-dependent.")))) + +(defcomp ~aff-demo-io-auto () + (div :class "rounded border border-red-200 bg-red-50 p-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block w-2 h-2 rounded-full bg-red-400") + (span :class "text-sm font-mono text-red-600" ":affinity :auto + IO")) + (p :class "text-red-800 mb-3" "Auto affinity with IO dependency — auto-detected as server-rendered.") + (~doc-code :code (highlight "(render-target name env io-names)" "lisp")))) + +(defcomp ~aff-demo-io-client () + :affinity :client + (div :class "rounded border border-violet-200 bg-violet-50 p-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block w-2 h-2 rounded-full bg-violet-400") + (span :class "text-sm font-mono text-violet-600" ":affinity :client + IO")) + (p :class "text-violet-800 mb-3" "Client affinity overrides IO — calls proxied to server via /sx/io/.") + (~doc-code :code (highlight "(component-affinity comp)" "lisp")))) + + +;; --- Main page component --- + +(defcomp ~affinity-demo-content (&key components) + (div :class "space-y-8" + (div :class "border-b border-stone-200 pb-6" + (h1 :class "text-2xl font-bold text-stone-900" "Affinity Annotations") + (p :class "mt-2 text-stone-600" + "Phase 7a: components declare where they prefer to render. The " + (code :class "bg-stone-100 px-1 rounded text-violet-700" "render-target") + " function in deps.sx combines the annotation with IO analysis to produce a per-component boundary decision.")) + + ;; Syntax + (~doc-section :title "Syntax" :id "syntax" + (p "Add " (code ":affinity") " between the params list and the body:") + (~doc-code :code (highlight "(defcomp ~my-component (&key title)\n :affinity :client ;; or :server, or omit for :auto\n (div title))" "lisp")) + (p "Three values:") + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li (code ":auto") " (default) — runtime decides from IO dependency analysis") + (li (code ":client") " — always render client-side; IO calls proxied to server") + (li (code ":server") " — always render server-side; never sent to client as SX"))) + + ;; Live components + (~doc-section :title "Live Components" :id "live" + (p "These components are defined with different affinities. The server analyzed them at registration time:") + + (div :class "space-y-4 mt-4" + (~aff-demo-auto) + (~aff-demo-client) + (~aff-demo-server) + (~aff-demo-io-auto) + (~aff-demo-io-client))) + + ;; Analysis table from server + (~doc-section :title "Server Analysis" :id "analysis" + (p "The server computed these render targets at component registration time:") + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Component") + (th :class "px-3 py-2 font-medium text-stone-600" "Affinity") + (th :class "px-3 py-2 font-medium text-stone-600" "IO Deps") + (th :class "px-3 py-2 font-medium text-stone-600" "Render Target"))) + (tbody + (map (fn (c) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" (get c "name")) + (td :class "px-3 py-2" + (span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase " + (case (get c "affinity") + "client" "bg-blue-100 text-blue-700" + "server" "bg-amber-100 text-amber-700" + "bg-stone-100 text-stone-600")) + (get c "affinity"))) + (td :class "px-3 py-2 text-stone-600" + (if (> (len (get c "io-refs")) 0) + (span :class "text-red-600 font-medium" + (join ", " (get c "io-refs"))) + (span :class "text-green-600" "none"))) + (td :class "px-3 py-2" + (span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase " + (if (= (get c "render-target") "client") + "bg-green-100 text-green-700" + "bg-orange-100 text-orange-700")) + (get c "render-target"))))) + components))))) + + ;; Decision matrix + (~doc-section :title "Decision Matrix" :id "matrix" + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Affinity") + (th :class "px-3 py-2 font-medium text-stone-600" "Has IO?") + (th :class "px-3 py-2 font-medium text-stone-600" "Render Target") + (th :class "px-3 py-2 font-medium text-stone-600" "Why"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono" ":auto") + (td :class "px-3 py-2" "No") + (td :class "px-3 py-2 font-bold text-green-700" "client") + (td :class "px-3 py-2 text-stone-600" "Pure — can render anywhere")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono" ":auto") + (td :class "px-3 py-2" "Yes") + (td :class "px-3 py-2 font-bold text-orange-700" "server") + (td :class "px-3 py-2 text-stone-600" "IO must resolve server-side")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono" ":client") + (td :class "px-3 py-2" "No") + (td :class "px-3 py-2 font-bold text-green-700" "client") + (td :class "px-3 py-2 text-stone-600" "Explicit + pure")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono" ":client") + (td :class "px-3 py-2" "Yes") + (td :class "px-3 py-2 font-bold text-green-700" "client") + (td :class "px-3 py-2 text-stone-600" "Override — IO proxied via /sx/io/")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono" ":server") + (td :class "px-3 py-2" "No") + (td :class "px-3 py-2 font-bold text-orange-700" "server") + (td :class "px-3 py-2 text-stone-600" "Override — auth-sensitive")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono" ":server") + (td :class "px-3 py-2" "Yes") + (td :class "px-3 py-2 font-bold text-orange-700" "server") + (td :class "px-3 py-2 text-stone-600" "Both affinity and IO say server")))))) + + ;; How it integrates + (~doc-section :title "How It Works" :id "how" + (ol :class "list-decimal list-inside text-stone-700 space-y-2" + (li (code "defcomp") " parses " (code ":affinity") " annotation between params and body") + (li "Component object stores " (code "affinity") " field (\"auto\", \"client\", or \"server\")") + (li (code "compute-all-io-refs") " scans transitive IO deps at registration time") + (li (code "render-target") " in deps.sx combines affinity + IO analysis → \"server\" or \"client\"") + (li "Server partial evaluator (" (code "_aser") ") checks " (code "render_target") ":") + (ul :class "list-disc pl-8 text-stone-600" + (li "\"server\" → expand component, embed rendered HTML") + (li "\"client\" → serialize as SX, let client render")) + (li "Client routing uses same info: IO deps in page registry → proxy registration"))) + + ;; Verification + (div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2" + (p :class "font-semibold text-amber-800" "How to verify") + (ol :class "list-decimal list-inside text-amber-700 space-y-1" + (li "View page source — components with render-target \"server\" are expanded to HTML") + (li "Components with render-target \"client\" appear as " (code "(~name ...)") " in the SX wire format") + (li "Navigate away and back — client-routable pure components render instantly") + (li "Check the analysis table above — it shows live data from the server's component registry"))))) diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx index b11c743..2831426 100644 --- a/sx/sx/boundary.sx +++ b/sx/sx/boundary.sx @@ -74,3 +74,8 @@ :params () :returns "async-generator" :service "sx") + +(define-page-helper "affinity-demo-data" + :params () + :returns "dict" + :service "sx") diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 0024488..99bcb8c 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -125,7 +125,8 @@ (dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer") (dict :label "Data Test" :href "/isomorphism/data-test") (dict :label "Async IO" :href "/isomorphism/async-io") - (dict :label "Streaming" :href "/isomorphism/streaming"))) + (dict :label "Streaming" :href "/isomorphism/streaming") + (dict :label "Affinity" :href "/isomorphism/affinity"))) (define plans-nav-items (list (dict :label "Status" :href "/plans/status" diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index b850c5b..fc0a627 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -1971,36 +1971,63 @@ (~doc-section :title "Phase 7: Full Isomorphism" :id "phase-7" (div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4" + (div :class "flex items-center gap-2 mb-2" + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-500 text-white uppercase" "In Progress")) (p :class "text-violet-900 font-medium" "What it enables") (p :class "text-violet-800" "Same SX code runs on either side. Runtime chooses optimal split. Offline-first with cached data + client eval.")) - (~doc-subsection :title "Approach" + (~doc-subsection :title "7a. Affinity Annotations & Render Target" - (div :class "space-y-4" - (div - (h4 :class "font-semibold text-stone-700" "1. Runtime boundary optimizer") - (p "Given component tree + IO dependency graph, decide per-component: server-expand, client-render, or stream. Planning step cached at registration, recomputed on component change.")) + (div :class "rounded border border-green-300 bg-green-50 p-3 mb-4" + (div :class "flex items-center gap-2 mb-1" + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")) + (p :class "text-green-800 text-sm" "Components declare where they prefer to render. The spec combines affinity with IO analysis to produce a per-component render target decision.")) - (div - (h4 :class "font-semibold text-stone-700" "2. Affinity annotations") - (~doc-code :code (highlight "(defcomp ~product-grid (&key products)\n :affinity :client ;; interactive, prefer client\n ...)\n\n(defcomp ~auth-menu (&key user)\n :affinity :server ;; auth-sensitive, always server\n ...)" "lisp")) - (p "Default: auto (runtime decides from IO analysis).")) + (p "Affinity annotations let component authors express rendering preferences:") + (~doc-code :code (highlight "(defcomp ~product-grid (&key products)\n :affinity :client ;; interactive, prefer client rendering\n (div ...))\n\n(defcomp ~auth-menu (&key user)\n :affinity :server ;; auth-sensitive, always server\n (div ...))\n\n(defcomp ~card (&key title)\n ;; no annotation = :affinity :auto (default)\n ;; runtime decides from IO analysis\n (div ...))" "lisp")) - (div - (h4 :class "font-semibold text-stone-700" "3. Optimistic data updates") - (p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection.")) + (p "The " (code "render-target") " function in deps.sx combines affinity with IO analysis:") + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li (code ":affinity :server") " → always " (code "\"server\"") " (auth-sensitive, secrets, heavy IO)") + (li (code ":affinity :client") " → always " (code "\"client\"") " (interactive, IO proxied)") + (li (code ":affinity :auto") " (default) → " (code "\"server\"") " if IO-dependent, " (code "\"client\"") " if pure")) - (div - (h4 :class "font-semibold text-stone-700" "4. Offline data layer") - (p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online.")) + (p "The server's partial evaluator (" (code "_aser") ") uses " (code "render_target") " instead of the previous " (code "is_pure") " check. Components with " (code ":affinity :client") " are serialized for client rendering even if they call IO primitives — the IO proxy (Phase 5) handles the calls.") - (div - (h4 :class "font-semibold text-stone-700" "5. Isomorphic testing") - (p "Evaluate same component on Python and JS, compare output. Extends existing test_sx_ref.py cross-evaluator comparison.")) + (~doc-subsection :title "Files" + (ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm" + (li "shared/sx/ref/eval.sx — defcomp annotation parsing, defcomp-kwarg helper") + (li "shared/sx/ref/deps.sx — render-target function, platform interface") + (li "shared/sx/types.py — Component.affinity field, render_target property") + (li "shared/sx/evaluator.py — _sf_defcomp annotation extraction") + (li "shared/sx/async_eval.py — _aser uses render_target") + (li "shared/sx/ref/bootstrap_js.py — Component.affinity, componentAffinity()") + (li "shared/sx/ref/bootstrap_py.py — component_affinity(), make_component()") + (li "shared/sx/ref/test-eval.sx — 4 new defcomp affinity tests") + (li "shared/sx/ref/test-deps.sx — 6 new render-target tests"))) - (div - (h4 :class "font-semibold text-stone-700" "6. Universal page descriptor") - (p "defpage is portable: server executes via execute_page(), client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment.")))) + (~doc-subsection :title "Verification" + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li "269 spec tests pass (10 new: 4 eval + 6 deps)") + (li "79 Python unit tests pass") + (li "Bootstrapped to both hosts (sx_ref.py + sx-browser.js)") + (li "Backward compatible: existing defcomp without :affinity defaults to \"auto\"")))) + + (~doc-subsection :title "7b. Runtime Boundary Optimizer" + (p "Given component tree + IO dependency graph + affinity annotations, decide per-component: server-expand, client-render, or stream. Planning step cached at registration, recomputed on component change.") + (p :class "text-stone-500 text-sm italic" "Next: integrate render-target into the bundle analyzer, page registry, and orchestration.sx.")) + + (~doc-subsection :title "7c. Optimistic Data Updates" + (p "Extend existing apply-optimistic/revert-optimistic in engine.sx from DOM-level to data-level. Client updates cached data optimistically, sends mutation to server, reverts on rejection.")) + + (~doc-subsection :title "7d. Offline Data Layer" + (p "Service Worker intercepts /internal/data/ requests, serves from IndexedDB when offline, syncs when back online.")) + + (~doc-subsection :title "7e. Isomorphic Testing" + (p "Evaluate same component on Python and JS, compare output. Extends existing test_sx_ref.py cross-evaluator comparison.")) + + (~doc-subsection :title "7f. Universal Page Descriptor" + (p "defpage is portable: server executes via execute_page(), client executes via route match → fetch data → eval content → render DOM. Same descriptor, different execution environment.")) (div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2" (p :class "text-amber-800 text-sm" (strong "Depends on: ") "All previous phases."))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 957c766..439f668 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -479,6 +479,18 @@ :stream-message stream-message :stream-time stream-time)) +(defpage affinity-demo + :path "/isomorphism/affinity" + :auth :public + :layout (:sx-section + :section "Isomorphism" + :sub-label "Isomorphism" + :sub-href "/isomorphism/" + :sub-nav (~section-nav :items isomorphism-nav-items :current "Affinity") + :selected "Affinity") + :data (affinity-demo-data) + :content (~affinity-demo-content :components components)) + ;; Wildcard must come AFTER specific routes (first-match routing) (defpage isomorphism-page :path "/isomorphism/" diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index 7d5e413..b8073a3 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -27,6 +27,7 @@ def _register_sx_helpers() -> None: "run-spec-tests": _run_spec_tests, "run-modular-tests": _run_modular_tests, "streaming-demo-data": _streaming_demo_data, + "affinity-demo-data": _affinity_demo_data, }) @@ -318,6 +319,8 @@ def _bundle_analyzer_data() -> dict: 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, @@ -875,3 +878,30 @@ async def _streaming_demo_data(): "stream-message": "Model inference completed in ~5 seconds", "stream-time": datetime.now(timezone.utc).isoformat(timespec="seconds"), } + + +def _affinity_demo_data() -> dict: + """Return affinity analysis for the demo components.""" + from shared.sx.jinja_bridge import get_component_env + from shared.sx.types import Component + + env = get_component_env() + demo_names = [ + "~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, + "render-target": val.render_target, + "io-refs": sorted(val.io_refs), + "is-pure": val.is_pure, + }) + return {"components": components}