Phase 4: Client-side routing for SX expression URLs

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 10:27:28 +00:00
parent feecbb66ba
commit 2076e1805f
3 changed files with 86 additions and 20 deletions

View File

@@ -14,7 +14,7 @@
// ========================================================================= // =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-11T23:22:03Z"; var SX_VERSION = "2026-03-12T10:26:23Z";
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); }
@@ -3706,7 +3706,9 @@ callExpr.push(dictGet(kwargs, k)); } }
// find-matching-route // find-matching-route
var findMatchingRoute = function(path, routes) { return (function() { 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 result = NIL;
{ var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) { { var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) {
(function() { (function() {
@@ -3719,8 +3721,24 @@ callExpr.push(dictGet(kwargs, k)); } }
})(); })();
} } } } } }
return result; 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) === // === Transpiled from signals (reactive signal runtime) ===
@@ -4058,12 +4076,20 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function domCallMethod() { function domCallMethod() {
var obj = arguments[0], method = arguments[1]; var obj = arguments[0], method = arguments[1];
var args = Array.prototype.slice.call(arguments, 2); var args = Array.prototype.slice.call(arguments, 2);
console.log("[sx] dom-call-method:", obj, method, args);
if (obj && typeof obj[method] === 'function') { if (obj && typeof obj[method] === 'function') {
try { return obj[method].apply(obj, args); } try { return obj[method].apply(obj, args); }
catch(e) { console.error("[sx] dom-call-method error:", e); return NIL; } 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; return NIL;
} }
@@ -5272,6 +5298,7 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
PRIMITIVES["dom-get-prop"] = domGetProp; PRIMITIVES["dom-get-prop"] = domGetProp;
PRIMITIVES["dom-set-prop"] = domSetProp; PRIMITIVES["dom-set-prop"] = domSetProp;
PRIMITIVES["dom-call-method"] = domCallMethod; PRIMITIVES["dom-call-method"] = domCallMethod;
PRIMITIVES["dom-post-message"] = domPostMessage;
PRIMITIVES["stop-propagation"] = stopPropagation_; PRIMITIVES["stop-propagation"] = stopPropagation_;
PRIMITIVES["error-message"] = errorMessage; PRIMITIVES["error-message"] = errorMessage;
PRIMITIVES["schedule-idle"] = scheduleIdle; PRIMITIVES["schedule-idle"] = scheduleIdle;

View File

@@ -102,25 +102,64 @@
(define find-matching-route :effects [] (define find-matching-route :effects []
(fn ((path :as string) (routes :as list)) (fn ((path :as string) (routes :as list))
(let ((path-segs (split-path-segments path)) ;; If path is an SX expression URL, convert to old-style for matching.
(result nil)) (let ((match-path (if (starts-with? path "/(")
(for-each (or (sx-url-to-path path) path)
(fn ((route :as dict)) path)))
(when (nil? result) (let ((path-segs (split-path-segments match-path))
(let ((params (match-route-segments path-segs (get route "parsed")))) (result nil))
(when (not (nil? params)) (for-each
(let ((matched (merge route {}))) (fn ((route :as dict))
(dict-set! matched "params" params) (when (nil? result)
(set! result matched)))))) (let ((params (match-route-segments path-segs (get route "parsed"))))
routes) (when (not (nil? params))
result))) (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 ;; Platform interface — none required
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; All functions use only pure primitives: ;; All functions use only pure primitives:
;; split, slice, starts-with?, ends-with?, len, empty?, ;; split, slice, starts-with?, ends-with?, len, empty?, replace,
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge, ;; map, filter, for-each, for-each-indexed, nth, get, dict-set!, merge,
;; list, nil?, not, = ;; list, nil?, not, =, case, join, str
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------