Phase 7a: affinity annotations + fix parser escape sequences
Add :affinity :client/:server/:auto annotations to defcomp, with render-target function combining affinity + IO analysis. Includes spec (eval.sx, deps.sx), tests, Python evaluator, and demo page. Fix critical bug: Python SX parser _ESCAPE_MAP was missing \r and \0, causing bootstrapped JS parser to treat 'r' as whitespace — breaking all client-side SX parsing. Also add \0 to JS string emitter and fix serializer round-tripping for \r and \0. Reserved word escaping: bootstrappers now auto-append _ to identifiers colliding with JS/Python reserved words (e.g. default → default_, final → final_), so the spec never needs to avoid host language keywords. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("</script", "<\\/script")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
@@ -392,6 +394,8 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
expr.html.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\0", "\\0")
|
||||
.replace("</script", "<\\/script")
|
||||
)
|
||||
return f'(raw! "{escaped}")'
|
||||
|
||||
@@ -29,6 +29,20 @@ from shared.sx.types import Symbol, Keyword, NIL as SX_NIL
|
||||
# SX → JavaScript transpiler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# JS reserved words — SX parameter/variable names that collide get _ suffix
|
||||
_JS_RESERVED = frozenset({
|
||||
"abstract", "arguments", "await", "boolean", "break", "byte", "case",
|
||||
"catch", "char", "class", "const", "continue", "debugger", "default",
|
||||
"delete", "do", "double", "else", "enum", "eval", "export", "extends",
|
||||
"final", "finally", "float", "for", "function", "goto", "if",
|
||||
"implements", "import", "in", "instanceof", "int", "interface", "let",
|
||||
"long", "native", "new", "package", "private", "protected", "public",
|
||||
"return", "short", "static", "super", "switch", "synchronized", "this",
|
||||
"throw", "throws", "transient", "try", "typeof", "var", "void",
|
||||
"volatile", "while", "with", "yield",
|
||||
})
|
||||
|
||||
|
||||
class JSEmitter:
|
||||
"""Transpile an SX AST node to JavaScript source code."""
|
||||
|
||||
@@ -114,6 +128,7 @@ class JSEmitter:
|
||||
"component-closure": "componentClosure",
|
||||
"component-has-children?": "componentHasChildren",
|
||||
"component-name": "componentName",
|
||||
"component-affinity": "componentAffinity",
|
||||
"macro-params": "macroParams",
|
||||
"macro-rest-param": "macroRestParam",
|
||||
"macro-body": "macroBody",
|
||||
@@ -176,6 +191,7 @@ class JSEmitter:
|
||||
"sf-lambda": "sfLambda",
|
||||
"sf-define": "sfDefine",
|
||||
"sf-defcomp": "sfDefcomp",
|
||||
"defcomp-kwarg": "defcompKwarg",
|
||||
"sf-defmacro": "sfDefmacro",
|
||||
"sf-begin": "sfBegin",
|
||||
"sf-quote": "sfQuote",
|
||||
@@ -278,6 +294,7 @@ class JSEmitter:
|
||||
"for-each-indexed": "forEachIndexed",
|
||||
"index-of": "indexOf_",
|
||||
"component-has-children?": "componentHasChildren",
|
||||
"component-affinity": "componentAffinity",
|
||||
# engine.sx
|
||||
"ENGINE_VERBS": "ENGINE_VERBS",
|
||||
"DEFAULT_SWAP": "DEFAULT_SWAP",
|
||||
@@ -531,6 +548,7 @@ class JSEmitter:
|
||||
"transitive-io-refs": "transitiveIoRefs",
|
||||
"compute-all-io-refs": "computeAllIoRefs",
|
||||
"component-pure?": "componentPure_p",
|
||||
"render-target": "renderTarget",
|
||||
# router.sx
|
||||
"split-path-segments": "splitPathSegments",
|
||||
"make-route-segment": "makeRouteSegment",
|
||||
@@ -552,6 +570,9 @@ class JSEmitter:
|
||||
parts = result.split("-")
|
||||
if len(parts) > 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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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")))))
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"<Component ~{self.name}({', '.join(self.params)})>"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user