Server cleanup: extract app-specific config into SX

Phase 1 Step 2 of architecture roadmap. The OCaml HTTP server is now
generic — all sx_docs-specific values (layout components, path prefix,
title, warmup paths, handler prefixes, CSS/JS, client libs) move into
sx/sx/app-config.sx as a __app-config dict. Server reads config at
startup with hardcoded defaults as fallback, so it works with no config,
partial config, or full config.

Removed: 9 demo data stubs, stepper cookie cache logic, page-functions.sx
directory heuristic. Added: 29-test server config test suite covering
standard, custom, no-config, and minimal-config scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 21:00:32 +00:00
parent 9dd90eba7f
commit 17b6c872f2
4 changed files with 364 additions and 61 deletions

View File

@@ -146,16 +146,35 @@ let io_counter = ref 0
let _request_cookies = Sx_scope.request_cookies
let _scope_stacks = Sx_scope.scope_stacks
(* ── App config ─────────────────────────────────────────────────────── *)
(* Populated from __app-config dict after SX files load. *)
let _app_config : (string, value) Hashtbl.t option ref = ref None
let _defpage_paths : string list ref = ref []
let get_app_config key default =
match !_app_config with
| Some d -> (match Hashtbl.find_opt d key with Some v -> v | None -> default)
| None -> default
let get_app_str key default =
match get_app_config key (String default) with String s -> s | _ -> default
let get_app_list key default =
match get_app_config key Nil with
| List l | ListRef { contents = l } ->
List.filter_map (function String s -> Some s | _ -> None) l
| _ -> default
(** Helpers safe to defer — pure functions whose results are only used
as rendering output (inlined into SX wire format), not in control flow. *)
let batchable_helpers = [
let batchable_helpers = ref [
"highlight"; "component-source"
]
let is_batchable name args =
name = "helper" &&
match args with
| String h :: _ -> List.mem h batchable_helpers
| String h :: _ -> List.mem h !batchable_helpers
| _ -> false
(** Read an io-response from stdin, discarding stale messages from old epochs. *)
@@ -1615,7 +1634,8 @@ let http_render_page env path headers =
let page_ast = match Hashtbl.find_opt d "page-ast" with Some v -> v | _ -> Nil in
let page_ast = if page_ast = Nil then error_page_ast "Page returned empty content" else page_ast in
begin
let wrapped = List [Symbol "~layouts/doc"; Keyword "path"; String nav_path; page_ast] in
let inner_layout = get_app_str "inner-layout" "~layouts/doc" in
let wrapped = List [Symbol inner_layout; Keyword "path"; String nav_path; page_ast] in
if is_ajax then begin
(* AJAX: return SX wire format (aser output) with text/sx content type *)
let body_result =
@@ -1628,7 +1648,8 @@ let http_render_page env path headers =
Some body_str
end else begin
(* Full page: aser → SSR → shell *)
let full_ast = List [Symbol "~shared:layout/app-body"; Keyword "content"; wrapped] in
let outer_layout = get_app_str "outer-layout" "~shared:layout/app-body" in
let full_ast = List [Symbol outer_layout; Keyword "content"; wrapped] in
let page_source = serialize_value full_ast in
let t1 = Unix.gettimeofday () in
let body_result =
@@ -1650,7 +1671,7 @@ let http_render_page env path headers =
let t3 = Unix.gettimeofday () in
let get_shell name = try env_get env ("__shell-" ^ name) with _ -> Nil in
let shell_args = [
Keyword "title"; String "SX"; Keyword "csrf"; String "";
Keyword "title"; String (get_app_str "title" "SX"); Keyword "csrf"; String "";
Keyword "page-sx"; String page_source;
Keyword "body-html"; String body_html;
Keyword "component-defs"; get_shell "component-defs";
@@ -1666,7 +1687,8 @@ let http_render_page env path headers =
Keyword "init-sx"; get_shell "init-sx";
Keyword "meta-html"; String "";
] in
let shell_call = List (Symbol "~shared:shell/sx-page-shell" :: shell_args) in
let shell_sym = get_app_str "shell" "~shared:shell/sx-page-shell" in
let shell_call = List (Symbol shell_sym :: shell_args) in
let html =
if env_has env "render-to-html" then
let render_call = List [Symbol "render-to-html";
@@ -1769,11 +1791,8 @@ let http_inject_shell_statics env static_dir sx_sxc =
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
Filename.dirname (Filename.dirname static_dir) in
let templates_dir = project_dir ^ "/shared/sx/templates" in
let client_libs = [
templates_dir ^ "/tw-layout.sx";
templates_dir ^ "/tw-type.sx";
templates_dir ^ "/tw.sx";
] in
let client_lib_names = get_app_list "client-libs" ["tw-layout.sx"; "tw-type.sx"; "tw.sx"] in
let client_libs = List.map (fun name -> templates_dir ^ "/" ^ name) client_lib_names in
List.iter (fun path ->
if Sys.file_exists path then begin
let src = In_channel.with_open_text path In_channel.input_all in
@@ -1809,9 +1828,9 @@ let http_inject_shell_statics env static_dir sx_sxc =
let platform_hash = file_hash (static_dir ^ "/wasm/sx-platform-2.js") in
let sxbc_hash = sxbc_combined_hash (static_dir ^ "/wasm") in
(* Read CSS for inline injection *)
let tw_css = read_css_file (static_dir ^ "/styles/tw.css") in
let basics_css = read_css_file (static_dir ^ "/styles/basics.css") in
let sx_css = basics_css ^ "\n" ^ tw_css in
let css_file_names = get_app_list "css-files" ["basics.css"; "tw.css"] in
let sx_css = String.concat "\n" (List.map (fun name ->
read_css_file (static_dir ^ "/styles/" ^ name)) css_file_names) in
ignore (env_bind env "__shell-component-defs" (String component_defs));
ignore (env_bind env "__shell-component-hash" (String component_hash));
(* Build minimal pages-sx from defpage definitions in loaded .sx files.
@@ -1840,11 +1859,13 @@ let http_inject_shell_statics env static_dir sx_sxc =
| Some v -> serialize_value v | _ -> "" in
let has_data = match extract_kw "data" rest with
| Some _ -> true | None -> false in
if path_val <> "" then
if path_val <> "" then begin
_defpage_paths := path_val :: !_defpage_paths;
Buffer.add_string pages_buf
(Printf.sprintf "{:name \"%s\" :path \"%s\" :auth \"public\" :has-data %s :content \"%s\"}\n"
name path_val (if has_data then "true" else "false")
(escape_sx_string content_val))
end
| _ -> ()
) exprs
with _ -> ()
@@ -1864,16 +1885,17 @@ let http_inject_shell_statics env static_dir sx_sxc =
ignore (env_bind env "__shell-sxbc-hash" (String sxbc_hash));
ignore (env_bind env "__shell-inline-css" Nil);
ignore (env_bind env "__shell-inline-head-js" Nil);
(* init-sx: trigger client-side render when sx-root is empty (SSR failed).
The boot code hydrates existing islands but doesn't do fresh render.
This script forces a render from page-sx after boot completes. *)
ignore (env_bind env "__shell-init-sx" (String
(* init-sx: trigger client-side render when sx-root is empty (SSR failed). *)
let default_init_sx =
"document.addEventListener('sx:boot-done', function() { \
var root = document.getElementById('sx-root'); \
if (root && !root.innerHTML.trim() && typeof SX !== 'undefined' && SX.renderPage) { \
SX.renderPage(); \
} \
});"));
});" in
let init_sx = match get_app_config "init-script" (Keyword "default") with
| String s -> s | _ -> default_init_sx in
ignore (env_bind env "__shell-init-sx" (String init_sx));
Printf.eprintf "[sx-http] Shell statics: defs=%d hash=%s css=%d wasm=%s platform=%s sxbc=%s\n%!"
(String.length component_defs) component_hash (String.length sx_css) wasm_hash platform_hash sxbc_hash
@@ -2292,17 +2314,7 @@ let http_setup_page_helpers env =
with _ -> String (";; component " ^ name ^ " not found"))
| _ -> raise (Eval_error "component-source: expected (name)"));
(* Stub remaining demo/action helpers that need real IO *)
let stub name = bind name (fun _args -> Nil) in
stub "run-spec-tests";
stub "run-modular-tests";
stub "streaming-demo-data";
stub "affinity-demo-data";
stub "optimistic-demo-data";
stub "action:add-demo-item";
stub "offline-demo-data";
stub "prove-data";
stub "page-helpers-demo-data"
ignore bind (* suppress unused warning *)
let http_mode port =
let env = make_server_env () in
@@ -2325,11 +2337,16 @@ let http_mode port =
let shared_sx = try Sys.getenv "SX_SHARED_DIR" with Not_found ->
project_dir ^ "/shared/sx/templates" in
let sx_sx = try Sys.getenv "SX_COMPONENTS_DIR" with Not_found ->
(* Docker: /app/sx, dev: /project/sx/sx *)
let docker_path = project_dir ^ "/sx" in
let dev_path = project_dir ^ "/sx/sx" in
if Sys.file_exists (docker_path ^ "/page-functions.sx") then docker_path
else dev_path in
if Sys.file_exists docker_path && Sys.is_directory docker_path
&& not (Sys.file_exists (docker_path ^ "/sx")) (* avoid matching parent of sx/sx *)
then docker_path
else if Sys.file_exists dev_path && Sys.is_directory dev_path then dev_path
else begin
Printf.eprintf "[sx-http] WARNING: no components dir at %s or %s\n%!" docker_path dev_path;
docker_path
end in
(* Expose project paths to SX helpers *)
ignore (env_bind env "_project-dir" (String project_dir));
ignore (env_bind env "_spec-dir" (String spec_base));
@@ -2387,6 +2404,15 @@ let http_mode port =
load_dir sx_sx;
let t1 = Unix.gettimeofday () in
Printf.eprintf "[sx-http] All files loaded in %.3fs\n%!" (t1 -. t0);
(* Extract app config from __app-config dict *)
(try match env_get env "__app-config" with
| Dict d ->
_app_config := Some d;
batchable_helpers := get_app_list "batchable-helpers" ["highlight"; "component-source"];
Printf.eprintf "[sx-http] App config loaded: title=%s prefix=%s\n%!"
(get_app_str "title" "?") (get_app_str "path-prefix" "?")
| _ -> Printf.eprintf "[sx-http] WARNING: __app-config is not a dict\n%!"
with _ -> Printf.eprintf "[sx-http] No __app-config found, using defaults\n%!");
(* SSR overrides — rebind browser-only functions AFTER .sx files load.
effect and register-in-scope are no-ops on the server; the SX definitions
from signals.sx are replaced so effect bodies never execute during SSR. *)
@@ -2497,14 +2523,15 @@ let http_mode port =
Printf.eprintf "[cache] %s → not found\n%!" path
in
(* Pre-warm + cache all key pages *)
let _warmup_paths = ["/sx/"; "/sx/(geography)"; "/sx/(language)"; "/sx/(applications)";
"/sx/(geography.(reactive.(examples)))";
"/sx/(applications.(sxtp))"; "/sx/(geography.(cek))";
"/sx/(geography.(reactive))"; "/sx/(geography.(hypermedia))";
] in
(* Pre-warm + cache key pages — from config or just homepage *)
let warmup_paths = match get_app_config "warmup-paths" (Keyword "auto") with
| Keyword "auto" -> [get_app_str "path-prefix" "/sx/"]
| List l | ListRef { contents = l } ->
List.filter_map (function String s -> Some s | _ -> None) l
| _ -> [get_app_str "path-prefix" "/sx/"]
in
let t_warm = Unix.gettimeofday () in
List.iter cache_response _warmup_paths;
List.iter cache_response warmup_paths;
let n_cached = Hashtbl.length response_cache in
Printf.eprintf "[sx-http] Pre-warmed %d pages in %.3fs\n%!"
n_cached (Unix.gettimeofday () -. t_warm);
@@ -2619,13 +2646,21 @@ let http_mode port =
| None -> ())
) (String.split_on_char ';' cookie_str)
| None -> ());
let app_prefix = get_app_str "path-prefix" "/sx/" in
let app_prefix_bare = if String.length app_prefix > 1
&& app_prefix.[String.length app_prefix - 1] = '/'
then String.sub app_prefix 0 (String.length app_prefix - 1) else app_prefix in
let app_home = get_app_str "home-path" app_prefix in
let debug_prefix = app_prefix ^ "_debug/" in
let debug_prefix_len = String.length debug_prefix in
if path = "/" then begin
write_response fd (http_redirect "/sx/"); true
write_response fd (http_redirect app_home); true
end else
(* Debug endpoint — runs on main thread, no render worker *)
let raw_decoded = url_decode raw_path in
if String.length path > 11 && String.sub path 0 11 = "/sx/_debug/" then begin
let cmd = String.sub raw_decoded 11 (String.length raw_decoded - 11) in
if String.length path > debug_prefix_len
&& String.sub path 0 debug_prefix_len = debug_prefix then begin
let cmd = String.sub raw_decoded debug_prefix_len (String.length raw_decoded - debug_prefix_len) in
let query_start = try String.index cmd '?' with Not_found -> String.length cmd in
let action = String.sub cmd 0 query_start in
let query = if query_start < String.length cmd - 1
@@ -2676,8 +2711,10 @@ let http_mode port =
else if String.sub s i (String.length sub) = sub then true
else has_sub s sub (i + 1) in
has_sub path "(api." 0 in
let is_sx = path = "/sx/" || path = "/sx"
|| (String.length path > 4 && String.sub path 0 4 = "/sx/") in
let app_prefix_len = String.length app_prefix in
let is_sx = path = app_prefix || path = app_prefix_bare
|| (String.length path > app_prefix_len
&& String.sub path 0 app_prefix_len = app_prefix) in
if is_sx && is_handler_path then begin
(* Handler dispatch — slug + path param extraction, method-based lookup, param binding *)
let response =
@@ -2706,8 +2743,9 @@ let http_mode port =
let req_method = String.uppercase_ascii !_req_method in
let try_key k = try let v = env_get env k in
if v <> Nil then Some (k, v) else None with _ -> None in
(* Try multiple handler name patterns: ex-slug, reactive-slug, slug *)
let prefixes = ["handler:ex-" ^ slug; "handler:reactive-" ^ slug; "handler:" ^ slug] in
let handler_prefix_list = get_app_list "handler-prefixes"
["handler:ex-"; "handler:reactive-"; "handler:"] in
let prefixes = List.map (fun p -> p ^ slug) handler_prefix_list in
let suffixes = match req_method with
| "POST" -> List.concat_map (fun base -> [base; base ^ "-save"; base ^ "-submit"]) prefixes
| "PUT" | "PATCH" -> List.concat_map (fun base -> [base; base ^ "-put"; base ^ "-save"]) prefixes
@@ -2763,10 +2801,7 @@ let http_mode port =
in
write_response fd response; true
end else if is_sx then begin
let has_stepper_cookie = Hashtbl.mem _request_cookies "sx-home-stepper" in
let cache_key = if is_ajax then "ajax:" ^ path
else if has_stepper_cookie then path ^ ":step=" ^ (try Hashtbl.find _request_cookies "sx-home-stepper" with Not_found -> "")
else path in
let cache_key = if is_ajax then "ajax:" ^ path else path in
match Hashtbl.find_opt response_cache cache_key with
| Some cached -> write_response fd cached; true
| None ->

