diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 9b78077..7f18587 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-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); diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 7627803..98ac55b 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -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 diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index d3efbf2..781e064 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -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: diff --git a/shared/sx/templates/cssx.sx b/shared/sx/templates/cssx.sx index 309d9e2..c6f005f 100644 --- a/shared/sx/templates/cssx.sx +++ b/shared/sx/templates/cssx.sx @@ -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)) diff --git a/shared/sx/templates/layout.sx b/shared/sx/templates/layout.sx index a3bfe6a..e885775 100644 --- a/shared/sx/templates/layout.sx +++ b/shared/sx/templates/layout.sx @@ -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" diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index 81d2bac..cf412a0 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -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) diff --git a/sx/sxc/pages/__init__.py b/sx/sxc/pages/__init__.py index 3df523b..48f8f0c 100644 --- a/sx/sxc/pages/__init__.py +++ b/sx/sxc/pages/__init__.py @@ -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") diff --git a/sx/sxc/pages/sx_router.py b/sx/sxc/pages/sx_router.py index c44cd6c..4833d85 100644 --- a/sx/sxc/pages/sx_router.py +++ b/sx/sxc/pages/sx_router.py @@ -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) diff --git a/sx/tests/test_demos.py b/sx/tests/test_demos.py index 3bb1623..7978083 100644 --- a/sx/tests/test_demos.py +++ b/sx/tests/test_demos.py @@ -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"), diff --git a/web/adapter-sx.sx b/web/adapter-sx.sx index 89173ec..e8278ad 100644 --- a/web/adapter-sx.sx +++ b/web/adapter-sx.sx @@ -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