Fix aser server-affinity expansion: keyword values, OOB wrapper, page helpers
Three bugs in aser-expand-component (adapter-sx.sx):
- Keyword values were eval'd (eval-expr can't handle <>, HTML tags);
now asered, matching the aser's rendering capabilities
- Missing default nil binding for unset &key params (caused
"Undefined symbol" errors for optional params like header-rows)
- aserCall string-quoted keyword values that were already serialized
SX — now inlines values starting with "(" directly
Server-affinity annotations for layout/nav shells:
- ~shared:layout/app-body, ~shared:layout/oob-sx — page structure
- ~layouts/nav-sibling-row, ~layouts/nav-children — server-side data
- ~layouts/doc already had :affinity :server
- ~cssx/flush marked :affinity :client (browser-only state)
Navigation fix: restore oob_page_sx wrapper for HTMX responses
so #main-panel section exists for sx-select/sx-swap targeting.
OCaml bridge: lazy page helper injection into kernel via IO proxy
(define name (fn (...) (helper "name" ...))) — enables aser_slot
to evaluate highlight/component-source etc. via coroutine bridge.
Playwright tests: added pageerror listener to test_no_console_errors,
new test_navigate_from_home_to_geography for HTMX nav regression.
Co-Authored-By: Claude Opus 4.6 (1M context) <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-18T20:14:01Z";
|
||||
var SX_VERSION = "2026-03-19T11:12:01Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -1460,6 +1460,9 @@ PRIMITIVES["cek-step"] = cekStep;
|
||||
var name = symbolName(expr);
|
||||
return (function() {
|
||||
var val = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name))))))));
|
||||
if (isSxTruthy((isSxTruthy(isNil(val)) && startsWith(name, "~")))) {
|
||||
debugLog("Component not found:", name);
|
||||
}
|
||||
return makeCekValue(val, env, kont);
|
||||
})();
|
||||
})(); if (_m == "keyword") return makeCekValue(keywordName(expr), env, kont); if (_m == "dict") return (function() {
|
||||
@@ -2673,14 +2676,15 @@ PRIMITIVES["aser"] = aser;
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
|
||||
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy((name == "raw!")) ? aserCall("raw!", args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
|
||||
var comp = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : NIL);
|
||||
return (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && (componentAffinity(comp) == "server"))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env));
|
||||
var expandAll = (isSxTruthy(envHas(env, "expand-components?")) ? expandComponents_p() : false);
|
||||
return (isSxTruthy((isSxTruthy(comp) && isMacro(comp))) ? aser(expandMacro(comp, args, env), env) : (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && isSxTruthy(sxOr(expandAll, (componentAffinity(comp) == "server"))) && !isSxTruthy((componentAffinity(comp) == "client")))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env)));
|
||||
})() : (isSxTruthy((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy((name == "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
|
||||
var f = trampoline(evalExpr(head, env));
|
||||
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
|
||||
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && isSxTruthy(!isSxTruthy(isComponent(f))) && !isSxTruthy(isIsland(f)))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : (isSxTruthy(isIsland(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f))))))));
|
||||
})())))))));
|
||||
})()))))))));
|
||||
})());
|
||||
})(); };
|
||||
PRIMITIVES["aser-list"] = aserList;
|
||||
@@ -2707,7 +2711,7 @@ PRIMITIVES["aser-fragment"] = aserFragment;
|
||||
var val = aser(nth(args, (i + 1)), env);
|
||||
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
|
||||
attrParts.push((String(":") + String(keywordName(arg))));
|
||||
attrParts.push(serialize(val));
|
||||
(isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(attrParts, val) : append_b(attrParts, serialize(val)));
|
||||
}
|
||||
skip = true;
|
||||
return (i = (i + 1));
|
||||
@@ -2738,7 +2742,8 @@ PRIMITIVES["aser-call"] = aserCall;
|
||||
var i = 0;
|
||||
var skip = false;
|
||||
var children = [];
|
||||
{ var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (envBind(local, keywordName(arg), trampoline(evalExpr(nth(args, (i + 1)), env))), (skip = true), (i = (i + 1))) : (append_b(children, arg), (i = (i + 1))))); } }
|
||||
{ var _c = params; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envBind(local, p, NIL); } }
|
||||
{ var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (envBind(local, keywordName(arg), aser(nth(args, (i + 1)), env)), (skip = true), (i = (i + 1))) : (append_b(children, arg), (i = (i + 1))))); } }
|
||||
if (isSxTruthy(componentHasChildren(comp))) {
|
||||
(function() {
|
||||
var aseredChildren = map(function(c) { return aser(c, env); }, children);
|
||||
|
||||
@@ -364,10 +364,6 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_slot_to_sx
|
||||
else:
|
||||
from .async_eval import async_eval_slot_to_sx
|
||||
from .types import Symbol, Keyword, NIL as _NIL
|
||||
|
||||
# Build AST with extra_env entries as keyword args so _aser_component
|
||||
@@ -381,6 +377,19 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
|
||||
ast.append(Keyword(k.replace("_", "-")))
|
||||
ast.append(v if v is not None else _NIL)
|
||||
|
||||
if os.environ.get("SX_USE_OCAML") == "1":
|
||||
from .ocaml_bridge import get_bridge
|
||||
from .parser import serialize
|
||||
bridge = await get_bridge()
|
||||
sx_text = serialize(ast)
|
||||
ocaml_ctx = {"_helper_service": _get_request_context().get("_helper_service", "")} if isinstance(_get_request_context(), dict) else {}
|
||||
return SxExpr(await bridge.aser_slot(sx_text, ctx=ocaml_ctx))
|
||||
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_slot_to_sx
|
||||
else:
|
||||
from .async_eval import async_eval_slot_to_sx
|
||||
|
||||
env = dict(get_component_env())
|
||||
env.update(extra_env)
|
||||
ctx = _get_request_context()
|
||||
@@ -399,12 +408,21 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
import os
|
||||
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
|
||||
if os.environ.get("SX_USE_OCAML") == "1":
|
||||
from .ocaml_bridge import get_bridge
|
||||
from .parser import serialize
|
||||
bridge = await get_bridge()
|
||||
sx_text = serialize(ast)
|
||||
return SxExpr(await bridge.aser(sx_text))
|
||||
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_to_sx
|
||||
else:
|
||||
from .async_eval import async_eval_to_sx
|
||||
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
env = dict(get_component_env())
|
||||
ctx = _get_request_context()
|
||||
return SxExpr(await async_eval_to_sx(ast, env, ctx))
|
||||
@@ -420,6 +438,10 @@ async def render_to_html(__name: str, **kwargs: Any) -> str:
|
||||
|
||||
Same as render_to_sx() but produces HTML output instead of SX wire
|
||||
format. Used by route renders that need HTML (full pages, fragments).
|
||||
|
||||
Note: does NOT use OCaml bridge — the shell render is a pure HTML
|
||||
template with no IO, so the Python renderer handles it reliably.
|
||||
The OCaml path is used for _render_to_sx and _eval_slot (IO-heavy).
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
import os
|
||||
|
||||
@@ -43,6 +43,7 @@ class OcamlBridge:
|
||||
self._lock = asyncio.Lock()
|
||||
self._started = False
|
||||
self._components_loaded = False
|
||||
self._helpers_injected = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Launch the OCaml subprocess and wait for (ready)."""
|
||||
@@ -141,6 +142,56 @@ class OcamlBridge:
|
||||
self._send(f'(aser "{_escape(source)}")')
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str:
|
||||
"""Like aser() but expands ALL components server-side.
|
||||
|
||||
Equivalent to Python's async_eval_slot_to_sx — used for layout
|
||||
slots where component bodies need server-side IO evaluation.
|
||||
"""
|
||||
await self._ensure_components()
|
||||
await self._ensure_helpers()
|
||||
async with self._lock:
|
||||
self._send(f'(aser-slot "{_escape(source)}")')
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def _ensure_helpers(self) -> None:
|
||||
"""Lazily inject page helpers into the kernel as IO proxies."""
|
||||
if self._helpers_injected:
|
||||
return
|
||||
self._helpers_injected = True
|
||||
try:
|
||||
from .pages import get_page_helpers
|
||||
helpers = get_page_helpers("sx")
|
||||
if not helpers:
|
||||
self._helpers_injected = False # retry later
|
||||
return
|
||||
async with self._lock:
|
||||
for name, fn in helpers.items():
|
||||
if callable(fn) and not name.startswith("~"):
|
||||
# Determine arity from Python function signature
|
||||
import inspect
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
nargs = sum(1 for p in sig.parameters.values()
|
||||
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD))
|
||||
except (ValueError, TypeError):
|
||||
nargs = 2
|
||||
nargs = max(nargs, 1) # at least 1 arg
|
||||
param_names = " ".join(chr(97 + i) for i in range(nargs))
|
||||
arg_list = " ".join(chr(97 + i) for i in range(nargs))
|
||||
sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))'
|
||||
try:
|
||||
self._send(f'(load-source "{_escape(sx_def)}")')
|
||||
await self._read_until_ok(ctx=None)
|
||||
except OcamlBridgeError:
|
||||
pass
|
||||
_logger.info("Injected %d page helpers into OCaml kernel",
|
||||
sum(1 for n, f in helpers.items()
|
||||
if callable(f) and not n.startswith("~")))
|
||||
except Exception as e:
|
||||
_logger.warning("Helper injection failed: %s", e)
|
||||
self._helpers_injected = False
|
||||
|
||||
async def _ensure_components(self) -> None:
|
||||
"""Load all .sx source files into the kernel on first use.
|
||||
|
||||
@@ -214,6 +265,27 @@ class OcamlBridge:
|
||||
_logger.error("Failed to load .sx files into OCaml kernel: %s", e)
|
||||
self._components_loaded = False # retry next time
|
||||
|
||||
async def inject_page_helpers(self, helpers: dict) -> None:
|
||||
"""Register page helpers as IO-routing definitions in the kernel.
|
||||
|
||||
Each helper becomes a function that yields (io-request "helper" name ...),
|
||||
routing the call back to Python via the coroutine bridge.
|
||||
"""
|
||||
await self._ensure_components()
|
||||
async with self._lock:
|
||||
count = 0
|
||||
for name, fn in helpers.items():
|
||||
if callable(fn) and not name.startswith("~"):
|
||||
sx_def = f'(define {name} (fn (&rest args) (apply helper (concat (list "{name}") args))))'
|
||||
try:
|
||||
self._send(f'(load-source "{_escape(sx_def)}")')
|
||||
await self._read_until_ok(ctx=None)
|
||||
count += 1
|
||||
except OcamlBridgeError:
|
||||
pass # non-fatal
|
||||
if count:
|
||||
_logger.info("Injected %d page helpers into OCaml kernel", count)
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the kernel environment to pristine state."""
|
||||
async with self._lock:
|
||||
|
||||
@@ -493,7 +493,7 @@
|
||||
;; (~cssx/flush)
|
||||
;; =========================================================================
|
||||
|
||||
(defcomp ~cssx/flush ()
|
||||
(defcomp ~cssx/flush () :affinity :client
|
||||
(let ((rules (collected "cssx")))
|
||||
(clear-collected! "cssx")
|
||||
(when (not (empty? rules))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(defcomp ~shared:layout/app-body (&key header-rows filter aside menu content)
|
||||
(defcomp ~shared:layout/app-body (&key header-rows filter aside menu content) :affinity :server
|
||||
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
|
||||
(when header-rows
|
||||
(div :class "w-full"
|
||||
@@ -24,7 +24,7 @@
|
||||
(when content content)
|
||||
(div :class "pb-8")))))))
|
||||
|
||||
(defcomp ~shared:layout/oob-sx (&key oobs filter aside menu content)
|
||||
(defcomp ~shared:layout/oob-sx (&key oobs filter aside menu content) :affinity :server
|
||||
(<>
|
||||
(when oobs oobs)
|
||||
(div :id "filter" :sx-swap-oob "outerHTML"
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
;; Current section with prev/next siblings.
|
||||
;; 3-column grid: prev is right-aligned, current centered, next left-aligned.
|
||||
;; Current page is larger in the leaf (bottom) row.
|
||||
(defcomp ~layouts/nav-sibling-row (&key node siblings is-leaf level depth)
|
||||
(defcomp ~layouts/nav-sibling-row (&key node siblings is-leaf level depth) :affinity :server
|
||||
(let* ((sibs (or siblings (list)))
|
||||
(count (len sibs))
|
||||
;; opacity = (n/x * 3/4) + 1/4
|
||||
@@ -97,7 +97,7 @@
|
||||
(str (get next-node "label") " \u2192")))))))
|
||||
|
||||
;; Children links — shown as clearly clickable buttons.
|
||||
(defcomp ~layouts/nav-children (&key items)
|
||||
(defcomp ~layouts/nav-children (&key items) :affinity :server
|
||||
(div :class "max-w-3xl mx-auto px-4 py-3"
|
||||
(div :class "flex flex-wrap justify-center gap-2"
|
||||
(map (fn (item)
|
||||
|
||||
@@ -33,4 +33,5 @@ def _load_sx_page_files() -> None:
|
||||
# helper is registered as an IO primitive in primitives_io.py,
|
||||
# intercepted by async_eval before hitting the CEK machine.
|
||||
import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5])
|
||||
|
||||
load_page_dir(os.path.dirname(__file__), "sx")
|
||||
|
||||
@@ -194,14 +194,24 @@ async def eval_sx_url(raw_path: str) -> Any:
|
||||
]
|
||||
|
||||
try:
|
||||
content_sx = await _eval_slot(wrapped_ast, env, ctx)
|
||||
import os as _os
|
||||
if _os.environ.get("SX_USE_OCAML") == "1":
|
||||
from shared.sx.ocaml_bridge import get_bridge
|
||||
from shared.sx.parser import serialize
|
||||
from shared.sx.types import SxExpr
|
||||
bridge = await get_bridge()
|
||||
sx_text = serialize(wrapped_ast)
|
||||
ocaml_ctx = {"_helper_service": "sx"}
|
||||
content_sx = SxExpr(await bridge.aser(sx_text, ctx=ocaml_ctx))
|
||||
else:
|
||||
content_sx = await _eval_slot(wrapped_ast, env, ctx)
|
||||
except Exception as e:
|
||||
logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True)
|
||||
return None
|
||||
|
||||
# Return response — Python wraps in page shell (CSS, scripts, headers)
|
||||
if is_htmx_request():
|
||||
return sx_response(content_sx)
|
||||
return sx_response(await oob_page_sx(content=content_sx))
|
||||
else:
|
||||
tctx = await get_template_context()
|
||||
html = await full_page_sx(tctx, header_rows="", content=content_sx)
|
||||
|
||||
@@ -31,7 +31,7 @@ def nav(page: Page, path: str):
|
||||
"""Navigate to an SX URL and wait for rendered content."""
|
||||
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
|
||||
# Wait for SX to render — look for any heading or paragraph in main panel
|
||||
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=15000)
|
||||
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=30000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -506,6 +506,37 @@ class TestSpecExplorer:
|
||||
# Key doc pages (smoke tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHomePage:
|
||||
def test_home_loads(self, page: Page):
|
||||
nav(page, "")
|
||||
expect(page.locator("#main-panel")).to_contain_text("sx", timeout=10000)
|
||||
|
||||
def test_no_console_errors(self, page: Page):
|
||||
"""Home page should have no JS errors (console or uncaught)."""
|
||||
errors = []
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
page.goto(f"{BASE}/sx/", wait_until="networkidle")
|
||||
page.wait_for_timeout(3000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors on home page: {fatal}"
|
||||
|
||||
def test_navigate_from_home_to_geography(self, page: Page):
|
||||
"""Click Geography nav link from home — content must render."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
nav(page, "")
|
||||
# Click the Geography link in the nav children
|
||||
geo_link = page.locator("a[sx-push-url]:has-text('Geography')").first
|
||||
expect(geo_link).to_be_visible(timeout=10000)
|
||||
geo_link.click()
|
||||
page.wait_for_timeout(5000)
|
||||
# Content must still be visible after navigation
|
||||
expect(page.locator("#main-panel")).to_contain_text("Geography", timeout=10000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors after navigation: {fatal}"
|
||||
|
||||
|
||||
class TestDocPages:
|
||||
@pytest.mark.parametrize("path,expected", [
|
||||
("(geography.(reactive))", "Reactive Islands"),
|
||||
|
||||
@@ -71,13 +71,30 @@
|
||||
(= name "<>")
|
||||
(aser-fragment args env)
|
||||
|
||||
;; Component call — expand server-affinity, serialize others
|
||||
;; raw! — pass through as serialized call
|
||||
(= name "raw!")
|
||||
(aser-call "raw!" args env)
|
||||
|
||||
;; Component call — expand if server-affinity or expand-components? is set.
|
||||
;; expand-components? is a platform primitive (like eval-expr, trampoline);
|
||||
;; adapter-async.sx uses the same pattern at line 684.
|
||||
;; Guard with env-has? for backward compat with older kernels.
|
||||
(starts-with? name "~")
|
||||
(let ((comp (if (env-has? env name) (env-get env name) nil)))
|
||||
(if (and comp (component? comp)
|
||||
(= (component-affinity comp) "server"))
|
||||
(aser-expand-component comp args env)
|
||||
(aser-call name args env)))
|
||||
(let ((comp (if (env-has? env name) (env-get env name) nil))
|
||||
(expand-all (if (env-has? env "expand-components?")
|
||||
(expand-components?) false)))
|
||||
(cond
|
||||
(and comp (macro? comp))
|
||||
(aser (expand-macro comp args env) env)
|
||||
(and comp (component? comp)
|
||||
(or expand-all
|
||||
(= (component-affinity comp) "server"))
|
||||
;; :affinity :client components are never expanded
|
||||
;; server-side — they depend on browser-only state.
|
||||
(not (= (component-affinity comp) "client")))
|
||||
(aser-expand-component comp args env)
|
||||
:else
|
||||
(aser-call name args env)))
|
||||
|
||||
;; Lake — serialize (server-morphable slot)
|
||||
(= name "lake")
|
||||
@@ -175,7 +192,14 @@
|
||||
(let ((val (aser (nth args (inc i)) env)))
|
||||
(when (not (nil? val))
|
||||
(append! attr-parts (str ":" (keyword-name arg)))
|
||||
(append! attr-parts (serialize val)))
|
||||
;; If the aser result is already serialized SX (starts
|
||||
;; with "("), inline it directly — don't re-serialize
|
||||
;; which would quote it as a string literal.
|
||||
(if (and (= (type-of val) "string")
|
||||
(> (string-length val) 0)
|
||||
(starts-with? val "("))
|
||||
(append! attr-parts val)
|
||||
(append! attr-parts (serialize val))))
|
||||
(set! skip true)
|
||||
(set! i (inc i)))
|
||||
(let ((val (aser arg env)))
|
||||
@@ -232,19 +256,24 @@
|
||||
(i 0)
|
||||
(skip false)
|
||||
(children (list)))
|
||||
;; Parse keyword args and positional children from args
|
||||
;; Keyword values are eval'd (they're data). Children are NOT eval'd
|
||||
;; (they may contain HTML tags that only the aser can handle).
|
||||
;; Default all keyword params to nil (same as the CEK evaluator)
|
||||
(for-each (fn (p) (env-bind! local p nil)) params)
|
||||
;; Parse keyword args and positional children from args.
|
||||
;; Keyword values are ASERED (not eval'd) — they may contain
|
||||
;; rendering constructs (<>, HTML tags) that eval-expr can't
|
||||
;; handle. The aser result is a string/value that the body's
|
||||
;; aser will inline correctly (strings starting with "(" are
|
||||
;; recognized as serialized SX by aserCall).
|
||||
(for-each
|
||||
(fn (arg)
|
||||
(if skip
|
||||
(do (set! skip false) (set! i (inc i)))
|
||||
(if (and (= (type-of arg) "keyword")
|
||||
(< (inc i) (len args)))
|
||||
;; Keyword arg: bind name = eval'd next arg
|
||||
;; Keyword arg: bind name = aser'd next arg
|
||||
(do
|
||||
(env-bind! local (keyword-name arg)
|
||||
(trampoline (eval-expr (nth args (inc i)) env)))
|
||||
(aser (nth args (inc i)) env))
|
||||
(set! skip true)
|
||||
(set! i (inc i)))
|
||||
;; Positional child: keep as unevaluated AST for aser
|
||||
|
||||
Reference in New Issue
Block a user