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 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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user