1
sx/sx/app-config.sx Normal file
View File

@@ -0,0 +1 @@
(define __app-config {:handler-prefixes (list "handler:ex-" "handler:reactive-" "handler:") :shell "~shared:shell/sx-page-shell" :inner-layout "~layouts/doc" :outer-layout "~shared:layout/app-body" :css-files (list "basics.css" "tw.css") :warmup-paths (list "/sx/" "/sx/(geography)" "/sx/(language)" "/sx/(applications)" "/sx/(geography.(reactive.(examples)))" "/sx/(applications.(sxtp))" "/sx/(geography.(cek))" "/sx/(geography.(reactive))" "/sx/(geography.(hypermedia))") :batchable-helpers (list "highlight" "component-source") :title "SX" :init-script :default :home-path "/sx/" :path-prefix "/sx/" :client-libs (list "tw-layout.sx" "tw-type.sx" "tw.sx")})

255
tests/test_server_config.sh Executable file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Test suite for SX HTTP server app config handling.
# Starts the server with different __app-config values and verifies behavior.
set -euo pipefail
cd "$(dirname "$0")/.."
PROJECT_DIR="$(pwd)"
SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
BASE_PORT=8090
PASS=0
FAIL=0
ERRORS=""
SERVER_PID=""
CURRENT_PORT=""
# ── Helpers ──────────────────────────────────────────────────────────
TMP_DIR=$(mktemp -d)
cleanup() {
if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
start_server() {
local port="$1"
local extra_env="${2:-}"
CURRENT_PORT="$port"
# Kill anything on this port
kill $(lsof -ti :"$port") 2>/dev/null || true
sleep 1
# Start server
eval "$extra_env SX_PROJECT_DIR=$PROJECT_DIR $SERVER --http $port" \
> "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" &
SERVER_PID=$!
# Wait for "Listening" message
local tries=0
while ! grep -q "Listening" "$TMP_DIR/stderr" 2>/dev/null; do
sleep 1
tries=$((tries + 1))
if [ $tries -gt 120 ]; then
echo "TIMEOUT waiting for server on port $port"
cat "$TMP_DIR/stderr"
return 1
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "Server died during startup on port $port"
cat "$TMP_DIR/stderr"
return 1
fi
done
echo " (server ready on port $port)"
}
stop_server() {
if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null
wait "$SERVER_PID" 2>/dev/null || true
sleep 1
fi
SERVER_PID=""
}
url() { echo "http://localhost:$CURRENT_PORT$1"; }
assert_status() {
local desc="$1" path="$2" expected="$3"
shift 3
local actual
actual=$(curl -s -o /dev/null -w "%{http_code}" "$@" "$(url "$path")" 2>/dev/null)
if [ "$actual" = "$expected" ]; then
PASS=$((PASS + 1))
echo " PASS: $desc"
else
FAIL=$((FAIL + 1))
ERRORS="$ERRORS\n FAIL: $desc — expected $expected, got $actual"
echo " FAIL: $desc — expected $expected, got $actual"
fi
}
assert_redirect() {
local desc="$1" path="$2" expected_path="$3"
local actual
actual=$(curl -s -o /dev/null -w "%{redirect_url}" "$(url "$path")" 2>/dev/null)
local expected="http://localhost:$CURRENT_PORT$expected_path"
if [ "$actual" = "$expected" ]; then
PASS=$((PASS + 1))
echo " PASS: $desc"
else
FAIL=$((FAIL + 1))
ERRORS="$ERRORS\n FAIL: $desc — expected → $expected, got → $actual"
echo " FAIL: $desc — expected → $expected_path, got → $actual"
fi
}
assert_body_contains() {
local desc="$1" path="$2" expected="$3"
shift 3
curl -s "$@" "$(url "$path")" > "$TMP_DIR/body" 2>/dev/null
if grep -qF "$expected" "$TMP_DIR/body"; then
PASS=$((PASS + 1))
echo " PASS: $desc"
else
FAIL=$((FAIL + 1))
local size=$(wc -c < "$TMP_DIR/body")
ERRORS="$ERRORS\n FAIL: $desc — body ($size bytes) missing '$expected'"
echo " FAIL: $desc — body ($size bytes) missing '$expected'"
fi
}
assert_stderr_contains() {
local desc="$1" expected="$2"
if grep -qF "$expected" "$TMP_DIR/stderr" 2>/dev/null; then
PASS=$((PASS + 1))
echo " PASS: $desc"
else
FAIL=$((FAIL + 1))
ERRORS="$ERRORS\n FAIL: $desc — stderr missing '$expected'"
echo " FAIL: $desc — stderr missing '$expected'"
fi
}
assert_stderr_not_contains() {
local desc="$1" expected="$2"
if grep -qF "$expected" "$TMP_DIR/stderr" 2>/dev/null; then
FAIL=$((FAIL + 1))
ERRORS="$ERRORS\n FAIL: $desc — stderr should NOT contain '$expected'"
echo " FAIL: $desc — stderr should NOT contain '$expected'"
else
PASS=$((PASS + 1))
echo " PASS: $desc"
fi
}
# ── Setup ────────────────────────────────────────────────────────────
if [ ! -x "$SERVER" ]; then
echo "ERROR: Server binary not found at $SERVER"
echo "Run: cd hosts/ocaml && eval \$(opam env) && dune build"
exit 1
fi
echo "=== SX HTTP Server Config Tests ==="
echo ""
# ── Test Group 1: Default config (standard app-config.sx) ───────────
echo "── Group 1: Standard config ──"
start_server $((BASE_PORT + 1))
assert_stderr_contains "config loaded" "App config loaded: title=SX prefix=/sx/"
assert_stderr_contains "warmup ran 9 pages" "Pre-warmed 9 pages"
assert_stderr_not_contains "no stepper cookie refs" "sx-home-stepper"
assert_status "homepage 200" "/sx/" 200
assert_status "AJAX homepage 200" "/sx/" 200 -H "sx-request: true"
assert_status "geography 200" "/sx/(geography)" 200
assert_status "language 200" "/sx/(language)" 200
assert_status "applications 200" "/sx/(applications)" 200
assert_redirect "root → /sx/" "/" "/sx/"
assert_status "handler 200" "/sx/(api.spec-detail)?name=render-to-html" 200
assert_status "static 200" "/static/styles/tw.css" 200
assert_status "debug 200" "/sx/_debug/eval?expr=(%2B%201%202)" 200
assert_status "unknown 404" "/nope" 404
assert_body_contains "title is SX" "/sx/" "<title>SX</title>"
assert_body_contains "AJAX returns SX wire format" "/sx/" "sx-swap-oob" -H "sx-request: true"
assert_body_contains "debug eval result" "/sx/_debug/eval?expr=(%2B%201%202)" "3"
stop_server
echo ""
# ── Test Group 2: Custom title + minimal warmup ─────────────────────
echo "── Group 2: Custom title ──"
mkdir -p "$TMP_DIR/custom-sx"
cp -r "$PROJECT_DIR/sx/sx/"* "$TMP_DIR/custom-sx/"
cat > "$TMP_DIR/custom-sx/app-config.sx" << 'SXEOF'
(define __app-config
{:title "My Custom App"
:path-prefix "/sx/"
:home-path "/sx/"
:inner-layout "~layouts/doc"
:outer-layout "~shared:layout/app-body"
:shell "~shared:shell/sx-page-shell"
:client-libs (list "tw-layout.sx" "tw-type.sx" "tw.sx")
:css-files (list "basics.css" "tw.css")
:batchable-helpers (list "highlight" "component-source")
:handler-prefixes (list "handler:ex-" "handler:reactive-" "handler:")
:warmup-paths (list "/sx/")
:init-script :default})
SXEOF
start_server $((BASE_PORT + 2)) "SX_COMPONENTS_DIR=$TMP_DIR/custom-sx"
assert_stderr_contains "custom title in log" "App config loaded: title=My Custom App"
assert_stderr_contains "warmup 1 page" "Pre-warmed 1 pages"
assert_status "homepage works" "/sx/" 200
assert_body_contains "custom title in HTML" "/sx/" "<title>My Custom App</title>"
stop_server
echo ""
# ── Test Group 3: No config (missing __app-config) ──────────────────
echo "── Group 3: No app config (defaults) ──"
mkdir -p "$TMP_DIR/noconfig-sx"
cp -r "$PROJECT_DIR/sx/sx/"* "$TMP_DIR/noconfig-sx/"
rm -f "$TMP_DIR/noconfig-sx/app-config.sx"
start_server $((BASE_PORT + 3)) "SX_COMPONENTS_DIR=$TMP_DIR/noconfig-sx"
assert_stderr_contains "defaults fallback" "No __app-config found, using defaults"
assert_status "homepage without config" "/sx/" 200
assert_redirect "root redirect without config" "/" "/sx/"
assert_body_contains "default title" "/sx/" "<title>SX</title>"
stop_server
echo ""
# ── Test Group 4: Minimal config (only title) ───────────────────────
echo "── Group 4: Minimal config ──"
mkdir -p "$TMP_DIR/minimal-sx"
cp -r "$PROJECT_DIR/sx/sx/"* "$TMP_DIR/minimal-sx/"
cat > "$TMP_DIR/minimal-sx/app-config.sx" << 'SXEOF'
(define __app-config {:title "Bare"})
SXEOF
start_server $((BASE_PORT + 4)) "SX_COMPONENTS_DIR=$TMP_DIR/minimal-sx"
assert_stderr_contains "minimal config log" "App config loaded: title=Bare"
assert_status "homepage with minimal config" "/sx/" 200
assert_body_contains "bare title" "/sx/" "<title>Bare</title>"
# Defaults should still work for everything not specified
assert_redirect "default redirect" "/" "/sx/"
assert_status "default debug" "/sx/_debug/eval?expr=1" 200
stop_server
echo ""
# ── Summary ──────────────────────────────────────────────────────────
echo "=== Results: $PASS passed, $FAIL failed ==="
if [ $FAIL -gt 0 ]; then
echo -e "\nFailures:$ERRORS"
exit 1
else
echo "All tests passed."
exit 0
fi

View File

@@ -2,14 +2,18 @@
sx-url-to-expr
(fn
(path)
(cond
(or (= path "/") (= path "/sx/") (= path "/sx"))
"home"
(starts-with? path "/sx/")
(join " " (split (slice path 4 (len path)) "."))
(starts-with? path "/")
(join " " (split (slice path 1 (len path)) "."))
:else path)))
(let
((prefix (cek-try (fn () (or (dict-get __app-config :path-prefix) "/sx/")) (fn (e) "/sx/")))
(prefix-len (len prefix))
(prefix-bare (slice prefix 0 (- prefix-len 1))))
(cond
(or (= path "/") (= path prefix) (= path prefix-bare))
"home"
(starts-with? path prefix)
(join " " (split (slice path prefix-len (len path)) "."))
(starts-with? path "/")
(join " " (split (slice path 1 (len path)) "."))
:else path))))
(define
sx-auto-quote
@@ -52,5 +56,13 @@
(nil? page-ast)
nil
(let
((nav-path (if (and (>= (len path) 4) (= (slice path 0 4) "/sx/")) path (str "/sx" path))))
((prefix (cek-try (fn () (or (dict-get __app-config :path-prefix) "/sx/")) (fn (e) "/sx/")))
(prefix-len (len prefix))
(nav-path
(if
(and
(>= (len path) prefix-len)
(= (slice path 0 prefix-len) prefix))
path
(str (slice prefix 0 (- prefix-len 1)) path))))
{:page-ast page-ast :nav-path nav-path :is-ajax is-ajax})))))