OCaml evaluator for page dispatch + handler aser, 83/83 Playwright tests

Major architectural change: page function dispatch and handler execution
now go through the OCaml kernel instead of the Python bootstrapped evaluator.

OCaml integration:
- Page dispatch: bridge.eval() evaluates SX URL expressions (geography, marshes, etc.)
- Handler aser: bridge.aser() serializes handler responses as SX wire format
- _ensure_components loads all .sx files into OCaml kernel (spec, web adapter, handlers)
- defhandler/defpage registered as no-op special forms so handler files load
- helper IO primitive dispatches to Python page helpers + IO handlers
- ok-raw response format for SX wire format (no double-escaping)
- Natural list serialization in eval (no (list ...) wrapper)
- Clean pipe: _read_until_ok always sends io-response on error

SX adapter (aser):
- scope-emit!/scope-peek aliases to avoid CEK special form conflict
- aser-fragment/aser-call: strings starting with "(" pass through unserialized
- Registered cond-scheme?, is-else-clause?, primitive?, get-primitive in kernel
- random-int, parse-int as kernel primitives; json-encode, into via IO bridge

Handler migration:
- All IO calls converted to (helper "name" args...) pattern
- request-arg, request-form, state-get, state-set!, now, component-source etc.
- Fixed bare (effect ...) in island bodies leaking disposer functions as text
- Fixed lower-case → lower, ~search-results → ~examples/search-results

Reactive islands:
- sx-hydrate-islands called after client-side navigation swap
- force-dispose-islands-in for outerHTML swaps (clears hydration markers)
- clear-processed! platform primitive for re-hydration

Content restructuring:
- Design, event bridge, named stores, phase 2 consolidated into reactive overview
- Marshes split into overview + 5 example sub-pages
- Nav links use sx-get/sx-target for client-side navigation

Playwright test suite (sx/tests/test_demos.py):
- 83 tests covering hypermedia demos, reactive islands, marshes, spec explorer
- Server-side rendering, handler interactions, island hydration, navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 17:22:51 +00:00
parent 5b6e883e6d
commit 71c2003a60
33 changed files with 1848 additions and 852 deletions

View File

@@ -28,6 +28,9 @@ services:
- ./sx/sx:/app/sx - ./sx/sx:/app/sx
- ./sx/path_setup.py:/app/path_setup.py - ./sx/path_setup.py:/app/path_setup.py
- ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh - ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh
# Spec + web SX files (loaded by OCaml kernel for aser, parser, render)
- ./spec:/app/spec:ro
- ./web:/app/web:ro
# OCaml SX kernel binary (built with: cd hosts/ocaml && eval $(opam env) && dune build) # OCaml SX kernel binary (built with: cd hosts/ocaml && eval $(opam env) && dune build)
- ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro - ./hosts/ocaml/_build/default/bin/sx_server.exe:/app/bin/sx_server:ro
- ./sx/__init__.py:/app/__init__.py:ro - ./sx/__init__.py:/app/__init__.py:ro

View File

