Phase 3: Client-side routing with SX page registry + routing analyzer demo
Add client-side route matching so pure pages (no IO deps) can render instantly without a server roundtrip. Page metadata serialized as SX dict literals (not JSON) in <script type="text/sx-pages"> blocks. - New router.sx spec: route pattern parsing and matching (6 pure functions) - boot.sx: process page registry using SX parser at startup - orchestration.sx: intercept boost links for client routing with try-first/fallback — client attempts local eval, falls back to server - helpers.py: _build_pages_sx() serializes defpage metadata as SX - Routing analyzer demo page showing per-page client/server classification - 32 tests for Phase 2 IO detection (scan_io_refs, transitive_io_refs, compute_all_io_refs, component_pure?) + fallback/ref parity - 37 tests for Phase 3 router functions + page registry serialization - Fix bootstrap_py.py _emit_let cell variable initialization bug - Fix missing primitive aliases (split, length, merge) in bootstrap_py.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -209,13 +209,15 @@
|
||||
function error(msg) { throw new Error(msg); }
|
||||
function inspect(x) { return JSON.stringify(x); }
|
||||
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Primitives
|
||||
// =========================================================================
|
||||
|
||||
var PRIMITIVES = {};
|
||||
|
||||
// Arithmetic
|
||||
// core.arithmetic
|
||||
PRIMITIVES["+"] = function() { var s = 0; for (var i = 0; i < arguments.length; i++) s += arguments[i]; return s; };
|
||||
PRIMITIVES["-"] = function(a, b) { return arguments.length === 1 ? -a : a - b; };
|
||||
PRIMITIVES["*"] = function() { var s = 1; for (var i = 0; i < arguments.length; i++) s *= arguments[i]; return s; };
|
||||
@@ -226,49 +228,31 @@
|
||||
PRIMITIVES["abs"] = Math.abs;
|
||||
PRIMITIVES["floor"] = Math.floor;
|
||||
PRIMITIVES["ceil"] = Math.ceil;
|
||||
PRIMITIVES["round"] = Math.round;
|
||||
PRIMITIVES["round"] = function(x, n) {
|
||||
if (n === undefined || n === 0) return Math.round(x);
|
||||
var f = Math.pow(10, n); return Math.round(x * f) / f;
|
||||
};
|
||||
PRIMITIVES["min"] = Math.min;
|
||||
PRIMITIVES["max"] = Math.max;
|
||||
PRIMITIVES["sqrt"] = Math.sqrt;
|
||||
PRIMITIVES["pow"] = Math.pow;
|
||||
PRIMITIVES["clamp"] = function(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); };
|
||||
|
||||
// Comparison
|
||||
PRIMITIVES["="] = function(a, b) { return a == b; };
|
||||
PRIMITIVES["!="] = function(a, b) { return a != b; };
|
||||
|
||||
// core.comparison
|
||||
PRIMITIVES["="] = function(a, b) { return a === b; };
|
||||
PRIMITIVES["!="] = function(a, b) { return a !== b; };
|
||||
PRIMITIVES["<"] = function(a, b) { return a < b; };
|
||||
PRIMITIVES[">"] = function(a, b) { return a > b; };
|
||||
PRIMITIVES["<="] = function(a, b) { return a <= b; };
|
||||
PRIMITIVES[">="] = function(a, b) { return a >= b; };
|
||||
|
||||
// Logic
|
||||
|
||||
// core.logic
|
||||
PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); };
|
||||
|
||||
// String
|
||||
PRIMITIVES["str"] = function() {
|
||||
var p = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var v = arguments[i]; if (isNil(v)) continue; p.push(String(v));
|
||||
}
|
||||
return p.join("");
|
||||
};
|
||||
PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); };
|
||||
PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); };
|
||||
PRIMITIVES["trim"] = function(s) { return String(s).trim(); };
|
||||
PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); };
|
||||
PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); };
|
||||
PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); };
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
PRIMITIVES["concat"] = function() {
|
||||
var out = [];
|
||||
for (var i = 0; i < arguments.length; i++) if (arguments[i]) out = out.concat(arguments[i]);
|
||||
return out;
|
||||
};
|
||||
PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); };
|
||||
|
||||
// Predicates
|
||||
// core.predicates
|
||||
PRIMITIVES["nil?"] = isNil;
|
||||
PRIMITIVES["number?"] = function(x) { return typeof x === "number"; };
|
||||
PRIMITIVES["string?"] = function(x) { return typeof x === "string"; };
|
||||
@@ -284,7 +268,33 @@
|
||||
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
|
||||
PRIMITIVES["zero?"] = function(n) { return n === 0; };
|
||||
|
||||
// Collections
|
||||
|
||||
// core.strings
|
||||
PRIMITIVES["str"] = function() {
|
||||
var p = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var v = arguments[i]; if (isNil(v)) continue; p.push(String(v));
|
||||
}
|
||||
return p.join("");
|
||||
};
|
||||
PRIMITIVES["upper"] = function(s) { return String(s).toUpperCase(); };
|
||||
PRIMITIVES["lower"] = function(s) { return String(s).toLowerCase(); };
|
||||
PRIMITIVES["trim"] = function(s) { return String(s).trim(); };
|
||||
PRIMITIVES["split"] = function(s, sep) { return String(s).split(sep || " "); };
|
||||
PRIMITIVES["join"] = function(sep, coll) { return coll.join(sep); };
|
||||
PRIMITIVES["replace"] = function(s, old, nw) { return s.split(old).join(nw); };
|
||||
PRIMITIVES["index-of"] = function(s, needle, from) { return String(s).indexOf(needle, from || 0); };
|
||||
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
|
||||
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
|
||||
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
|
||||
PRIMITIVES["concat"] = function() {
|
||||
var out = [];
|
||||
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
// core.collections
|
||||
PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); };
|
||||
PRIMITIVES["dict"] = function() {
|
||||
var d = {};
|
||||
@@ -304,6 +314,15 @@
|
||||
PRIMITIVES["nth"] = function(c, n) { return c && n >= 0 && n < c.length ? c[n] : NIL; };
|
||||
PRIMITIVES["cons"] = function(x, c) { return [x].concat(c || []); };
|
||||
PRIMITIVES["append"] = function(c, x) { return (c || []).concat([x]); };
|
||||
PRIMITIVES["chunk-every"] = function(c, n) {
|
||||
var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r;
|
||||
};
|
||||
PRIMITIVES["zip-pairs"] = function(c) {
|
||||
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
|
||||
};
|
||||
|
||||
|
||||
// core.dict
|
||||
PRIMITIVES["keys"] = function(d) { return Object.keys(d || {}); };
|
||||
PRIMITIVES["vals"] = function(d) { var r = []; for (var k in d) r.push(d[k]); return r; };
|
||||
PRIMITIVES["merge"] = function() {
|
||||
@@ -321,28 +340,16 @@
|
||||
for (var i = 1; i < arguments.length; i++) delete out[arguments[i]];
|
||||
return out;
|
||||
};
|
||||
PRIMITIVES["chunk-every"] = function(c, n) {
|
||||
var r = []; for (var i = 0; i < c.length; i += n) r.push(c.slice(i, i + n)); return r;
|
||||
};
|
||||
PRIMITIVES["zip-pairs"] = function(c) {
|
||||
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
|
||||
};
|
||||
PRIMITIVES["into"] = function(target, coll) {
|
||||
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
|
||||
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
|
||||
return r;
|
||||
};
|
||||
|
||||
// Format
|
||||
|
||||
// stdlib.format
|
||||
PRIMITIVES["format-decimal"] = function(v, p) { return Number(v).toFixed(p || 2); };
|
||||
PRIMITIVES["parse-int"] = function(v, d) { var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; };
|
||||
PRIMITIVES["pluralize"] = function(n, s, p) {
|
||||
if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s");
|
||||
return n == 1 ? "" : "s";
|
||||
};
|
||||
PRIMITIVES["escape"] = function(s) {
|
||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
};
|
||||
PRIMITIVES["format-date"] = function(s, fmt) {
|
||||
if (!s) return "";
|
||||
try {
|
||||
@@ -357,12 +364,21 @@
|
||||
} catch (e) { return String(s); }
|
||||
};
|
||||
PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; };
|
||||
PRIMITIVES["split-ids"] = function(s) {
|
||||
if (!s) return [];
|
||||
return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; });
|
||||
|
||||
|
||||
// stdlib.text
|
||||
PRIMITIVES["pluralize"] = function(n, s, p) {
|
||||
if (s || (p && p !== "s")) return n == 1 ? (s || "") : (p || "s");
|
||||
return n == 1 ? "" : "s";
|
||||
};
|
||||
PRIMITIVES["escape"] = function(s) {
|
||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||
};
|
||||
PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); };
|
||||
|
||||
|
||||
// stdlib.style
|
||||
PRIMITIVES["css"] = function() {
|
||||
// Stub — CSSX requires style dictionary which is browser-only
|
||||
var atoms = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var a = arguments[i];
|
||||
@@ -383,6 +399,14 @@
|
||||
return new StyleValue("sx-merged", allDecls, [], [], []);
|
||||
};
|
||||
|
||||
|
||||
// stdlib.debug
|
||||
PRIMITIVES["assert"] = function(cond, msg) {
|
||||
if (!isSxTruthy(cond)) throw new Error("Assertion error: " + (msg || "Assertion failed"));
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
function isPrimitive(name) { return name in PRIMITIVES; }
|
||||
function getPrimitive(name) { return PRIMITIVES[name]; }
|
||||
|
||||
@@ -508,6 +532,92 @@
|
||||
return NIL;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Platform: deps module — component dependency analysis
|
||||
// =========================================================================
|
||||
|
||||
function componentDeps(c) {
|
||||
return c.deps ? c.deps.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetDeps(c, deps) {
|
||||
c.deps = deps;
|
||||
}
|
||||
|
||||
function componentCssClasses(c) {
|
||||
return c.cssClasses ? c.cssClasses.slice() : [];
|
||||
}
|
||||
|
||||
function envComponents(env) {
|
||||
var names = [];
|
||||
for (var k in env) {
|
||||
var v = env[k];
|
||||
if (v && (v._component || v._macro)) names.push(k);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function regexFindAll(pattern, source) {
|
||||
var re = new RegExp(pattern, "g");
|
||||
var results = [];
|
||||
var m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
if (m[1] !== undefined) results.push(m[1]);
|
||||
else results.push(m[0]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function scanCssClasses(source) {
|
||||
var classes = {};
|
||||
var result = [];
|
||||
var m;
|
||||
var re1 = /:class\s+"([^"]*)"/g;
|
||||
while ((m = re1.exec(source)) !== null) {
|
||||
var parts = m[1].split(/\s+/);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (parts[i] && !classes[parts[i]]) {
|
||||
classes[parts[i]] = true;
|
||||
result.push(parts[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
|
||||
while ((m = re2.exec(source)) !== null) {
|
||||
var re3 = /"([^"]*)"/g;
|
||||
var m2;
|
||||
while ((m2 = re3.exec(m[1])) !== null) {
|
||||
var parts2 = m2[1].split(/\s+/);
|
||||
for (var j = 0; j < parts2.length; j++) {
|
||||
if (parts2[j] && !classes[parts2[j]]) {
|
||||
classes[parts2[j]] = true;
|
||||
result.push(parts2[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var re4 = /;;\s*@css\s+(.+)/g;
|
||||
while ((m = re4.exec(source)) !== null) {
|
||||
var parts3 = m[1].split(/\s+/);
|
||||
for (var k = 0; k < parts3.length; k++) {
|
||||
if (parts3[k] && !classes[parts3[k]]) {
|
||||
classes[parts3[k]] = true;
|
||||
result.push(parts3[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function componentIoRefs(c) {
|
||||
return c.ioRefs ? c.ioRefs.slice() : [];
|
||||
}
|
||||
|
||||
function componentSetIoRefs(c, refs) {
|
||||
c.ioRefs = refs;
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Platform interface — Parser
|
||||
// =========================================================================
|
||||
@@ -547,10 +657,10 @@
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
|
||||
return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "letrec")) ? sfLetrec(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefhandler(args, env) : (isSxTruthy((name == "defpage")) ? sfDefpage(args, env) : (isSxTruthy((name == "defquery")) ? sfDefquery(args, env) : (isSxTruthy((name == "defaction")) ? sfDefaction(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "reset")) ? sfReset(args, env) : (isSxTruthy((name == "shift")) ? sfShift(args, env) : (isSxTruthy((name == "dynamic-wind")) ? sfDynamicWind(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() {
|
||||
var mac = envGet(env, name);
|
||||
return makeThunk(expandMacro(mac, args, env), env);
|
||||
})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))));
|
||||
})() : (isSxTruthy(isRenderExpr(expr)) ? renderExpr(expr, env) : evalCall(head, args, env)))))))))))))))))))))))))))))))))))))));
|
||||
})() : evalCall(head, args, env)));
|
||||
})(); };
|
||||
|
||||
@@ -651,7 +761,7 @@
|
||||
})()); };
|
||||
|
||||
// sf-let
|
||||
var sfLet = function(args, env) { return (function() {
|
||||
var sfLet = function(args, env) { return (isSxTruthy((typeOf(first(args)) == "symbol")) ? sfNamedLet(args, env) : (function() {
|
||||
var bindings = first(args);
|
||||
var body = rest(args);
|
||||
var local = envExtend(env);
|
||||
@@ -668,6 +778,27 @@
|
||||
})());
|
||||
{ var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } }
|
||||
return makeThunk(last(body), local);
|
||||
})()); };
|
||||
|
||||
// sf-named-let
|
||||
var sfNamedLet = function(args, env) { return (function() {
|
||||
var loopName = symbolName(first(args));
|
||||
var bindings = nth(args, 1);
|
||||
var body = slice(args, 2);
|
||||
var params = [];
|
||||
var inits = [];
|
||||
(isSxTruthy((isSxTruthy((typeOf(first(bindings)) == "list")) && (len(first(bindings)) == 2))) ? forEach(function(binding) { params.push((isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : first(binding)));
|
||||
return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pairIdx) { return (append_b(params, (isSxTruthy((typeOf(nth(bindings, (pairIdx * 2))) == "symbol")) ? symbolName(nth(bindings, (pairIdx * 2))) : nth(bindings, (pairIdx * 2)))), append_b(inits, nth(bindings, ((pairIdx * 2) + 1)))); }, NIL, range(0, (len(bindings) / 2))));
|
||||
return (function() {
|
||||
var loopBody = (isSxTruthy((len(body) == 1)) ? first(body) : cons(makeSymbol("begin"), body));
|
||||
var loopFn = makeLambda(params, loopBody, env);
|
||||
loopFn.name = loopName;
|
||||
lambdaClosure(loopFn)[loopName] = loopFn;
|
||||
return (function() {
|
||||
var initVals = map(function(e) { return trampoline(evalExpr(e, env)); }, inits);
|
||||
return callLambda(loopFn, initVals, env);
|
||||
})();
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// sf-lambda
|
||||
@@ -797,6 +928,49 @@
|
||||
return value;
|
||||
})(); };
|
||||
|
||||
// sf-letrec
|
||||
var sfLetrec = function(args, env) { return (function() {
|
||||
var bindings = first(args);
|
||||
var body = rest(args);
|
||||
var local = envExtend(env);
|
||||
var names = [];
|
||||
var valExprs = [];
|
||||
(isSxTruthy((isSxTruthy((typeOf(first(bindings)) == "list")) && (len(first(bindings)) == 2))) ? forEach(function(binding) { return (function() {
|
||||
var vname = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : first(binding));
|
||||
names.push(vname);
|
||||
valExprs.push(nth(binding, 1));
|
||||
return envSet(local, vname, NIL);
|
||||
})(); }, bindings) : reduce(function(acc, pairIdx) { return (function() {
|
||||
var vname = (isSxTruthy((typeOf(nth(bindings, (pairIdx * 2))) == "symbol")) ? symbolName(nth(bindings, (pairIdx * 2))) : nth(bindings, (pairIdx * 2)));
|
||||
var valExpr = nth(bindings, ((pairIdx * 2) + 1));
|
||||
names.push(vname);
|
||||
valExprs.push(valExpr);
|
||||
return envSet(local, vname, NIL);
|
||||
})(); }, NIL, range(0, (len(bindings) / 2))));
|
||||
(function() {
|
||||
var values = map(function(e) { return trampoline(evalExpr(e, local)); }, valExprs);
|
||||
{ var _c = zip(names, values); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; local[first(pair)] = nth(pair, 1); } }
|
||||
return forEach(function(val) { return (isSxTruthy(isLambda(val)) ? forEach(function(n) { return envSet(lambdaClosure(val), n, envGet(local, n)); }, names) : NIL); }, values);
|
||||
})();
|
||||
{ var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } }
|
||||
return makeThunk(last(body), local);
|
||||
})(); };
|
||||
|
||||
// sf-dynamic-wind
|
||||
var sfDynamicWind = function(args, env) { return (function() {
|
||||
var before = trampoline(evalExpr(first(args), env));
|
||||
var body = trampoline(evalExpr(nth(args, 1), env));
|
||||
var after = trampoline(evalExpr(nth(args, 2), env));
|
||||
callThunk(before, env);
|
||||
pushWind(before, after);
|
||||
return (function() {
|
||||
var result = callThunk(body, env);
|
||||
popWind();
|
||||
callThunk(after, env);
|
||||
return result;
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// expand-macro
|
||||
var expandMacro = function(mac, rawArgs, env) { return (function() {
|
||||
var local = envMerge(macroClosure(mac), env);
|
||||
@@ -989,7 +1163,7 @@ return (isSxTruthy((pos >= lenSrc)) ? error("Unexpected end of input") : (functi
|
||||
return (isSxTruthy((ch == "(")) ? ((pos = (pos + 1)), readList(")")) : (isSxTruthy((ch == "[")) ? ((pos = (pos + 1)), readList("]")) : (isSxTruthy((ch == "{")) ? ((pos = (pos + 1)), readMap()) : (isSxTruthy((ch == "\"")) ? readString() : (isSxTruthy((ch == ":")) ? readKeyword() : (isSxTruthy((ch == "`")) ? ((pos = (pos + 1)), [makeSymbol("quasiquote"), readExpr()]) : (isSxTruthy((ch == ",")) ? ((pos = (pos + 1)), (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == "@"))) ? ((pos = (pos + 1)), [makeSymbol("splice-unquote"), readExpr()]) : [makeSymbol("unquote"), readExpr()])) : (isSxTruthy(sxOr((isSxTruthy((ch >= "0")) && (ch <= "9")), (isSxTruthy((ch == "-")) && isSxTruthy(((pos + 1) < lenSrc)) && (function() {
|
||||
var nextCh = nth(source, (pos + 1));
|
||||
return (isSxTruthy((nextCh >= "0")) && (nextCh <= "9"));
|
||||
})()))) ? readNumber() : (isSxTruthy(isIdentStart(ch)) ? readSymbol() : error((String("Unexpected character: ") + String(ch))))))))))));
|
||||
})()))) ? readNumber() : (isSxTruthy((isSxTruthy((ch == ".")) && isSxTruthy(((pos + 2) < lenSrc)) && isSxTruthy((nth(source, (pos + 1)) == ".")) && (nth(source, (pos + 2)) == "."))) ? ((pos = (pos + 3)), makeSymbol("...")) : (isSxTruthy(isIdentStart(ch)) ? readSymbol() : error((String("Unexpected character: ") + String(ch)))))))))))));
|
||||
})()); };
|
||||
return (function() {
|
||||
var exprs = [];
|
||||
@@ -1008,6 +1182,154 @@ continue; } else { return NIL; } } };
|
||||
var sxSerializeDict = function(d) { return (String("{") + String(join(" ", reduce(function(acc, key) { return concat(acc, [(String(":") + String(key)), sxSerialize(dictGet(d, key))]); }, [], keys(d)))) + String("}")); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-html ===
|
||||
|
||||
// render-to-html
|
||||
var renderToHtml = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); if (_m == "raw-html") return rawHtmlContent(expr); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); };
|
||||
|
||||
// render-value-to-html
|
||||
var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "style-value") return styleValueClass(val); return escapeHtml((String(val))); })(); };
|
||||
|
||||
// RENDER_HTML_FORMS
|
||||
var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defmacro", "defstyle", "defkeyframes", "defhandler", "map", "map-indexed", "filter", "for-each"];
|
||||
|
||||
// render-html-form?
|
||||
var isRenderHtmlForm = function(name) { return contains(RENDER_HTML_FORMS, name); };
|
||||
|
||||
// render-list-to-html
|
||||
var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() {
|
||||
var head = first(expr);
|
||||
return (isSxTruthy(!(typeOf(head) == "symbol")) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() {
|
||||
var name = symbolName(head);
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name)))));
|
||||
})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))));
|
||||
})());
|
||||
})()); };
|
||||
|
||||
// dispatch-html-form
|
||||
var dispatchHtmlForm = function(name, expr, env) { return (isSxTruthy((name == "if")) ? (function() {
|
||||
var condVal = trampoline(evalExpr(nth(expr, 1), env));
|
||||
return (isSxTruthy(condVal) ? renderToHtml(nth(expr, 2), env) : (isSxTruthy((len(expr) > 3)) ? renderToHtml(nth(expr, 3), env) : ""));
|
||||
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!trampoline(evalExpr(nth(expr, 1), env))) ? "" : join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(2, len(expr))))) : (isSxTruthy((name == "cond")) ? (function() {
|
||||
var branch = evalCond(rest(expr), env);
|
||||
return (isSxTruthy(branch) ? renderToHtml(branch, env) : "");
|
||||
})() : (isSxTruthy((name == "case")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() {
|
||||
var local = processBindings(nth(expr, 1), env);
|
||||
return join("", map(function(i) { return renderToHtml(nth(expr, i), local); }, range(2, len(expr))));
|
||||
})() : (isSxTruthy(sxOr((name == "begin"), (name == "do"))) ? join("", map(function(i) { return renderToHtml(nth(expr, i), env); }, range(1, len(expr)))) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((name == "map")) ? (function() {
|
||||
var f = trampoline(evalExpr(nth(expr, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(expr, 2), env));
|
||||
return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll));
|
||||
})() : (isSxTruthy((name == "map-indexed")) ? (function() {
|
||||
var f = trampoline(evalExpr(nth(expr, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(expr, 2), env));
|
||||
return join("", mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [i, item], env) : renderToHtml(apply(f, [i, item]), env)); }, coll));
|
||||
})() : (isSxTruthy((name == "filter")) ? renderToHtml(trampoline(evalExpr(expr, env)), env) : (isSxTruthy((name == "for-each")) ? (function() {
|
||||
var f = trampoline(evalExpr(nth(expr, 1), env));
|
||||
var coll = trampoline(evalExpr(nth(expr, 2), env));
|
||||
return join("", map(function(item) { return (isSxTruthy(isLambda(f)) ? renderLambdaHtml(f, [item], env) : renderToHtml(apply(f, [item]), env)); }, coll));
|
||||
})() : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))))); };
|
||||
|
||||
// render-lambda-html
|
||||
var renderLambdaHtml = function(f, args, env) { return (function() {
|
||||
var local = envMerge(lambdaClosure(f), env);
|
||||
forEachIndexed(function(i, p) { return envSet(local, p, nth(args, i)); }, lambdaParams(f));
|
||||
return renderToHtml(lambdaBody(f), local);
|
||||
})(); };
|
||||
|
||||
// render-html-component
|
||||
var renderHtmlComponent = function(comp, args, env) { return (function() {
|
||||
var kwargs = {};
|
||||
var children = [];
|
||||
reduce(function(state, arg) { return (function() {
|
||||
var skip = get(state, "skip");
|
||||
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
|
||||
var val = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env));
|
||||
kwargs[keywordName(arg)] = val;
|
||||
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
|
||||
})() : (append_b(children, arg), assoc(state, "i", (get(state, "i") + 1)))));
|
||||
})(); }, {["i"]: 0, ["skip"]: false}, args);
|
||||
return (function() {
|
||||
var local = envMerge(componentClosure(comp), env);
|
||||
{ var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } }
|
||||
if (isSxTruthy(componentHasChildren(comp))) {
|
||||
local["children"] = makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children)));
|
||||
}
|
||||
return renderToHtml(componentBody(comp), local);
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// render-html-element
|
||||
var renderHtmlElement = function(tag, args, env) { return (function() {
|
||||
var parsed = parseElementArgs(args, env);
|
||||
var attrs = first(parsed);
|
||||
var children = nth(parsed, 1);
|
||||
var isVoid = contains(VOID_ELEMENTS, tag);
|
||||
return (String("<") + String(tag) + String(renderAttrs(attrs)) + String((isSxTruthy(isVoid) ? " />" : (String(">") + String(join("", map(function(c) { return renderToHtml(c, env); }, children))) + String("</") + String(tag) + String(">")))));
|
||||
})(); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-sx ===
|
||||
|
||||
// render-to-sx
|
||||
var renderToSx = function(expr, env) { return (function() {
|
||||
var result = aser(expr, env);
|
||||
return (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result));
|
||||
})(); };
|
||||
|
||||
// aser
|
||||
var aser = function(expr, env) { return (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);
|
||||
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)); return expr; })(); };
|
||||
|
||||
// aser-list
|
||||
var aserList = function(expr, env) { return (function() {
|
||||
var head = first(expr);
|
||||
var args = rest(expr);
|
||||
return (isSxTruthy(!(typeOf(head) == "symbol")) ? map(function(x) { return aser(x, env); }, expr) : (function() {
|
||||
var name = symbolName(head);
|
||||
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
|
||||
var f = trampoline(evalExpr(head, env));
|
||||
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
|
||||
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isLambda(f)) && !isComponent(f))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f)))))));
|
||||
})())))));
|
||||
})());
|
||||
})(); };
|
||||
|
||||
// aser-fragment
|
||||
var aserFragment = function(children, env) { return (function() {
|
||||
var parts = filter(function(x) { return !isNil(x); }, map(function(c) { return aser(c, env); }, children));
|
||||
return (isSxTruthy(isEmpty(parts)) ? "" : (String("(<> ") + String(join(" ", map(serialize, parts))) + String(")")));
|
||||
})(); };
|
||||
|
||||
// aser-call
|
||||
var aserCall = function(name, args, env) { return (function() {
|
||||
var parts = [name];
|
||||
reduce(function(state, arg) { return (function() {
|
||||
var skip = get(state, "skip");
|
||||
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
|
||||
var val = aser(nth(args, (get(state, "i") + 1)), env);
|
||||
if (isSxTruthy(!isNil(val))) {
|
||||
parts.push((String(":") + String(keywordName(arg))));
|
||||
parts.push(serialize(val));
|
||||
}
|
||||
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
|
||||
})() : (function() {
|
||||
var val = aser(arg, env);
|
||||
if (isSxTruthy(!isNil(val))) {
|
||||
parts.push(serialize(val));
|
||||
}
|
||||
return assoc(state, "i", (get(state, "i") + 1));
|
||||
})()));
|
||||
})(); }, {["i"]: 0, ["skip"]: false}, args);
|
||||
return (String("(") + String(join(" ", parts)) + String(")"));
|
||||
})(); };
|
||||
|
||||
|
||||
// === Transpiled from adapter-dom ===
|
||||
|
||||
// SVG_NS
|
||||
@@ -1663,7 +1985,7 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
|
||||
if (isSxTruthy(!domHasAttr(link, "sx-push-url"))) {
|
||||
domSetAttr(link, "sx-push-url", "true");
|
||||
}
|
||||
bindBoostLink(link, domGetAttr(link, "href"));
|
||||
bindClientRouteLink(link, domGetAttr(link, "href"));
|
||||
} } }
|
||||
return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form, "boost")) && shouldBoostForm(form))) ? (markProcessed(form, "boost"), (function() {
|
||||
var method = upper(sxOr(domGetAttr(form, "method"), "GET"));
|
||||
@@ -1677,6 +1999,27 @@ return forEach(function(form) { return (isSxTruthy((isSxTruthy(!isProcessed(form
|
||||
return bindBoostForm(form, method, action);
|
||||
})()) : NIL); }, domQueryAll(container, "form")); };
|
||||
|
||||
// try-client-route
|
||||
var tryClientRoute = function(pathname) { return (function() {
|
||||
var match = findMatchingRoute(pathname, _pageRoutes);
|
||||
return (isSxTruthy(isNil(match)) ? false : (isSxTruthy(get(match, "has-data")) ? false : (function() {
|
||||
var contentSrc = get(match, "content");
|
||||
var closure = sxOr(get(match, "closure"), {});
|
||||
var params = get(match, "params");
|
||||
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? false : (function() {
|
||||
var env = merge(closure, params);
|
||||
var rendered = tryEvalContent(contentSrc, env);
|
||||
return (isSxTruthy(isNil(rendered)) ? false : (function() {
|
||||
var target = domQueryById("main-panel");
|
||||
return (isSxTruthy(isNil(target)) ? false : (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), logInfo((String("sx:route client ") + String(pathname))), true));
|
||||
})());
|
||||
})());
|
||||
})()));
|
||||
})(); };
|
||||
|
||||
// bind-client-route-link
|
||||
var bindClientRouteLink = function(link, href) { return bindClientRouteClick(link, href, function() { return bindBoostLink(link, href); }); };
|
||||
|
||||
// process-sse
|
||||
var processSse = function(root) { return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "sse")) ? (markProcessed(el, "sse"), bindSse(el)) : NIL); }, domQueryAll(sxOr(root, domBody()), "[sx-sse]")); };
|
||||
|
||||
@@ -1758,8 +2101,11 @@ return bindInlineHandlers(root); };
|
||||
var main = domQueryById("main-panel");
|
||||
var url = browserLocationHref();
|
||||
return (isSxTruthy(main) ? (function() {
|
||||
var pathname = urlPathname(url);
|
||||
return (isSxTruthy(tryClientRoute(pathname)) ? browserScrollTo(0, scrollY) : (function() {
|
||||
var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash);
|
||||
return fetchAndRestore(main, url, headers, scrollY);
|
||||
})());
|
||||
})() : NIL);
|
||||
})(); };
|
||||
|
||||
@@ -2061,10 +2407,208 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
return setSxStylesCookie(hash);
|
||||
})());
|
||||
})()) : NIL); }, scripts);
|
||||
})(); };
|
||||
|
||||
// _page-routes
|
||||
var _pageRoutes = [];
|
||||
|
||||
// process-page-scripts
|
||||
var processPageScripts = function() { return (function() {
|
||||
var scripts = queryPageScripts();
|
||||
return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "pages")) ? (markProcessed(s, "pages"), (function() {
|
||||
var text = domTextContent(s);
|
||||
return (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? (function() {
|
||||
var pages = parse(text);
|
||||
return forEach(function(page) { return append_b(_pageRoutes, merge(page, {'parsed': [Symbol('parse-route-pattern'), [Symbol('get'), Symbol('page'), 'path']]})); }, pages);
|
||||
})() : NIL);
|
||||
})()) : NIL); }, scripts);
|
||||
})(); };
|
||||
|
||||
// boot-init
|
||||
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), processPageScripts(), sxHydrateElements(NIL), processElements(NIL)); };
|
||||
|
||||
|
||||
// === Transpiled from deps (component dependency analysis) ===
|
||||
|
||||
// scan-refs
|
||||
var scanRefs = function(node) { return (function() {
|
||||
var refs = [];
|
||||
scanRefsWalk(node, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// scan-refs-walk
|
||||
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// transitive-deps-walk
|
||||
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-deps
|
||||
var transitiveDeps = function(name, env) { return (function() {
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveDepsWalk(key, seen, env);
|
||||
return filter(function(x) { return !(x == key); }, seen);
|
||||
})(); };
|
||||
|
||||
// compute-all-deps
|
||||
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// scan-components-from-source
|
||||
var scanComponentsFromSource = function(source) { return (function() {
|
||||
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
|
||||
return map(function(m) { return (String("~") + String(m)); }, matches);
|
||||
})(); };
|
||||
|
||||
// components-needed
|
||||
var componentsNeeded = function(pageSource, env) { return (function() {
|
||||
var direct = scanComponentsFromSource(pageSource);
|
||||
var allNeeded = [];
|
||||
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!contains(allNeeded, name))) {
|
||||
allNeeded.push(name);
|
||||
}
|
||||
(function() {
|
||||
var val = envGet(env, name);
|
||||
return (function() {
|
||||
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isEmpty(componentDeps(val)))) ? componentDeps(val) : transitiveDeps(name, env));
|
||||
return forEach(function(dep) { return (isSxTruthy(!contains(allNeeded, dep)) ? append_b(allNeeded, dep) : NIL); }, deps);
|
||||
})();
|
||||
})(); } }
|
||||
return allNeeded;
|
||||
})(); };
|
||||
|
||||
// page-component-bundle
|
||||
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
|
||||
|
||||
// page-css-classes
|
||||
var pageCssClasses = function(pageSource, env) { return (function() {
|
||||
var needed = componentsNeeded(pageSource, env);
|
||||
var classes = [];
|
||||
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!contains(classes, cls)) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
|
||||
})(); } }
|
||||
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!contains(classes, cls))) {
|
||||
classes.push(cls);
|
||||
} } }
|
||||
return classes;
|
||||
})(); };
|
||||
|
||||
// scan-io-refs-walk
|
||||
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
|
||||
var name = symbolName(node);
|
||||
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!contains(refs, name)) ? append_b(refs, name) : NIL) : NIL);
|
||||
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
|
||||
|
||||
// scan-io-refs
|
||||
var scanIoRefs = function(node, ioNames) { return (function() {
|
||||
var refs = [];
|
||||
scanIoRefsWalk(node, ioNames, refs);
|
||||
return refs;
|
||||
})(); };
|
||||
|
||||
// transitive-io-refs-walk
|
||||
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!contains(seen, n)) ? (append_b(seen, n), (function() {
|
||||
var val = envGet(env, n);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!contains(allRefs, ref)) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
|
||||
})()) : NIL); };
|
||||
|
||||
// transitive-io-refs
|
||||
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
|
||||
var allRefs = [];
|
||||
var seen = [];
|
||||
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
|
||||
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
|
||||
return allRefs;
|
||||
})(); };
|
||||
|
||||
// compute-all-io-refs
|
||||
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
|
||||
var val = envGet(env, name);
|
||||
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
|
||||
})(); }, envComponents(env)); };
|
||||
|
||||
// component-pure?
|
||||
var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); };
|
||||
|
||||
|
||||
// === Transpiled from router (client-side route matching) ===
|
||||
|
||||
// split-path-segments
|
||||
var splitPathSegments = function(path) { return (function() {
|
||||
var trimmed = (isSxTruthy(startsWith(path, "/")) ? slice(path, 1) : path);
|
||||
return (function() {
|
||||
var trimmed2 = (isSxTruthy((isSxTruthy(!isEmpty(trimmed)) && endsWith(trimmed, "/"))) ? slice(trimmed, 0, (length(trimmed) - 1)) : trimmed);
|
||||
return (isSxTruthy(isEmpty(trimmed2)) ? [] : split(trimmed2, "/"));
|
||||
})();
|
||||
})(); };
|
||||
|
||||
// make-route-segment
|
||||
var makeRouteSegment = function(seg) { return (isSxTruthy((isSxTruthy(startsWith(seg, "<")) && endsWith(seg, ">"))) ? (function() {
|
||||
var paramName = slice(seg, 1, (length(seg) - 1));
|
||||
return (function() {
|
||||
var d = {};
|
||||
d["type"] = "param";
|
||||
d["value"] = paramName;
|
||||
return d;
|
||||
})();
|
||||
})() : (function() {
|
||||
var d = {};
|
||||
d["type"] = "literal";
|
||||
d["value"] = seg;
|
||||
return d;
|
||||
})()); };
|
||||
|
||||
// parse-route-pattern
|
||||
var parseRoutePattern = function(pattern) { return (function() {
|
||||
var segments = splitPathSegments(pattern);
|
||||
return map(makeRouteSegment, segments);
|
||||
})(); };
|
||||
|
||||
// match-route-segments
|
||||
var matchRouteSegments = function(pathSegs, parsedSegs) { return (isSxTruthy(!(length(pathSegs) == length(parsedSegs))) ? NIL : (function() {
|
||||
var params = {};
|
||||
var matched = true;
|
||||
forEachIndexed(function(i, parsedSeg) { return (isSxTruthy(matched) ? (function() {
|
||||
var pathSeg = nth(pathSegs, i);
|
||||
var segType = get(parsedSeg, "type");
|
||||
return (isSxTruthy((segType == "literal")) ? (isSxTruthy(!(pathSeg == get(parsedSeg, "value"))) ? (matched = false) : NIL) : (isSxTruthy((segType == "param")) ? dictSet(params, get(parsedSeg, "value"), pathSeg) : (matched = false)));
|
||||
})() : NIL); }, parsedSegs);
|
||||
return (isSxTruthy(matched) ? params : NIL);
|
||||
})()); };
|
||||
|
||||
// match-route
|
||||
var matchRoute = function(path, pattern) { return (function() {
|
||||
var pathSegs = splitPathSegments(path);
|
||||
var parsedSegs = parseRoutePattern(pattern);
|
||||
return matchRouteSegments(pathSegs, parsedSegs);
|
||||
})(); };
|
||||
|
||||
// find-matching-route
|
||||
var findMatchingRoute = function(path, routes) { return (function() {
|
||||
var pathSegs = splitPathSegments(path);
|
||||
var result = NIL;
|
||||
{ var _c = routes; for (var _i = 0; _i < _c.length; _i++) { var route = _c[_i]; if (isSxTruthy(isNil(result))) {
|
||||
(function() {
|
||||
var params = matchRouteSegments(pathSegs, get(route, "parsed"));
|
||||
return (isSxTruthy(!isNil(params)) ? (function() {
|
||||
var matched = merge(route, {});
|
||||
matched["params"] = params;
|
||||
return (result = matched);
|
||||
})() : NIL);
|
||||
})();
|
||||
} } }
|
||||
return result;
|
||||
})(); };
|
||||
|
||||
|
||||
// =========================================================================
|
||||
@@ -2711,6 +3255,50 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Client-side route bindings ---
|
||||
|
||||
function bindClientRouteClick(link, href, fallbackFn) {
|
||||
link.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
var pathname = urlPathname(href);
|
||||
if (tryClientRoute(pathname)) {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
if (typeof window !== "undefined") window.scrollTo(0, 0);
|
||||
} else {
|
||||
logInfo("sx:route server " + pathname);
|
||||
executeRequest(link, { method: "GET", url: href }).then(function() {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryEvalContent(source, env) {
|
||||
try {
|
||||
var merged = merge(componentEnv);
|
||||
if (env && !isNil(env)) {
|
||||
var ks = Object.keys(env);
|
||||
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
|
||||
}
|
||||
return sxRenderWithEnv(source, merged);
|
||||
} catch (e) {
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
} catch (e) {
|
||||
// Fallback: strip query/hash
|
||||
var idx = href.indexOf("?");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
idx = href.indexOf("#");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inline handlers ---
|
||||
|
||||
function bindInlineHandler(el, eventName, body) {
|
||||
@@ -2981,6 +3569,12 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
document.querySelectorAll('script[type="text/sx-styles"]'));
|
||||
}
|
||||
|
||||
function queryPageScripts() {
|
||||
if (!_hasDom) return [];
|
||||
return Array.prototype.slice.call(
|
||||
document.querySelectorAll('script[type="text/sx-pages"]'));
|
||||
}
|
||||
|
||||
// --- localStorage ---
|
||||
|
||||
function localStorageGet(key) {
|
||||
@@ -3076,6 +3670,9 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
};
|
||||
|
||||
// Expose render functions as primitives so SX code can call them
|
||||
if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml;
|
||||
if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx;
|
||||
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
|
||||
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
|
||||
|
||||
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
|
||||
@@ -3095,12 +3692,25 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
}
|
||||
|
||||
function render(source) {
|
||||
if (!_hasDom) {
|
||||
var exprs = parse(source);
|
||||
var parts = [];
|
||||
for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv)));
|
||||
return parts.join("");
|
||||
}
|
||||
var exprs = parse(source);
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < exprs.length; i++) frag.appendChild(renderToDom(exprs[i], merge(componentEnv), null));
|
||||
return frag;
|
||||
}
|
||||
|
||||
function renderToString(source) {
|
||||
var exprs = parse(source);
|
||||
var parts = [];
|
||||
for (var i = 0; i < exprs.length; i++) parts.push(renderToHtml(exprs[i], merge(componentEnv)));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
var Sx = {
|
||||
VERSION: "ref-2.0",
|
||||
parse: parse,
|
||||
@@ -3108,7 +3718,7 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); },
|
||||
loadComponents: loadComponents,
|
||||
render: render,
|
||||
|
||||
renderToString: renderToString,
|
||||
serialize: serialize,
|
||||
NIL: NIL,
|
||||
Symbol: Symbol,
|
||||
@@ -3116,6 +3726,8 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
isTruthy: isSxTruthy,
|
||||
isNil: isNil,
|
||||
componentEnv: componentEnv,
|
||||
renderToHtml: function(expr, env) { return renderToHtml(expr, env || merge(componentEnv)); },
|
||||
renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); },
|
||||
renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,
|
||||
parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,
|
||||
morphNode: typeof morphNode === "function" ? morphNode : null,
|
||||
@@ -3131,7 +3743,21 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||
getEnv: function() { return componentEnv; },
|
||||
init: typeof bootInit === "function" ? bootInit : null,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+orchestration+parser, bootstrap-compiled)"
|
||||
scanRefs: scanRefs,
|
||||
transitiveDeps: transitiveDeps,
|
||||
computeAllDeps: computeAllDeps,
|
||||
componentsNeeded: componentsNeeded,
|
||||
pageComponentBundle: pageComponentBundle,
|
||||
pageCssClasses: pageCssClasses,
|
||||
scanIoRefs: scanIoRefs,
|
||||
transitiveIoRefs: transitiveIoRefs,
|
||||
computeAllIoRefs: computeAllIoRefs,
|
||||
componentPure_p: componentPure_p,
|
||||
splitPathSegments: splitPathSegments,
|
||||
parseRoutePattern: parseRoutePattern,
|
||||
matchRoute: matchRoute,
|
||||
findMatchingRoute: findMatchingRoute,
|
||||
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
|
||||
};
|
||||
|
||||
|
||||
@@ -3154,4 +3780,4 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = Sx;
|
||||
else global.Sx = Sx;
|
||||
|
||||
})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);
|
||||
})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this);
|
||||
|
||||
@@ -631,6 +631,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>
|
||||
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
|
||||
<script type="text/sx-pages">{pages_sx}</script>
|
||||
<script type="text/sx" data-mount="body">{page_sx}</script>
|
||||
<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>
|
||||
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
|
||||
@@ -638,6 +639,66 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
|
||||
</html>"""
|
||||
|
||||
|
||||
def _build_pages_sx(service: str) -> str:
|
||||
"""Build SX page registry for client-side routing.
|
||||
|
||||
Returns SX dict literals (one per page) parseable by the client's
|
||||
``parse`` function. Each dict has keys: name, path, auth, has-data,
|
||||
content, closure.
|
||||
"""
|
||||
from .pages import get_all_pages
|
||||
from .parser import serialize as sx_serialize
|
||||
|
||||
pages = get_all_pages(service)
|
||||
if not pages:
|
||||
return ""
|
||||
|
||||
entries = []
|
||||
for page_def in pages.values():
|
||||
content_src = ""
|
||||
if page_def.content_expr is not None:
|
||||
try:
|
||||
content_src = sx_serialize(page_def.content_expr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
auth = page_def.auth if isinstance(page_def.auth, str) else "custom"
|
||||
has_data = "true" if page_def.data_expr is not None else "false"
|
||||
|
||||
# Build closure as SX dict
|
||||
closure_parts: list[str] = []
|
||||
for k, v in page_def.closure.items():
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
closure_parts.append(f":{k} {_sx_literal(v)}")
|
||||
closure_sx = "{" + " ".join(closure_parts) + "}"
|
||||
|
||||
entry = (
|
||||
"{:name " + _sx_literal(page_def.name)
|
||||
+ " :path " + _sx_literal(page_def.path)
|
||||
+ " :auth " + _sx_literal(auth)
|
||||
+ " :has-data " + has_data
|
||||
+ " :content " + _sx_literal(content_src)
|
||||
+ " :closure " + closure_sx + "}"
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
return "\n".join(entries)
|
||||
|
||||
|
||||
def _sx_literal(v: object) -> str:
|
||||
"""Serialize a Python value as an SX literal."""
|
||||
if v is None:
|
||||
return "nil"
|
||||
if isinstance(v, bool):
|
||||
return "true" if v else "false"
|
||||
if isinstance(v, (int, float)):
|
||||
return str(v)
|
||||
if isinstance(v, str):
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
return f'"{escaped}"'
|
||||
return "nil"
|
||||
|
||||
|
||||
def sx_page(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "") -> str:
|
||||
"""Return a minimal HTML shell that boots the page from sx source.
|
||||
@@ -692,6 +753,14 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
else:
|
||||
styles_json = _build_style_dict_json()
|
||||
|
||||
# Page registry for client-side routing
|
||||
pages_sx = ""
|
||||
try:
|
||||
from quart import current_app
|
||||
pages_sx = _build_pages_sx(current_app.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _SX_PAGE_TEMPLATE.format(
|
||||
title=_html_escape(title),
|
||||
asset_url=asset_url,
|
||||
@@ -701,6 +770,7 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
component_defs=component_defs,
|
||||
styles_hash=styles_hash,
|
||||
styles_json=styles_json,
|
||||
pages_sx=pages_sx,
|
||||
page_sx=page_sx,
|
||||
sx_css=sx_css,
|
||||
sx_css_classes=sx_css_classes,
|
||||
|
||||
@@ -295,6 +295,33 @@
|
||||
scripts))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Page registry for client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define _page-routes (list))
|
||||
|
||||
(define process-page-scripts
|
||||
(fn ()
|
||||
;; Process <script type="text/sx-pages"> tags.
|
||||
;; Parses SX page registry and builds route entries with parsed patterns.
|
||||
(let ((scripts (query-page-scripts)))
|
||||
(for-each
|
||||
(fn (s)
|
||||
(when (not (is-processed? s "pages"))
|
||||
(mark-processed! s "pages")
|
||||
(let ((text (dom-text-content s)))
|
||||
(when (and text (not (empty? (trim text))))
|
||||
(let ((pages (parse text)))
|
||||
(for-each
|
||||
(fn (page)
|
||||
(append! _page-routes
|
||||
(merge page
|
||||
{"parsed" (parse-route-pattern (get page "path"))})))
|
||||
pages))))))
|
||||
scripts))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Full boot sequence
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -305,12 +332,14 @@
|
||||
;; 1. CSS tracking
|
||||
;; 2. Style dictionary
|
||||
;; 3. Process scripts (components + mounts)
|
||||
;; 4. Hydrate [data-sx] elements
|
||||
;; 5. Process engine elements
|
||||
;; 4. Process page registry (client-side routing)
|
||||
;; 5. Hydrate [data-sx] elements
|
||||
;; 6. Process engine elements
|
||||
(do
|
||||
(init-css-tracking)
|
||||
(init-style-dict)
|
||||
(process-sx-scripts nil)
|
||||
(process-page-scripts)
|
||||
(sx-hydrate-elements nil)
|
||||
(process-elements nil))))
|
||||
|
||||
@@ -354,6 +383,7 @@
|
||||
;; === Script queries ===
|
||||
;; (query-sx-scripts root) → list of <script type="text/sx"> elements
|
||||
;; (query-style-scripts) → list of <script type="text/sx-styles"> elements
|
||||
;; (query-page-scripts) → list of <script type="text/sx-pages"> elements
|
||||
;;
|
||||
;; === localStorage ===
|
||||
;; (local-storage-get key) → string or nil
|
||||
|
||||
@@ -376,6 +376,11 @@ class JSEmitter:
|
||||
"event-source-listen": "eventSourceListen",
|
||||
"bind-boost-link": "bindBoostLink",
|
||||
"bind-boost-form": "bindBoostForm",
|
||||
"bind-client-route-link": "bindClientRouteLink",
|
||||
"bind-client-route-click": "bindClientRouteClick",
|
||||
"try-client-route": "tryClientRoute",
|
||||
"try-eval-content": "tryEvalContent",
|
||||
"url-pathname": "urlPathname",
|
||||
"bind-inline-handler": "bindInlineHandler",
|
||||
"bind-preload": "bindPreload",
|
||||
"mark-processed!": "markProcessed",
|
||||
@@ -490,6 +495,9 @@ class JSEmitter:
|
||||
"log-info": "logInfo",
|
||||
"log-parse-error": "logParseError",
|
||||
"parse-and-load-style-dict": "parseAndLoadStyleDict",
|
||||
"_page-routes": "_pageRoutes",
|
||||
"process-page-scripts": "processPageScripts",
|
||||
"query-page-scripts": "queryPageScripts",
|
||||
# deps.sx
|
||||
"scan-refs": "scanRefs",
|
||||
"scan-refs-walk": "scanRefsWalk",
|
||||
@@ -513,6 +521,14 @@ class JSEmitter:
|
||||
"transitive-io-refs": "transitiveIoRefs",
|
||||
"compute-all-io-refs": "computeAllIoRefs",
|
||||
"component-pure?": "componentPure_p",
|
||||
# router.sx
|
||||
"split-path-segments": "splitPathSegments",
|
||||
"make-route-segment": "makeRouteSegment",
|
||||
"parse-route-pattern": "parseRoutePattern",
|
||||
"match-route-segments": "matchRouteSegments",
|
||||
"match-route": "matchRoute",
|
||||
"find-matching-route": "findMatchingRoute",
|
||||
"for-each-indexed": "forEachIndexed",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
@@ -1026,6 +1042,7 @@ ADAPTER_DEPS = {
|
||||
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1170,6 +1187,7 @@ def compile_ref_to_js(
|
||||
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
|
||||
spec_mod_set.add(sm)
|
||||
has_deps = "deps" in spec_mod_set
|
||||
has_router = "router" in spec_mod_set
|
||||
|
||||
# Core files always included, then selected adapters, then spec modules
|
||||
sx_files = [
|
||||
@@ -1256,7 +1274,7 @@ def compile_ref_to_js(
|
||||
parts.append(fixups_js(has_html, has_sx, has_dom))
|
||||
if has_continuations:
|
||||
parts.append(CONTINUATIONS_JS)
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps))
|
||||
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router))
|
||||
parts.append(EPILOGUE)
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -2589,6 +2607,50 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
});
|
||||
}
|
||||
|
||||
// --- Client-side route bindings ---
|
||||
|
||||
function bindClientRouteClick(link, href, fallbackFn) {
|
||||
link.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
var pathname = urlPathname(href);
|
||||
if (tryClientRoute(pathname)) {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
if (typeof window !== "undefined") window.scrollTo(0, 0);
|
||||
} else {
|
||||
logInfo("sx:route server " + pathname);
|
||||
executeRequest(link, { method: "GET", url: href }).then(function() {
|
||||
try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryEvalContent(source, env) {
|
||||
try {
|
||||
var merged = merge(componentEnv);
|
||||
if (env && !isNil(env)) {
|
||||
var ks = Object.keys(env);
|
||||
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
|
||||
}
|
||||
return sxRenderWithEnv(source, merged);
|
||||
} catch (e) {
|
||||
return NIL;
|
||||
}
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
} catch (e) {
|
||||
// Fallback: strip query/hash
|
||||
var idx = href.indexOf("?");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
idx = href.indexOf("#");
|
||||
if (idx >= 0) href = href.substring(0, idx);
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inline handlers ---
|
||||
|
||||
function bindInlineHandler(el, eventName, body) {
|
||||
@@ -2861,6 +2923,12 @@ PLATFORM_BOOT_JS = """
|
||||
document.querySelectorAll('script[type="text/sx-styles"]'));
|
||||
}
|
||||
|
||||
function queryPageScripts() {
|
||||
if (!_hasDom) return [];
|
||||
return Array.prototype.slice.call(
|
||||
document.querySelectorAll('script[type="text/sx-pages"]'));
|
||||
}
|
||||
|
||||
// --- localStorage ---
|
||||
|
||||
function localStorageGet(key) {
|
||||
@@ -2968,7 +3036,7 @@ def fixups_js(has_html, has_sx, has_dom):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False):
|
||||
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps=False, has_router=False):
|
||||
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
|
||||
if has_parser:
|
||||
parser = '''
|
||||
@@ -3101,6 +3169,11 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append(' transitiveIoRefs: transitiveIoRefs,')
|
||||
api_lines.append(' computeAllIoRefs: computeAllIoRefs,')
|
||||
api_lines.append(' componentPure_p: componentPure_p,')
|
||||
if has_router:
|
||||
api_lines.append(' splitPathSegments: splitPathSegments,')
|
||||
api_lines.append(' parseRoutePattern: parseRoutePattern,')
|
||||
api_lines.append(' matchRoute: matchRoute,')
|
||||
api_lines.append(' findMatchingRoute: findMatchingRoute,')
|
||||
|
||||
api_lines.append(f' _version: "{version}"')
|
||||
api_lines.append(' };')
|
||||
|
||||
@@ -258,6 +258,13 @@ class PyEmitter:
|
||||
"transitive-io-refs": "transitive_io_refs",
|
||||
"compute-all-io-refs": "compute_all_io_refs",
|
||||
"component-pure?": "component_pure_p",
|
||||
# router.sx
|
||||
"split-path-segments": "split_path_segments",
|
||||
"make-route-segment": "make_route_segment",
|
||||
"parse-route-pattern": "parse_route_pattern",
|
||||
"match-route-segments": "match_route_segments",
|
||||
"match-route": "match_route",
|
||||
"find-matching-route": "find_matching_route",
|
||||
}
|
||||
if name in RENAMES:
|
||||
return RENAMES[name]
|
||||
@@ -391,6 +398,9 @@ class PyEmitter:
|
||||
assignments.append((self._mangle(vname), self.emit(bindings[i + 1])))
|
||||
# Nested IIFE for sequential let (each binding can see previous ones):
|
||||
# (lambda a: (lambda b: body)(val_b))(val_a)
|
||||
# Cell variables (mutated by nested set!) are initialized in _cells dict
|
||||
# instead of lambda params, since the body reads _cells[name].
|
||||
cell_vars = getattr(self, '_current_cell_vars', set())
|
||||
body_parts = [self.emit(b) for b in body]
|
||||
if len(body) == 1:
|
||||
body_str = body_parts[0]
|
||||
@@ -399,7 +409,11 @@ class PyEmitter:
|
||||
# Build from inside out
|
||||
result = body_str
|
||||
for name, val in reversed(assignments):
|
||||
result = f"(lambda {name}: {result})({val})"
|
||||
if name in cell_vars:
|
||||
# Cell var: initialize in _cells dict, not as lambda param
|
||||
result = f"_sx_begin(_sx_cell_set(_cells, {self._py_string(name)}, {val}), {result})"
|
||||
else:
|
||||
result = f"(lambda {name}: {result})({val})"
|
||||
return result
|
||||
|
||||
def _emit_if(self, expr) -> str:
|
||||
@@ -828,6 +842,7 @@ ADAPTER_FILES = {
|
||||
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1947,6 +1962,9 @@ range = PRIMITIVES["range"]
|
||||
apply = lambda f, args: f(*args)
|
||||
assoc = PRIMITIVES["assoc"]
|
||||
concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
'''
|
||||
|
||||
|
||||
|
||||
@@ -505,7 +505,7 @@
|
||||
(dom-set-attr link "sx-swap" "innerHTML"))
|
||||
(when (not (dom-has-attr? link "sx-push-url"))
|
||||
(dom-set-attr link "sx-push-url" "true"))
|
||||
(bind-boost-link link (dom-get-attr link "href"))))
|
||||
(bind-client-route-link link (dom-get-attr link "href"))))
|
||||
(dom-query-all container "a[href]"))
|
||||
(for-each
|
||||
(fn (form)
|
||||
@@ -523,6 +523,52 @@
|
||||
(dom-query-all container "form"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define try-client-route
|
||||
(fn (pathname)
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; Only works for pages without :data dependencies.
|
||||
(let ((match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match)
|
||||
false
|
||||
(if (get match "has-data")
|
||||
false
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params")))
|
||||
(if (or (nil? content-src) (empty? content-src))
|
||||
false
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
false
|
||||
(let ((target (dom-query-by-id "main-panel")))
|
||||
(if (nil? target)
|
||||
false
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(log-info (str "sx:route client " pathname))
|
||||
true))))))))))))
|
||||
|
||||
|
||||
(define bind-client-route-link
|
||||
(fn (link href)
|
||||
;; Bind a boost link with client-side routing. If the route can be
|
||||
;; rendered client-side (pure page, no :data), do so. Otherwise
|
||||
;; fall back to standard server fetch via bind-boost-link.
|
||||
(bind-client-route-click link href
|
||||
(fn ()
|
||||
;; Fallback: use standard boost link binding
|
||||
(bind-boost-link link href)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; SSE processing
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -668,13 +714,17 @@
|
||||
|
||||
(define handle-popstate
|
||||
(fn (scrollY)
|
||||
;; Handle browser back/forward navigation
|
||||
;; Handle browser back/forward navigation.
|
||||
;; Try client-side route first, fall back to server fetch.
|
||||
(let ((main (dom-query-by-id "main-panel"))
|
||||
(url (browser-location-href)))
|
||||
(when main
|
||||
(let ((headers (build-request-headers main
|
||||
(loaded-component-names) _css-hash)))
|
||||
(fetch-and-restore main url headers scrollY))))))
|
||||
(let ((pathname (url-pathname url)))
|
||||
(if (try-client-route pathname)
|
||||
(browser-scroll-to 0 scrollY)
|
||||
(let ((headers (build-request-headers main
|
||||
(loaded-component-names) _css-hash)))
|
||||
(fetch-and-restore main url headers scrollY))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -773,6 +823,7 @@
|
||||
;; === Boost bindings ===
|
||||
;; (bind-boost-link el href) → void (click handler + pushState)
|
||||
;; (bind-boost-form form method action) → void (submit handler)
|
||||
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
|
||||
;;
|
||||
;; === Inline handlers ===
|
||||
;; (bind-inline-handler el event-name body) → void (new Function)
|
||||
@@ -803,10 +854,22 @@
|
||||
;; === Parsing ===
|
||||
;; (try-parse-json s) → parsed value or nil
|
||||
;;
|
||||
;; === Client-side routing ===
|
||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||
;; (url-pathname href) → extract pathname from URL string
|
||||
;;
|
||||
;; From boot.sx:
|
||||
;; _page-routes → list of route entries
|
||||
;;
|
||||
;; From router.sx:
|
||||
;; (find-matching-route path routes) → matching entry with params, or nil
|
||||
;; (parse-route-pattern pattern) → parsed pattern segments
|
||||
;;
|
||||
;; === Browser (via engine.sx) ===
|
||||
;; (browser-location-href) → current URL string
|
||||
;; (browser-navigate url) → void
|
||||
;; (browser-reload) → void
|
||||
;; (browser-scroll-to x y) → void
|
||||
;; (browser-media-matches? query) → boolean
|
||||
;; (browser-confirm msg) → boolean
|
||||
;; (browser-prompt msg) → string or nil
|
||||
|
||||
126
shared/sx/ref/router.sx
Normal file
126
shared/sx/ref/router.sx
Normal file
@@ -0,0 +1,126 @@
|
||||
;; ==========================================================================
|
||||
;; router.sx — Client-side route matching specification
|
||||
;;
|
||||
;; Pure functions for matching URL paths against Flask-style route patterns.
|
||||
;; Used by client-side routing to determine if a page can be rendered
|
||||
;; locally without a server roundtrip.
|
||||
;;
|
||||
;; All functions are pure — no IO, no platform-specific operations.
|
||||
;; Uses only primitives from primitives.sx (string ops, list ops).
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. Split path into segments
|
||||
;; --------------------------------------------------------------------------
|
||||
;; "/docs/hello" → ("docs" "hello")
|
||||
;; "/" → ()
|
||||
;; "/docs/" → ("docs")
|
||||
|
||||
(define split-path-segments
|
||||
(fn (path)
|
||||
(let ((trimmed (if (starts-with? path "/") (slice path 1) path)))
|
||||
(let ((trimmed2 (if (and (not (empty? trimmed))
|
||||
(ends-with? trimmed "/"))
|
||||
(slice trimmed 0 (- (length trimmed) 1))
|
||||
trimmed)))
|
||||
(if (empty? trimmed2)
|
||||
(list)
|
||||
(split trimmed2 "/"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. Parse Flask-style route pattern into segment descriptors
|
||||
;; --------------------------------------------------------------------------
|
||||
;; "/docs/<slug>" → ({"type" "literal" "value" "docs"}
|
||||
;; {"type" "param" "value" "slug"})
|
||||
|
||||
(define make-route-segment
|
||||
(fn (seg)
|
||||
(if (and (starts-with? seg "<") (ends-with? seg ">"))
|
||||
(let ((param-name (slice seg 1 (- (length seg) 1))))
|
||||
(let ((d {}))
|
||||
(dict-set! d "type" "param")
|
||||
(dict-set! d "value" param-name)
|
||||
d))
|
||||
(let ((d {}))
|
||||
(dict-set! d "type" "literal")
|
||||
(dict-set! d "value" seg)
|
||||
d))))
|
||||
|
||||
(define parse-route-pattern
|
||||
(fn (pattern)
|
||||
(let ((segments (split-path-segments pattern)))
|
||||
(map make-route-segment segments))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. Match path segments against parsed pattern
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns params dict if match, nil if no match.
|
||||
|
||||
(define match-route-segments
|
||||
(fn (path-segs parsed-segs)
|
||||
(if (not (= (length path-segs) (length parsed-segs)))
|
||||
nil
|
||||
(let ((params {})
|
||||
(matched true))
|
||||
(for-each-indexed
|
||||
(fn (i parsed-seg)
|
||||
(when matched
|
||||
(let ((path-seg (nth path-segs i))
|
||||
(seg-type (get parsed-seg "type")))
|
||||
(cond
|
||||
(= seg-type "literal")
|
||||
(when (not (= path-seg (get parsed-seg "value")))
|
||||
(set! matched false))
|
||||
(= seg-type "param")
|
||||
(dict-set! params (get parsed-seg "value") path-seg)
|
||||
:else
|
||||
(set! matched false)))))
|
||||
parsed-segs)
|
||||
(if matched params nil)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. Public API: match a URL path against a pattern string
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Returns params dict (may be empty for exact matches) or nil.
|
||||
|
||||
(define match-route
|
||||
(fn (path pattern)
|
||||
(let ((path-segs (split-path-segments path))
|
||||
(parsed-segs (parse-route-pattern pattern)))
|
||||
(match-route-segments path-segs parsed-segs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. Search a list of route entries for first match
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Each entry: {"pattern" "/docs/<slug>" "parsed" [...] "name" "docs-page" ...}
|
||||
;; Returns matching entry with "params" added, or nil.
|
||||
|
||||
(define find-matching-route
|
||||
(fn (path routes)
|
||||
(let ((path-segs (split-path-segments path))
|
||||
(result nil))
|
||||
(for-each
|
||||
(fn (route)
|
||||
(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)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Platform interface — none required
|
||||
;; --------------------------------------------------------------------------
|
||||
;; All functions use only pure primitives:
|
||||
;; split, slice, starts-with?, ends-with?, length, empty?,
|
||||
;; map, for-each, for-each-indexed, nth, get, dict-set!, merge,
|
||||
;; list, nil?, not, =
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -876,6 +876,9 @@ range = PRIMITIVES["range"]
|
||||
apply = lambda f, args: f(*args)
|
||||
assoc = PRIMITIVES["assoc"]
|
||||
concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1237,6 +1240,40 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (
|
||||
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
|
||||
|
||||
|
||||
# === Transpiled from router (client-side route matching) ===
|
||||
|
||||
# split-path-segments
|
||||
split_path_segments = lambda path: (lambda trimmed: (lambda trimmed2: ([] if sx_truthy(empty_p(trimmed2)) else split(trimmed2, '/')))((slice(trimmed, 0, (length(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed)))((slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path))
|
||||
|
||||
# make-route-segment
|
||||
make_route_segment = lambda seg: ((lambda param_name: (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'param'), _sx_dict_set(d, 'value', param_name), d))({}))(slice(seg, 1, (length(seg) - 1))) if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))) else (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'literal'), _sx_dict_set(d, 'value', seg), d))({}))
|
||||
|
||||
# parse-route-pattern
|
||||
parse_route_pattern = lambda pattern: (lambda segments: map(make_route_segment, segments))(split_path_segments(pattern))
|
||||
|
||||
# match-route-segments
|
||||
def match_route_segments(path_segs, parsed_segs):
|
||||
_cells = {}
|
||||
return (NIL if sx_truthy((not sx_truthy((length(path_segs) == length(parsed_segs))))) else (lambda params: _sx_begin(_sx_cell_set(_cells, 'matched', True), _sx_begin(for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs), (params if sx_truthy(_cells['matched']) else NIL))))({}))
|
||||
|
||||
# match-route
|
||||
match_route = lambda path, pattern: (lambda path_segs: (lambda parsed_segs: match_route_segments(path_segs, parsed_segs))(parse_route_pattern(pattern)))(split_path_segments(path))
|
||||
|
||||
# find-matching-route
|
||||
def find_matching_route(path, routes):
|
||||
_cells = {}
|
||||
path_segs = split_path_segments(path)
|
||||
_cells['result'] = NIL
|
||||
for route in routes:
|
||||
if sx_truthy(is_nil(_cells['result'])):
|
||||
params = match_route_segments(path_segs, get(route, 'parsed'))
|
||||
if sx_truthy((not sx_truthy(is_nil(params)))):
|
||||
matched = merge(route, {})
|
||||
matched['params'] = params
|
||||
_cells['result'] = matched
|
||||
return _cells['result']
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Fixups -- wire up render adapter dispatch
|
||||
# =========================================================================
|
||||
|
||||
392
shared/sx/tests/test_io_detection.py
Normal file
392
shared/sx/tests/test_io_detection.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Tests for Phase 2 IO detection — component purity analysis.
|
||||
|
||||
Tests both the hand-written fallback (deps.py) and the bootstrapped
|
||||
sx_ref.py implementation of IO reference scanning and transitive
|
||||
IO classification.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from shared.sx.parser import parse_all
|
||||
from shared.sx.types import Component, Macro, Symbol
|
||||
from shared.sx.deps import (
|
||||
_scan_io_refs_fallback,
|
||||
_transitive_io_refs_fallback,
|
||||
_compute_all_io_refs_fallback,
|
||||
compute_all_io_refs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_env(*sx_sources: str) -> dict:
|
||||
"""Parse and evaluate component definitions into an env dict."""
|
||||
from shared.sx.evaluator import _eval, _trampoline
|
||||
env: dict = {}
|
||||
for source in sx_sources:
|
||||
exprs = parse_all(source)
|
||||
for expr in exprs:
|
||||
_trampoline(_eval(expr, env))
|
||||
return env
|
||||
|
||||
|
||||
IO_NAMES = {"fetch-data", "call-action", "app-url", "config", "db-query"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_io_refs_fallback — scan single AST for IO primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanIoRefs:
|
||||
def test_no_io_refs(self):
|
||||
env = make_env('(defcomp ~card (&key title) (div :class "p-4" title))')
|
||||
comp = env["~card"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_direct_io_ref(self):
|
||||
env = make_env('(defcomp ~page (&key) (div (fetch-data "posts")))')
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_multiple_io_refs(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x") (config "y")))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"fetch-data", "config"}
|
||||
|
||||
def test_io_in_nested_control_flow(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key show) '
|
||||
' (if show (div (app-url "/")) (span "none")))'
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_io_in_dict_value(self):
|
||||
env = make_env(
|
||||
'(defcomp ~wrap (&key) (div {:data (db-query "x")}))'
|
||||
)
|
||||
comp = env["~wrap"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == {"db-query"}
|
||||
|
||||
def test_non_io_symbol_ignored(self):
|
||||
"""Symbols that aren't in the IO set should not be detected."""
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key) (div (str "hello") (len "world")))'
|
||||
)
|
||||
comp = env["~card"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_component_ref_not_io(self):
|
||||
"""Component references (~name) should not appear as IO refs."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~card :title "hi")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
comp = env["~page"]
|
||||
refs = _scan_io_refs_fallback(comp.body, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _transitive_io_refs_fallback — follow deps to find all IO refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransitiveIoRefs:
|
||||
def test_pure_component(self):
|
||||
env = make_env(
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~card", env, IO_NAMES)
|
||||
assert refs == set()
|
||||
|
||||
def test_direct_io(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "posts")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_transitive_io_through_dep(self):
|
||||
"""IO ref in a dependency should propagate to the parent."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/home")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_multiple_transitive_io(self):
|
||||
"""IO refs from multiple deps should be unioned."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~header) (~footer)))',
|
||||
'(defcomp ~header (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~footer (&key) (footer (config "site-name")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"app-url", "config"}
|
||||
|
||||
def test_deep_transitive_io(self):
|
||||
"""IO refs should propagate through multiple levels."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~layout)))',
|
||||
'(defcomp ~layout (&key) (div (~sidebar)))',
|
||||
'(defcomp ~sidebar (&key) (nav (fetch-data "menu")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_circular_deps_no_infinite_loop(self):
|
||||
"""Circular component references should not cause infinite recursion."""
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div (~b) (app-url "/")))',
|
||||
'(defcomp ~b (&key) (div (~a)))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~a", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_without_tilde_prefix(self):
|
||||
"""Should auto-add ~ prefix when not provided."""
|
||||
env = make_env(
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("nav", env, IO_NAMES)
|
||||
assert refs == {"app-url"}
|
||||
|
||||
def test_missing_dep_component(self):
|
||||
"""Referencing a component not in env should not crash."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~unknown) (fetch-data "x")))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert refs == {"fetch-data"}
|
||||
|
||||
def test_macro_io_detection(self):
|
||||
"""IO refs in macros should be detected too."""
|
||||
env = make_env(
|
||||
'(defmacro ~with-data (body) (list (quote div) (list (quote fetch-data) "x") body))',
|
||||
'(defcomp ~page (&key) (div (~with-data (span "hi"))))',
|
||||
)
|
||||
refs = _transitive_io_refs_fallback("~page", env, IO_NAMES)
|
||||
assert "fetch-data" in refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_all_io_refs_fallback — batch computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeAllIoRefs:
|
||||
def test_sets_io_refs_on_components(self):
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key title) (div title))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data", "app-url"}
|
||||
assert env["~nav"].io_refs == {"app-url"}
|
||||
assert env["~card"].io_refs == set()
|
||||
|
||||
def test_pure_components_get_empty_set(self):
|
||||
env = make_env(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span "world"))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~a"].io_refs == set()
|
||||
assert env["~b"].io_refs == set()
|
||||
|
||||
def test_transitive_io_via_compute_all(self):
|
||||
"""Transitive IO refs should be cached on the parent component."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~child)))',
|
||||
'(defcomp ~child (&key) (div (config "key")))',
|
||||
)
|
||||
_compute_all_io_refs_fallback(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"config"}
|
||||
assert env["~child"].io_refs == {"config"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API dispatch — compute_all_io_refs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublicApiIoRefs:
|
||||
def test_fallback_mode(self):
|
||||
"""Public API should work in fallback mode (SX_USE_REF != 1)."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x")))',
|
||||
'(defcomp ~leaf (&key) (span "pure"))',
|
||||
)
|
||||
old_val = os.environ.get("SX_USE_REF")
|
||||
try:
|
||||
os.environ.pop("SX_USE_REF", None)
|
||||
compute_all_io_refs(env, IO_NAMES)
|
||||
assert env["~page"].io_refs == {"fetch-data"}
|
||||
assert env["~leaf"].io_refs == set()
|
||||
finally:
|
||||
if old_val is not None:
|
||||
os.environ["SX_USE_REF"] = old_val
|
||||
|
||||
def test_ref_mode(self):
|
||||
"""Public API should work with bootstrapped sx_ref.py (SX_USE_REF=1)."""
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (fetch-data "x")))',
|
||||
'(defcomp ~leaf (&key) (span "pure"))',
|
||||
)
|
||||
old_val = os.environ.get("SX_USE_REF")
|
||||
try:
|
||||
os.environ["SX_USE_REF"] = "1"
|
||||
compute_all_io_refs(env, IO_NAMES)
|
||||
# sx_ref returns lists, compute_all_io_refs converts as needed
|
||||
page_refs = env["~page"].io_refs
|
||||
leaf_refs = env["~leaf"].io_refs
|
||||
# May be list or set depending on backend
|
||||
assert "fetch-data" in page_refs
|
||||
assert len(leaf_refs) == 0
|
||||
finally:
|
||||
if old_val is not None:
|
||||
os.environ["SX_USE_REF"] = old_val
|
||||
else:
|
||||
os.environ.pop("SX_USE_REF", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrapped sx_ref.py IO functions — direct testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxRefIoFunctions:
|
||||
"""Test the bootstrapped sx_ref.py IO functions directly."""
|
||||
|
||||
def test_scan_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import scan_io_refs
|
||||
env = make_env('(defcomp ~page (&key) (div (fetch-data "x") (config "y")))')
|
||||
comp = env["~page"]
|
||||
refs = scan_io_refs(comp.body, list(IO_NAMES))
|
||||
assert set(refs) == {"fetch-data", "config"}
|
||||
|
||||
def test_scan_io_refs_no_match(self):
|
||||
from shared.sx.ref.sx_ref import scan_io_refs
|
||||
env = make_env('(defcomp ~card (&key title) (div title))')
|
||||
comp = env["~card"]
|
||||
refs = scan_io_refs(comp.body, list(IO_NAMES))
|
||||
assert refs == []
|
||||
|
||||
def test_transitive_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
)
|
||||
refs = transitive_io_refs("~page", env, list(IO_NAMES))
|
||||
assert set(refs) == {"app-url"}
|
||||
|
||||
def test_transitive_io_refs_pure(self):
|
||||
from shared.sx.ref.sx_ref import transitive_io_refs
|
||||
env = make_env('(defcomp ~card (&key) (div "hi"))')
|
||||
refs = transitive_io_refs("~card", env, list(IO_NAMES))
|
||||
assert refs == []
|
||||
|
||||
def test_compute_all_io_refs(self):
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~nav) (fetch-data "x")))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~card (&key) (div "pure"))',
|
||||
)
|
||||
ref_compute(env, list(IO_NAMES))
|
||||
page_refs = env["~page"].io_refs
|
||||
nav_refs = env["~nav"].io_refs
|
||||
card_refs = env["~card"].io_refs
|
||||
assert "fetch-data" in page_refs
|
||||
assert "app-url" in page_refs
|
||||
assert "app-url" in nav_refs
|
||||
assert len(card_refs) == 0
|
||||
|
||||
def test_component_pure_p(self):
|
||||
from shared.sx.ref.sx_ref import component_pure_p
|
||||
env = make_env(
|
||||
'(defcomp ~pure-card (&key) (div "hello"))',
|
||||
'(defcomp ~io-card (&key) (div (fetch-data "x")))',
|
||||
)
|
||||
io_list = list(IO_NAMES)
|
||||
assert component_pure_p("~pure-card", env, io_list) is True
|
||||
assert component_pure_p("~io-card", env, io_list) is False
|
||||
|
||||
def test_component_pure_p_transitive(self):
|
||||
"""A component is impure if any transitive dep uses IO."""
|
||||
from shared.sx.ref.sx_ref import component_pure_p
|
||||
env = make_env(
|
||||
'(defcomp ~page (&key) (div (~child)))',
|
||||
'(defcomp ~child (&key) (div (config "key")))',
|
||||
)
|
||||
io_list = list(IO_NAMES)
|
||||
assert component_pure_p("~page", env, io_list) is False
|
||||
assert component_pure_p("~child", env, io_list) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parity: fallback vs bootstrapped produce same results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFallbackVsRefParity:
|
||||
"""Ensure fallback Python and bootstrapped sx_ref.py agree."""
|
||||
|
||||
def _check_parity(self, *sx_sources: str):
|
||||
"""Run both implementations and verify io_refs match."""
|
||||
from shared.sx.ref.sx_ref import compute_all_io_refs as ref_compute
|
||||
|
||||
# Run fallback
|
||||
env_fb = make_env(*sx_sources)
|
||||
_compute_all_io_refs_fallback(env_fb, IO_NAMES)
|
||||
|
||||
# Run bootstrapped
|
||||
env_ref = make_env(*sx_sources)
|
||||
ref_compute(env_ref, list(IO_NAMES))
|
||||
|
||||
# Compare all components
|
||||
for key in env_fb:
|
||||
if isinstance(env_fb[key], Component):
|
||||
fb_refs = env_fb[key].io_refs or set()
|
||||
ref_refs = env_ref[key].io_refs
|
||||
# Normalize: fallback returns set, ref returns list/set
|
||||
assert set(fb_refs) == set(ref_refs), (
|
||||
f"Mismatch for {key}: fallback={fb_refs}, ref={set(ref_refs)}"
|
||||
)
|
||||
|
||||
def test_parity_pure_components(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~a (&key) (div "hello"))',
|
||||
'(defcomp ~b (&key) (span (~a)))',
|
||||
)
|
||||
|
||||
def test_parity_io_components(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~page (&key) (div (~header) (fetch-data "x")))',
|
||||
'(defcomp ~header (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~footer (&key) (footer "static"))',
|
||||
)
|
||||
|
||||
def test_parity_deep_chain(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~a (&key) (div (~b)))',
|
||||
'(defcomp ~b (&key) (div (~c)))',
|
||||
'(defcomp ~c (&key) (div (config "x")))',
|
||||
)
|
||||
|
||||
def test_parity_mixed(self):
|
||||
self._check_parity(
|
||||
'(defcomp ~layout (&key) (div (~nav) (~content) (~footer)))',
|
||||
'(defcomp ~nav (&key) (nav (app-url "/")))',
|
||||
'(defcomp ~content (&key) (main "pure content"))',
|
||||
'(defcomp ~footer (&key) (footer (config "name")))',
|
||||
)
|
||||
300
shared/sx/tests/test_router.py
Normal file
300
shared/sx/tests/test_router.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Tests for the router.sx spec — client-side route matching.
|
||||
|
||||
Tests the bootstrapped Python router functions (from sx_ref.py) and
|
||||
the SX page registry serialization (from helpers.py).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from shared.sx.ref import sx_ref
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split-path-segments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSplitPathSegments:
|
||||
def test_simple(self):
|
||||
assert sx_ref.split_path_segments("/docs/hello") == ["docs", "hello"]
|
||||
|
||||
def test_root(self):
|
||||
assert sx_ref.split_path_segments("/") == []
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert sx_ref.split_path_segments("/docs/") == ["docs"]
|
||||
|
||||
def test_no_leading_slash(self):
|
||||
assert sx_ref.split_path_segments("docs/hello") == ["docs", "hello"]
|
||||
|
||||
def test_single_segment(self):
|
||||
assert sx_ref.split_path_segments("/about") == ["about"]
|
||||
|
||||
def test_deep_path(self):
|
||||
assert sx_ref.split_path_segments("/a/b/c/d") == ["a", "b", "c", "d"]
|
||||
|
||||
def test_empty(self):
|
||||
assert sx_ref.split_path_segments("") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse-route-pattern
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseRoutePattern:
|
||||
def test_literal_only(self):
|
||||
result = sx_ref.parse_route_pattern("/docs/")
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "literal"
|
||||
assert result[0]["value"] == "docs"
|
||||
|
||||
def test_param(self):
|
||||
result = sx_ref.parse_route_pattern("/docs/<slug>")
|
||||
assert len(result) == 2
|
||||
assert result[0] == {"type": "literal", "value": "docs"}
|
||||
assert result[1] == {"type": "param", "value": "slug"}
|
||||
|
||||
def test_multiple_params(self):
|
||||
result = sx_ref.parse_route_pattern("/users/<uid>/posts/<pid>")
|
||||
assert len(result) == 4
|
||||
assert result[0]["type"] == "literal"
|
||||
assert result[1] == {"type": "param", "value": "uid"}
|
||||
assert result[2]["type"] == "literal"
|
||||
assert result[3] == {"type": "param", "value": "pid"}
|
||||
|
||||
def test_root_pattern(self):
|
||||
result = sx_ref.parse_route_pattern("/")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatchRoute:
|
||||
def test_exact_match(self):
|
||||
params = sx_ref.match_route("/docs/", "/docs/")
|
||||
assert params is not None
|
||||
assert params == {}
|
||||
|
||||
def test_param_match(self):
|
||||
params = sx_ref.match_route("/docs/components", "/docs/<slug>")
|
||||
assert params is not None
|
||||
assert params == {"slug": "components"}
|
||||
|
||||
def test_no_match_different_length(self):
|
||||
result = sx_ref.match_route("/docs/a/b", "/docs/<slug>")
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_no_match_literal_mismatch(self):
|
||||
result = sx_ref.match_route("/api/hello", "/docs/<slug>")
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_root_match(self):
|
||||
params = sx_ref.match_route("/", "/")
|
||||
assert params is not None
|
||||
assert params == {}
|
||||
|
||||
def test_multiple_params(self):
|
||||
params = sx_ref.match_route("/users/42/posts/7", "/users/<uid>/posts/<pid>")
|
||||
assert params is not None
|
||||
assert params == {"uid": "42", "pid": "7"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find-matching-route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindMatchingRoute:
|
||||
def _make_routes(self, patterns):
|
||||
"""Build route entries like boot.sx does — with parsed patterns."""
|
||||
routes = []
|
||||
for name, pattern in patterns:
|
||||
route = {
|
||||
"name": name,
|
||||
"path": pattern,
|
||||
"parsed": sx_ref.parse_route_pattern(pattern),
|
||||
"has-data": False,
|
||||
"content": "(div \"test\")",
|
||||
}
|
||||
routes.append(route)
|
||||
return routes
|
||||
|
||||
def test_first_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("docs-index", "/docs/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/docs/components", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "docs-page"
|
||||
assert match["params"] == {"slug": "components"}
|
||||
|
||||
def test_exact_before_param(self):
|
||||
routes = self._make_routes([
|
||||
("docs-index", "/docs/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/docs/", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "docs-index"
|
||||
|
||||
def test_no_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("docs-page", "/docs/<slug>"),
|
||||
])
|
||||
result = sx_ref.find_matching_route("/unknown/path", routes)
|
||||
assert result is sx_ref.NIL or result is None
|
||||
|
||||
def test_root_match(self):
|
||||
routes = self._make_routes([
|
||||
("home", "/"),
|
||||
("about", "/about"),
|
||||
])
|
||||
match = sx_ref.find_matching_route("/", routes)
|
||||
assert match is not None
|
||||
assert match["name"] == "home"
|
||||
|
||||
def test_params_not_on_original(self):
|
||||
"""find-matching-route should not mutate the original route entry."""
|
||||
routes = self._make_routes([("page", "/docs/<slug>")])
|
||||
match = sx_ref.find_matching_route("/docs/test", routes)
|
||||
assert match["params"] == {"slug": "test"}
|
||||
# Original should not have params key
|
||||
assert "params" not in routes[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page registry SX serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildPagesSx:
|
||||
"""Test the SX page registry format — serialize + parse round-trip."""
|
||||
|
||||
def test_round_trip_simple(self):
|
||||
"""SX dict literal round-trips through serialize → parse."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
# Build an SX dict literal like _build_pages_sx does
|
||||
entry = (
|
||||
"{:name " + _sx_literal("home")
|
||||
+ " :path " + _sx_literal("/")
|
||||
+ " :auth " + _sx_literal("public")
|
||||
+ " :has-data false"
|
||||
+ " :content " + _sx_literal("(~home-content)")
|
||||
+ " :closure {}}"
|
||||
)
|
||||
|
||||
parsed = parse_all(entry)
|
||||
assert len(parsed) == 1
|
||||
d = parsed[0]
|
||||
assert d["name"] == "home"
|
||||
assert d["path"] == "/"
|
||||
assert d["auth"] == "public"
|
||||
assert d["has-data"] is False
|
||||
assert d["content"] == "(~home-content)"
|
||||
assert d["closure"] == {}
|
||||
|
||||
def test_round_trip_multiple(self):
|
||||
"""Multiple SX dict literals parse as a list."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entries = []
|
||||
for name, path in [("home", "/"), ("docs", "/docs/<slug>")]:
|
||||
entry = (
|
||||
"{:name " + _sx_literal(name)
|
||||
+ " :path " + _sx_literal(path)
|
||||
+ " :has-data false"
|
||||
+ " :content " + _sx_literal("(div)")
|
||||
+ " :closure {}}"
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
text = "\n".join(entries)
|
||||
parsed = parse_all(text)
|
||||
assert len(parsed) == 2
|
||||
assert parsed[0]["name"] == "home"
|
||||
assert parsed[1]["name"] == "docs"
|
||||
assert parsed[1]["path"] == "/docs/<slug>"
|
||||
|
||||
def test_content_with_quotes(self):
|
||||
"""Content expressions with quotes survive serialization."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
content = '(~doc-page :title "Hello \\"World\\"")'
|
||||
entry = (
|
||||
"{:name " + _sx_literal("test")
|
||||
+ " :content " + _sx_literal(content)
|
||||
+ " :closure {}}"
|
||||
)
|
||||
parsed = parse_all(entry)
|
||||
assert parsed[0]["content"] == content
|
||||
|
||||
def test_closure_with_values(self):
|
||||
"""Closure dict with various value types."""
|
||||
from shared.sx.helpers import _sx_literal
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entry = '{:name "test" :closure {:label "hello" :count 42 :active true}}'
|
||||
parsed = parse_all(entry)
|
||||
closure = parsed[0]["closure"]
|
||||
assert closure["label"] == "hello"
|
||||
assert closure["count"] == 42
|
||||
assert closure["active"] is True
|
||||
|
||||
def test_has_data_true(self):
|
||||
"""has-data true marks server-only pages."""
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
entry = '{:name "analyzer" :path "/data" :has-data true :content "" :closure {}}'
|
||||
parsed = parse_all(entry)
|
||||
assert parsed[0]["has-data"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sx_literal helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSxLiteral:
|
||||
def test_string(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("hello") == '"hello"'
|
||||
|
||||
def test_string_with_quotes(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal('say "hi"') == '"say \\"hi\\""'
|
||||
|
||||
def test_string_with_newline(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("line1\nline2") == '"line1\\nline2"'
|
||||
|
||||
def test_string_with_backslash(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("a\\b") == '"a\\\\b"'
|
||||
|
||||
def test_int(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(42) == "42"
|
||||
|
||||
def test_float(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(3.14) == "3.14"
|
||||
|
||||
def test_bool_true(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(True) == "true"
|
||||
|
||||
def test_bool_false(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(False) == "false"
|
||||
|
||||
def test_none(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal(None) == "nil"
|
||||
|
||||
def test_empty_string(self):
|
||||
from shared.sx.helpers import _sx_literal
|
||||
assert _sx_literal("") == '""'
|
||||
@@ -100,11 +100,13 @@
|
||||
(dict :label "CSSX" :href "/specs/cssx")
|
||||
(dict :label "Continuations" :href "/specs/continuations")
|
||||
(dict :label "call/cc" :href "/specs/callcc")
|
||||
(dict :label "Deps" :href "/specs/deps")))
|
||||
(dict :label "Deps" :href "/specs/deps")
|
||||
(dict :label "Router" :href "/specs/router")))
|
||||
|
||||
(define isomorphism-nav-items (list
|
||||
(dict :label "Roadmap" :href "/isomorphism/")
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")))
|
||||
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
|
||||
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Reader Macros" :href "/plans/reader-macros"
|
||||
@@ -175,7 +177,10 @@
|
||||
(define module-spec-items (list
|
||||
(dict :slug "deps" :filename "deps.sx" :title "Deps"
|
||||
:desc "Component dependency analysis and IO detection — per-page bundling, transitive closure, CSS scoping, pure/IO classification."
|
||||
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")))
|
||||
:prose "The deps module analyzes component dependency graphs and classifies components as pure or IO-dependent. Phase 1 (bundling): walks component AST bodies to find transitive ~component references, computes the minimal set needed per page, and collects per-page CSS classes from only the used components. Phase 2 (IO detection): scans component ASTs for references to IO primitive names (from boundary.sx declarations — frag, query, service, current-user, highlight, etc.), computes transitive IO refs through the component graph, and caches the result on each component. Components with no transitive IO refs are pure — they can render anywhere without server data. IO-dependent components must expand server-side. The spec provides the classification; each host's async partial evaluator acts on it (expand IO-dependent server-side, serialize pure for client). All functions are pure — each host bootstraps them to native code via --spec-modules deps. Platform functions (component-deps, component-set-deps!, component-css-classes, component-io-refs, component-set-io-refs!, env-components, regex-find-all, scan-css-classes) are implemented natively per target.")
|
||||
(dict :slug "router" :filename "router.sx" :title "Router"
|
||||
:desc "Client-side route matching — Flask-style pattern parsing, segment matching, route table search."
|
||||
:prose "The router module provides pure functions for matching URL paths against Flask-style route patterns (e.g. /docs/<slug>). Used by client-side routing (Phase 3) to determine if a page can be rendered locally without a server roundtrip. split-path-segments breaks a path into segments, parse-route-pattern converts patterns into typed segment descriptors, match-route-segments tests a path against a parsed pattern returning extracted params, and find-matching-route searches a route table for the first match. No platform interface needed — uses only pure string and list primitives. Bootstrapped via --spec-modules deps,router.")))
|
||||
|
||||
(define all-spec-items (concat core-spec-items (concat adapter-spec-items (concat browser-spec-items (concat extension-spec-items module-spec-items)))))
|
||||
|
||||
|
||||
@@ -615,7 +615,8 @@
|
||||
(li (strong "CSS on-demand: ") "CSSX resolves keywords to CSS rules, injects only used rules.")
|
||||
(li (strong "Boundary enforcement: ") "boundary.sx + SX_BOUNDARY_STRICT=1 validates all primitives/IO/helpers at registration.")
|
||||
(li (strong "Dependency analysis: ") "deps.sx computes per-page component bundles — only definitions a page actually uses are sent.")
|
||||
(li (strong "IO detection: ") "deps.sx classifies every component as pure or IO-dependent by scanning for boundary primitive references transitively. The spec provides the classification; each host's async evaluator acts on it — expanding IO-dependent components server-side, serializing pure ones for client rendering.")))
|
||||
(li (strong "IO detection: ") "deps.sx classifies every component as pure or IO-dependent. Server expands IO components, serializes pure ones for client.")
|
||||
(li (strong "Client-side routing: ") "router.sx matches URL patterns. Pure pages render instantly without server roundtrips. Pages with :data fall through to server transparently.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Phase 1
|
||||
@@ -747,46 +748,74 @@
|
||||
|
||||
(~doc-section :title "Phase 3: Client-Side Routing (SPA Mode)" :id "phase-3"
|
||||
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
|
||||
(p :class "text-violet-900 font-medium" "What it enables")
|
||||
(p :class "text-violet-800" "After initial page load, client resolves routes locally using cached components + data. Only hits server for fresh data or unknown routes. Like Next.js client-side navigation."))
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-4 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
|
||||
(a :href "/specs/router" :class "text-green-700 underline text-sm font-medium" "View canonical spec: router.sx")
|
||||
(a :href "/isomorphism/routing-analyzer" :class "text-green-700 underline text-sm font-medium" "Live routing analyzer"))
|
||||
(p :class "text-green-900 font-medium" "What it enables")
|
||||
(p :class "text-green-800" "After initial page load, pure pages render instantly without server roundtrips. Client matches routes locally, evaluates content expressions with cached components, and only falls back to server for pages with :data dependencies."))
|
||||
|
||||
(~doc-subsection :title "Current Mechanism"
|
||||
(p "All routing is server-side via defpage → Quart routes. Client navigates via sx-boost links doing sx-get + morphing. Every navigation = server roundtrip."))
|
||||
|
||||
(~doc-subsection :title "Approach"
|
||||
(~doc-subsection :title "Architecture"
|
||||
(p "Three-layer approach: spec defines pure route matching, page registry bridges server metadata to client, orchestration intercepts navigation for try-first/fallback.")
|
||||
|
||||
(div :class "space-y-4"
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "1. Client-side page registry")
|
||||
(p "Serialize defpage routing info to client:")
|
||||
(~doc-code :code (highlight "(script :type \"text/sx-pages\")\n;; {\"docs-page\": {\"path\": \"/docs/:slug\", \"auth\": \"public\",\n;; \"content\": \"(case slug ...)\", \"data\": null}}" "lisp")))
|
||||
(h4 :class "font-semibold text-stone-700" "1. Route matching spec (router.sx)")
|
||||
(p "New spec module with pure functions for Flask-style route pattern matching:")
|
||||
(~doc-code :code (highlight "(define split-path-segments ;; \"/docs/hello\" → (\"docs\" \"hello\")\n(define parse-route-pattern ;; \"/docs/<slug>\" → segment descriptors\n(define match-route-segments ;; segments + pattern → params dict or nil\n(define find-matching-route ;; path + route table → first match" "lisp"))
|
||||
(p "No platform interface needed — uses only pure string and list primitives. Bootstrapped to both hosts via " (code "--spec-modules deps,router") "."))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "2. Client route matcher")
|
||||
(p "New spec file shared/sx/ref/router.sx — convert /docs/<slug> patterns to matchers. On boost-link click: match URL → if found and pure, evaluate locally. If IO needed: fetch data from server, evaluate content locally. No match: fall through to standard fetch."))
|
||||
(h4 :class "font-semibold text-stone-700" "2. Page registry")
|
||||
(p "Server serializes defpage metadata as SX dict literals inside " (code "<script type=\"text/sx-pages\">") ". Each entry carries name, path pattern, auth level, has-data flag, serialized content expression, and closure values.")
|
||||
(~doc-code :code (highlight "{:name \"docs-page\" :path \"/docs/<slug>\"\n :auth \"public\" :has-data false\n :content \"(case slug ...)\" :closure {}}" "lisp"))
|
||||
(p "boot.sx processes these at startup using the SX parser — the same " (code "parse") " function from parser.sx — building route entries with parsed patterns into the " (code "_page-routes") " table. No JSON dependency."))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "3. Data endpoint")
|
||||
(~doc-code :code (highlight "GET /internal/page-data/<page-name>?<params>\n# Returns JSON with evaluated :data expression\n# Reuses execute_page() logic, stops after :data step" "python")))
|
||||
(h4 :class "font-semibold text-stone-700" "3. Client-side interception (orchestration.sx)")
|
||||
(p (code "bind-client-route-link") " replaces " (code "bind-boost-link") " in boost processing. On click:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Extract pathname from href")
|
||||
(li "Call " (code "find-matching-route") " against " (code "_page-routes"))
|
||||
(li "If match found AND no :data: evaluate content expression locally with component env + URL params")
|
||||
(li "If evaluation succeeds: swap into #main-panel, pushState, log " (code "\"sx:route client /path\""))
|
||||
(li "If anything fails (no match, has data, eval error): transparent fallback to server fetch"))
|
||||
(p (code "handle-popstate") " also tries client routing before server fetch on back/forward."))))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "4. Layout caching")
|
||||
(p "Layouts depend on auth/fragments, so cache current layout and reuse across navigations. SX-Layout-Hash header tracks staleness."))
|
||||
(~doc-subsection :title "What becomes client-routable"
|
||||
(p "Pages WITHOUT " (code ":data") " that have pure content expressions — most of this docs app:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code "/") ", " (code "/docs/") ", " (code "/docs/<slug>") " (most slugs), " (code "/protocols/") ", " (code "/protocols/<slug>"))
|
||||
(li (code "/examples/") ", " (code "/examples/<slug>") ", " (code "/essays/") ", " (code "/essays/<slug>"))
|
||||
(li (code "/plans/") ", " (code "/plans/<slug>") ", " (code "/isomorphism/") ", " (code "/bootstrappers/")))
|
||||
(p "Pages that fall through to server:")
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code "/docs/primitives") " and " (code "/docs/special-forms") " (call " (code "primitives-data") " / " (code "special-forms-data") " helpers)")
|
||||
(li (code "/reference/<slug>") " (has " (code ":data (reference-data slug)") ")")
|
||||
(li (code "/bootstrappers/<slug>") " (has " (code ":data (bootstrapper-data slug)") ")")
|
||||
(li (code "/isomorphism/bundle-analyzer") " (has " (code ":data (bundle-analyzer-data)") ")")))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "5. Integration with orchestration.sx")
|
||||
(p "Intercept bind-boost-link to try client-side resolution first."))))
|
||||
(~doc-subsection :title "Try-first/fallback design"
|
||||
(p "Client routing uses a try-first approach: attempt local evaluation in a try/catch, fall back to server fetch on any failure. This avoids needing perfect static analysis of content expressions — if a content expression calls a page helper the client doesn't have, the eval throws, and the server handles it transparently.")
|
||||
(p "Console messages provide visibility: " (code "sx:route client /essays/why-sexps") " vs " (code "sx:route server /specs/eval") "."))
|
||||
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
|
||||
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 1 (client knows which components each page needs), Phase 2 (which pages are pure vs IO)."))
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/ref/router.sx — route pattern matching spec")
|
||||
(li "shared/sx/ref/boot.sx — process page registry scripts")
|
||||
(li "shared/sx/ref/orchestration.sx — client route interception")
|
||||
(li "shared/sx/ref/bootstrap_js.py — router spec module + platform functions")
|
||||
(li "shared/sx/ref/bootstrap_py.py — router spec module (parity)")
|
||||
(li "shared/sx/helpers.py — page registry SX serialization")))
|
||||
|
||||
(~doc-subsection :title "Verification"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "Pure page navigation: zero server requests")
|
||||
(li "IO page navigation: exactly one data request (not full page fetch)")
|
||||
(li "Browser back/forward works with client-resolved routes")
|
||||
(li "Disabling client registry → identical behavior to current"))))
|
||||
(li "Pure page navigation: zero server requests, console shows \"sx:route client\"")
|
||||
(li "IO/data page fallback: falls through to server fetch transparently")
|
||||
(li "Browser back/forward works with client-routed pages")
|
||||
(li "Disabling page registry → identical behavior to before")
|
||||
(li "Bootstrap parity: sx_ref.py and sx-ref.js both contain router functions"))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Phase 4
|
||||
|
||||
96
sx/sx/routing-analyzer.sx
Normal file
96
sx/sx/routing-analyzer.sx
Normal file
@@ -0,0 +1,96 @@
|
||||
;; Routing analyzer — live demonstration of client-side routing classification.
|
||||
;; Shows which pages route client-side (pure, instant) vs server-side (IO/data).
|
||||
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-2 bg-blue-100 text-blue-800 bg-amber-100 text-amber-800 grid-cols-4 marker:text-stone-400 bg-blue-50 bg-amber-50 text-blue-700 text-amber-700 border-blue-200 border-amber-200 bg-blue-500 bg-amber-500 grid-cols-3 border-green-200 bg-green-50 text-green-700
|
||||
|
||||
(defcomp ~routing-analyzer-content (&key pages total-pages client-count
|
||||
server-count registry-sample)
|
||||
(~doc-page :title "Routing Analyzer"
|
||||
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"Live classification of all " (strong (str total-pages)) " pages by routing mode. "
|
||||
"Pages without " (code ":data") " dependencies are "
|
||||
(span :class "text-green-700 font-medium" "client-routable")
|
||||
" — after initial load they render instantly from the page registry without a server roundtrip. "
|
||||
"Pages with data dependencies fall back to "
|
||||
(span :class "text-amber-700 font-medium" "server fetch")
|
||||
" transparently. Powered by "
|
||||
(a :href "/specs/router" :class "text-violet-700 underline" "router.sx")
|
||||
" route matching and "
|
||||
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
|
||||
" IO detection.")
|
||||
|
||||
(div :class "mb-8 grid grid-cols-4 gap-4"
|
||||
(~analyzer-stat :label "Total Pages" :value (str total-pages)
|
||||
:cls "text-violet-600")
|
||||
(~analyzer-stat :label "Client-Routable" :value (str client-count)
|
||||
:cls "text-green-600")
|
||||
(~analyzer-stat :label "Server-Only" :value (str server-count)
|
||||
:cls "text-amber-600")
|
||||
(~analyzer-stat :label "Client Ratio" :value (str (round (* (/ client-count total-pages) 100)) "%")
|
||||
:cls "text-blue-600"))
|
||||
|
||||
;; Route classification bar
|
||||
(div :class "mb-8"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "text-sm font-medium text-stone-600" "Client")
|
||||
(div :class "flex-1")
|
||||
(span :class "text-sm font-medium text-stone-600" "Server"))
|
||||
(div :class "w-full bg-amber-200 rounded-full h-4 overflow-hidden"
|
||||
(div :class "bg-green-500 h-4 rounded-l-full transition-all"
|
||||
:style (str "width: " (round (* (/ client-count total-pages) 100)) "%"))))
|
||||
|
||||
(~doc-section :title "Route Table" :id "routes"
|
||||
(div :class "space-y-2"
|
||||
(map (fn (page)
|
||||
(~routing-row
|
||||
:name (get page "name")
|
||||
:path (get page "path")
|
||||
:mode (get page "mode")
|
||||
:has-data (get page "has-data")
|
||||
:content-expr (get page "content-expr")
|
||||
:reason (get page "reason")))
|
||||
pages)))
|
||||
|
||||
(~doc-section :title "Page Registry Format" :id "registry"
|
||||
(p :class "text-stone-600 mb-4"
|
||||
"The server serializes page metadata as SX dict literals inside "
|
||||
(code "<script type=\"text/sx-pages\">")
|
||||
". The client's parser reads these at boot, building a route table with parsed URL patterns. "
|
||||
"No JSON involved — the same SX parser handles everything.")
|
||||
(when (not (empty? registry-sample))
|
||||
(div :class "not-prose"
|
||||
(pre :class "text-xs leading-relaxed whitespace-pre-wrap overflow-x-auto bg-stone-100 rounded border border-stone-200 p-4"
|
||||
(code (highlight registry-sample "lisp"))))))
|
||||
|
||||
(~doc-section :title "How Client Routing Works" :id "how"
|
||||
(ol :class "list-decimal pl-5 space-y-2 text-stone-700"
|
||||
(li (strong "Boot: ") "boot.sx finds " (code "<script type=\"text/sx-pages\">") ", calls " (code "parse") " on the SX content, then " (code "parse-route-pattern") " on each page's path to build " (code "_page-routes") ".")
|
||||
(li (strong "Click: ") "orchestration.sx intercepts boost link clicks via " (code "bind-client-route-link") ". Extracts the pathname from the href.")
|
||||
(li (strong "Match: ") (code "find-matching-route") " from router.sx tests the pathname against all parsed patterns. Returns the first match with extracted URL params.")
|
||||
(li (strong "Check: ") "If the matched page has " (code ":has-data true") ", skip to server fetch. Otherwise proceed to client eval.")
|
||||
(li (strong "Eval: ") (code "try-eval-content") " merges the component env + URL params + closure, then parses and renders the content expression to DOM.")
|
||||
(li (strong "Swap: ") "On success, the rendered DOM replaces " (code "#main-panel") " contents, " (code "pushState") " updates the URL, and the console logs " (code "sx:route client /path") ".")
|
||||
(li (strong "Fallback: ") "If anything fails (no match, eval error, missing component), the click falls through to a standard server fetch. Console logs " (code "sx:route server /path") ". The user sees no difference.")))))
|
||||
|
||||
(defcomp ~routing-row (&key name path mode has-data content-expr reason)
|
||||
(div :class (str "rounded border p-3 flex items-center gap-3 "
|
||||
(if (= mode "client")
|
||||
"border-green-200 bg-green-50"
|
||||
"border-amber-200 bg-amber-50"))
|
||||
;; Mode badge
|
||||
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
|
||||
(if (= mode "client")
|
||||
"bg-green-600 text-white"
|
||||
"bg-amber-500 text-white"))
|
||||
mode)
|
||||
;; Page info
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "flex items-center gap-2"
|
||||
(span :class "font-mono font-semibold text-stone-800 text-sm" name)
|
||||
(span :class "text-stone-400 text-xs font-mono" path))
|
||||
(when reason
|
||||
(div :class "text-xs text-stone-500 mt-0.5" reason)))
|
||||
;; Content expression
|
||||
(when content-expr
|
||||
(div :class "hidden md:block max-w-xs truncate"
|
||||
(code :class "text-xs text-stone-500" content-expr)))))
|
||||
@@ -182,7 +182,8 @@ continuations.sx depends on: eval (optional)
|
||||
callcc.sx depends on: eval (optional)
|
||||
|
||||
;; Spec modules (optional — loaded via --spec-modules)
|
||||
deps.sx depends on: eval (optional)")))
|
||||
deps.sx depends on: eval (optional)
|
||||
router.sx (standalone — pure string/list ops)")))
|
||||
|
||||
(div :class "space-y-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Extensions")
|
||||
|
||||
@@ -416,6 +416,9 @@
|
||||
"bundle-analyzer" (~bundle-analyzer-content
|
||||
:pages pages :total-components total-components :total-macros total-macros
|
||||
:pure-count pure-count :io-count io-count)
|
||||
"routing-analyzer" (~routing-analyzer-content
|
||||
:pages pages :total-pages total-pages :client-count client-count
|
||||
:server-count server-count :registry-sample registry-sample)
|
||||
:else (~plan-isomorphic-content)))
|
||||
|
||||
(defpage bundle-analyzer
|
||||
@@ -432,6 +435,20 @@
|
||||
:pages pages :total-components total-components :total-macros total-macros
|
||||
:pure-count pure-count :io-count io-count))
|
||||
|
||||
(defpage routing-analyzer
|
||||
:path "/isomorphism/routing-analyzer"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Routing Analyzer")
|
||||
:selected "Routing Analyzer")
|
||||
:data (routing-analyzer-data)
|
||||
:content (~routing-analyzer-content
|
||||
:pages pages :total-pages total-pages :client-count client-count
|
||||
:server-count server-count :registry-sample registry-sample))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Plans section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,7 @@ def _register_sx_helpers() -> None:
|
||||
"read-spec-file": _read_spec_file,
|
||||
"bootstrapper-data": _bootstrapper_data,
|
||||
"bundle-analyzer-data": _bundle_analyzer_data,
|
||||
"routing-analyzer-data": _routing_analyzer_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -342,6 +343,82 @@ def _bundle_analyzer_data() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _routing_analyzer_data() -> dict:
|
||||
"""Compute per-page routing classification for the sx-docs app."""
|
||||
from shared.sx.pages import get_all_pages
|
||||
from shared.sx.parser import serialize as sx_serialize
|
||||
from shared.sx.helpers import _sx_literal
|
||||
|
||||
pages_data = []
|
||||
full_content: list[tuple[str, str, bool]] = [] # (name, full_content, has_data)
|
||||
client_count = 0
|
||||
server_count = 0
|
||||
|
||||
for name, page_def in sorted(get_all_pages("sx").items()):
|
||||
has_data = page_def.data_expr is not None
|
||||
content_src = ""
|
||||
if page_def.content_expr is not None:
|
||||
try:
|
||||
content_src = sx_serialize(page_def.content_expr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
full_content.append((name, content_src, has_data))
|
||||
|
||||
# Determine routing mode and reason
|
||||
if has_data:
|
||||
mode = "server"
|
||||
reason = "Has :data expression — needs server IO"
|
||||
server_count += 1
|
||||
elif not content_src:
|
||||
mode = "server"
|
||||
reason = "No content expression"
|
||||
server_count += 1
|
||||
else:
|
||||
mode = "client"
|
||||
reason = ""
|
||||
client_count += 1
|
||||
|
||||
pages_data.append({
|
||||
"name": name,
|
||||
"path": page_def.path,
|
||||
"mode": mode,
|
||||
"has-data": has_data,
|
||||
"content-expr": content_src[:80] + ("..." if len(content_src) > 80 else ""),
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
# Sort: client pages first, then server
|
||||
pages_data.sort(key=lambda p: (0 if p["mode"] == "client" else 1, p["name"]))
|
||||
|
||||
# Build a sample of the SX page registry format (use full content, first 3)
|
||||
total = client_count + server_count
|
||||
sample_entries = []
|
||||
sorted_full = sorted(full_content, key=lambda x: x[0])
|
||||
for name, csrc, hd in sorted_full[:3]:
|
||||
page_def = get_all_pages("sx").get(name)
|
||||
if not page_def:
|
||||
continue
|
||||
entry = (
|
||||
"{:name " + _sx_literal(name)
|
||||
+ "\n :path " + _sx_literal(page_def.path)
|
||||
+ "\n :auth " + _sx_literal("public")
|
||||
+ " :has-data " + ("true" if hd else "false")
|
||||
+ "\n :content " + _sx_literal(csrc)
|
||||
+ "\n :closure {}}"
|
||||
)
|
||||
sample_entries.append(entry)
|
||||
registry_sample = "\n\n".join(sample_entries)
|
||||
|
||||
return {
|
||||
"pages": pages_data,
|
||||
"total-pages": total,
|
||||
"client-count": client_count,
|
||||
"server-count": server_count,
|
||||
"registry-sample": registry_sample,
|
||||
}
|
||||
|
||||
|
||||
def _attr_detail_data(slug: str) -> dict:
|
||||
"""Return attribute detail data for a specific attribute slug.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user