diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 1ad9aea7..726c0c67 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -385,6 +385,16 @@ let setup_evaluator_bridge env = | [fn_val; List call_args] -> Sx_ref.cek_call fn_val (List call_args) | _ -> raise (Eval_error "call-lambda: expected (fn args env?)")); + bind "cek-try" (fun args -> + match args with + | [thunk; handler] -> + (try Sx_ref.cek_call thunk Nil + with Eval_error msg -> Sx_ref.cek_call handler (String msg)) + | [thunk] -> + (try let r = Sx_ref.cek_call thunk Nil in + List [Symbol "ok"; r] + with Eval_error msg -> List [Symbol "error"; String msg]) + | _ -> Nil); bind "cek-call" (fun args -> match args with | [fn_val; List call_args] -> Sx_ref.cek_call fn_val (List call_args) diff --git a/hosts/ocaml/browser/bundle.sh b/hosts/ocaml/browser/bundle.sh index f9682c85..ddb7bb09 100755 --- a/hosts/ocaml/browser/bundle.sh +++ b/hosts/ocaml/browser/bundle.sh @@ -55,6 +55,11 @@ cp "$ROOT/web/adapter-dom.sx" "$DIST/sx/" cp "$ROOT/web/lib/boot-helpers.sx" "$DIST/sx/" cp "$ROOT/web/lib/hypersx.sx" "$DIST/sx/" +# 7b. Test harness (for inline test runners) +cp "$ROOT/spec/harness.sx" "$DIST/sx/" +cp "$ROOT/web/harness-reactive.sx" "$DIST/sx/" +cp "$ROOT/web/harness-web.sx" "$DIST/sx/" + # 8. Web framework cp "$ROOT/web/engine.sx" "$DIST/sx/" cp "$ROOT/web/orchestration.sx" "$DIST/sx/" diff --git a/hosts/ocaml/browser/sx-platform.js b/hosts/ocaml/browser/sx-platform.js index 097e3b95..12b055aa 100644 --- a/hosts/ocaml/browser/sx-platform.js +++ b/hosts/ocaml/browser/sx-platform.js @@ -255,6 +255,10 @@ // Boot helpers (platform functions in pure SX) "sx/boot-helpers.sx", "sx/hypersx.sx", + // Test harness (for inline test runners) + "sx/harness.sx", + "sx/harness-reactive.sx", + "sx/harness-web.sx", // Web framework "sx/engine.sx", "sx/orchestration.sx", diff --git a/hosts/ocaml/browser/sx_browser.ml b/hosts/ocaml/browser/sx_browser.ml new file mode 100644 index 00000000..1cb6554e --- /dev/null +++ b/hosts/ocaml/browser/sx_browser.ml @@ -0,0 +1,585 @@ +(** sx_browser.ml — OCaml SX kernel compiled to WASM/JS for browser use. + + Exposes the CEK machine, bytecode VM, parser, and primitives as a + global [SxKernel] object that the JS platform layer binds to. + + Fresh implementation on the ocaml-vm branch — builds on the bytecode + VM + lazy JIT infrastructure. *) + +open Js_of_ocaml +open Sx_types + +(* ================================================================== *) +(* Opaque value handle table *) +(* *) +(* Non-primitive SX values (lambdas, components, signals, etc.) are *) +(* stored here and represented on the JS side as objects with an *) +(* __sx_handle integer key. Preserves identity across JS↔OCaml. *) +(* ================================================================== *) + +let _next_handle = ref 0 +let _handle_table : (int, value) Hashtbl.t = Hashtbl.create 256 + +let alloc_handle (v : value) : int = + let id = !_next_handle in + incr _next_handle; + Hashtbl.replace _handle_table id v; + id + +let get_handle (id : int) : value = + match Hashtbl.find_opt _handle_table id with + | Some v -> v + | None -> raise (Eval_error (Printf.sprintf "Invalid SX handle: %d" id)) + +(* JS-side opaque host object table. + Host objects (DOM elements, console, etc.) are stored here to preserve + identity across the OCaml↔JS boundary. Represented as Dict with + __host_handle key on the OCaml side. *) +let _next_host_handle = ref 0 +let _alloc_host_handle = Js.Unsafe.pure_js_expr + "(function() { var t = {}; var n = 0; return { put: function(obj) { var id = n++; t[id] = obj; return id; }, get: function(id) { return t[id]; } }; })()" +let host_put (obj : Js.Unsafe.any) : int = + let id = !_next_host_handle in + incr _next_host_handle; + ignore (Js.Unsafe.meth_call _alloc_host_handle "put" [| obj |]); + id +let host_get_js (id : int) : Js.Unsafe.any = + Js.Unsafe.meth_call _alloc_host_handle "get" [| Js.Unsafe.inject id |] + +(* ================================================================== *) +(* Global environment *) +(* ================================================================== *) + +(* Force module initialization — these modules register primitives + in their let () = ... blocks but aren't referenced directly. *) +let () = Sx_scope.clear_all () + +let global_env = make_env () +let _sx_render_mode = ref false + +let call_sx_fn (fn : value) (args : value list) : value = + Sx_ref.eval_expr (List (fn :: args)) (Env global_env) + +(* ================================================================== *) +(* Value conversion: OCaml <-> JS *) +(* ================================================================== *) + +(** Tag a JS function with __sx_handle and _type properties. *) +let _tag_fn = Js.Unsafe.pure_js_expr + "(function(fn, handle, type) { fn.__sx_handle = handle; fn._type = type; return fn; })" + +let rec value_to_js (v : value) : Js.Unsafe.any = + match v with + | Nil -> Js.Unsafe.inject Js.null + | Bool b -> Js.Unsafe.inject (Js.bool b) + | Number n -> Js.Unsafe.inject (Js.number_of_float n) + | String s -> Js.Unsafe.inject (Js.string s) + | RawHTML s -> Js.Unsafe.inject (Js.string s) + | Symbol s -> + Js.Unsafe.inject (Js.Unsafe.obj [| + ("_type", Js.Unsafe.inject (Js.string "symbol")); + ("name", Js.Unsafe.inject (Js.string s)) |]) + | Keyword k -> + Js.Unsafe.inject (Js.Unsafe.obj [| + ("_type", Js.Unsafe.inject (Js.string "keyword")); + ("name", Js.Unsafe.inject (Js.string k)) |]) + | List items | ListRef { contents = items } -> + let arr = items |> List.map value_to_js |> Array.of_list in + Js.Unsafe.inject (Js.Unsafe.obj [| + ("_type", Js.Unsafe.inject (Js.string "list")); + ("items", Js.Unsafe.inject (Js.array arr)) |]) + | Dict d -> + (* Check for __host_handle — return original JS object *) + (match Hashtbl.find_opt d "__host_handle" with + | Some (Number n) -> host_get_js (int_of_float n) + | _ -> + let obj = Js.Unsafe.obj [||] in + Js.Unsafe.set obj (Js.string "_type") (Js.string "dict"); + Hashtbl.iter (fun k v -> + Js.Unsafe.set obj (Js.string k) (value_to_js v)) d; + Js.Unsafe.inject obj) + (* Callable values: wrap as JS functions with __sx_handle *) + | Lambda _ | NativeFn _ | Continuation _ | VmClosure _ -> + let handle = alloc_handle v in + let inner = Js.wrap_callback (fun args_js -> + try + let arg = js_to_value args_js in + let args = match arg with Nil -> [] | _ -> [arg] in + let result = call_sx_fn v args in + value_to_js result + with Eval_error msg -> + ignore (Js.Unsafe.meth_call + (Js.Unsafe.get Js.Unsafe.global (Js.string "console")) + "error" [| Js.Unsafe.inject (Js.string ("[sx] " ^ msg)) |]); + Js.Unsafe.inject Js.null) in + Js.Unsafe.fun_call _tag_fn [| + Js.Unsafe.inject inner; + Js.Unsafe.inject handle; + Js.Unsafe.inject (Js.string (type_of v)) |] + (* Non-callable compound: tagged object with handle *) + | _ -> + let handle = alloc_handle v in + Js.Unsafe.inject (Js.Unsafe.obj [| + ("_type", Js.Unsafe.inject (Js.string (type_of v))); + ("__sx_handle", Js.Unsafe.inject handle) |]) + +and js_to_value (js : Js.Unsafe.any) : value = + if Js.Unsafe.equals js Js.null || Js.Unsafe.equals js Js.undefined then Nil + else + let ty = Js.to_string (Js.typeof js) in + match ty with + | "number" -> Number (Js.float_of_number (Js.Unsafe.coerce js)) + | "boolean" -> Bool (Js.to_bool (Js.Unsafe.coerce js)) + | "string" -> String (Js.to_string (Js.Unsafe.coerce js)) + | "function" -> + let h = Js.Unsafe.get js (Js.string "__sx_handle") in + if not (Js.Unsafe.equals h Js.undefined) then + get_handle (Js.float_of_number (Js.Unsafe.coerce h) |> int_of_float) + else + (* Plain JS function — wrap as NativeFn *) + NativeFn ("js-callback", fun args -> + let js_args = args |> List.map value_to_js |> Array.of_list in + js_to_value (Js.Unsafe.fun_call js (Array.map Fun.id js_args))) + | "object" -> + let h = Js.Unsafe.get js (Js.string "__sx_handle") in + if not (Js.Unsafe.equals h Js.undefined) then + get_handle (Js.float_of_number (Js.Unsafe.coerce h) |> int_of_float) + else + let type_field = Js.Unsafe.get js (Js.string "_type") in + if Js.Unsafe.equals type_field Js.undefined then begin + if Js.to_bool (Js.Unsafe.global##._Array##isArray js) then begin + let n = Js.float_of_number (Js.Unsafe.coerce (Js.Unsafe.get js (Js.string "length"))) |> int_of_float in + List (List.init n (fun i -> + js_to_value (Js.array_get (Js.Unsafe.coerce js) i |> Js.Optdef.to_option |> Option.get))) + end else begin + (* Opaque host object — store in JS-side table, return Dict with __host_handle *) + let id = host_put js in + let d = Hashtbl.create 2 in + Hashtbl.replace d "__host_handle" (Number (float_of_int id)); + Dict d + end + end else begin + let tag = Js.to_string (Js.Unsafe.coerce type_field) in + match tag with + | "symbol" -> Symbol (Js.to_string (Js.Unsafe.get js (Js.string "name"))) + | "keyword" -> Keyword (Js.to_string (Js.Unsafe.get js (Js.string "name"))) + | "list" -> + let items_js = Js.Unsafe.get js (Js.string "items") in + let n = Js.float_of_number (Js.Unsafe.coerce (Js.Unsafe.get items_js (Js.string "length"))) |> int_of_float in + List (List.init n (fun i -> + js_to_value (Js.array_get (Js.Unsafe.coerce items_js) i |> Js.Optdef.to_option |> Option.get))) + | "dict" -> + let d = Hashtbl.create 8 in + let keys = Js.Unsafe.global##._Object##keys js in + let len = keys##.length in + for i = 0 to len - 1 do + let k = Js.to_string (Js.array_get keys i |> Js.Optdef.to_option |> Option.get) in + if k <> "_type" then + Hashtbl.replace d k (js_to_value (Js.Unsafe.get js (Js.string k))) + done; + Dict d + | _ -> Nil + end + | _ -> Nil + +(* ================================================================== *) +(* Side-channel return (bypasses js_of_ocaml stripping properties) *) +(* ================================================================== *) + +let return_via_side_channel (v : Js.Unsafe.any) : Js.Unsafe.any = + Js.Unsafe.set Js.Unsafe.global (Js.string "__sxR") v; v + +(* ================================================================== *) +(* Core API *) +(* ================================================================== *) + +let api_parse src_js = + let src = Js.to_string src_js in + try + let values = Sx_parser.parse_all src in + Js.Unsafe.inject (Js.array (values |> List.map value_to_js |> Array.of_list)) + with Parse_error msg -> + Js.Unsafe.inject (Js.string ("Parse error: " ^ msg)) + +let api_eval src_js = + let src = Js.to_string src_js in + try + let exprs = Sx_parser.parse_all src in + let env = Env global_env in + let result = List.fold_left (fun _acc expr -> Sx_ref.eval_expr expr env) Nil exprs in + return_via_side_channel (value_to_js result) + with + | Eval_error msg -> Js.Unsafe.inject (Js.string ("Error: " ^ msg)) + | Parse_error msg -> Js.Unsafe.inject (Js.string ("Parse error: " ^ msg)) + +let api_eval_expr expr_js _env_js = + let expr = js_to_value expr_js in + try + return_via_side_channel (value_to_js (Sx_ref.eval_expr expr (Env global_env))) + with Eval_error msg -> + Js.Unsafe.inject (Js.string ("Error: " ^ msg)) + +let api_load src_js = + let src = Js.to_string src_js in + try + let exprs = Sx_parser.parse_all src in + let env = Env global_env in + let count = ref 0 in + List.iter (fun expr -> ignore (Sx_ref.eval_expr expr env); incr count) exprs; + Js.Unsafe.inject !count + with + | Eval_error msg -> Js.Unsafe.inject (Js.string ("Error: " ^ msg)) + | Parse_error msg -> Js.Unsafe.inject (Js.string ("Parse error: " ^ msg)) + +let api_render_to_html expr_js = + let expr = js_to_value expr_js in + let prev = !_sx_render_mode in + _sx_render_mode := true; + (try + let html = Sx_render.render_to_html expr global_env in + _sx_render_mode := prev; + Js.Unsafe.inject (Js.string html) + with Eval_error msg -> + _sx_render_mode := prev; + Js.Unsafe.inject (Js.string ("Error: " ^ msg))) + +let api_stringify v_js = + Js.Unsafe.inject (Js.string (inspect (js_to_value v_js))) + +let api_type_of v_js = + Js.Unsafe.inject (Js.string (type_of (js_to_value v_js))) + +let api_inspect v_js = + Js.Unsafe.inject (Js.string (inspect (js_to_value v_js))) + +let api_engine () = + Js.Unsafe.inject (Js.string "ocaml-vm-wasm") + +let api_register_native name_js callback_js = + let name = Js.to_string name_js in + let native_fn args = + let js_args = args |> List.map value_to_js |> Array.of_list in + js_to_value (Js.Unsafe.fun_call callback_js [| Js.Unsafe.inject (Js.array js_args) |]) + in + ignore (env_bind global_env name (NativeFn (name, native_fn))); + Js.Unsafe.inject Js.null + +let api_call_fn fn_js args_js = + try + let fn = js_to_value fn_js in + let args = Array.to_list (Array.map js_to_value (Js.to_array (Js.Unsafe.coerce args_js))) in + return_via_side_channel (value_to_js (call_sx_fn fn args)) + with + | Eval_error msg -> + ignore (Js.Unsafe.meth_call + (Js.Unsafe.get Js.Unsafe.global (Js.string "console")) + "error" [| Js.Unsafe.inject (Js.string ("[sx] callFn: " ^ msg)) |]); + Js.Unsafe.inject Js.null + | exn -> + ignore (Js.Unsafe.meth_call + (Js.Unsafe.get Js.Unsafe.global (Js.string "console")) + "error" [| Js.Unsafe.inject (Js.string ("[sx] callFn: " ^ Printexc.to_string exn)) |]); + Js.Unsafe.inject Js.null + +let api_is_callable fn_js = + if Js.Unsafe.equals fn_js Js.null || Js.Unsafe.equals fn_js Js.undefined then + Js.Unsafe.inject (Js.bool false) + else + let h = Js.Unsafe.get fn_js (Js.string "__sx_handle") in + if Js.Unsafe.equals h Js.undefined then Js.Unsafe.inject (Js.bool false) + else Js.Unsafe.inject (Js.bool (is_callable (get_handle (Js.float_of_number (Js.Unsafe.coerce h) |> int_of_float)))) + +let api_fn_arity fn_js = + let h = Js.Unsafe.get fn_js (Js.string "__sx_handle") in + if Js.Unsafe.equals h Js.undefined then Js.Unsafe.inject (Js.number_of_float (-1.0)) + else + let v = get_handle (Js.float_of_number (Js.Unsafe.coerce h) |> int_of_float) in + match v with + | Lambda l -> Js.Unsafe.inject (Js.number_of_float (float_of_int (List.length l.l_params))) + | _ -> Js.Unsafe.inject (Js.number_of_float (-1.0)) + +(* ================================================================== *) +(* Platform bindings (registered in global env) *) +(* ================================================================== *) + +let () = + let bind name fn = ignore (env_bind global_env name (NativeFn (name, fn))) in + + (* --- Evaluation --- *) + bind "cek-eval" (fun args -> + match args with + | [String s] -> let e = Sx_parser.parse_all s in (match e with h :: _ -> Sx_ref.eval_expr h (Env global_env) | [] -> Nil) + | [expr] -> Sx_ref.eval_expr expr (Env global_env) + | [expr; env_val] -> Sx_ref.eval_expr expr env_val + | _ -> raise (Eval_error "cek-eval: expected 1-2 args")); + + bind "eval-expr-cek" (fun args -> + match args with + | [expr; e] -> Sx_ref.eval_expr expr e + | [expr] -> Sx_ref.eval_expr expr (Env global_env) + | _ -> raise (Eval_error "eval-expr-cek: expected 1-2 args")); + + bind "cek-call" (fun args -> + match args with + | [f; Nil] -> Sx_ref.eval_expr (List [f]) (Env global_env) + | [f; List al] -> Sx_ref.eval_expr (List (f :: al)) (Env global_env) + | [f; a] -> Sx_ref.eval_expr (List [f; a]) (Env global_env) + | [f] -> Sx_ref.eval_expr (List [f]) (Env global_env) + | f :: rest -> Sx_ref.eval_expr (List (f :: rest)) (Env global_env) + | _ -> raise (Eval_error "cek-call: expected function and args")); + + bind "sx-parse" (fun args -> + match args with + | [String src] -> List (Sx_parser.parse_all src) + | _ -> raise (Eval_error "sx-parse: expected string")); + + bind "sx-serialize" (fun args -> + match args with + | [v] -> String (inspect v) + | _ -> raise (Eval_error "sx-serialize: expected 1 arg")); + + (* --- Assertions & equality --- *) + let rec deep_equal a b = + match a, b with + | Nil, Nil -> true | Bool a, Bool b -> a = b + | Number a, Number b -> a = b | String a, String b -> a = b + | Symbol a, Symbol b -> a = b | Keyword a, Keyword b -> a = b + | (List a | ListRef { contents = a }), (List b | ListRef { contents = b }) -> + List.length a = List.length b && List.for_all2 deep_equal a b + | Dict a, Dict b -> + let ka = Hashtbl.fold (fun k _ acc -> k :: acc) a [] in + let kb = Hashtbl.fold (fun k _ acc -> k :: acc) b [] in + List.length ka = List.length kb && + List.for_all (fun k -> Hashtbl.mem b k && + deep_equal (Hashtbl.find a k) (Hashtbl.find b k)) ka + | _ -> false + in + bind "equal?" (fun args -> match args with [a; b] -> Bool (deep_equal a b) | _ -> raise (Eval_error "equal?: 2 args")); + bind "assert" (fun args -> + match args with + | [cond] -> if not (sx_truthy cond) then raise (Eval_error "Assertion failed"); Bool true + | [cond; msg] -> if not (sx_truthy cond) then raise (Eval_error ("Assertion: " ^ value_to_string msg)); Bool true + | _ -> raise (Eval_error "assert: 1-2 args")); + + bind "try-call" (fun args -> + match args with + | [thunk] -> + (try ignore (Sx_ref.eval_expr (List [thunk]) (Env global_env)); + let d = Hashtbl.create 2 in Hashtbl.replace d "ok" (Bool true); Dict d + with Eval_error msg -> + let d = Hashtbl.create 2 in Hashtbl.replace d "ok" (Bool false); Hashtbl.replace d "error" (String msg); Dict d) + | _ -> raise (Eval_error "try-call: 1 arg")); + + (* --- List mutation --- *) + bind "append!" (fun args -> + match args with + | [ListRef r; v] -> r := !r @ [v]; ListRef r + | [List items; v] -> List (items @ [v]) + | _ -> raise (Eval_error "append!: list and value")); + + (* --- Environment ops --- *) + (* Use unwrap_env for nil/dict tolerance, matching the server kernel *) + let uw = Sx_runtime.unwrap_env in + bind "make-env" (fun _ -> Env (make_env ())); + bind "global-env" (fun _ -> Env global_env); + bind "env-has?" (fun args -> match args with [e; String k] | [e; Keyword k] -> Bool (env_has (uw e) k) | _ -> raise (Eval_error "env-has?")); + bind "env-get" (fun args -> match args with [e; String k] | [e; Keyword k] -> env_get (uw e) k | _ -> raise (Eval_error "env-get")); + bind "env-bind!" (fun args -> match args with [e; String k; v] | [e; Keyword k; v] -> env_bind (uw e) k v | _ -> raise (Eval_error "env-bind!")); + bind "env-set!" (fun args -> match args with [e; String k; v] | [e; Keyword k; v] -> env_set (uw e) k v | _ -> raise (Eval_error "env-set!")); + bind "env-extend" (fun args -> match args with [e] -> Env (env_extend (uw e)) | _ -> raise (Eval_error "env-extend")); + bind "env-merge" (fun args -> match args with [a; b] -> Sx_runtime.env_merge a b | _ -> raise (Eval_error "env-merge")); + + (* --- Type constructors --- *) + bind "make-symbol" (fun args -> match args with [String s] -> Symbol s | [v] -> Symbol (value_to_string v) | _ -> raise (Eval_error "make-symbol")); + bind "make-keyword" (fun args -> match args with [String s] -> Keyword s | _ -> raise (Eval_error "make-keyword")); + bind "keyword-name" (fun args -> match args with [Keyword k] -> String k | _ -> raise (Eval_error "keyword-name")); + bind "symbol-name" (fun args -> match args with [Symbol s] -> String s | _ -> raise (Eval_error "symbol-name")); + + (* --- Component/Island accessors (must handle both types) --- *) + bind "component-name" (fun args -> + match args with [Component c] -> String c.c_name | [Island i] -> String i.i_name | _ -> String ""); + bind "component-closure" (fun args -> + match args with [Component c] -> Env c.c_closure | [Island i] -> Env i.i_closure | _ -> Dict (Hashtbl.create 0)); + bind "component-params" (fun args -> + match args with + | [Component c] -> List (List.map (fun s -> String s) c.c_params) + | [Island i] -> List (List.map (fun s -> String s) i.i_params) + | _ -> Nil); + bind "component-body" (fun args -> + match args with [Component c] -> c.c_body | [Island i] -> i.i_body | _ -> Nil); + let has_children_impl = NativeFn ("component-has-children?", fun args -> + match args with [Component c] -> Bool c.c_has_children | [Island i] -> Bool i.i_has_children | _ -> Bool false) in + ignore (env_bind global_env "component-has-children" has_children_impl); + ignore (env_bind global_env "component-has-children?" has_children_impl); + bind "component-affinity" (fun args -> + match args with [Component c] -> String c.c_affinity | [Island _] -> String "client" | _ -> String "auto"); + bind "component-param-types" (fun _ -> Nil); + bind "component-set-param-types!" (fun _ -> Nil); + + (* --- CEK stepping --- *) + bind "make-cek-state" (fun args -> match args with [c; e; k] -> Sx_ref.make_cek_state c e k | _ -> raise (Eval_error "make-cek-state")); + bind "cek-step" (fun args -> match args with [s] -> Sx_ref.cek_step s | _ -> raise (Eval_error "cek-step")); + bind "cek-phase" (fun args -> match args with [s] -> Sx_ref.cek_phase s | _ -> raise (Eval_error "cek-phase")); + bind "cek-value" (fun args -> match args with [s] -> Sx_ref.cek_value s | _ -> raise (Eval_error "cek-value")); + bind "cek-terminal?" (fun args -> match args with [s] -> Sx_ref.cek_terminal_p s | _ -> raise (Eval_error "cek-terminal?")); + bind "cek-kont" (fun args -> match args with [s] -> Sx_ref.cek_kont s | _ -> raise (Eval_error "cek-kont")); + bind "frame-type" (fun args -> match args with [f] -> Sx_ref.frame_type f | _ -> raise (Eval_error "frame-type")); + + (* --- Strict mode --- *) + ignore (env_bind global_env "*strict*" (Bool false)); + ignore (env_bind global_env "*prim-param-types*" Nil); + bind "set-strict!" (fun args -> match args with [v] -> Sx_ref._strict_ref := v; ignore (env_set global_env "*strict*" v); Nil | _ -> Nil); + bind "set-prim-param-types!" (fun args -> match args with [v] -> Sx_ref._prim_param_types_ref := v; ignore (env_set global_env "*prim-param-types*" v); Nil | _ -> Nil); + bind "value-matches-type?" (fun args -> match args with [v; t] -> Sx_ref.value_matches_type_p v t | _ -> Nil); + + (* --- Apply --- *) + bind "apply" (fun args -> + match args with + | f :: rest -> + let all_args = match List.rev rest with List last :: prefix -> List.rev prefix @ last | _ -> rest in + Sx_runtime.sx_call f all_args + | _ -> raise (Eval_error "apply")); + + (* --- Scope stack --- *) + (* Scope primitives (scope-push!, scope-pop!, context, collect!, collected, + emit!, emitted, scope-emit!, scope-emitted, etc.) are registered by + Sx_scope module initialization in the primitives table. + The CEK evaluator falls through to the primitives table when a symbol + isn't in the env, so these work automatically. + Only provide-push!/provide-pop! need explicit env bindings as aliases. *) + bind "provide-push!" (fun args -> match args with [n; v] -> Sx_runtime.provide_push n v | _ -> raise (Eval_error "provide-push!")); + bind "provide-pop!" (fun args -> match args with [n] -> Sx_runtime.provide_pop n | _ -> raise (Eval_error "provide-pop!")); + + (* --- Fragment / raw HTML --- *) + bind "<>" (fun args -> + RawHTML (String.concat "" (List.map (fun a -> + match a with String s | RawHTML s -> s | Nil -> "" + | List _ -> Sx_render.render_to_html a global_env + | _ -> value_to_string a) args))); + bind "raw!" (fun args -> + RawHTML (String.concat "" (List.map (fun a -> + match a with String s | RawHTML s -> s | _ -> value_to_string a) args))); + + bind "define-page-helper" (fun _ -> Nil); + + (* --- Render --- *) + Sx_render.setup_render_env global_env; + bind "set-render-active!" (fun _ -> Nil); + bind "render-active?" (fun _ -> Bool true); + bind "is-html-tag?" (fun args -> match args with [String s] -> Bool (Sx_render.is_html_tag s) | _ -> Bool false); + + (* --- Render constants needed by web adapters --- *) + let html_tags = List (List.map (fun s -> String s) Sx_render.html_tags) in + let void_elements = List (List.map (fun s -> String s) Sx_render.void_elements) in + let boolean_attrs = List (List.map (fun s -> String s) Sx_render.boolean_attrs) in + ignore (env_bind global_env "HTML_TAGS" html_tags); + ignore (env_bind global_env "VOID_ELEMENTS" void_elements); + ignore (env_bind global_env "BOOLEAN_ATTRS" boolean_attrs); + + (* --- Error handling --- *) + bind "cek-try" (fun args -> + match args with + | [thunk; handler] -> + (try Sx_ref.cek_call thunk Nil + with Eval_error msg -> Sx_ref.cek_call handler (String msg)) + | [thunk] -> + (try let r = Sx_ref.cek_call thunk Nil in + List [Symbol "ok"; r] + with Eval_error msg -> List [Symbol "error"; String msg]) + | _ -> Nil); + + (* --- Evaluator bridge functions needed by spec .sx files --- *) + bind "eval-expr" (fun args -> + match args with [expr; e] -> Sx_ref.eval_expr expr e | [expr] -> Sx_ref.eval_expr expr (Env global_env) | _ -> Nil); + bind "trampoline" (fun args -> match args with [v] -> !Sx_primitives._sx_trampoline_fn v | _ -> Nil); + bind "expand-macro" (fun args -> + match args with [mac; raw; Env e] -> Sx_ref.expand_macro mac raw (Env e) | [mac; raw] -> Sx_ref.expand_macro mac raw (Env global_env) | _ -> Nil); + bind "call-lambda" (fun args -> + match args with + | [fn_val; call_args; Env _e] -> Sx_ref.cek_call fn_val call_args + | [fn_val; call_args] -> Sx_ref.cek_call fn_val call_args + | _ -> raise (Eval_error "call-lambda: expected (fn args env?)")); + bind "cek-call" (fun args -> + match args with [f; a] -> Sx_ref.cek_call f a | _ -> raise (Eval_error "cek-call")); + bind "cek-eval" (fun args -> + match args with [expr] -> Sx_ref.eval_expr expr (Env global_env) | [expr; e] -> Sx_ref.eval_expr expr e | _ -> Nil); + bind "qq-expand-runtime" (fun args -> + match args with [template] -> Sx_ref.qq_expand template (Env global_env) | [template; Env e] -> Sx_ref.qq_expand template (Env e) | _ -> Nil); + + (* --- Type predicates needed by adapters --- *) + bind "thunk?" (fun args -> match args with [Thunk _] -> Bool true | _ -> Bool false); + bind "thunk-expr" (fun args -> match args with [v] -> thunk_expr v | _ -> Nil); + bind "thunk-env" (fun args -> match args with [v] -> thunk_env v | _ -> Nil); + bind "lambda?" (fun args -> match args with [Lambda _] -> Bool true | _ -> Bool false); + bind "macro?" (fun args -> match args with [Macro _] -> Bool true | _ -> Bool false); + bind "island?" (fun args -> match args with [Island _] -> Bool true | _ -> Bool false); + bind "component?" (fun args -> match args with [Component _] | [Island _] -> Bool true | _ -> Bool false); + bind "callable?" (fun args -> match args with [NativeFn _] | [Lambda _] | [Component _] | [Island _] -> Bool true | _ -> Bool false); + bind "spread?" (fun args -> match args with [Spread _] -> Bool true | _ -> Bool false); + bind "continuation?" (fun args -> match args with [Continuation _] -> Bool true | _ -> Bool false); + bind "lambda-params" (fun args -> match args with [Lambda l] -> List (List.map (fun s -> String s) l.l_params) | _ -> List []); + bind "lambda-body" (fun args -> match args with [Lambda l] -> l.l_body | _ -> Nil); + bind "lambda-closure" (fun args -> match args with [Lambda l] -> Env l.l_closure | _ -> Dict (Hashtbl.create 0)); + + (* --- Core operations needed by adapters --- *) + bind "spread-attrs" (fun args -> + match args with [Spread pairs] -> let d = Hashtbl.create 4 in List.iter (fun (k, v) -> Hashtbl.replace d k v) pairs; Dict d | _ -> Dict (Hashtbl.create 0)); + bind "make-spread" (fun args -> + match args with [Dict d] -> Spread (Hashtbl.fold (fun k v acc -> (k, v) :: acc) d []) | _ -> Nil); + bind "make-raw-html" (fun args -> match args with [String s] -> RawHTML s | [v] -> RawHTML (value_to_string v) | _ -> Nil); + bind "raw-html-content" (fun args -> match args with [RawHTML s] -> String s | [String s] -> String s | _ -> String ""); + bind "empty-dict?" (fun args -> match args with [Dict d] -> Bool (Hashtbl.length d = 0) | _ -> Bool true); + bind "identical?" (fun args -> match args with [a; b] -> Bool (a == b) | _ -> raise (Eval_error "identical?")); + bind "for-each-indexed" (fun args -> + match args with + | [fn_val; List items] | [fn_val; ListRef { contents = items }] -> + List.iteri (fun i item -> + ignore (Sx_ref.eval_expr (List [fn_val; Number (float_of_int i); item]) (Env global_env)) + ) items; Nil + | _ -> Nil); + + (* --- String/number helpers used by orchestration/browser --- *) + bind "make-sx-expr" (fun args -> match args with [String s] -> SxExpr s | _ -> raise (Eval_error "make-sx-expr")); + bind "sx-expr-source" (fun args -> match args with [SxExpr s] -> String s | [String s] -> String s | _ -> raise (Eval_error "sx-expr-source")); + bind "parse-int" (fun args -> + match args with + | [String s] -> (try Number (float_of_int (int_of_string s)) with _ -> Nil) + | [String s; default_val] -> (try Number (float_of_int (int_of_string s)) with _ -> default_val) + | [Number n] | [Number n; _] -> Number (Float.round n) + | [_; default_val] -> default_val | _ -> Nil); + bind "parse-number" (fun args -> match args with [String s] -> (try Number (float_of_string s) with _ -> Nil) | _ -> Nil); + + (* --- Server-only stubs (no-ops in browser) --- *) + bind "query" (fun _ -> Nil); + bind "action" (fun _ -> Nil); + bind "request-arg" (fun args -> match args with [_; d] -> d | _ -> Nil); + bind "request-method" (fun _ -> String "GET"); + bind "ctx" (fun _ -> Nil); + bind "helper" (fun _ -> Nil) + +(* ================================================================== *) +(* Register global SxKernel object *) +(* ================================================================== *) + +let () = + let sx = Js.Unsafe.obj [||] in + let wrap fn = Js.Unsafe.fun_call + (Js.Unsafe.pure_js_expr "(function(fn) { return function() { globalThis.__sxR = undefined; var r = fn.apply(null, arguments); return globalThis.__sxR !== undefined ? globalThis.__sxR : r; }; })") + [| Js.Unsafe.inject (Js.wrap_callback fn) |] in + + Js.Unsafe.set sx (Js.string "parse") (Js.wrap_callback api_parse); + Js.Unsafe.set sx (Js.string "stringify") (Js.wrap_callback api_stringify); + Js.Unsafe.set sx (Js.string "eval") (wrap api_eval); + Js.Unsafe.set sx (Js.string "evalExpr") (wrap api_eval_expr); + Js.Unsafe.set sx (Js.string "renderToHtml") (Js.wrap_callback api_render_to_html); + Js.Unsafe.set sx (Js.string "load") (Js.wrap_callback api_load); + Js.Unsafe.set sx (Js.string "typeOf") (Js.wrap_callback api_type_of); + Js.Unsafe.set sx (Js.string "inspect") (Js.wrap_callback api_inspect); + Js.Unsafe.set sx (Js.string "engine") (Js.wrap_callback api_engine); + Js.Unsafe.set sx (Js.string "registerNative") (Js.wrap_callback api_register_native); + Js.Unsafe.set sx (Js.string "loadSource") (Js.wrap_callback api_load); + Js.Unsafe.set sx (Js.string "callFn") (wrap api_call_fn); + Js.Unsafe.set sx (Js.string "isCallable") (Js.wrap_callback api_is_callable); + Js.Unsafe.set sx (Js.string "fnArity") (Js.wrap_callback api_fn_arity); + + Js.Unsafe.set Js.Unsafe.global (Js.string "SxKernel") sx diff --git a/sx/sx/reactive-islands/demo.sx b/sx/sx/reactive-islands/demo.sx index fbb1effb..490adf8e 100644 --- a/sx/sx/reactive-islands/demo.sx +++ b/sx/sx/reactive-islands/demo.sx @@ -1,218 +1,35 @@ -;; --------------------------------------------------------------------------- -;; Examples — individual reactive island demo pages -;; --------------------------------------------------------------------------- +(defcomp ~reactive-islands/demo/reactive-islands-demo-content () (~docs/page :title "Reactive Islands — Examples" (~docs/section :title "Live interactive islands" :id "intro" (p (strong "Every example below is a live interactive island") " — not a static code snippet. Click the buttons, type in the inputs. The signal runtime is defined in " (code "signals.sx") ", bootstrapped to JavaScript. No hand-written signal logic.") (p "Each island uses " (code "defisland") " with signals (" (code "signal") ", " (code "deref") ", " (code "reset!") ", " (code "swap!") "), derived values (" (code "computed") "), side effects (" (code "effect") "), and batch updates (" (code "batch") ").")) (~docs/section :title "Examples" :id "examples" (ol :class "space-y-1" (map (fn (item) (li (a :href (get item "href") :sx-get (get item "href") :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-600 hover:underline" (get item "label")))) reactive-examples-nav-items))))) -;; Overview page — summary with links to individual examples -(defcomp ~reactive-islands/demo/reactive-islands-demo-content () - (~docs/page :title "Reactive Islands — Examples" - (~docs/section :title "Live interactive islands" :id "intro" - (p (strong "Every example below is a live interactive island") " — not a static code snippet. Click the buttons, type in the inputs. The signal runtime is defined in " (code "signals.sx") ", bootstrapped to JavaScript. No hand-written signal logic.") - (p "Each island uses " (code "defisland") " with signals (" (code "signal") ", " (code "deref") ", " (code "reset!") ", " (code "swap!") "), derived values (" (code "computed") "), side effects (" (code "effect") "), and batch updates (" (code "batch") ").")) - (~docs/section :title "Examples" :id "examples" - (ol :class "space-y-1" - (map (fn (item) - (li (a :href (get item "href") - :sx-get (get item "href") :sx-target "#main-panel" - :sx-select "#main-panel" :sx-swap "outerHTML" - :sx-push-url "true" - :class "text-violet-600 hover:underline" - (get item "label")))) - reactive-examples-nav-items))))) +(defcomp ~reactive-islands/demo/example-counter () (~docs/page :title "Signal + Computed + Effect" (p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.") (~reactive-islands/index/demo-counter :initial 0) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"−\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp")) (p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing."))) -;; --------------------------------------------------------------------------- -;; Individual example pages -;; --------------------------------------------------------------------------- +(defcomp ~reactive-islands/demo/example-temperature () (~docs/page :title "Temperature Converter" (p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.") (~reactive-islands/index/demo-temperature) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"−5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp")) (p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes.") (~reactive-islands/test-runner :test-src (str "(defsuite \"temperature converter\"\n" " (deftest \"initial celsius is 20\"\n" " (let ((celsius (signal 20)))\n" " (assert-signal-value celsius 20)))\n" " (deftest \"computed fahrenheit derives from celsius\"\n" " (let ((celsius (signal 20))\n" " (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))\n" " (assert-signal-value fahrenheit 68)\n" " (assert-computed-depends-on fahrenheit celsius)))\n" " (deftest \"+5 increments celsius\"\n" " (let ((celsius (signal 20))\n" " (btn (mock-element \"button\")))\n" " (mock-add-listener! btn \"click\"\n" " (fn (e) (swap! celsius (fn (c) (+ c 5)))))\n" " (simulate-click btn)\n" " (assert-signal-value celsius 25)))\n" " (deftest \"−5 decrements celsius\"\n" " (let ((celsius (signal 20))\n" " (btn (mock-element \"button\")))\n" " (mock-add-listener! btn \"click\"\n" " (fn (e) (swap! celsius (fn (c) (- c 5)))))\n" " (simulate-click btn)\n" " (assert-signal-value celsius 15)))\n" " (deftest \"fahrenheit updates on celsius change\"\n" " (let ((celsius (signal 20))\n" " (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))\n" " (reset! celsius 0)\n" " (assert-signal-value fahrenheit 32)\n" " (reset! celsius 100)\n" " (assert-signal-value fahrenheit 212)))\n" " (deftest \"multiple clicks accumulate\"\n" " (let ((celsius (signal 20))\n" " (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32))))\n" " (btn (mock-element \"button\")))\n" " (mock-add-listener! btn \"click\"\n" " (fn (e) (swap! celsius (fn (c) (+ c 5)))))\n" " (simulate-click btn)\n" " (simulate-click btn)\n" " (simulate-click btn)\n" " (assert-signal-value celsius 35)\n" " (assert-signal-value fahrenheit 95))))")))) -(defcomp ~reactive-islands/demo/example-counter () - (~docs/page :title "Signal + Computed + Effect" - (p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.") - (~reactive-islands/index/demo-counter :initial 0) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"−\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp")) - (p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing."))) +(defcomp ~reactive-islands/demo/example-stopwatch () (~docs/page :title "Effect + Cleanup: Stopwatch" (p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.") (~reactive-islands/index/demo-stopwatch) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp")) (p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order."))) -(defcomp ~reactive-islands/demo/example-temperature () - (~docs/page :title "Temperature Converter" - (p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.") - (~reactive-islands/index/demo-temperature) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"−5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp")) - (p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes."))) +(defcomp ~reactive-islands/demo/example-imperative () (~docs/page :title "Imperative Pattern" (p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".") (~reactive-islands/index/demo-imperative) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp")) (p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))) -(defcomp ~reactive-islands/demo/example-stopwatch () - (~docs/page :title "Effect + Cleanup: Stopwatch" - (p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.") - (~reactive-islands/index/demo-stopwatch) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp")) - (p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order."))) +(defcomp ~reactive-islands/demo/example-reactive-list () (~docs/page :title "Reactive List" (p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.") (~reactive-islands/index/demo-reactive-list) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp")) (p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass."))) -(defcomp ~reactive-islands/demo/example-imperative () - (~docs/page :title "Imperative Pattern" - (p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".") - (~reactive-islands/index/demo-imperative) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp")) - (p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))) +(defcomp ~reactive-islands/demo/example-input-binding () (~docs/page :title "Input Binding" (p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.") (~reactive-islands/index/demo-input-binding) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp")) (p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump."))) -(defcomp ~reactive-islands/demo/example-reactive-list () - (~docs/page :title "Reactive List" - (p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.") - (~reactive-islands/index/demo-reactive-list) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp")) - (p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass."))) +(defcomp ~reactive-islands/demo/example-portal () (~docs/page :title "Portals" (p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.") (~reactive-islands/index/demo-portal) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp")) (p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup."))) -(defcomp ~reactive-islands/demo/example-input-binding () - (~docs/page :title "Input Binding" - (p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.") - (~reactive-islands/index/demo-input-binding) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp")) - (p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump."))) +(defcomp ~reactive-islands/demo/example-error-boundary () (~docs/page :title "Error Boundaries" (p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.") (~reactive-islands/index/demo-error-boundary) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp")) (p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") "."))) -(defcomp ~reactive-islands/demo/example-portal () - (~docs/page :title "Portals" - (p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.") - (~reactive-islands/index/demo-portal) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp")) - (p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup."))) +(defcomp ~reactive-islands/demo/example-refs () (~docs/page :title "Refs — Imperative DOM Access" (p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.") (~reactive-islands/index/demo-refs) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp")) (p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") "."))) -(defcomp ~reactive-islands/demo/example-error-boundary () - (~docs/page :title "Error Boundaries" - (p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.") - (~reactive-islands/index/demo-error-boundary) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp")) - (p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") "."))) +(defcomp ~reactive-islands/demo/example-dynamic-class () (~docs/page :title "Dynamic Class and Style" (p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.") (~reactive-islands/index/demo-dynamic-class) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp")) (p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string."))) -(defcomp ~reactive-islands/demo/example-refs () - (~docs/page :title "Refs — Imperative DOM Access" - (p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.") - (~reactive-islands/index/demo-refs) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp")) - (p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") "."))) +(defcomp ~reactive-islands/demo/example-resource () (~docs/page :title "Resource + Suspense Pattern" (p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.") (~reactive-islands/index/demo-resource) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp")) (p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically."))) -(defcomp ~reactive-islands/demo/example-dynamic-class () - (~docs/page :title "Dynamic Class and Style" - (p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.") - (~reactive-islands/index/demo-dynamic-class) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp")) - (p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string."))) +(defcomp ~reactive-islands/demo/example-transition () (~docs/page :title "Transition Pattern" (p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.") (~reactive-islands/index/demo-transition) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp")) (p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations."))) -(defcomp ~reactive-islands/demo/example-resource () - (~docs/page :title "Resource + Suspense Pattern" - (p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.") - (~reactive-islands/index/demo-resource) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp")) - (p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically."))) +(defcomp ~reactive-islands/demo/example-stores () (~docs/page :title "Shared Stores" (p "React uses " (code "Context") " or state management libraries for cross-component state. SX uses " (code "def-store") " / " (code "use-store") " — named signal containers that persist across island creation/destruction.") (~reactive-islands/index/demo-store-writer) (~reactive-islands/index/demo-store-reader) (~docs/code :src (highlight ";; Island A — creates/writes the store\n(defisland ~reactive-islands/demo/store-writer ()\n (let ((store (def-store \"theme\" (fn ()\n (dict \"color\" (signal \"violet\")\n \"dark\" (signal false))))))\n (select :bind (get store \"color\")\n (option :value \"violet\" \"Violet\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"checkbox\" :bind (get store \"dark\"))))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/demo/store-reader ()\n (let ((store (use-store \"theme\")))\n (div :class (str \"bg-\" (deref (get store \"color\")) \"-100\")\n \"Styled by signals from Island A\")))" "lisp")) (p "React equivalent: " (code "createContext") " + " (code "useContext") " or Redux/Zustand. Stores are simpler — just named dicts of signals at page scope. " (code "def-store") " creates once, " (code "use-store") " retrieves. Stores survive island disposal but clear on full page navigation."))) -(defcomp ~reactive-islands/demo/example-transition () - (~docs/page :title "Transition Pattern" - (p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.") - (~reactive-islands/index/demo-transition) - (~docs/code :src (highlight "(defisland ~reactive-islands/demo/transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp")) - (p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations."))) +(defcomp ~reactive-islands/demo/example-event-bridge-demo () (~docs/page :title "Event Bridge" (p "Server-rendered content inside an island (an htmx \"lake\") can communicate with island signals via DOM custom events. Buttons with " (code "data-sx-emit") " dispatch events that island effects catch.") (~reactive-islands/index/demo-event-bridge) (~docs/code :src (highlight ";; Island listens for custom events from server-rendered content\n(defisland ~reactive-islands/demo/event-bridge ()\n (let ((messages (signal (list))))\n ;; Bridge: auto-listen for \"inbox:message\" events\n (bridge-event container \"inbox:message\" messages\n (fn (detail) (append (deref messages) (get detail \"text\"))))\n (div\n ;; Lake content (server-rendered) has data-sx-emit buttons\n (div :id \"lake\"\n :sx-get \"/my-content\"\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\")\n ;; Island reads the signal reactively\n (ul (map (fn (msg) (li msg)) (deref messages))))))" "lisp")) (p "The " (code "data-sx-emit") " attribute is processed by the client engine — it adds a click handler that dispatches a CustomEvent with the JSON from " (code "data-sx-emit-detail") ". The event bubbles up to the island container where " (code "bridge-event") " catches it."))) -(defcomp ~reactive-islands/demo/example-stores () - (~docs/page :title "Shared Stores" - (p "React uses " (code "Context") " or state management libraries for cross-component state. SX uses " (code "def-store") " / " (code "use-store") " — named signal containers that persist across island creation/destruction.") - (~reactive-islands/index/demo-store-writer) - (~reactive-islands/index/demo-store-reader) - (~docs/code :src (highlight ";; Island A — creates/writes the store\n(defisland ~reactive-islands/demo/store-writer ()\n (let ((store (def-store \"theme\" (fn ()\n (dict \"color\" (signal \"violet\")\n \"dark\" (signal false))))))\n (select :bind (get store \"color\")\n (option :value \"violet\" \"Violet\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"checkbox\" :bind (get store \"dark\"))))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/demo/store-reader ()\n (let ((store (use-store \"theme\")))\n (div :class (str \"bg-\" (deref (get store \"color\")) \"-100\")\n \"Styled by signals from Island A\")))" "lisp")) - (p "React equivalent: " (code "createContext") " + " (code "useContext") " or Redux/Zustand. Stores are simpler — just named dicts of signals at page scope. " (code "def-store") " creates once, " (code "use-store") " retrieves. Stores survive island disposal but clear on full page navigation."))) +(defcomp ~reactive-islands/demo/example-defisland () (~docs/page :title "How defisland Works" (p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.") (~docs/code :src (highlight ";; Definition — same syntax as defcomp\n(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~reactive-islands/demo/counter :initial 42)\n\n;; Server-side rendering:\n;;
\n;; 42\n;;
\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp")) (p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders."))) -(defcomp ~reactive-islands/demo/example-event-bridge-demo () - (~docs/page :title "Event Bridge" - (p "Server-rendered content inside an island (an htmx \"lake\") can communicate with island signals via DOM custom events. Buttons with " (code "data-sx-emit") " dispatch events that island effects catch.") - (~reactive-islands/index/demo-event-bridge) - (~docs/code :src (highlight ";; Island listens for custom events from server-rendered content\n(defisland ~reactive-islands/demo/event-bridge ()\n (let ((messages (signal (list))))\n ;; Bridge: auto-listen for \"inbox:message\" events\n (bridge-event container \"inbox:message\" messages\n (fn (detail) (append (deref messages) (get detail \"text\"))))\n (div\n ;; Lake content (server-rendered) has data-sx-emit buttons\n (div :id \"lake\"\n :sx-get \"/my-content\"\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\")\n ;; Island reads the signal reactively\n (ul (map (fn (msg) (li msg)) (deref messages))))))" "lisp")) - (p "The " (code "data-sx-emit") " attribute is processed by the client engine — it adds a click handler that dispatches a CustomEvent with the JSON from " (code "data-sx-emit-detail") ". The event bubbles up to the island container where " (code "bridge-event") " catches it."))) +(defcomp ~reactive-islands/demo/example-tests () (~docs/page :title "Test Suite" (p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).") (~docs/code :src (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp")) (p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals")))) -(defcomp ~reactive-islands/demo/example-defisland () - (~docs/page :title "How defisland Works" - (p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.") - (~docs/code :src (highlight ";; Definition — same syntax as defcomp\n(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~reactive-islands/demo/counter :initial 42)\n\n;; Server-side rendering:\n;;
\n;; 42\n;;
\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp")) - (p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders."))) - -(defcomp ~reactive-islands/demo/example-tests () - (~docs/page :title "Test Suite" - (p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).") - (~docs/code :src (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp")) - (p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals")))) - -(defcomp ~reactive-islands/demo/example-coverage () - (~docs/page :title "React Feature Coverage" - (p "Every React feature has an SX equivalent — most are simpler because signals are fine-grained.") - (div :class "overflow-x-auto rounded border border-stone-200" - (table :class "w-full text-left text-sm" - (thead (tr :class "border-b border-stone-200 bg-stone-100" - (th :class "px-3 py-2 font-medium text-stone-600" "React") - (th :class "px-3 py-2 font-medium text-stone-600" "SX") - (th :class "px-3 py-2 font-medium text-stone-600" "Demo"))) - (tbody - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "useState") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(signal value)") - (td :class "px-3 py-2 text-xs text-stone-500" "#1")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "useMemo") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(computed (fn () ...))") - (td :class "px-3 py-2 text-xs text-stone-500" "#1, #2")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "useEffect") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(effect (fn () ...))") - (td :class "px-3 py-2 text-xs text-stone-500" "#3")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "useRef") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(dict \"current\" nil) + :ref") - (td :class "px-3 py-2 text-xs text-stone-500" "#9")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "useCallback") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(fn (...) ...) — no dep arrays") - (td :class "px-3 py-2 text-xs text-stone-500" "N/A")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "className / style") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" ":class (str ...) :style (str ...)") - (td :class "px-3 py-2 text-xs text-stone-500" "#10")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Controlled inputs") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" ":bind signal") - (td :class "px-3 py-2 text-xs text-stone-500" "#6")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "key prop") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" ":key value") - (td :class "px-3 py-2 text-xs text-stone-500" "#5")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "createPortal") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(portal \"#target\" ...)") - (td :class "px-3 py-2 text-xs text-stone-500" "#7")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "ErrorBoundary") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(error-boundary fallback ...)") - (td :class "px-3 py-2 text-xs text-stone-500" "#8")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Suspense + use()") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(resource fn) + cond/deref") - (td :class "px-3 py-2 text-xs text-stone-500" "#11")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "startTransition") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "schedule-idle + batch") - (td :class "px-3 py-2 text-xs text-stone-500" "#12")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Context / Redux") - (td :class "px-3 py-2 font-mono text-xs text-violet-700" "def-store / use-store") - (td :class "px-3 py-2 text-xs text-stone-500" "#13")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Virtual DOM / diffing") - (td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained signals update exact DOM nodes") - (td :class "px-3 py-2 text-xs text-stone-500" "")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "JSX / build step") - (td :class "px-3 py-2 text-xs text-stone-500" "N/A — s-expressions are the syntax") - (td :class "px-3 py-2 text-xs text-stone-500" "")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Server Components") - (td :class "px-3 py-2 text-xs text-stone-500" "N/A — aser mode already expands server-side") - (td :class "px-3 py-2 text-xs text-stone-500" "")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Concurrent rendering") - (td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained updates are inherently incremental") - (td :class "px-3 py-2 text-xs text-stone-500" "")) - (tr - (td :class "px-3 py-2 text-stone-700" "Hooks rules") - (td :class "px-3 py-2 text-xs text-stone-500" "N/A — signals are values, no ordering rules") - (td :class "px-3 py-2 text-xs text-stone-500" ""))))))) +(defcomp ~reactive-islands/demo/example-coverage () (~docs/page :title "React Feature Coverage" (p "Every React feature has an SX equivalent — most are simpler because signals are fine-grained.") (div :class "overflow-x-auto rounded border border-stone-200" (table :class "w-full text-left text-sm" (thead (tr :class "border-b border-stone-200 bg-stone-100" (th :class "px-3 py-2 font-medium text-stone-600" "React") (th :class "px-3 py-2 font-medium text-stone-600" "SX") (th :class "px-3 py-2 font-medium text-stone-600" "Demo"))) (tbody (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "useState") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(signal value)") (td :class "px-3 py-2 text-xs text-stone-500" "#1")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "useMemo") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(computed (fn () ...))") (td :class "px-3 py-2 text-xs text-stone-500" "#1, #2")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "useEffect") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(effect (fn () ...))") (td :class "px-3 py-2 text-xs text-stone-500" "#3")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "useRef") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(dict \"current\" nil) + :ref") (td :class "px-3 py-2 text-xs text-stone-500" "#9")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "useCallback") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(fn (...) ...) — no dep arrays") (td :class "px-3 py-2 text-xs text-stone-500" "N/A")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "className / style") (td :class "px-3 py-2 font-mono text-xs text-violet-700" ":class (str ...) :style (str ...)") (td :class "px-3 py-2 text-xs text-stone-500" "#10")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Controlled inputs") (td :class "px-3 py-2 font-mono text-xs text-violet-700" ":bind signal") (td :class "px-3 py-2 text-xs text-stone-500" "#6")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "key prop") (td :class "px-3 py-2 font-mono text-xs text-violet-700" ":key value") (td :class "px-3 py-2 text-xs text-stone-500" "#5")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "createPortal") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(portal \"#target\" ...)") (td :class "px-3 py-2 text-xs text-stone-500" "#7")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "ErrorBoundary") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(error-boundary fallback ...)") (td :class "px-3 py-2 text-xs text-stone-500" "#8")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Suspense + use()") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "(resource fn) + cond/deref") (td :class "px-3 py-2 text-xs text-stone-500" "#11")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "startTransition") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "schedule-idle + batch") (td :class "px-3 py-2 text-xs text-stone-500" "#12")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Context / Redux") (td :class "px-3 py-2 font-mono text-xs text-violet-700" "def-store / use-store") (td :class "px-3 py-2 text-xs text-stone-500" "#13")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Virtual DOM / diffing") (td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained signals update exact DOM nodes") (td :class "px-3 py-2 text-xs text-stone-500" "")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "JSX / build step") (td :class "px-3 py-2 text-xs text-stone-500" "N/A — s-expressions are the syntax") (td :class "px-3 py-2 text-xs text-stone-500" "")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Server Components") (td :class "px-3 py-2 text-xs text-stone-500" "N/A — aser mode already expands server-side") (td :class "px-3 py-2 text-xs text-stone-500" "")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Concurrent rendering") (td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained updates are inherently incremental") (td :class "px-3 py-2 text-xs text-stone-500" "")) (tr (td :class "px-3 py-2 text-stone-700" "Hooks rules") (td :class "px-3 py-2 text-xs text-stone-500" "N/A — signals are values, no ordering rules") (td :class "px-3 py-2 text-xs text-stone-500" ""))))))) diff --git a/sx/sx/reactive-islands/test-runner.sx b/sx/sx/reactive-islands/test-runner.sx new file mode 100644 index 00000000..f404a950 --- /dev/null +++ b/sx/sx/reactive-islands/test-runner.sx @@ -0,0 +1,76 @@ +;; ~reactive-islands/test-runner — inline test runner island +;; +;; Displays test results for a test suite. Runs tests on mount and +;; shows pass/fail with details. + +(defisland ~reactive-islands/test-runner (&key test-src) + (let ((results (signal nil)) + (running (signal false))) + + (letrec + ((run-tests (fn () + (reset! running true) + (let ((parsed (sx-parse test-src)) + (test-results (list))) + ;; Walk parsed expressions looking for deftest/defsuite + (for-each (fn (expr) + (when (and (list? expr) (not (empty? expr)) + (= (type-of (first expr)) "symbol")) + (let ((head (symbol-name (first expr)))) + (cond + (= head "defsuite") + ;; Process each deftest in the suite + (for-each (fn (child) + (when (and (list? child) (not (empty? child)) + (= (type-of (first child)) "symbol") + (= (symbol-name (first child)) "deftest")) + (let ((test-name (nth child 1)) + (test-body (last child))) + (let ((result (try-test test-name test-body))) + (append! test-results result))))) + (slice expr 2)) + (= head "deftest") + (let ((test-name (nth expr 1)) + (test-body (last expr))) + (append! test-results (try-test test-name test-body))))))) + parsed) + (reset! results test-results) + (reset! running false)))) + + (try-test (fn (name body) + (let ((error-msg nil)) + ;; Evaluate the test body, catch assertion failures + (let ((ok (cek-try + (fn () (cek-eval (sx-serialize body)) true) + (fn (err) (set! error-msg (str err)) false)))) + {:name name :pass ok :error error-msg}))))) + + ;; Run on mount + (run-tests) + + (div :class "mt-6 rounded border border-stone-200 bg-stone-50 p-4" + (div :class "flex items-center justify-between mb-3" + (h4 :class "text-sm font-semibold text-stone-700" "Tests") + (button :class "px-2 py-1 text-xs rounded bg-stone-200 hover:bg-stone-300" + :on-click (fn (e) (run-tests)) + "Re-run")) + + (if (deref running) + (p :class "text-stone-400 text-sm italic" "Running...") + (if (nil? (deref results)) + (p :class "text-stone-400 text-sm italic" "No results") + (let ((r (deref results)) + (pass-count (len (filter (fn (t) (get t "pass")) r))) + (fail-count (len (filter (fn (t) (not (get t "pass"))) r)))) + (div :class "space-y-2" + (div :class "text-sm font-mono" + (span :class (if (= fail-count 0) "text-emerald-600" "text-red-600") + (str pass-count "/" (len r) " passed"))) + (map (fn (t) + (div :class "flex items-start gap-2 text-xs font-mono py-0.5" + (span :class (if (get t "pass") "text-emerald-500" "text-red-500") + (if (get t "pass") "✓" "✗")) + (span :class "text-stone-600" (get t "name")) + (when (get t "error") + (span :class "text-red-400 ml-2" (get t "error"))))) + r))))))))) diff --git a/sx/sx/reactive-islands/test-temperature.sx b/sx/sx/reactive-islands/test-temperature.sx new file mode 100644 index 00000000..d1f2a247 --- /dev/null +++ b/sx/sx/reactive-islands/test-temperature.sx @@ -0,0 +1,76 @@ +;; Tests for ~reactive-islands/index/demo-temperature +;; +;; Tests the reactive logic: signal creation, computed derivation, +;; button click handlers, and value propagation. + +(defsuite "temperature converter" + + (deftest "initial celsius is 20" + (let ((celsius (signal 20))) + (assert-signal-value celsius 20))) + + (deftest "computed fahrenheit derives from celsius" + (let ((celsius (signal 20)) + (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32))))) + (assert-signal-value fahrenheit 68) + (assert-computed-depends-on fahrenheit celsius))) + + (deftest "+5 button increments celsius by 5" + (let ((celsius (signal 20)) + (btn (mock-element "button"))) + (mock-add-listener! btn "click" + (fn (e) (swap! celsius (fn (c) (+ c 5))))) + (simulate-click btn) + (assert-signal-value celsius 25))) + + (deftest "−5 button decrements celsius by 5" + (let ((celsius (signal 20)) + (btn (mock-element "button"))) + (mock-add-listener! btn "click" + (fn (e) (swap! celsius (fn (c) (- c 5))))) + (simulate-click btn) + (assert-signal-value celsius 15))) + + (deftest "fahrenheit updates when celsius changes" + (let ((celsius (signal 20)) + (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32))))) + (assert-signal-value fahrenheit 68) + (reset! celsius 25) + (assert-signal-value fahrenheit 77) + (reset! celsius 0) + (assert-signal-value fahrenheit 32) + (reset! celsius 100) + (assert-signal-value fahrenheit 212))) + + (deftest "multiple clicks accumulate" + (let ((celsius (signal 20)) + (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))) + (plus-btn (mock-element "button")) + (minus-btn (mock-element "button"))) + (mock-add-listener! plus-btn "click" + (fn (e) (swap! celsius (fn (c) (+ c 5))))) + (mock-add-listener! minus-btn "click" + (fn (e) (swap! celsius (fn (c) (- c 5))))) + ;; +5 +5 +5 + (simulate-click plus-btn) + (simulate-click plus-btn) + (simulate-click plus-btn) + (assert-signal-value celsius 35) + (assert-signal-value fahrenheit 95) + ;; -5 + (simulate-click minus-btn) + (assert-signal-value celsius 30) + (assert-signal-value fahrenheit 86))) + + (deftest "celsius signal has subscribers after computed" + (let ((celsius (signal 20)) + (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32))))) + (assert-signal-has-subscribers celsius))) + + (deftest "click events are logged on mock element" + (let ((btn (mock-element "button"))) + (mock-add-listener! btn "click" (fn (e) nil)) + (simulate-click btn) + (simulate-click btn) + (assert-event-fired btn "click") + (assert= (event-fire-count btn "click") 2 "Expected 2 click events"))))