@@ -1129,6 +1129,9 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["context"] = sxContext; PRIMITIVES["context"] = sxContext;
PRIMITIVES["emit!"] = sxEmit; PRIMITIVES["emit!"] = sxEmit;
PRIMITIVES["emitted"] = sxEmitted; PRIMITIVES["emitted"] = sxEmitted;
// Aliases for aser adapter (avoids CEK special form conflict on server)
PRIMITIVES["scope-emit!"] = sxEmit;
PRIMITIVES["scope-peek"] = sxEmitted;
''', ''',
} }
# Modules to include by default (all) # Modules to include by default (all)
@@ -2890,6 +2893,7 @@ PLATFORM_ORCHESTRATION_JS = """
function markProcessed(el, key) { el[PROCESSED + key] = true; } function markProcessed(el, key) { el[PROCESSED + key] = true; }
function isProcessed(el, key) { return !!el[PROCESSED + key]; } function isProcessed(el, key) { return !!el[PROCESSED + key]; }
function clearProcessed(el, key) { delete el[PROCESSED + key]; }
// --- Script cloning --- // --- Script cloning ---

View File

@@ -420,6 +420,7 @@
"bind-preload" "bindPreload" "bind-preload" "bindPreload"
"mark-processed!" "markProcessed" "mark-processed!" "markProcessed"
"is-processed?" "isProcessed" "is-processed?" "isProcessed"
"clear-processed!" "clearProcessed"
"create-script-clone" "createScriptClone" "create-script-clone" "createScriptClone"
"sx-render" "sxRender" "sx-render" "sxRender"
"sx-process-scripts" "sxProcessScripts" "sx-process-scripts" "sxProcessScripts"

View File

@@ -118,7 +118,10 @@ let setup_io_env env =
bind "request-arg" (fun args -> bind "request-arg" (fun args ->
match args with match args with
| [name] -> io_request "request-arg" [name] | [name] -> io_request "request-arg" [name]
| _ -> raise (Eval_error "request-arg: expected 1 arg")); | [name; default] ->
let result = io_request "request-arg" [name] in
if result = Nil then default else result
| _ -> raise (Eval_error "request-arg: expected 1-2 args"));
bind "request-method" (fun _args -> bind "request-method" (fun _args ->
io_request "request-method" []); io_request "request-method" []);
@@ -126,7 +129,11 @@ let setup_io_env env =
bind "ctx" (fun args -> bind "ctx" (fun args ->
match args with match args with
| [key] -> io_request "ctx" [key] | [key] -> io_request "ctx" [key]
| _ -> raise (Eval_error "ctx: expected 1 arg")) | _ -> raise (Eval_error "ctx: expected 1 arg"));
(* Generic helper call — dispatches to Python page helpers *)
bind "helper" (fun args ->
io_request "helper" args)
(* ====================================================================== *) (* ====================================================================== *)
@@ -165,6 +172,82 @@ let make_server_env () =
(* HTML renderer *) (* HTML renderer *)
Sx_render.setup_render_env env; Sx_render.setup_render_env env;
(* Render-mode flags *)
bind "set-render-active!" (fun _args -> Nil);
bind "render-active?" (fun _args -> Bool true);
(* Scope stack — platform primitives for render-time dynamic scope.
Used by aser for spread/provide/emit patterns. *)
let scope_stacks : (string, value list) Hashtbl.t = Hashtbl.create 8 in
bind "scope-push!" (fun args ->
match args with
| [String name; value] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
Hashtbl.replace scope_stacks name (value :: stack); Nil
| _ -> Nil);
bind "scope-pop!" (fun args ->
match args with
| [String name] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
(match stack with
| _ :: rest -> Hashtbl.replace scope_stacks name rest
| [] -> ()); Nil
| _ -> Nil);
bind "scope-peek" (fun args ->
match args with
| [String name] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
(match stack with v :: _ -> v | [] -> Nil)
| _ -> Nil);
(* scope-emit! / scope-peek — Hashtbl-based scope primitives for aser.
Different names from emit!/emitted to avoid CEK special form conflict. *)
bind "scope-emit!" (fun args ->
match args with
| [String name; value] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
(match stack with
| List items :: rest ->
Hashtbl.replace scope_stacks name (List (items @ [value]) :: rest)
| Nil :: rest ->
Hashtbl.replace scope_stacks name (List [value] :: rest)
| _ :: _ -> ()
| [] -> ()); Nil
| _ -> Nil);
(* Evaluator bridge — aser calls these spec functions.
Route to the OCaml CEK machine. *)
bind "eval-expr" (fun args ->
match args with
| [expr; Env e] -> Sx_ref.eval_expr expr (Env e)
| [expr] -> Sx_ref.eval_expr expr (Env env)
| _ -> raise (Eval_error "eval-expr: expected (expr env?)"));
bind "trampoline" (fun args ->
match args with
| [v] -> v (* CEK never produces thunks *)
| _ -> raise (Eval_error "trampoline: expected 1 arg"));
bind "call-lambda" (fun args ->
match args with
| [fn_val; List call_args; Env e] ->
Sx_ref.eval_expr (List (fn_val :: call_args)) (Env e)
| [fn_val; List call_args] ->
Sx_ref.eval_expr (List (fn_val :: call_args)) (Env env)
| _ -> raise (Eval_error "call-lambda: expected (fn args env?)"));
bind "expand-macro" (fun args ->
match args with
| [Macro m; List macro_args; Env e] ->
let body_env = { bindings = Hashtbl.create 16; parent = Some e } in
List.iteri (fun i p ->
let v = if i < List.length macro_args then List.nth macro_args i else Nil in
Hashtbl.replace body_env.bindings p v
) m.m_params;
Sx_ref.eval_expr m.m_body (Env body_env)
| _ -> raise (Eval_error "expand-macro: expected (macro args env)"));
(* Register <> as a special form — evaluates all children, returns list *)
ignore (Sx_ref.register_special_form (String "<>") (NativeFn ("<>", fun args ->
List (List.map (fun a -> Sx_ref.eval_expr a (Env env)) args))));
(* Missing primitives that may be referenced *) (* Missing primitives that may be referenced *)
bind "upcase" (fun args -> bind "upcase" (fun args ->
match args with match args with
@@ -181,6 +264,109 @@ let make_server_env () =
| [String s] -> Keyword s | [String s] -> Keyword s
| _ -> raise (Eval_error "make-keyword: expected string")); | _ -> raise (Eval_error "make-keyword: expected string"));
(* Type predicates and accessors — platform interface for aser *)
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 "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));
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 "spread?" (fun _args -> Bool false);
bind "spread-attrs" (fun _args -> Dict (Hashtbl.create 0));
bind "is-html-tag?" (fun args ->
match args with
| [String s] -> Bool (Sx_render.is_html_tag s)
| _ -> Bool false);
(* Spec evaluator helpers needed by render.sx when loaded at runtime *)
bind "random-int" (fun args ->
match args with
| [Number lo; Number hi] ->
let lo = int_of_float lo and hi = int_of_float hi in
Number (float_of_int (lo + Random.int (max 1 (hi - lo + 1))))
| _ -> raise (Eval_error "random-int: expected (low high)"));
bind "parse-int" (fun args ->
match args with
| [String s] -> (try Number (float_of_int (int_of_string s)) with _ -> Nil)
| [Number n] -> Number (Float.round n)
| _ -> Nil);
bind "json-encode" (fun args -> io_request "helper" (String "json-encode" :: args));
bind "into" (fun args -> io_request "helper" (String "into" :: args));
bind "sleep" (fun args -> io_request "sleep" args);
bind "set-response-status" (fun args -> io_request "set-response-status" args);
bind "set-response-header" (fun args -> io_request "set-response-header" args);
(* Application constructs — no-ops in the kernel, but needed so
handler/page files load successfully (their define forms get evaluated) *)
ignore (Sx_ref.register_special_form (String "defhandler") (NativeFn ("defhandler", fun _args -> Nil)));
ignore (Sx_ref.register_special_form (String "defpage") (NativeFn ("defpage", fun _args -> Nil)));
bind "cond-scheme?" (fun args ->
match args with
| [clauses] -> Sx_ref.cond_scheme_p clauses
| _ -> Bool false);
bind "is-else-clause?" (fun args ->
match args with
| [test] -> Sx_ref.is_else_clause test
| _ -> Bool false);
bind "primitive?" (fun args ->
match args with
| [String name] ->
(* Check if name is bound in the env as a NativeFn *)
(try match env_get env name with NativeFn _ -> Bool true | _ -> Bool false
with _ -> Bool false)
| _ -> Bool false);
bind "get-primitive" (fun args ->
match args with
| [String name] ->
(try env_get env name with _ -> Nil)
| _ -> Nil);
bind "escape-string" (fun args ->
match args with
| [String s] ->
let buf = Buffer.create (String.length s) in
String.iter (fun c -> match c with
| '"' -> Buffer.add_string buf "\\\""
| '\\' -> Buffer.add_string buf "\\\\"
| '\n' -> Buffer.add_string buf "\\n"
| '\r' -> Buffer.add_string buf "\\r"
| '\t' -> Buffer.add_string buf "\\t"
| c -> Buffer.add_char buf c) s;
String (Buffer.contents buf)
| _ -> raise (Eval_error "escape-string: expected string"));
bind "string-length" (fun args -> bind "string-length" (fun args ->
match args with match args with
| [String s] -> Number (float_of_int (String.length s)) | [String s] -> Number (float_of_int (String.length s))
@@ -370,7 +556,56 @@ let dispatch env cmd =
let result = List.fold_left (fun _acc expr -> let result = List.fold_left (fun _acc expr ->
Sx_ref.eval_expr expr (Env env) Sx_ref.eval_expr expr (Env env)
) Nil exprs in ) Nil exprs in
send_ok_value result (* Use ok-raw with natural list serialization — no (list ...) wrapping.
This preserves the SX structure for Python to parse back. *)
let rec raw_serialize = function
| Nil -> "nil"
| Bool true -> "true"
| Bool false -> "false"
| Number n ->
if Float.is_integer n then string_of_int (int_of_float n)
else Printf.sprintf "%g" n
| String s -> "\"" ^ escape_sx_string s ^ "\""
| Symbol s -> s
| Keyword k -> ":" ^ k
| List items | ListRef { contents = items } ->
"(" ^ String.concat " " (List.map raw_serialize items) ^ ")"
| Dict d ->
let pairs = Hashtbl.fold (fun k v acc ->
(Printf.sprintf ":%s %s" k (raw_serialize v)) :: acc) d [] in
"{" ^ String.concat " " pairs ^ "}"
| Component c -> "~" ^ c.c_name
| Island i -> "~" ^ i.i_name
| SxExpr s -> s
| RawHTML s -> "\"" ^ escape_sx_string s ^ "\""
| _ -> "nil"
in
send (Printf.sprintf "(ok-raw %s)" (raw_serialize result))
with
| Eval_error msg -> send_error msg
| exn -> send_error (Printexc.to_string exn))
| List [Symbol "aser"; String src] ->
(* Evaluate and serialize as SX wire format.
Calls the SX-defined aser function from adapter-sx.sx.
aser is loaded into the kernel env via _ensure_components. *)
(try
let exprs = Sx_parser.parse_all src in
let expr = match exprs with
| [e] -> e
| [] -> Nil
| _ -> List (Symbol "<>" :: exprs)
in
(* Call (aser <quoted-expr> <env>) *)
let call = List [Symbol "aser";
List [Symbol "quote"; expr];
Env env] in
let result = Sx_ref.eval_expr call (Env env) in
(* Send raw SX wire format without re-escaping.
Use (ok-raw ...) so Python knows not to unescape. *)
(match result with
| String s | SxExpr s -> send (Printf.sprintf "(ok-raw %s)" s)
| _ -> send_ok_value result)
with with
| Eval_error msg -> send_error msg | Eval_error msg -> send_error msg
| exn -> send_error (Printexc.to_string exn)) | exn -> send_error (Printexc.to_string exn))

View File

@@ -14,7 +14,7 @@
// ========================================================================= // =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-17T17:31:51Z"; var SX_VERSION = "2026-03-18T13:07:01Z";
function isNil(x) { return x === NIL || x === null || x === undefined; } function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); } function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -509,6 +509,9 @@
PRIMITIVES["context"] = sxContext; PRIMITIVES["context"] = sxContext;
PRIMITIVES["emit!"] = sxEmit; PRIMITIVES["emit!"] = sxEmit;
PRIMITIVES["emitted"] = sxEmitted; PRIMITIVES["emitted"] = sxEmitted;
// Aliases for aser adapter (avoids CEK special form conflict on server)
PRIMITIVES["scope-emit!"] = sxEmit;
PRIMITIVES["scope-peek"] = sxEmitted;
function isPrimitive(name) { return name in PRIMITIVES; } function isPrimitive(name) { return name in PRIMITIVES; }
@@ -2659,8 +2662,8 @@ return (function() {
var result = (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { var result = (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() {
var name = symbolName(expr); var name = symbolName(expr);
return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name))))))));
})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return (sxEmit("element-attrs", spreadAttrs(expr)), NIL); return expr; })(); })(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return (scopeEmit_b("element-attrs", spreadAttrs(expr)), NIL); return expr; })();
return (isSxTruthy(isSpread(result)) ? (sxEmit("element-attrs", spreadAttrs(result)), NIL) : result); return (isSxTruthy(isSpread(result)) ? (scopeEmit_b("element-attrs", spreadAttrs(result)), NIL) : result);
})(); }; })(); };
PRIMITIVES["aser"] = aser; PRIMITIVES["aser"] = aser;
@@ -2684,9 +2687,9 @@ PRIMITIVES["aser-list"] = aserList;
var parts = []; var parts = [];
{ var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() { { var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() {
var result = aser(c, env); var result = aser(c, env);
return (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(parts, serialize(item)) : NIL); }, result) : (isSxTruthy(!isSxTruthy(isNil(result))) ? append_b(parts, serialize(result)) : NIL)); return (isSxTruthy(isNil(result)) ? NIL : (isSxTruthy((isSxTruthy((typeOf(result) == "string")) && isSxTruthy((stringLength(result) > 0)) && startsWith(result, "("))) ? append_b(parts, result) : (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((isSxTruthy((typeOf(item) == "string")) && isSxTruthy((stringLength(item) > 0)) && startsWith(item, "("))) ? append_b(parts, item) : append_b(parts, serialize(item))) : NIL); }, result) : append_b(parts, serialize(result)))));
})(); } } })(); } }
return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", parts)) + String(")"))); return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? first(parts) : (String("(<> ") + String(join(" ", parts)) + String(")"))));
})(); }; })(); };
PRIMITIVES["aser-fragment"] = aserFragment; PRIMITIVES["aser-fragment"] = aserFragment;
@@ -2708,11 +2711,11 @@ PRIMITIVES["aser-fragment"] = aserFragment;
})() : (function() { })() : (function() {
var val = aser(arg, env); var val = aser(arg, env);
if (isSxTruthy(!isSxTruthy(isNil(val)))) { if (isSxTruthy(!isSxTruthy(isNil(val)))) {
(isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? append_b(childParts, serialize(item)) : NIL); }, val) : append_b(childParts, serialize(val))); (isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(childParts, val) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((isSxTruthy((typeOf(item) == "string")) && isSxTruthy((stringLength(item) > 0)) && startsWith(item, "("))) ? append_b(childParts, item) : append_b(childParts, serialize(item))) : NIL); }, val) : append_b(childParts, serialize(val))));
} }
return (i = (i + 1)); return (i = (i + 1));
})())); } } })())); } }
{ var _c = sxEmitted("element-attrs"); for (var _i = 0; _i < _c.length; _i++) { var spreadDict = _c[_i]; { var _c = keys(spreadDict); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; (function() { { var _c = scopePeek("element-attrs"); for (var _i = 0; _i < _c.length; _i++) { var spreadDict = _c[_i]; { var _c = keys(spreadDict); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; (function() {
var v = dictGet(spreadDict, k); var v = dictGet(spreadDict, k);
attrParts.push((String(":") + String(k))); attrParts.push((String(":") + String(k)));
return append_b(attrParts, serialize(v)); return append_b(attrParts, serialize(v));
@@ -3975,7 +3978,7 @@ return processElements(t); });
return (function() { return (function() {
var selectSel = domGetAttr(el, "sx-select"); var selectSel = domGetAttr(el, "sx-select");
var content = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); var content = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container));
disposeIslandsIn(target); (isSxTruthy((swapStyle == "outerHTML")) ? forceDisposeIslandsIn(target) : disposeIslandsIn(target));
return withTransition(useTransition, function() { swapDomNodes(target, content, swapStyle); return withTransition(useTransition, function() { swapDomNodes(target, content, swapStyle);
return postSwap(target); }); return postSwap(target); });
})(); })();
@@ -4062,7 +4065,8 @@ PRIMITIVES["bind-triggers"] = bindTriggers;
PRIMITIVES["bind-event"] = bindEvent; PRIMITIVES["bind-event"] = bindEvent;
// post-swap // post-swap
var postSwap = function(root) { activateScripts(root); var postSwap = function(root) { logInfo((String("post-swap: root=") + String((isSxTruthy(root) ? domTagName(root) : "nil"))));
activateScripts(root);
sxProcessScripts(root); sxProcessScripts(root);
sxHydrate(root); sxHydrate(root);
sxHydrateIslands(root); sxHydrateIslands(root);
@@ -4331,7 +4335,7 @@ PRIMITIVES["offline-aware-mutation"] = offlineAwareMutation;
PRIMITIVES["current-page-layout"] = currentPageLayout; PRIMITIVES["current-page-layout"] = currentPageLayout;
// swap-rendered-content // swap-rendered-content
var swapRenderedContent = function(target, rendered, pathname) { return (disposeIslandsIn(target), domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), runPostRenderHooks(), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; var swapRenderedContent = function(target, rendered, pathname) { return (disposeIslandsIn(target), domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), sxHydrateIslands(target), runPostRenderHooks(), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); };
PRIMITIVES["swap-rendered-content"] = swapRenderedContent; PRIMITIVES["swap-rendered-content"] = swapRenderedContent;
// resolve-route-target // resolve-route-target
@@ -4696,7 +4700,8 @@ PRIMITIVES["process-page-scripts"] = processPageScripts;
// sx-hydrate-islands // sx-hydrate-islands
var sxHydrateIslands = function(root) { return (function() { var sxHydrateIslands = function(root) { return (function() {
var els = domQueryAll(sxOr(root, domBody()), "[data-sx-island]"); var els = domQueryAll(sxOr(root, domBody()), "[data-sx-island]");
return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "island-hydrated"))) ? (markProcessed(el, "island-hydrated"), hydrateIsland(el)) : NIL); }, els); logInfo((String("sx-hydrate-islands: ") + String(len(els)) + String(" island(s) in ") + String((isSxTruthy(root) ? "subtree" : "document"))));
return forEach(function(el) { return (isSxTruthy(isProcessed(el, "island-hydrated")) ? logInfo((String(" skip (already hydrated): ") + String(domGetAttr(el, "data-sx-island")))) : (logInfo((String(" hydrating: ") + String(domGetAttr(el, "data-sx-island")))), markProcessed(el, "island-hydrated"), hydrateIsland(el))); }, els);
})(); }; })(); };
PRIMITIVES["sx-hydrate-islands"] = sxHydrateIslands; PRIMITIVES["sx-hydrate-islands"] = sxHydrateIslands;
@@ -4729,10 +4734,11 @@ PRIMITIVES["sx-hydrate-islands"] = sxHydrateIslands;
PRIMITIVES["hydrate-island"] = hydrateIsland; PRIMITIVES["hydrate-island"] = hydrateIsland;
// dispose-island // dispose-island
var disposeIsland = function(el) { return (function() { var disposeIsland = function(el) { (function() {
var disposers = domGetData(el, "sx-disposers"); var disposers = domGetData(el, "sx-disposers");
return (isSxTruthy(disposers) ? (forEach(function(d) { return (isSxTruthy(isCallable(d)) ? d() : NIL); }, disposers), domSetData(el, "sx-disposers", NIL)) : NIL); return (isSxTruthy(disposers) ? (forEach(function(d) { return (isSxTruthy(isCallable(d)) ? d() : NIL); }, disposers), domSetData(el, "sx-disposers", NIL)) : NIL);
})(); }; })();
return clearProcessed(el, "island-hydrated"); };
PRIMITIVES["dispose-island"] = disposeIsland; PRIMITIVES["dispose-island"] = disposeIsland;
// dispose-islands-in // dispose-islands-in
@@ -4745,6 +4751,13 @@ PRIMITIVES["dispose-island"] = disposeIsland;
})() : NIL); }; })() : NIL); };
PRIMITIVES["dispose-islands-in"] = disposeIslandsIn; PRIMITIVES["dispose-islands-in"] = disposeIslandsIn;
// force-dispose-islands-in
var forceDisposeIslandsIn = function(root) { return (isSxTruthy(root) ? (function() {
var islands = domQueryAll(root, "[data-sx-island]");
return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (logInfo((String("force-disposing ") + String(len(islands)) + String(" island(s)"))), forEach(disposeIsland, islands)) : NIL);
})() : NIL); };
PRIMITIVES["force-dispose-islands-in"] = forceDisposeIslandsIn;
// *pre-render-hooks* // *pre-render-hooks*
var _preRenderHooks_ = []; var _preRenderHooks_ = [];
PRIMITIVES["*pre-render-hooks*"] = _preRenderHooks_; PRIMITIVES["*pre-render-hooks*"] = _preRenderHooks_;
@@ -6949,6 +6962,7 @@ PRIMITIVES["resource"] = resource;
function markProcessed(el, key) { el[PROCESSED + key] = true; } function markProcessed(el, key) { el[PROCESSED + key] = true; }
function isProcessed(el, key) { return !!el[PROCESSED + key]; } function isProcessed(el, key) { return !!el[PROCESSED + key]; }
function clearProcessed(el, key) { delete el[PROCESSED + key]; }
// --- Script cloning --- // --- Script cloning ---

View File

@@ -137,36 +137,55 @@ async def execute_handler(
1. Build env from component env + handler closure 1. Build env from component env + handler closure
2. Bind handler params from args (typically request.args) 2. Bind handler params from args (typically request.args)
3. Evaluate via ``async_eval_to_sx`` (I/O inline, components serialized) 3. Evaluate via OCaml kernel (or Python fallback)
4. Return ``SxExpr`` wire format 4. Return ``SxExpr`` wire format
""" """
from .jinja_bridge import get_component_env, _get_request_context from .jinja_bridge import get_component_env, _get_request_context
from .pages import get_page_helpers from .pages import get_page_helpers
from .parser import serialize
from .types import NIL, SxExpr
import os import os
if os.environ.get("SX_USE_REF") == "1":
from .ref.async_eval_ref import async_eval_to_sx
else:
from .async_eval import async_eval_to_sx
from .types import NIL
if args is None: if args is None:
args = {} args = {}
# Build environment use_ocaml = os.environ.get("SX_USE_OCAML") == "1"
env = dict(get_component_env())
env.update(get_page_helpers(service_name))
env.update(handler_def.closure)
# Bind handler params from request args if use_ocaml:
for param in handler_def.params: from .ocaml_bridge import get_bridge
env[param] = args.get(param, args.get(param.replace("-", "_"), NIL))
# Get request context for I/O primitives # Serialize handler body with bound params as a let expression
ctx = _get_request_context() param_bindings = []
for param in handler_def.params:
val = args.get(param, args.get(param.replace("-", "_"), NIL))
param_bindings.append(f"({param} {serialize(val)})")
# Async eval → sx source — I/O primitives are awaited inline, body_sx = serialize(handler_def.body)
# but component/tag calls serialize to sx wire format (not HTML). if param_bindings:
return await async_eval_to_sx(handler_def.body, env, ctx) sx_text = f"(let ({' '.join(param_bindings)}) {body_sx})"
else:
sx_text = body_sx
bridge = await get_bridge()
ocaml_ctx = {"_helper_service": service_name}
result_sx = await bridge.aser(sx_text, ctx=ocaml_ctx)
return SxExpr(result_sx or "")
else:
# Python fallback
if os.environ.get("SX_USE_REF") == "1":
from .ref.async_eval_ref import async_eval_to_sx
else:
from .async_eval import async_eval_to_sx
env = dict(get_component_env())
env.update(get_page_helpers(service_name))
env.update(handler_def.closure)
for param in handler_def.params:
env[param] = args.get(param, args.get(param.replace("-", "_"), NIL))
ctx = _get_request_context()
return await async_eval_to_sx(handler_def.body, env, ctx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -101,28 +101,26 @@ class OcamlBridge:
"""Load an .sx file for side effects (defcomp, define, defmacro).""" """Load an .sx file for side effects (defcomp, define, defmacro)."""
async with self._lock: async with self._lock:
self._send(f'(load "{_escape(path)}")') self._send(f'(load "{_escape(path)}")')
kind, value = await self._read_response() value = await self._read_until_ok(ctx=None)
if kind == "error":
raise OcamlBridgeError(f"load {path}: {value}")
return int(float(value)) if value else 0 return int(float(value)) if value else 0
async def load_source(self, source: str) -> int: async def load_source(self, source: str) -> int:
"""Evaluate SX source for side effects.""" """Evaluate SX source for side effects."""
async with self._lock: async with self._lock:
self._send(f'(load-source "{_escape(source)}")') self._send(f'(load-source "{_escape(source)}")')
kind, value = await self._read_response() value = await self._read_until_ok(ctx=None)
if kind == "error":
raise OcamlBridgeError(f"load-source: {value}")
return int(float(value)) if value else 0 return int(float(value)) if value else 0
async def eval(self, source: str) -> str: async def eval(self, source: str, ctx: dict[str, Any] | None = None) -> str:
"""Evaluate SX expression, return serialized result.""" """Evaluate SX expression, return serialized result.
Supports io-requests (helper calls, query, action, etc.) via the
coroutine bridge, just like render().
"""
await self._ensure_components()
async with self._lock: async with self._lock:
self._send(f'(eval "{_escape(source)}")') self._send(f'(eval "{_escape(source)}")')
kind, value = await self._read_response() return await self._read_until_ok(ctx)
if kind == "error":
raise OcamlBridgeError(f"eval: {value}")
return value or ""
async def render( async def render(
self, self,
@@ -135,40 +133,84 @@ class OcamlBridge:
self._send(f'(render "{_escape(source)}")') self._send(f'(render "{_escape(source)}")')
return await self._read_until_ok(ctx) return await self._read_until_ok(ctx)
async def aser(self, source: str, ctx: dict[str, Any] | None = None) -> str:
"""Evaluate SX and return SX wire format, handling io-requests."""
await self._ensure_components()
async with self._lock:
self._send(f'(aser "{_escape(source)}")')
return await self._read_until_ok(ctx)
async def _ensure_components(self) -> None: async def _ensure_components(self) -> None:
"""Load component definitions into the kernel on first use.""" """Load all .sx source files into the kernel on first use.
Errors during loading are handled gracefully — IO responses are
always sent back to keep the pipe clean.
"""
if self._components_loaded: if self._components_loaded:
return return
self._components_loaded = True self._components_loaded = True
try: try:
from .jinja_bridge import get_component_env, _CLIENT_LIBRARY_SOURCES from .jinja_bridge import _watched_dirs, _dirs_from_cache
from .parser import serialize import glob
from .types import Component, Island, Macro
env = get_component_env() # Skip patterns — files that use constructs not available in the kernel
parts: list[str] = list(_CLIENT_LIBRARY_SOURCES) skip_names = {"boundary.sx", "forms.sx"}
for key, val in env.items(): skip_dirs = {"tests"}
if isinstance(val, Island):
ps = ["&key"] + list(val.params) # Collect files to load
if val.has_children: all_files: list[str] = []
ps.extend(["&rest", "children"])
parts.append(f"(defisland ~{val.name} ({' '.join(ps)}) {serialize(val.body)})") # Spec files needed by aser
elif isinstance(val, Component): spec_dir = os.path.join(os.path.dirname(__file__), "../../spec")
ps = ["&key"] + list(val.params) for spec_file in ["parser.sx", "render.sx"]:
if val.has_children: path = os.path.normpath(os.path.join(spec_dir, spec_file))
ps.extend(["&rest", "children"]) if os.path.isfile(path):
parts.append(f"(defcomp ~{val.name} ({' '.join(ps)}) {serialize(val.body)})") all_files.append(path)
elif isinstance(val, Macro):
ps = list(val.params) # All directories loaded into the Python env
if val.rest_param: all_dirs = list(set(_watched_dirs) | _dirs_from_cache)
ps.extend(["&rest", val.rest_param])
parts.append(f"(defmacro {val.name} ({' '.join(ps)}) {serialize(val.body)})") # Web adapters (aser lives in adapter-sx.sx) — only load specific files
if parts: web_dir = os.path.join(os.path.dirname(__file__), "../../web")
source = "\n".join(parts) if os.path.isdir(web_dir):
await self.load_source(source) for web_file in ["adapter-sx.sx"]:
_logger.info("Loaded %d definitions into OCaml kernel", len(parts)) path = os.path.normpath(os.path.join(web_dir, web_file))
if os.path.isfile(path):
all_files.append(path)
for directory in sorted(all_dirs):
files = sorted(
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
)
for filepath in files:
basename = os.path.basename(filepath)
# Skip known-bad files
if basename in skip_names:
continue
# Skip test and handler directories
parts = filepath.replace("\\", "/").split("/")
if any(d in skip_dirs for d in parts):
continue
all_files.append(filepath)
# Load all files under a single lock
count = 0
skipped = 0
async with self._lock:
for filepath in all_files:
try:
self._send(f'(load "{_escape(filepath)}")')
value = await self._read_until_ok(ctx=None)
# Response may be a number (count) or a value — just count files
count += 1
except OcamlBridgeError as e:
skipped += 1
_logger.warning("OCaml load skipped %s: %s",
filepath, e)
_logger.info("Loaded %d definitions from .sx files into OCaml kernel (%d skipped)",
count, skipped)
except Exception as e: except Exception as e:
_logger.error("Failed to load components into OCaml kernel: %s", e) _logger.error("Failed to load .sx files into OCaml kernel: %s", e)
self._components_loaded = False # retry next time self._components_loaded = False # retry next time
async def reset(self) -> None: async def reset(self) -> None:
@@ -217,14 +259,19 @@ class OcamlBridge:
"""Read lines until (ok ...) or (error ...). """Read lines until (ok ...) or (error ...).
Handles (io-request ...) by fulfilling IO and sending (io-response ...). Handles (io-request ...) by fulfilling IO and sending (io-response ...).
ALWAYS sends a response to keep the pipe clean, even on error.
""" """
while True: while True:
line = await self._readline() line = await self._readline()
if line.startswith("(io-request "): if line.startswith("(io-request "):
result = await self._handle_io_request(line, ctx) try:
# Send response back to OCaml result = await self._handle_io_request(line, ctx)
self._send(f"(io-response {_serialize_for_ocaml(result)})") self._send(f"(io-response {_serialize_for_ocaml(result)})")
except Exception as e:
# MUST send a response or the pipe desyncs
_logger.warning("IO request failed, sending nil: %s", e)
self._send("(io-response nil)")
continue continue
kind, value = _parse_response(line) kind, value = _parse_response(line)
@@ -264,7 +311,15 @@ class OcamlBridge:
return self._io_request_method() return self._io_request_method()
elif req_name == "ctx": elif req_name == "ctx":
return self._io_ctx(args, ctx) return self._io_ctx(args, ctx)
elif req_name == "helper":
return await self._io_helper(args, ctx)
else: else:
# Fall back to registered IO handlers (set-response-status, sleep, etc.)
from .primitives_io import _IO_HANDLERS, RequestContext
io_handler = _IO_HANDLERS.get(req_name)
if io_handler is not None:
helper_args = [_to_python(a) for a in args]
return await io_handler(helper_args, {}, ctx or RequestContext())
raise OcamlBridgeError(f"Unknown io-request type: {req_name}") raise OcamlBridgeError(f"Unknown io-request type: {req_name}")
async def _io_query(self, args: list) -> Any: async def _io_query(self, args: list) -> Any:
@@ -309,6 +364,43 @@ class OcamlBridge:
key = _to_str(args[0]) if args else "" key = _to_str(args[0]) if args else ""
return ctx.get(key) return ctx.get(key)
async def _io_helper(self, args: list, ctx: dict[str, Any] | None) -> Any:
"""Handle (io-request "helper" name arg1 arg2 ...).
Dispatches to registered page helpers — Python functions like
read-spec-file, bootstrapper-data, etc. The helper service name
is passed via ctx["_helper_service"].
"""
import asyncio
from .pages import get_page_helpers
from .primitives_io import _IO_HANDLERS, RequestContext
name = _to_str(args[0]) if args else ""
helper_args = [_to_python(a) for a in args[1:]]
# Check page helpers first (application-level)
service = (ctx or {}).get("_helper_service", "sx")
helpers = get_page_helpers(service)
fn = helpers.get(name)
if fn is not None:
result = fn(*helper_args)
if asyncio.iscoroutine(result):
result = await result
return result
# Fall back to IO primitives (now, state-get, state-set!, etc.)
io_handler = _IO_HANDLERS.get(name)
if io_handler is not None:
return await io_handler(helper_args, {}, RequestContext())
# Fall back to regular primitives (json-encode, into, etc.)
from .primitives import get_primitive as _get_prim
prim = _get_prim(name)
if prim is not None:
return prim(*helper_args)
raise OcamlBridgeError(f"Unknown helper: {name!r}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Module-level singleton # Module-level singleton
@@ -344,6 +436,9 @@ def _parse_response(line: str) -> tuple[str, str | None]:
line = line.strip() line = line.strip()
if line == "(ok)": if line == "(ok)":
return ("ok", None) return ("ok", None)
if line.startswith("(ok-raw "):
# Raw SX wire format — no unescaping needed
return ("ok", line[8:-1])
if line.startswith("(ok "): if line.startswith("(ok "):
value = line[4:-1] # strip (ok and ) value = line[4:-1] # strip (ok and )
# If the value is a quoted string, unquote it # If the value is a quoted string, unquote it
@@ -369,6 +464,16 @@ def _unescape(s: str) -> str:
) )
def _to_python(val: Any) -> Any:
"""Convert an SX parsed value to a plain Python value."""
from .types import NIL as _NIL
if val is None or val is _NIL:
return None
if hasattr(val, "name"): # Symbol or Keyword
return val.name
return val
def _to_str(val: Any) -> str: def _to_str(val: Any) -> str:
"""Convert an SX parsed value to a Python string.""" """Convert an SX parsed value to a Python string."""
if isinstance(val, str): if isinstance(val, str):

View File

@@ -642,7 +642,8 @@ from . import primitives_ctx # noqa: E402, F401
# Auto-derive IO_PRIMITIVES from registered handlers # Auto-derive IO_PRIMITIVES from registered handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
IO_PRIMITIVES: frozenset[str] = frozenset(_IO_HANDLERS.keys()) # Placeholder — rebuilt at end of file after all handlers are registered
IO_PRIMITIVES: frozenset[str] = frozenset()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -703,9 +704,45 @@ _PRIMITIVES["relations-from"] = _bridge_relations_from
# Validate all IO handlers against boundary.sx # Validate all IO handlers against boundary.sx
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@register_io_handler("helper")
async def _io_helper(
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
) -> Any:
"""``(helper "name" args...)`` → dispatch to page helpers or IO handlers.
Universal IO dispatcher — same interface as the OCaml kernel's helper
IO primitive. Checks page helpers first, then IO handlers.
"""
if not args:
raise ValueError("helper requires a name")
name = str(args[0])
helper_args = args[1:]
# Check page helpers first
from .pages import get_page_helpers
helpers = get_page_helpers("sx")
fn = helpers.get(name)
if fn is not None:
import asyncio
result = fn(*helper_args)
if asyncio.iscoroutine(result):
result = await result
return result
# Fall back to IO handlers
io_handler = _IO_HANDLERS.get(name)
if io_handler is not None:
return await io_handler(helper_args, {}, ctx)
raise ValueError(f"Unknown helper: {name!r}")
def _validate_io_handlers() -> None: def _validate_io_handlers() -> None:
from .boundary import validate_io from .boundary import validate_io
for name in _IO_HANDLERS: for name in _IO_HANDLERS:
validate_io(name) validate_io(name)
_validate_io_handlers() _validate_io_handlers()
# Rebuild IO_PRIMITIVES now that all handlers (including helper) are registered
IO_PRIMITIVES = frozenset(_IO_HANDLERS.keys())

View File

@@ -1,90 +1,10 @@
"""Documentation content for the sx docs site. """Documentation content for the sx docs site.
All page content as Python data structures, consumed by sx_components.py Data structures consumed by helpers.py for pages that need server-side data.
to build s-expression page trees. Navigation is defined in nav-data.sx (the single source of truth).
""" """
from __future__ import annotations from __future__ import annotations
# ---------------------------------------------------------------------------
# Navigation
# ---------------------------------------------------------------------------
DOCS_NAV = [
("Introduction", "/(language.(doc.introduction))"),
("Getting Started", "/(language.(doc.getting-started))"),
("Components", "/(language.(doc.components))"),
("Evaluator", "/(language.(doc.evaluator))"),
("Primitives", "/(language.(doc.primitives))"),
("CSS", "/(language.(doc.css))"),
("Server Rendering", "/(language.(doc.server-rendering))"),
]
REFERENCE_NAV = [
("Attributes", "/(geography.(hypermedia.(reference.attributes)))"),
("Headers", "/(geography.(hypermedia.(reference.headers)))"),
("Events", "/(geography.(hypermedia.(reference.events)))"),
("JS API", "/(geography.(hypermedia.(reference.js-api)))"),
]
PROTOCOLS_NAV = [
("Wire Format", "/(applications.(protocol.wire-format))"),
("Fragments", "/(applications.(protocol.fragments))"),
("Resolver I/O", "/(applications.(protocol.resolver-io))"),
("Internal Services", "/(applications.(protocol.internal-services))"),
("ActivityPub", "/(applications.(protocol.activitypub))"),
("Future", "/(applications.(protocol.future))"),
]
EXAMPLES_NAV = [
("Click to Load", "/(geography.(hypermedia.(example.click-to-load)))"),
("Form Submission", "/(geography.(hypermedia.(example.form-submission)))"),
("Polling", "/(geography.(hypermedia.(example.polling)))"),
("Delete Row", "/(geography.(hypermedia.(example.delete-row)))"),
("Inline Edit", "/(geography.(hypermedia.(example.inline-edit)))"),
("OOB Swaps", "/(geography.(hypermedia.(example.oob-swaps)))"),
("Lazy Loading", "/(geography.(hypermedia.(example.lazy-loading)))"),
("Infinite Scroll", "/(geography.(hypermedia.(example.infinite-scroll)))"),
("Progress Bar", "/(geography.(hypermedia.(example.progress-bar)))"),
("Active Search", "/(geography.(hypermedia.(example.active-search)))"),
("Inline Validation", "/(geography.(hypermedia.(example.inline-validation)))"),
("Value Select", "/(geography.(hypermedia.(example.value-select)))"),
("Reset on Submit", "/(geography.(hypermedia.(example.reset-on-submit)))"),
("Edit Row", "/(geography.(hypermedia.(example.edit-row)))"),
("Bulk Update", "/(geography.(hypermedia.(example.bulk-update)))"),
("Swap Positions", "/(geography.(hypermedia.(example.swap-positions)))"),
("Select Filter", "/(geography.(hypermedia.(example.select-filter)))"),
("Tabs", "/(geography.(hypermedia.(example.tabs)))"),
("Animations", "/(geography.(hypermedia.(example.animations)))"),
("Dialogs", "/(geography.(hypermedia.(example.dialogs)))"),
("Keyboard Shortcuts", "/(geography.(hypermedia.(example.keyboard-shortcuts)))"),
("PUT / PATCH", "/(geography.(hypermedia.(example.put-patch)))"),
("JSON Encoding", "/(geography.(hypermedia.(example.json-encoding)))"),
("Vals & Headers", "/(geography.(hypermedia.(example.vals-and-headers)))"),
("Loading States", "/(geography.(hypermedia.(example.loading-states)))"),
("Request Abort", "/(geography.(hypermedia.(example.sync-replace)))"),
("Retry", "/(geography.(hypermedia.(example.retry)))"),
]
ESSAYS_NAV = [
("sx sucks", "/(etc.(essay.sx-sucks))"),
("Why S-Expressions", "/(etc.(essay.why-sexps))"),
("The htmx/React Hybrid", "/(etc.(essay.htmx-react-hybrid))"),
("On-Demand CSS", "/(etc.(essay.on-demand-css))"),
("Client Reactivity", "/(etc.(essay.client-reactivity))"),
("SX Native", "/(etc.(essay.sx-native))"),
("The SX Manifesto", "/(etc.(philosophy.sx-manifesto))"),
("Tail-Call Optimization", "/(etc.(essay.tail-call-optimization))"),
("Continuations", "/(etc.(essay.continuations))"),
]
MAIN_NAV = [
("Docs", "/(language.(doc.introduction))"),
("Reference", "/(geography.(hypermedia.(reference)))"),
("Protocols", "/(applications.(protocol.wire-format))"),
("Examples", "/(geography.(hypermedia.(example.click-to-load)))"),
("Essays", "/(etc.(essay.sx-sucks))"),
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Reference: Attributes # Reference: Attributes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -58,18 +58,18 @@
(let ((running (signal false)) (let ((running (signal false))
(elapsed (signal 0)) (elapsed (signal 0))
(time-text (create-text-node "0.0s")) (time-text (create-text-node "0.0s"))
(btn-text (create-text-node "Start"))) (btn-text (create-text-node "Start"))
(effect (fn () (_e1 (effect (fn ()
(when (deref running) (when (deref running)
(let ((id (set-interval (fn () (swap! elapsed inc)) 100))) (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))
(fn () (clear-interval id)))))) (fn () (clear-interval id)))))))
(effect (fn () (_e2 (effect (fn ()
(let ((e (deref elapsed))) (let ((e (deref elapsed)))
(dom-set-text-content time-text (dom-set-text-content time-text
(str (floor (/ e 10)) "." (mod e 10) "s"))))) (str (floor (/ e 10)) "." (mod e 10) "s"))))))
(effect (fn () (_e3 (effect (fn ()
(dom-set-text-content btn-text (dom-set-text-content btn-text
(if (deref running) "Stop" "Start")))) (if (deref running) "Stop" "Start"))))))
(div :class "rounded-lg border border-stone-200 p-4" (div :class "rounded-lg border border-stone-200 p-4"
(div :class "flex items-center gap-3" (div :class "flex items-center gap-3"
(span :class "text-2xl font-bold text-violet-700 font-mono min-w-[5ch]" time-text) (span :class "text-2xl font-bold text-violet-700 font-mono min-w-[5ch]" time-text)
@@ -82,10 +82,10 @@
(defisland ~geography/cek/demo-batch () (defisland ~geography/cek/demo-batch ()
(let ((first-sig (signal 0)) (let ((first-sig (signal 0))
(second-sig (signal 0)) (second-sig (signal 0))
(renders (signal 0))) (renders (signal 0))
(effect (fn () (_eff (effect (fn ()
(deref first-sig) (deref second-sig) (deref first-sig) (deref second-sig)
(swap! renders inc))) (swap! renders inc)))))
(div :class "rounded-lg border border-stone-200 p-4 space-y-2" (div :class "rounded-lg border border-stone-200 p-4 space-y-2"
(div :class "flex items-center gap-4 text-sm" (div :class "flex items-center gap-4 text-sm"
(span (str "first: " (deref first-sig))) (span (str "first: " (deref first-sig)))

View File

@@ -59,11 +59,11 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%Y-%m-%d %H:%M:%S"))) (let ((now (helper "now" "%Y-%m-%d %H:%M:%S")))
(<> (<>
(~examples/click-result :time now) (~examples/click-result :time now)
(~docs/oob-code :target-id "click-comp" (~docs/oob-code :target-id "click-comp"
:text (component-source "~examples/click-result")) :text (helper "component-source" "~examples/click-result"))
(~docs/oob-code :target-id "click-wire" (~docs/oob-code :target-id "click-wire"
:text (str "(~examples/click-result :time \"" now "\")"))))) :text (str "(~examples/click-result :time \"" now "\")")))))
@@ -78,11 +78,11 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((name (request-form "name" ""))) (let ((name (helper "request-form" "name" "")))
(<> (<>
(~examples/form-result :name name) (~examples/form-result :name name)
(~docs/oob-code :target-id "form-comp" (~docs/oob-code :target-id "form-comp"
:text (component-source "~examples/form-result")) :text (helper "component-source" "~examples/form-result"))
(~docs/oob-code :target-id "form-wire" (~docs/oob-code :target-id "form-wire"
:text (str "(~examples/form-result :name \"" name "\")"))))) :text (str "(~examples/form-result :name \"" name "\")")))))
@@ -96,16 +96,17 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((n (+ (state-get "ex-poll-n" 0) 1))) (let ((prev (helper "state-get" "ex-poll-n" 0)))
(state-set! "ex-poll-n" n) (let ((n (+ prev 1)))
(let ((now (now "%H:%M:%S")) (helper "state-set!" "ex-poll-n" n)
(let ((now (helper "now" "%H:%M:%S"))
(count (if (< n 10) n 10))) (count (if (< n 10) n 10)))
(<> (<>
(~examples/poll-result :time now :count count) (~examples/poll-result :time now :count count)
(~docs/oob-code :target-id "poll-comp" (~docs/oob-code :target-id "poll-comp"
:text (component-source "~examples/poll-result")) :text (helper "component-source" "~examples/poll-result"))
(~docs/oob-code :target-id "poll-wire" (~docs/oob-code :target-id "poll-wire"
:text (str "(~examples/poll-result :time \"" now "\" :count " count ")")))))) :text (str "(~examples/poll-result :time \"" now "\" :count " count ")")))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -120,7 +121,7 @@
(&key item-id) (&key item-id)
(<> (<>
(~docs/oob-code :target-id "delete-comp" (~docs/oob-code :target-id "delete-comp"
:text (component-source "~examples/delete-row")) :text (helper "component-source" "~examples/delete-row"))
(~docs/oob-code :target-id "delete-wire" (~docs/oob-code :target-id "delete-wire"
:text "(empty — row removed by outerHTML swap)"))) :text "(empty — row removed by outerHTML swap)")))
@@ -134,11 +135,11 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((value (request-arg "value" ""))) (let ((value (helper "request-arg" "value" "")))
(<> (<>
(~examples/inline-edit-form :value value) (~examples/inline-edit-form :value value)
(~docs/oob-code :target-id "edit-comp" (~docs/oob-code :target-id "edit-comp"
:text (component-source "~examples/inline-edit-form")) :text (helper "component-source" "~examples/inline-edit-form"))
(~docs/oob-code :target-id "edit-wire" (~docs/oob-code :target-id "edit-wire"
:text (str "(~examples/inline-edit-form :value \"" value "\")"))))) :text (str "(~examples/inline-edit-form :value \"" value "\")")))))
@@ -148,11 +149,11 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((value (request-form "value" ""))) (let ((value (helper "request-form" "value" "")))
(<> (<>
(~examples/inline-view :value value) (~examples/inline-view :value value)
(~docs/oob-code :target-id "edit-comp" (~docs/oob-code :target-id "edit-comp"
:text (component-source "~examples/inline-view")) :text (helper "component-source" "~examples/inline-view"))
(~docs/oob-code :target-id "edit-wire" (~docs/oob-code :target-id "edit-wire"
:text (str "(~examples/inline-view :value \"" value "\")"))))) :text (str "(~examples/inline-view :value \"" value "\")")))))
@@ -161,11 +162,11 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((value (request-arg "value" ""))) (let ((value (helper "request-arg" "value" "")))
(<> (<>
(~examples/inline-view :value value) (~examples/inline-view :value value)
(~docs/oob-code :target-id "edit-comp" (~docs/oob-code :target-id "edit-comp"
:text (component-source "~examples/inline-view")) :text (helper "component-source" "~examples/inline-view"))
(~docs/oob-code :target-id "edit-wire" (~docs/oob-code :target-id "edit-wire"
:text (str "(~examples/inline-view :value \"" value "\")"))))) :text (str "(~examples/inline-view :value \"" value "\")")))))
@@ -179,7 +180,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(p :class "text-emerald-600 font-medium" "Box A updated!") (p :class "text-emerald-600 font-medium" "Box A updated!")
(p :class "text-sm text-stone-500" (str "at " now)) (p :class "text-sm text-stone-500" (str "at " now))
@@ -199,11 +200,11 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(~examples/lazy-result :time now) (~examples/lazy-result :time now)
(~docs/oob-code :target-id "lazy-comp" (~docs/oob-code :target-id "lazy-comp"
:text (component-source "~examples/lazy-result")) :text (helper "component-source" "~examples/lazy-result"))
(~docs/oob-code :target-id "lazy-wire" (~docs/oob-code :target-id "lazy-wire"
:text (str "(~examples/lazy-result :time \"" now "\")"))))) :text (str "(~examples/lazy-result :time \"" now "\")")))))
@@ -217,7 +218,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((page (request-arg "page" "2"))) (let ((page (helper "request-arg" "page" "2")))
(let ((pg (parse-int page)) (let ((pg (parse-int page))
(start (+ (* (- (parse-int page) 1) 5) 1))) (start (+ (* (- (parse-int page) 1) 5) 1)))
(<> (<>
@@ -249,30 +250,31 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((n (+ (state-get "ex-job-counter" 0) 1))) (let ((prev-job (helper "state-get" "ex-job-counter" 0)))
(state-set! "ex-job-counter" n) (let ((n (+ prev-job 1)))
(helper "state-set!" "ex-job-counter" n)
(let ((job-id (str "job-" n))) (let ((job-id (str "job-" n)))
(state-set! (str "ex-job-" job-id) 0) (helper "state-set!" (str "ex-job-" job-id) 0)
(<> (<>
(~examples/progress-status :percent 0 :job-id job-id) (~examples/progress-status :percent 0 :job-id job-id)
(~docs/oob-code :target-id "progress-comp" (~docs/oob-code :target-id "progress-comp"
:text (component-source "~examples/progress-status")) :text (helper "component-source" "~examples/progress-status"))
(~docs/oob-code :target-id "progress-wire" (~docs/oob-code :target-id "progress-wire"
:text (str "(~examples/progress-status :percent 0 :job-id \"" job-id "\")")))))) :text (str "(~examples/progress-status :percent 0 :job-id \"" job-id "\")")))))))
(defhandler ex-progress-status (defhandler ex-progress-status
:path "/sx/(geography.(hypermedia.(example.(api.progress-status))))" :path "/sx/(geography.(hypermedia.(example.(api.progress-status))))"
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((job-id (request-arg "job" ""))) (let ((job-id (helper "request-arg" "job" "")))
(let ((current (state-get (str "ex-job-" job-id) 0))) (let ((current (helper "state-get" (str "ex-job-" job-id) 0)))
(let ((next (if (>= (+ current (random-int 15 30)) 100) 100 (+ current (random-int 15 30))))) (let ((next (if (>= (+ current (random-int 15 30)) 100) 100 (+ current (random-int 15 30)))))
(state-set! (str "ex-job-" job-id) next) (helper "state-set!" (str "ex-job-" job-id) next)
(<> (<>
(~examples/progress-status :percent next :job-id job-id) (~examples/progress-status :percent next :job-id job-id)
(~docs/oob-code :target-id "progress-comp" (~docs/oob-code :target-id "progress-comp"
:text (component-source "~examples/progress-status")) :text (helper "component-source" "~examples/progress-status"))
(~docs/oob-code :target-id "progress-wire" (~docs/oob-code :target-id "progress-wire"
:text (str "(~examples/progress-status :percent " next " :job-id \"" job-id "\")"))))))) :text (str "(~examples/progress-status :percent " next " :job-id \"" job-id "\")")))))))
@@ -286,17 +288,17 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((q (request-arg "q" ""))) (let ((q (helper "request-arg" "q" "")))
(let ((results (if (= q "") (let ((results (if (= q "")
search-languages search-languages
(filter (fn (lang) (contains? (lower-case lang) (lower-case q))) (filter (fn (lang) (contains? (lower lang) (lower q)))
search-languages)))) search-languages))))
(<> (<>
(~search-results :items results :query q) (~examples/search-results :items results :query q)
(~docs/oob-code :target-id "search-comp" (~docs/oob-code :target-id "search-comp"
:text (component-source "~search-results")) :text (helper "component-source" "~examples/search-results"))
(~docs/oob-code :target-id "search-wire" (~docs/oob-code :target-id "search-wire"
:text (str "(~search-results :items (list ...) :query \"" q "\")")))))) :text (str "(~examples/search-results :items (list ...) :query \"" q "\")"))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -308,27 +310,37 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((email (request-arg "email" ""))) (let ((email (helper "request-arg" "email" "")))
(let ((result (cond
(cond (= email "")
(= email "") (<>
(list "validation-error" "(~examples/validation-error :message \"Email is required\")" (~examples/validation-error :message "Email is required")
(~examples/validation-error :message "Email is required")) (~docs/oob-code :target-id "validate-comp"
(not (contains? email "@")) :text (helper "component-source" "~examples/validation-error"))
(list "validation-error" "(~examples/validation-error :message \"Invalid email format\")" (~docs/oob-code :target-id "validate-wire"
(~examples/validation-error :message "Invalid email format")) :text "(~examples/validation-error :message \"Email is required\")"))
(some (fn (e) (= (lower-case e) (lower-case email))) taken-emails) (not (contains? email "@"))
(list "validation-error" (str "(~examples/validation-error :message \"" email " is already taken\")") (<>
(~examples/validation-error :message (str email " is already taken"))) (~examples/validation-error :message "Invalid email format")
:else (~docs/oob-code :target-id "validate-comp"
(list "validation-ok" (str "(~examples/validation-ok :email \"" email "\")") :text (helper "component-source" "~examples/validation-error"))
(~examples/validation-ok :email email))))) (~docs/oob-code :target-id "validate-wire"
(<> :text "(~examples/validation-error :message \"Invalid email format\")"))
(nth result 2) (some (fn (e) (= (lower e) (lower email))) taken-emails)
(~docs/oob-code :target-id "validate-comp" (<>
:text (component-source (first result))) (~examples/validation-error :message (str email " is already taken"))
(~docs/oob-code :target-id "validate-wire" (~docs/oob-code :target-id "validate-comp"
:text (nth result 1)))))) :text (helper "component-source" "~examples/validation-error"))
(~docs/oob-code :target-id "validate-wire"
:text (str "(~examples/validation-error :message \"" email " is already taken\")")))
:else
(<>
(~examples/validation-ok :email email)
(~docs/oob-code :target-id "validate-comp"
:text (helper "component-source" "~examples/validation-ok"))
(~docs/oob-code :target-id "validate-wire"
:text (str "(~examples/validation-ok :email \"" email "\")"))))))
(defhandler ex-validate-submit (defhandler ex-validate-submit
:path "/sx/(geography.(hypermedia.(example.(api.validate-submit))))" :path "/sx/(geography.(hypermedia.(example.(api.validate-submit))))"
@@ -336,7 +348,7 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((email (request-form "email" ""))) (let ((email (helper "request-form" "email" "")))
(if (or (= email "") (not (contains? email "@"))) (if (or (= email "") (not (contains? email "@")))
(p :class "text-sm text-rose-600 mt-2" "Please enter a valid email.") (p :class "text-sm text-rose-600 mt-2" "Please enter a valid email.")
(p :class "text-sm text-emerald-600 mt-2" (str "Form submitted with: " email))))) (p :class "text-sm text-emerald-600 mt-2" (str "Form submitted with: " email)))))
@@ -351,15 +363,14 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((cat (request-arg "category" ""))) (let ((cat (helper "request-arg" "category" "")))
(let ((items (get value-select-data cat (list)))) (let ((items (get value-select-data cat (list))))
(let ((options (if (empty? items) (<>
(list (option :value "" "No items")) (if (empty? items)
(map (fn (i) (option :value i i)) items)))) (option :value "" "No items")
(<> (map (fn (i) (option :value i i)) items))
options (~docs/oob-code :target-id "values-wire"
(~docs/oob-code :target-id "values-wire" :text (str "(options for \"" cat "\")"))))))
:text (str "(options for \"" cat "\")")))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -372,12 +383,12 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((msg (request-form "message" "(empty)")) (let ((msg (helper "request-form" "message" "(empty)"))
(now (now "%H:%M:%S"))) (now (helper "now" "%H:%M:%S")))
(<> (<>
(~examples/reset-message :message msg :time now) (~examples/reset-message :message msg :time now)
(~docs/oob-code :target-id "reset-comp" (~docs/oob-code :target-id "reset-comp"
:text (component-source "~examples/reset-message")) :text (helper "component-source" "~examples/reset-message"))
(~docs/oob-code :target-id "reset-wire" (~docs/oob-code :target-id "reset-wire"
:text (str "(~examples/reset-message :message \"" msg "\" :time \"" now "\")"))))) :text (str "(~examples/reset-message :message \"" msg "\" :time \"" now "\")")))))
@@ -392,12 +403,12 @@
:returns "element" :returns "element"
(&key row-id) (&key row-id)
(let ((default (get edit-row-defaults row-id {"id" row-id "name" "" "price" "0" "stock" "0"}))) (let ((default (get edit-row-defaults row-id {"id" row-id "name" "" "price" "0" "stock" "0"})))
(let ((row (state-get (str "ex-row-" row-id) default))) (let ((row (helper "state-get" (str "ex-row-" row-id) default)))
(<> (<>
(~examples/edit-row-form :id (get row "id") :name (get row "name") (~examples/edit-row-form :id (get row "id") :name (get row "name")
:price (get row "price") :stock (get row "stock")) :price (get row "price") :stock (get row "stock"))
(~docs/oob-code :target-id "editrow-comp" (~docs/oob-code :target-id "editrow-comp"
:text (component-source "~examples/edit-row-form")) :text (helper "component-source" "~examples/edit-row-form"))
(~docs/oob-code :target-id "editrow-wire" (~docs/oob-code :target-id "editrow-wire"
:text (str "(~examples/edit-row-form :id \"" (get row "id") "\" ...)")))))) :text (str "(~examples/edit-row-form :id \"" (get row "id") "\" ...)"))))))
@@ -407,15 +418,15 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key row-id) (&key row-id)
(let ((name (request-form "name" "")) (let ((name (helper "request-form" "name" ""))
(price (request-form "price" "0")) (price (helper "request-form" "price" "0"))
(stock (request-form "stock" "0"))) (stock (helper "request-form" "stock" "0")))
(state-set! (str "ex-row-" row-id) (helper "state-set!" (str "ex-row-" row-id)
{"id" row-id "name" name "price" price "stock" stock}) {"id" row-id "name" name "price" price "stock" stock})
(<> (<>
(~examples/edit-row-view :id row-id :name name :price price :stock stock) (~examples/edit-row-view :id row-id :name name :price price :stock stock)
(~docs/oob-code :target-id "editrow-comp" (~docs/oob-code :target-id "editrow-comp"
:text (component-source "~examples/edit-row-view")) :text (helper "component-source" "~examples/edit-row-view"))
(~docs/oob-code :target-id "editrow-wire" (~docs/oob-code :target-id "editrow-wire"
:text (str "(~examples/edit-row-view :id \"" row-id "\" ...)"))))) :text (str "(~examples/edit-row-view :id \"" row-id "\" ...)")))))
@@ -425,12 +436,12 @@
:returns "element" :returns "element"
(&key row-id) (&key row-id)
(let ((default (get edit-row-defaults row-id {"id" row-id "name" "" "price" "0" "stock" "0"}))) (let ((default (get edit-row-defaults row-id {"id" row-id "name" "" "price" "0" "stock" "0"})))
(let ((row (state-get (str "ex-row-" row-id) default))) (let ((row (helper "state-get" (str "ex-row-" row-id) default)))
(<> (<>
(~examples/edit-row-view :id (get row "id") :name (get row "name") (~examples/edit-row-view :id (get row "id") :name (get row "name")
:price (get row "price") :stock (get row "stock")) :price (get row "price") :stock (get row "stock"))
(~docs/oob-code :target-id "editrow-comp" (~docs/oob-code :target-id "editrow-comp"
:text (component-source "~examples/edit-row-view")) :text (helper "component-source" "~examples/edit-row-view"))
(~docs/oob-code :target-id "editrow-wire" (~docs/oob-code :target-id "editrow-wire"
:text (str "(~examples/edit-row-view :id \"" (get row "id") "\" ...)")))))) :text (str "(~examples/edit-row-view :id \"" (get row "id") "\" ...)"))))))
@@ -444,29 +455,29 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((action (request-arg "action" "activate")) (let ((action (helper "request-arg" "action" "activate"))
(ids (request-form-list "ids"))) (ids (helper "request-form-list" "ids")))
(let ((new-status (if (= action "activate") "active" "inactive"))) (let ((new-status (if (= action "activate") "active" "inactive")))
;; Update matching users in state ;; Update matching users in state
(for-each (fn (uid) (for-each (fn (uid)
(let ((default (get bulk-user-defaults uid nil))) (let ((default (get bulk-user-defaults uid nil)))
(let ((user (state-get (str "ex-bulk-" uid) default))) (let ((user (helper "state-get" (str "ex-bulk-" uid) default)))
(when user (when user
(state-set! (str "ex-bulk-" uid) (helper "state-set!" (str "ex-bulk-" uid)
(assoc user "status" new-status)))))) (assoc user "status" new-status))))))
ids) ids)
;; Return all rows ;; Return all rows
(let ((rows (map (fn (uid) (let ((rows (map (fn (uid)
(let ((default (get bulk-user-defaults uid (let ((default (get bulk-user-defaults uid
{"id" uid "name" "" "email" "" "status" "active"}))) {"id" uid "name" "" "email" "" "status" "active"})))
(let ((u (state-get (str "ex-bulk-" uid) default))) (let ((u (helper "state-get" (str "ex-bulk-" uid) default)))
(~examples/bulk-row :id (get u "id") :name (get u "name") (~examples/bulk-row :id (get u "id") :name (get u "name")
:email (get u "email") :status (get u "status"))))) :email (get u "email") :status (get u "status")))))
(list "1" "2" "3" "4" "5")))) (list "1" "2" "3" "4" "5"))))
(<> (<>
rows rows
(~docs/oob-code :target-id "bulk-comp" (~docs/oob-code :target-id "bulk-comp"
:text (component-source "~examples/bulk-row")) :text (helper "component-source" "~examples/bulk-row"))
(~docs/oob-code :target-id "bulk-wire" (~docs/oob-code :target-id "bulk-wire"
:text (str "(updated " (len ids) " users to " new-status ")"))))))) :text (str "(updated " (len ids) " users to " new-status ")")))))))
@@ -481,10 +492,11 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((mode (request-arg "mode" "beforeend")) (let ((mode (helper "request-arg" "mode" "beforeend"))
(n (+ (state-get "ex-swap-n" 0) 1)) (prev-swap (helper "state-get" "ex-swap-n" 0))
(now (now "%H:%M:%S"))) (now (helper "now" "%H:%M:%S")))
(state-set! "ex-swap-n" n) (let ((n (+ prev-swap 1)))
(helper "state-set!" "ex-swap-n" n)
(<> (<>
(div :class "px-3 py-2 text-sm text-stone-700" (div :class "px-3 py-2 text-sm text-stone-700"
(str "[" now "] " mode " (#" n ")")) (str "[" now "] " mode " (#" n ")"))
@@ -492,7 +504,7 @@
:class "self-center text-sm text-stone-500" :class "self-center text-sm text-stone-500"
(str "Count: " n)) (str "Count: " n))
(~docs/oob-code :target-id "swap-wire" (~docs/oob-code :target-id "swap-wire"
:text (str "(entry + oob counter: " n ")"))))) :text (str "(entry + oob counter: " n ")"))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -504,7 +516,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3" (div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3"
(h4 :class "font-semibold text-violet-800" "Dashboard Header") (h4 :class "font-semibold text-violet-800" "Dashboard Header")
@@ -556,12 +568,12 @@
:returns "element" :returns "element"
(&key) (&key)
(let ((idx (random-int 0 4)) (let ((idx (random-int 0 4))
(now (now "%H:%M:%S"))) (now (helper "now" "%H:%M:%S")))
(let ((color (nth anim-colors idx))) (let ((color (nth anim-colors idx)))
(<> (<>
(~anim-result :color color :time now) (~anim-result :color color :time now)
(~docs/oob-code :target-id "anim-comp" (~docs/oob-code :target-id "anim-comp"
:text (component-source "~anim-result")) :text (helper "component-source" "~anim-result"))
(~docs/oob-code :target-id "anim-wire" (~docs/oob-code :target-id "anim-wire"
:text (str "(~anim-result :color \"" color "\" :time \"" now "\")")))))) :text (str "(~anim-result :color \"" color "\" :time \"" now "\")"))))))
@@ -579,7 +591,7 @@
(~examples/dialog-modal :title "Confirm Action" (~examples/dialog-modal :title "Confirm Action"
:message "Are you sure you want to proceed? This is a demo dialog rendered entirely with sx components.") :message "Are you sure you want to proceed? This is a demo dialog rendered entirely with sx components.")
(~docs/oob-code :target-id "dialog-comp" (~docs/oob-code :target-id "dialog-comp"
:text (component-source "~examples/dialog-modal")) :text (helper "component-source" "~examples/dialog-modal"))
(~docs/oob-code :target-id "dialog-wire" (~docs/oob-code :target-id "dialog-wire"
:text "(~examples/dialog-modal :title \"Confirm Action\" :message \"...\")"))) :text "(~examples/dialog-modal :title \"Confirm Action\" :message \"...\")")))
@@ -602,12 +614,12 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((key (request-arg "key" ""))) (let ((key (helper "request-arg" "key" "")))
(let ((action (get kbd-actions key (str "Unknown key: " key)))) (let ((action (get kbd-actions key (str "Unknown key: " key))))
(<> (<>
(~examples/kbd-result :key key :action action) (~examples/kbd-result :key key :action action)
(~docs/oob-code :target-id "kbd-comp" (~docs/oob-code :target-id "kbd-comp"
:text (component-source "~examples/kbd-result")) :text (helper "component-source" "~examples/kbd-result"))
(~docs/oob-code :target-id "kbd-wire" (~docs/oob-code :target-id "kbd-wire"
:text (str "(~examples/kbd-result :key \"" key "\" :action \"" action "\")")))))) :text (str "(~examples/kbd-result :key \"" key "\" :action \"" action "\")"))))))
@@ -621,12 +633,12 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((p (state-get "ex-profile" (let ((p (helper "state-get" "ex-profile"
{"name" "Ada Lovelace" "email" "ada@example.com" "role" "Engineer"}))) {"name" "Ada Lovelace" "email" "ada@example.com" "role" "Engineer"})))
(<> (<>
(~examples/pp-form-full :name (get p "name") :email (get p "email") :role (get p "role")) (~examples/pp-form-full :name (get p "name") :email (get p "email") :role (get p "role"))
(~docs/oob-code :target-id "pp-comp" (~docs/oob-code :target-id "pp-comp"
:text (component-source "~examples/pp-form-full")) :text (helper "component-source" "~examples/pp-form-full"))
(~docs/oob-code :target-id "pp-wire" (~docs/oob-code :target-id "pp-wire"
:text (str "(~examples/pp-form-full :name \"" (get p "name") "\" ...)"))))) :text (str "(~examples/pp-form-full :name \"" (get p "name") "\" ...)")))))
@@ -636,14 +648,14 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((name (request-form "name" "")) (let ((name (helper "request-form" "name" ""))
(email (request-form "email" "")) (email (helper "request-form" "email" ""))
(role (request-form "role" ""))) (role (helper "request-form" "role" "")))
(state-set! "ex-profile" {"name" name "email" email "role" role}) (helper "state-set!" "ex-profile" {"name" name "email" email "role" role})
(<> (<>
(~examples/pp-view :name name :email email :role role) (~examples/pp-view :name name :email email :role role)
(~docs/oob-code :target-id "pp-comp" (~docs/oob-code :target-id "pp-comp"
:text (component-source "~examples/pp-view")) :text (helper "component-source" "~examples/pp-view"))
(~docs/oob-code :target-id "pp-wire" (~docs/oob-code :target-id "pp-wire"
:text (str "(~examples/pp-view :name \"" name "\" ...)"))))) :text (str "(~examples/pp-view :name \"" name "\" ...)")))))
@@ -652,12 +664,12 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((p (state-get "ex-profile" (let ((p (helper "state-get" "ex-profile"
{"name" "Ada Lovelace" "email" "ada@example.com" "role" "Engineer"}))) {"name" "Ada Lovelace" "email" "ada@example.com" "role" "Engineer"})))
(<> (<>
(~examples/pp-view :name (get p "name") :email (get p "email") :role (get p "role")) (~examples/pp-view :name (get p "name") :email (get p "email") :role (get p "role"))
(~docs/oob-code :target-id "pp-comp" (~docs/oob-code :target-id "pp-comp"
:text (component-source "~examples/pp-view")) :text (helper "component-source" "~examples/pp-view"))
(~docs/oob-code :target-id "pp-wire" (~docs/oob-code :target-id "pp-wire"
:text (str "(~examples/pp-view :name \"" (get p "name") "\" ...)"))))) :text (str "(~examples/pp-view :name \"" (get p "name") "\" ...)")))))
@@ -672,13 +684,13 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((data (request-json)) (let ((data (helper "request-json"))
(ct (request-content-type))) (ct (helper "request-content-type")))
(let ((body (json-encode data))) (let ((body (json-encode data)))
(<> (<>
(~examples/json-result :body body :content-type ct) (~examples/json-result :body body :content-type ct)
(~docs/oob-code :target-id "json-comp" (~docs/oob-code :target-id "json-comp"
:text (component-source "~examples/json-result")) :text (helper "component-source" "~examples/json-result"))
(~docs/oob-code :target-id "json-wire" (~docs/oob-code :target-id "json-wire"
:text (str "(~examples/json-result :body \"" body "\" :content-type \"" ct "\")")))))) :text (str "(~examples/json-result :body \"" body "\" :content-type \"" ct "\")"))))))
@@ -692,7 +704,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((vals (into (list) (request-args-all)))) (let ((vals (helper "into" (list) (helper "request-args-all"))))
(let ((filtered (filter (fn (pair) (and (not (= (first pair) "_")) (let ((filtered (filter (fn (pair) (and (not (= (first pair) "_"))
(not (= (first pair) "sx-request")))) (not (= (first pair) "sx-request"))))
vals))) vals)))
@@ -700,7 +712,7 @@
(<> (<>
(~examples/echo-result :label "values" :items items) (~examples/echo-result :label "values" :items items)
(~docs/oob-code :target-id "vals-comp" (~docs/oob-code :target-id "vals-comp"
:text (component-source "~examples/echo-result")) :text (helper "component-source" "~examples/echo-result"))
(~docs/oob-code :target-id "vals-wire" (~docs/oob-code :target-id "vals-wire"
:text (str "(~examples/echo-result :label \"values\" :items (list ...))"))))))) :text (str "(~examples/echo-result :label \"values\" :items (list ...))")))))))
@@ -709,13 +721,13 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((all-headers (into (list) (request-headers-all)))) (let ((all-headers (helper "into" (list) (helper "request-headers-all"))))
(let ((custom (filter (fn (pair) (starts-with? (first pair) "x-")) all-headers))) (let ((custom (filter (fn (pair) (starts-with? (first pair) "x-")) all-headers)))
(let ((items (map (fn (pair) (str (first pair) ": " (nth pair 1))) custom))) (let ((items (map (fn (pair) (str (first pair) ": " (nth pair 1))) custom)))
(<> (<>
(~examples/echo-result :label "headers" :items items) (~examples/echo-result :label "headers" :items items)
(~docs/oob-code :target-id "vals-comp" (~docs/oob-code :target-id "vals-comp"
:text (component-source "~examples/echo-result")) :text (helper "component-source" "~examples/echo-result"))
(~docs/oob-code :target-id "vals-wire" (~docs/oob-code :target-id "vals-wire"
:text (str "(~examples/echo-result :label \"headers\" :items (list ...))"))))))) :text (str "(~examples/echo-result :label \"headers\" :items (list ...))")))))))
@@ -730,11 +742,11 @@
:returns "element" :returns "element"
(&key) (&key)
(sleep 2000) (sleep 2000)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(~examples/loading-result :time now) (~examples/loading-result :time now)
(~docs/oob-code :target-id "loading-comp" (~docs/oob-code :target-id "loading-comp"
:text (component-source "~examples/loading-result")) :text (helper "component-source" "~examples/loading-result"))
(~docs/oob-code :target-id "loading-wire" (~docs/oob-code :target-id "loading-wire"
:text (str "(~examples/loading-result :time \"" now "\")"))))) :text (str "(~examples/loading-result :time \"" now "\")")))))
@@ -750,11 +762,11 @@
(&key) (&key)
(let ((delay-ms (random-int 500 2000))) (let ((delay-ms (random-int 500 2000)))
(sleep delay-ms) (sleep delay-ms)
(let ((q (request-arg "q" ""))) (let ((q (helper "request-arg" "q" "")))
(<> (<>
(~examples/sync-result :query q :delay (str delay-ms)) (~examples/sync-result :query q :delay (str delay-ms))
(~docs/oob-code :target-id "sync-comp" (~docs/oob-code :target-id "sync-comp"
:text (component-source "~examples/sync-result")) :text (helper "component-source" "~examples/sync-result"))
(~docs/oob-code :target-id "sync-wire" (~docs/oob-code :target-id "sync-wire"
:text (str "(~examples/sync-result :query \"" q "\" :delay \"" delay-ms "\")")))))) :text (str "(~examples/sync-result :query \"" q "\" :delay \"" delay-ms "\")"))))))
@@ -768,8 +780,9 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((n (+ (state-get "ex-flaky-n" 0) 1))) (let ((prev-flaky (helper "state-get" "ex-flaky-n" 0)))
(state-set! "ex-flaky-n" n) (let ((n (+ prev-flaky 1)))
(helper "state-set!" "ex-flaky-n" n)
(if (not (= (mod n 3) 0)) (if (not (= (mod n 3) 0))
(do (do
(set-response-status 503) (set-response-status 503)
@@ -777,6 +790,6 @@
(<> (<>
(~examples/retry-result :attempt (str n) :message "Success! The endpoint finally responded.") (~examples/retry-result :attempt (str n) :message "Success! The endpoint finally responded.")
(~docs/oob-code :target-id "retry-comp" (~docs/oob-code :target-id "retry-comp"
:text (component-source "~examples/retry-result")) :text (helper "component-source" "~examples/retry-result"))
(~docs/oob-code :target-id "retry-wire" (~docs/oob-code :target-id "retry-wire"
:text (str "(~examples/retry-result :attempt \"" n "\" ...)")))))) :text (str "(~examples/retry-result :attempt \"" n "\" ...)")))))))

View File

@@ -10,7 +10,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(span :class "text-stone-800 text-sm" "Server time: " (strong now)) (span :class "text-stone-800 text-sm" "Server time: " (strong now))
(~docs/oob-code :target-id "ref-wire-sx-get" (~docs/oob-code :target-id "ref-wire-sx-get"
@@ -24,7 +24,7 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((name (request-form "name" "stranger"))) (let ((name (helper "request-form" "name" "stranger")))
(<> (<>
(span :class "text-stone-800 text-sm" "Hello, " (strong name) "!") (span :class "text-stone-800 text-sm" "Hello, " (strong name) "!")
(~docs/oob-code :target-id "ref-wire-sx-post" (~docs/oob-code :target-id "ref-wire-sx-post"
@@ -38,7 +38,7 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((status (request-form "status" "unknown"))) (let ((status (helper "request-form" "status" "unknown")))
(<> (<>
(span :class "text-stone-700 text-sm" "Status: " (strong status) " — updated via PUT") (span :class "text-stone-700 text-sm" "Status: " (strong status) " — updated via PUT")
(~docs/oob-code :target-id "ref-wire-sx-put" (~docs/oob-code :target-id "ref-wire-sx-put"
@@ -52,7 +52,7 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((theme (request-form "theme" "unknown"))) (let ((theme (helper "request-form" "theme" "unknown")))
(<> (<>
theme theme
(~docs/oob-code :target-id "ref-wire-sx-patch" (~docs/oob-code :target-id "ref-wire-sx-patch"
@@ -76,7 +76,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((q (request-arg "q" ""))) (let ((q (helper "request-arg" "q" "")))
(let ((sx-text (if (= q "") (let ((sx-text (if (= q "")
"(span :class \"text-stone-400 text-sm\" \"Start typing to trigger a search.\")" "(span :class \"text-stone-400 text-sm\" \"Start typing to trigger a search.\")"
(str "(span :class \"text-stone-800 text-sm\" \"Results for: \" (strong \"" q "\"))")))) (str "(span :class \"text-stone-800 text-sm\" \"Results for: \" (strong \"" q "\"))"))))
@@ -93,7 +93,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(div :class "text-sm text-violet-700" (str "New item (" now ")")) (div :class "text-sm text-violet-700" (str "New item (" now ")"))
(~docs/oob-code :target-id "ref-wire-sx-swap" (~docs/oob-code :target-id "ref-wire-sx-swap"
@@ -106,7 +106,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(span :class "text-emerald-700 text-sm" "Main updated at " now) (span :class "text-emerald-700 text-sm" "Main updated at " now)
(div :id "ref-oob-side" :sx-swap-oob "innerHTML" (div :id "ref-oob-side" :sx-swap-oob "innerHTML"
@@ -121,7 +121,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(div :id "the-header" (h3 "Page header — not selected")) (div :id "the-header" (h3 "Page header — not selected"))
(div :id "the-content" (div :id "the-content"
@@ -138,7 +138,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((q (request-arg "q" ""))) (let ((q (helper "request-arg" "q" "")))
(sleep 800) (sleep 800)
(<> (<>
(span :class "text-stone-800 text-sm" "Echo: " (strong q)) (span :class "text-stone-800 text-sm" "Echo: " (strong q))
@@ -152,7 +152,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((name (request-header "SX-Prompt" "anonymous"))) (let ((name (helper "request-header" "SX-Prompt" "anonymous")))
(<> (<>
(span :class "text-stone-800 text-sm" "Hello, " (strong name) "!") (span :class "text-stone-800 text-sm" "Hello, " (strong name) "!")
(~docs/oob-code :target-id "ref-wire-sx-prompt" (~docs/oob-code :target-id "ref-wire-sx-prompt"
@@ -180,7 +180,7 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((name (request-file-name "file"))) (let ((name (helper "request-file-name" "file")))
(let ((display (if (nil? name) "(no file)" name))) (let ((display (if (nil? name) "(no file)" name)))
(let ((sx-text (str "(span :class \"text-stone-800 text-sm\" \"Received: \" (strong \"" display "\"))"))) (let ((sx-text (str "(span :class \"text-stone-800 text-sm\" \"Received: \" (strong \"" display "\"))")))
(<> (<>
@@ -194,7 +194,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((all-headers (into (list) (request-headers-all)))) (let ((all-headers (into (list) (helper "request-headers-all"))))
(let ((custom (filter (let ((custom (filter
(fn (pair) (starts-with? (first pair) "x-")) (fn (pair) (starts-with? (first pair) "x-"))
all-headers))) all-headers)))
@@ -218,7 +218,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((vals (into (list) (request-args-all)))) (let ((vals (into (list) (helper "request-args-all"))))
(let ((sx-text (let ((sx-text
(if (empty? vals) (if (empty? vals)
"(span :class \"text-stone-400 text-sm\" \"No values received.\")" "(span :class \"text-stone-400 text-sm\" \"No values received.\")"
@@ -240,7 +240,7 @@
:csrf false :csrf false
:returns "element" :returns "element"
(&key) (&key)
(let ((vals (into (list) (request-form-all)))) (let ((vals (into (list) (helper "request-form-all"))))
(let ((sx-text (let ((sx-text
(if (empty? vals) (if (empty? vals)
"(span :class \"text-stone-400 text-sm\" \"No values received.\")" "(span :class \"text-stone-400 text-sm\" \"No values received.\")"
@@ -261,8 +261,9 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((n (+ (state-get "ref-flaky-n" 0) 1))) (let ((prev (helper "state-get" "ref-flaky-n" 0)))
(state-set! "ref-flaky-n" n) (let ((n (+ prev 1)))
(helper "state-set!" "ref-flaky-n" n)
(if (not (= (mod n 3) 0)) (if (not (= (mod n 3) 0))
(do (do
(set-response-status 503) (set-response-status 503)
@@ -270,7 +271,7 @@
(let ((sx-text (str "(span :class \"text-emerald-700 text-sm\" \"Success on attempt \" \"" n "\" \"!\")"))) (let ((sx-text (str "(span :class \"text-emerald-700 text-sm\" \"Success on attempt \" \"" n "\" \"!\")")))
(<> (<>
(span :class "text-emerald-700 text-sm" "Success on attempt " (str n) "!") (span :class "text-emerald-700 text-sm" "Success on attempt " (str n) "!")
(~docs/oob-code :target-id "ref-wire-sx-retry" :text sx-text)))))) (~docs/oob-code :target-id "ref-wire-sx-retry" :text sx-text)))))))
;; --- sx-trigger-event demo: response header triggers --- ;; --- sx-trigger-event demo: response header triggers ---
@@ -279,7 +280,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(set-response-header "SX-Trigger" "showNotice") (set-response-header "SX-Trigger" "showNotice")
(<> (<>
(span :class "text-stone-800 text-sm" "Loaded at " (strong now) " — check the border!")))) (span :class "text-stone-800 text-sm" "Loaded at " (strong now) " — check the border!"))))
@@ -291,7 +292,7 @@
:method :get :method :get
:returns "element" :returns "element"
(&key) (&key)
(let ((now (now "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(set-response-header "SX-Retarget" "#ref-hdr-retarget-alt") (set-response-header "SX-Retarget" "#ref-hdr-retarget-alt")
(<> (<>
(span :class "text-violet-700 text-sm" "Retargeted at " (strong now))))) (span :class "text-violet-700 text-sm" "Retargeted at " (strong now)))))

View File

@@ -195,30 +195,30 @@
;; Validate — reset to default if out of range ;; Validate — reset to default if out of range
(when (or (< (deref step-idx) 0) (> (deref step-idx) 16)) (when (or (< (deref step-idx) 0) (> (deref step-idx) 16))
(reset! step-idx 9)))) (reset! step-idx 9))))
;; Auto-parse via effect ;; Auto-parse via effect (bind to _ to suppress return value in DOM)
(effect (fn () (let ((_eff (effect (fn ()
(let ((parsed (sx-parse source))) (let ((parsed (sx-parse source)))
(when (not (empty? parsed)) (when (not (empty? parsed))
(let ((result (list)) (let ((result (list))
(step-ref (dict "v" 0))) (step-ref (dict "v" 0)))
(split-tag (first parsed) result) (split-tag (first parsed) result)
(reset! steps result) (reset! steps result)
(let ((tokens (list))) (let ((tokens (list)))
(dict-set! step-ref "v" 0) (dict-set! step-ref "v" 0)
(build-code-tokens (first parsed) tokens step-ref 0) (build-code-tokens (first parsed) tokens step-ref 0)
(reset! code-tokens tokens)) (reset! code-tokens tokens))
;; Defer code DOM build until lake exists ;; Defer code DOM build until lake exists
(schedule-idle (fn () (schedule-idle (fn ()
(build-code-dom) (build-code-dom)
;; Clear preview and replay to initial step-idx ;; Clear preview and replay to initial step-idx
(let ((preview (get-preview))) (let ((preview (get-preview)))
(when preview (dom-set-prop preview "innerHTML" ""))) (when preview (dom-set-prop preview "innerHTML" "")))
(let ((target (deref step-idx))) (let ((target (deref step-idx)))
(reset! step-idx 0) (reset! step-idx 0)
(set-stack (list (get-preview))) (set-stack (list (get-preview)))
(for-each (fn (_) (do-step)) (slice (deref steps) 0 target))) (for-each (fn (_) (do-step)) (slice (deref steps) 0 target)))
(update-code-highlight) (update-code-highlight)
(run-post-render-hooks)))))))) (run-post-render-hooks))))))))))
(div :class "space-y-4" (div :class "space-y-4"
;; Code view lake — spans built imperatively, classes updated on step ;; Code view lake — spans built imperatively, classes updated on step
(div (~cssx/tw :tokens "font-mono bg-stone-50 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre-wrap") (div (~cssx/tw :tokens "font-mono bg-stone-50 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre-wrap")
@@ -241,4 +241,4 @@
"text-violet-300 cursor-not-allowed")) "text-violet-300 cursor-not-allowed"))
"\u25b6")) "\u25b6"))
;; Live preview lake ;; Live preview lake
(lake :id "home-preview"))))) (lake :id "home-preview"))))))

View File

@@ -60,7 +60,7 @@
(dict :label "Delivery" :href "/sx/(applications.(cssx.delivery))") (dict :label "Delivery" :href "/sx/(applications.(cssx.delivery))")
(dict :label "Async CSS" :href "/sx/(applications.(cssx.async))") (dict :label "Async CSS" :href "/sx/(applications.(cssx.async))")
(dict :label "Live Styles" :href "/sx/(applications.(cssx.live))") (dict :label "Live Styles" :href "/sx/(applications.(cssx.live))")
(dict :label "Comparisons" :href "/sx/(applications.(cssx.comparisons))") (dict :label "Comparisons" :href "/sx/(applications.(cssx.comparison))")
(dict :label "Philosophy" :href "/sx/(applications.(cssx.philosophy))"))) (dict :label "Philosophy" :href "/sx/(applications.(cssx.philosophy))")))
(define essays-nav-items (list (define essays-nav-items (list
@@ -252,25 +252,36 @@
(dict :label "Mother Language" :href "/sx/(etc.(plan.mother-language))" (dict :label "Mother Language" :href "/sx/(etc.(plan.mother-language))"
:summary "SX as its own compiler. OCaml as substrate (closest to CEK), Koka as alternative (compile-time linearity), ultimately self-hosting. One language, every target."))) :summary "SX as its own compiler. OCaml as substrate (closest to CEK), Koka as alternative (compile-time linearity), ultimately self-hosting. One language, every target.")))
(define reactive-examples-nav-items (list
{:label "Counter" :href "/sx/(geography.(reactive.(examples.counter)))"}
{:label "Temperature" :href "/sx/(geography.(reactive.(examples.temperature)))"}
{:label "Stopwatch" :href "/sx/(geography.(reactive.(examples.stopwatch)))"}
{:label "Imperative" :href "/sx/(geography.(reactive.(examples.imperative)))"}
{:label "Reactive List" :href "/sx/(geography.(reactive.(examples.reactive-list)))"}
{:label "Input Binding" :href "/sx/(geography.(reactive.(examples.input-binding)))"}
{:label "Portals" :href "/sx/(geography.(reactive.(examples.portal)))"}
{:label "Error Boundary" :href "/sx/(geography.(reactive.(examples.error-boundary)))"}
{:label "Refs" :href "/sx/(geography.(reactive.(examples.refs)))"}
{:label "Dynamic Class" :href "/sx/(geography.(reactive.(examples.dynamic-class)))"}
{:label "Resource" :href "/sx/(geography.(reactive.(examples.resource)))"}
{:label "Transitions" :href "/sx/(geography.(reactive.(examples.transition)))"}
{:label "Stores" :href "/sx/(geography.(reactive.(examples.stores)))"}
{:label "Event Bridge" :href "/sx/(geography.(reactive.(examples.event-bridge-demo)))"}
{:label "defisland" :href "/sx/(geography.(reactive.(examples.defisland)))"}
{:label "Tests" :href "/sx/(geography.(reactive.(examples.tests)))"}
{:label "Coverage" :href "/sx/(geography.(reactive.(examples.coverage)))"}))
(define reactive-islands-nav-items (list (define reactive-islands-nav-items (list
(dict :label "Examples" :href "/sx/(geography.(reactive.examples))" (dict :label "Examples" :href "/sx/(geography.(reactive.(examples)))"
:summary "Live interactive islands — click the buttons, type in the inputs." :summary "Live interactive islands — click the buttons, type in the inputs."
:children (list :children reactive-examples-nav-items)))
{:label "Counter" :href "/sx/(geography.(reactive.examples))#demo-counter"}
{:label "Temperature" :href "/sx/(geography.(reactive.examples))#demo-temperature"} (define marshes-examples-nav-items (list
{:label "Stopwatch" :href "/sx/(geography.(reactive.examples))#demo-stopwatch"} {:label "Hypermedia Feeds State" :href "/sx/(geography.(marshes.hypermedia-feeds))"}
{:label "Reactive List" :href "/sx/(geography.(reactive.examples))#demo-reactive-list"} {:label "Server Writes to Signals" :href "/sx/(geography.(marshes.server-signals))"}
{:label "Input Binding" :href "/sx/(geography.(reactive.examples))#demo-input-binding"} {:label "sx-on-settle" :href "/sx/(geography.(marshes.on-settle))"}
{:label "Portals" :href "/sx/(geography.(reactive.examples))#demo-portal"} {:label "Signal-Bound Triggers" :href "/sx/(geography.(marshes.signal-triggers))"}
{:label "Error Boundary" :href "/sx/(geography.(reactive.examples))#demo-error-boundary"} {:label "Reactive View Transform" :href "/sx/(geography.(marshes.view-transform))"}))
{:label "Refs" :href "/sx/(geography.(reactive.examples))#demo-refs"}
{:label "Dynamic Class" :href "/sx/(geography.(reactive.examples))#demo-dynamic-class"}
{:label "Resource" :href "/sx/(geography.(reactive.examples))#demo-resource"}
{:label "Transitions" :href "/sx/(geography.(reactive.examples))#demo-transition"}
{:label "Shared Stores" :href "/sx/(geography.(reactive.examples))#demo-stores"}
{:label "Event Bridge" :href "/sx/(geography.(reactive.examples))#demo-event-bridge"}))
(dict :label "Marshes" :href "/sx/(geography.(reactive.marshes))"
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms, client state modifies hypermedia.")))
(define bootstrappers-nav-items (list (define bootstrappers-nav-items (list
(dict :label "Overview" :href "/sx/(language.(bootstrapper))") (dict :label "Overview" :href "/sx/(language.(bootstrapper))")
@@ -417,7 +428,8 @@
{:label "Spreads" :href "/sx/(geography.(spreads))" {:label "Spreads" :href "/sx/(geography.(spreads))"
:summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on scopes."} :summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on scopes."}
{:label "Marshes" :href "/sx/(geography.(marshes))" {:label "Marshes" :href "/sx/(geography.(marshes))"
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."} :summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."
:children marshes-examples-nav-items}
{:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items} {:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items}
{:label "CEK Machine" :href "/sx/(geography.(cek))" :children cek-nav-items})} {:label "CEK Machine" :href "/sx/(geography.(cek))" :children cek-nav-items})}
{:label "Language" :href "/sx/(language)" {:label "Language" :href "/sx/(language)"

View File

@@ -1,22 +1,51 @@
;; SX docs page functions — section + page dispatch for GraphSX URL routing. ;; SX docs page functions — section + page dispatch for GraphSX URL routing.
;; ;;
;; IMPORTANT: Page functions return QUOTED expressions (unevaluated ASTs). ;; Page functions return QUOTED expressions (unevaluated ASTs).
;; The Python router evaluates these functions with async_eval to get the AST, ;; The router evaluates these via the OCaml kernel (or Python fallback).
;; then passes it through _eval_slot (aser) for component expansion and HTML
;; tag handling. This two-phase approach is necessary because eval_expr doesn't
;; handle HTML tags — only the aser/render paths do.
;; ;;
;; Pattern: ;; Pattern:
;; Simple: '(~component-name) ;; Simple: '(~component-name)
;; Data: (let ((data (helper))) `(~component :key ,val)) ;; Data: (let ((data (helper "name" arg))) `(~component :key ,val))
;; ;;
;; URL eval: /(language.(doc.introduction)) ;; IO: Application data is fetched via the (helper name ...) IO primitive,
;; → (language (doc "introduction")) ;; which dispatches to Python page helpers through the coroutine bridge.
;; → async_eval returns [Symbol("~docs-content/docs-introduction-content")] ;; This keeps the spec clean — no application functions leak into the kernel.
;; → _eval_slot wraps in (~layouts/doc :path "..." <ast>) and renders via aser
;; ---------------------------------------------------------------------------
;; Convention-based page dispatch
;; ---------------------------------------------------------------------------
;; ;;
;; NOTE: Lambda &rest is not supported by call-lambda in the current spec. ;; Most page functions are boilerplate: slug → component name via a naming
;; All functions take explicit positional params; missing args default to nil. ;; convention. Instead of hand-writing case statements, derive the component
;; symbol from the slug at runtime.
;;
;; Naming conventions:
;; essay: "sx-sucks" → ~essays/sx-sucks/essay-sx-sucks
;; plan: "status" → ~plans/status/plan-status-content
;; example: "tabs" → ~examples-content/example-tabs
;; protocol: "fragments" → ~protocols/fragments-content
;; cssx: "patterns" → ~cssx/patterns-content
;; ri-example: "counter" → ~reactive-islands/demo/example-counter
;; Build a component symbol from a slug and a naming pattern.
;; Pattern: prefix + slug + infix + slug + suffix
;; When infix is nil, slug appears once: prefix + slug + suffix
(define slug->component
(fn (slug prefix infix suffix)
(if infix
(make-symbol (str prefix slug infix slug suffix))
(make-symbol (str prefix slug suffix)))))
;; Make a simple slug-dispatcher: given a naming convention, returns a function
;; that maps (slug) → '(~derived-component-name).
;; default-name is a STRING of the component name for the nil-slug (index) case.
;; (We use a string + make-symbol because bare ~symbols get evaluated as lookups.)
(define make-page-fn
(fn (default-name prefix infix suffix)
(fn (slug)
(if (nil? slug)
(list (make-symbol default-name))
(list (slug->component slug prefix infix suffix))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Section functions — structural, pass through content or return index ;; Section functions — structural, pass through content or return index
@@ -49,16 +78,15 @@
(if (nil? content) nil content))) (if (nil? content) nil content)))
(define reactive (define reactive
(fn (slug) (fn (content)
(if (nil? slug) (if (nil? content)
'(~reactive-islands/index/reactive-islands-index-content) '(~reactive-islands/index/reactive-islands-index-content)
(case slug content)))
"demo" '(~reactive-islands/demo/reactive-islands-demo-content)
"event-bridge" '(~reactive-islands/event-bridge/reactive-islands-event-bridge-content) ;; Convention: ~reactive-islands/demo/example-{slug}
"named-stores" '(~reactive-islands/named-stores/reactive-islands-named-stores-content) (define examples
"plan" '(~reactive-islands/plan/reactive-islands-plan-content) (make-page-fn "~reactive-islands/demo/reactive-islands-demo-content" "~reactive-islands/demo/example-" nil ""))
"phase2" '(~reactive-islands/phase2/reactive-islands-phase2-content)
:else '(~reactive-islands/index/reactive-islands-index-content)))))
(define cek (define cek
(fn (slug) (fn (slug)
@@ -83,8 +111,16 @@
(if (nil? content) '(~geography/spreads-content) content))) (if (nil? content) '(~geography/spreads-content) content)))
(define marshes (define marshes
(fn (content) (fn (slug)
(if (nil? content) '(~reactive-islands/marshes/reactive-islands-marshes-content) content))) (if (nil? slug)
'(~reactive-islands/marshes/reactive-islands-marshes-content)
(case slug
"hypermedia-feeds" '(~reactive-islands/marshes/example-hypermedia-feeds)
"server-signals" '(~reactive-islands/marshes/example-server-signals)
"on-settle" '(~reactive-islands/marshes/example-on-settle)
"signal-triggers" '(~reactive-islands/marshes/example-signal-triggers)
"view-transform" '(~reactive-islands/marshes/example-view-transform)
:else '(~reactive-islands/marshes/reactive-islands-marshes-content)))))
(define isomorphism (define isomorphism
(fn (slug) (fn (slug)
@@ -92,7 +128,7 @@
'(~plans/isomorphic/plan-isomorphic-content) '(~plans/isomorphic/plan-isomorphic-content)
(case slug (case slug
"bundle-analyzer" "bundle-analyzer"
(let ((data (bundle-analyzer-data))) (let ((data (helper "bundle-analyzer-data")))
`(~analyzer/bundle-analyzer-content `(~analyzer/bundle-analyzer-content
:pages ,(get data "pages") :pages ,(get data "pages")
:total-components ,(get data "total-components") :total-components ,(get data "total-components")
@@ -100,7 +136,7 @@
:pure-count ,(get data "pure-count") :pure-count ,(get data "pure-count")
:io-count ,(get data "io-count"))) :io-count ,(get data "io-count")))
"routing-analyzer" "routing-analyzer"
(let ((data (routing-analyzer-data))) (let ((data (helper "routing-analyzer-data")))
`(~routing-analyzer/content `(~routing-analyzer/content
:pages ,(get data "pages") :pages ,(get data "pages")
:total-pages ,(get data "total-pages") :total-pages ,(get data "total-pages")
@@ -108,7 +144,7 @@
:server-count ,(get data "server-count") :server-count ,(get data "server-count")
:registry-sample ,(get data "registry-sample"))) :registry-sample ,(get data "registry-sample")))
"data-test" "data-test"
(let ((data (data-test-data))) (let ((data (helper "data-test-data")))
`(~data-test/content `(~data-test/content
:server-time ,(get data "server-time") :server-time ,(get data "server-time")
:items ,(get data "items") :items ,(get data "items")
@@ -116,17 +152,17 @@
:transport ,(get data "transport"))) :transport ,(get data "transport")))
"async-io" '(~async-io-demo/content) "async-io" '(~async-io-demo/content)
"affinity" "affinity"
(let ((data (affinity-demo-data))) (let ((data (helper "affinity-demo-data")))
`(~affinity-demo/content `(~affinity-demo/content
:components ,(get data "components") :components ,(get data "components")
:page-plans ,(get data "page-plans"))) :page-plans ,(get data "page-plans")))
"optimistic" "optimistic"
(let ((data (optimistic-demo-data))) (let ((data (helper "optimistic-demo-data")))
`(~optimistic-demo/content `(~optimistic-demo/content
:items ,(get data "items") :items ,(get data "items")
:server-time ,(get data "server-time"))) :server-time ,(get data "server-time")))
"offline" "offline"
(let ((data (offline-demo-data))) (let ((data (helper "offline-demo-data")))
`(~offline-demo/content `(~offline-demo/content
:notes ,(get data "notes") :notes ,(get data "notes")
:server-time ,(get data "server-time"))) :server-time ,(get data "server-time")))
@@ -148,11 +184,11 @@
"components" '(~docs-content/docs-components-content) "components" '(~docs-content/docs-components-content)
"evaluator" '(~docs-content/docs-evaluator-content) "evaluator" '(~docs-content/docs-evaluator-content)
"primitives" "primitives"
(let ((data (primitives-data))) (let ((data (helper "primitives-data")))
`(~docs-content/docs-primitives-content `(~docs-content/docs-primitives-content
:prims (~docs/primitives-tables :primitives ,data))) :prims (~docs/primitives-tables :primitives ,data)))
"special-forms" "special-forms"
(let ((data (special-forms-data))) (let ((data (helper "special-forms-data")))
`(~docs-content/docs-special-forms-content `(~docs-content/docs-special-forms-content
:forms (~docs/special-forms-tables :forms ,data))) :forms (~docs/special-forms-tables :forms ,data)))
"server-rendering" '(~docs-content/docs-server-rendering-content) "server-rendering" '(~docs-content/docs-server-rendering-content)
@@ -184,7 +220,7 @@
`(~specs/overview-content :spec-title "Extensions" :spec-files ,files)) `(~specs/overview-content :spec-title "Extensions" :spec-files ,files))
:else (let ((found-spec (find-spec slug))) :else (let ((found-spec (find-spec slug)))
(if found-spec (if found-spec
(let ((src (read-spec-file (get found-spec "filename")))) (let ((src (helper "read-spec-file" (get found-spec "filename"))))
`(~specs/detail-content `(~specs/detail-content
:spec-title ,(get found-spec "title") :spec-title ,(get found-spec "title")
:spec-desc ,(get found-spec "desc") :spec-desc ,(get found-spec "desc")
@@ -200,7 +236,7 @@
'(~specs/architecture-content) '(~specs/architecture-content)
(let ((found-spec (find-spec slug))) (let ((found-spec (find-spec slug)))
(if found-spec (if found-spec
(let ((data (spec-explorer-data (let ((data (helper "spec-explorer-data"
(get found-spec "filename") (get found-spec "filename")
(get found-spec "title") (get found-spec "title")
(get found-spec "desc")))) (get found-spec "desc"))))
@@ -216,7 +252,7 @@
(dict :title (get item "title") :desc (get item "desc") (dict :title (get item "title") :desc (get item "desc")
:prose (get item "prose") :prose (get item "prose")
:filename (get item "filename") :href (str "/sx/(language.(spec." (get item "slug") "))") :filename (get item "filename") :href (str "/sx/(language.(spec." (get item "slug") "))")
:source (read-spec-file (get item "filename")))) :source (helper "read-spec-file" (get item "filename"))))
items))) items)))
;; Bootstrappers (under language) ;; Bootstrappers (under language)
@@ -224,7 +260,7 @@
(fn (slug) (fn (slug)
(if (nil? slug) (if (nil? slug)
'(~specs/bootstrappers-index-content) '(~specs/bootstrappers-index-content)
(let ((data (bootstrapper-data slug))) (let ((data (helper "bootstrapper-data" slug)))
(if (get data "bootstrapper-not-found") (if (get data "bootstrapper-not-found")
`(~specs/not-found :slug ,slug) `(~specs/not-found :slug ,slug)
(case slug (case slug
@@ -250,7 +286,7 @@
:bootstrapper-source ,(get data "bootstrapper-source") :bootstrapper-source ,(get data "bootstrapper-source")
:bootstrapped-output ,(get data "bootstrapped-output")) :bootstrapped-output ,(get data "bootstrapped-output"))
"page-helpers" "page-helpers"
(let ((ph-data (page-helpers-demo-data))) (let ((ph-data (helper "page-helpers-demo-data")))
`(~page-helpers-demo/content `(~page-helpers-demo/content
:sf-categories ,(get ph-data "sf-categories") :sf-categories ,(get ph-data "sf-categories")
:sf-total ,(get ph-data "sf-total") :sf-total ,(get ph-data "sf-total")
@@ -277,7 +313,7 @@
(define test (define test
(fn (slug) (fn (slug)
(if (nil? slug) (if (nil? slug)
(let ((data (run-modular-tests "all"))) (let ((data (helper "run-modular-tests""all")))
`(~testing/overview-content `(~testing/overview-content
:server-results ,(get data "server-results") :server-results ,(get data "server-results")
:framework-source ,(get data "framework-source") :framework-source ,(get data "framework-source")
@@ -290,7 +326,7 @@
(case slug (case slug
"runners" '(~testing/runners-content) "runners" '(~testing/runners-content)
:else :else
(let ((data (run-modular-tests slug))) (let ((data (helper "run-modular-tests"slug)))
(case slug (case slug
"eval" `(~testing/spec-content "eval" `(~testing/spec-content
:spec-name "eval" :spec-title "Evaluator Tests" :spec-name "eval" :spec-title "Evaluator Tests"
@@ -342,7 +378,7 @@
(fn (slug) (fn (slug)
(if (nil? slug) (if (nil? slug)
'(~examples/reference-index-content) '(~examples/reference-index-content)
(let ((data (reference-data slug))) (let ((data (helper "reference-data" slug)))
(case slug (case slug
"attributes" `(~reference/attrs-content "attributes" `(~reference/attrs-content
:req-table (~docs/attr-table-from-data :title "Request Attributes" :attrs ,(get data "req-attrs")) :req-table (~docs/attr-table-from-data :title "Request Attributes" :attrs ,(get data "req-attrs"))
@@ -371,7 +407,7 @@
(if (nil? slug) nil (if (nil? slug) nil
(case kind (case kind
"attributes" "attributes"
(let ((data (attr-detail-data slug))) (let ((data (helper "attr-detail-data" slug)))
(if (get data "attr-not-found") (if (get data "attr-not-found")
`(~reference/attr-not-found :slug ,slug) `(~reference/attr-not-found :slug ,slug)
`(~reference/attr-detail-content `(~reference/attr-detail-content
@@ -382,7 +418,7 @@
:handler-code ,(get data "attr-handler") :handler-code ,(get data "attr-handler")
:wire-placeholder-id ,(get data "attr-wire-id")))) :wire-placeholder-id ,(get data "attr-wire-id"))))
"headers" "headers"
(let ((data (header-detail-data slug))) (let ((data (helper "header-detail-data" slug)))
(if (get data "header-not-found") (if (get data "header-not-found")
`(~reference/attr-not-found :slug ,slug) `(~reference/attr-not-found :slug ,slug)
`(~reference/header-detail-content `(~reference/header-detail-content
@@ -392,7 +428,7 @@
:example-code ,(get data "header-example") :example-code ,(get data "header-example")
:demo ,(get data "header-demo")))) :demo ,(get data "header-demo"))))
"events" "events"
(let ((data (event-detail-data slug))) (let ((data (helper "event-detail-data" slug)))
(if (get data "event-not-found") (if (get data "event-not-found")
`(~reference/attr-not-found :slug ,slug) `(~reference/attr-not-found :slug ,slug)
`(~reference/event-detail-content `(~reference/event-detail-content
@@ -403,39 +439,11 @@
:else nil)))) :else nil))))
;; Examples (under geography → hypermedia) ;; Examples (under geography → hypermedia)
;; Convention: ~examples-content/example-{slug}
(define example (define example
(fn (slug) (fn (slug)
(if (nil? slug) (if (nil? slug) nil
nil (list (slug->component slug "~examples-content/example-" nil "")))))
(case slug
"click-to-load" '(~examples-content/example-click-to-load)
"form-submission" '(~examples-content/example-form-submission)
"polling" '(~examples-content/example-polling)
"delete-row" '(~examples-content/example-delete-row)
"inline-edit" '(~examples-content/example-inline-edit)
"oob-swaps" '(~examples-content/example-oob-swaps)
"lazy-loading" '(~examples-content/example-lazy-loading)
"infinite-scroll" '(~examples-content/example-infinite-scroll)
"progress-bar" '(~examples-content/example-progress-bar)
"active-search" '(~examples-content/example-active-search)
"inline-validation" '(~examples-content/example-inline-validation)
"value-select" '(~examples-content/example-value-select)
"reset-on-submit" '(~examples-content/example-reset-on-submit)
"edit-row" '(~examples-content/example-edit-row)
"bulk-update" '(~examples-content/example-bulk-update)
"swap-positions" '(~examples-content/example-swap-positions)
"select-filter" '(~examples-content/example-select-filter)
"tabs" '(~examples-content/example-tabs)
"animations" '(~examples-content/example-animations)
"dialogs" '(~examples-content/example-dialogs)
"keyboard-shortcuts" '(~examples-content/example-keyboard-shortcuts)
"put-patch" '(~examples-content/example-put-patch)
"json-encoding" '(~examples-content/example-json-encoding)
"vals-and-headers" '(~examples-content/example-vals-and-headers)
"loading-states" '(~examples-content/example-loading-states)
"sync-replace" '(~examples-content/example-sync-replace)
"retry" '(~examples-content/example-retry)
:else '(~examples-content/example-click-to-load)))))
;; SX URLs (under applications) ;; SX URLs (under applications)
(define sx-urls (define sx-urls
@@ -443,59 +451,19 @@
'(~sx-urls/urls-content))) '(~sx-urls/urls-content)))
;; CSSX (under applications) ;; CSSX (under applications)
;; Convention: ~cssx/{slug}-content
(define cssx (define cssx
(fn (slug) (make-page-fn "~cssx/overview-content" "~cssx/" nil "-content"))
(if (nil? slug)
'(~cssx/overview-content)
(case slug
"patterns" '(~cssx/patterns-content)
"delivery" '(~cssx/delivery-content)
"async" '(~cssx/async-content)
"live" '(~cssx/live-content)
"comparisons" '(~cssx/comparison-content)
"philosophy" '(~cssx/philosophy-content)
:else '(~cssx/overview-content)))))
;; Protocols (under applications) ;; Protocols (under applications)
;; Convention: ~protocols/{slug}-content
(define protocol (define protocol
(fn (slug) (make-page-fn "~protocols/wire-format-content" "~protocols/" nil "-content"))
(if (nil? slug)
'(~protocols/wire-format-content)
(case slug
"wire-format" '(~protocols/wire-format-content)
"fragments" '(~protocols/fragments-content)
"resolver-io" '(~protocols/resolver-io-content)
"internal-services" '(~protocols/internal-services-content)
"activitypub" '(~protocols/activitypub-content)
"future" '(~protocols/future-content)
:else '(~protocols/wire-format-content)))))
;; Essays (under etc) ;; Essays (under etc)
;; Convention: ~essays/{slug}/essay-{slug}
(define essay (define essay
(fn (slug) (make-page-fn "~essays/index/essays-index-content" "~essays/" "/essay-" ""))
(if (nil? slug)
'(~essays/index/essays-index-content)
(case slug
"sx-sucks" '(~essays/sx-sucks/essay-sx-sucks)
"why-sexps" '(~essays/why-sexps/essay-why-sexps)
"htmx-react-hybrid" '(~essays/htmx-react-hybrid/essay-htmx-react-hybrid)
"on-demand-css" '(~essays/on-demand-css/essay-on-demand-css)
"client-reactivity" '(~essays/client-reactivity/essay-client-reactivity)
"sx-native" '(~essays/sx-native/essay-sx-native)
"tail-call-optimization" '(~essays/tail-call-optimization/essay-tail-call-optimization)
"continuations" '(~essays/continuations/essay-continuations)
"reflexive-web" '(~essays/reflexive-web/essay-reflexive-web)
"server-architecture" '(~essays/server-architecture/essay-server-architecture)
"separation-of-concerns" '(~essays/separation-of-concerns/essay-separation-of-concerns)
"sx-and-ai" '(~essays/sx-and-ai/essay-sx-and-ai)
"no-alternative" '(~essays/no-alternative/essay-no-alternative)
"zero-tooling" '(~essays/zero-tooling/essay-zero-tooling)
"react-is-hypermedia" '(~essays/react-is-hypermedia/essay-react-is-hypermedia)
"hegelian-synthesis" '(~essays/hegelian-synthesis/essay-hegelian-synthesis)
"the-art-chain" '(~essays/the-art-chain/essay-the-art-chain)
"self-defining-medium" '(~essays/self-defining-medium/essay-self-defining-medium)
"hypermedia-age-of-ai" '(~essays/hypermedia-age-of-ai/essay-hypermedia-age-of-ai)
:else '(~essays/index/essays-index-content)))))
;; Philosophy (under etc) ;; Philosophy (under etc)
(define philosophy (define philosophy
@@ -512,47 +480,6 @@
:else '(~essays/philosophy-index/content))))) :else '(~essays/philosophy-index/content)))))
;; Plans (under etc) ;; Plans (under etc)
;; Convention: ~plans/{slug}/plan-{slug}-content
(define plan (define plan
(fn (slug) (make-page-fn "~plans/index/plans-index-content" "~plans/" "/plan-" "-content"))
(if (nil? slug)
'(~plans/index/plans-index-content)
(case slug
"status" '(~plans/status/plan-status-content)
"reader-macros" '(~plans/reader-macros/plan-reader-macros-content)
"reader-macro-demo" '(~plans/reader-macro-demo/plan-reader-macro-demo-content)
"theorem-prover"
(let ((data (prove-data)))
'(~plans/theorem-prover/plan-theorem-prover-content))
"self-hosting-bootstrapper" '(~plans/self-hosting-bootstrapper/plan-self-hosting-bootstrapper-content)
"js-bootstrapper" '(~plans/js-bootstrapper/plan-js-bootstrapper-content)
"sx-activity" '(~plans/sx-activity/plan-sx-activity-content)
"predictive-prefetch" '(~plans/predictive-prefetch/plan-predictive-prefetch-content)
"content-addressed-components" '(~plans/content-addressed-components/plan-content-addressed-components-content)
"environment-images" '(~plans/environment-images/plan-environment-images-content)
"runtime-slicing" '(~plans/runtime-slicing/plan-runtime-slicing-content)
"typed-sx" '(~plans/typed-sx/plan-typed-sx-content)
"nav-redesign" '(~plans/nav-redesign/plan-nav-redesign-content)
"fragment-protocol" '(~plans/fragment-protocol/plan-fragment-protocol-content)
"glue-decoupling" '(~plans/glue-decoupling/plan-glue-decoupling-content)
"social-sharing" '(~plans/social-sharing/plan-social-sharing-content)
"sx-ci" '(~plans/sx-ci/plan-sx-ci-content)
"live-streaming" '(~plans/live-streaming/plan-live-streaming-content)
"sx-web-platform" '(~plans/sx-web-platform/plan-sx-web-platform-content)
"sx-forge" '(~plans/sx-forge/plan-sx-forge-content)
"sx-swarm" '(~plans/sx-swarm/plan-sx-swarm-content)
"sx-proxy" '(~plans/sx-proxy/plan-sx-proxy-content)
"mother-language" '(~plans/mother-language/plan-mother-language-content)
"isolated-evaluator" '(~plans/isolated-evaluator/plan-isolated-evaluator-content)
"rust-wasm-host" '(~plans/rust-wasm-host/plan-rust-wasm-host-content)
"async-eval-convergence" '(~plans/async-eval-convergence/plan-async-eval-convergence-content)
"wasm-bytecode-vm" '(~plans/wasm-bytecode-vm/plan-wasm-bytecode-vm-content)
"generative-sx" '(~plans/generative-sx/plan-generative-sx-content)
"art-dag-sx" '(~plans/art-dag-sx/plan-art-dag-sx-content)
"spec-explorer" '(~plans/spec-explorer/plan-spec-explorer-content)
"sx-urls" '(~plans/sx-urls/plan-sx-urls-content)
"sx-protocol" '(~plans/sx-protocol/plan-sx-protocol-content)
"scoped-effects" '(~plans/scoped-effects/plan-scoped-effects-content)
"foundations" '(~plans/foundations/plan-foundations-content)
"cek-reactive" '(~plans/cek-reactive/plan-cek-reactive-content)
"reactive-runtime" '(~plans/reactive-runtime/plan-reactive-runtime-content)
:else '(~plans/index/plans-index-content)))))

View File

@@ -1,192 +1,218 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Examples page — live interactive islands, one per section ;; Examples — individual reactive island demo pages
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Overview page — summary with links to individual examples
(defcomp ~reactive-islands/demo/reactive-islands-demo-content () (defcomp ~reactive-islands/demo/reactive-islands-demo-content ()
(~docs/page :title "Reactive Islands — Examples" (~docs/page :title "Reactive Islands — Examples"
(~docs/section :title "Live interactive islands" :id "intro" (~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 (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") ").")) (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"
(~docs/section :title "1. Signal + Computed + Effect" :id "demo-counter" (ol :class "space-y-1"
(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.") (map (fn (item)
(~reactive-islands/index/demo-counter :initial 0) (li (a :href (get item "href")
(~docs/code :code (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")) :sx-get (get item "href") :sx-target "#main-panel"
(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.")) :sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
(~docs/section :title "2. Temperature Converter" :id "demo-temperature" :class "text-violet-600 hover:underline"
(p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.") (get item "label"))))
(~reactive-islands/index/demo-temperature) reactive-examples-nav-items)))))
(~docs/code :code (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."))
(~docs/section :title "3. Effect + Cleanup: Stopwatch" :id "demo-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 :code (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."))
(~docs/section :title "4. Imperative Pattern" :id "demo-imperative"
(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 :code (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."))
(~docs/section :title "5. Reactive List" :id "demo-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 :code (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."))
(~docs/section :title "6. Input Binding" :id "demo-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 :code (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."))
(~docs/section :title "7. Portals" :id "demo-portal"
(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 :code (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."))
(~docs/section :title "8. Error Boundaries" :id "demo-error-boundary"
(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 :code (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") "."))
(~docs/section :title "9. Refs — Imperative DOM Access" :id "demo-refs"
(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 :code (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\")") "."))
(~docs/section :title "10. Dynamic Class and Style" :id "demo-dynamic-class"
(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 :code (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."))
(~docs/section :title "11. Resource + Suspense Pattern" :id "demo-resource"
(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 :code (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."))
(~docs/section :title "12. Transition Pattern" :id "demo-transition"
(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 :code (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."))
(~docs/section :title "13. Shared Stores" :id "demo-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 :code (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."))
(~docs/section :title "14. Event Bridge" :id "demo-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 :code (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."))
(~docs/section :title "15. How defisland Works" :id "how-defisland"
(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 :code (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;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\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."))
(~docs/section :title "16. Test suite" :id "demo-tests"
(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 :code (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")))
(~docs/section :title "React Feature Coverage" :id "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" ""))))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Event Bridge — DOM events for lake→island communication ;; Individual example pages
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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-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 :code (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;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\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 :code (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" "")))))))

View File

@@ -28,7 +28,7 @@
(ul :class "space-y-2 text-stone-600 list-disc pl-5" (ul :class "space-y-2 text-stone-600 list-disc pl-5"
(li (strong "Swap inside island: ") "Signals survive. The lake content is replaced but the island's signal closures are untouched. Effects re-bind to new DOM nodes if needed.") (li (strong "Swap inside island: ") "Signals survive. The lake content is replaced but the island's signal closures are untouched. Effects re-bind to new DOM nodes if needed.")
(li (strong "Swap outside island: ") "Signals survive. The island is not affected by swaps to other parts of the page.") (li (strong "Swap outside island: ") "Signals survive. The island is not affected by swaps to other parts of the page.")
(li (strong "Swap replaces island: ") "Signals are " (em "lost") ". The island is disposed. This is where " (a :href "/sx/(geography.(reactive.named-stores))" :sx-get "/sx/(geography.(reactive.named-stores))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "named stores") " come in — they persist at page level, surviving island destruction."))) (li (strong "Swap replaces island: ") "Signals are " (em "lost") ". The island is disposed. This is where " (a :href "/sx/(geography.(reactive.(named-stores)))" :sx-get "/sx/(geography.(reactive.(named-stores)))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "named stores") " come in — they persist at page level, surviving island destruction.")))
(~docs/section :title "Spec" :id "spec" (~docs/section :title "Spec" :id "spec"
(p "The event bridge is spec'd in " (code "signals.sx") " (sections 12-13). Three functions:") (p "The event bridge is spec'd in " (code "signals.sx") " (sections 12-13). Three functions:")

View File

@@ -63,6 +63,41 @@
(li (strong "Updates: ") "Signal changes update only subscribed DOM nodes. No full island re-render") (li (strong "Updates: ") "Signal changes update only subscribed DOM nodes. No full island re-render")
(li (strong "Disposal: ") "Island removed from DOM — all signals and effects cleaned up via " (code "with-island-scope")))) (li (strong "Disposal: ") "Island removed from DOM — all signals and effects cleaned up via " (code "with-island-scope"))))
(~docs/section :title "htmx Lakes" :id "lakes"
(p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia. This works because signals live in closures, not the DOM.")
(div :class "space-y-2 mt-3"
(div :class "rounded border border-green-200 bg-green-50 p-3"
(div :class "font-semibold text-green-800 text-sm" "Swap inside island")
(p :class "text-sm text-stone-600 mt-1" "Lake content replaced. Signals survive. Effects rebind to new DOM."))
(div :class "rounded border border-green-200 bg-green-50 p-3"
(div :class "font-semibold text-green-800 text-sm" "Swap outside island")
(p :class "text-sm text-stone-600 mt-1" "Different part of page updated. Island completely unaffected."))
(div :class "rounded border border-amber-200 bg-amber-50 p-3"
(div :class "font-semibold text-amber-800 text-sm" "Swap replaces island")
(p :class "text-sm text-stone-600 mt-1" "Island disposed. Local signals lost. Named stores persist — new island reconnects via " (code "use-store") "."))
(div :class "rounded border border-stone-200 p-3"
(div :class "font-semibold text-stone-800 text-sm" "Full page navigation")
(p :class "text-sm text-stone-600 mt-1" "Everything cleared. " (code "clear-stores") " wipes the registry."))))
(~docs/section :title "Event Bridge" :id "event-bridge"
(p "A lake has no access to island signals, but can communicate back via DOM custom events. Elements with " (code "data-sx-emit") " dispatch a " (code "CustomEvent") " on click; an island effect catches it and updates a signal.")
(~docs/code :code (highlight ";; Island listens for events from server-rendered lake content\n(bridge-event container \"cart:add\" items\n (fn (detail) (append (deref items) detail)))\n\n;; Server-rendered button dispatches CustomEvent on click\n(button :data-sx-emit \"cart:add\"\n :data-sx-emit-detail (json-serialize (dict :id 42))\n \"Add to Cart\")" "lisp"))
(p "Three primitives: " (code "emit-event") " (dispatch), " (code "on-event") " (listen), " (code "bridge-event") " (listen + update signal with automatic cleanup)."))
(~docs/section :title "Named Stores" :id "stores"
(p "A named store is a dict of signals at " (em "page") " scope — not island scope. Multiple islands share the same signals. Stores survive island destruction and recreation.")
(~docs/code :code (highlight ";; Create once — idempotent, returns existing on second call\n(def-store \"cart\" (fn ()\n (dict :items (signal (list))\n :count (computed (fn () (length (deref items)))))))\n\n;; Use from any island, anywhere in the DOM\n(let ((store (use-store \"cart\")))\n (span (deref (get store \"count\"))))" "lisp"))
(p (code "def-store") " creates, " (code "use-store") " retrieves, " (code "clear-stores") " wipes all on full page navigation."))
(~docs/section :title "Design Principles" :id "principles"
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
(li (strong "Islands are opt-in.") " " (code "defcomp") " is the default. " (code "defisland") " adds reactivity. No overhead for static content.")
(li (strong "Signals are values, not hooks.") " Create anywhere — conditionals, loops, closures. No rules of hooks, no dependency arrays.")
(li (strong "Fine-grained, not component-grained.") " A signal change updates the specific DOM node that reads it. No virtual DOM, no diffing, no component re-renders.")
(li (strong "The server is still the authority.") " Islands handle client interactions. The server handles auth, data, routing.")
(li (strong "Spec-first.") " Signal semantics live in " (code "signals.sx") ". Bootstrapped to JS and Python. Same primitives on future hosts.")
(li (strong "No build step.") " Reactive bindings created at runtime. No JSX compilation, no bundler plugins.")))
(~docs/section :title "Implementation Status" :id "status" (~docs/section :title "Implementation Status" :id "status"
(p :class "text-stone-600 mb-3" "All signal logic lives in " (code ".sx") " spec files and is bootstrapped to JavaScript and Python. No SX-specific logic in host languages.") (p :class "text-stone-600 mb-3" "All signal logic lives in " (code ".sx") " spec files and is bootstrapped to JavaScript and Python. No SX-specific logic in host languages.")
@@ -141,11 +176,6 @@
(td :class "px-3 py-2 text-stone-700" "Portals") (td :class "px-3 py-2 text-stone-700" "Portals")
(td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: portal render-dom form")) (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: portal render-dom form"))
(tr
(td :class "px-3 py-2 text-stone-700" "Phase 2 remaining")
(td :class "px-3 py-2 text-stone-500 font-medium" "P2")
(td :class "px-3 py-2 font-mono text-xs text-stone-500"
(a :href "/sx/(geography.(reactive.phase2))" :sx-get "/sx/(geography.(reactive.phase2))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries + resource + patterns")))
(tr :class "border-b border-stone-100" (tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Error boundaries") (td :class "px-3 py-2 text-stone-700" "Error boundaries")
(td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 text-green-700 font-medium" "Done")
@@ -430,18 +460,18 @@
;; Set initial filtered list ;; Set initial filtered list
(reset! filtered all-items) (reset! filtered all-items)
;; Filter effect — defers via schedule-idle so typing stays snappy ;; Filter effect — defers via schedule-idle so typing stays snappy
(effect (fn () (let ((_eff (effect (fn ()
(let ((q (lower (deref query)))) (let ((q (lower (deref query))))
(if (= q "") (if (= q "")
(do (reset! pending false) (do (reset! pending false)
(reset! filtered all-items)) (reset! filtered all-items))
(do (reset! pending true) (do (reset! pending true)
(schedule-idle (fn () (schedule-idle (fn ()
(batch (fn () (batch (fn ()
(reset! filtered (reset! filtered
(filter (fn (item) (contains? (lower item) q)) all-items)) (filter (fn (item) (contains? (lower item) q)) all-items))
(reset! pending false)))))))))) (reset! pending false))))))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3" (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
(div :class "flex items-center gap-3" (div :class "flex items-center gap-3"
(input :type "text" :bind query :placeholder "Filter features..." (input :type "text" :bind query :placeholder "Filter features..."
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48") :class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
@@ -451,7 +481,7 @@
(map (fn (item) (map (fn (item)
(li :key item :class "text-sm text-stone-700 bg-white rounded px-3 py-1.5" (li :key item :class "text-sm text-stone-700 bg-white rounded px-3 py-1.5"
item)) item))
(deref filtered)))))) (deref filtered)))))))
;; 13. Shared stores — cross-island state via def-store / use-store ;; 13. Shared stores — cross-island state via def-store / use-store
(defisland ~reactive-islands/index/demo-store-writer () (defisland ~reactive-islands/index/demo-store-writer ()
@@ -485,16 +515,16 @@
;; 14. Event bridge — lake→island communication via custom DOM events ;; 14. Event bridge — lake→island communication via custom DOM events
(defisland ~reactive-islands/index/demo-event-bridge () (defisland ~reactive-islands/index/demo-event-bridge ()
(let ((messages (signal (list))) (let ((container-ref (dict "current" nil))
(container nil)) (messages (signal (list)))
;; Bridge: listen for "inbox:message" events from server-rendered content (_eff (schedule-idle (fn ()
(effect (fn () (let ((el (get container-ref "current")))
(when container (when el
(on-event container "inbox:message" (on-event el "inbox:message"
(fn (e) (fn (e)
(swap! messages (fn (old) (swap! messages (fn (old)
(append old (get (event-detail e) "text"))))))))) (append old (get (event-detail e) "text"))))))))))))
(div :ref (dict "current" nil) (div :ref container-ref
(p :class "text-xs font-semibold text-stone-500 mb-2" "Event Bridge Demo") (p :class "text-xs font-semibold text-stone-500 mb-2" "Event Bridge Demo")
(p :class "text-sm text-stone-600 mb-2" (p :class "text-sm text-stone-600 mb-2"
"The buttons below simulate server-rendered content dispatching events into the island.") "The buttons below simulate server-rendered content dispatching events into the island.")

View File

@@ -274,98 +274,90 @@
(p "In a marsh, you can't point to a piece of DOM and say \"this is server territory\" or \"this is client territory.\" It's both. The server sent it. The client transformed it. The server can update it. The client will re-transform it. The signal reads the server data. The server data feeds the signal. Subject and substance are one.") (p "In a marsh, you can't point to a piece of DOM and say \"this is server territory\" or \"this is client territory.\" It's both. The server sent it. The client transformed it. The server can update it. The client will re-transform it. The signal reads the server data. The server data feeds the signal. Subject and substance are one.")
(p "The practical consequence: an SX application can handle " (em "any") " interaction pattern without breaking its architecture. Pure content → hypermedia. Micro-interactions → L1 DOM ops. Reactive UI → islands. Server slots → lakes. And now, for the places where reactivity and hypermedia must truly merge — marshes.")) (p "The practical consequence: an SX application can handle " (em "any") " interaction pattern without breaking its architecture. Pure content → hypermedia. Micro-interactions → L1 DOM ops. Reactive UI → islands. Server slots → lakes. And now, for the places where reactivity and hypermedia must truly merge — marshes."))
;; ===================================================================== (~docs/section :title "Examples" :id "examples"
;; X. Live demos (p (strong "Live interactive islands") " — click the buttons, inspect the DOM.")
;; ===================================================================== (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"))))
marshes-examples-nav-items)))))
(~docs/section :title "Live demos" :id "demos" ;; ---------------------------------------------------------------------------
(p (strong "These are live interactive islands") " — not static code snippets. Click the buttons. Inspect the DOM.") ;; Individual example pages
;; ---------------------------------------------------------------------------
;; ----------------------------------------------------------------- (defcomp ~reactive-islands/marshes/example-hypermedia-feeds ()
;; Demo 1: Server content feeds reactive state (~docs/page :title "Hypermedia Feeds Reactive State"
;; ----------------------------------------------------------------- (p "Click \"Fetch Price\" to hit a real server endpoint. The response is " (em "hypermedia") " — SX content swapped into the page. But a " (code "data-init") " script in the response also writes to the " (code "\"demo-price\"") " store signal. The island's reactive UI — total, savings, price display — updates instantly from the signal change.")
(p "This is the marsh pattern: " (strong "the server response is both content and a signal write") ". Hypermedia and reactivity aren't separate — the same response does both.")
(~docs/subsection :title "Demo 1: Hypermedia feeds reactive state" (~reactive-islands/marshes/demo-marsh-product)
(p "Click \"Fetch Price\" to hit a real server endpoint. The response is " (em "hypermedia") " — SX content swapped into the page. But a " (code "data-init") " script in the response also writes to the " (code "\"demo-price\"") " store signal. The island's reactive UI — total, savings, price display — updates instantly from the signal change.")
(p "This is the marsh pattern: " (strong "the server response is both content and a signal write") ". Hypermedia and reactivity aren't separate — the same response does both.")
(~reactive-islands/marshes/demo-marsh-product) (~docs/code :code (highlight ";; Island with a store-backed price signal\n(defisland ~reactive-islands/marshes/demo-marsh-product ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99))))\n (qty (signal 1))\n (total (computed (fn () (* (deref price) (deref qty))))))\n (div\n ;; Reactive price display — updates when store changes\n (span \"$\" (deref price))\n (span \"Qty:\") (button \"-\") (span (deref qty)) (button \"+\")\n (span \"Total: $\" (deref total))\n\n ;; Fetch from server — response arrives as hypermedia\n (button :sx-get \"/sx/(geography.(reactive.(api.flash-sale)))\"\n :sx-target \"#marsh-server-msg\"\n :sx-swap \"innerHTML\"\n \"Fetch Price\")\n ;; Server response lands here:\n (div :id \"marsh-server-msg\"))))" "lisp"))
(~docs/code :code (highlight ";; Island with a store-backed price signal\n(defisland ~reactive-islands/marshes/demo-marsh-product ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99))))\n (qty (signal 1))\n (total (computed (fn () (* (deref price) (deref qty))))))\n (div\n ;; Reactive price display — updates when store changes\n (span \"$\" (deref price))\n (span \"Qty:\") (button \"-\") (span (deref qty)) (button \"+\")\n (span \"Total: $\" (deref total))\n\n ;; Fetch from server — response arrives as hypermedia\n (button :sx-get \"/sx/(geography.(reactive.(api.flash-sale)))\"\n :sx-target \"#marsh-server-msg\"\n :sx-swap \"innerHTML\"\n \"Fetch Price\")\n ;; Server response lands here:\n (div :id \"marsh-server-msg\"))))" "lisp")) (~docs/code :code (highlight ";; Server returns SX content + a data-init script:\n;;\n;; (<>\n;; (p \"Flash sale! Price: $14.99\")\n;; (script :type \"text/sx\" :data-init\n;; \"(reset! (use-store \\\"demo-price\\\") 14.99)\"))\n;;\n;; The <p> is swapped in as normal hypermedia content.\n;; The script writes to the store signal.\n;; The island's (deref price), total, and savings\n;; all update reactively — no re-render, no diffing." "lisp"))
(~docs/code :code (highlight ";; Server returns SX content + a data-init script:\n;;\n;; (<>\n;; (p \"Flash sale! Price: $14.99\")\n;; (script :type \"text/sx\" :data-init\n;; \"(reset! (use-store \\\"demo-price\\\") 14.99)\"))\n;;\n;; The <p> is swapped in as normal hypermedia content.\n;; The script writes to the store signal.\n;; The island's (deref price), total, and savings\n;; all update reactively — no re-render, no diffing." "lisp")) (p "Two things happen from one server response: content appears in the swap target (hypermedia) and the price signal updates (reactivity). The island didn't fetch the price. The server didn't call a signal API. The response " (em "is") " both.")))
(p "Two things happen from one server response: content appears in the swap target (hypermedia) and the price signal updates (reactivity). The island didn't fetch the price. The server didn't call a signal API. The response " (em "is") " both.")) (defcomp ~reactive-islands/marshes/example-server-signals ()
(~docs/page :title "Server Writes to Signals"
(p "Two separate islands share a named store " (code "\"demo-price\"") ". Island A creates the store and has control buttons. Island B reads it. Signal changes propagate instantly across island boundaries.")
;; ----------------------------------------------------------------- (div :class "space-y-3"
;; Demo 2: Server → Signal (simulated + live) (~reactive-islands/marshes/demo-marsh-store-writer)
;; ----------------------------------------------------------------- (~reactive-islands/marshes/demo-marsh-store-reader))
(~docs/subsection :title "Demo 2: Server writes to signals" (p :class "mt-3 text-sm text-stone-500" "The \"Flash Sale\" buttons call " (code "(reset! price 14.99)") " — exactly what " (code "data-sx-signal=\"demo-price:14.99\"") " does during morph.")
(p "Two separate islands share a named store " (code "\"demo-price\"") ". Island A creates the store and has control buttons. Island B reads it. Signal changes propagate instantly across island boundaries.")
(div :class "space-y-3" (div :class "mt-4 rounded border border-stone-200 bg-stone-50 p-3"
(~reactive-islands/marshes/demo-marsh-store-writer) (p :class "text-sm font-medium text-stone-700 mb-2" "Server endpoint (ready for morph integration):")
(~reactive-islands/marshes/demo-marsh-store-reader)) (div :id "marsh-flash-target"
:class "min-h-[2rem]")
(button :class "mt-2 px-3 py-1.5 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:sx-get "/sx/(geography.(reactive.(api.flash-sale)))"
:sx-target "#marsh-flash-target"
:sx-swap "innerHTML"
"Fetch from server"))
(p :class "mt-3 text-sm text-stone-500" "The \"Flash Sale\" buttons call " (code "(reset! price 14.99)") " — exactly what " (code "data-sx-signal=\"demo-price:14.99\"") " does during morph.") (~docs/code :code (highlight ";; Island A — creates the store, has control buttons\n(defisland ~reactive-islands/marshes/demo-marsh-store-writer ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99)))))\n ;; (reset! price 14.99) is what data-sx-signal does during morph\n (button :on-click (fn (e) (reset! price 14.99))\n \"Flash Sale $14.99\")))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/marshes/demo-marsh-store-reader ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99)))))\n (span \"$\" (deref price))))\n\n;; Server returns: data-sx-signal writes to the store during morph\n;; (div :data-sx-signal \"demo-price:14.99\"\n;; (p \"Flash sale! Price updated.\"))" "lisp"))
(div :class "mt-4 rounded border border-stone-200 bg-stone-50 p-3" (p "In production, the server response includes " (code "data-sx-signal=\"demo-price:14.99\"") ". The morph algorithm processes this attribute, calls " (code "(reset! (use-store \"demo-price\") 14.99)") ", and removes the attribute from the DOM. Every island reading that store updates instantly — fine-grained, no re-render.")))
(p :class "text-sm font-medium text-stone-700 mb-2" "Server endpoint (ready for morph integration):")
(div :id "marsh-flash-target"
:class "min-h-[2rem]")
(button :class "mt-2 px-3 py-1.5 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:sx-get "/sx/(geography.(reactive.(api.flash-sale)))"
:sx-target "#marsh-flash-target"
:sx-swap "innerHTML"
"Fetch from server"))
(~docs/code :code (highlight ";; Island A — creates the store, has control buttons\n(defisland ~reactive-islands/marshes/demo-marsh-store-writer ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99)))))\n ;; (reset! price 14.99) is what data-sx-signal does during morph\n (button :on-click (fn (e) (reset! price 14.99))\n \"Flash Sale $14.99\")))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/marshes/demo-marsh-store-reader ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99)))))\n (span \"$\" (deref price))))\n\n;; Server returns: data-sx-signal writes to the store during morph\n;; (div :data-sx-signal \"demo-price:14.99\"\n;; (p \"Flash sale! Price updated.\"))" "lisp")) (defcomp ~reactive-islands/marshes/example-on-settle ()
(~docs/page :title "sx-on-settle"
(p "After a swap settles, the trigger element's " (code "sx-on-settle") " attribute is parsed and evaluated as SX. This runs " (em "after") " the content is in the DOM — so you can update reactive state based on what the server returned.")
(p "Click \"Fetch Item\" to load server content. The response is pure hypermedia. But " (code "sx-on-settle") " on the button increments a fetch counter signal " (em "after") " the swap. The counter updates reactively.")
(p "In production, the server response includes " (code "data-sx-signal=\"demo-price:14.99\"") ". The morph algorithm processes this attribute, calls " (code "(reset! (use-store \"demo-price\") 14.99)") ", and removes the attribute from the DOM. Every island reading that store updates instantly — fine-grained, no re-render.")) (~reactive-islands/marshes/demo-marsh-settle)
;; ----------------------------------------------------------------- (~docs/code :code (highlight ";; sx-on-settle runs SX after the swap settles\n(defisland ~reactive-islands/marshes/demo-marsh-settle ()\n (let ((count (def-store \"settle-count\" (fn () (signal 0)))))\n (div\n ;; Reactive counter — updates from sx-on-settle\n (span \"Fetched: \" (deref count) \" times\")\n\n ;; Button with sx-on-settle hook\n (button :sx-get \"/sx/(geography.(reactive.(api.settle-data)))\"\n :sx-target \"#settle-result\"\n :sx-swap \"innerHTML\"\n :sx-on-settle \"(swap! (use-store \\\"settle-count\\\") inc)\"\n \"Fetch Item\")\n\n ;; Server content lands here (pure hypermedia)\n (div :id \"settle-result\"))))" "lisp"))
;; Demo 3: sx-on-settle — post-swap SX evaluation
;; -----------------------------------------------------------------
(~docs/subsection :title "Demo 3: sx-on-settle" (p "The server knows nothing about signals or counters. It returns plain content. The " (code "sx-on-settle") " hook is a client-side concern — it runs in the global SX environment with access to all primitives.")))
(p "After a swap settles, the trigger element's " (code "sx-on-settle") " attribute is parsed and evaluated as SX. This runs " (em "after") " the content is in the DOM — so you can update reactive state based on what the server returned.")
(p "Click \"Fetch Item\" to load server content. The response is pure hypermedia. But " (code "sx-on-settle") " on the button increments a fetch counter signal " (em "after") " the swap. The counter updates reactively.")
(~reactive-islands/marshes/demo-marsh-settle) (defcomp ~reactive-islands/marshes/example-signal-triggers ()
(~docs/page :title "Signal-Bound Triggers"
(p "Inside an island, " (em "all") " attributes are reactive — including " (code "sx-get") ". When an attribute value contains " (code "deref") ", the DOM adapter wraps it in an effect that re-sets the attribute when signals change.")
(p "Select a search category. The " (code "sx-get") " URL on the search button changes reactively. Click \"Search\" to fetch from the current endpoint. The URL was computed from the " (code "mode") " signal at render time and updates whenever the mode changes.")
(~docs/code :code (highlight ";; sx-on-settle runs SX after the swap settles\n(defisland ~reactive-islands/marshes/demo-marsh-settle ()\n (let ((count (def-store \"settle-count\" (fn () (signal 0)))))\n (div\n ;; Reactive counter — updates from sx-on-settle\n (span \"Fetched: \" (deref count) \" times\")\n\n ;; Button with sx-on-settle hook\n (button :sx-get \"/sx/(geography.(reactive.(api.settle-data)))\"\n :sx-target \"#settle-result\"\n :sx-swap \"innerHTML\"\n :sx-on-settle \"(swap! (use-store \\\"settle-count\\\") inc)\"\n \"Fetch Item\")\n\n ;; Server content lands here (pure hypermedia)\n (div :id \"settle-result\"))))" "lisp")) (~reactive-islands/marshes/demo-marsh-signal-url)
(p "The server knows nothing about signals or counters. It returns plain content. The " (code "sx-on-settle") " hook is a client-side concern — it runs in the global SX environment with access to all primitives.")) (~docs/code :code (highlight ";; sx-get URL computed from a signal\n(defisland ~reactive-islands/marshes/demo-marsh-signal-url ()\n (let ((mode (signal \"products\"))\n (query (signal \"\")))\n (div\n ;; Mode selector — changes what we're searching\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! mode \"products\"))\n :class (computed (fn () ...active-class...))\n \"Products\")\n (button :on-click (fn (e) (reset! mode \"events\")) \"Events\")\n (button :on-click (fn (e) (reset! mode \"posts\")) \"Posts\"))\n\n ;; Search button — URL is a computed expression\n (button :sx-get (computed (fn ()\n (str \"/sx/(geography.(reactive.(api.search-\"\n (deref mode) \")))\" \"?q=\" (deref query))))\n :sx-target \"#signal-results\"\n :sx-swap \"innerHTML\"\n \"Search\")\n\n (div :id \"signal-results\"))))" "lisp"))
;; ----------------------------------------------------------------- (p "No custom plumbing. The same " (code "reactive-attr") " mechanism that makes " (code ":class") " reactive also makes " (code ":sx-get") " reactive. " (code "get-verb-info") " reads " (code "dom-get-attr") " at trigger time — it sees the current URL because the effect already updated the DOM attribute.")))
;; Demo 4: Signal-bound triggers
;; -----------------------------------------------------------------
(~docs/subsection :title "Demo 4: Signal-bound triggers" (defcomp ~reactive-islands/marshes/example-view-transform ()
(p "Inside an island, " (em "all") " attributes are reactive — including " (code "sx-get") ". When an attribute value contains " (code "deref") ", the DOM adapter wraps it in an effect that re-sets the attribute when signals change.") (~docs/page :title "Reactive View Transform"
(p "Select a search category. The " (code "sx-get") " URL on the search button changes reactively. Click \"Search\" to fetch from the current endpoint. The URL was computed from the " (code "mode") " signal at render time and updates whenever the mode changes.") (p "A view-mode signal controls how items are displayed. Click \"Fetch Catalog\" to load items from the server, then toggle the view mode. The " (em "same") " data re-renders differently based on client state — no server round-trip for view changes.")
(~reactive-islands/marshes/demo-marsh-signal-url) (~reactive-islands/marshes/demo-marsh-view-transform)
(~docs/code :code (highlight ";; sx-get URL computed from a signal\n(defisland ~reactive-islands/marshes/demo-marsh-signal-url ()\n (let ((mode (signal \"products\"))\n (query (signal \"\")))\n (div\n ;; Mode selector — changes what we're searching\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! mode \"products\"))\n :class (computed (fn () ...active-class...))\n \"Products\")\n (button :on-click (fn (e) (reset! mode \"events\")) \"Events\")\n (button :on-click (fn (e) (reset! mode \"posts\")) \"Posts\"))\n\n ;; Search button — URL is a computed expression\n (button :sx-get (computed (fn ()\n (str \"/sx/(geography.(reactive.(api.search-\"\n (deref mode) \")))\" \"?q=\" (deref query))))\n :sx-target \"#signal-results\"\n :sx-swap \"innerHTML\"\n \"Search\")\n\n (div :id \"signal-results\"))))" "lisp")) (~docs/code :code (highlight ";; View mode transforms display without refetch\n(defisland ~reactive-islands/marshes/demo-marsh-view-transform ()\n (let ((view (signal \"list\"))\n (items (signal nil)))\n (div\n ;; View toggle\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! view \"list\")) \"List\")\n (button :on-click (fn (e) (reset! view \"grid\")) \"Grid\")\n (button :on-click (fn (e) (reset! view \"compact\")) \"Compact\"))\n\n ;; Fetch from server — stores raw data in signal\n (button :sx-get \"/sx/(geography.(reactive.(api.catalog)))\"\n :sx-target \"#catalog-raw\"\n :sx-swap \"innerHTML\"\n \"Fetch Catalog\")\n\n ;; Raw server content (hidden, used as data source)\n (div :id \"catalog-raw\" :class \"hidden\")\n\n ;; Reactive display — re-renders when view changes\n (div (computed (fn () (render-view (deref view) (deref items))))))))" "lisp"))
(p "No custom plumbing. The same " (code "reactive-attr") " mechanism that makes " (code ":class") " reactive also makes " (code ":sx-get") " reactive. " (code "get-verb-info") " reads " (code "dom-get-attr") " at trigger time — it sees the current URL because the effect already updated the DOM attribute.")) (p "The view signal doesn't just toggle CSS classes — it fundamentally reshapes the DOM. List view shows description. Grid view arranges in columns. Compact view shows names only. All from the same server data, transformed by client state.")))
;; -----------------------------------------------------------------
;; Demo 5: Reactive view transform
;; -----------------------------------------------------------------
(~docs/subsection :title "Demo 5: Reactive view transform"
(p "A view-mode signal controls how items are displayed. Click \"Fetch Catalog\" to load items from the server, then toggle the view mode. The " (em "same") " data re-renders differently based on client state — no server round-trip for view changes.")
(~reactive-islands/marshes/demo-marsh-view-transform)
(~docs/code :code (highlight ";; View mode transforms display without refetch\n(defisland ~reactive-islands/marshes/demo-marsh-view-transform ()\n (let ((view (signal \"list\"))\n (items (signal nil)))\n (div\n ;; View toggle\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! view \"list\")) \"List\")\n (button :on-click (fn (e) (reset! view \"grid\")) \"Grid\")\n (button :on-click (fn (e) (reset! view \"compact\")) \"Compact\"))\n\n ;; Fetch from server — stores raw data in signal\n (button :sx-get \"/sx/(geography.(reactive.(api.catalog)))\"\n :sx-target \"#catalog-raw\"\n :sx-swap \"innerHTML\"\n \"Fetch Catalog\")\n\n ;; Raw server content (hidden, used as data source)\n (div :id \"catalog-raw\" :class \"hidden\")\n\n ;; Reactive display — re-renders when view changes\n (div (computed (fn () (render-view (deref view) (deref items))))))))" "lisp"))
(p "The view signal doesn't just toggle CSS classes — it fundamentally reshapes the DOM. List view shows description. Grid view arranges in columns. Compact view shows names only. All from the same server data, transformed by client state."))
)))
;; =========================================================================== ;; ===========================================================================

View File

@@ -46,7 +46,7 @@
(~docs/section :title "htmx Lakes" :id "lakes" (~docs/section :title "htmx Lakes" :id "lakes"
(p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia.") (p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia.")
(p "This works because signals live in JavaScript closures, not in the DOM. When a swap replaces lake content, the island's signals are unaffected. The lake can communicate back to the island via the " (a :href "/sx/(geography.(reactive.event-bridge))" :sx-get "/sx/(geography.(reactive.event-bridge))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "event bridge") ".") (p "This works because signals live in JavaScript closures, not in the DOM. When a swap replaces lake content, the island's signals are unaffected. The lake can communicate back to the island via the " (a :href "/sx/(geography.(reactive.(event-bridge)))" :sx-get "/sx/(geography.(reactive.(event-bridge)))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "event bridge") ".")
(~docs/subsection :title "Navigation scenarios" (~docs/subsection :title "Navigation scenarios"
(div :class "space-y-3" (div :class "space-y-3"

View File

@@ -2,7 +2,7 @@
;; Spec Explorer — structured interactive view of SX spec files ;; Spec Explorer — structured interactive view of SX spec files
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~specs-explorer/spec-explorer-content (&key data) (defcomp ~specs-explorer/spec-explorer-content (&key data) :affinity :server
(~docs/page :title (str (get data "title") " — Explorer") (~docs/page :title (str (get data "title") " — Explorer")
;; Header with filename and source link ;; Header with filename and source link

View File

@@ -133,7 +133,7 @@
;; Geography + Applications + Etc ;; Geography + Applications + Etc
(div (div
(p :class "font-semibold text-stone-700 text-sm mt-2 mb-1" "Geography") (p :class "font-semibold text-stone-700 text-sm mt-2 mb-1" "Geography")
(p (a :href "/sx/(geography.(reactive.demo))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(reactive.demo))")) (p (a :href "/sx/(geography.(reactive.(examples.counter)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(reactive.(examples.counter)))"))
(p (a :href "/sx/(geography.(hypermedia.(reference.attributes)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(reference.attributes)))")) (p (a :href "/sx/(geography.(hypermedia.(reference.attributes)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(reference.attributes)))"))
(p (a :href "/sx/(geography.(hypermedia.(example.click-to-load)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(example.click-to-load)))")) (p (a :href "/sx/(geography.(hypermedia.(example.click-to-load)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(example.click-to-load)))"))
(p (a :href "/sx/(geography.(hypermedia.(example.infinite-scroll)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(example.infinite-scroll)))")) (p (a :href "/sx/(geography.(hypermedia.(example.infinite-scroll)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(example.infinite-scroll)))"))
@@ -314,7 +314,7 @@
(p "Each additional dot pops one more level of nesting. " (p "Each additional dot pops one more level of nesting. "
"N dots = pop N-1 levels:") "N dots = pop N-1 levels:")
(~docs/code :code (highlight (~docs/code :code (highlight
";; Current: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; ... → /(geography.(hypermedia)) ;; pop 2 levels\n;; .... → /(geography) ;; pop 3 levels\n;; ..... → / ;; pop 4 levels (root)\n;;\n;; Combine with a slug to navigate across sections:\n;; ...reactive.demo → /(geography.(reactive.demo)) ;; pop 2, into reactive\n;; ....language.(doc.intro)\n;; → /(language.(doc.intro)) ;; pop 3, into language" ";; Current: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; ... → /(geography.(hypermedia)) ;; pop 2 levels\n;; .... → /(geography) ;; pop 3 levels\n;; ..... → / ;; pop 4 levels (root)\n;;\n;; Combine with a slug to navigate across sections:\n;; ...reactive.(examples) → /(geography.(reactive.(examples))) ;; pop 2, into reactive\n;; ....language.(doc.intro)\n;; → /(language.(doc.intro)) ;; pop 3, into language"
"lisp"))) "lisp")))
(~docs/subsection :title "Why this is functional, not textual" (~docs/subsection :title "Why this is functional, not textual"

View File

@@ -1,7 +1,7 @@
;; SX docs — documentation page components ;; SX docs — documentation page components
(defcomp ~docs/page (&key title &rest children) (defcomp ~docs/page (&key title &rest children)
(div :class "max-w-4xl mx-auto px-6 py-8" (div :class "max-w-4xl mx-auto px-6 pb-8 pt-4"
(div :class "prose prose-stone max-w-none space-y-6" children))) (div :class "prose prose-stone max-w-none space-y-6" children)))
(defcomp ~docs/section (&key title id &rest children) (defcomp ~docs/section (&key title id &rest children)

View File

@@ -7,7 +7,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-time (&key) (defhandler ref-time (&key)
(let ((now (format-time (now) "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(span :class "text-stone-800 text-sm" (span :class "text-stone-800 text-sm"
"Server time: " (strong now)))) "Server time: " (strong now))))
@@ -49,7 +49,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-trigger-search (&key) (defhandler ref-trigger-search (&key)
(let ((q (or (request-arg "q") ""))) (let ((q (helper "request-arg" "q" "")))
(if (empty? q) (if (empty? q)
(span :class "text-stone-400 text-sm" "Start typing to trigger a search.") (span :class "text-stone-400 text-sm" "Start typing to trigger a search.")
(span :class "text-stone-800 text-sm" (span :class "text-stone-800 text-sm"
@@ -60,7 +60,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-swap-item (&key) (defhandler ref-swap-item (&key)
(let ((now (format-time (now) "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(div :class "text-sm text-violet-700" (div :class "text-sm text-violet-700"
"New item (" now ")"))) "New item (" now ")")))
@@ -69,7 +69,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-oob (&key) (defhandler ref-oob (&key)
(let ((now (format-time (now) "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(span :class "text-emerald-700 text-sm" (span :class "text-emerald-700 text-sm"
"Main updated at " now) "Main updated at " now)
@@ -82,7 +82,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-select-page (&key) (defhandler ref-select-page (&key)
(let ((now (format-time (now) "%H:%M:%S"))) (let ((now (helper "now" "%H:%M:%S")))
(<> (<>
(div :id "the-header" (div :id "the-header"
(h3 "Page header — not selected")) (h3 "Page header — not selected"))
@@ -98,7 +98,7 @@
(defhandler ref-slow-echo (&key) (defhandler ref-slow-echo (&key)
(sleep 800) (sleep 800)
(let ((q (or (request-arg "q") ""))) (let ((q (helper "request-arg" "q" "")))
(span :class "text-stone-800 text-sm" (span :class "text-stone-800 text-sm"
"Echo: " (strong q)))) "Echo: " (strong q))))
@@ -116,7 +116,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-echo-headers (&key) (defhandler ref-echo-headers (&key)
(let ((headers (request-headers :prefix "X-"))) (let ((headers (helper "request-headers" :prefix "X-")))
(if (empty? headers) (if (empty? headers)
(span :class "text-stone-400 text-sm" "No custom headers received.") (span :class "text-stone-400 text-sm" "No custom headers received.")
(ul :class "text-sm text-stone-700 space-y-1" (ul :class "text-sm text-stone-700 space-y-1"
@@ -129,7 +129,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defhandler ref-echo-vals (&key) (defhandler ref-echo-vals (&key)
(let ((vals (request-args))) (let ((vals (helper "request-args")))
(if (empty? vals) (if (empty? vals)
(span :class "text-stone-400 text-sm" "No values received.") (span :class "text-stone-400 text-sm" "No values received.")
(ul :class "text-sm text-stone-700 space-y-1" (ul :class "text-sm text-stone-700 space-y-1"

View File

@@ -29,5 +29,8 @@ def _load_sx_page_files() -> None:
helpers = get_page_helpers("sx") helpers = get_page_helpers("sx")
for name, fn in helpers.items(): for name, fn in helpers.items():
PRIMITIVES[name] = fn PRIMITIVES[name] = fn
# helper is registered as an IO primitive in primitives_io.py,
# intercepted by async_eval before hitting the CEK machine.
import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5]) import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5])
load_page_dir(os.path.dirname(__file__), "sx") load_page_dir(os.path.dirname(__file__), "sx")

View File

@@ -369,17 +369,11 @@
:path "/language/specs/explore/<slug>" :path "/language/specs/explore/<slug>"
:auth :public :auth :public
:layout :sx-docs :layout :sx-docs
:data (helper "spec-explorer-data-by-slug" slug)
:content (~layouts/doc :path (str "/sx/(language.(spec.(explore." slug ")))") :content (~layouts/doc :path (str "/sx/(language.(spec.(explore." slug ")))")
(let ((spec (find-spec slug))) (if data
(if spec (~specs-explorer/spec-explorer-content :data data)
(let ((data (spec-explorer-data (~specs/not-found :slug slug))))
(get spec "filename")
(get spec "title")
(get spec "desc"))))
(if data
(~specs-explorer/spec-explorer-content :data data)
(~specs/not-found :slug slug)))
(~specs/not-found :slug slug)))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Bootstrappers section ;; Bootstrappers section

View File

@@ -35,6 +35,7 @@ def _register_sx_helpers() -> None:
"prove-data": _prove_data, "prove-data": _prove_data,
"page-helpers-demo-data": _page_helpers_demo_data, "page-helpers-demo-data": _page_helpers_demo_data,
"spec-explorer-data": _spec_explorer_data, "spec-explorer-data": _spec_explorer_data,
"spec-explorer-data-by-slug": _spec_explorer_data_by_slug,
"handler-source": _handler_source, "handler-source": _handler_source,
}) })
@@ -329,6 +330,35 @@ def _collect_symbols(expr) -> set[str]:
return result return result
_SPEC_SLUG_MAP = {
"parser": ("parser.sx", "Parser", "Tokenization and parsing"),
"evaluator": ("eval.sx", "Evaluator", "Tree-walking evaluation"),
"primitives": ("primitives.sx", "Primitives", "Built-in pure functions"),
"render": ("render.sx", "Renderer", "Three rendering modes"),
"special-forms": ("special-forms.sx", "Special Forms", "Special form dispatch"),
"signals": ("signals.sx", "Signals", "Fine-grained reactive primitives"),
"adapter-dom": ("adapter-dom.sx", "DOM Adapter", "Client-side DOM rendering"),
"adapter-html": ("adapter-html.sx", "HTML Adapter", "Server-side HTML rendering"),
"adapter-sx": ("adapter-sx.sx", "SX Adapter", "SX wire format serialization"),
"engine": ("engine.sx", "SxEngine", "Pure logic for the browser engine"),
"orchestration": ("orchestration.sx", "Orchestration", "Browser lifecycle"),
"boot": ("boot.sx", "Boot", "Browser initialization"),
"router": ("router.sx", "Router", "URL parsing and route matching"),
"boundary": ("boundary.sx", "Boundary", "Language/platform boundary"),
"continuations": ("continuations.sx", "Continuations", "Delimited continuations"),
"types": ("types.sx", "Types", "Optional gradual type system"),
}
def _spec_explorer_data_by_slug(slug: str) -> dict | None:
"""Look up spec by slug and return explorer data."""
entry = _SPEC_SLUG_MAP.get(slug)
if not entry:
return None
filename, title, desc = entry
return _spec_explorer_data(filename, title, desc)
def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict | None: def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict | None:
"""Parse a spec file into structured metadata for the spec explorer. """Parse a spec file into structured metadata for the spec explorer.

View File

@@ -150,16 +150,40 @@ async def eval_sx_url(raw_path: str) -> Any:
page_ast = expr page_ast = expr
else: else:
import os import os
if os.environ.get("SX_USE_REF") == "1": use_ocaml = os.environ.get("SX_USE_OCAML") == "1"
from shared.sx.ref.async_eval_ref import async_eval
else:
from shared.sx.async_eval import async_eval
try: if use_ocaml:
page_ast = await async_eval(expr, env, ctx) # OCaml kernel — the universal evaluator
except Exception as e: try:
logger.error("SX URL page-fn eval failed for %s: %s", raw_path, e, exc_info=True) from shared.sx.ocaml_bridge import get_bridge
return None from shared.sx.parser import serialize, parse_all
bridge = await get_bridge()
sx_text = serialize(expr)
ocaml_ctx = {"_helper_service": "sx"}
result_text = await bridge.eval(sx_text, ctx=ocaml_ctx)
if result_text:
parsed = parse_all(result_text)
page_ast = parsed[0] if parsed else []
else:
page_ast = []
except Exception as e:
logger.error("SX URL page-fn eval (OCaml) failed for %s: %s",
raw_path, e, exc_info=True)
return None
else:
# Python fallback
if os.environ.get("SX_USE_REF") == "1":
from shared.sx.ref.async_eval_ref import async_eval
else:
from shared.sx.async_eval import async_eval
try:
page_ast = await async_eval(expr, env, ctx)
except Exception as e:
logger.error("SX URL page-fn eval failed for %s: %s",
raw_path, e, exc_info=True)
return None
if page_ast is None: if page_ast is None:
page_ast = [] page_ast = []

12
sx/tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
"""Pytest configuration for Playwright tests."""
import pytest
@pytest.fixture(scope="session")
def browser_type_launch_args():
return {"headless": True}
@pytest.fixture(scope="session")
def browser_context_args():
return {"ignore_https_errors": True}

543
sx/tests/test_demos.py Normal file
View File

@@ -0,0 +1,543 @@
"""Automated Playwright tests for SX docs demos.
Covers hypermedia examples, reactive islands, and marshes.
Run against the dev server:
cd sx/tests
pytest test_demos.py -v --headed # visible browser
pytest test_demos.py -v # headless
Requires: pip install pytest-playwright
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
import os
BASE = os.environ.get("SX_TEST_BASE", "https://sx.rose-ash.com")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def browser_context_args():
return {"ignore_https_errors": True}
def nav(page: Page, path: str):
"""Navigate to an SX URL and wait for rendered content."""
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
# Wait for SX to render — look for any heading or paragraph in main panel
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=15000)
# ---------------------------------------------------------------------------
# Hypermedia Examples
# ---------------------------------------------------------------------------
class TestClickToLoad:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
expect(page.locator("#main-panel")).to_contain_text("Click to Load")
def test_click_loads_content(self, page: Page):
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
page.click("button:has-text('Load content')")
expect(page.locator("#click-result")).to_contain_text(
"Content loaded", timeout=5000
)
class TestFormSubmission:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
expect(page.locator("#main-panel")).to_contain_text("Form Submission")
def test_submit_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
page.fill("input[name='name']", "TestUser")
page.click("button[type='submit']")
expect(page.locator("#form-result")).to_contain_text("TestUser", timeout=5000)
class TestPolling:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.polling)))")
expect(page.locator("#main-panel")).to_contain_text("Polling")
def test_poll_updates(self, page: Page):
nav(page, "(geography.(hypermedia.(example.polling)))")
expect(page.locator("#poll-target")).to_contain_text("Server time", timeout=10000)
class TestDeleteRow:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.delete-row)))")
expect(page.locator("#main-panel")).to_contain_text("Delete Row")
class TestInlineEdit:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-edit)))")
expect(page.locator("#main-panel")).to_contain_text("Inline Edit")
def test_edit_shows_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-edit)))")
page.click("#edit-target button:has-text('edit')")
expect(page.locator("input[name='value']")).to_be_visible(timeout=5000)
class TestOobSwaps:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.oob-swaps)))")
expect(page.locator("#main-panel")).to_contain_text("OOB")
def test_oob_updates_both_boxes(self, page: Page):
nav(page, "(geography.(hypermedia.(example.oob-swaps)))")
page.click("button:has-text('Update both boxes')")
expect(page.locator("#oob-box-a")).to_contain_text("Box A updated", timeout=5000)
expect(page.locator("#oob-box-b").last).to_contain_text("Box B updated", timeout=5000)
class TestLazyLoading:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.lazy-loading)))")
expect(page.locator("#main-panel")).to_contain_text("Lazy Loading")
def test_content_loads_automatically(self, page: Page):
nav(page, "(geography.(hypermedia.(example.lazy-loading)))")
expect(page.locator("#lazy-target")).to_contain_text("Content loaded", timeout=10000)
class TestInfiniteScroll:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.infinite-scroll)))")
expect(page.locator("#main-panel")).to_contain_text("Infinite Scroll")
class TestProgressBar:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.progress-bar)))")
expect(page.locator("#main-panel")).to_contain_text("Progress Bar")
def test_progress_starts(self, page: Page):
nav(page, "(geography.(hypermedia.(example.progress-bar)))")
page.click("button:has-text('Start job')")
expect(page.locator("#progress-target")).to_contain_text("complete", timeout=10000)
class TestActiveSearch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.active-search)))")
expect(page.locator("#main-panel")).to_contain_text("Active Search")
def test_search_returns_results(self, page: Page):
nav(page, "(geography.(hypermedia.(example.active-search)))")
page.locator("input[name='q']").type("Py")
expect(page.locator("#search-results")).to_contain_text("Python", timeout=5000)
class TestInlineValidation:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-validation)))")
expect(page.locator("#main-panel")).to_contain_text("Inline Validation")
def test_invalid_email(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-validation)))")
page.fill("input[name='email']", "notanemail")
page.locator("input[name='email']").blur()
expect(page.locator("#email-feedback")).to_contain_text("Invalid", timeout=5000)
class TestValueSelect:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.value-select)))")
expect(page.locator("#main-panel")).to_contain_text("Value Select")
def test_select_changes_options(self, page: Page):
nav(page, "(geography.(hypermedia.(example.value-select)))")
page.select_option("select[name='category']", "Languages")
expect(page.locator("#value-items")).to_contain_text("Python", timeout=5000)
class TestResetOnSubmit:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.reset-on-submit)))")
expect(page.locator("#main-panel")).to_contain_text("Reset")
def test_has_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.reset-on-submit)))")
expect(page.locator("form")).to_be_visible(timeout=5000)
class TestEditRow:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.edit-row)))")
expect(page.locator("#main-panel")).to_contain_text("Edit Row")
class TestBulkUpdate:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.bulk-update)))")
expect(page.locator("#main-panel")).to_contain_text("Bulk Update")
def test_has_checkboxes(self, page: Page):
nav(page, "(geography.(hypermedia.(example.bulk-update)))")
expect(page.locator("input[type='checkbox']").first).to_be_visible(timeout=5000)
class TestSwapPositions:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.swap-positions)))")
expect(page.locator("#main-panel")).to_contain_text("Swap")
class TestSelectFilter:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.select-filter)))")
expect(page.locator("#main-panel")).to_contain_text("Select Filter")
class TestTabs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.tabs)))")
expect(page.locator("#main-panel")).to_contain_text("Tabs")
class TestAnimations:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.animations)))")
expect(page.locator("#main-panel")).to_contain_text("Animation")
class TestDialogs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.dialogs)))")
expect(page.locator("#main-panel")).to_contain_text("Dialog")
class TestKeyboardShortcuts:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.keyboard-shortcuts)))")
expect(page.locator("#main-panel")).to_contain_text("Keyboard")
class TestPutPatch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.put-patch)))")
expect(page.locator("#main-panel")).to_contain_text("PUT")
class TestJsonEncoding:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.json-encoding)))")
expect(page.locator("#main-panel")).to_contain_text("JSON")
class TestValsAndHeaders:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.vals-and-headers)))")
expect(page.locator("#main-panel")).to_contain_text("Vals")
class TestLoadingStates:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.loading-states)))")
expect(page.locator("#main-panel")).to_contain_text("Loading")
class TestSyncReplace:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.sync-replace)))")
expect(page.locator("#main-panel")).to_contain_text("Request Abort")
class TestRetry:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.retry)))")
expect(page.locator("#main-panel")).to_contain_text("Retry")
# ---------------------------------------------------------------------------
# Reactive Islands
# ---------------------------------------------------------------------------
class TestReactiveIslandsOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive))")
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands")
def test_architecture_table(self, page: Page):
nav(page, "(geography.(reactive))")
expect(page.locator("table").first).to_be_visible()
class TestReactiveExamplesOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples)))")
expect(page.locator("#main-panel")).to_contain_text("Examples")
class TestReactiveCounter:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value")
def test_counter_increments(self, page: Page):
nav(page, "(geography.(reactive.(examples.counter)))")
# Find the island's + button
island = page.locator("[data-sx-island*='demo-counter']")
expect(island).to_be_visible(timeout=5000)
# Wait for hydration
page.wait_for_timeout(1000)
initial = island.locator("span.text-2xl").text_content()
island.locator("button", has_text="+").click()
page.wait_for_timeout(500)
updated = island.locator("span.text-2xl").text_content()
assert initial != updated, f"Counter should change: {initial} -> {updated}"
class TestReactiveTemperature:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.temperature)))")
expect(page.locator("#main-panel")).to_contain_text("Temperature")
def test_temperature_updates(self, page: Page):
nav(page, "(geography.(reactive.(examples.temperature)))")
island = page.locator("[data-sx-island*='demo-temperature']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
# Should show celsius value > 20 (default)
expect(island).to_contain_text("25")
class TestReactiveStopwatch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.stopwatch)))")
expect(page.locator("#main-panel")).to_contain_text("Stopwatch")
class TestReactiveImperative:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.imperative)))")
expect(page.locator("#main-panel")).to_contain_text("Imperative")
class TestReactiveList:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.reactive-list)))")
expect(page.locator("#main-panel")).to_contain_text("Reactive List")
def test_add_item(self, page: Page):
nav(page, "(geography.(reactive.(examples.reactive-list)))")
island = page.locator("[data-sx-island*='demo-reactive-list']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("button", has_text="Add Item").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("Item 1")
class TestReactiveInputBinding:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.input-binding)))")
expect(page.locator("#main-panel")).to_contain_text("Input Binding")
def test_input_binding(self, page: Page):
nav(page, "(geography.(reactive.(examples.input-binding)))")
island = page.locator("[data-sx-island*='demo-input-binding']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("input[type='text']").fill("World")
page.wait_for_timeout(500)
expect(island).to_contain_text("Hello, World!")
class TestReactivePortal:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.portal)))")
expect(page.locator("#main-panel")).to_contain_text("Portal")
class TestReactiveErrorBoundary:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.error-boundary)))")
expect(page.locator("#main-panel")).to_contain_text("Error")
class TestReactiveRefs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.refs)))")
expect(page.locator("#main-panel")).to_contain_text("Refs")
class TestReactiveDynamicClass:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.dynamic-class)))")
expect(page.locator("#main-panel")).to_contain_text("Dynamic")
class TestReactiveResource:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.resource)))")
expect(page.locator("#main-panel")).to_contain_text("Resource")
class TestReactiveTransition:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.transition)))")
expect(page.locator("#main-panel")).to_contain_text("Transition")
class TestReactiveStores:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.stores)))")
expect(page.locator("#main-panel")).to_contain_text("Store")
class TestReactiveEventBridge:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.event-bridge-demo)))")
expect(page.locator("#main-panel")).to_contain_text("Event Bridge")
class TestReactiveDefisland:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.defisland)))")
expect(page.locator("#main-panel")).to_contain_text("defisland")
class TestReactiveTests:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.tests)))")
expect(page.locator("#main-panel")).to_contain_text("test")
class TestReactiveCoverage:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.coverage)))")
expect(page.locator("#main-panel")).to_contain_text("React")
# ---------------------------------------------------------------------------
# Marshes
# ---------------------------------------------------------------------------
class TestMarshesOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes))")
expect(page.locator("#main-panel")).to_contain_text("Marshes")
class TestMarshesHypermediaFeeds:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.hypermedia-feeds))")
expect(page.locator("#main-panel")).to_contain_text("Hypermedia Feeds")
class TestMarshesServerSignals:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.server-signals))")
expect(page.locator("#main-panel")).to_contain_text("Server Writes")
class TestMarshesOnSettle:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.on-settle))")
expect(page.locator("#main-panel")).to_contain_text("sx-on-settle")
class TestMarshesSignalTriggers:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.signal-triggers))")
expect(page.locator("#main-panel")).to_contain_text("Signal-Bound")
class TestMarshesViewTransform:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.view-transform))")
expect(page.locator("#main-panel")).to_contain_text("Reactive View")
# ---------------------------------------------------------------------------
# Spec Explorer
# ---------------------------------------------------------------------------
class TestSpecExplorer:
def test_evaluator_server_renders(self, page: Page):
"""Server returns spec explorer content with evaluator data."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.evaluator)))")
assert resp.ok, f"Server returned {resp.status}"
body = resp.text()
assert "Evaluator" in body, "Should contain evaluator title"
assert "eval" in body.lower(), "Should contain evaluator content"
def test_parser_server_renders(self, page: Page):
"""Server returns spec explorer content with parser data."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.parser)))")
assert resp.ok, f"Server returned {resp.status}"
body = resp.text()
assert "Parser" in body, "Should contain parser title"
def test_has_spec_source(self, page: Page):
"""Spec explorer includes actual spec source code."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.evaluator)))")
body = resp.text()
assert "define" in body, "Should contain define forms from spec"
assert "eval-expr" in body, "Should contain eval-expr from evaluator spec"
# ---------------------------------------------------------------------------
# Key doc pages (smoke tests)
# ---------------------------------------------------------------------------
class TestDocPages:
@pytest.mark.parametrize("path,expected", [
("(geography.(reactive))", "Reactive Islands"),
("(geography.(hypermedia.(reference.attributes)))", "Attributes"),
("(geography.(scopes))", "Scopes"),
("(geography.(provide))", "Provide"),
("(geography.(spreads))", "Spreads"),
("(language.(doc.introduction))", "Introduction"),
("(language.(spec.core))", "Core"),
("(applications.(cssx))", "CSSX"),
("(etc.(essay.why-sexps))", "Why S-Expressions"),
])
def test_page_loads(self, page: Page, path: str, expected: str):
nav(page, path)
expect(page.locator("#main-panel")).to_contain_text(expected, timeout=10000)
# ---------------------------------------------------------------------------
# Navigation tests
# ---------------------------------------------------------------------------
class TestClientNavigation:
def test_navigate_between_reactive_examples(self, page: Page):
"""Navigate from counter to temperature via server fetch."""
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
# Click temperature link in sibling nav — has sx-get for server fetch
temp_link = page.locator("a[sx-push-url]:has-text('Temperature')").first
expect(temp_link).to_be_visible(timeout=5000)
temp_link.click()
page.wait_for_timeout(5000)
expect(page.locator("#main-panel")).to_contain_text("Temperature", timeout=10000)
def test_reactive_island_works_after_navigation(self, page: Page):
"""Island should be functional after client-side navigation."""
nav(page, "(geography.(reactive.(examples.temperature)))")
page.wait_for_timeout(3000) # Wait for hydration
# Click +5 and verify
island = page.locator("[data-sx-island*='demo-temperature']")
expect(island).to_be_visible(timeout=5000)
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("25")

View File

@@ -50,12 +50,12 @@
(aser-list expr env)) (aser-list expr env))
;; Spread — emit attrs to nearest element provider ;; Spread — emit attrs to nearest element provider
"spread" (do (emit! "element-attrs" (spread-attrs expr)) nil) "spread" (do (scope-emit! "element-attrs" (spread-attrs expr)) nil)
:else expr))) :else expr)))
;; Catch spread values from function calls and symbol lookups ;; Catch spread values from function calls and symbol lookups
(if (spread? result) (if (spread? result)
(do (emit! "element-attrs" (spread-attrs result)) nil) (do (scope-emit! "element-attrs" (spread-attrs result)) nil)
result)))) result))))
@@ -119,18 +119,34 @@
(for-each (for-each
(fn (c) (fn (c)
(let ((result (aser c env))) (let ((result (aser c env)))
(if (= (type-of result) "list") (cond
(for-each (nil? result) nil
(fn (item) ;; Serialized SX from aser (tags, components, fragments)
(when (not (nil? item)) ;; starts with "(" — use directly without re-quoting
(append! parts (serialize item)))) (and (= (type-of result) "string")
result) (> (string-length result) 0)
(when (not (nil? result)) (starts-with? result "("))
(append! parts (serialize result)))))) (append! parts result)
;; list results (from map etc.)
(= (type-of result) "list")
(for-each
(fn (item)
(when (not (nil? item))
(if (and (= (type-of item) "string")
(> (string-length item) 0)
(starts-with? item "("))
(append! parts item)
(append! parts (serialize item)))))
result)
;; Everything else — serialize normally (quotes strings)
:else
(append! parts (serialize result)))))
children) children)
(if (empty? parts) (if (empty? parts)
"" ""
(str "(<> " (join " " parts) ")"))))) (if (= (len parts) 1)
(first parts)
(str "(<> " (join " " parts) ")"))))))
(define aser-call :effects [render] (define aser-call :effects [render]
@@ -160,13 +176,26 @@
(set! i (inc i))) (set! i (inc i)))
(let ((val (aser arg env))) (let ((val (aser arg env)))
(when (not (nil? val)) (when (not (nil? val))
(if (= (type-of val) "list") (cond
(for-each ;; Serialized SX (tags, components) — use directly
(fn (item) (and (= (type-of val) "string")
(when (not (nil? item)) (> (string-length val) 0)
(append! child-parts (serialize item)))) (starts-with? val "("))
val) (append! child-parts val)
(append! child-parts (serialize val)))) ;; List results (from map etc.)
(= (type-of val) "list")
(for-each
(fn (item)
(when (not (nil? item))
(if (and (= (type-of item) "string")
(> (string-length item) 0)
(starts-with? item "("))
(append! child-parts item)
(append! child-parts (serialize item)))))
val)
;; Plain values — serialize normally
:else
(append! child-parts (serialize val))))
(set! i (inc i)))))) (set! i (inc i))))))
args) args)
;; Collect emitted spread attrs — goes after explicit attrs, before children ;; Collect emitted spread attrs — goes after explicit attrs, before children
@@ -178,7 +207,7 @@
(append! attr-parts (str ":" k)) (append! attr-parts (str ":" k))
(append! attr-parts (serialize v)))) (append! attr-parts (serialize v))))
(keys spread-dict))) (keys spread-dict)))
(emitted "element-attrs")) (scope-peek "element-attrs"))
(scope-pop! "element-attrs") (scope-pop! "element-attrs")
(let ((parts (concat (list name) attr-parts child-parts))) (let ((parts (concat (list name) attr-parts child-parts)))
(str "(" (join " " parts) ")"))))) (str "(" (join " " parts) ")")))))

View File

@@ -336,11 +336,15 @@
(define sx-hydrate-islands :effects [mutation io] (define sx-hydrate-islands :effects [mutation io]
(fn (root) (fn (root)
(let ((els (dom-query-all (or root (dom-body)) "[data-sx-island]"))) (let ((els (dom-query-all (or root (dom-body)) "[data-sx-island]")))
(log-info (str "sx-hydrate-islands: " (len els) " island(s) in " (if root "subtree" "document")))
(for-each (for-each
(fn (el) (fn (el)
(when (not (is-processed? el "island-hydrated")) (if (is-processed? el "island-hydrated")
(mark-processed! el "island-hydrated") (log-info (str " skip (already hydrated): " (dom-get-attr el "data-sx-island")))
(hydrate-island el))) (do
(log-info (str " hydrating: " (dom-get-attr el "data-sx-island")))
(mark-processed! el "island-hydrated")
(hydrate-island el))))
els)))) els))))
(define hydrate-island :effects [mutation io] (define hydrate-island :effects [mutation io]
@@ -398,7 +402,9 @@
(fn ((d :as lambda)) (fn ((d :as lambda))
(when (callable? d) (d))) (when (callable? d) (d)))
disposers) disposers)
(dom-set-data el "sx-disposers" nil))))) (dom-set-data el "sx-disposers" nil)))
;; Clear hydration marker so the island can be re-hydrated
(clear-processed! el "island-hydrated")))
(define dispose-islands-in :effects [mutation io] (define dispose-islands-in :effects [mutation io]
(fn (root) (fn (root)
@@ -416,6 +422,16 @@
(log-info (str "disposing " (len to-dispose) " island(s)")) (log-info (str "disposing " (len to-dispose) " island(s)"))
(for-each dispose-island to-dispose)))))))) (for-each dispose-island to-dispose))))))))
(define force-dispose-islands-in :effects [mutation io]
(fn (root)
;; Dispose ALL islands in root, including hydrated ones.
;; Used when the target is being completely replaced (outerHTML swap).
(when root
(let ((islands (dom-query-all root "[data-sx-island]")))
(when (and islands (not (empty? islands)))
(log-info (str "force-disposing " (len islands) " island(s)"))
(for-each dispose-island islands))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; Render hooks — generic pre/post callbacks for hydration, swap, mount. ;; Render hooks — generic pre/post callbacks for hydration, swap, mount.

View File

@@ -291,8 +291,12 @@
(content (if select-sel (content (if select-sel
(select-from-container container select-sel) (select-from-container container select-sel)
(children-to-fragment container)))) (children-to-fragment container))))
;; Dispose old islands before swap ;; Dispose old islands before swap.
(dispose-islands-in target) ;; outerHTML replaces the target entirely — force-dispose all islands.
;; Other swap styles (innerHTML, beforeend, etc.) may preserve islands.
(if (= swap-style "outerHTML")
(force-dispose-islands-in target)
(dispose-islands-in target))
;; Swap ;; Swap
(with-transition use-transition (with-transition use-transition
(fn () (fn ()
@@ -456,6 +460,7 @@
(define post-swap :effects [mutation io] (define post-swap :effects [mutation io]
(fn (root) (fn (root)
;; Run lifecycle after swap: activate scripts, process SX, hydrate, process ;; Run lifecycle after swap: activate scripts, process SX, hydrate, process
(log-info (str "post-swap: root=" (if root (dom-tag-name root) "nil")))
(activate-scripts root) (activate-scripts root)
(sx-process-scripts root) (sx-process-scripts root)
(sx-hydrate root) (sx-hydrate root)
@@ -871,6 +876,7 @@
(hoist-head-elements-full target) (hoist-head-elements-full target)
(process-elements target) (process-elements target)
(sx-hydrate-elements target) (sx-hydrate-elements target)
(sx-hydrate-islands target)
(run-post-render-hooks) (run-post-render-hooks)
(dom-dispatch target "sx:clientRoute" (dom-dispatch target "sx:clientRoute"
(dict "pathname" pathname)) (dict "pathname" pathname))