diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 45f503c..d094de8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,6 +12,8 @@ x-dev-env: &dev-env WORKERS: "1" SX_USE_REF: "1" SX_BOUNDARY_STRICT: "1" + SX_USE_OCAML: "1" + SX_OCAML_BIN: "/app/bin/sx_server" x-sibling-models: &sibling-models # Every app needs all sibling __init__.py + models/ for cross-domain SQLAlchemy imports @@ -44,6 +46,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./blog/alembic.ini:/app/blog/alembic.ini:ro - ./blog/alembic:/app/blog/alembic:ro - ./blog/app.py:/app/app.py @@ -83,6 +88,9 @@ services: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - /root/rose-ash/_snapshot:/app/_snapshot - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./market/alembic.ini:/app/market/alembic.ini:ro - ./market/alembic:/app/market/alembic:ro - ./market/app.py:/app/app.py @@ -121,6 +129,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./cart/alembic.ini:/app/cart/alembic.ini:ro - ./cart/alembic:/app/cart/alembic:ro - ./cart/app.py:/app/app.py @@ -159,6 +170,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./events/alembic.ini:/app/events/alembic.ini:ro - ./events/alembic:/app/events/alembic:ro - ./events/app.py:/app/app.py @@ -197,6 +211,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./federation/alembic.ini:/app/federation/alembic.ini:ro - ./federation/alembic:/app/federation/alembic:ro - ./federation/app.py:/app/app.py @@ -235,6 +252,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./account/alembic.ini:/app/account/alembic.ini:ro - ./account/alembic:/app/account/alembic:ro - ./account/app.py:/app/app.py @@ -273,6 +293,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./relations/alembic.ini:/app/relations/alembic.ini:ro - ./relations/alembic:/app/relations/alembic:ro - ./relations/app.py:/app/app.py @@ -304,6 +327,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./likes/alembic.ini:/app/likes/alembic.ini:ro - ./likes/alembic:/app/likes/alembic:ro - ./likes/app.py:/app/app.py @@ -335,6 +361,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./orders/alembic.ini:/app/orders/alembic.ini:ro - ./orders/alembic:/app/orders/alembic:ro - ./orders/app.py:/app/app.py @@ -369,6 +398,9 @@ services: volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./test/app.py:/app/app.py - ./test/sx:/app/sx - ./test/bp:/app/bp @@ -393,9 +425,13 @@ services: - "8012:8000" environment: <<: *dev-env + SX_STANDALONE: "true" volumes: - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./sx/app.py:/app/app.py - ./sx/sxc:/app/sxc - ./sx/bp:/app/bp @@ -431,6 +467,9 @@ services: dockerfile: test/Dockerfile.unit volumes: - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./artdag/core:/app/artdag/core - ./artdag/l1/tests:/app/artdag/l1/tests - ./artdag/l1/sexp_effects:/app/artdag/l1/sexp_effects @@ -456,6 +495,9 @@ services: dockerfile: test/Dockerfile.integration volumes: - ./shared:/app/shared + - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro + - ./spec:/app/spec:ro + - ./web:/app/web:ro - ./artdag:/app/artdag profiles: - test diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 8f5d5e3..5f8b003 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -697,38 +697,36 @@ let compile_adapter env = (Array.length outer_code.Sx_vm.constants) (if Array.length outer_code.Sx_vm.constants > 0 then type_of outer_code.Sx_vm.constants.(0) else "empty"); - (* The compiled define body is (fn ...) which compiles to - OP_CLOSURE + [upvalue descriptors] + OP_RETURN. - Extract the inner code object from constants[idx]. *) - let code = - let bc = outer_code.Sx_vm.bytecode in - if Array.length bc >= 4 && bc.(0) = 51 then begin - let idx = bc.(1) lor (bc.(2) lsl 8) in + let bc = outer_code.Sx_vm.bytecode in + if Array.length bc >= 4 && bc.(0) = 51 then begin + (* The compiled define body is (fn ...) which compiles to + OP_CLOSURE + [upvalue descriptors] + OP_RETURN. + Extract the inner code object from constants[idx]. *) + let idx = bc.(1) lor (bc.(2) lsl 8) in + let code = if idx < Array.length outer_code.Sx_vm.constants then begin let inner_val = outer_code.Sx_vm.constants.(idx) in try Sx_vm.code_from_value inner_val with e -> Printf.eprintf "[vm] inner code_from_value failed for %s: %s\n%!" name (Printexc.to_string e); - Printf.eprintf "[vm] inner val type: %s\n%!" (type_of inner_val); - (match inner_val with - | Dict d -> - Printf.eprintf "[vm] inner keys: %s\n%!" - (String.concat ", " (Hashtbl.fold (fun k _ acc -> k::acc) d [])); - (match Hashtbl.find_opt d "bytecode" with - | Some v -> Printf.eprintf "[vm] bytecode type: %s\n%!" (type_of v) - | None -> Printf.eprintf "[vm] NO bytecode key\n%!") - | _ -> ()); raise e end else outer_code - end else outer_code - in - let cl = { Sx_vm.code; upvalues = [||]; name = Some name; - env_ref = globals } in - Hashtbl.replace globals name - (NativeFn ("vm:" ^ name, fun args -> - Sx_vm.call_closure cl args globals)); - incr compiled + in + let cl = { Sx_vm.code; upvalues = [||]; name = Some name; + env_ref = globals } in + Hashtbl.replace globals name + (NativeFn ("vm:" ^ name, fun args -> + Sx_vm.call_closure cl args globals)); + incr compiled + end else begin + (* Not a lambda — constant expression (e.g. (list ...)). + Execute once and store the resulting value directly. *) + let value = Sx_vm.execute_module outer_code globals in + Hashtbl.replace globals name value; + Printf.eprintf "[vm] %s: constant (type=%s)\n%!" name (type_of value); + incr compiled + end | _ -> () (* non-dict result — skip *) with e -> Printf.eprintf "[vm] FAIL adapter %s: %s\n%!" name (Printexc.to_string e)) @@ -861,11 +859,11 @@ let dispatch env cmd = io_queue := []; io_counter := 0; let t0 = Unix.gettimeofday () in + let expand_fn = NativeFn ("expand-components?", fun _args -> Bool true) in + ignore (env_bind env "expand-components?" expand_fn); let result = match !vm_adapter_globals with | Some globals -> - (* VM path: call compiled aser directly *) - Hashtbl.replace globals "expand-components?" - (NativeFn ("expand-components?", fun _args -> Bool true)); + Hashtbl.replace globals "expand-components?" expand_fn; let aser_fn = try Hashtbl.find globals "aser" with Not_found -> raise (Eval_error "VM: aser not compiled") in let r = match aser_fn with @@ -875,15 +873,10 @@ let dispatch env cmd = Hashtbl.remove globals "expand-components?"; r | None -> - (* CEK fallback *) - ignore (env_bind env "expand-components?" - (NativeFn ("expand-components?", fun _args -> Bool true))); let call = List [Symbol "aser"; List [Symbol "quote"; expr]; Env env] in - let r = Sx_ref.eval_expr call (Env env) in - Hashtbl.remove env.bindings "expand-components?"; - r + Sx_ref.eval_expr call (Env env) in let t1 = Unix.gettimeofday () in io_batch_mode := false; @@ -911,6 +904,78 @@ let dispatch env cmd = Hashtbl.remove env.bindings "expand-components?"; send_error (Printexc.to_string exn)) + | List (Symbol "sx-page-full" :: String page_src :: shell_kwargs) -> + (* Full page render: aser-slot body + render-to-html shell in ONE call. + shell_kwargs are keyword pairs: :title "..." :csrf "..." etc. + These are passed directly to ~shared:shell/sx-page-shell. *) + (try + (* Phase 1: aser-slot the page body *) + let exprs = Sx_parser.parse_all page_src in + let expr = match exprs with + | [e] -> e | [] -> Nil | _ -> List (Symbol "<>" :: exprs) + in + io_batch_mode := true; + io_queue := []; + io_counter := 0; + let t0 = Unix.gettimeofday () in + let expand_fn = NativeFn ("expand-components?", fun _args -> Bool true) in + ignore (env_bind env "expand-components?" expand_fn); + let body_result = match !vm_adapter_globals with + | Some globals -> + Hashtbl.replace globals "expand-components?" expand_fn; + let aser_fn = try Hashtbl.find globals "aser" + with Not_found -> raise (Eval_error "VM: aser not compiled") in + let r = match aser_fn with + | NativeFn (_, fn) -> fn [expr; Env env] + | _ -> raise (Eval_error "VM: aser not a function") + in + Hashtbl.remove globals "expand-components?"; + r + | None -> + let call = List [Symbol "aser"; + List [Symbol "quote"; expr]; + Env env] in + Sx_ref.eval_expr call (Env env) + in + let t1 = Unix.gettimeofday () in + io_batch_mode := false; + Hashtbl.remove env.bindings "expand-components?"; + let body_str = match body_result with + | String s | SxExpr s -> s + | _ -> serialize_value body_result + in + let body_final = flush_batched_io body_str in + let t2 = Unix.gettimeofday () in + (* Phase 2: render shell with body + all kwargs. + Resolve symbol references (e.g. __shell-component-defs) to their + values from the env — these were pre-injected by the bridge. *) + let resolved_kwargs = List.map (fun v -> + match v with + | Symbol s -> + (try env_get env s + with _ -> try Sx_primitives.get_primitive s with _ -> v) + | _ -> v + ) shell_kwargs in + let shell_args = Keyword "page-sx" :: String body_final :: resolved_kwargs in + let shell_call = List (Symbol "~shared:shell/sx-page-shell" :: shell_args) in + let html = Sx_render.render_to_html shell_call env in + let t3 = Unix.gettimeofday () in + Printf.eprintf "[sx-page-full] aser=%.3fs io=%.3fs shell=%.3fs total=%.3fs body=%d html=%d\n%!" + (t1 -. t0) (t2 -. t1) (t3 -. t2) (t3 -. t0) + (String.length body_final) (String.length html); + send_ok_string html + with + | Eval_error msg -> + io_batch_mode := false; + io_queue := []; + Hashtbl.remove env.bindings "expand-components?"; + send_error msg + | exn -> + io_batch_mode := false; + io_queue := []; + Hashtbl.remove env.bindings "expand-components?"; + send_error (Printexc.to_string exn)) + | List [Symbol "render"; String src] -> (try let exprs = Sx_parser.parse_all src in diff --git a/hosts/ocaml/bootstrap.py b/hosts/ocaml/bootstrap.py index bc762e9..0098562 100644 --- a/hosts/ocaml/bootstrap.py +++ b/hosts/ocaml/bootstrap.py @@ -200,6 +200,17 @@ def compile_spec_to_ml(spec_dir: str | None = None) -> str: return '\n'.join(fixed) output = fix_mutable_reads(output) + # Fix cek_call: the spec passes (make-env) as the env arg to + # continue_with_call, but the transpiler evaluates it at transpile + # time (it's a primitive), producing Dict instead of Env. + # Fix cek_call: the spec passes (make-env) as the env arg to + # continue_with_call, but the transpiler evaluates make-env at + # transpile time (it's a primitive), producing Dict instead of Env. + output = output.replace( + "((Dict (Hashtbl.create 0))) (a) ((List []))", + "(Env (Sx_types.make_env ())) (a) ((List []))", + ) + return output diff --git a/hosts/ocaml/lib/sx_primitives.ml b/hosts/ocaml/lib/sx_primitives.ml index 2b77928..d314605 100644 --- a/hosts/ocaml/lib/sx_primitives.ml +++ b/hosts/ocaml/lib/sx_primitives.ml @@ -618,20 +618,44 @@ let () = match args with | [f; (List items | ListRef { contents = items })] -> List (List.map (fun x -> call_any f [x]) items) + | [_; Nil] -> List [] | _ -> raise (Eval_error "map: expected (fn list)")); + register "map-indexed" (fun args -> + match args with + | [f; (List items | ListRef { contents = items })] -> + List (List.mapi (fun i x -> call_any f [Number (float_of_int i); x]) items) + | [_; Nil] -> List [] + | _ -> raise (Eval_error "map-indexed: expected (fn list)")); register "filter" (fun args -> match args with | [f; (List items | ListRef { contents = items })] -> List (List.filter (fun x -> sx_truthy (call_any f [x])) items) + | [_; Nil] -> List [] | _ -> raise (Eval_error "filter: expected (fn list)")); register "for-each" (fun args -> match args with | [f; (List items | ListRef { contents = items })] -> List.iter (fun x -> ignore (call_any f [x])) items; Nil - | _ -> raise (Eval_error "for-each: expected (fn list)")); + | [_; Nil] -> Nil (* nil collection = no-op *) + | _ -> + let types = String.concat ", " (List.map (fun v -> type_of v) args) in + raise (Eval_error (Printf.sprintf "for-each: expected (fn list), got (%s) %d args" types (List.length args)))); register "reduce" (fun args -> match args with | [f; init; (List items | ListRef { contents = items })] -> List.fold_left (fun acc x -> call_any f [acc; x]) init items | _ -> raise (Eval_error "reduce: expected (fn init list)")); + register "some" (fun args -> + match args with + | [f; (List items | ListRef { contents = items })] -> + (try List.find (fun x -> sx_truthy (call_any f [x])) items + with Not_found -> Bool false) + | [_; Nil] -> Bool false + | _ -> raise (Eval_error "some: expected (fn list)")); + register "every?" (fun args -> + match args with + | [f; (List items | ListRef { contents = items })] -> + Bool (List.for_all (fun x -> sx_truthy (call_any f [x])) items) + | [_; Nil] -> Bool true + | _ -> raise (Eval_error "every?: expected (fn list)")); () diff --git a/hosts/ocaml/lib/sx_ref.ml b/hosts/ocaml/lib/sx_ref.ml index 859a574..b366469 100644 --- a/hosts/ocaml/lib/sx_ref.ml +++ b/hosts/ocaml/lib/sx_ref.ml @@ -422,7 +422,7 @@ and step_sf_deref args env kont = (* cek-call *) and cek_call f args = - (let a = (if sx_truthy ((is_nil (args))) then (List []) else args) in (if sx_truthy ((is_nil (f))) then Nil else (if sx_truthy ((let _or = (is_lambda (f)) in if sx_truthy _or then _or else (is_callable (f)))) then (cek_run ((continue_with_call (f) (a) ((Dict (Hashtbl.create 0))) (a) ((List []))))) else Nil))) + (let a = (if sx_truthy ((is_nil (args))) then (List []) else args) in (if sx_truthy ((is_nil (f))) then Nil else (if sx_truthy ((let _or = (is_lambda (f)) in if sx_truthy _or then _or else (is_callable (f)))) then (cek_run ((continue_with_call (f) (a) ((make_env ())) (a) ((List []))))) else Nil))) (* reactive-shift-deref *) and reactive_shift_deref sig' env kont = diff --git a/hosts/ocaml/lib/sx_runtime.ml b/hosts/ocaml/lib/sx_runtime.ml index 2e8f09a..15cd6a7 100644 --- a/hosts/ocaml/lib/sx_runtime.ml +++ b/hosts/ocaml/lib/sx_runtime.ml @@ -240,7 +240,7 @@ let type_of v = String (Sx_types.type_of v) The transpiled CEK machine stores envs in dicts as Env values. *) let unwrap_env = function | Env e -> e - | _ -> raise (Eval_error "Expected env") + | v -> raise (Eval_error ("Expected env, got " ^ Sx_types.type_of v)) let env_has e name = Bool (Sx_types.env_has (unwrap_env e) (value_to_str name)) let env_get e name = Sx_types.env_get (unwrap_env e) (value_to_str name) diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index c8b89ec..c706d85 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -792,6 +792,162 @@ def _sx_literal(v: object) -> str: +_cached_shell_static: dict[str, Any] | None = None +_cached_shell_comp_hash: str | None = None + + +def invalidate_shell_cache(): + """Call on component hot-reload to recompute shell statics.""" + global _cached_shell_static, _cached_shell_comp_hash + _cached_shell_static = None + _cached_shell_comp_hash = None + + +def _get_shell_static() -> dict[str, Any]: + """Compute and cache all shell kwargs that don't change per-request. + + This is the expensive part: component dep scanning, serialization, + CSS class scanning, rule lookup, pages registry. All stable until + components are hot-reloaded. + """ + global _cached_shell_static, _cached_shell_comp_hash + from .jinja_bridge import components_for_page, css_classes_for_page, _component_env_hash + from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash + + current_hash = _component_env_hash() + if _cached_shell_static is not None and _cached_shell_comp_hash == current_hash: + return _cached_shell_static + + import time + t0 = time.monotonic() + + from quart import current_app as _ca + from .jinja_bridge import client_components_tag, _COMPONENT_ENV, _CLIENT_LIBRARY_SOURCES + from .jinja_bridge import _component_env_hash + from .parser import serialize as _serialize + + # Send ALL component definitions — the hash is stable per env so the + # browser caches them across all pages. Server-side expansion handles + # the per-page subset; the client needs the full set for client-side + # routing to any page. + parts = [] + for key, val in _COMPONENT_ENV.items(): + from .types import Island, Component, Macro + if isinstance(val, Island): + ps = ["&key"] + list(val.params) + if val.has_children: ps.extend(["&rest", "children"]) + parts.append(f"(defisland ~{val.name} ({' '.join(ps)}) {_serialize(val.body, pretty=True)})") + elif isinstance(val, Component): + ps = ["&key"] + list(val.params) + if val.has_children: ps.extend(["&rest", "children"]) + parts.append(f"(defcomp ~{val.name} ({' '.join(ps)}) {_serialize(val.body, pretty=True)})") + elif isinstance(val, Macro): + ps = list(val.params) + if val.rest_param: ps.extend(["&rest", val.rest_param]) + parts.append(f"(defmacro {val.name} ({' '.join(ps)}) {_serialize(val.body, pretty=True)})") + all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts + component_defs = "\n".join(all_parts) + component_hash = _component_env_hash() + + # CSS: scan ALL components (not per-page) for the static cache + sx_css = "" + sx_css_classes = "" + if registry_loaded(): + classes: set[str] = set() + from .types import Island as _I, Component as _C + for val in _COMPONENT_ENV.values(): + if isinstance(val, (_I, _C)) and val.css_classes: + classes.update(val.css_classes) + classes.update(["bg-stone-50", "text-stone-900"]) + rules = lookup_rules(classes) + sx_css = get_preamble() + rules + sx_css_classes = store_css_hash(classes) + + pages_sx = _build_pages_sx(_ca.name) + + _shell_cfg = _ca.config.get("SX_SHELL", {}) + + static = dict( + component_hash=component_hash, + component_defs=component_defs, + pages_sx=pages_sx, + sx_css=sx_css, + sx_css_classes=sx_css_classes, + sx_js_hash=_script_hash("sx-browser.js"), + body_js_hash=_script_hash("body.js"), + asset_url=_ca.config.get("ASSET_URL", "/static"), + head_scripts=_shell_cfg.get("head_scripts"), + inline_css=_shell_cfg.get("inline_css"), + inline_head_js=_shell_cfg.get("inline_head_js"), + init_sx=_shell_cfg.get("init_sx"), + body_scripts=_shell_cfg.get("body_scripts"), + ) + + t1 = time.monotonic() + import logging + logging.getLogger("sx.pages").info( + "[shell-static] computed in %.3fs, comp_defs=%d css=%d pages=%d", + t1 - t0, len(component_defs), len(sx_css), len(pages_sx)) + + _cached_shell_static = static + _cached_shell_comp_hash = current_hash + return static + + +async def _build_shell_kwargs(ctx: dict, page_sx: str, *, + meta_html: str = "", + head_scripts: list[str] | None = None, + inline_css: str | None = None, + inline_head_js: str | None = None, + init_sx: str | None = None, + body_scripts: list[str] | None = None) -> dict[str, Any]: + """Compute all shell kwargs for sx-page-shell. + + Static parts (components, CSS, pages) are cached. Only per-request + values (title, csrf) are computed fresh. + """ + static = _get_shell_static() + + asset_url = get_asset_url(ctx) or static["asset_url"] + title = ctx.get("base_title", "Rose Ash") + csrf = _get_csrf_token() + + kwargs: dict[str, Any] = dict(static) + kwargs.update( + title=_html_escape(title), + asset_url=asset_url, + meta_html=meta_html, + csrf=_html_escape(csrf), + ) + + # Per-page CSS: scan THIS page's classes and add to cached CSS + from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded + if registry_loaded() and page_sx: + page_classes = scan_classes_from_sx(page_sx) + if page_classes: + extra_rules = lookup_rules(page_classes) + if extra_rules: + kwargs["sx_css"] = static["sx_css"] + extra_rules + + # Cookie-based component caching + client_hash = _get_sx_comp_cookie() + if not _is_dev_mode() and client_hash and client_hash == static["component_hash"]: + kwargs["component_defs"] = "" + + # Per-call overrides + if head_scripts is not None: + kwargs["head_scripts"] = head_scripts + if inline_css is not None: + kwargs["inline_css"] = inline_css + if inline_head_js is not None: + kwargs["inline_head_js"] = inline_head_js + if init_sx is not None: + kwargs["init_sx"] = init_sx + if body_scripts is not None: + kwargs["body_scripts"] = body_scripts + return kwargs + + async def sx_page(ctx: dict, page_sx: str, *, meta_html: str = "", head_scripts: list[str] | None = None, @@ -799,109 +955,18 @@ async def sx_page(ctx: dict, page_sx: str, *, inline_head_js: str | None = None, init_sx: str | None = None, body_scripts: list[str] | None = None) -> str: - """Return a minimal HTML shell that boots the page from sx source. - - The browser loads component definitions and page sx, then sx.js - renders everything client-side. CSS rules are scanned from the sx - source and component defs, then injected as a