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:
2026-03-19 12:06:24 +00:00
parent 171c18d3be
commit 109ca7c70b
10 changed files with 201 additions and 31 deletions

View File

@@ -14,7 +14,7 @@
// ========================================================================= // =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); 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 isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); } function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1460,6 +1460,9 @@ PRIMITIVES["cek-step"] = cekStep;
var name = symbolName(expr); var name = symbolName(expr);
return (function() { 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)))))))); 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); return makeCekValue(val, env, kont);
})(); })();
})(); if (_m == "keyword") return makeCekValue(keywordName(expr), env, kont); if (_m == "dict") return (function() { })(); 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); var args = rest(expr);
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() { return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() {
var name = symbolName(head); 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); 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() { })() : (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 f = trampoline(evalExpr(head, env));
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args); 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)))))))); 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; PRIMITIVES["aser-list"] = aserList;
@@ -2707,7 +2711,7 @@ PRIMITIVES["aser-fragment"] = aserFragment;
var val = aser(nth(args, (i + 1)), env); var val = aser(nth(args, (i + 1)), env);
if (isSxTruthy(!isSxTruthy(isNil(val)))) { if (isSxTruthy(!isSxTruthy(isNil(val)))) {
attrParts.push((String(":") + String(keywordName(arg)))); 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; skip = true;
return (i = (i + 1)); return (i = (i + 1));
@@ -2738,7 +2742,8 @@ PRIMITIVES["aser-call"] = aserCall;
var i = 0; var i = 0;
var skip = false; var skip = false;
var children = []; 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))) { if (isSxTruthy(componentHasChildren(comp))) {
(function() { (function() {
var aseredChildren = map(function(c) { return aser(c, env); }, children); var aseredChildren = map(function(c) { return aser(c, env); }, children);

View File

@@ -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 from .jinja_bridge import get_component_env, _get_request_context
import os 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 from .types import Symbol, Keyword, NIL as _NIL
# Build AST with extra_env entries as keyword args so _aser_component # 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(Keyword(k.replace("_", "-")))
ast.append(v if v is not None else _NIL) 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 = dict(get_component_env())
env.update(extra_env) env.update(extra_env)
ctx = _get_request_context() 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 from .jinja_bridge import get_component_env, _get_request_context
import os 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": if os.environ.get("SX_USE_REF") == "1":
from .ref.async_eval_ref import async_eval_to_sx from .ref.async_eval_ref import async_eval_to_sx
else: else:
from .async_eval import async_eval_to_sx from .async_eval import async_eval_to_sx
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env()) env = dict(get_component_env())
ctx = _get_request_context() ctx = _get_request_context()
return SxExpr(await async_eval_to_sx(ast, env, ctx)) 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 Same as render_to_sx() but produces HTML output instead of SX wire
format. Used by route renders that need HTML (full pages, fragments). 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 from .jinja_bridge import get_component_env, _get_request_context
import os import os

View File

@@ -43,6 +43,7 @@ class OcamlBridge:
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._started = False self._started = False
self._components_loaded = False self._components_loaded = False
self._helpers_injected = False
async def start(self) -> None: async def start(self) -> None:
"""Launch the OCaml subprocess and wait for (ready).""" """Launch the OCaml subprocess and wait for (ready)."""
@@ -141,6 +142,56 @@ class OcamlBridge:
self._send(f'(aser "{_escape(source)}")') self._send(f'(aser "{_escape(source)}")')
return await self._read_until_ok(ctx) 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: async def _ensure_components(self) -> None:
"""Load all .sx source files into the kernel on first use. """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) _logger.error("Failed to load .sx files into OCaml kernel: %s", e)
self._components_loaded = False # retry next time 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: async def reset(self) -> None:
"""Reset the kernel environment to pristine state.""" """Reset the kernel environment to pristine state."""
async with self._lock: async with self._lock:

View File

@@ -493,7 +493,7 @@
;; (~cssx/flush) ;; (~cssx/flush)
;; ========================================================================= ;; =========================================================================
(defcomp ~cssx/flush () (defcomp ~cssx/flush () :affinity :client
(let ((rules (collected "cssx"))) (let ((rules (collected "cssx")))
(clear-collected! "cssx") (clear-collected! "cssx")
(when (not (empty? rules)) (when (not (empty? rules))

View File

@@ -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" (div :class "max-w-screen-2xl mx-auto py-1 px-1"
(when header-rows (when header-rows
(div :class "w-full" (div :class "w-full"
@@ -24,7 +24,7 @@
(when content content) (when content content)
(div :class "pb-8"))))))) (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) (when oobs oobs)
(div :id "filter" :sx-swap-oob "outerHTML" (div :id "filter" :sx-swap-oob "outerHTML"

View File

@@ -57,7 +57,7 @@
;; Current section with prev/next siblings. ;; Current section with prev/next siblings.
;; 3-column grid: prev is right-aligned, current centered, next left-aligned. ;; 3-column grid: prev is right-aligned, current centered, next left-aligned.
;; Current page is larger in the leaf (bottom) row. ;; 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))) (let* ((sibs (or siblings (list)))
(count (len sibs)) (count (len sibs))
;; opacity = (n/x * 3/4) + 1/4 ;; opacity = (n/x * 3/4) + 1/4
@@ -97,7 +97,7 @@
(str (get next-node "label") " \u2192"))))))) (str (get next-node "label") " \u2192")))))))
;; Children links — shown as clearly clickable buttons. ;; 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 "max-w-3xl mx-auto px-4 py-3"
(div :class "flex flex-wrap justify-center gap-2" (div :class "flex flex-wrap justify-center gap-2"
(map (fn (item) (map (fn (item)

View File

@@ -33,4 +33,5 @@ def _load_sx_page_files() -> None:
# helper is registered as an IO primitive in primitives_io.py, # helper is registered as an IO primitive in primitives_io.py,
# intercepted by async_eval before hitting the CEK machine. # 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]) 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") load_page_dir(os.path.dirname(__file__), "sx")

View File

@@ -194,14 +194,24 @@ async def eval_sx_url(raw_path: str) -> Any:
] ]
try: 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: except Exception as e:
logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True) logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True)
return None return None
# Return response — Python wraps in page shell (CSS, scripts, headers) # Return response — Python wraps in page shell (CSS, scripts, headers)
if is_htmx_request(): if is_htmx_request():
return sx_response(content_sx) return sx_response(await oob_page_sx(content=content_sx))
else: else:
tctx = await get_template_context() tctx = await get_template_context()
html = await full_page_sx(tctx, header_rows="", content=content_sx) html = await full_page_sx(tctx, header_rows="", content=content_sx)

View File

@@ -31,7 +31,7 @@ def nav(page: Page, path: str):
"""Navigate to an SX URL and wait for rendered content.""" """Navigate to an SX URL and wait for rendered content."""
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle") page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
# Wait for SX to render — look for any heading or paragraph in main panel # 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) # 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: class TestDocPages:
@pytest.mark.parametrize("path,expected", [ @pytest.mark.parametrize("path,expected", [
("(geography.(reactive))", "Reactive Islands"), ("(geography.(reactive))", "Reactive Islands"),

View File

@@ -71,13 +71,30 @@
(= name "<>") (= name "<>")
(aser-fragment args env) (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 "~") (starts-with? name "~")
(let ((comp (if (env-has? env name) (env-get env name) nil))) (let ((comp (if (env-has? env name) (env-get env name) nil))
(if (and comp (component? comp) (expand-all (if (env-has? env "expand-components?")
(= (component-affinity comp) "server")) (expand-components?) false)))
(aser-expand-component comp args env) (cond
(aser-call name args env))) (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) ;; Lake — serialize (server-morphable slot)
(= name "lake") (= name "lake")
@@ -175,7 +192,14 @@
(let ((val (aser (nth args (inc i)) env))) (let ((val (aser (nth args (inc i)) env)))
(when (not (nil? val)) (when (not (nil? val))
(append! attr-parts (str ":" (keyword-name arg))) (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! skip true)
(set! i (inc i))) (set! i (inc i)))
(let ((val (aser arg env))) (let ((val (aser arg env)))
@@ -232,19 +256,24 @@
(i 0) (i 0)
(skip false) (skip false)
(children (list))) (children (list)))
;; Parse keyword args and positional children from args ;; Default all keyword params to nil (same as the CEK evaluator)
;; Keyword values are eval'd (they're data). Children are NOT eval'd (for-each (fn (p) (env-bind! local p nil)) params)
;; (they may contain HTML tags that only the aser can handle). ;; 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 (for-each
(fn (arg) (fn (arg)
(if skip (if skip
(do (set! skip false) (set! i (inc i))) (do (set! skip false) (set! i (inc i)))
(if (and (= (type-of arg) "keyword") (if (and (= (type-of arg) "keyword")
(< (inc i) (len args))) (< (inc i) (len args)))
;; Keyword arg: bind name = eval'd next arg ;; Keyword arg: bind name = aser'd next arg
(do (do
(env-bind! local (keyword-name arg) (env-bind! local (keyword-name arg)
(trampoline (eval-expr (nth args (inc i)) env))) (aser (nth args (inc i)) env))
(set! skip true) (set! skip true)
(set! i (inc i))) (set! i (inc i)))
;; Positional child: keep as unevaluated AST for aser ;; Positional child: keep as unevaluated AST for aser