SxExpr aser wire format fix + Playwright test infrastructure + blob protocol
Aser serialization: aser-call/fragment now return SxExpr instead of String. serialize/inspect passes SxExpr through unquoted, preventing the double- escaping (\" → \\\" ) that broke client-side parsing when aser wire format was output via raw! into <script> tags. Added make-sx-expr + sx-expr-source primitives to OCaml and JS hosts. Binary blob protocol: eval, aser, aser-slot, and sx-page-full now send SX source as length-prefixed blobs instead of escaped strings. Eliminates pipe desync from concurrent requests and removes all string-escape round-trips between Python and OCaml. Bridge safety: re-entrancy guard (_in_io_handler) raises immediately if an IO handler tries to call the bridge, preventing silent deadlocks. Fetch error logging: orchestration.sx error callback now logs method + URL via log-warn. Platform catches (fetchAndRestore, fetchPreload, bindBoostForm) also log errors instead of silently swallowing them. Transpiler fixes: makeEnv, scopePeek, scopeEmit, makeSxExpr added as platform function definitions + transpiler mappings — were referenced in transpiled code but never defined as JS functions. Playwright test infrastructure: - nav() captures JS errors and fails fast with the actual error message - Checks for [object Object] rendering artifacts - New tests: delete-row interaction, full page refresh, back button, direct load with fresh context, code block content verification - Default base URL changed to localhost:8013 (standalone dev server) - docker-compose.dev-sx.yml: port 8013 exposed for local testing - test-sx-build.sh: build + unit tests + Playwright smoke tests Geography content: index page component written (sx/sx/geography/index.sx) describing OCaml evaluator, wire formats, rendering pipeline, and topic links. Wiring blocked by aser-expand-component children passing issue. Tests: 1080/1080 JS, 952/952 OCaml, 66/66 Playwright Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,8 @@ services:
|
|||||||
SX_OCAML_BIN: "/app/bin/sx_server"
|
SX_OCAML_BIN: "/app/bin/sx_server"
|
||||||
SX_BOUNDARY_STRICT: "1"
|
SX_BOUNDARY_STRICT: "1"
|
||||||
SX_DEV: "1"
|
SX_DEV: "1"
|
||||||
|
ports:
|
||||||
|
- "8013:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- /root/rose-ash/_config/dev-sh-config.yaml:/app/config/app-config.yaml:ro
|
- /root/rose-ash/_config/dev-sh-config.yaml:/app/config/app-config.yaml:ro
|
||||||
- ./shared:/app/shared
|
- ./shared:/app/shared
|
||||||
|
|||||||
@@ -1130,8 +1130,10 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
|
|||||||
PRIMITIVES["emit!"] = sxEmit;
|
PRIMITIVES["emit!"] = sxEmit;
|
||||||
PRIMITIVES["emitted"] = sxEmitted;
|
PRIMITIVES["emitted"] = sxEmitted;
|
||||||
// Aliases for aser adapter (avoids CEK special form conflict on server)
|
// Aliases for aser adapter (avoids CEK special form conflict on server)
|
||||||
PRIMITIVES["scope-emit!"] = sxEmit;
|
var scopeEmit = sxEmit;
|
||||||
PRIMITIVES["scope-peek"] = sxEmitted;
|
var scopePeek = sxEmitted;
|
||||||
|
PRIMITIVES["scope-emit!"] = scopeEmit;
|
||||||
|
PRIMITIVES["scope-peek"] = scopePeek;
|
||||||
''',
|
''',
|
||||||
}
|
}
|
||||||
# Modules to include by default (all)
|
# Modules to include by default (all)
|
||||||
@@ -1170,6 +1172,7 @@ PLATFORM_JS_PRE = '''
|
|||||||
if (x._spread) return "spread";
|
if (x._spread) return "spread";
|
||||||
if (x._macro) return "macro";
|
if (x._macro) return "macro";
|
||||||
if (x._raw) return "raw-html";
|
if (x._raw) return "raw-html";
|
||||||
|
if (x._sx_expr) return "sx-expr";
|
||||||
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
|
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
|
||||||
if (Array.isArray(x)) return "list";
|
if (Array.isArray(x)) return "list";
|
||||||
if (typeof x === "object") return "dict";
|
if (typeof x === "object") return "dict";
|
||||||
@@ -1441,6 +1444,7 @@ PLATFORM_JS_POST = '''
|
|||||||
// escape-html and escape-attr are now library functions defined in render.sx
|
// escape-html and escape-attr are now library functions defined in render.sx
|
||||||
function rawHtmlContent(r) { return r.html; }
|
function rawHtmlContent(r) { return r.html; }
|
||||||
function makeRawHtml(s) { return { _raw: true, html: s }; }
|
function makeRawHtml(s) { return { _raw: true, html: s }; }
|
||||||
|
function makeSxExpr(s) { return { _sx_expr: true, source: s }; }
|
||||||
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
|
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
|
||||||
|
|
||||||
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
|
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
|
||||||
@@ -1669,7 +1673,8 @@ CEK_FIXUPS_JS = '''
|
|||||||
PRIMITIVES["island?"] = isIsland;
|
PRIMITIVES["island?"] = isIsland;
|
||||||
PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); };
|
PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); };
|
||||||
PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; };
|
PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; };
|
||||||
PRIMITIVES["make-env"] = function() { return merge(componentEnv, PRIMITIVES); };
|
function makeEnv() { return merge(componentEnv, PRIMITIVES); }
|
||||||
|
PRIMITIVES["make-env"] = makeEnv;
|
||||||
|
|
||||||
// localStorage — defined here (before boot) so islands can use at hydration
|
// localStorage — defined here (before boot) so islands can use at hydration
|
||||||
PRIMITIVES["local-storage-get"] = function(key) {
|
PRIMITIVES["local-storage-get"] = function(key) {
|
||||||
@@ -1792,7 +1797,7 @@ PLATFORM_PARSER_JS = r"""
|
|||||||
function escapeString(s) {
|
function escapeString(s) {
|
||||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
||||||
}
|
}
|
||||||
function sxExprSource(e) { return typeof e === "string" ? e : String(e); }
|
function sxExprSource(e) { return typeof e === "string" ? e : (e && e.source ? e.source : String(e)); }
|
||||||
var charFromCode = PRIMITIVES["char-from-code"];
|
var charFromCode = PRIMITIVES["char-from-code"];
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -2298,7 +2303,10 @@ PLATFORM_ORCHESTRATION_JS = """
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch(function() { location.reload(); });
|
}).catch(function(err) {
|
||||||
|
logWarn("sx:popstate fetch error " + url + " — " + (err && err.message ? err.message : err));
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchStreaming(target, url, headers) {
|
function fetchStreaming(target, url, headers) {
|
||||||
@@ -2436,7 +2444,9 @@ PLATFORM_ORCHESTRATION_JS = """
|
|||||||
return resp.text().then(function(text) {
|
return resp.text().then(function(text) {
|
||||||
preloadCacheSet(cache, url, text, ct);
|
preloadCacheSet(cache, url, text, ct);
|
||||||
});
|
});
|
||||||
}).catch(function() { /* ignore */ });
|
}).catch(function(err) {
|
||||||
|
logInfo("sx:preload error " + url + " — " + (err && err.message ? err.message : err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Request body building ---
|
// --- Request body building ---
|
||||||
@@ -2725,6 +2735,8 @@ PLATFORM_ORCHESTRATION_JS = """
|
|||||||
var liveAction = form.getAttribute("action") || _action || location.href;
|
var liveAction = form.getAttribute("action") || _action || location.href;
|
||||||
executeRequest(form, { method: liveMethod, url: liveAction }).then(function() {
|
executeRequest(form, { method: liveMethod, url: liveAction }).then(function() {
|
||||||
try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {}
|
try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {}
|
||||||
|
}).catch(function(err) {
|
||||||
|
logWarn("sx:boost form error " + liveMethod + " " + liveAction + " — " + (err && err.message ? err.message : err));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,19 @@ env["component-has-children"] = function(c) {
|
|||||||
return c && c.has_children ? c.has_children : false;
|
return c && c.has_children ? c.has_children : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Aser test helper: parse SX source, evaluate via aser, return wire format string
|
||||||
|
env["render-sx"] = function(source) {
|
||||||
|
const exprs = Sx.parse(source);
|
||||||
|
const parts = [];
|
||||||
|
for (const expr of exprs) {
|
||||||
|
const result = Sx.renderToSx(expr, env);
|
||||||
|
if (result !== null && result !== undefined && result !== Sx.NIL) {
|
||||||
|
parts.push(typeof result === "string" ? result : Sx.serialize(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
};
|
||||||
|
|
||||||
// Platform test functions
|
// Platform test functions
|
||||||
env["try-call"] = function(thunk) {
|
env["try-call"] = function(thunk) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
"make-action-def" "makeActionDef"
|
"make-action-def" "makeActionDef"
|
||||||
"make-page-def" "makePageDef"
|
"make-page-def" "makePageDef"
|
||||||
"make-symbol" "makeSymbol"
|
"make-symbol" "makeSymbol"
|
||||||
|
"make-env" "makeEnv"
|
||||||
|
"make-sx-expr" "makeSxExpr"
|
||||||
"make-keyword" "makeKeyword"
|
"make-keyword" "makeKeyword"
|
||||||
"lambda-params" "lambdaParams"
|
"lambda-params" "lambdaParams"
|
||||||
"lambda-body" "lambdaBody"
|
"lambda-body" "lambdaBody"
|
||||||
@@ -163,6 +165,7 @@
|
|||||||
"aser-special" "aserSpecial"
|
"aser-special" "aserSpecial"
|
||||||
"eval-case-aser" "evalCaseAser"
|
"eval-case-aser" "evalCaseAser"
|
||||||
"sx-serialize" "sxSerialize"
|
"sx-serialize" "sxSerialize"
|
||||||
|
|
||||||
"sx-serialize-dict" "sxSerializeDict"
|
"sx-serialize-dict" "sxSerializeDict"
|
||||||
"sx-expr-source" "sxExprSource"
|
"sx-expr-source" "sxExprSource"
|
||||||
"sf-if" "sfIf"
|
"sf-if" "sfIf"
|
||||||
@@ -620,6 +623,8 @@
|
|||||||
"cond-scheme?" "condScheme_p"
|
"cond-scheme?" "condScheme_p"
|
||||||
"scope-push!" "scopePush"
|
"scope-push!" "scopePush"
|
||||||
"scope-pop!" "scopePop"
|
"scope-pop!" "scopePop"
|
||||||
|
"scope-emit!" "scopeEmit"
|
||||||
|
"scope-peek" "scopePeek"
|
||||||
"provide-push!" "providePush"
|
"provide-push!" "providePush"
|
||||||
"provide-pop!" "providePop"
|
"provide-pop!" "providePop"
|
||||||
"context" "sxContext"
|
"context" "sxContext"
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ let read_line_blocking () =
|
|||||||
try Some (input_line stdin)
|
try Some (input_line stdin)
|
||||||
with End_of_file -> None
|
with End_of_file -> None
|
||||||
|
|
||||||
|
(** Read exactly N bytes from stdin (blocking). *)
|
||||||
|
let read_exact_bytes n =
|
||||||
|
let buf = Bytes.create n in
|
||||||
|
really_input stdin buf 0 n;
|
||||||
|
Bytes.to_string buf
|
||||||
|
|
||||||
|
(** Read a length-prefixed blob from stdin.
|
||||||
|
Expects the next line to be "(blob N)" where N is byte count,
|
||||||
|
followed by exactly N bytes of raw data, then a newline. *)
|
||||||
|
let read_blob () =
|
||||||
|
match read_line_blocking () with
|
||||||
|
| None -> raise (Eval_error "read_blob: stdin closed")
|
||||||
|
| Some line ->
|
||||||
|
let line = String.trim line in
|
||||||
|
match Sx_parser.parse_all line with
|
||||||
|
| [List [Symbol "blob"; Number n]] ->
|
||||||
|
let len = int_of_float n in
|
||||||
|
let data = read_exact_bytes len in
|
||||||
|
(* consume trailing newline *)
|
||||||
|
(try ignore (input_line stdin) with End_of_file -> ());
|
||||||
|
data
|
||||||
|
| _ -> raise (Eval_error ("read_blob: expected (blob N), got: " ^ line))
|
||||||
|
|
||||||
(** Batch IO mode — collect requests during aser-slot, resolve after. *)
|
(** Batch IO mode — collect requests during aser-slot, resolve after. *)
|
||||||
let io_batch_mode = ref false
|
let io_batch_mode = ref false
|
||||||
let io_queue : (int * string * value list) list ref = ref []
|
let io_queue : (int * string * value list) list ref = ref []
|
||||||
@@ -120,8 +143,8 @@ let io_request name args =
|
|||||||
incr io_counter;
|
incr io_counter;
|
||||||
let id = !io_counter in
|
let id = !io_counter in
|
||||||
io_queue := (id, name, args) :: !io_queue;
|
io_queue := (id, name, args) :: !io_queue;
|
||||||
(* Placeholder starts with ( so aser inlines it as pre-serialized SX *)
|
(* Return SxExpr so serialize/inspect passes it through unquoted *)
|
||||||
String (Printf.sprintf "(\xc2\xabIO:%d\xc2\xbb)" id)
|
SxExpr (Printf.sprintf "(\xc2\xabIO:%d\xc2\xbb)" id)
|
||||||
end else begin
|
end else begin
|
||||||
let args_str = String.concat " " (List.map serialize_value args) in
|
let args_str = String.concat " " (List.map serialize_value args) in
|
||||||
send (Printf.sprintf "(io-request \"%s\" %s)" name args_str);
|
send (Printf.sprintf "(io-request \"%s\" %s)" name args_str);
|
||||||
@@ -539,6 +562,17 @@ let make_server_env () =
|
|||||||
| [a; b] -> Bool (a == b)
|
| [a; b] -> Bool (a == b)
|
||||||
| _ -> raise (Eval_error "identical?: expected 2 args"));
|
| _ -> raise (Eval_error "identical?: expected 2 args"));
|
||||||
|
|
||||||
|
bind "make-sx-expr" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String s] -> SxExpr s
|
||||||
|
| _ -> raise (Eval_error "make-sx-expr: expected string"));
|
||||||
|
|
||||||
|
bind "sx-expr-source" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [SxExpr s] -> String s
|
||||||
|
| [String s] -> String s
|
||||||
|
| _ -> raise (Eval_error "sx-expr-source: expected sx-expr or string"));
|
||||||
|
|
||||||
bind "make-continuation" (fun args ->
|
bind "make-continuation" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [f] ->
|
| [f] ->
|
||||||
@@ -760,7 +794,7 @@ let compile_adapter env =
|
|||||||
(* Command dispatch *)
|
(* Command dispatch *)
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
|
|
||||||
let dispatch env cmd =
|
let rec dispatch env cmd =
|
||||||
match cmd with
|
match cmd with
|
||||||
| List [Symbol "ping"] ->
|
| List [Symbol "ping"] ->
|
||||||
send_ok_string "ocaml-cek"
|
send_ok_string "ocaml-cek"
|
||||||
@@ -792,6 +826,10 @@ let dispatch env cmd =
|
|||||||
| 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))
|
||||||
|
|
||||||
|
| List [Symbol "eval-blob"] ->
|
||||||
|
let src = read_blob () in
|
||||||
|
dispatch env (List [Symbol "eval"; String src])
|
||||||
|
|
||||||
| List [Symbol "eval"; String src] ->
|
| List [Symbol "eval"; String src] ->
|
||||||
(try
|
(try
|
||||||
let exprs = Sx_parser.parse_all src in
|
let exprs = Sx_parser.parse_all src in
|
||||||
@@ -827,6 +865,16 @@ let dispatch env cmd =
|
|||||||
| 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))
|
||||||
|
|
||||||
|
| List [Symbol "aser-blob"] ->
|
||||||
|
(* Like aser but reads source as a binary blob. *)
|
||||||
|
let src = read_blob () in
|
||||||
|
dispatch env (List [Symbol "aser"; String src])
|
||||||
|
|
||||||
|
| List [Symbol "aser-slot-blob"] ->
|
||||||
|
(* Like aser-slot but reads source as a binary blob. *)
|
||||||
|
let src = read_blob () in
|
||||||
|
dispatch env (List [Symbol "aser-slot"; String src])
|
||||||
|
|
||||||
| List [Symbol "aser"; String src] ->
|
| List [Symbol "aser"; String src] ->
|
||||||
(* Evaluate and serialize as SX wire format.
|
(* Evaluate and serialize as SX wire format.
|
||||||
Calls the SX-defined aser function from adapter-sx.sx.
|
Calls the SX-defined aser function from adapter-sx.sx.
|
||||||
@@ -920,6 +968,12 @@ let dispatch env cmd =
|
|||||||
Hashtbl.remove env.bindings "expand-components?";
|
Hashtbl.remove env.bindings "expand-components?";
|
||||||
send_error (Printexc.to_string exn))
|
send_error (Printexc.to_string exn))
|
||||||
|
|
||||||
|
| List (Symbol "sx-page-full-blob" :: shell_kwargs) ->
|
||||||
|
(* Like sx-page-full but reads page source as a length-prefixed blob
|
||||||
|
from the next line(s), avoiding string-escape round-trip issues. *)
|
||||||
|
let page_src = read_blob () in
|
||||||
|
dispatch env (List (Symbol "sx-page-full" :: String page_src :: shell_kwargs))
|
||||||
|
|
||||||
| List (Symbol "sx-page-full" :: String page_src :: shell_kwargs) ->
|
| List (Symbol "sx-page-full" :: String page_src :: shell_kwargs) ->
|
||||||
(* Full page render: aser-slot body + render-to-html shell in ONE call.
|
(* Full page render: aser-slot body + render-to-html shell in ONE call.
|
||||||
shell_kwargs are keyword pairs: :title "..." :csrf "..." etc.
|
shell_kwargs are keyword pairs: :title "..." :csrf "..." etc.
|
||||||
@@ -1167,6 +1221,10 @@ let cli_mode mode =
|
|||||||
let result = Sx_ref.eval_expr call (Env env) in
|
let result = Sx_ref.eval_expr call (Env env) in
|
||||||
(match result with
|
(match result with
|
||||||
| String s | SxExpr s -> print_string s
|
| String s | SxExpr s -> print_string s
|
||||||
|
| Dict d when Hashtbl.mem d "__aser_sx" ->
|
||||||
|
(match Hashtbl.find d "__aser_sx" with
|
||||||
|
| String s | SxExpr s -> print_string s
|
||||||
|
| v -> print_string (serialize_value v))
|
||||||
| _ -> print_string (serialize_value result));
|
| _ -> print_string (serialize_value result));
|
||||||
flush stdout
|
flush stdout
|
||||||
| "aser-slot" ->
|
| "aser-slot" ->
|
||||||
@@ -1180,6 +1238,10 @@ let cli_mode mode =
|
|||||||
let result = Sx_ref.eval_expr call (Env env) in
|
let result = Sx_ref.eval_expr call (Env env) in
|
||||||
(match result with
|
(match result with
|
||||||
| String s | SxExpr s -> print_string s
|
| String s | SxExpr s -> print_string s
|
||||||
|
| Dict d when Hashtbl.mem d "__aser_sx" ->
|
||||||
|
(match Hashtbl.find d "__aser_sx" with
|
||||||
|
| String s | SxExpr s -> print_string s
|
||||||
|
| v -> print_string (serialize_value v))
|
||||||
| _ -> print_string (serialize_value result));
|
| _ -> print_string (serialize_value result));
|
||||||
flush stdout
|
flush stdout
|
||||||
| _ ->
|
| _ ->
|
||||||
|
|||||||
@@ -393,7 +393,18 @@ let rec inspect = function
|
|||||||
| Number n ->
|
| Number n ->
|
||||||
if Float.is_integer n then Printf.sprintf "%d" (int_of_float n)
|
if Float.is_integer n then Printf.sprintf "%d" (int_of_float n)
|
||||||
else Printf.sprintf "%g" n
|
else Printf.sprintf "%g" n
|
||||||
| String s -> Printf.sprintf "%S" s
|
| String s ->
|
||||||
|
let buf = Buffer.create (String.length s + 2) in
|
||||||
|
Buffer.add_char buf '"';
|
||||||
|
String.iter (function
|
||||||
|
| '"' -> 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;
|
||||||
|
Buffer.add_char buf '"';
|
||||||
|
Buffer.contents buf
|
||||||
| Symbol s -> s
|
| Symbol s -> s
|
||||||
| Keyword k -> ":" ^ k
|
| Keyword k -> ":" ^ k
|
||||||
| List items | ListRef { contents = items } ->
|
| List items | ListRef { contents = items } ->
|
||||||
|
|||||||
@@ -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-19T14:05:23Z";
|
var SX_VERSION = "2026-03-22T20:34:05Z";
|
||||||
|
|
||||||
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); }
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
if (x._spread) return "spread";
|
if (x._spread) return "spread";
|
||||||
if (x._macro) return "macro";
|
if (x._macro) return "macro";
|
||||||
if (x._raw) return "raw-html";
|
if (x._raw) return "raw-html";
|
||||||
|
if (x._sx_expr) return "sx-expr";
|
||||||
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
|
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
|
||||||
if (Array.isArray(x)) return "list";
|
if (Array.isArray(x)) return "list";
|
||||||
if (typeof x === "object") return "dict";
|
if (typeof x === "object") return "dict";
|
||||||
@@ -510,8 +511,10 @@
|
|||||||
PRIMITIVES["emit!"] = sxEmit;
|
PRIMITIVES["emit!"] = sxEmit;
|
||||||
PRIMITIVES["emitted"] = sxEmitted;
|
PRIMITIVES["emitted"] = sxEmitted;
|
||||||
// Aliases for aser adapter (avoids CEK special form conflict on server)
|
// Aliases for aser adapter (avoids CEK special form conflict on server)
|
||||||
PRIMITIVES["scope-emit!"] = sxEmit;
|
var scopeEmit = sxEmit;
|
||||||
PRIMITIVES["scope-peek"] = sxEmitted;
|
var scopePeek = sxEmitted;
|
||||||
|
PRIMITIVES["scope-emit!"] = scopeEmit;
|
||||||
|
PRIMITIVES["scope-peek"] = scopePeek;
|
||||||
|
|
||||||
|
|
||||||
function isPrimitive(name) { return name in PRIMITIVES; }
|
function isPrimitive(name) { return name in PRIMITIVES; }
|
||||||
@@ -592,6 +595,7 @@
|
|||||||
// escape-html and escape-attr are now library functions defined in render.sx
|
// escape-html and escape-attr are now library functions defined in render.sx
|
||||||
function rawHtmlContent(r) { return r.html; }
|
function rawHtmlContent(r) { return r.html; }
|
||||||
function makeRawHtml(s) { return { _raw: true, html: s }; }
|
function makeRawHtml(s) { return { _raw: true, html: s }; }
|
||||||
|
function makeSxExpr(s) { return { _sx_expr: true, source: s }; }
|
||||||
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
|
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
|
||||||
|
|
||||||
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
|
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
|
||||||
@@ -848,7 +852,7 @@
|
|||||||
function escapeString(s) {
|
function escapeString(s) {
|
||||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
||||||
}
|
}
|
||||||
function sxExprSource(e) { return typeof e === "string" ? e : String(e); }
|
function sxExprSource(e) { return typeof e === "string" ? e : (e && e.source ? e.source : String(e)); }
|
||||||
var charFromCode = PRIMITIVES["char-from-code"];
|
var charFromCode = PRIMITIVES["char-from-code"];
|
||||||
|
|
||||||
|
|
||||||
@@ -1646,7 +1650,7 @@ PRIMITIVES["step-sf-deref"] = stepSfDeref;
|
|||||||
// cek-call
|
// cek-call
|
||||||
var cekCall = function(f, args) { return (function() {
|
var cekCall = function(f, args) { return (function() {
|
||||||
var a = (isSxTruthy(isNil(args)) ? [] : args);
|
var a = (isSxTruthy(isNil(args)) ? [] : args);
|
||||||
return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(sxOr(isLambda(f), isCallable(f))) ? cekRun(continueWithCall(f, a, {}, a, [])) : NIL));
|
return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(sxOr(isLambda(f), isCallable(f))) ? cekRun(continueWithCall(f, a, makeEnv(), a, [])) : NIL));
|
||||||
})(); };
|
})(); };
|
||||||
PRIMITIVES["cek-call"] = cekCall;
|
PRIMITIVES["cek-call"] = cekCall;
|
||||||
|
|
||||||
@@ -2655,7 +2659,7 @@ PRIMITIVES["serialize-island-state"] = serializeIslandState;
|
|||||||
// render-to-sx
|
// render-to-sx
|
||||||
var renderToSx = function(expr, env) { return (function() {
|
var renderToSx = function(expr, env) { return (function() {
|
||||||
var result = aser(expr, env);
|
var result = aser(expr, env);
|
||||||
return (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result));
|
return (isSxTruthy((typeOf(result) == "sx-expr")) ? sxExprSource(result) : (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result)));
|
||||||
})(); };
|
})(); };
|
||||||
PRIMITIVES["render-to-sx"] = renderToSx;
|
PRIMITIVES["render-to-sx"] = renderToSx;
|
||||||
|
|
||||||
@@ -2665,8 +2669,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 (scopeEmit_b("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("element-attrs", spreadAttrs(expr)), NIL); return expr; })();
|
||||||
return (isSxTruthy(isSpread(result)) ? (scopeEmit_b("element-attrs", spreadAttrs(result)), NIL) : result);
|
return (isSxTruthy(isSpread(result)) ? (scopeEmit("element-attrs", spreadAttrs(result)), NIL) : result);
|
||||||
})(); };
|
})(); };
|
||||||
PRIMITIVES["aser"] = aser;
|
PRIMITIVES["aser"] = aser;
|
||||||
|
|
||||||
@@ -2694,9 +2698,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(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(isNil(result)) ? NIL : (isSxTruthy((typeOf(result) == "sx-expr")) ? append_b(parts, sxExprSource(result)) : (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((typeOf(item) == "sx-expr")) ? append_b(parts, sxExprSource(item)) : append_b(parts, serialize(item))) : NIL); }, result) : append_b(parts, serialize(result)))));
|
||||||
})(); } }
|
})(); } }
|
||||||
return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? first(parts) : (String("(<> ") + String(join(" ", parts)) + String(")"))));
|
return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? makeSxExpr(first(parts)) : makeSxExpr((String("(<> ") + String(join(" ", parts)) + String(")")))));
|
||||||
})(); };
|
})(); };
|
||||||
PRIMITIVES["aser-fragment"] = aserFragment;
|
PRIMITIVES["aser-fragment"] = aserFragment;
|
||||||
|
|
||||||
@@ -2711,14 +2715,14 @@ PRIMITIVES["aser-fragment"] = aserFragment;
|
|||||||
var val = aser(nth(args, (i + 1)), env);
|
var val = aser(nth(args, (i + 1)), env);
|
||||||
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
|
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
|
||||||
attrParts.push((String(":") + String(keywordName(arg))));
|
attrParts.push((String(":") + String(keywordName(arg))));
|
||||||
(isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(attrParts, val) : append_b(attrParts, serialize(val)));
|
(isSxTruthy((typeOf(val) == "sx-expr")) ? append_b(attrParts, sxExprSource(val)) : append_b(attrParts, serialize(val)));
|
||||||
}
|
}
|
||||||
skip = true;
|
skip = true;
|
||||||
return (i = (i + 1));
|
return (i = (i + 1));
|
||||||
})() : (function() {
|
})() : (function() {
|
||||||
var val = aser(arg, env);
|
var val = aser(arg, env);
|
||||||
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
|
if (isSxTruthy(!isSxTruthy(isNil(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))));
|
(isSxTruthy((typeOf(val) == "sx-expr")) ? append_b(childParts, sxExprSource(val)) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((typeOf(item) == "sx-expr")) ? append_b(childParts, sxExprSource(item)) : append_b(childParts, serialize(item))) : NIL); }, val) : append_b(childParts, serialize(val))));
|
||||||
}
|
}
|
||||||
return (i = (i + 1));
|
return (i = (i + 1));
|
||||||
})())); } }
|
})())); } }
|
||||||
@@ -2730,7 +2734,7 @@ PRIMITIVES["aser-fragment"] = aserFragment;
|
|||||||
scopePop("element-attrs");
|
scopePop("element-attrs");
|
||||||
return (function() {
|
return (function() {
|
||||||
var parts = concat([name], attrParts, childParts);
|
var parts = concat([name], attrParts, childParts);
|
||||||
return (String("(") + String(join(" ", parts)) + String(")"));
|
return makeSxExpr((String("(") + String(join(" ", parts)) + String(")")));
|
||||||
})();
|
})();
|
||||||
})(); };
|
})(); };
|
||||||
PRIMITIVES["aser-call"] = aserCall;
|
PRIMITIVES["aser-call"] = aserCall;
|
||||||
@@ -3952,7 +3956,7 @@ PRIMITIVES["execute-request"] = executeRequest;
|
|||||||
domAddClass(el, "sx-request");
|
domAddClass(el, "sx-request");
|
||||||
domSetAttr(el, "aria-busy", "true");
|
domSetAttr(el, "aria-busy", "true");
|
||||||
domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method});
|
domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method});
|
||||||
return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); });
|
return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? (logWarn((String("sx:fetch error ") + String(method) + String(" ") + String(finalUrl) + String(" — ") + String(err))), domDispatch(el, "sx:requestError", {["error"]: err})) : NIL)); });
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
@@ -5866,7 +5870,8 @@ PRIMITIVES["resource"] = resource;
|
|||||||
PRIMITIVES["island?"] = isIsland;
|
PRIMITIVES["island?"] = isIsland;
|
||||||
PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); };
|
PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); };
|
||||||
PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; };
|
PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; };
|
||||||
PRIMITIVES["make-env"] = function() { return merge(componentEnv, PRIMITIVES); };
|
function makeEnv() { return merge(componentEnv, PRIMITIVES); }
|
||||||
|
PRIMITIVES["make-env"] = makeEnv;
|
||||||
|
|
||||||
// localStorage — defined here (before boot) so islands can use at hydration
|
// localStorage — defined here (before boot) so islands can use at hydration
|
||||||
PRIMITIVES["local-storage-get"] = function(key) {
|
PRIMITIVES["local-storage-get"] = function(key) {
|
||||||
@@ -6393,7 +6398,10 @@ PRIMITIVES["resource"] = resource;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch(function() { location.reload(); });
|
}).catch(function(err) {
|
||||||
|
logWarn("sx:popstate fetch error " + url + " — " + (err && err.message ? err.message : err));
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchStreaming(target, url, headers) {
|
function fetchStreaming(target, url, headers) {
|
||||||
@@ -6531,7 +6539,9 @@ PRIMITIVES["resource"] = resource;
|
|||||||
return resp.text().then(function(text) {
|
return resp.text().then(function(text) {
|
||||||
preloadCacheSet(cache, url, text, ct);
|
preloadCacheSet(cache, url, text, ct);
|
||||||
});
|
});
|
||||||
}).catch(function() { /* ignore */ });
|
}).catch(function(err) {
|
||||||
|
logInfo("sx:preload error " + url + " — " + (err && err.message ? err.message : err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Request body building ---
|
// --- Request body building ---
|
||||||
@@ -6820,6 +6830,8 @@ PRIMITIVES["resource"] = resource;
|
|||||||
var liveAction = form.getAttribute("action") || _action || location.href;
|
var liveAction = form.getAttribute("action") || _action || location.href;
|
||||||
executeRequest(form, { method: liveMethod, url: liveAction }).then(function() {
|
executeRequest(form, { method: liveMethod, url: liveAction }).then(function() {
|
||||||
try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {}
|
try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {}
|
||||||
|
}).catch(function(err) {
|
||||||
|
logWarn("sx:boost form error " + liveMethod + " " + liveAction + " — " + (err && err.message ? err.message : err));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class OcamlBridge:
|
|||||||
self._binary = binary or os.environ.get("SX_OCAML_BIN") or _DEFAULT_BIN
|
self._binary = binary or os.environ.get("SX_OCAML_BIN") or _DEFAULT_BIN
|
||||||
self._proc: asyncio.subprocess.Process | None = None
|
self._proc: asyncio.subprocess.Process | None = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._in_io_handler = False # re-entrancy guard
|
||||||
self._started = False
|
self._started = False
|
||||||
self._components_loaded = False
|
self._components_loaded = False
|
||||||
self._helpers_injected = False
|
self._helpers_injected = False
|
||||||
@@ -123,7 +124,8 @@ class OcamlBridge:
|
|||||||
"""
|
"""
|
||||||
await self._ensure_components()
|
await self._ensure_components()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._send(f'(eval "{_escape(source)}")')
|
await self._send('(eval-blob)')
|
||||||
|
await self._send_blob(source)
|
||||||
return await self._read_until_ok(ctx)
|
return await self._read_until_ok(ctx)
|
||||||
|
|
||||||
async def render(
|
async def render(
|
||||||
@@ -141,7 +143,8 @@ class OcamlBridge:
|
|||||||
"""Evaluate SX and return SX wire format, handling io-requests."""
|
"""Evaluate SX and return SX wire format, handling io-requests."""
|
||||||
await self._ensure_components()
|
await self._ensure_components()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._send(f'(aser "{_escape(source)}")')
|
await self._send('(aser-blob)')
|
||||||
|
await self._send_blob(source)
|
||||||
return await self._read_until_ok(ctx)
|
return await self._read_until_ok(ctx)
|
||||||
|
|
||||||
async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str:
|
async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str:
|
||||||
@@ -156,7 +159,8 @@ class OcamlBridge:
|
|||||||
# a separate lock acquisition could let another coroutine
|
# a separate lock acquisition could let another coroutine
|
||||||
# interleave commands between injection and aser-slot.
|
# interleave commands between injection and aser-slot.
|
||||||
await self._inject_helpers_locked()
|
await self._inject_helpers_locked()
|
||||||
await self._send(f'(aser-slot "{_escape(source)}")')
|
await self._send('(aser-slot-blob)')
|
||||||
|
await self._send_blob(source)
|
||||||
return await self._read_until_ok(ctx)
|
return await self._read_until_ok(ctx)
|
||||||
|
|
||||||
_shell_statics_injected: bool = False
|
_shell_statics_injected: bool = False
|
||||||
@@ -227,7 +231,10 @@ class OcamlBridge:
|
|||||||
static_keys = {"component_hash", "sx_css_classes", "asset_url",
|
static_keys = {"component_hash", "sx_css_classes", "asset_url",
|
||||||
"sx_js_hash", "body_js_hash",
|
"sx_js_hash", "body_js_hash",
|
||||||
"head_scripts", "body_scripts"}
|
"head_scripts", "body_scripts"}
|
||||||
parts = [f'(sx-page-full "{_escape(page_source)}"']
|
# page_source is SX wire format that may contain \" escapes.
|
||||||
|
# Send via binary blob protocol to avoid double-escaping
|
||||||
|
# through the SX string parser round-trip.
|
||||||
|
parts = ['(sx-page-full-blob']
|
||||||
for key, val in shell_kwargs.items():
|
for key, val in shell_kwargs.items():
|
||||||
k = key.replace("_", "-")
|
k = key.replace("_", "-")
|
||||||
if key in PLACEHOLDER_KEYS:
|
if key in PLACEHOLDER_KEYS:
|
||||||
@@ -248,6 +255,8 @@ class OcamlBridge:
|
|||||||
parts.append(")")
|
parts.append(")")
|
||||||
cmd = "".join(parts)
|
cmd = "".join(parts)
|
||||||
await self._send(cmd)
|
await self._send(cmd)
|
||||||
|
# Send page source as binary blob (avoids string-escape issues)
|
||||||
|
await self._send_blob(page_source)
|
||||||
html = await self._read_until_ok(ctx)
|
html = await self._read_until_ok(ctx)
|
||||||
# Splice in large blobs
|
# Splice in large blobs
|
||||||
for token, blob in placeholders.items():
|
for token, blob in placeholders.items():
|
||||||
@@ -473,11 +482,30 @@ class OcamlBridge:
|
|||||||
|
|
||||||
async def _send(self, line: str) -> None:
|
async def _send(self, line: str) -> None:
|
||||||
"""Write a line to the subprocess stdin and flush."""
|
"""Write a line to the subprocess stdin and flush."""
|
||||||
|
if self._in_io_handler:
|
||||||
|
raise OcamlBridgeError(
|
||||||
|
f"Re-entrant bridge call from IO handler: {line[:80]}. "
|
||||||
|
f"IO handlers must not call the bridge — use Python-only code."
|
||||||
|
)
|
||||||
assert self._proc and self._proc.stdin
|
assert self._proc and self._proc.stdin
|
||||||
_logger.debug("SEND: %s", line[:120])
|
_logger.debug("SEND: %s", line[:120])
|
||||||
self._proc.stdin.write((line + "\n").encode())
|
self._proc.stdin.write((line + "\n").encode())
|
||||||
await self._proc.stdin.drain()
|
await self._proc.stdin.drain()
|
||||||
|
|
||||||
|
async def _send_blob(self, data: str) -> None:
|
||||||
|
"""Send a length-prefixed binary blob to the subprocess.
|
||||||
|
|
||||||
|
Protocol: sends "(blob N)\\n" followed by exactly N bytes, then "\\n".
|
||||||
|
The OCaml side reads the length, then reads exactly N bytes.
|
||||||
|
This avoids string-escape round-trip issues for SX wire format.
|
||||||
|
"""
|
||||||
|
assert self._proc and self._proc.stdin
|
||||||
|
encoded = data.encode()
|
||||||
|
self._proc.stdin.write(f"(blob {len(encoded)})\n".encode())
|
||||||
|
self._proc.stdin.write(encoded)
|
||||||
|
self._proc.stdin.write(b"\n")
|
||||||
|
await self._proc.stdin.drain()
|
||||||
|
|
||||||
async def _readline(self) -> str:
|
async def _readline(self) -> str:
|
||||||
"""Read a line from the subprocess stdout."""
|
"""Read a line from the subprocess stdout."""
|
||||||
assert self._proc and self._proc.stdout
|
assert self._proc and self._proc.stdout
|
||||||
@@ -574,7 +602,24 @@ class OcamlBridge:
|
|||||||
line: str,
|
line: str,
|
||||||
ctx: dict[str, Any] | None,
|
ctx: dict[str, Any] | None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Dispatch an io-request to the appropriate Python handler."""
|
"""Dispatch an io-request to the appropriate Python handler.
|
||||||
|
|
||||||
|
IO handlers MUST NOT call the bridge (eval/aser/render) — doing so
|
||||||
|
would deadlock since the lock is already held. The _in_io_handler
|
||||||
|
flag triggers an immediate error if this rule is violated.
|
||||||
|
"""
|
||||||
|
self._in_io_handler = True
|
||||||
|
try:
|
||||||
|
return await self._dispatch_io(line, ctx)
|
||||||
|
finally:
|
||||||
|
self._in_io_handler = False
|
||||||
|
|
||||||
|
async def _dispatch_io(
|
||||||
|
self,
|
||||||
|
line: str,
|
||||||
|
ctx: dict[str, Any] | None,
|
||||||
|
) -> Any:
|
||||||
|
"""Inner dispatch for IO requests."""
|
||||||
from .parser import parse_all
|
from .parser import parse_all
|
||||||
|
|
||||||
# Parse the io-request
|
# Parse the io-request
|
||||||
|
|||||||
@@ -181,6 +181,47 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
|
|||||||
# Page execution
|
# Page execution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _wrap_with_env(expr: Any, env: dict) -> str:
|
||||||
|
"""Serialize an expression wrapped with let-bindings from env.
|
||||||
|
|
||||||
|
Injects page env values (URL params, data results) as let-bindings
|
||||||
|
so the OCaml kernel can evaluate the expression with those bindings.
|
||||||
|
Only injects non-component, non-callable values that pages add dynamically.
|
||||||
|
"""
|
||||||
|
from .parser import serialize
|
||||||
|
from .ocaml_bridge import _serialize_for_ocaml
|
||||||
|
from .types import Symbol, Keyword, NIL
|
||||||
|
|
||||||
|
body = serialize(expr)
|
||||||
|
bindings = []
|
||||||
|
for k, v in env.items():
|
||||||
|
# Skip component definitions — already loaded in kernel
|
||||||
|
if k.startswith("~") or callable(v):
|
||||||
|
continue
|
||||||
|
# Skip env keys that are component-env infrastructure
|
||||||
|
if isinstance(v, (type, type(None))) and v is not None:
|
||||||
|
continue
|
||||||
|
# Serialize the value
|
||||||
|
if v is NIL or v is None:
|
||||||
|
sv = "nil"
|
||||||
|
elif isinstance(v, bool):
|
||||||
|
sv = "true" if v else "false"
|
||||||
|
elif isinstance(v, (int, float)):
|
||||||
|
sv = str(int(v)) if isinstance(v, float) and v == int(v) else str(v)
|
||||||
|
elif isinstance(v, str):
|
||||||
|
sv = _serialize_for_ocaml(v)
|
||||||
|
elif isinstance(v, (list, dict)):
|
||||||
|
sv = _serialize_for_ocaml(v)
|
||||||
|
else:
|
||||||
|
# Component, Lambda, etc — skip, already in kernel
|
||||||
|
continue
|
||||||
|
bindings.append(f"({k} {sv})")
|
||||||
|
|
||||||
|
if not bindings:
|
||||||
|
return body
|
||||||
|
return f"(let ({' '.join(bindings)}) {body})"
|
||||||
|
|
||||||
|
|
||||||
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
||||||
"""Evaluate a page slot expression and return an sx source string.
|
"""Evaluate a page slot expression and return an sx source string.
|
||||||
|
|
||||||
@@ -188,6 +229,15 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
|||||||
the result as SX wire format, not HTML.
|
the result as SX wire format, not HTML.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
if os.environ.get("SX_USE_OCAML") == "1":
|
||||||
|
from .ocaml_bridge import get_bridge
|
||||||
|
from .parser import serialize
|
||||||
|
bridge = await get_bridge()
|
||||||
|
# Wrap expression with let-bindings for env values that pages
|
||||||
|
# inject (URL params, data results, etc.)
|
||||||
|
sx_text = _wrap_with_env(expr, env)
|
||||||
|
service = ctx.get("_helper_service", "") if isinstance(ctx, dict) else ""
|
||||||
|
return await bridge.aser_slot(sx_text, ctx={"_helper_service": service})
|
||||||
if os.environ.get("SX_USE_REF") == "1":
|
if os.environ.get("SX_USE_REF") == "1":
|
||||||
from .ref.async_eval_ref import async_eval_slot_to_sx
|
from .ref.async_eval_ref import async_eval_slot_to_sx
|
||||||
else:
|
else:
|
||||||
@@ -248,12 +298,19 @@ async def execute_page(
|
|||||||
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
|
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
|
||||||
"""
|
"""
|
||||||
from .jinja_bridge import get_component_env, _get_request_context
|
from .jinja_bridge import get_component_env, _get_request_context
|
||||||
from .async_eval import async_eval
|
|
||||||
from .page import get_template_context
|
from .page import get_template_context
|
||||||
from .helpers import full_page_sx, oob_page_sx, sx_response
|
from .helpers import full_page_sx, oob_page_sx, sx_response
|
||||||
from .layouts import get_layout
|
from .layouts import get_layout
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
_use_ocaml = os.environ.get("SX_USE_OCAML") == "1"
|
||||||
|
if _use_ocaml:
|
||||||
|
from .ocaml_bridge import get_bridge
|
||||||
|
from .parser import serialize, parse_all
|
||||||
|
from .ocaml_bridge import _serialize_for_ocaml
|
||||||
|
else:
|
||||||
|
from .async_eval import async_eval
|
||||||
|
|
||||||
if url_params is None:
|
if url_params is None:
|
||||||
url_params = {}
|
url_params = {}
|
||||||
|
|
||||||
@@ -275,7 +332,19 @@ async def execute_page(
|
|||||||
# Evaluate :data expression if present
|
# Evaluate :data expression if present
|
||||||
_multi_stream_content = None
|
_multi_stream_content = None
|
||||||
if page_def.data_expr is not None:
|
if page_def.data_expr is not None:
|
||||||
data_result = await async_eval(page_def.data_expr, env, ctx)
|
if _use_ocaml:
|
||||||
|
bridge = await get_bridge()
|
||||||
|
sx_text = _wrap_with_env(page_def.data_expr, env)
|
||||||
|
ocaml_ctx = {"_helper_service": service_name}
|
||||||
|
raw = await bridge.eval(sx_text, ctx=ocaml_ctx)
|
||||||
|
# Parse result back to Python dict/value
|
||||||
|
if raw:
|
||||||
|
parsed = parse_all(raw)
|
||||||
|
data_result = parsed[0] if parsed else {}
|
||||||
|
else:
|
||||||
|
data_result = {}
|
||||||
|
else:
|
||||||
|
data_result = await async_eval(page_def.data_expr, env, ctx)
|
||||||
if hasattr(data_result, '__aiter__'):
|
if hasattr(data_result, '__aiter__'):
|
||||||
# Multi-stream: consume generator, eval :content per chunk,
|
# Multi-stream: consume generator, eval :content per chunk,
|
||||||
# combine into shell with resolved suspense slots.
|
# combine into shell with resolved suspense slots.
|
||||||
@@ -358,7 +427,18 @@ async def execute_page(
|
|||||||
k = raw[i]
|
k = raw[i]
|
||||||
if isinstance(k, SxKeyword) and i + 1 < len(raw):
|
if isinstance(k, SxKeyword) and i + 1 < len(raw):
|
||||||
raw_val = raw[i + 1]
|
raw_val = raw[i + 1]
|
||||||
resolved = await async_eval(raw_val, env, ctx)
|
if _use_ocaml:
|
||||||
|
bridge = await get_bridge()
|
||||||
|
sx_text = _wrap_with_env(raw_val, env)
|
||||||
|
ocaml_ctx = {"_helper_service": service_name}
|
||||||
|
raw_result = await bridge.eval(sx_text, ctx=ocaml_ctx)
|
||||||
|
if raw_result:
|
||||||
|
parsed = parse_all(raw_result)
|
||||||
|
resolved = parsed[0] if parsed else None
|
||||||
|
else:
|
||||||
|
resolved = None
|
||||||
|
else:
|
||||||
|
resolved = await async_eval(raw_val, env, ctx)
|
||||||
layout_kwargs[k.name.replace("-", "_")] = resolved
|
layout_kwargs[k.name.replace("-", "_")] = resolved
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
|
|||||||
"""
|
"""
|
||||||
from .jinja_bridge import get_component_env, _get_request_context
|
from .jinja_bridge import get_component_env, _get_request_context
|
||||||
import os
|
import os
|
||||||
if os.environ.get("SX_USE_REF") == "1":
|
|
||||||
from .ref.async_eval_ref import async_eval
|
|
||||||
else:
|
|
||||||
from .async_eval import async_eval
|
|
||||||
|
|
||||||
env = dict(get_component_env())
|
env = dict(get_component_env())
|
||||||
env.update(query_def.closure)
|
env.update(query_def.closure)
|
||||||
@@ -38,6 +34,26 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
|
|||||||
val = int(val)
|
val = int(val)
|
||||||
env[param] = val
|
env[param] = val
|
||||||
|
|
||||||
|
if os.environ.get("SX_USE_OCAML") == "1":
|
||||||
|
from .ocaml_bridge import get_bridge
|
||||||
|
from .parser import serialize, parse_all
|
||||||
|
from .pages import _wrap_with_env
|
||||||
|
bridge = await get_bridge()
|
||||||
|
sx_text = _wrap_with_env(query_def.body, env)
|
||||||
|
ctx = {"_helper_service": ""}
|
||||||
|
raw = await bridge.eval(sx_text, ctx=ctx)
|
||||||
|
if raw:
|
||||||
|
parsed = parse_all(raw)
|
||||||
|
result = parsed[0] if parsed else None
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
return _normalize(result)
|
||||||
|
|
||||||
|
if os.environ.get("SX_USE_REF") == "1":
|
||||||
|
from .ref.async_eval_ref import async_eval
|
||||||
|
else:
|
||||||
|
from .async_eval import async_eval
|
||||||
|
|
||||||
ctx = _get_request_context()
|
ctx = _get_request_context()
|
||||||
result = await async_eval(query_def.body, env, ctx)
|
result = await async_eval(query_def.body, env, ctx)
|
||||||
return _normalize(result)
|
return _normalize(result)
|
||||||
@@ -50,10 +66,6 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
|
|||||||
"""
|
"""
|
||||||
from .jinja_bridge import get_component_env, _get_request_context
|
from .jinja_bridge import get_component_env, _get_request_context
|
||||||
import os
|
import os
|
||||||
if os.environ.get("SX_USE_REF") == "1":
|
|
||||||
from .ref.async_eval_ref import async_eval
|
|
||||||
else:
|
|
||||||
from .async_eval import async_eval
|
|
||||||
|
|
||||||
env = dict(get_component_env())
|
env = dict(get_component_env())
|
||||||
env.update(action_def.closure)
|
env.update(action_def.closure)
|
||||||
@@ -64,6 +76,26 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
|
|||||||
val = payload.get(param, payload.get(snake, NIL))
|
val = payload.get(param, payload.get(snake, NIL))
|
||||||
env[param] = val
|
env[param] = val
|
||||||
|
|
||||||
|
if os.environ.get("SX_USE_OCAML") == "1":
|
||||||
|
from .ocaml_bridge import get_bridge
|
||||||
|
from .parser import serialize, parse_all
|
||||||
|
from .pages import _wrap_with_env
|
||||||
|
bridge = await get_bridge()
|
||||||
|
sx_text = _wrap_with_env(action_def.body, env)
|
||||||
|
ctx = {"_helper_service": ""}
|
||||||
|
raw = await bridge.eval(sx_text, ctx=ctx)
|
||||||
|
if raw:
|
||||||
|
parsed = parse_all(raw)
|
||||||
|
result = parsed[0] if parsed else None
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
return _normalize(result)
|
||||||
|
|
||||||
|
if os.environ.get("SX_USE_REF") == "1":
|
||||||
|
from .ref.async_eval_ref import async_eval
|
||||||
|
else:
|
||||||
|
from .async_eval import async_eval
|
||||||
|
|
||||||
ctx = _get_request_context()
|
ctx = _get_request_context()
|
||||||
result = await async_eval(action_def.body, env, ctx)
|
result = await async_eval(action_def.body, env, ctx)
|
||||||
return _normalize(result)
|
return _normalize(result)
|
||||||
|
|||||||
148
sx/sx/geography/index.sx
Normal file
148
sx/sx/geography/index.sx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
;; Geography index — architecture overview
|
||||||
|
;; Describes the rendering pipeline: OCaml evaluator → wire formats → client
|
||||||
|
|
||||||
|
(defcomp ~geography/index-content () :affinity :server
|
||||||
|
(<>
|
||||||
|
(h2 :class "text-3xl font-bold text-stone-800 mb-4" "Geography")
|
||||||
|
(p :class "text-lg text-stone-600 mb-8"
|
||||||
|
"Where code runs and how it gets there. Geography maps the rendering pipeline from server-side evaluation through wire formats to client-side hydration.")
|
||||||
|
|
||||||
|
;; Architecture diagram
|
||||||
|
(div :class "bg-stone-50 border border-stone-200 rounded-lg p-6 mb-8"
|
||||||
|
(h3 :class "text-xl font-semibold text-stone-700 mb-4" "Rendering Pipeline")
|
||||||
|
(div :class "space-y-4"
|
||||||
|
;; Server
|
||||||
|
(div :class "flex items-start gap-4"
|
||||||
|
(div :class "w-28 shrink-0 font-mono text-sm font-semibold text-sky-700 bg-sky-50 rounded px-2 py-1 text-center" "OCaml kernel")
|
||||||
|
(div
|
||||||
|
(p :class "text-stone-700"
|
||||||
|
"The evaluator is a CEK machine written in SX and bootstrapped to OCaml. It evaluates page definitions, expands components, resolves IO (helpers, queries), and serializes the result as SX wire format.")
|
||||||
|
(p :class "text-sm text-stone-500 mt-1"
|
||||||
|
"spec/evaluator.sx → hosts/ocaml/ → aser-slot with batch IO")))
|
||||||
|
|
||||||
|
;; Wire format
|
||||||
|
(div :class "flex items-start gap-4"
|
||||||
|
(div :class "w-28 shrink-0 font-mono text-sm font-semibold text-amber-700 bg-amber-50 rounded px-2 py-1 text-center" "Wire format")
|
||||||
|
(div
|
||||||
|
(p :class "text-stone-700"
|
||||||
|
"The aser (async-serialize) mode produces SX text — HTML tags and component calls serialized as s-expressions. Components with server affinity are expanded; client components stay as calls. The wire format is placed in a "
|
||||||
|
(code :class "text-sm" "<script type=\"text/sx\">")
|
||||||
|
" tag inside the HTML shell.")
|
||||||
|
(p :class "text-sm text-stone-500 mt-1"
|
||||||
|
"web/adapter-sx.sx → SxExpr values pass through serialize unquoted")))
|
||||||
|
|
||||||
|
;; Client
|
||||||
|
(div :class "flex items-start gap-4"
|
||||||
|
(div :class "w-28 shrink-0 font-mono text-sm font-semibold text-emerald-700 bg-emerald-50 rounded px-2 py-1 text-center" "sx-browser.js")
|
||||||
|
(div
|
||||||
|
(p :class "text-stone-700"
|
||||||
|
"The client engine parses the SX wire format, evaluates component definitions, renders the DOM, and hydrates reactive islands. It includes the same CEK evaluator (transpiled from the spec), the parser, all web adapters, and the orchestration layer for fetch/swap/polling.")
|
||||||
|
(p :class "text-sm text-stone-500 mt-1"
|
||||||
|
"spec/ + web/ → hosts/javascript/cli.py → sx-browser.js (~400KB)")))))
|
||||||
|
|
||||||
|
;; What lives where
|
||||||
|
(h3 :class "text-xl font-semibold text-stone-700 mb-4 mt-8" "What lives where")
|
||||||
|
|
||||||
|
(div :class "grid md:grid-cols-2 gap-4 mb-8"
|
||||||
|
;; Spec
|
||||||
|
(div :class "border border-stone-200 rounded-lg p-4"
|
||||||
|
(h4 :class "font-semibold text-stone-700 mb-2" "Spec (shared)")
|
||||||
|
(p :class "text-sm text-stone-600 mb-2" "The canonical SX language, bootstrapped identically to OCaml, JavaScript, and Python:")
|
||||||
|
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||||
|
(li "CEK evaluator — frames, step function, call dispatch")
|
||||||
|
(li "Parser — tokenizer, s-expression reader, serializer")
|
||||||
|
(li "Primitives — ~80 built-in pure functions")
|
||||||
|
(li "Render modes — HTML, SX wire, DOM")))
|
||||||
|
|
||||||
|
;; Web adapters
|
||||||
|
(div :class "border border-stone-200 rounded-lg p-4"
|
||||||
|
(h4 :class "font-semibold text-stone-700 mb-2" "Web Adapters")
|
||||||
|
(p :class "text-sm text-stone-600 mb-2" "SX-defined modules that run on both server and client:")
|
||||||
|
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||||
|
(li "adapter-sx.sx — aser wire format (server component expansion)")
|
||||||
|
(li "adapter-html.sx — server HTML rendering")
|
||||||
|
(li "adapter-dom.sx — client DOM rendering")
|
||||||
|
(li "orchestration.sx — fetch, swap, polling, navigation")
|
||||||
|
(li "engine.sx — trigger parsing, request building")))
|
||||||
|
|
||||||
|
;; OCaml kernel
|
||||||
|
(div :class "border border-stone-200 rounded-lg p-4"
|
||||||
|
(h4 :class "font-semibold text-stone-700 mb-2" "OCaml Kernel (server)")
|
||||||
|
(p :class "text-sm text-stone-600 mb-2" "Persistent process connected via a binary pipe protocol:")
|
||||||
|
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||||
|
(li "CEK evaluator + VM bytecode compiler")
|
||||||
|
(li "Batch IO bridge — defers helper/query calls to Python")
|
||||||
|
(li "Length-prefixed blob protocol — no string escaping")
|
||||||
|
(li "Component hot-reload on .sx file changes")))
|
||||||
|
|
||||||
|
;; sx-browser.js
|
||||||
|
(div :class "border border-stone-200 rounded-lg p-4"
|
||||||
|
(h4 :class "font-semibold text-stone-700 mb-2" "sx-browser.js (client)")
|
||||||
|
(p :class "text-sm text-stone-600 mb-2" "Single JS bundle transpiled from spec + web adapters:")
|
||||||
|
(ul :class "text-sm text-stone-600 space-y-1 list-disc ml-4"
|
||||||
|
(li "Parses SX wire format from script tags")
|
||||||
|
(li "Renders component trees to DOM")
|
||||||
|
(li "Hydrates reactive islands (signals, effects)")
|
||||||
|
(li "Client-side routing with defpage")
|
||||||
|
(li "HTMX-like fetch/swap orchestration"))))
|
||||||
|
|
||||||
|
;; Rendering modes
|
||||||
|
(h3 :class "text-xl font-semibold text-stone-700 mb-4 mt-8" "Rendering Modes")
|
||||||
|
|
||||||
|
(div :class "overflow-x-auto mb-8"
|
||||||
|
(table :class "w-full text-sm"
|
||||||
|
(thead
|
||||||
|
(tr :class "border-b border-stone-200"
|
||||||
|
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Mode")
|
||||||
|
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Runs on")
|
||||||
|
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Components")
|
||||||
|
(th :class "px-3 py-2 text-left font-medium text-stone-600" "Output")))
|
||||||
|
(tbody
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "px-3 py-2 font-mono" "render-to-html")
|
||||||
|
(td :class "px-3 py-2" "Server (OCaml)")
|
||||||
|
(td :class "px-3 py-2" "Expanded recursively")
|
||||||
|
(td :class "px-3 py-2" "HTML string"))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "px-3 py-2 font-mono" "aser / aser-slot")
|
||||||
|
(td :class "px-3 py-2" "Server (OCaml)")
|
||||||
|
(td :class "px-3 py-2" "Server-affinity expanded; client preserved")
|
||||||
|
(td :class "px-3 py-2" "SX wire format"))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "px-3 py-2 font-mono" "render-to-dom")
|
||||||
|
(td :class "px-3 py-2" "Client (sx-browser.js)")
|
||||||
|
(td :class "px-3 py-2" "Expanded recursively")
|
||||||
|
(td :class "px-3 py-2" "DOM nodes"))
|
||||||
|
(tr :class "border-b border-stone-100"
|
||||||
|
(td :class "px-3 py-2 font-mono" "client routing")
|
||||||
|
(td :class "px-3 py-2" "Client (sx-browser.js)")
|
||||||
|
(td :class "px-3 py-2" "defpage content evaluated locally")
|
||||||
|
(td :class "px-3 py-2" "DOM swap")))))
|
||||||
|
|
||||||
|
;; Topics
|
||||||
|
(h3 :class "text-xl font-semibold text-stone-700 mb-4 mt-8" "Topics")
|
||||||
|
|
||||||
|
(div :class "grid md:grid-cols-2 gap-4"
|
||||||
|
(a :href "/sx/(geography.(hypermedia))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||||
|
(h4 :class "font-semibold text-stone-700" "Hypermedia Lakes")
|
||||||
|
(p :class "text-sm text-stone-500" "Server-driven UI with sx-get/post/put/delete — fetch, swap, and the request lifecycle."))
|
||||||
|
|
||||||
|
(a :href "/sx/(geography.(reactive))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||||
|
(h4 :class "font-semibold text-stone-700" "Reactive Islands")
|
||||||
|
(p :class "text-sm text-stone-500" "Client-side signals and effects hydrated from server-rendered HTML. defisland, deref, lakes."))
|
||||||
|
|
||||||
|
(a :href "/sx/(geography.(marshes))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||||
|
(h4 :class "font-semibold text-stone-700" "Marshes")
|
||||||
|
(p :class "text-sm text-stone-500" "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive views reshape server content."))
|
||||||
|
|
||||||
|
(a :href "/sx/(geography.(scopes))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||||
|
(h4 :class "font-semibold text-stone-700" "Scopes")
|
||||||
|
(p :class "text-sm text-stone-500" "Render-time dynamic scope — the primitive beneath provide, collect!, spreads, and islands."))
|
||||||
|
|
||||||
|
(a :href "/sx/(geography.(cek))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||||
|
(h4 :class "font-semibold text-stone-700" "CEK Machine")
|
||||||
|
(p :class "text-sm text-stone-500" "The evaluator internals — frames, continuations, tail-call optimization, and the VM bytecode compiler."))
|
||||||
|
|
||||||
|
(a :href "/sx/(geography.(isomorphism))" :class "block border border-stone-200 rounded-lg p-4 hover:border-sky-300 hover:bg-sky-50 transition-colors"
|
||||||
|
(h4 :class "font-semibold text-stone-700" "Isomorphism")
|
||||||
|
(p :class "text-sm text-stone-500" "One spec, multiple hosts — how the same SX code runs on OCaml, JavaScript, and Python.")))))
|
||||||
@@ -587,7 +587,8 @@
|
|||||||
:path "/geography/"
|
:path "/geography/"
|
||||||
:auth :public
|
:auth :public
|
||||||
:layout :sx-docs
|
:layout :sx-docs
|
||||||
:content (~layouts/doc :path "/sx/(geography)"))
|
:content (~layouts/doc :path "/sx/(geography)"
|
||||||
|
(~geography/index-content)))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
;; Reactive Islands section (under Geography)
|
;; Reactive Islands section (under Geography)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import pytest
|
|||||||
from playwright.sync_api import Page, expect
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
import os
|
import os
|
||||||
BASE = os.environ.get("SX_TEST_BASE", "https://sx.rose-ash.com")
|
BASE = os.environ.get("SX_TEST_BASE", "http://localhost:8013")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -28,10 +28,33 @@ def browser_context_args():
|
|||||||
|
|
||||||
|
|
||||||
def nav(page: Page, path: str):
|
def nav(page: Page, path: str):
|
||||||
"""Navigate to an SX URL and wait for rendered content."""
|
"""Navigate to an SX URL and wait for rendered content.
|
||||||
|
|
||||||
|
Captures JS errors during page load and fails immediately with the
|
||||||
|
actual error message instead of waiting for a 30s timeout.
|
||||||
|
"""
|
||||||
|
js_errors: list[str] = []
|
||||||
|
page.on("pageerror", lambda err: js_errors.append(str(err)))
|
||||||
|
|
||||||
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
|
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
|
||||||
|
|
||||||
|
# Poll briefly for JS errors — pageerror fires async during networkidle
|
||||||
|
for _ in range(10):
|
||||||
|
if js_errors:
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
|
||||||
|
# Fail fast on JS errors — don't wait for content that will never appear
|
||||||
|
if js_errors:
|
||||||
|
pytest.fail(f"JS error on {path}: {js_errors[0]}")
|
||||||
|
|
||||||
# Wait for SX to render — look for any heading or paragraph in main panel
|
# Wait for SX to render — look for any heading or paragraph in main panel
|
||||||
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=30000)
|
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=10000)
|
||||||
|
|
||||||
|
# Check for rendering artifacts that indicate broken serialization
|
||||||
|
main_text = page.locator("#main-panel").text_content() or ""
|
||||||
|
if "[object Object]" in main_text:
|
||||||
|
pytest.fail(f"Rendering artifact on {path}: [object Object] in page content")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -56,6 +79,20 @@ class TestFormSubmission:
|
|||||||
nav(page, "(geography.(hypermedia.(example.form-submission)))")
|
nav(page, "(geography.(hypermedia.(example.form-submission)))")
|
||||||
expect(page.locator("#main-panel")).to_contain_text("Form Submission")
|
expect(page.locator("#main-panel")).to_contain_text("Form Submission")
|
||||||
|
|
||||||
|
def test_has_code_examples(self, page: Page):
|
||||||
|
"""Page must show component source and handler source code."""
|
||||||
|
nav(page, "(geography.(hypermedia.(example.form-submission)))")
|
||||||
|
code_blocks = page.locator("pre code")
|
||||||
|
expect(code_blocks.first).not_to_be_empty(timeout=5000)
|
||||||
|
# Should have at least component code and handler code
|
||||||
|
count = code_blocks.count()
|
||||||
|
assert count >= 2, f"Expected at least 2 code blocks, got {count}"
|
||||||
|
# Handler code should contain defhandler
|
||||||
|
all_code = " ".join(code_blocks.nth(i).text_content() or ""
|
||||||
|
for i in range(count))
|
||||||
|
assert "defhandler" in all_code or "sx-post" in all_code, \
|
||||||
|
f"Code blocks should contain handler or component source"
|
||||||
|
|
||||||
def test_submit_form(self, page: Page):
|
def test_submit_form(self, page: Page):
|
||||||
nav(page, "(geography.(hypermedia.(example.form-submission)))")
|
nav(page, "(geography.(hypermedia.(example.form-submission)))")
|
||||||
page.fill("input[name='name']", "TestUser")
|
page.fill("input[name='name']", "TestUser")
|
||||||
@@ -78,6 +115,16 @@ class TestDeleteRow:
|
|||||||
nav(page, "(geography.(hypermedia.(example.delete-row)))")
|
nav(page, "(geography.(hypermedia.(example.delete-row)))")
|
||||||
expect(page.locator("#main-panel")).to_contain_text("Delete Row")
|
expect(page.locator("#main-panel")).to_contain_text("Delete Row")
|
||||||
|
|
||||||
|
def test_delete_removes_row(self, page: Page):
|
||||||
|
nav(page, "(geography.(hypermedia.(example.delete-row)))")
|
||||||
|
rows = page.locator("#delete-rows tr")
|
||||||
|
initial_count = rows.count()
|
||||||
|
assert initial_count >= 2, f"Expected at least 2 rows, got {initial_count}"
|
||||||
|
# Accept the confirm dialog
|
||||||
|
page.on("dialog", lambda d: d.accept())
|
||||||
|
rows.first.locator("button:has-text('delete')").click()
|
||||||
|
expect(rows).to_have_count(initial_count - 1, timeout=5000)
|
||||||
|
|
||||||
|
|
||||||
class TestInlineEdit:
|
class TestInlineEdit:
|
||||||
def test_page_loads(self, page: Page):
|
def test_page_loads(self, page: Page):
|
||||||
@@ -642,3 +689,46 @@ class TestClientNavigation:
|
|||||||
island.locator("button", has_text="+5").click()
|
island.locator("button", has_text="+5").click()
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
expect(island).to_contain_text("25")
|
expect(island).to_contain_text("25")
|
||||||
|
|
||||||
|
def test_full_refresh_preserves_content(self, page: Page):
|
||||||
|
"""Navigate to a page, then hard-refresh — content must re-render."""
|
||||||
|
errors = _setup_error_capture(page)
|
||||||
|
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
|
||||||
|
expect(page.locator("#main-panel")).to_contain_text("Click to Load")
|
||||||
|
# Hard refresh
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
expect(page.locator("#main-panel")).to_contain_text("Click to Load", timeout=10000)
|
||||||
|
_check_no_fatal_errors(errors)
|
||||||
|
|
||||||
|
def test_back_button_after_navigation(self, page: Page):
|
||||||
|
"""Navigate A → B, then back — A must re-render without errors."""
|
||||||
|
errors = _setup_error_capture(page)
|
||||||
|
nav(page, "(geography.(reactive.(examples.counter)))")
|
||||||
|
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
|
||||||
|
|
||||||
|
# Navigate to temperature
|
||||||
|
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(3000)
|
||||||
|
expect(page.locator("#main-panel")).to_contain_text("Temperature", timeout=10000)
|
||||||
|
|
||||||
|
# Go back
|
||||||
|
page.go_back()
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
|
||||||
|
_check_no_fatal_errors(errors)
|
||||||
|
|
||||||
|
def test_direct_load_no_js_errors(self, browser):
|
||||||
|
"""Fresh browser context — direct page load must have zero JS errors."""
|
||||||
|
ctx = browser.new_context(ignore_https_errors=True)
|
||||||
|
page = ctx.new_page()
|
||||||
|
try:
|
||||||
|
errors = _setup_error_capture(page)
|
||||||
|
page.goto(f"{BASE}/sx/(geography.(hypermedia.(example.delete-row)))", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
page.wait_for_selector("#main-panel", timeout=15000)
|
||||||
|
_check_no_fatal_errors(errors)
|
||||||
|
finally:
|
||||||
|
ctx.close()
|
||||||
|
|||||||
93
test-sx-build.sh
Executable file
93
test-sx-build.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# test-sx-build.sh — Build sx-browser.js and run all tests.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./test-sx-build.sh # build + unit tests + playwright smoke
|
||||||
|
# ./test-sx-build.sh --unit-only # build + unit tests only (no server needed)
|
||||||
|
# ./test-sx-build.sh --no-build # skip build, run tests only
|
||||||
|
#
|
||||||
|
# Requires:
|
||||||
|
# - Python 3 with hosts/javascript/cli.py deps
|
||||||
|
# - Node.js for unit tests
|
||||||
|
# - pytest-playwright for browser tests (pip install pytest-playwright)
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# SX_TEST_BASE — override test server URL (default: https://sx.rose-ash.com)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
UNIT_ONLY=false
|
||||||
|
NO_BUILD=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--unit-only) UNIT_ONLY=true ;;
|
||||||
|
--no-build) NO_BUILD=true ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# --- Step 1: Build ---
|
||||||
|
if [ "$NO_BUILD" = false ]; then
|
||||||
|
echo -e "${YELLOW}[1/3] Building sx-browser.js...${NC}"
|
||||||
|
python3 hosts/javascript/cli.py --output shared/static/scripts/sx-browser.js
|
||||||
|
echo -e "${GREEN}Build OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}[1/3] Skipping build (--no-build)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Step 2: Unit tests ---
|
||||||
|
echo -e "${YELLOW}[2/3] Running JS unit tests...${NC}"
|
||||||
|
UNIT_RESULT=$(node hosts/javascript/run_tests.js --full 2>&1)
|
||||||
|
UNIT_LAST=$(echo "$UNIT_RESULT" | tail -1)
|
||||||
|
if echo "$UNIT_LAST" | grep -q "0 failed"; then
|
||||||
|
echo -e "${GREEN}$UNIT_LAST${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}$UNIT_LAST${NC}"
|
||||||
|
echo "$UNIT_RESULT" | grep "FAIL:" | head -20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also run aser tests
|
||||||
|
echo -e "${YELLOW} Running aser tests...${NC}"
|
||||||
|
ASER_RESULT=$(node hosts/javascript/run_tests.js test-aser 2>&1)
|
||||||
|
ASER_LAST=$(echo "$ASER_RESULT" | tail -1)
|
||||||
|
ASER_PASS=$(echo "$ASER_LAST" | grep -oP '\d+ passed' || echo "0 passed")
|
||||||
|
ASER_FAIL=$(echo "$ASER_LAST" | grep -oP '\d+ failed' || echo "0 failed")
|
||||||
|
echo -e "${GREEN} Aser: $ASER_PASS, $ASER_FAIL${NC}"
|
||||||
|
|
||||||
|
# --- Step 3: Playwright smoke tests ---
|
||||||
|
if [ "$UNIT_ONLY" = true ]; then
|
||||||
|
echo -e "${YELLOW}[3/3] Skipping Playwright tests (--unit-only)${NC}"
|
||||||
|
echo -e "${GREEN}All done.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}[3/3] Running Playwright smoke tests...${NC}"
|
||||||
|
|
||||||
|
BASE="${SX_TEST_BASE:-http://localhost:8013}"
|
||||||
|
|
||||||
|
# Quick connectivity check
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/sx/" 2>/dev/null || echo "000")
|
||||||
|
if [ "$HTTP_CODE" = "000" ]; then
|
||||||
|
echo -e "${RED}Server unreachable at $BASE — skipping Playwright tests${NC}"
|
||||||
|
echo -e "${YELLOW}Run with SX_TEST_BASE=http://localhost:PORT to test locally${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -m pytest sx/tests/test_demos.py -v -x -k "test_page_loads" --tb=short 2>&1 | tail -20
|
||||||
|
PW_EXIT=${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
if [ "$PW_EXIT" -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}Playwright smoke tests passed${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Playwright smoke tests failed (exit $PW_EXIT)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}All tests passed.${NC}"
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
(define render-to-sx :effects [render]
|
(define render-to-sx :effects [render]
|
||||||
(fn (expr (env :as dict))
|
(fn (expr (env :as dict))
|
||||||
(let ((result (aser expr env)))
|
(let ((result (aser expr env)))
|
||||||
;; aser-call already returns serialized SX strings;
|
;; aser-call returns SxExpr which serialize passes through unquoted.
|
||||||
;; only serialize non-string values
|
;; Plain strings from data need serialization (quoting).
|
||||||
(if (= (type-of result) "string")
|
(cond
|
||||||
result
|
(= (type-of result) "sx-expr") (sx-expr-source result)
|
||||||
(serialize result)))))
|
(= (type-of result) "string") result
|
||||||
|
:else (serialize result)))))
|
||||||
|
|
||||||
(define aser :effects [render]
|
(define aser :effects [render]
|
||||||
(fn ((expr :as any) (env :as dict))
|
(fn ((expr :as any) (env :as dict))
|
||||||
@@ -143,21 +144,15 @@
|
|||||||
(let ((result (aser c env)))
|
(let ((result (aser c env)))
|
||||||
(cond
|
(cond
|
||||||
(nil? result) nil
|
(nil? result) nil
|
||||||
;; Serialized SX from aser (tags, components, fragments)
|
(= (type-of result) "sx-expr")
|
||||||
;; starts with "(" — use directly without re-quoting
|
(append! parts (sx-expr-source result))
|
||||||
(and (= (type-of result) "string")
|
|
||||||
(> (string-length result) 0)
|
|
||||||
(starts-with? result "("))
|
|
||||||
(append! parts result)
|
|
||||||
;; list results (from map etc.)
|
;; list results (from map etc.)
|
||||||
(= (type-of result) "list")
|
(= (type-of result) "list")
|
||||||
(for-each
|
(for-each
|
||||||
(fn (item)
|
(fn (item)
|
||||||
(when (not (nil? item))
|
(when (not (nil? item))
|
||||||
(if (and (= (type-of item) "string")
|
(if (= (type-of item) "sx-expr")
|
||||||
(> (string-length item) 0)
|
(append! parts (sx-expr-source item))
|
||||||
(starts-with? item "("))
|
|
||||||
(append! parts item)
|
|
||||||
(append! parts (serialize item)))))
|
(append! parts (serialize item)))))
|
||||||
result)
|
result)
|
||||||
;; Everything else — serialize normally (quotes strings)
|
;; Everything else — serialize normally (quotes strings)
|
||||||
@@ -167,8 +162,8 @@
|
|||||||
(if (empty? parts)
|
(if (empty? parts)
|
||||||
""
|
""
|
||||||
(if (= (len parts) 1)
|
(if (= (len parts) 1)
|
||||||
(first parts)
|
(make-sx-expr (first parts))
|
||||||
(str "(<> " (join " " parts) ")"))))))
|
(make-sx-expr (str "(<> " (join " " parts) ")")))))))
|
||||||
|
|
||||||
|
|
||||||
(define aser-call :effects [render]
|
(define aser-call :effects [render]
|
||||||
@@ -193,33 +188,23 @@
|
|||||||
(let ((val (aser (nth args (inc i)) env)))
|
(let ((val (aser (nth args (inc i)) env)))
|
||||||
(when (not (nil? val))
|
(when (not (nil? val))
|
||||||
(append! attr-parts (str ":" (keyword-name arg)))
|
(append! attr-parts (str ":" (keyword-name arg)))
|
||||||
;; If the aser result is already serialized SX (starts
|
(if (= (type-of val) "sx-expr")
|
||||||
;; with "("), inline it directly — don't re-serialize
|
(append! attr-parts (sx-expr-source val))
|
||||||
;; which would quote it as a string literal.
|
|
||||||
(if (and (= (type-of val) "string")
|
|
||||||
(> (string-length val) 0)
|
|
||||||
(starts-with? val "("))
|
|
||||||
(append! attr-parts val)
|
|
||||||
(append! attr-parts (serialize val))))
|
(append! attr-parts (serialize val))))
|
||||||
(set! skip true)
|
(set! skip true)
|
||||||
(set! i (inc i)))
|
(set! i (inc i)))
|
||||||
(let ((val (aser arg env)))
|
(let ((val (aser arg env)))
|
||||||
(when (not (nil? val))
|
(when (not (nil? val))
|
||||||
(cond
|
(cond
|
||||||
;; Serialized SX (tags, components) — use directly
|
(= (type-of val) "sx-expr")
|
||||||
(and (= (type-of val) "string")
|
(append! child-parts (sx-expr-source val))
|
||||||
(> (string-length val) 0)
|
|
||||||
(starts-with? val "("))
|
|
||||||
(append! child-parts val)
|
|
||||||
;; List results (from map etc.)
|
;; List results (from map etc.)
|
||||||
(= (type-of val) "list")
|
(= (type-of val) "list")
|
||||||
(for-each
|
(for-each
|
||||||
(fn (item)
|
(fn (item)
|
||||||
(when (not (nil? item))
|
(when (not (nil? item))
|
||||||
(if (and (= (type-of item) "string")
|
(if (= (type-of item) "sx-expr")
|
||||||
(> (string-length item) 0)
|
(append! child-parts (sx-expr-source item))
|
||||||
(starts-with? item "("))
|
|
||||||
(append! child-parts item)
|
|
||||||
(append! child-parts (serialize item)))))
|
(append! child-parts (serialize item)))))
|
||||||
val)
|
val)
|
||||||
;; Plain values — serialize normally
|
;; Plain values — serialize normally
|
||||||
@@ -239,7 +224,7 @@
|
|||||||
(scope-peek "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) ")")))))
|
(make-sx-expr (str "(" (join " " parts) ")"))))))
|
||||||
|
|
||||||
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
@@ -272,6 +257,7 @@
|
|||||||
(if (and (= (type-of arg) "keyword")
|
(if (and (= (type-of arg) "keyword")
|
||||||
(< (inc i) (len args)))
|
(< (inc i) (len args)))
|
||||||
;; Keyword arg: bind name = aser'd next arg
|
;; Keyword arg: bind name = aser'd next arg
|
||||||
|
;; SxExpr values pass through serialize unquoted automatically
|
||||||
(do
|
(do
|
||||||
(env-bind! local (keyword-name arg)
|
(env-bind! local (keyword-name arg)
|
||||||
(aser (nth args (inc i)) env))
|
(aser (nth args (inc i)) env))
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
(clear-loading-state el indicator disabled-elts)
|
(clear-loading-state el indicator disabled-elts)
|
||||||
(revert-optimistic optimistic-state)
|
(revert-optimistic optimistic-state)
|
||||||
(when (not (abort-error? err))
|
(when (not (abort-error? err))
|
||||||
|
(log-warn (str "sx:fetch error " method " " final-url " — " err))
|
||||||
(dom-dispatch el "sx:requestError"
|
(dom-dispatch el "sx:requestError"
|
||||||
(dict "error" err))))))))))))
|
(dict "error" err))))))))))))
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,16 @@
|
|||||||
(deftest "component with children serializes unexpanded"
|
(deftest "component with children serializes unexpanded"
|
||||||
(assert-equal "(~box (p \"inside\"))"
|
(assert-equal "(~box (p \"inside\"))"
|
||||||
(render-sx "(do (defcomp ~box (&key &rest children) (div children))
|
(render-sx "(do (defcomp ~box (&key &rest children) (div children))
|
||||||
(~box (p \"inside\")))"))))
|
(~box (p \"inside\")))")))
|
||||||
|
|
||||||
|
(deftest "string keyword arg starting with paren is quoted"
|
||||||
|
(assert-equal "(~info :text \"(hello world)\")"
|
||||||
|
(render-sx "(do (defcomp ~info (&key text) (code text))
|
||||||
|
(~info :text \"(hello world)\"))")))
|
||||||
|
|
||||||
|
(deftest "string child starting with paren is quoted"
|
||||||
|
(assert-equal "(p \"(not code)\")"
|
||||||
|
(render-sx "(let ((x \"(not code)\")) (p x))"))))
|
||||||
|
|
||||||
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user