From 2076e1805f70c761525b43adc0d0a14f65d10034 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 12 Mar 2026 10:27:28 +0000 Subject: [PATCH] Phase 4: Client-side routing for SX expression URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sx-url-to-path to router.sx that converts SX expression URLs to old-style slash paths for route matching. find-matching-route now transparently handles both formats — the browser URL stays as the SX expression while matching uses the equivalent old-style path. /(language.(doc.introduction)) → /language/docs/introduction for matching but pushState keeps the SX URL in the browser bar. - router.sx: add _fn-to-segment (doc→docs, etc.), sx-url-to-path - router.sx: modify find-matching-route to convert SX URLs before matching - Rebootstrap sx-browser.js and sx_ref.py Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 35 +++++++++++++-- shared/sx/ref/router.sx | 69 ++++++++++++++++++++++------- shared/sx/ref/sx_ref.py | 2 +- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 7b969d9..384a47f 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-11T23:22:03Z"; + var SX_VERSION = "2026-03-12T10:26:23Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -3706,7 +3706,9 @@ callExpr.push(dictGet(kwargs, k)); } } // find-matching-route var findMatchingRoute = function(path, routes) { return (function() { - var pathSegs = splitPathSegments(path); + var matchPath = (isSxTruthy(startsWith(path, "/(")) ? sxOr(sxUrlToPath(path), path) : path); + return (function() { + var pathSegs = splitPathSegments(matchPath); var result = NIL; { var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) { (function() { @@ -3719,8 +3721,24 @@ callExpr.push(dictGet(kwargs, k)); } } })(); } } } return result; +})(); })(); }; + // _fn-to-segment + var _fnToSegment = function(name) { return (function() { var _m = name; if (_m == "doc") return "docs"; if (_m == "spec") return "specs"; if (_m == "bootstrapper") return "bootstrappers"; if (_m == "test") return "testing"; if (_m == "example") return "examples"; if (_m == "protocol") return "protocols"; if (_m == "essay") return "essays"; if (_m == "plan") return "plans"; if (_m == "reference-detail") return "reference"; return name; })(); }; + + // sx-url-to-path + var sxUrlToPath = function(url) { return (isSxTruthy(!isSxTruthy((isSxTruthy(startsWith(url, "/(")) && endsWith(url, ")")))) ? NIL : (function() { + var inner = slice(url, 2, (len(url) - 1)); + return (function() { + var s = replace_(replace_(replace_(inner, ".", "/"), "(", ""), ")", ""); + return (function() { + var segs = filter(function(s) { return !isSxTruthy(isEmpty(s)); }, split(s, "/")); + return (String("/") + String(join("/", map(_fnToSegment, segs)))); +})(); +})(); +})()); }; + // === Transpiled from signals (reactive signal runtime) === @@ -4058,12 +4076,20 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function domCallMethod() { var obj = arguments[0], method = arguments[1]; var args = Array.prototype.slice.call(arguments, 2); - console.log("[sx] dom-call-method:", obj, method, args); if (obj && typeof obj[method] === 'function') { try { return obj[method].apply(obj, args); } catch(e) { console.error("[sx] dom-call-method error:", e); return NIL; } } - console.warn("[sx] dom-call-method: method not found or obj null", obj, method); + return NIL; + } + // Post a message to an iframe's contentWindow without exposing the cross-origin + // Window object to the SX evaluator (which would trigger _thunk access errors). + function domPostMessage(iframe, msg, origin) { + try { + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage(msg, origin || '*'); + } + } catch(e) { console.error("[sx] domPostMessage error:", e); } return NIL; } @@ -5272,6 +5298,7 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { PRIMITIVES["dom-get-prop"] = domGetProp; PRIMITIVES["dom-set-prop"] = domSetProp; PRIMITIVES["dom-call-method"] = domCallMethod; + PRIMITIVES["dom-post-message"] = domPostMessage; PRIMITIVES["stop-propagation"] = stopPropagation_; PRIMITIVES["error-message"] = errorMessage; PRIMITIVES["schedule-idle"] = scheduleIdle; diff --git a/shared/sx/ref/router.sx b/shared/sx/ref/router.sx index 9eb8f1f..754a915 100644 --- a/shared/sx/ref/router.sx +++ b/shared/sx/ref/router.sx @@ -102,25 +102,64 @@ (define find-matching-route :effects [] (fn ((path :as string) (routes :as list)) - (let ((path-segs (split-path-segments path)) - (result nil)) - (for-each - (fn ((route :as dict)) - (when (nil? result) - (let ((params (match-route-segments path-segs (get route "parsed")))) - (when (not (nil? params)) - (let ((matched (merge route {}))) - (dict-set! matched "params" params) - (set! result matched)))))) - routes) - result))) + ;; If path is an SX expression URL, convert to old-style for matching. + (let ((match-path (if (starts-with? path "/(") + (or (sx-url-to-path path) path) + path))) + (let ((path-segs (split-path-segments match-path)) + (result nil)) + (for-each + (fn ((route :as dict)) + (when (nil? result) + (let ((params (match-route-segments path-segs (get route "parsed")))) + (when (not (nil? params)) + (let ((matched (merge route {}))) + (dict-set! matched "params" params) + (set! result matched)))))) + routes) + result)))) + + +;; -------------------------------------------------------------------------- +;; 6. SX expression URL → old-style path conversion +;; -------------------------------------------------------------------------- +;; Converts /(language.(doc.introduction)) → /language/docs/introduction +;; so client-side routing can match SX URLs against Flask-style patterns. + +(define _fn-to-segment :effects [] + (fn ((name :as string)) + (case name + "doc" "docs" + "spec" "specs" + "bootstrapper" "bootstrappers" + "test" "testing" + "example" "examples" + "protocol" "protocols" + "essay" "essays" + "plan" "plans" + "reference-detail" "reference" + :else name))) + +(define sx-url-to-path :effects [] + (fn ((url :as string)) + ;; Convert an SX expression URL to an old-style slash path. + ;; "/(language.(doc.introduction))" → "/language/docs/introduction" + ;; Returns nil for non-SX URLs (those not starting with "/(" ). + (if (not (and (starts-with? url "/(") (ends-with? url ")"))) + nil + (let ((inner (slice url 2 (- (len url) 1)))) + ;; "language.(doc.introduction)" → dots to slashes, strip parens + (let ((s (replace (replace (replace inner "." "/") "(" "") ")" ""))) + ;; "language/doc/introduction" → split, map names, rejoin + (let ((segs (filter (fn (s) (not (empty? s))) (split s "/")))) + (str "/" (join "/" (map _fn-to-segment segs))))))))) ;; -------------------------------------------------------------------------- ;; Platform interface — none required ;; -------------------------------------------------------------------------- ;; All functions use only pure primitives: -;; split, slice, starts-with?, ends-with?, len, empty?, -;; map, for-each, for-each-indexed, nth, get, dict-set!, merge, -;; list, nil?, not, = +;; split, slice, starts-with?, ends-with?, len, empty?, replace, +;; map, filter, for-each, for-each-indexed, nth, get, dict-set!, merge, +;; list, nil?, not, =, case, join, str ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 387c6ca..c7ce15f 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -3973,4 +3973,4 @@ def render(expr, env=None): def make_env(**kwargs): """Create an environment with initial bindings.""" - return _Env(dict(kwargs)) \ No newline at end of file + return _Env(dict(kwargs))