diff --git a/blog/queries.sx b/blog/queries.sx index e03c4a1..56a1b43 100644 --- a/blog/queries.sx +++ b/blog/queries.sx @@ -10,7 +10,8 @@ (defquery posts-by-ids (&key ids) "Fetch multiple blog posts by comma-separated IDs." - (service "blog" "get-posts-by-ids" :ids (split-ids ids))) + (service "blog" "get-posts-by-ids" + :ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ","))))) (defquery search-posts (&key query page per-page) "Search blog posts by text query, paginated." @@ -35,4 +36,5 @@ (defquery page-configs-batch (&key container-type ids) "Return PageConfigs for multiple container IDs (comma-separated)." (service "page-config" "get-batch" - :container-type container-type :ids (split-ids ids))) + :container-type container-type + :ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ","))))) diff --git a/market/queries.sx b/market/queries.sx index a2d2def..36adcad 100644 --- a/market/queries.sx +++ b/market/queries.sx @@ -7,8 +7,10 @@ (defquery products-by-ids (&key ids) "Return product details for comma-separated IDs." - (service "market-data" "products-by-ids" :ids (split-ids ids))) + (service "market-data" "products-by-ids" + :ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ","))))) (defquery marketplaces-by-ids (&key ids) "Return marketplace data for comma-separated IDs." - (service "market-data" "marketplaces-by-ids" :ids (split-ids ids))) + (service "market-data" "marketplaces-by-ids" + :ids (map parse-int (filter (fn (s) (not (empty? s))) (split (str ids) ","))))) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 54e95ad..f08ed77 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -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,25 +228,48 @@ 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 + + // core.predicates + PRIMITIVES["nil?"] = isNil; + PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; + PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; + PRIMITIVES["list?"] = Array.isArray; + PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; + PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; + PRIMITIVES["contains?"] = function(c, k) { + if (typeof c === "string") return c.indexOf(String(k)) !== -1; + if (Array.isArray(c)) return c.indexOf(k) !== -1; + return k in c; + }; + PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; + PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; + PRIMITIVES["zero?"] = function(n) { return n === 0; }; + + + // core.strings PRIMITIVES["str"] = function() { var p = []; for (var i = 0; i < arguments.length; i++) { @@ -263,28 +288,12 @@ 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]); + for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]); return out; }; - PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); }; - // Predicates - PRIMITIVES["nil?"] = isNil; - PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; - PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; - PRIMITIVES["list?"] = Array.isArray; - PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; - PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; - PRIMITIVES["contains?"] = function(c, k) { - if (typeof c === "string") return c.indexOf(String(k)) !== -1; - if (Array.isArray(c)) return c.indexOf(k) !== -1; - return k in c; - }; - PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; - PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; - PRIMITIVES["zero?"] = function(n) { return n === 0; }; - // Collections + // core.collections PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; PRIMITIVES["dict"] = function() { var d = {}; @@ -304,6 +313,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 +339,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,"""); - }; PRIMITIVES["format-date"] = function(s, fmt) { if (!s) return ""; try { @@ -357,12 +363,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,"'"); + }; + 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 +398,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 +531,25 @@ return NIL; } + // ========================================================================= + // Platform interface — Parser + // ========================================================================= + // Character classification derived from the grammar: + // ident-start → [a-zA-Z_~*+\-><=/!?&] + // ident-char → ident-start + [0-9.:\/\[\]#,] + + var _identStartRe = /[a-zA-Z_~*+\-><=/!?&]/; + var _identCharRe = /[a-zA-Z0-9_~*+\-><=/!?.:&/\[\]#,]/; + + function isIdentStart(ch) { return _identStartRe.test(ch); } + function isIdentChar(ch) { return _identCharRe.test(ch); } + function parseNumber(s) { return Number(s); } + function escapeString(s) { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t"); + } + function sxExprSource(e) { return typeof e === "string" ? e : String(e); } + + // === Transpiled from eval === // trampoline @@ -878,6 +920,265 @@ })(); }, keys(attrs))); }; + // === Transpiled from parser === + + // sx-parse + var sxParse = function(source) { return (function() { + var pos = 0; + var lenSrc = len(source); + var skipComment = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && !(nth(source, pos) == "\n")))) { pos = (pos + 1); +continue; } else { return NIL; } } }; + var skipWs = function() { while(true) { if (isSxTruthy((pos < lenSrc))) { { var ch = nth(source, pos); +if (isSxTruthy(sxOr((ch == " "), (ch == "\t"), (ch == "\n"), (ch == "\\r")))) { pos = (pos + 1); +continue; } else if (isSxTruthy((ch == ";"))) { pos = (pos + 1); +skipComment(); +continue; } else { return NIL; } } } else { return NIL; } } }; + var readString = function() { pos = (pos + 1); +return (function() { + var buf = ""; + var readStrLoop = function() { while(true) { if (isSxTruthy((pos >= lenSrc))) { return error("Unterminated string"); } else { { var ch = nth(source, pos); +if (isSxTruthy((ch == "\""))) { pos = (pos + 1); +return NIL; } else if (isSxTruthy((ch == "\\"))) { pos = (pos + 1); +{ var esc = nth(source, pos); +buf = (String(buf) + String((isSxTruthy((esc == "n")) ? "\n" : (isSxTruthy((esc == "t")) ? "\t" : (isSxTruthy((esc == "r")) ? "\\r" : esc))))); +pos = (pos + 1); +continue; } } else { buf = (String(buf) + String(ch)); +pos = (pos + 1); +continue; } } } } }; + readStrLoop(); + return buf; +})(); }; + var readIdent = function() { return (function() { + var start = pos; + var readIdentLoop = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && isIdentChar(nth(source, pos))))) { pos = (pos + 1); +continue; } else { return NIL; } } }; + readIdentLoop(); + return slice(source, start, pos); +})(); }; + var readKeyword = function() { pos = (pos + 1); +return makeKeyword(readIdent()); }; + var readNumber = function() { return (function() { + var start = pos; + if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == "-")))) { + pos = (pos + 1); +} + var readDigits = function() { while(true) { if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (function() { + var c = nth(source, pos); + return (isSxTruthy((c >= "0")) && (c <= "9")); +})()))) { pos = (pos + 1); +continue; } else { return NIL; } } }; + readDigits(); + if (isSxTruthy((isSxTruthy((pos < lenSrc)) && (nth(source, pos) == ".")))) { + pos = (pos + 1); + readDigits(); +} + if (isSxTruthy((isSxTruthy((pos < lenSrc)) && sxOr((nth(source, pos) == "e"), (nth(source, pos) == "E"))))) { + pos = (pos + 1); + if (isSxTruthy((isSxTruthy((pos < lenSrc)) && sxOr((nth(source, pos) == "+"), (nth(source, pos) == "-"))))) { + pos = (pos + 1); +} + readDigits(); +} + return parseNumber(slice(source, start, pos)); +})(); }; + var readSymbol = function() { return (function() { + var name = readIdent(); + return (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : makeSymbol(name)))); +})(); }; + var readList = function(closeCh) { return (function() { + var items = []; + var readListLoop = function() { while(true) { skipWs(); +if (isSxTruthy((pos >= lenSrc))) { return error("Unterminated list"); } else { if (isSxTruthy((nth(source, pos) == closeCh))) { pos = (pos + 1); +return NIL; } else { items.push(readExpr()); +continue; } } } }; + readListLoop(); + return items; +})(); }; + var readMap = function() { return (function() { + var result = {}; + var readMapLoop = function() { while(true) { skipWs(); +if (isSxTruthy((pos >= lenSrc))) { return error("Unterminated map"); } else { if (isSxTruthy((nth(source, pos) == "}"))) { pos = (pos + 1); +return NIL; } else { { var keyExpr = readExpr(); +var keyStr = (isSxTruthy((typeOf(keyExpr) == "keyword")) ? keywordName(keyExpr) : (String(keyExpr))); +var valExpr = readExpr(); +result[keyStr] = valExpr; +continue; } } } } }; + readMapLoop(); + return result; +})(); }; + var readExpr = function() { skipWs(); +return (isSxTruthy((pos >= lenSrc)) ? error("Unexpected end of input") : (function() { + var ch = nth(source, pos); + 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)))))))))))); +})()); }; + return (function() { + var exprs = []; + var parseLoop = function() { while(true) { skipWs(); +if (isSxTruthy((pos < lenSrc))) { exprs.push(readExpr()); +continue; } else { return NIL; } } }; + parseLoop(); + return exprs; +})(); +})(); }; + + // sx-serialize + var sxSerialize = function(val) { return (function() { var _m = typeOf(val); if (_m == "nil") return "nil"; if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "number") return (String(val)); if (_m == "string") return (String("\"") + String(escapeString(val)) + String("\"")); if (_m == "symbol") return symbolName(val); if (_m == "keyword") return (String(":") + String(keywordName(val))); if (_m == "list") return (String("(") + String(join(" ", map(sxSerialize, val))) + String(")")); if (_m == "dict") return sxSerializeDict(val); if (_m == "sx-expr") return sxExprSource(val); return (String(val)); })(); }; + + // sx-serialize-dict + 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(""))))); +})(); }; + + + // === 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 @@ -1043,6 +1344,900 @@ })(); }; + // === Transpiled from engine === + + // ENGINE_VERBS + var ENGINE_VERBS = ["get", "post", "put", "delete", "patch"]; + + // DEFAULT_SWAP + var DEFAULT_SWAP = "outerHTML"; + + // parse-time + var parseTime = function(s) { return (isSxTruthy(isNil(s)) ? 0 : (isSxTruthy(endsWith(s, "ms")) ? parseInt_(s, 0) : (isSxTruthy(endsWith(s, "s")) ? (parseInt_(replace_(s, "s", ""), 0) * 1000) : parseInt_(s, 0)))); }; + + // parse-trigger-spec + var parseTriggerSpec = function(spec) { return (isSxTruthy(isNil(spec)) ? NIL : (function() { + var rawParts = split(spec, ","); + return filter(function(x) { return !isNil(x); }, map(function(part) { return (function() { + var tokens = split(trim(part), " "); + return (isSxTruthy(isEmpty(tokens)) ? NIL : (isSxTruthy((isSxTruthy((first(tokens) == "every")) && (len(tokens) >= 2))) ? {["event"]: "every", ["modifiers"]: {["interval"]: parseTime(nth(tokens, 1))}} : (function() { + var mods = {}; + { var _c = rest(tokens); for (var _i = 0; _i < _c.length; _i++) { var tok = _c[_i]; (isSxTruthy((tok == "once")) ? dictSet(mods, "once", true) : (isSxTruthy((tok == "changed")) ? dictSet(mods, "changed", true) : (isSxTruthy(startsWith(tok, "delay:")) ? dictSet(mods, "delay", parseTime(slice(tok, 6))) : (isSxTruthy(startsWith(tok, "from:")) ? dictSet(mods, "from", slice(tok, 5)) : NIL)))); } } + return {["event"]: first(tokens), ["modifiers"]: mods}; +})())); +})(); }, rawParts)); +})()); }; + + // default-trigger + var defaultTrigger = function(tagName) { return (isSxTruthy((tagName == "FORM")) ? [{["event"]: "submit", ["modifiers"]: {}}] : (isSxTruthy(sxOr((tagName == "INPUT"), (tagName == "SELECT"), (tagName == "TEXTAREA"))) ? [{["event"]: "change", ["modifiers"]: {}}] : [{["event"]: "click", ["modifiers"]: {}}])); }; + + // get-verb-info + var getVerbInfo = function(el) { return some(function(verb) { return (function() { + var url = domGetAttr(el, (String("sx-") + String(verb))); + return (isSxTruthy(url) ? {["method"]: upper(verb), ["url"]: url} : NIL); +})(); }, ENGINE_VERBS); }; + + // build-request-headers + var buildRequestHeaders = function(el, loadedComponents, cssHash) { return (function() { + var headers = {["SX-Request"]: "true", ["SX-Current-URL"]: browserLocationHref()}; + (function() { + var targetSel = domGetAttr(el, "sx-target"); + return (isSxTruthy(targetSel) ? dictSet(headers, "SX-Target", targetSel) : NIL); +})(); + if (isSxTruthy(!isEmpty(loadedComponents))) { + headers["SX-Components"] = join(",", loadedComponents); +} + if (isSxTruthy(cssHash)) { + headers["SX-Css"] = cssHash; +} + (function() { + var extraH = domGetAttr(el, "sx-headers"); + return (isSxTruthy(extraH) ? (function() { + var parsed = parseHeaderValue(extraH); + return (isSxTruthy(parsed) ? forEach(function(key) { return dictSet(headers, key, (String(get(parsed, key)))); }, keys(parsed)) : NIL); +})() : NIL); +})(); + return headers; +})(); }; + + // process-response-headers + var processResponseHeaders = function(getHeader) { return {["redirect"]: getHeader("SX-Redirect"), ["refresh"]: getHeader("SX-Refresh"), ["trigger"]: getHeader("SX-Trigger"), ["retarget"]: getHeader("SX-Retarget"), ["reswap"]: getHeader("SX-Reswap"), ["location"]: getHeader("SX-Location"), ["replace-url"]: getHeader("SX-Replace-Url"), ["css-hash"]: getHeader("SX-Css-Hash"), ["trigger-swap"]: getHeader("SX-Trigger-After-Swap"), ["trigger-settle"]: getHeader("SX-Trigger-After-Settle"), ["content-type"]: getHeader("Content-Type")}; }; + + // parse-swap-spec + var parseSwapSpec = function(rawSwap, globalTransitions_p) { return (function() { + var parts = split(sxOr(rawSwap, DEFAULT_SWAP), " "); + var style = first(parts); + var useTransition = globalTransitions_p; + { var _c = rest(parts); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; (isSxTruthy((p == "transition:true")) ? (useTransition = true) : (isSxTruthy((p == "transition:false")) ? (useTransition = false) : NIL)); } } + return {["style"]: style, ["transition"]: useTransition}; +})(); }; + + // parse-retry-spec + var parseRetrySpec = function(retryAttr) { return (isSxTruthy(isNil(retryAttr)) ? NIL : (function() { + var parts = split(retryAttr, ":"); + return {["strategy"]: first(parts), ["start-ms"]: parseInt_(nth(parts, 1), 1000), ["cap-ms"]: parseInt_(nth(parts, 2), 30000)}; +})()); }; + + // next-retry-ms + var nextRetryMs = function(currentMs, capMs) { return min((currentMs * 2), capMs); }; + + // filter-params + var filterParams = function(paramsSpec, allParams) { return (isSxTruthy(isNil(paramsSpec)) ? allParams : (isSxTruthy((paramsSpec == "none")) ? [] : (isSxTruthy((paramsSpec == "*")) ? allParams : (isSxTruthy(startsWith(paramsSpec, "not ")) ? (function() { + var excluded = map(trim, split(slice(paramsSpec, 4), ",")); + return filter(function(p) { return !contains(excluded, first(p)); }, allParams); +})() : (function() { + var allowed = map(trim, split(paramsSpec, ",")); + return filter(function(p) { return contains(allowed, first(p)); }, allParams); +})())))); }; + + // resolve-target + var resolveTarget = function(el) { return (function() { + var sel = domGetAttr(el, "sx-target"); + return (isSxTruthy(sxOr(isNil(sel), (sel == "this"))) ? el : (isSxTruthy((sel == "closest")) ? domParent(el) : domQuery(sel))); +})(); }; + + // apply-optimistic + var applyOptimistic = function(el) { return (function() { + var directive = domGetAttr(el, "sx-optimistic"); + return (isSxTruthy(isNil(directive)) ? NIL : (function() { + var target = sxOr(resolveTarget(el), el); + var state = {["target"]: target, ["directive"]: directive}; + (isSxTruthy((directive == "remove")) ? (dictSet(state, "opacity", domGetStyle(target, "opacity")), domSetStyle(target, "opacity", "0"), domSetStyle(target, "pointer-events", "none")) : (isSxTruthy((directive == "disable")) ? (dictSet(state, "disabled", domGetProp(target, "disabled")), domSetProp(target, "disabled", true)) : (isSxTruthy(startsWith(directive, "add-class:")) ? (function() { + var cls = slice(directive, 10); + state["add-class"] = cls; + return domAddClass(target, cls); +})() : NIL))); + return state; +})()); +})(); }; + + // revert-optimistic + var revertOptimistic = function(state) { return (isSxTruthy(state) ? (function() { + var target = get(state, "target"); + var directive = get(state, "directive"); + return (isSxTruthy((directive == "remove")) ? (domSetStyle(target, "opacity", sxOr(get(state, "opacity"), "")), domSetStyle(target, "pointer-events", "")) : (isSxTruthy((directive == "disable")) ? domSetProp(target, "disabled", sxOr(get(state, "disabled"), false)) : (isSxTruthy(get(state, "add-class")) ? domRemoveClass(target, get(state, "add-class")) : NIL))); +})() : NIL); }; + + // find-oob-swaps + var findOobSwaps = function(container) { return (function() { + var results = []; + { var _c = ["sx-swap-oob", "hx-swap-oob"]; for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() { + var oobEls = domQueryAll(container, (String("[") + String(attr) + String("]"))); + return forEach(function(oob) { return (function() { + var swapType = sxOr(domGetAttr(oob, attr), "outerHTML"); + var targetId = domId(oob); + domRemoveAttr(oob, attr); + return (isSxTruthy(targetId) ? append_b(results, {["element"]: oob, ["swap-type"]: swapType, ["target-id"]: targetId}) : NIL); +})(); }, oobEls); +})(); } } + return results; +})(); }; + + // morph-node + var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!(domNodeType(oldNode) == domNodeType(newNode)), !(domNodeName(oldNode) == domNodeName(newNode)))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!(domTextContent(oldNode) == domTextContent(newNode))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!(isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); }; + + // sync-attrs + var syncAttrs = function(oldEl, newEl) { { var _c = domAttrList(newEl); for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() { + var name = first(attr); + var val = nth(attr, 1); + return (isSxTruthy(!(domGetAttr(oldEl, name) == val)) ? domSetAttr(oldEl, name, val) : NIL); +})(); } } +return forEach(function(attr) { return (isSxTruthy(!domHasAttr(newEl, first(attr))) ? domRemoveAttr(oldEl, first(attr)) : NIL); }, domAttrList(oldEl)); }; + + // morph-children + var morphChildren = function(oldParent, newParent) { return (function() { + var oldKids = domChildList(oldParent); + var newKids = domChildList(newParent); + var oldById = reduce(function(acc, kid) { return (function() { + var id = domId(kid); + return (isSxTruthy(id) ? (dictSet(acc, id, kid), acc) : acc); +})(); }, {}, oldKids); + var oi = 0; + { var _c = newKids; for (var _i = 0; _i < _c.length; _i++) { var newChild = _c[_i]; (function() { + var matchId = domId(newChild); + var matchById = (isSxTruthy(matchId) ? dictGet(oldById, matchId) : NIL); + return (isSxTruthy((isSxTruthy(matchById) && !isNil(matchById))) ? ((isSxTruthy((isSxTruthy((oi < len(oldKids))) && !(matchById == nth(oldKids, oi)))) ? domInsertBefore(oldParent, matchById, (isSxTruthy((oi < len(oldKids))) ? nth(oldKids, oi) : NIL)) : NIL), morphNode(matchById, newChild), (oi = (oi + 1))) : (isSxTruthy((oi < len(oldKids))) ? (function() { + var oldChild = nth(oldKids, oi); + return (isSxTruthy((isSxTruthy(domId(oldChild)) && !matchId)) ? domInsertBefore(oldParent, domClone(newChild), oldChild) : (morphNode(oldChild, newChild), (oi = (oi + 1)))); +})() : domAppend(oldParent, domClone(newChild)))); +})(); } } + return forEach(function(i) { return (isSxTruthy((i >= oi)) ? (function() { + var leftover = nth(oldKids, i); + return (isSxTruthy((isSxTruthy(domIsChildOf(leftover, oldParent)) && isSxTruthy(!domHasAttr(leftover, "sx-preserve")) && !domHasAttr(leftover, "sx-ignore"))) ? domRemoveChild(oldParent, leftover) : NIL); +})() : NIL); }, range(oi, len(oldKids))); +})(); }; + + // swap-dom-nodes + var swapDomNodes = function(target, newNodes, strategy) { return (function() { var _m = strategy; if (_m == "innerHTML") return (isSxTruthy(domIsFragment(newNodes)) ? morphChildren(target, newNodes) : (function() { + var wrapper = domCreateElement("div", NIL); + domAppend(wrapper, newNodes); + return morphChildren(target, wrapper); +})()); if (_m == "outerHTML") return (function() { + var parent = domParent(target); + (isSxTruthy(domIsFragment(newNodes)) ? (function() { + var fc = domFirstChild(newNodes); + return (isSxTruthy(fc) ? (morphNode(target, fc), (function() { + var sib = domNextSibling(fc); + return insertRemainingSiblings(parent, target, sib); +})()) : domRemoveChild(parent, target)); +})() : morphNode(target, newNodes)); + return parent; +})(); if (_m == "afterend") return domInsertAfter(target, newNodes); if (_m == "beforeend") return domAppend(target, newNodes); if (_m == "afterbegin") return domPrepend(target, newNodes); if (_m == "beforebegin") return domInsertBefore(domParent(target), newNodes, target); if (_m == "delete") return domRemoveChild(domParent(target), target); if (_m == "none") return NIL; return (isSxTruthy(domIsFragment(newNodes)) ? morphChildren(target, newNodes) : (function() { + var wrapper = domCreateElement("div", NIL); + domAppend(wrapper, newNodes); + return morphChildren(target, wrapper); +})()); })(); }; + + // insert-remaining-siblings + var insertRemainingSiblings = function(parent, refNode, sib) { return (isSxTruthy(sib) ? (function() { + var next = domNextSibling(sib); + domInsertAfter(refNode, sib); + return insertRemainingSiblings(parent, sib, next); +})() : NIL); }; + + // swap-html-string + var swapHtmlString = function(target, html, strategy) { return (function() { var _m = strategy; if (_m == "innerHTML") return domSetInnerHtml(target, html); if (_m == "outerHTML") return (function() { + var parent = domParent(target); + domInsertAdjacentHtml(target, "afterend", html); + domRemoveChild(parent, target); + return parent; +})(); if (_m == "afterend") return domInsertAdjacentHtml(target, "afterend", html); if (_m == "beforeend") return domInsertAdjacentHtml(target, "beforeend", html); if (_m == "afterbegin") return domInsertAdjacentHtml(target, "afterbegin", html); if (_m == "beforebegin") return domInsertAdjacentHtml(target, "beforebegin", html); if (_m == "delete") return domRemoveChild(domParent(target), target); if (_m == "none") return NIL; return domSetInnerHtml(target, html); })(); }; + + // handle-history + var handleHistory = function(el, url, respHeaders) { return (function() { + var pushUrl = domGetAttr(el, "sx-push-url"); + var replaceUrl = domGetAttr(el, "sx-replace-url"); + var hdrReplace = get(respHeaders, "replace-url"); + return (isSxTruthy(hdrReplace) ? browserReplaceState(hdrReplace) : (isSxTruthy((isSxTruthy(pushUrl) && !(pushUrl == "false"))) ? browserPushState((isSxTruthy((pushUrl == "true")) ? url : pushUrl)) : (isSxTruthy((isSxTruthy(replaceUrl) && !(replaceUrl == "false"))) ? browserReplaceState((isSxTruthy((replaceUrl == "true")) ? url : replaceUrl)) : NIL))); +})(); }; + + // PRELOAD_TTL + var PRELOAD_TTL = 30000; + + // preload-cache-get + var preloadCacheGet = function(cache, url) { return (function() { + var entry = dictGet(cache, url); + return (isSxTruthy(isNil(entry)) ? NIL : (isSxTruthy(((nowMs() - get(entry, "timestamp")) > PRELOAD_TTL)) ? (dictDelete(cache, url), NIL) : (dictDelete(cache, url), entry))); +})(); }; + + // preload-cache-set + var preloadCacheSet = function(cache, url, text, contentType) { return dictSet(cache, url, {["text"]: text, ["content-type"]: contentType, ["timestamp"]: nowMs()}); }; + + // classify-trigger + var classifyTrigger = function(trigger) { return (function() { + var event = get(trigger, "event"); + return (isSxTruthy((event == "every")) ? "poll" : (isSxTruthy((event == "intersect")) ? "intersect" : (isSxTruthy((event == "load")) ? "load" : (isSxTruthy((event == "revealed")) ? "revealed" : "event")))); +})(); }; + + // should-boost-link? + var shouldBoostLink = function(link) { return (function() { + var href = domGetAttr(link, "href"); + return (isSxTruthy(href) && isSxTruthy(!startsWith(href, "#")) && isSxTruthy(!startsWith(href, "javascript:")) && isSxTruthy(!startsWith(href, "mailto:")) && isSxTruthy(browserSameOrigin(href)) && isSxTruthy(!domHasAttr(link, "sx-get")) && isSxTruthy(!domHasAttr(link, "sx-post")) && !domHasAttr(link, "sx-disable")); +})(); }; + + // should-boost-form? + var shouldBoostForm = function(form) { return (isSxTruthy(!domHasAttr(form, "sx-get")) && isSxTruthy(!domHasAttr(form, "sx-post")) && !domHasAttr(form, "sx-disable")); }; + + // parse-sse-swap + var parseSseSwap = function(el) { return sxOr(domGetAttr(el, "sx-sse-swap"), "message"); }; + + + // === Transpiled from orchestration === + + // _preload-cache + var _preloadCache = {}; + + // _css-hash + var _cssHash = NIL; + + // dispatch-trigger-events + var dispatchTriggerEvents = function(el, headerVal) { return (isSxTruthy(headerVal) ? (function() { + var parsed = tryParseJson(headerVal); + return (isSxTruthy(parsed) ? forEach(function(key) { return domDispatch(el, key, get(parsed, key)); }, keys(parsed)) : forEach(function(name) { return (function() { + var trimmed = trim(name); + return (isSxTruthy(!isEmpty(trimmed)) ? domDispatch(el, trimmed, {}) : NIL); +})(); }, split(headerVal, ","))); +})() : NIL); }; + + // init-css-tracking + var initCssTracking = function() { return (function() { + var meta = domQuery("meta[name=\"sx-css-classes\"]"); + return (isSxTruthy(meta) ? (function() { + var content = domGetAttr(meta, "content"); + return (isSxTruthy(content) ? (_cssHash = content) : NIL); +})() : NIL); +})(); }; + + // execute-request + var executeRequest = function(el, verbInfo, extraParams) { return (function() { + var info = sxOr(getVerbInfo(el), verbInfo); + return (isSxTruthy(isNil(info)) ? promiseResolve(NIL) : (function() { + var verb = get(info, "method"); + var url = get(info, "url"); + return (isSxTruthy((function() { + var media = domGetAttr(el, "sx-media"); + return (isSxTruthy(media) && !browserMediaMatches(media)); +})()) ? promiseResolve(NIL) : (isSxTruthy((function() { + var confirmMsg = domGetAttr(el, "sx-confirm"); + return (isSxTruthy(confirmMsg) && !browserConfirm(confirmMsg)); +})()) ? promiseResolve(NIL) : (function() { + var promptMsg = domGetAttr(el, "sx-prompt"); + var promptVal = (isSxTruthy(promptMsg) ? browserPrompt(promptMsg) : NIL); + return (isSxTruthy((isSxTruthy(promptMsg) && isNil(promptVal))) ? promiseResolve(NIL) : (isSxTruthy(!validateForRequest(el)) ? promiseResolve(NIL) : doFetch(el, verb, verb, url, (isSxTruthy(promptVal) ? assoc(sxOr(extraParams, {}), "SX-Prompt", promptVal) : extraParams)))); +})())); +})()); +})(); }; + + // do-fetch + var doFetch = function(el, verb, method, url, extraParams) { return (function() { + var sync = domGetAttr(el, "sx-sync"); + if (isSxTruthy((sync == "replace"))) { + abortPrevious(el); +} + return (function() { + var ctrl = newAbortController(); + trackController(el, ctrl); + return (function() { + var bodyInfo = buildRequestBody(el, method, url); + var finalUrl = get(bodyInfo, "url"); + var body = get(bodyInfo, "body"); + var ct = get(bodyInfo, "content-type"); + var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); + var csrf = csrfToken(); + if (isSxTruthy(extraParams)) { + { var _c = keys(extraParams); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; headers[k] = get(extraParams, k); } } +} + if (isSxTruthy(ct)) { + headers["Content-Type"] = ct; +} + if (isSxTruthy(csrf)) { + headers["X-CSRFToken"] = csrf; +} + return (function() { + var cached = preloadCacheGet(_preloadCache, finalUrl); + var optimisticState = applyOptimistic(el); + var indicator = showIndicator(el); + var disabledElts = disableElements(el); + domAddClass(el, "sx-request"); + domSetAttr(el, "aria-busy", "true"); + domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method}); + return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!respOk) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), handleRetry(el, verb, method, finalUrl, extraParams)) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isAbortError(err)) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); }); +})(); +})(); +})(); +})(); }; + + // handle-fetch-success + var handleFetchSuccess = function(el, url, verb, extraParams, getHeader, text) { return (function() { + var respHeaders = processResponseHeaders(getHeader); + (function() { + var newHash = get(respHeaders, "css-hash"); + return (isSxTruthy(newHash) ? (_cssHash = newHash) : NIL); +})(); + dispatchTriggerEvents(el, get(respHeaders, "trigger")); + return (isSxTruthy(get(respHeaders, "redirect")) ? browserNavigate(get(respHeaders, "redirect")) : (isSxTruthy(get(respHeaders, "refresh")) ? browserReload() : (isSxTruthy(get(respHeaders, "location")) ? fetchLocation(get(respHeaders, "location")) : (function() { + var targetEl = (isSxTruthy(get(respHeaders, "retarget")) ? domQuery(get(respHeaders, "retarget")) : resolveTarget(el)); + var swapSpec = parseSwapSpec(sxOr(get(respHeaders, "reswap"), domGetAttr(el, "sx-swap")), domHasClass(domBody(), "sx-transitions")); + var swapStyle = get(swapSpec, "style"); + var useTransition = get(swapSpec, "transition"); + var ct = sxOr(get(respHeaders, "content-type"), ""); + (isSxTruthy(contains(ct, "text/sx")) ? handleSxResponse(el, targetEl, text, swapStyle, useTransition) : handleHtmlResponse(el, targetEl, text, swapStyle, useTransition)); + dispatchTriggerEvents(el, get(respHeaders, "trigger-swap")); + handleHistory(el, url, respHeaders); + if (isSxTruthy(get(respHeaders, "trigger-settle"))) { + setTimeout_(function() { return dispatchTriggerEvents(el, get(respHeaders, "trigger-settle")); }, 20); +} + return domDispatch(el, "sx:afterSwap", {["target"]: targetEl, ["swap"]: swapStyle}); +})()))); +})(); }; + + // handle-sx-response + var handleSxResponse = function(el, target, text, swapStyle, useTransition) { return (function() { + var cleaned = stripComponentScripts(text); + return (function() { + var final = extractResponseCss(cleaned); + return (function() { + var trimmed = trim(final); + return (isSxTruthy(!isEmpty(trimmed)) ? (function() { + var rendered = sxRender(trimmed); + var container = domCreateElement("div", NIL); + domAppend(container, rendered); + processOobSwaps(container, function(t, oob, s) { swapDomNodes(t, oob, s); +sxHydrate(t); +return processElements(t); }); + return (function() { + var selectSel = domGetAttr(el, "sx-select"); + var content = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + return withTransition(useTransition, function() { swapDomNodes(target, content, swapStyle); +return postSwap(target); }); +})(); +})() : NIL); +})(); +})(); +})(); }; + + // handle-html-response + var handleHtmlResponse = function(el, target, text, swapStyle, useTransition) { return (function() { + var doc = domParseHtmlDocument(text); + return (isSxTruthy(doc) ? (function() { + var selectSel = domGetAttr(el, "sx-select"); + return (isSxTruthy(selectSel) ? (function() { + var html = selectHtmlFromDoc(doc, selectSel); + return withTransition(useTransition, function() { swapHtmlString(target, html, swapStyle); +return postSwap(target); }); +})() : (function() { + var container = domCreateElement("div", NIL); + domSetInnerHtml(container, domBodyInnerHtml(doc)); + processOobSwaps(container, function(t, oob, s) { swapDomNodes(t, oob, s); +return postSwap(t); }); + hoistHeadElements(container); + return withTransition(useTransition, function() { swapDomNodes(target, childrenToFragment(container), swapStyle); +return postSwap(target); }); +})()); +})() : NIL); +})(); }; + + // handle-retry + var handleRetry = function(el, verb, method, url, extraParams) { return (function() { + var retryAttr = domGetAttr(el, "sx-retry"); + var spec = parseRetrySpec(retryAttr); + return (isSxTruthy(spec) ? (function() { + var currentMs = sxOr(domGetAttr(el, "data-sx-retry-ms"), get(spec, "start-ms")); + return (function() { + var ms = parseInt_(currentMs, get(spec, "start-ms")); + domSetAttr(el, "data-sx-retry-ms", (String(nextRetryMs(ms, get(spec, "cap-ms"))))); + return setTimeout_(function() { return doFetch(el, verb, method, url, extraParams); }, ms); +})(); +})() : NIL); +})(); }; + + // bind-triggers + var bindTriggers = function(el, verbInfo) { return (function() { + var triggers = sxOr(parseTriggerSpec(domGetAttr(el, "sx-trigger")), defaultTrigger(domTagName(el))); + return forEach(function(trigger) { return (function() { + var kind = classifyTrigger(trigger); + var mods = get(trigger, "modifiers"); + return (isSxTruthy((kind == "poll")) ? setInterval_(function() { return executeRequest(el, NIL, NIL); }, get(mods, "interval")) : (isSxTruthy((kind == "intersect")) ? observeIntersection(el, function() { return executeRequest(el, NIL, NIL); }, false, get(mods, "delay")) : (isSxTruthy((kind == "load")) ? setTimeout_(function() { return executeRequest(el, NIL, NIL); }, sxOr(get(mods, "delay"), 0)) : (isSxTruthy((kind == "revealed")) ? observeIntersection(el, function() { return executeRequest(el, NIL, NIL); }, true, get(mods, "delay")) : (isSxTruthy((kind == "event")) ? bindEvent(el, get(trigger, "event"), mods, verbInfo) : NIL))))); +})(); }, triggers); +})(); }; + + // bind-event + var bindEvent = function(el, eventName, mods, verbInfo) { return (function() { + var timer = NIL; + var lastVal = NIL; + var listenTarget = (isSxTruthy(get(mods, "from")) ? domQuery(get(mods, "from")) : el); + return (isSxTruthy(listenTarget) ? domAddListener(listenTarget, eventName, function(e) { return (function() { + var shouldFire = true; + if (isSxTruthy(get(mods, "changed"))) { + (function() { + var val = elementValue(el); + return (isSxTruthy((val == lastVal)) ? (shouldFire = false) : (lastVal = val)); +})(); +} + return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, verbInfo, NIL); }, get(mods, "delay")))) : executeRequest(el, verbInfo, NIL))) : NIL); +})(); }, (isSxTruthy(get(mods, "once")) ? {["once"]: true} : NIL)) : NIL); +})(); }; + + // post-swap + var postSwap = function(root) { activateScripts(root); +sxProcessScripts(root); +sxHydrate(root); +return processElements(root); }; + + // activate-scripts + var activateScripts = function(root) { return (isSxTruthy(root) ? (function() { + var scripts = domQueryAll(root, "script"); + return forEach(function(dead) { return (isSxTruthy((isSxTruthy(!domHasAttr(dead, "data-components")) && !domHasAttr(dead, "data-sx-activated"))) ? (function() { + var live = createScriptClone(dead); + domSetAttr(live, "data-sx-activated", "true"); + return domReplaceChild(domParent(dead), live, dead); +})() : NIL); }, scripts); +})() : NIL); }; + + // process-oob-swaps + var processOobSwaps = function(container, swapFn) { return (function() { + var oobs = findOobSwaps(container); + return forEach(function(oob) { return (function() { + var targetId = get(oob, "target-id"); + var target = domQueryById(targetId); + var oobEl = get(oob, "element"); + var swapType = get(oob, "swap-type"); + if (isSxTruthy(domParent(oobEl))) { + domRemoveChild(domParent(oobEl), oobEl); +} + return (isSxTruthy(target) ? swapFn(target, oobEl, swapType) : NIL); +})(); }, oobs); +})(); }; + + // hoist-head-elements + var hoistHeadElements = function(container) { { var _c = domQueryAll(container, "style[data-sx-css]"); for (var _i = 0; _i < _c.length; _i++) { var style = _c[_i]; if (isSxTruthy(domParent(style))) { + domRemoveChild(domParent(style), style); +} +domAppendToHead(style); } } +return forEach(function(link) { if (isSxTruthy(domParent(link))) { + domRemoveChild(domParent(link), link); +} +return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"]")); }; + + // process-boosted + var processBoosted = function(root) { return forEach(function(container) { return boostDescendants(container); }, domQueryAll(sxOr(root, domBody()), "[sx-boost]")); }; + + // boost-descendants + var boostDescendants = function(container) { { var _c = domQueryAll(container, "a[href]"); for (var _i = 0; _i < _c.length; _i++) { var link = _c[_i]; if (isSxTruthy((isSxTruthy(!isProcessed(link, "boost")) && shouldBoostLink(link)))) { + markProcessed(link, "boost"); + if (isSxTruthy(!domHasAttr(link, "sx-target"))) { + domSetAttr(link, "sx-target", "#main-panel"); +} + if (isSxTruthy(!domHasAttr(link, "sx-swap"))) { + domSetAttr(link, "sx-swap", "innerHTML"); +} + if (isSxTruthy(!domHasAttr(link, "sx-push-url"))) { + domSetAttr(link, "sx-push-url", "true"); +} + bindBoostLink(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")); + var action = sxOr(domGetAttr(form, "action"), browserLocationHref()); + if (isSxTruthy(!domHasAttr(form, "sx-target"))) { + domSetAttr(form, "sx-target", "#main-panel"); +} + if (isSxTruthy(!domHasAttr(form, "sx-swap"))) { + domSetAttr(form, "sx-swap", "innerHTML"); +} + return bindBoostForm(form, method, action); +})()) : NIL); }, domQueryAll(container, "form")); }; + + // 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]")); }; + + // bind-sse + var bindSse = function(el) { return (function() { + var url = domGetAttr(el, "sx-sse"); + return (isSxTruthy(url) ? (function() { + var source = eventSourceConnect(url, el); + var eventName = parseSseSwap(el); + return eventSourceListen(source, eventName, function(data) { return bindSseSwap(el, data); }); +})() : NIL); +})(); }; + + // bind-sse-swap + var bindSseSwap = function(el, data) { return (function() { + var target = resolveTarget(el); + var swapSpec = parseSwapSpec(domGetAttr(el, "sx-swap"), domHasClass(domBody(), "sx-transitions")); + var swapStyle = get(swapSpec, "style"); + var useTransition = get(swapSpec, "transition"); + var trimmed = trim(data); + return (isSxTruthy(!isEmpty(trimmed)) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() { + var rendered = sxRender(trimmed); + var container = domCreateElement("div", NIL); + domAppend(container, rendered); + return withTransition(useTransition, function() { swapDomNodes(target, childrenToFragment(container), swapStyle); +return postSwap(target); }); +})() : withTransition(useTransition, function() { swapHtmlString(target, trimmed, swapStyle); +return postSwap(target); })) : NIL); +})(); }; + + // bind-inline-handlers + var bindInlineHandlers = function(root) { return forEach(function(el) { return forEach(function(attr) { return (function() { + var name = first(attr); + var body = nth(attr, 1); + return (isSxTruthy(startsWith(name, "sx-on:")) ? (function() { + var eventName = slice(name, 6); + return (isSxTruthy(!isProcessed(el, (String("on:") + String(eventName)))) ? (markProcessed(el, (String("on:") + String(eventName))), bindInlineHandler(el, eventName, body)) : NIL); +})() : NIL); +})(); }, domAttrList(el)); }, domQueryAll(sxOr(root, domBody()), "[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:load]")); }; + + // bind-preload-for + var bindPreloadFor = function(el) { return (function() { + var preloadAttr = domGetAttr(el, "sx-preload"); + return (isSxTruthy(preloadAttr) ? (function() { + var info = getVerbInfo(el); + return (isSxTruthy(info) ? (function() { + var url = get(info, "url"); + var headers = buildRequestHeaders(el, loadedComponentNames(), _cssHash); + var events = (isSxTruthy((preloadAttr == "mousedown")) ? ["mousedown", "touchstart"] : ["mouseover"]); + var debounceMs = (isSxTruthy((preloadAttr == "mousedown")) ? 0 : 100); + return bindPreload(el, events, debounceMs, function() { return doPreload(url, headers); }); +})() : NIL); +})() : NIL); +})(); }; + + // do-preload + var doPreload = function(url, headers) { return (isSxTruthy(isNil(preloadCacheGet(_preloadCache, url))) ? fetchPreload(url, headers, _preloadCache) : NIL); }; + + // VERB_SELECTOR + var VERB_SELECTOR = (String("[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]")); + + // process-elements + var processElements = function(root) { (function() { + var els = domQueryAll(sxOr(root, domBody()), VERB_SELECTOR); + return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "verb")) ? (markProcessed(el, "verb"), processOne(el)) : NIL); }, els); +})(); +processBoosted(root); +processSse(root); +return bindInlineHandlers(root); }; + + // process-one + var processOne = function(el) { return (function() { + var verbInfo = getVerbInfo(el); + return (isSxTruthy(verbInfo) ? (isSxTruthy(!domHasAttr(el, "sx-disable")) ? (bindTriggers(el, verbInfo), bindPreloadFor(el)) : NIL) : NIL); +})(); }; + + // handle-popstate + var handlePopstate = function(scrollY) { return (function() { + var main = domQueryById("main-panel"); + var url = browserLocationHref(); + return (isSxTruthy(main) ? (function() { + var headers = buildRequestHeaders(main, loadedComponentNames(), _cssHash); + return fetchAndRestore(main, url, headers, scrollY); +})() : NIL); +})(); }; + + // engine-init + var engineInit = function() { return (initCssTracking(), sxProcessScripts(NIL), sxHydrate(NIL), processElements(NIL)); }; + + + // === Transpiled from cssx === + + // _style-atoms + var _styleAtoms = {}; + + // _pseudo-variants + var _pseudoVariants = {}; + + // _responsive-breakpoints + var _responsiveBreakpoints = {}; + + // _style-keyframes + var _styleKeyframes = {}; + + // _arbitrary-patterns + var _arbitraryPatterns = []; + + // _child-selector-prefixes + var _childSelectorPrefixes = []; + + // _style-cache + var _styleCache = {}; + + // _injected-styles + var _injectedStyles = {}; + + // load-style-dict + var loadStyleDict = function(data) { _styleAtoms = sxOr(get(data, "a"), {}); +_pseudoVariants = sxOr(get(data, "v"), {}); +_responsiveBreakpoints = sxOr(get(data, "b"), {}); +_styleKeyframes = sxOr(get(data, "k"), {}); +_childSelectorPrefixes = sxOr(get(data, "c"), []); +_arbitraryPatterns = map(function(pair) { return {["re"]: compileRegex((String("^") + String(first(pair)) + String("$"))), ["tmpl"]: nth(pair, 1)}; }, sxOr(get(data, "p"), [])); +return (_styleCache = {}); }; + + // split-variant + var splitVariant = function(atom) { return (function() { + var result = NIL; + { var _c = keys(_responsiveBreakpoints); for (var _i = 0; _i < _c.length; _i++) { var bp = _c[_i]; if (isSxTruthy(isNil(result))) { + (function() { + var prefix = (String(bp) + String(":")); + return (isSxTruthy(startsWith(atom, prefix)) ? (function() { + var restAtom = slice(atom, len(prefix)); + return (function() { + var innerMatch = NIL; + { var _c = keys(_pseudoVariants); for (var _i = 0; _i < _c.length; _i++) { var pv = _c[_i]; if (isSxTruthy(isNil(innerMatch))) { + (function() { + var innerPrefix = (String(pv) + String(":")); + return (isSxTruthy(startsWith(restAtom, innerPrefix)) ? (innerMatch = [(String(bp) + String(":") + String(pv)), slice(restAtom, len(innerPrefix))]) : NIL); +})(); +} } } + return (result = sxOr(innerMatch, [bp, restAtom])); +})(); +})() : NIL); +})(); +} } } + if (isSxTruthy(isNil(result))) { + { var _c = keys(_pseudoVariants); for (var _i = 0; _i < _c.length; _i++) { var pv = _c[_i]; if (isSxTruthy(isNil(result))) { + (function() { + var prefix = (String(pv) + String(":")); + return (isSxTruthy(startsWith(atom, prefix)) ? (result = [pv, slice(atom, len(prefix))]) : NIL); +})(); +} } } +} + return sxOr(result, [NIL, atom]); +})(); }; + + // resolve-atom + var resolveAtom = function(atom) { return (function() { + var decls = dictGet(_styleAtoms, atom); + return (isSxTruthy(!isNil(decls)) ? decls : (isSxTruthy(startsWith(atom, "animate-")) ? (function() { + var kfName = slice(atom, 8); + return (isSxTruthy(dictHas(_styleKeyframes, kfName)) ? (String("animation-name:") + String(kfName)) : NIL); +})() : (function() { + var matchResult = NIL; + { var _c = _arbitraryPatterns; for (var _i = 0; _i < _c.length; _i++) { var pat = _c[_i]; if (isSxTruthy(isNil(matchResult))) { + (function() { + var m = regexMatch(get(pat, "re"), atom); + return (isSxTruthy(m) ? (matchResult = regexReplaceGroups(get(pat, "tmpl"), m)) : NIL); +})(); +} } } + return matchResult; +})())); +})(); }; + + // is-child-selector-atom? + var isChildSelectorAtom = function(atom) { return some(function(prefix) { return startsWith(atom, prefix); }, _childSelectorPrefixes); }; + + // hash-style + var hashStyle = function(input) { return fnv1aHash(input); }; + + // resolve-style + var resolveStyle = function(atoms) { return (function() { + var key = join("\\0", atoms); + return (function() { + var cached = dictGet(_styleCache, key); + return (isSxTruthy(!isNil(cached)) ? cached : (function() { + var baseDecls = []; + var mediaRules = []; + var pseudoRules = []; + var kfNeeded = []; + { var _c = atoms; for (var _i = 0; _i < _c.length; _i++) { var a = _c[_i]; if (isSxTruthy(a)) { + (function() { + var clean = (isSxTruthy(startsWith(a, ":")) ? slice(a, 1) : a); + return (function() { + var parts = splitVariant(clean); + return (function() { + var variant = first(parts); + var base = nth(parts, 1); + var decls = resolveAtom(base); + return (isSxTruthy(decls) ? ((isSxTruthy(startsWith(base, "animate-")) ? (function() { + var kfName = slice(base, 8); + return (isSxTruthy(dictHas(_styleKeyframes, kfName)) ? append_b(kfNeeded, [kfName, dictGet(_styleKeyframes, kfName)]) : NIL); +})() : NIL), (isSxTruthy(isNil(variant)) ? (isSxTruthy(isChildSelectorAtom(base)) ? append_b(pseudoRules, [">:not(:first-child)", decls]) : append_b(baseDecls, decls)) : (isSxTruthy(dictHas(_responsiveBreakpoints, variant)) ? append_b(mediaRules, [dictGet(_responsiveBreakpoints, variant), decls]) : (isSxTruthy(dictHas(_pseudoVariants, variant)) ? append_b(pseudoRules, [dictGet(_pseudoVariants, variant), decls]) : (function() { + var vparts = split(variant, ":"); + var mediaPart = NIL; + var pseudoPart = NIL; + { var _c = vparts; for (var _i = 0; _i < _c.length; _i++) { var vp = _c[_i]; (isSxTruthy(dictHas(_responsiveBreakpoints, vp)) ? (mediaPart = dictGet(_responsiveBreakpoints, vp)) : (isSxTruthy(dictHas(_pseudoVariants, vp)) ? (pseudoPart = dictGet(_pseudoVariants, vp)) : NIL)); } } + if (isSxTruthy(mediaPart)) { + mediaRules.push([mediaPart, decls]); +} + if (isSxTruthy(pseudoPart)) { + pseudoRules.push([pseudoPart, decls]); +} + return (isSxTruthy((isSxTruthy(isNil(mediaPart)) && isNil(pseudoPart))) ? append_b(baseDecls, decls) : NIL); +})())))) : NIL); +})(); +})(); +})(); +} } } + return (function() { + var hashInput = join(";", baseDecls); + { var _c = mediaRules; for (var _i = 0; _i < _c.length; _i++) { var mr = _c[_i]; hashInput = (String(hashInput) + String("@") + String(first(mr)) + String("{") + String(nth(mr, 1)) + String("}")); } } + { var _c = pseudoRules; for (var _i = 0; _i < _c.length; _i++) { var pr = _c[_i]; hashInput = (String(hashInput) + String(first(pr)) + String("{") + String(nth(pr, 1)) + String("}")); } } + { var _c = kfNeeded; for (var _i = 0; _i < _c.length; _i++) { var kf = _c[_i]; hashInput = (String(hashInput) + String(nth(kf, 1))); } } + return (function() { + var cn = (String("sx-") + String(hashStyle(hashInput))); + var sv = makeStyleValue_(cn, join(";", baseDecls), mediaRules, pseudoRules, kfNeeded); + _styleCache[key] = sv; + injectStyleValue(sv, atoms); + return sv; +})(); +})(); +})()); +})(); +})(); }; + + // merge-style-values + var mergeStyleValues = function(styles) { return (isSxTruthy((len(styles) == 1)) ? first(styles) : (function() { + var allDecls = []; + var allMedia = []; + var allPseudo = []; + var allKf = []; + { var _c = styles; for (var _i = 0; _i < _c.length; _i++) { var sv = _c[_i]; if (isSxTruthy(styleValueDeclarations(sv))) { + allDecls.push(styleValueDeclarations(sv)); +} +allMedia = concat(allMedia, styleValueMediaRules(sv)); +allPseudo = concat(allPseudo, styleValuePseudoRules(sv)); +allKf = concat(allKf, styleValueKeyframes_(sv)); } } + return (function() { + var hashInput = join(";", allDecls); + { var _c = allMedia; for (var _i = 0; _i < _c.length; _i++) { var mr = _c[_i]; hashInput = (String(hashInput) + String("@") + String(first(mr)) + String("{") + String(nth(mr, 1)) + String("}")); } } + { var _c = allPseudo; for (var _i = 0; _i < _c.length; _i++) { var pr = _c[_i]; hashInput = (String(hashInput) + String(first(pr)) + String("{") + String(nth(pr, 1)) + String("}")); } } + { var _c = allKf; for (var _i = 0; _i < _c.length; _i++) { var kf = _c[_i]; hashInput = (String(hashInput) + String(nth(kf, 1))); } } + return (function() { + var cn = (String("sx-") + String(hashStyle(hashInput))); + var merged = makeStyleValue_(cn, join(";", allDecls), allMedia, allPseudo, allKf); + injectStyleValue(merged, []); + return merged; +})(); +})(); +})()); }; + + + // === Transpiled from boot === + + // HEAD_HOIST_SELECTOR + var HEAD_HOIST_SELECTOR = "meta, title, link[rel='canonical'], script[type='application/ld+json']"; + + // hoist-head-elements-full + var hoistHeadElementsFull = function(root) { return (function() { + var els = domQueryAll(root, HEAD_HOIST_SELECTOR); + return forEach(function(el) { return (function() { + var tag = lower(domTagName(el)); + return (isSxTruthy((tag == "title")) ? (setDocumentTitle(domTextContent(el)), domRemoveChild(domParent(el), el)) : (isSxTruthy((tag == "meta")) ? ((function() { + var name = domGetAttr(el, "name"); + var prop = domGetAttr(el, "property"); + if (isSxTruthy(name)) { + removeHeadElement((String("meta[name=\"") + String(name) + String("\"]"))); +} + return (isSxTruthy(prop) ? removeHeadElement((String("meta[property=\"") + String(prop) + String("\"]"))) : NIL); +})(), domRemoveChild(domParent(el), el), domAppendToHead(el)) : (isSxTruthy((isSxTruthy((tag == "link")) && (domGetAttr(el, "rel") == "canonical"))) ? (removeHeadElement("link[rel=\"canonical\"]"), domRemoveChild(domParent(el), el), domAppendToHead(el)) : (domRemoveChild(domParent(el), el), domAppendToHead(el))))); +})(); }, els); +})(); }; + + // sx-mount + var sxMount = function(target, source, extraEnv) { return (function() { + var el = resolveMountTarget(target); + return (isSxTruthy(el) ? (function() { + var node = sxRenderWithEnv(source, extraEnv); + domSetTextContent(el, ""); + domAppend(el, node); + hoistHeadElementsFull(el); + processElements(el); + return sxHydrateElements(el); +})() : NIL); +})(); }; + + // sx-hydrate-elements + var sxHydrateElements = function(root) { return (function() { + var els = domQueryAll(sxOr(root, domBody()), "[data-sx]"); + return forEach(function(el) { return (isSxTruthy(!isProcessed(el, "hydrated")) ? (markProcessed(el, "hydrated"), sxUpdateElement(el, NIL)) : NIL); }, els); +})(); }; + + // sx-update-element + var sxUpdateElement = function(el, newEnv) { return (function() { + var target = resolveMountTarget(el); + return (isSxTruthy(target) ? (function() { + var source = domGetAttr(target, "data-sx"); + return (isSxTruthy(source) ? (function() { + var baseEnv = parseEnvAttr(target); + var env = mergeEnvs(baseEnv, newEnv); + return (function() { + var node = sxRenderWithEnv(source, env); + domSetTextContent(target, ""); + domAppend(target, node); + return (isSxTruthy(newEnv) ? storeEnvAttr(target, baseEnv, newEnv) : NIL); +})(); +})() : NIL); +})() : NIL); +})(); }; + + // sx-render-component + var sxRenderComponent = function(name, kwargs, extraEnv) { return (function() { + var fullName = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); + return (function() { + var env = getRenderEnv(extraEnv); + var comp = envGet(env, fullName); + return (isSxTruthy(!isComponent(comp)) ? error((String("Unknown component: ") + String(fullName))) : (function() { + var callExpr = [makeSymbol(fullName)]; + { var _c = keys(kwargs); for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; callExpr.push(makeKeyword(toKebab(k))); +callExpr.push(dictGet(kwargs, k)); } } + return renderToDom(callExpr, env, NIL); +})()); +})(); +})(); }; + + // process-sx-scripts + var processSxScripts = function(root) { return (function() { + var scripts = querySxScripts(root); + return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "script")) ? (markProcessed(s, "script"), (function() { + var text = domTextContent(s); + return (isSxTruthy(domHasAttr(s, "data-components")) ? processComponentScript(s, text) : (isSxTruthy(sxOr(isNil(text), isEmpty(trim(text)))) ? NIL : (isSxTruthy(domHasAttr(s, "data-mount")) ? (function() { + var mountSel = domGetAttr(s, "data-mount"); + var target = domQuery(mountSel); + return (isSxTruthy(target) ? sxMount(target, text, NIL) : NIL); +})() : sxLoadComponents(text)))); +})()) : NIL); }, scripts); +})(); }; + + // process-component-script + var processComponentScript = function(script, text) { return (function() { + var hash = domGetAttr(script, "data-hash"); + return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? sxLoadComponents(text) : NIL) : (function() { + var hasInline = (isSxTruthy(text) && !isEmpty(trim(text))); + (function() { + var cachedHash = localStorageGet("sx-components-hash"); + return (isSxTruthy((cachedHash == hash)) ? (isSxTruthy(hasInline) ? (localStorageSet("sx-components-hash", hash), localStorageSet("sx-components-src", text), sxLoadComponents(text), logInfo("components: downloaded (cookie stale)")) : (function() { + var cached = localStorageGet("sx-components-src"); + return (isSxTruthy(cached) ? (sxLoadComponents(cached), logInfo((String("components: cached (") + String(hash) + String(")")))) : (clearSxCompCookie(), browserReload())); +})()) : (isSxTruthy(hasInline) ? (localStorageSet("sx-components-hash", hash), localStorageSet("sx-components-src", text), sxLoadComponents(text), logInfo((String("components: downloaded (") + String(hash) + String(")")))) : (localStorageRemove("sx-components-hash"), localStorageRemove("sx-components-src"), clearSxCompCookie(), browserReload()))); +})(); + return setSxCompCookie(hash); +})()); +})(); }; + + // init-style-dict + var initStyleDict = function() { return (function() { + var scripts = queryStyleScripts(); + return forEach(function(s) { return (isSxTruthy(!isProcessed(s, "styles")) ? (markProcessed(s, "styles"), (function() { + var text = domTextContent(s); + var hash = domGetAttr(s, "data-hash"); + return (isSxTruthy(isNil(hash)) ? (isSxTruthy((isSxTruthy(text) && !isEmpty(trim(text)))) ? parseAndLoadStyleDict(text) : NIL) : (function() { + var hasInline = (isSxTruthy(text) && !isEmpty(trim(text))); + (function() { + var cachedHash = localStorageGet("sx-styles-hash"); + return (isSxTruthy((cachedHash == hash)) ? (isSxTruthy(hasInline) ? (localStorageSet("sx-styles-src", text), parseAndLoadStyleDict(text), logInfo("styles: downloaded (cookie stale)")) : (function() { + var cached = localStorageGet("sx-styles-src"); + return (isSxTruthy(cached) ? (parseAndLoadStyleDict(cached), logInfo((String("styles: cached (") + String(hash) + String(")")))) : (clearSxStylesCookie(), browserReload())); +})()) : (isSxTruthy(hasInline) ? (localStorageSet("sx-styles-hash", hash), localStorageSet("sx-styles-src", text), parseAndLoadStyleDict(text), logInfo((String("styles: downloaded (") + String(hash) + String(")")))) : (localStorageRemove("sx-styles-hash"), localStorageRemove("sx-styles-src"), clearSxStylesCookie(), browserReload()))); +})(); + return setSxStylesCookie(hash); +})()); +})()) : NIL); }, scripts); +})(); }; + + // boot-init + var bootInit = function() { return (initCssTracking(), initStyleDict(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; + + // ========================================================================= // Platform interface — DOM adapter (browser-only) // ========================================================================= @@ -1200,6 +2395,846 @@ function domTagName(el) { return el && el.tagName ? el.tagName : ""; } + // ========================================================================= + // Platform interface — Engine pure logic (browser + node compatible) + // ========================================================================= + + function browserLocationHref() { + return typeof location !== "undefined" ? location.href : ""; + } + + function browserSameOrigin(url) { + try { return new URL(url, location.href).origin === location.origin; } + catch (e) { return true; } + } + + function browserPushState(url) { + if (typeof history !== "undefined") { + try { history.pushState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function browserReplaceState(url) { + if (typeof history !== "undefined") { + try { history.replaceState({ sxUrl: url, scrollY: typeof window !== "undefined" ? window.scrollY : 0 }, "", url); } + catch (e) {} + } + } + + function nowMs() { return Date.now(); } + + function parseHeaderValue(s) { + if (!s) return null; + try { + if (s.charAt(0) === "{" && s.charAt(1) === ":") return parse(s); + return JSON.parse(s); + } catch (e) { return null; } + } + + + // ========================================================================= + // Platform interface — Orchestration (browser-only) + // ========================================================================= + + // --- Browser/Network --- + + function browserNavigate(url) { + if (typeof location !== "undefined") location.assign(url); + } + + function browserReload() { + if (typeof location !== "undefined") location.reload(); + } + + function browserScrollTo(x, y) { + if (typeof window !== "undefined") window.scrollTo(x, y); + } + + function browserMediaMatches(query) { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + } + + function browserConfirm(msg) { + if (typeof window === "undefined") return false; + return window.confirm(msg); + } + + function browserPrompt(msg) { + if (typeof window === "undefined") return NIL; + var r = window.prompt(msg); + return r === null ? NIL : r; + } + + function csrfToken() { + if (!_hasDom) return NIL; + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : NIL; + } + + function isCrossOrigin(url) { + try { + var h = new URL(url, location.href).hostname; + return h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0); + } catch (e) { return false; } + } + + // --- Promises --- + + function promiseResolve(val) { return Promise.resolve(val); } + + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } + + // --- Abort controllers --- + + var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null; + + function abortPrevious(el) { + if (_controllers) { + var prev = _controllers.get(el); + if (prev) prev.abort(); + } + } + + function trackController(el, ctrl) { + if (_controllers) _controllers.set(el, ctrl); + } + + function newAbortController() { + return typeof AbortController !== "undefined" ? new AbortController() : { signal: null, abort: function() {} }; + } + + function controllerSignal(ctrl) { return ctrl ? ctrl.signal : null; } + + function isAbortError(err) { return err && err.name === "AbortError"; } + + // --- Timers --- + + function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); } + function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); } + function clearTimeout_(id) { clearTimeout(id); } + function requestAnimationFrame_(fn) { + if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn); + else setTimeout(fn, 16); + } + + // --- Fetch --- + + function fetchRequest(config, successFn, errorFn) { + var opts = { method: config.method, headers: config.headers }; + if (config.signal) opts.signal = config.signal; + if (config.body && config.method !== "GET") opts.body = config.body; + if (config["cross-origin"]) opts.credentials = "include"; + + var p = (config.preloaded && config.preloaded !== NIL) + ? Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ "Content-Type": config.preloaded["content-type"] || "" }), + text: function() { return Promise.resolve(config.preloaded.text); } + }) + : fetch(config.url, opts); + + return p.then(function(resp) { + return resp.text().then(function(text) { + var getHeader = function(name) { + var v = resp.headers.get(name); + return v === null ? NIL : v; + }; + return successFn(resp.ok, resp.status, getHeader, text); + }); + }).catch(function(err) { + return errorFn(err); + }); + } + + function fetchLocation(headerVal) { + if (!_hasDom) return; + var locUrl = headerVal; + try { var obj = JSON.parse(headerVal); locUrl = obj.path || obj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function(r) { + return r.text().then(function(t) { + var main = document.getElementById("main-panel"); + if (main) { + main.innerHTML = t; + postSwap(main); + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + } + }); + }); + } + + function fetchAndRestore(main, url, headers, scrollY) { + var opts = { headers: headers }; + try { + var h = new URL(url, location.href).hostname; + if (h !== location.hostname && + (h.indexOf(".rose-ash.com") >= 0 || h.indexOf(".localhost") >= 0)) { + opts.credentials = "include"; + } + } catch (e) {} + + fetch(url, opts).then(function(resp) { + return resp.text().then(function(text) { + text = stripComponentScripts(text); + text = extractResponseCss(text); + text = text.trim(); + if (text.charAt(0) === "(") { + try { + var dom = sxRender(text); + var container = document.createElement("div"); + container.appendChild(dom); + processOobSwaps(container, function(t, oob, s) { + swapDomNodes(t, oob, s); + sxHydrate(t); + processElements(t); + }); + var newMain = container.querySelector("#main-panel"); + morphChildren(main, newMain || container); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } catch (err) { + console.error("sx-ref popstate error:", err); + location.reload(); + } + } else { + var parser = new DOMParser(); + var doc = parser.parseFromString(text, "text/html"); + var newMain = doc.getElementById("main-panel"); + if (newMain) { + morphChildren(main, newMain); + postSwap(main); + if (typeof window !== "undefined") window.scrollTo(0, scrollY || 0); + } else { + location.reload(); + } + } + }); + }).catch(function() { location.reload(); }); + } + + function fetchPreload(url, headers, cache) { + fetch(url, { headers: headers }).then(function(resp) { + if (!resp.ok) return; + var ct = resp.headers.get("Content-Type") || ""; + return resp.text().then(function(text) { + preloadCacheSet(cache, url, text, ct); + }); + }).catch(function() { /* ignore */ }); + } + + // --- Request body building --- + + function buildRequestBody(el, method, url) { + if (!_hasDom) return { body: null, url: url, "content-type": NIL }; + var body = null; + var ct = NIL; + var finalUrl = url; + var isJson = el.getAttribute("sx-encoding") === "json"; + + if (method !== "GET") { + var form = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form) { + if (isJson) { + var fd = new FormData(form); + var obj = {}; + fd.forEach(function(v, k) { + if (obj[k] !== undefined) { + if (!Array.isArray(obj[k])) obj[k] = [obj[k]]; + obj[k].push(v); + } else { obj[k] = v; } + }); + body = JSON.stringify(obj); + ct = "application/json"; + } else { + body = new URLSearchParams(new FormData(form)); + ct = "application/x-www-form-urlencoded"; + } + } + } + + // sx-params + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + paramsSpec.substring(4).split(",").forEach(function(k) { body.delete(k.trim()); }); + } else if (paramsSpec !== "*") { + var allowed = paramsSpec.split(",").map(function(s) { return s.trim(); }); + var filtered = new URLSearchParams(); + allowed.forEach(function(k) { + body.getAll(k).forEach(function(v) { filtered.append(k, v); }); + }); + body = filtered; + } + } + + // sx-include + var includeSel = el.getAttribute("sx-include"); + if (includeSel && method !== "GET") { + if (!body) body = new URLSearchParams(); + document.querySelectorAll(includeSel).forEach(function(inp) { + if (inp.name) body.append(inp.name, inp.value); + }); + } + + // sx-vals + var valsAttr = el.getAttribute("sx-vals"); + if (valsAttr) { + try { + var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr); + if (method === "GET") { + for (var vk in vals) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]); + } else if (body instanceof URLSearchParams) { + for (var vk2 in vals) body.append(vk2, vals[vk2]); + } else if (!body) { + body = new URLSearchParams(); + for (var vk3 in vals) body.append(vk3, vals[vk3]); + ct = "application/x-www-form-urlencoded"; + } + } catch (e) {} + } + + // GET form data → URL + if (method === "GET") { + var form2 = el.closest("form") || (el.tagName === "FORM" ? el : null); + if (form2) { + var qs = new URLSearchParams(new FormData(form2)).toString(); + if (qs) finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + qs; + } + if ((el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA") && el.name) { + finalUrl += (finalUrl.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(el.name) + "=" + encodeURIComponent(el.value); + } + } + + return { body: body, url: finalUrl, "content-type": ct }; + } + + // --- Loading state --- + + function showIndicator(el) { + if (!_hasDom) return NIL; + var sel = el.getAttribute("sx-indicator"); + var ind = sel ? (document.querySelector(sel) || el.closest(sel)) : null; + if (ind) { ind.classList.add("sx-request"); ind.style.display = ""; } + return ind || NIL; + } + + function disableElements(el) { + if (!_hasDom) return []; + var sel = el.getAttribute("sx-disabled-elt"); + if (!sel) return []; + var elts = Array.prototype.slice.call(document.querySelectorAll(sel)); + elts.forEach(function(e) { e.disabled = true; }); + return elts; + } + + function clearLoadingState(el, indicator, disabledElts) { + el.classList.remove("sx-request"); + el.removeAttribute("aria-busy"); + if (indicator && !isNil(indicator)) { + indicator.classList.remove("sx-request"); + indicator.style.display = "none"; + } + if (disabledElts) { + for (var i = 0; i < disabledElts.length; i++) disabledElts[i].disabled = false; + } + } + + // --- DOM extras --- + + function domQueryById(id) { + return _hasDom ? document.getElementById(id) : null; + } + + function domMatches(el, sel) { + return el && el.matches ? el.matches(sel) : false; + } + + function domClosest(el, sel) { + return el && el.closest ? el.closest(sel) : null; + } + + function domBody() { + return _hasDom ? document.body : null; + } + + function domHasClass(el, cls) { + return el && el.classList ? el.classList.contains(cls) : false; + } + + function domAppendToHead(el) { + if (_hasDom && document.head) document.head.appendChild(el); + } + + function domParseHtmlDocument(text) { + if (!_hasDom) return null; + return new DOMParser().parseFromString(text, "text/html"); + } + + function domOuterHtml(el) { + return el ? el.outerHTML : ""; + } + + function domBodyInnerHtml(doc) { + return doc && doc.body ? doc.body.innerHTML : ""; + } + + // --- Events --- + + function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } + function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; } + + function domAddListener(el, event, fn, opts) { + if (!el || !el.addEventListener) return; + var o = {}; + if (opts && !isNil(opts)) { + if (opts.once || opts["once"]) o.once = true; + } + el.addEventListener(event, fn, o); + } + + // --- Validation --- + + function validateForRequest(el) { + if (!_hasDom) return true; + var attr = el.getAttribute("sx-validate"); + if (attr === null) { + var vForm = el.closest("[sx-validate]"); + if (vForm) attr = vForm.getAttribute("sx-validate"); + } + if (attr === null) return true; // no validation configured + var form = el.tagName === "FORM" ? el : el.closest("form"); + if (form && !form.reportValidity()) return false; + if (attr && attr !== "true" && attr !== "") { + var fn = window[attr]; + if (typeof fn === "function" && !fn(el)) return false; + } + return true; + } + + // --- View Transitions --- + + function withTransition(enabled, fn) { + if (enabled && _hasDom && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } + + // --- IntersectionObserver --- + + function observeIntersection(el, fn, once, delay) { + if (!_hasDom || !("IntersectionObserver" in window)) { fn(); return; } + var fired = false; + var d = isNil(delay) ? 0 : delay; + var obs = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (!entry.isIntersecting) return; + if (once && fired) return; + fired = true; + if (once) obs.unobserve(el); + if (d) setTimeout(fn, d); else fn(); + }); + }); + obs.observe(el); + } + + // --- EventSource --- + + function eventSourceConnect(url, el) { + var source = new EventSource(url); + source.addEventListener("error", function() { domDispatch(el, "sx:sseError", {}); }); + source.addEventListener("open", function() { domDispatch(el, "sx:sseOpen", {}); }); + if (typeof MutationObserver !== "undefined") { + var obs = new MutationObserver(function() { + if (!document.body.contains(el)) { source.close(); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true, subtree: true }); + } + return source; + } + + function eventSourceListen(source, event, fn) { + source.addEventListener(event, function(e) { fn(e.data); }); + } + + // --- Boost bindings --- + + function bindBoostLink(el, href) { + el.addEventListener("click", function(e) { + e.preventDefault(); + executeRequest(el, { method: "GET", url: href }).then(function() { + try { history.pushState({ sxUrl: href, scrollY: window.scrollY }, "", href); } catch (err) {} + }); + }); + } + + function bindBoostForm(form, method, action) { + form.addEventListener("submit", function(e) { + e.preventDefault(); + executeRequest(form, { method: method, url: action }).then(function() { + try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {} + }); + }); + } + + // --- Inline handlers --- + + function bindInlineHandler(el, eventName, body) { + el.addEventListener(eventName, new Function("event", body)); + } + + // --- Preload binding --- + + function bindPreload(el, events, debounceMs, fn) { + var timer = null; + events.forEach(function(evt) { + el.addEventListener(evt, function() { + if (debounceMs) { + clearTimeout(timer); + timer = setTimeout(fn, debounceMs); + } else { + fn(); + } + }); + }); + } + + // --- Processing markers --- + + var PROCESSED = "_sxBound"; + + function markProcessed(el, key) { el[PROCESSED + key] = true; } + function isProcessed(el, key) { return !!el[PROCESSED + key]; } + + // --- Script cloning --- + + function createScriptClone(dead) { + var live = document.createElement("script"); + for (var i = 0; i < dead.attributes.length; i++) + live.setAttribute(dead.attributes[i].name, dead.attributes[i].value); + live.textContent = dead.textContent; + return live; + } + + // --- SX API references --- + + function sxRender(source) { + var SxObj = typeof Sx !== "undefined" ? Sx : null; + if (SxObj && SxObj.render) return SxObj.render(source); + throw new Error("No SX renderer available"); + } + + function sxProcessScripts(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : null; + var r = (root && root !== NIL) ? root : undefined; + if (SxObj && SxObj.processScripts) SxObj.processScripts(r); + } + + function sxHydrate(root) { + var SxObj = typeof Sx !== "undefined" ? Sx : null; + var r = (root && root !== NIL) ? root : undefined; + if (SxObj && SxObj.hydrate) SxObj.hydrate(r); + } + + function loadedComponentNames() { + var SxObj = typeof Sx !== "undefined" ? Sx : null; + if (!SxObj) return []; + var env = SxObj.componentEnv || (SxObj.getEnv ? SxObj.getEnv() : {}); + return Object.keys(env).filter(function(k) { return k.charAt(0) === "~"; }); + } + + // --- Response processing --- + + function stripComponentScripts(text) { + var SxObj = typeof Sx !== "undefined" ? Sx : null; + return text.replace(/]*type="text\/sx"[^>]*data-components[^>]*>([\s\S]*?)<\/script>/gi, + function(_, defs) { if (SxObj && SxObj.loadComponents) SxObj.loadComponents(defs); return ""; }); + } + + function extractResponseCss(text) { + if (!_hasDom) return text; + var target = document.getElementById("sx-css"); + if (!target) return text; + return text.replace(/]*data-sx-css[^>]*>([\s\S]*?)<\/style>/gi, + function(_, css) { target.textContent += css; return ""; }); + } + + function selectFromContainer(container, sel) { + var frag = document.createDocumentFragment(); + sel.split(",").forEach(function(s) { + container.querySelectorAll(s.trim()).forEach(function(m) { frag.appendChild(m); }); + }); + return frag; + } + + function childrenToFragment(container) { + var frag = document.createDocumentFragment(); + while (container.firstChild) frag.appendChild(container.firstChild); + return frag; + } + + function selectHtmlFromDoc(doc, sel) { + var parts = sel.split(",").map(function(s) { return s.trim(); }); + var frags = []; + parts.forEach(function(s) { + doc.querySelectorAll(s).forEach(function(m) { frags.push(m.outerHTML); }); + }); + return frags.join(""); + } + + // --- Parsing --- + + function tryParseJson(s) { + if (!s) return NIL; + try { return JSON.parse(s); } catch (e) { return NIL; } + } + + + // ========================================================================= + // Platform interface — CSSX (style dictionary) + // ========================================================================= + + function fnv1aHash(input) { + var h = 0x811c9dc5; + for (var i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = (h * 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, "0").substring(0, 6); + } + + function compileRegex(pattern) { + try { return new RegExp(pattern); } catch (e) { return null; } + } + + function regexMatch(re, s) { + if (!re) return NIL; + var m = s.match(re); + return m ? Array.prototype.slice.call(m) : NIL; + } + + function regexReplaceGroups(tmpl, match) { + var result = tmpl; + for (var j = 1; j < match.length; j++) { + result = result.split("{" + (j - 1) + "}").join(match[j]); + } + return result; + } + + function makeStyleValue_(cn, decls, media, pseudo, kf) { + return new StyleValue(cn, decls || "", media || [], pseudo || [], kf || []); + } + + function styleValueDeclarations(sv) { return sv.declarations; } + function styleValueMediaRules(sv) { return sv.mediaRules; } + function styleValuePseudoRules(sv) { return sv.pseudoRules; } + function styleValueKeyframes_(sv) { return sv.keyframes; } + + function injectStyleValue(sv, atoms) { + if (_injectedStyles[sv.className]) return; + _injectedStyles[sv.className] = true; + + if (!_hasDom) return; + var cssTarget = document.getElementById("sx-css"); + if (!cssTarget) return; + + var rules = []; + // Child-selector atoms are now routed to pseudoRules by the resolver + // with selector ">:not(:first-child)", so base declarations are always + // applied directly to the class. + if (sv.declarations) { + rules.push("." + sv.className + "{" + sv.declarations + "}"); + } + for (var pi = 0; pi < sv.pseudoRules.length; pi++) { + var sel = sv.pseudoRules[pi][0], decls = sv.pseudoRules[pi][1]; + if (sel.indexOf("&") >= 0) { + rules.push(sel.replace(/&/g, "." + sv.className) + "{" + decls + "}"); + } else { + rules.push("." + sv.className + sel + "{" + decls + "}"); + } + } + for (var mi = 0; mi < sv.mediaRules.length; mi++) { + rules.push("@media " + sv.mediaRules[mi][0] + "{." + sv.className + "{" + sv.mediaRules[mi][1] + "}}"); + } + for (var ki = 0; ki < sv.keyframes.length; ki++) { + rules.push(sv.keyframes[ki][1]); + } + cssTarget.textContent += rules.join(""); + } + + // Replace stub css primitive with real CSSX implementation + PRIMITIVES["css"] = function() { + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return resolveStyle(atoms); + }; + + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + return mergeStyleValues(valid); + }; + + + // ========================================================================= + // Platform interface — Boot (mount, hydrate, scripts, cookies) + // ========================================================================= + + function resolveMountTarget(target) { + if (typeof target === "string") return _hasDom ? document.querySelector(target) : null; + return target; + } + + function sxRenderWithEnv(source, extraEnv) { + var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + var exprs = parse(source); + if (!_hasDom) return null; + var frag = document.createDocumentFragment(); + for (var i = 0; i < exprs.length; i++) { + var node = renderToDom(exprs[i], env, null); + if (node) frag.appendChild(node); + } + return frag; + } + + function getRenderEnv(extraEnv) { + return extraEnv ? merge(componentEnv, extraEnv) : componentEnv; + } + + function mergeEnvs(base, newEnv) { + return newEnv ? merge(componentEnv, base, newEnv) : merge(componentEnv, base); + } + + function sxLoadComponents(text) { + try { + var exprs = parse(text); + for (var i = 0; i < exprs.length; i++) trampoline(evalExpr(exprs[i], componentEnv)); + } catch (err) { + logParseError("loadComponents", text, err); + throw err; + } + } + + function setDocumentTitle(s) { + if (_hasDom) document.title = s || ""; + } + + function removeHeadElement(sel) { + if (!_hasDom) return; + var old = document.head.querySelector(sel); + if (old) old.parentNode.removeChild(old); + } + + function querySxScripts(root) { + if (!_hasDom) return []; + var r = (root && root !== NIL) ? root : document; + return Array.prototype.slice.call( + r.querySelectorAll('script[type="text/sx"]')); + } + + function queryStyleScripts() { + if (!_hasDom) return []; + return Array.prototype.slice.call( + document.querySelectorAll('script[type="text/sx-styles"]')); + } + + // --- localStorage --- + + function localStorageGet(key) { + try { var v = localStorage.getItem(key); return v === null ? NIL : v; } + catch (e) { return NIL; } + } + + function localStorageSet(key, val) { + try { localStorage.setItem(key, val); } catch (e) {} + } + + function localStorageRemove(key) { + try { localStorage.removeItem(key); } catch (e) {} + } + + // --- Cookies --- + + function setSxCompCookie(hash) { + if (_hasDom) document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax"; + } + + function clearSxCompCookie() { + if (_hasDom) document.cookie = "sx-comp-hash=;path=/;max-age=0;SameSite=Lax"; + } + + function setSxStylesCookie(hash) { + if (_hasDom) document.cookie = "sx-styles-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax"; + } + + function clearSxStylesCookie() { + if (_hasDom) document.cookie = "sx-styles-hash=;path=/;max-age=0;SameSite=Lax"; + } + + // --- Env helpers --- + + function parseEnvAttr(el) { + var attr = el && el.getAttribute ? el.getAttribute("data-sx-env") : null; + if (!attr) return {}; + try { return JSON.parse(attr); } catch (e) { return {}; } + } + + function storeEnvAttr(el, base, newEnv) { + var merged = merge(base, newEnv); + if (el && el.setAttribute) el.setAttribute("data-sx-env", JSON.stringify(merged)); + } + + function toKebab(s) { return s.replace(/_/g, "-"); } + + // --- Logging --- + + function logInfo(msg) { + if (typeof console !== "undefined") console.log("[sx-ref] " + msg); + } + + function logParseError(label, text, err) { + if (typeof console === "undefined") return; + var msg = err && err.message ? err.message : String(err); + var colMatch = msg.match(/col (\d+)/); + var lineMatch = msg.match(/line (\d+)/); + if (colMatch && text) { + var errLine = lineMatch ? parseInt(lineMatch[1]) : 1; + var errCol = parseInt(colMatch[1]); + var lines = text.split("\n"); + var pos = 0; + for (var i = 0; i < errLine - 1 && i < lines.length; i++) pos += lines[i].length + 1; + pos += errCol; + var ws = 80; + var start = Math.max(0, pos - ws); + var end = Math.min(text.length, pos + ws); + console.error("[sx-ref] " + label + ":", msg, + "\n around error (pos ~" + pos + "):", + "\n \u00ab" + text.substring(start, pos) + "\u26d4" + text.substring(pos, end) + "\u00bb"); + } else { + console.error("[sx-ref] " + label + ":", msg); + } + } + + function parseAndLoadStyleDict(text) { + try { loadStyleDict(JSON.parse(text)); } + catch (e) { if (typeof console !== "undefined") console.warn("[sx-ref] style dict parse error", e); } + } + + // ========================================================================= // Post-transpilation fixups // ========================================================================= @@ -1212,12 +3247,13 @@ }; // 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; - // Minimal fallback parser (no parser adapter) - function parse(text) { - throw new Error("Parser adapter not included — cannot parse SX source at runtime"); - } + // Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes) + var parse = sxParse; // ========================================================================= // Public API @@ -1233,12 +3269,25 @@ } 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, @@ -1246,7 +3295,7 @@ eval: function(expr, env) { return trampoline(evalExpr(expr, env || merge(componentEnv))); }, loadComponents: loadComponents, render: render, - + renderToString: renderToString, serialize: serialize, NIL: NIL, Symbol: Symbol, @@ -1254,10 +3303,43 @@ 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, - _version: "ref-2.0 (dom, bootstrap-compiled)" + parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null, + morphNode: typeof morphNode === "function" ? morphNode : null, + morphChildren: typeof morphChildren === "function" ? morphChildren : null, + swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null, + process: typeof processElements === "function" ? processElements : null, + executeRequest: typeof executeRequest === "function" ? executeRequest : null, + postSwap: typeof postSwap === "function" ? postSwap : null, + processScripts: typeof processSxScripts === "function" ? processSxScripts : null, + mount: typeof sxMount === "function" ? sxMount : null, + hydrate: typeof sxHydrateElements === "function" ? sxHydrateElements : null, + update: typeof sxUpdateElement === "function" ? sxUpdateElement : null, + renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null, + getEnv: function() { return componentEnv; }, + init: typeof bootInit === "function" ? bootInit : null, + _version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" }; + + // --- Popstate listener --- + if (typeof window !== "undefined") { + window.addEventListener("popstate", function(e) { + handlePopstate(e && e.state ? e.state.scrollY || 0 : 0); + }); + } + + // --- Auto-init --- + if (typeof document !== "undefined") { + var _sxInit = function() { bootInit(); }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _sxInit); + } else { + _sxInit(); + } + } if (typeof module !== "undefined" && module.exports) module.exports = Sx; else global.Sx = Sx; diff --git a/shared/sx/__init__.py b/shared/sx/__init__.py index 60b5c29..93a0eb2 100644 --- a/shared/sx/__init__.py +++ b/shared/sx/__init__.py @@ -51,6 +51,7 @@ from .primitives import ( get_primitive, register_primitive, ) +from . import primitives_stdlib # noqa: F401 — registers stdlib primitives from .env import Env __all__ = [ diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index f9938a4..800f935 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -10,7 +10,7 @@ from __future__ import annotations import math from typing import Any, Callable -from .types import Keyword, Lambda, NIL +from .types import Keyword, NIL # --------------------------------------------------------------------------- @@ -265,12 +265,6 @@ def prim_join(sep: str, coll: list) -> str: def prim_replace(s: str, old: str, new: str) -> str: return s.replace(old, new) -@register_primitive("strip-tags") -def prim_strip_tags(s: str) -> str: - """Strip HTML tags from a string.""" - import re - return re.sub(r"<[^>]+>", "", s) - @register_primitive("slice") def prim_slice(coll: Any, start: int, end: Any = None) -> Any: """Slice a string or list: (slice coll start end?).""" @@ -432,181 +426,3 @@ def prim_into(target: Any, coll: Any) -> Any: return result raise ValueError(f"into: unsupported target type {type(target).__name__}") - -# --------------------------------------------------------------------------- -# Format helpers -# --------------------------------------------------------------------------- - -@register_primitive("format-date") -def prim_format_date(date_str: Any, fmt: str) -> str: - """``(format-date date-str fmt)`` → formatted date string.""" - from datetime import datetime - try: - dt = datetime.fromisoformat(str(date_str)) - return dt.strftime(fmt) - except (ValueError, TypeError): - return str(date_str) if date_str else "" - - -@register_primitive("format-decimal") -def prim_format_decimal(val: Any, places: Any = 2) -> str: - """``(format-decimal val places)`` → formatted decimal string.""" - try: - return f"{float(val):.{int(places)}f}" - except (ValueError, TypeError): - return "0." + "0" * int(places) - - -@register_primitive("parse-int") -def prim_parse_int(val: Any, default: Any = 0) -> int | Any: - """``(parse-int val default?)`` → int(val) with fallback.""" - try: - return int(val) - except (ValueError, TypeError): - return default - - -@register_primitive("parse-datetime") -def prim_parse_datetime(val: Any) -> Any: - """``(parse-datetime "2024-01-15T10:00:00")`` → ISO string or nil.""" - from datetime import datetime - if not val or val is NIL: - return NIL - try: - dt = datetime.fromisoformat(str(val)) - return dt.isoformat() - except (ValueError, TypeError): - return NIL - - -@register_primitive("split-ids") -def prim_split_ids(val: Any) -> list[int]: - """``(split-ids "1,2,3")`` → [1, 2, 3]. Parse comma-separated int IDs.""" - if not val or val is NIL: - return [] - return [int(x.strip()) for x in str(val).split(",") if x.strip()] - - -# --------------------------------------------------------------------------- -# Assertions -# --------------------------------------------------------------------------- - -@register_primitive("assert") -def prim_assert(condition: Any, message: str = "Assertion failed") -> bool: - if not condition: - raise RuntimeError(f"Assertion error: {message}") - return True - - -# --------------------------------------------------------------------------- -# Text helpers -# --------------------------------------------------------------------------- - -@register_primitive("pluralize") -def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str: - """``(pluralize count)`` → "s" if count != 1, else "". - ``(pluralize count "item" "items")`` → "item" or "items".""" - try: - n = int(count) - except (ValueError, TypeError): - n = 0 - if singular or plural != "s": - return singular if n == 1 else plural - return "" if n == 1 else "s" - - -@register_primitive("escape") -def prim_escape(s: Any) -> str: - """``(escape val)`` → HTML-escaped string.""" - from markupsafe import escape as _escape - return str(_escape(str(s) if s is not None and s is not NIL else "")) - - -# --------------------------------------------------------------------------- -# Style primitives -# --------------------------------------------------------------------------- - -@register_primitive("css") -def prim_css(*args: Any) -> Any: - """``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue. - - Accepts keyword atoms (strings without colon prefix) and runtime - strings. Returns a StyleValue with a content-addressed class name - and all resolved CSS declarations. - """ - from .style_resolver import resolve_style - atoms = tuple( - (a.name if isinstance(a, Keyword) else str(a)) - for a in args if a is not None and a is not NIL and a is not False - ) - if not atoms: - return NIL - return resolve_style(atoms) - - -@register_primitive("merge-styles") -def prim_merge_styles(*styles: Any) -> Any: - """``(merge-styles style1 style2)`` → merged StyleValue. - - Merges multiple StyleValues; later declarations win. - """ - from .types import StyleValue - from .style_resolver import merge_styles - valid = [s for s in styles if isinstance(s, StyleValue)] - if not valid: - return NIL - if len(valid) == 1: - return valid[0] - return merge_styles(valid) - - -# --------------------------------------------------------------------------- -# Sync IO bridge primitives -# -# These are declared in boundary.sx (I/O tier), NOT primitives.sx. -# They bypass @register_primitive validation because they aren't pure. -# But they must be evaluator-visible because they're called inline in .sx -# code (inside let, filter, etc.) where the async IO interceptor can't -# reach them — particularly in async_eval_ref.py which only intercepts -# IO at the top level. -# -# The async evaluators also intercept these via IO_PRIMITIVES, so the -# async path works too. This registration ensures the sync fallback works. -# --------------------------------------------------------------------------- - -def _bridge_app_url(service, *path_parts): - from shared.infrastructure.urls import app_url - path = str(path_parts[0]) if path_parts else "/" - return app_url(str(service), path) - -def _bridge_asset_url(*path_parts): - from shared.infrastructure.urls import asset_url - path = str(path_parts[0]) if path_parts else "" - return asset_url(path) - -def _bridge_config(key): - from shared.config import config - cfg = config() - return cfg.get(str(key)) - -def _bridge_jinja_global(key, *default): - from quart import current_app - d = default[0] if default else None - return current_app.jinja_env.globals.get(str(key), d) - -def _bridge_relations_from(entity_type): - from shared.sx.relations import relations_from - return [ - { - "name": d.name, "from_type": d.from_type, "to_type": d.to_type, - "cardinality": d.cardinality, "nav": d.nav, - "nav_icon": d.nav_icon, "nav_label": d.nav_label, - } - for d in relations_from(str(entity_type)) - ] - -_PRIMITIVES["app-url"] = _bridge_app_url -_PRIMITIVES["asset-url"] = _bridge_asset_url -_PRIMITIVES["config"] = _bridge_config -_PRIMITIVES["jinja-global"] = _bridge_jinja_global -_PRIMITIVES["relations-from"] = _bridge_relations_from diff --git a/shared/sx/primitives_ctx.py b/shared/sx/primitives_ctx.py new file mode 100644 index 0000000..d8d1c2f --- /dev/null +++ b/shared/sx/primitives_ctx.py @@ -0,0 +1,544 @@ +""" +Service-specific page context IO handlers. + +These are application-specific (rose-ash), not part of the generic SX +framework. Each handler builds a dict of template data from Quart request +context for use by .sx page components. +""" +from __future__ import annotations + +from typing import Any + +from .primitives_io import register_io_handler + + +# --------------------------------------------------------------------------- +# Root / post headers +# --------------------------------------------------------------------------- + +@register_io_handler("root-header-ctx") +async def _io_root_header_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(root-header-ctx)`` → dict with all root header values. + + Fetches cart-mini, auth-menu, nav-tree fragments and computes + settings-url / is-admin from rights. Result is cached on ``g`` + per request so multiple calls (e.g. header + mobile) are free. + """ + from quart import g, current_app, request + cached = getattr(g, "_root_header_ctx", None) + if cached is not None: + return cached + + from shared.infrastructure.fragments import fetch_fragments + from shared.infrastructure.cart_identity import current_cart_identity + from shared.infrastructure.urls import app_url + from shared.config import config + from .types import NIL + + user = getattr(g, "user", None) + ident = current_cart_identity() + + cart_params: dict[str, Any] = {} + if ident["user_id"] is not None: + cart_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + cart_params["session_id"] = ident["session_id"] + + auth_params: dict[str, Any] = {} + if user and getattr(user, "email", None): + auth_params["email"] = user.email + + nav_params = {"app_name": current_app.name, "path": request.path} + + cart_mini, auth_menu, nav_tree = await fetch_fragments([ + ("cart", "cart-mini", cart_params or None), + ("account", "auth-menu", auth_params or None), + ("blog", "nav-tree", nav_params), + ]) + + rights = getattr(g, "rights", None) or {} + is_admin = ( + rights.get("admin", False) + if isinstance(rights, dict) + else getattr(rights, "admin", False) + ) + + result = { + "cart-mini": cart_mini or NIL, + "blog-url": app_url("blog", ""), + "site-title": config()["title"], + "app-label": current_app.name, + "nav-tree": nav_tree or NIL, + "auth-menu": auth_menu or NIL, + "nav-panel": NIL, + "settings-url": app_url("blog", "/settings/") if is_admin else "", + "is-admin": is_admin, + } + g._root_header_ctx = result + return result + + +@register_io_handler("post-header-ctx") +async def _io_post_header_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(post-header-ctx)`` → dict with post-level header values.""" + from quart import g, request + cached = getattr(g, "_post_header_ctx", None) + if cached is not None: + return cached + + from shared.infrastructure.urls import app_url + from .types import NIL + from .parser import SxExpr + + dctx = getattr(g, "_defpage_ctx", None) or {} + post = dctx.get("post") or {} + slug = post.get("slug", "") + if not slug: + result: dict[str, Any] = {"slug": ""} + g._post_header_ctx = result + return result + + title = (post.get("title") or "")[:160] + feature_image = post.get("feature_image") or NIL + + # Container nav (pre-fetched by page helper into defpage ctx) + raw_nav = dctx.get("container_nav") or "" + container_nav: Any = NIL + nav_str = str(raw_nav).strip() + if nav_str and nav_str.replace("(<>", "").replace(")", "").strip(): + if isinstance(raw_nav, SxExpr): + container_nav = raw_nav + else: + container_nav = SxExpr(nav_str) + + page_cart_count = dctx.get("page_cart_count", 0) or 0 + + rights = getattr(g, "rights", None) or {} + is_admin = ( + rights.get("admin", False) + if isinstance(rights, dict) + else getattr(rights, "admin", False) + ) + + is_admin_page = dctx.get("is_admin_section") or "/admin" in request.path + + from quart import current_app + select_colours = current_app.jinja_env.globals.get("select_colours", "") + + result = { + "slug": slug, + "title": title, + "feature-image": feature_image, + "link-href": app_url("blog", f"/{slug}/"), + "container-nav": container_nav, + "page-cart-count": page_cart_count, + "cart-href": app_url("cart", f"/{slug}/") if page_cart_count else "", + "admin-href": app_url("blog", f"/{slug}/admin/"), + "is-admin": is_admin, + "is-admin-page": is_admin_page or NIL, + "select-colours": select_colours, + } + g._post_header_ctx = result + return result + + +# --------------------------------------------------------------------------- +# Cart +# --------------------------------------------------------------------------- + +@register_io_handler("cart-page-ctx") +async def _io_cart_page_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(cart-page-ctx)`` → dict with cart page header values.""" + from quart import g + from .types import NIL + from shared.infrastructure.urls import app_url + + page_post = getattr(g, "page_post", None) + if not page_post: + return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"} + + slug = getattr(page_post, "slug", "") or "" + title = (getattr(page_post, "title", "") or "")[:160] + feature_image = getattr(page_post, "feature_image", None) or NIL + + return { + "slug": slug, + "title": title, + "feature-image": feature_image, + "page-cart-url": app_url("cart", f"/{slug}/"), + "cart-url": app_url("cart", "/"), + } + + +# --------------------------------------------------------------------------- +# Events +# --------------------------------------------------------------------------- + +@register_io_handler("events-calendar-ctx") +async def _io_events_calendar_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(events-calendar-ctx)`` → dict with events calendar header values.""" + from quart import g + cal = getattr(g, "calendar", None) + if not cal: + dctx = getattr(g, "_defpage_ctx", None) or {} + cal = dctx.get("calendar") + if not cal: + return {"slug": ""} + return { + "slug": getattr(cal, "slug", "") or "", + "name": getattr(cal, "name", "") or "", + "description": getattr(cal, "description", "") or "", + } + + +@register_io_handler("events-day-ctx") +async def _io_events_day_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(events-day-ctx)`` → dict with events day header values.""" + from quart import g, url_for + from .types import NIL + from .parser import SxExpr + + dctx = getattr(g, "_defpage_ctx", None) or {} + cal = getattr(g, "calendar", None) or dctx.get("calendar") + day_date = dctx.get("day_date") or getattr(g, "day_date", None) + if not cal or not day_date: + return {"date-str": ""} + + cal_slug = getattr(cal, "slug", "") or "" + + # Build confirmed entries nav + confirmed = dctx.get("confirmed_entries") or [] + rights = getattr(g, "rights", None) or {} + is_admin = ( + rights.get("admin", False) + if isinstance(rights, dict) + else getattr(rights, "admin", False) + ) + + from .helpers import sx_call + nav_parts: list[str] = [] + if confirmed: + entry_links = [] + for entry in confirmed: + href = url_for( + "calendar.day.calendar_entries.calendar_entry.get", + calendar_slug=cal_slug, + year=day_date.year, month=day_date.month, day=day_date.day, + entry_id=entry.id, + ) + start = entry.start_at.strftime("%H:%M") if entry.start_at else "" + end = ( + f" \u2013 {entry.end_at.strftime('%H:%M')}" + if entry.end_at else "" + ) + entry_links.append(sx_call( + "events-day-entry-link", + href=href, name=entry.name, time_str=f"{start}{end}", + )) + inner = "".join(entry_links) + nav_parts.append(sx_call( + "events-day-entries-nav", inner=SxExpr(inner), + )) + + if is_admin and day_date: + admin_href = url_for( + "defpage_day_admin", calendar_slug=cal_slug, + year=day_date.year, month=day_date.month, day=day_date.day, + ) + nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog")) + + return { + "date-str": day_date.strftime("%A %d %B %Y"), + "year": day_date.year, + "month": day_date.month, + "day": day_date.day, + "nav": SxExpr("".join(nav_parts)) if nav_parts else NIL, + } + + +@register_io_handler("events-entry-ctx") +async def _io_events_entry_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(events-entry-ctx)`` → dict with events entry header values.""" + from quart import g, url_for + from .types import NIL + from .parser import SxExpr + + dctx = getattr(g, "_defpage_ctx", None) or {} + cal = getattr(g, "calendar", None) or dctx.get("calendar") + entry = getattr(g, "entry", None) or dctx.get("entry") + if not cal or not entry: + return {"id": ""} + + cal_slug = getattr(cal, "slug", "") or "" + day = dctx.get("day") + month = dctx.get("month") + year = dctx.get("year") + + # Times + start = entry.start_at + end = entry.end_at + time_str = "" + if start: + time_str = start.strftime("%H:%M") + if end: + time_str += f" \u2192 {end.strftime('%H:%M')}" + + link_href = url_for( + "calendar.day.calendar_entries.calendar_entry.get", + calendar_slug=cal_slug, + year=year, month=month, day=day, entry_id=entry.id, + ) + + # Build nav: associated posts + admin link + entry_posts = dctx.get("entry_posts") or [] + rights = getattr(g, "rights", None) or {} + is_admin = ( + rights.get("admin", False) + if isinstance(rights, dict) + else getattr(rights, "admin", False) + ) + + from .helpers import sx_call + from shared.infrastructure.urls import app_url + + nav_parts: list[str] = [] + if entry_posts: + post_links = "" + for ep in entry_posts: + ep_slug = getattr(ep, "slug", "") + ep_title = getattr(ep, "title", "") + feat = getattr(ep, "feature_image", None) + href = app_url("blog", f"/{ep_slug}/") + if feat: + img_html = sx_call("events-post-img", src=feat, alt=ep_title) + else: + img_html = sx_call("events-post-img-placeholder") + post_links += sx_call( + "events-entry-nav-post-link", + href=href, img=SxExpr(img_html), title=ep_title, + ) + nav_parts.append( + sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links)) + .replace(' :hx-swap-oob "true"', '') + ) + + if is_admin: + admin_url = url_for( + "calendar.day.calendar_entries.calendar_entry.admin.admin", + calendar_slug=cal_slug, + day=day, month=month, year=year, entry_id=entry.id, + ) + nav_parts.append(sx_call("events-entry-admin-link", href=admin_url)) + + # Entry admin nav (ticket_types link) + admin_href = url_for( + "calendar.day.calendar_entries.calendar_entry.admin.admin", + calendar_slug=cal_slug, + day=day, month=month, year=year, entry_id=entry.id, + ) if is_admin else "" + + ticket_types_href = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.get", + calendar_slug=cal_slug, entry_id=entry.id, + year=year, month=month, day=day, + ) + + from quart import current_app + select_colours = current_app.jinja_env.globals.get("select_colours", "") + + return { + "id": str(entry.id), + "name": entry.name or "", + "time-str": time_str, + "link-href": link_href, + "nav": SxExpr("".join(nav_parts)) if nav_parts else NIL, + "admin-href": admin_href, + "ticket-types-href": ticket_types_href, + "is-admin": is_admin, + "select-colours": select_colours, + } + + +@register_io_handler("events-slot-ctx") +async def _io_events_slot_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(events-slot-ctx)`` → dict with events slot header values.""" + from quart import g + dctx = getattr(g, "_defpage_ctx", None) or {} + slot = getattr(g, "slot", None) or dctx.get("slot") + if not slot: + return {"name": ""} + return { + "name": getattr(slot, "name", "") or "", + "description": getattr(slot, "description", "") or "", + } + + +@register_io_handler("events-ticket-type-ctx") +async def _io_events_ticket_type_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(events-ticket-type-ctx)`` → dict with ticket type header values.""" + from quart import g, url_for + + dctx = getattr(g, "_defpage_ctx", None) or {} + cal = getattr(g, "calendar", None) or dctx.get("calendar") + entry = getattr(g, "entry", None) or dctx.get("entry") + ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type") + if not cal or not entry or not ticket_type: + return {"id": ""} + + cal_slug = getattr(cal, "slug", "") or "" + day = dctx.get("day") + month = dctx.get("month") + year = dctx.get("year") + + link_href = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get", + calendar_slug=cal_slug, year=year, month=month, day=day, + entry_id=entry.id, ticket_type_id=ticket_type.id, + ) + + return { + "id": str(ticket_type.id), + "name": getattr(ticket_type, "name", "") or "", + "link-href": link_href, + } + + +# --------------------------------------------------------------------------- +# Market +# --------------------------------------------------------------------------- + +@register_io_handler("market-header-ctx") +async def _io_market_header_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(market-header-ctx)`` → dict with market header data.""" + from quart import g, url_for + from shared.config import config as get_config + from .parser import SxExpr + + cfg = get_config() + market_title = cfg.get("market_title", "") + link_href = url_for("defpage_market_home") + + # Get categories if market is loaded + market = getattr(g, "market", None) + categories = {} + if market: + from bp.browse.services.nav import get_nav + nav_data = await get_nav(g.s, market_id=market.id) + categories = nav_data.get("cats", {}) + + # Build minimal ctx for existing helper functions + select_colours = getattr(g, "select_colours", "") + if not select_colours: + from quart import current_app + select_colours = current_app.jinja_env.globals.get("select_colours", "") + rights = getattr(g, "rights", None) or {} + + mini_ctx: dict[str, Any] = { + "market_title": market_title, + "top_slug": "", + "sub_slug": "", + "categories": categories, + "qs": "", + "hx_select_search": "#main-panel", + "select_colours": select_colours, + "rights": rights, + "category_label": "", + } + + # Build header + mobile nav data via new data-driven helpers + from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx + header_data = _market_header_data(mini_ctx) + mobile_nav = _mobile_nav_panel_sx(mini_ctx) + + return { + "market-title": market_title, + "link-href": link_href, + "top-slug": "", + "sub-slug": "", + "categories": header_data.get("categories", []), + "hx-select": header_data.get("hx-select", "#main-panel"), + "select-colours": header_data.get("select-colours", ""), + "all-href": header_data.get("all-href", ""), + "all-active": header_data.get("all-active", False), + "admin-href": header_data.get("admin-href", ""), + "mobile-nav": SxExpr(mobile_nav) if mobile_nav else "", + } + + +# --------------------------------------------------------------------------- +# Federation +# --------------------------------------------------------------------------- + +@register_io_handler("federation-actor-ctx") +async def _io_federation_actor_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any] | None: + """``(federation-actor-ctx)`` → serialized actor dict or None.""" + from quart import g + actor = getattr(g, "_social_actor", None) + if not actor: + return None + return { + "id": actor.id, + "preferred_username": actor.preferred_username, + "display_name": getattr(actor, "display_name", None), + "icon_url": getattr(actor, "icon_url", None), + "actor_url": getattr(actor, "actor_url", ""), + } + + +# --------------------------------------------------------------------------- +# Misc UI contexts +# --------------------------------------------------------------------------- + +@register_io_handler("select-colours") +async def _io_select_colours( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> str: + """``(select-colours)`` → the shared select/hover CSS class string.""" + from quart import current_app + return current_app.jinja_env.globals.get("select_colours", "") + + +@register_io_handler("account-nav-ctx") +async def _io_account_nav_ctx( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> Any: + """``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL.""" + from quart import g + from .types import NIL + from .parser import SxExpr + from .helpers import sx_call + val = getattr(g, "account_nav", None) + if not val: + return NIL + if isinstance(val, SxExpr): + return val + return sx_call("rich-text", html=str(val)) + + +@register_io_handler("app-rights") +async def _io_app_rights( + args: list[Any], kwargs: dict[str, Any], ctx: Any +) -> dict[str, Any]: + """``(app-rights)`` → user rights dict from ``g.rights``.""" + from quart import g + return getattr(g, "rights", None) or {} diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index fdcb868..83703bd 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -22,57 +22,26 @@ from __future__ import annotations import contextvars from typing import Any + # --------------------------------------------------------------------------- -# Registry of async primitives (name → metadata) +# Registry of async IO handlers (name → coroutine) # --------------------------------------------------------------------------- -# Names that the resolver recognises as I/O nodes requiring async resolution. -# The resolver collects these during tree-walk, groups them, and dispatches -# them in parallel. -IO_PRIMITIVES: frozenset[str] = frozenset({ - "frag", - "query", - "action", - "current-user", - "htmx-request?", - "service", - "request-arg", - "request-path", - "nav-tree", - "get-children", - "g", - "csrf-token", - "abort", - "url-for", - "route-prefix", - "root-header-ctx", - "post-header-ctx", - "select-colours", - "account-nav-ctx", - "app-rights", - "federation-actor-ctx", - "request-view-args", - "cart-page-ctx", - "events-calendar-ctx", - "events-day-ctx", - "events-entry-ctx", - "events-slot-ctx", - "events-ticket-type-ctx", - "market-header-ctx", - "app-url", - "asset-url", - "config", - "jinja-global", - "relations-from", -}) +_IO_HANDLERS: dict[str, Any] = {} + + +def register_io_handler(name: str): + """Decorator that registers an async function as an IO handler.""" + def decorator(fn): + _IO_HANDLERS[name] = fn + return fn + return decorator # --------------------------------------------------------------------------- # Request context (set per-request by the resolver) # --------------------------------------------------------------------------- -# ContextVar for the handler's domain service object. -# Set by the handler blueprint before executing a defhandler. _handler_service: contextvars.ContextVar[Any] = contextvars.ContextVar( "_handler_service", default=None ) @@ -89,10 +58,7 @@ def get_handler_service() -> Any: class RequestContext: - """Per-request context provided to I/O primitives. - - Populated by the resolver from the Quart request before resolution begins. - """ + """Per-request context provided to I/O primitives.""" __slots__ = ("user", "is_htmx", "extras") def __init__( @@ -116,11 +82,7 @@ async def execute_io( kwargs: dict[str, Any], ctx: RequestContext, ) -> Any: - """Execute an I/O primitive by name. - - Called by the resolver after collecting and grouping I/O nodes. - Returns the result to be substituted back into the tree. - """ + """Execute an I/O primitive by name.""" handler = _IO_HANDLERS.get(name) if handler is None: raise RuntimeError(f"Unknown I/O primitive: {name}") @@ -128,7 +90,7 @@ async def execute_io( # --------------------------------------------------------------------------- -# Individual handlers +# Helpers # --------------------------------------------------------------------------- def _clean_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: @@ -137,113 +99,8 @@ def _clean_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in kwargs.items() if v is not None and v is not NIL} -async def _io_frag( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> str: - """``(frag "service" "type" :key val ...)`` → fetch_fragment.""" - if len(args) < 2: - raise ValueError("frag requires service and fragment type") - service = str(args[0]) - frag_type = str(args[1]) - params = _clean_kwargs(kwargs) - - from shared.infrastructure.fragments import fetch_fragment - return await fetch_fragment(service, frag_type, params=params or None) - - -async def _io_query( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> Any: - """``(query "service" "query-name" :key val ...)`` → fetch_data.""" - if len(args) < 2: - raise ValueError("query requires service and query name") - service = str(args[0]) - query_name = str(args[1]) - params = _clean_kwargs(kwargs) - - from shared.infrastructure.data_client import fetch_data - return await fetch_data(service, query_name, params=params or None) - - -async def _io_action( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> Any: - """``(action "service" "action-name" :key val ...)`` → call_action.""" - if len(args) < 2: - raise ValueError("action requires service and action name") - service = str(args[0]) - action_name = str(args[1]) - payload = _clean_kwargs(kwargs) - - from shared.infrastructure.actions import call_action - return await call_action(service, action_name, payload=payload or None) - - -async def _io_current_user( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any] | None: - """``(current-user)`` → user dict from request context.""" - return ctx.user - - -async def _io_htmx_request( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> bool: - """``(htmx-request?)`` → True if HX-Request header present.""" - return ctx.is_htmx - - -async def _io_service( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> Any: - """``(service "svc-name" "method-name" :key val ...)`` → call domain service. - - Looks up the service from the shared registry by name, then calls the - named method with ``g.s`` (async session) + keyword args. Falls back - to the bound handler service if only one positional arg is given. - """ - if not args: - raise ValueError("service requires at least a method name") - - if len(args) >= 2: - # (service "calendar" "associated-entries" :key val ...) - from shared.services.registry import services as svc_registry - svc_name = str(args[0]).replace("-", "_") - svc = getattr(svc_registry, svc_name, None) - if svc is None: - raise RuntimeError(f"No service registered as: {svc_name}") - method_name = str(args[1]).replace("-", "_") - else: - # (service "method-name" :key val ...) — legacy / bound service - svc = get_handler_service() - if svc is None: - raise RuntimeError( - "No handler service bound — cannot call (service ...)") - method_name = str(args[0]).replace("-", "_") - - method = getattr(svc, method_name, None) - if method is None: - raise RuntimeError(f"Service has no method: {method_name}") - - # Convert kwarg keys from kebab-case to snake_case, NIL → None - from .types import NIL - clean_kwargs = { - k.replace("-", "_"): (None if v is NIL else v) - for k, v in kwargs.items() - } - from quart import g - result = await method(g.s, **clean_kwargs) - - return _convert_result(result) - - def _dto_to_dict(obj: Any) -> dict[str, Any]: - """Convert a DTO/dataclass/namedtuple to a plain dict. - - Adds ``{field}_year``, ``{field}_month``, ``{field}_day`` convenience - keys for any datetime-valued field so sx handlers can build URL paths - without parsing date strings. - """ + """Convert a DTO/dataclass/namedtuple to a plain dict.""" if hasattr(obj, "__dataclass_fields__"): from shared.contracts.dtos import dto_to_dict return dto_to_dict(obj) @@ -253,7 +110,6 @@ def _dto_to_dict(obj: Any) -> dict[str, Any]: d = {k: v for k, v in obj.__dict__.items() if not k.startswith("_")} else: return {"value": obj} - # Expand datetime fields into year/month/day convenience keys for key, val in list(d.items()): if hasattr(val, "year") and hasattr(val, "strftime"): d[f"{key}_year"] = val.year @@ -263,23 +119,17 @@ def _dto_to_dict(obj: Any) -> dict[str, Any]: def _convert_result(result: Any) -> Any: - """Convert a service method result for sx consumption. - - Converts DTOs/dataclasses to dicts, datetimes to ISO strings, - and ensures only SX-typed values cross the boundary. - """ + """Convert a service method result for sx consumption.""" if result is None: from .types import NIL return NIL if isinstance(result, (int, float, str, bool)): return result - # datetime → ISO string at the edge if hasattr(result, "isoformat") and callable(result.isoformat): return result.isoformat() if isinstance(result, dict): return {k: _convert_result(v) for k, v in result.items()} if isinstance(result, tuple): - # Tuple returns (e.g. (entries, has_more)) → list for sx access return [_convert_result(item) for item in result] if hasattr(result, "__dataclass_fields__") or hasattr(result, "_asdict"): return _dto_to_dict(result) @@ -293,6 +143,106 @@ def _convert_result(result: Any) -> Any: return result +# --------------------------------------------------------------------------- +# Generic IO handlers +# --------------------------------------------------------------------------- + +@register_io_handler("frag") +async def _io_frag( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> str: + """``(frag "service" "type" :key val ...)`` → fetch_fragment.""" + if len(args) < 2: + raise ValueError("frag requires service and fragment type") + service = str(args[0]) + frag_type = str(args[1]) + params = _clean_kwargs(kwargs) + from shared.infrastructure.fragments import fetch_fragment + return await fetch_fragment(service, frag_type, params=params or None) + + +@register_io_handler("query") +async def _io_query( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(query "service" "query-name" :key val ...)`` → fetch_data.""" + if len(args) < 2: + raise ValueError("query requires service and query name") + service = str(args[0]) + query_name = str(args[1]) + params = _clean_kwargs(kwargs) + from shared.infrastructure.data_client import fetch_data + return await fetch_data(service, query_name, params=params or None) + + +@register_io_handler("action") +async def _io_action( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(action "service" "action-name" :key val ...)`` → call_action.""" + if len(args) < 2: + raise ValueError("action requires service and action name") + service = str(args[0]) + action_name = str(args[1]) + payload = _clean_kwargs(kwargs) + from shared.infrastructure.actions import call_action + return await call_action(service, action_name, payload=payload or None) + + +@register_io_handler("current-user") +async def _io_current_user( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> dict[str, Any] | None: + """``(current-user)`` → user dict from request context.""" + return ctx.user + + +@register_io_handler("htmx-request?") +async def _io_htmx_request( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> bool: + """``(htmx-request?)`` → True if HX-Request header present.""" + return ctx.is_htmx + + +@register_io_handler("service") +async def _io_service( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(service "svc-name" "method-name" :key val ...)`` → call domain service.""" + if not args: + raise ValueError("service requires at least a method name") + + if len(args) >= 2: + from shared.services.registry import services as svc_registry + svc_name = str(args[0]).replace("-", "_") + svc = getattr(svc_registry, svc_name, None) + if svc is None: + raise RuntimeError(f"No service registered as: {svc_name}") + method_name = str(args[1]).replace("-", "_") + else: + svc = get_handler_service() + if svc is None: + raise RuntimeError( + "No handler service bound — cannot call (service ...)") + method_name = str(args[0]).replace("-", "_") + + method = getattr(svc, method_name, None) + if method is None: + raise RuntimeError(f"Service has no method: {method_name}") + + from .types import NIL + clean_kwargs = { + k.replace("-", "_"): (None if v is NIL else v) + for k, v in kwargs.items() + } + from quart import g + result = await method(g.s, **clean_kwargs) + + return _convert_result(result) + + +@register_io_handler("request-arg") async def _io_request_arg( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> Any: @@ -305,6 +255,7 @@ async def _io_request_arg( return request.args.get(name, default) +@register_io_handler("request-path") async def _io_request_path( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: @@ -313,6 +264,7 @@ async def _io_request_path( return request.path +@register_io_handler("nav-tree") async def _io_nav_tree( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> list[dict[str, Any]]: @@ -323,6 +275,7 @@ async def _io_nav_tree( return [_dto_to_dict(node) for node in nodes] +@register_io_handler("get-children") async def _io_get_children( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> list[dict[str, Any]]: @@ -334,23 +287,17 @@ async def _io_get_children( return [_dto_to_dict(child) for child in children] -# --------------------------------------------------------------------------- -# Handler registry -# --------------------------------------------------------------------------- - +@register_io_handler("g") async def _io_g( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> Any: - """``(g "key")`` → getattr(g, key, None). - - Reads a value from the Quart request-local ``g`` object. - Kebab-case keys are converted to snake_case automatically. - """ + """``(g "key")`` → getattr(g, key, None).""" from quart import g key = str(args[0]).replace("-", "_") if args else "" return getattr(g, key, None) +@register_io_handler("csrf-token") async def _io_csrf_token( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: @@ -362,14 +309,11 @@ async def _io_csrf_token( return "" +@register_io_handler("abort") async def _io_abort( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> Any: - """``(abort 403 "message")`` — raise HTTP error from SX. - - Allows defpages to abort with HTTP error codes for auth/ownership - checks without needing a Python page helper. - """ + """``(abort 403 "message")`` — raise HTTP error from SX.""" if not args: raise ValueError("abort requires a status code") from quart import abort @@ -378,26 +322,23 @@ async def _io_abort( abort(status, message) +@register_io_handler("url-for") async def _io_url_for( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: - """``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs). - - Generates a URL for the given endpoint. Keyword args become URL - parameters (kebab-case converted to snake_case). - """ + """``(url-for "endpoint" :key val ...)`` → url_for(endpoint, **kwargs).""" if not args: raise ValueError("url-for requires an endpoint name") from quart import url_for endpoint = str(args[0]) clean = {k.replace("-", "_"): v for k, v in _clean_kwargs(kwargs).items()} - # Convert numeric values for int URL params for k, v in clean.items(): if isinstance(v, str) and v.isdigit(): clean[k] = int(v) return url_for(endpoint, **clean) +@register_io_handler("route-prefix") async def _io_route_prefix( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: @@ -406,230 +347,7 @@ async def _io_route_prefix( return route_prefix() -async def _io_root_header_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(root-header-ctx)`` → dict with all root header values. - - Fetches cart-mini, auth-menu, nav-tree fragments and computes - settings-url / is-admin from rights. Result is cached on ``g`` - per request so multiple calls (e.g. header + mobile) are free. - """ - from quart import g, current_app, request - cached = getattr(g, "_root_header_ctx", None) - if cached is not None: - return cached - - from shared.infrastructure.fragments import fetch_fragments - from shared.infrastructure.cart_identity import current_cart_identity - from shared.infrastructure.urls import app_url - from shared.config import config - from .types import NIL - - user = getattr(g, "user", None) - ident = current_cart_identity() - - cart_params: dict[str, Any] = {} - if ident["user_id"] is not None: - cart_params["user_id"] = ident["user_id"] - if ident["session_id"] is not None: - cart_params["session_id"] = ident["session_id"] - - auth_params: dict[str, Any] = {} - if user and getattr(user, "email", None): - auth_params["email"] = user.email - - nav_params = {"app_name": current_app.name, "path": request.path} - - cart_mini, auth_menu, nav_tree = await fetch_fragments([ - ("cart", "cart-mini", cart_params or None), - ("account", "auth-menu", auth_params or None), - ("blog", "nav-tree", nav_params), - ]) - - rights = getattr(g, "rights", None) or {} - is_admin = ( - rights.get("admin", False) - if isinstance(rights, dict) - else getattr(rights, "admin", False) - ) - - result = { - "cart-mini": cart_mini or NIL, - "blog-url": app_url("blog", ""), - "site-title": config()["title"], - "app-label": current_app.name, - "nav-tree": nav_tree or NIL, - "auth-menu": auth_menu or NIL, - "nav-panel": NIL, - "settings-url": app_url("blog", "/settings/") if is_admin else "", - "is-admin": is_admin, - } - g._root_header_ctx = result - return result - - -async def _io_select_colours( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> str: - """``(select-colours)`` → the shared select/hover CSS class string.""" - from quart import current_app - return current_app.jinja_env.globals.get("select_colours", "") - - -async def _io_account_nav_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> Any: - """``(account-nav-ctx)`` → account nav fragments as SxExpr, or NIL. - - Reads ``g.account_nav`` (set by account service's before_request hook), - wrapping HTML strings in ``~rich-text`` for SX rendering. - """ - from quart import g - from .types import NIL - from .parser import SxExpr - from .helpers import sx_call - val = getattr(g, "account_nav", None) - if not val: - return NIL - if isinstance(val, SxExpr): - return val - # HTML string → wrap for SX rendering - return sx_call("rich-text", html=str(val)) - - -async def _io_app_rights( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(app-rights)`` → user rights dict from ``g.rights``.""" - from quart import g - return getattr(g, "rights", None) or {} - - -async def _io_post_header_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(post-header-ctx)`` → dict with post-level header values. - - Reads post data from ``g._defpage_ctx`` (set by per-service page - helpers), fetches container-nav and page cart count. Result is - cached on ``g`` per request. - - Returns dict with keys: slug, title, feature-image, link-href, - container-nav, page-cart-count, cart-href, admin-href, is-admin, - is-admin-page, select-colours. - """ - from quart import g, request - cached = getattr(g, "_post_header_ctx", None) - if cached is not None: - return cached - - from shared.infrastructure.urls import app_url - from .types import NIL - from .parser import SxExpr - - dctx = getattr(g, "_defpage_ctx", None) or {} - post = dctx.get("post") or {} - slug = post.get("slug", "") - if not slug: - result: dict[str, Any] = {"slug": ""} - g._post_header_ctx = result - return result - - title = (post.get("title") or "")[:160] - feature_image = post.get("feature_image") or NIL - - # Container nav (pre-fetched by page helper into defpage ctx) - raw_nav = dctx.get("container_nav") or "" - container_nav: Any = NIL - nav_str = str(raw_nav).strip() - if nav_str and nav_str.replace("(<>", "").replace(")", "").strip(): - if isinstance(raw_nav, SxExpr): - container_nav = raw_nav - else: - container_nav = SxExpr(nav_str) - - page_cart_count = dctx.get("page_cart_count", 0) or 0 - - rights = getattr(g, "rights", None) or {} - is_admin = ( - rights.get("admin", False) - if isinstance(rights, dict) - else getattr(rights, "admin", False) - ) - - is_admin_page = dctx.get("is_admin_section") or "/admin" in request.path - - from quart import current_app - select_colours = current_app.jinja_env.globals.get("select_colours", "") - - result = { - "slug": slug, - "title": title, - "feature-image": feature_image, - "link-href": app_url("blog", f"/{slug}/"), - "container-nav": container_nav, - "page-cart-count": page_cart_count, - "cart-href": app_url("cart", f"/{slug}/") if page_cart_count else "", - "admin-href": app_url("blog", f"/{slug}/admin/"), - "is-admin": is_admin, - "is-admin-page": is_admin_page or NIL, - "select-colours": select_colours, - } - g._post_header_ctx = result - return result - - -async def _io_cart_page_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(cart-page-ctx)`` → dict with cart page header values. - - Reads ``g.page_post`` (set by cart's before_request) and returns - slug, title, feature-image, and cart-url for the page cart header. - """ - from quart import g - from .types import NIL - from shared.infrastructure.urls import app_url - - page_post = getattr(g, "page_post", None) - if not page_post: - return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"} - - slug = getattr(page_post, "slug", "") or "" - title = (getattr(page_post, "title", "") or "")[:160] - feature_image = getattr(page_post, "feature_image", None) or NIL - - return { - "slug": slug, - "title": title, - "feature-image": feature_image, - "page-cart-url": app_url("cart", f"/{slug}/"), - "cart-url": app_url("cart", "/"), - } - - -async def _io_federation_actor_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any] | None: - """``(federation-actor-ctx)`` → serialized actor dict or None. - - Reads ``g._social_actor`` (set by federation social blueprint's - before_request hook) and serializes to a dict for .sx components. - """ - from quart import g - actor = getattr(g, "_social_actor", None) - if not actor: - return None - return { - "id": actor.id, - "preferred_username": actor.preferred_username, - "display_name": getattr(actor, "display_name", None), - "icon_url": getattr(actor, "icon_url", None), - "actor_url": getattr(actor, "actor_url", ""), - } - - +@register_io_handler("request-view-args") async def _io_request_view_args( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> Any: @@ -641,252 +359,7 @@ async def _io_request_view_args( return (request.view_args or {}).get(key) -async def _io_events_calendar_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(events-calendar-ctx)`` → dict with events calendar header values. - - Reads ``g.calendar`` or ``g._defpage_ctx["calendar"]`` and returns - slug, name, description for the calendar header row. - """ - from quart import g - cal = getattr(g, "calendar", None) - if not cal: - dctx = getattr(g, "_defpage_ctx", None) or {} - cal = dctx.get("calendar") - if not cal: - return {"slug": ""} - return { - "slug": getattr(cal, "slug", "") or "", - "name": getattr(cal, "name", "") or "", - "description": getattr(cal, "description", "") or "", - } - - -async def _io_events_day_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(events-day-ctx)`` → dict with events day header values. - - Reads ``g.day_date``, ``g.calendar``, confirmed entries from - ``g._defpage_ctx``. Pre-builds the confirmed entries nav as SxExpr. - """ - from quart import g, url_for - from .types import NIL - from .parser import SxExpr - - dctx = getattr(g, "_defpage_ctx", None) or {} - cal = getattr(g, "calendar", None) or dctx.get("calendar") - day_date = dctx.get("day_date") or getattr(g, "day_date", None) - if not cal or not day_date: - return {"date-str": ""} - - cal_slug = getattr(cal, "slug", "") or "" - - # Build confirmed entries nav - confirmed = dctx.get("confirmed_entries") or [] - rights = getattr(g, "rights", None) or {} - is_admin = ( - rights.get("admin", False) - if isinstance(rights, dict) - else getattr(rights, "admin", False) - ) - - from .helpers import sx_call - nav_parts: list[str] = [] - if confirmed: - entry_links = [] - for entry in confirmed: - href = url_for( - "calendar.day.calendar_entries.calendar_entry.get", - calendar_slug=cal_slug, - year=day_date.year, month=day_date.month, day=day_date.day, - entry_id=entry.id, - ) - start = entry.start_at.strftime("%H:%M") if entry.start_at else "" - end = ( - f" \u2013 {entry.end_at.strftime('%H:%M')}" - if entry.end_at else "" - ) - entry_links.append(sx_call( - "events-day-entry-link", - href=href, name=entry.name, time_str=f"{start}{end}", - )) - inner = "".join(entry_links) - nav_parts.append(sx_call( - "events-day-entries-nav", inner=SxExpr(inner), - )) - - if is_admin and day_date: - admin_href = url_for( - "defpage_day_admin", calendar_slug=cal_slug, - year=day_date.year, month=day_date.month, day=day_date.day, - ) - nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog")) - - return { - "date-str": day_date.strftime("%A %d %B %Y"), - "year": day_date.year, - "month": day_date.month, - "day": day_date.day, - "nav": SxExpr("".join(nav_parts)) if nav_parts else NIL, - } - - -async def _io_events_entry_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(events-entry-ctx)`` → dict with events entry header values. - - Reads ``g.entry``, ``g.calendar``, and entry_posts from - ``g._defpage_ctx``. Pre-builds entry nav (posts + admin link) as SxExpr. - """ - from quart import g, url_for - from .types import NIL - from .parser import SxExpr - - dctx = getattr(g, "_defpage_ctx", None) or {} - cal = getattr(g, "calendar", None) or dctx.get("calendar") - entry = getattr(g, "entry", None) or dctx.get("entry") - if not cal or not entry: - return {"id": ""} - - cal_slug = getattr(cal, "slug", "") or "" - day = dctx.get("day") - month = dctx.get("month") - year = dctx.get("year") - - # Times - start = entry.start_at - end = entry.end_at - time_str = "" - if start: - time_str = start.strftime("%H:%M") - if end: - time_str += f" \u2192 {end.strftime('%H:%M')}" - - link_href = url_for( - "calendar.day.calendar_entries.calendar_entry.get", - calendar_slug=cal_slug, - year=year, month=month, day=day, entry_id=entry.id, - ) - - # Build nav: associated posts + admin link - entry_posts = dctx.get("entry_posts") or [] - rights = getattr(g, "rights", None) or {} - is_admin = ( - rights.get("admin", False) - if isinstance(rights, dict) - else getattr(rights, "admin", False) - ) - - from .helpers import sx_call - from shared.infrastructure.urls import app_url - - nav_parts: list[str] = [] - if entry_posts: - post_links = "" - for ep in entry_posts: - ep_slug = getattr(ep, "slug", "") - ep_title = getattr(ep, "title", "") - feat = getattr(ep, "feature_image", None) - href = app_url("blog", f"/{ep_slug}/") - if feat: - img_html = sx_call("events-post-img", src=feat, alt=ep_title) - else: - img_html = sx_call("events-post-img-placeholder") - post_links += sx_call( - "events-entry-nav-post-link", - href=href, img=SxExpr(img_html), title=ep_title, - ) - nav_parts.append( - sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links)) - .replace(' :hx-swap-oob "true"', '') - ) - - if is_admin: - admin_url = url_for( - "calendar.day.calendar_entries.calendar_entry.admin.admin", - calendar_slug=cal_slug, - day=day, month=month, year=year, entry_id=entry.id, - ) - nav_parts.append(sx_call("events-entry-admin-link", href=admin_url)) - - # Entry admin nav (ticket_types link) - admin_href = url_for( - "calendar.day.calendar_entries.calendar_entry.admin.admin", - calendar_slug=cal_slug, - day=day, month=month, year=year, entry_id=entry.id, - ) if is_admin else "" - - ticket_types_href = url_for( - "calendar.day.calendar_entries.calendar_entry.ticket_types.get", - calendar_slug=cal_slug, entry_id=entry.id, - year=year, month=month, day=day, - ) - - from quart import current_app - select_colours = current_app.jinja_env.globals.get("select_colours", "") - - return { - "id": str(entry.id), - "name": entry.name or "", - "time-str": time_str, - "link-href": link_href, - "nav": SxExpr("".join(nav_parts)) if nav_parts else NIL, - "admin-href": admin_href, - "ticket-types-href": ticket_types_href, - "is-admin": is_admin, - "select-colours": select_colours, - } - - -async def _io_events_slot_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(events-slot-ctx)`` → dict with events slot header values.""" - from quart import g - dctx = getattr(g, "_defpage_ctx", None) or {} - slot = getattr(g, "slot", None) or dctx.get("slot") - if not slot: - return {"name": ""} - return { - "name": getattr(slot, "name", "") or "", - "description": getattr(slot, "description", "") or "", - } - - -async def _io_events_ticket_type_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(events-ticket-type-ctx)`` → dict with ticket type header values.""" - from quart import g, url_for - - dctx = getattr(g, "_defpage_ctx", None) or {} - cal = getattr(g, "calendar", None) or dctx.get("calendar") - entry = getattr(g, "entry", None) or dctx.get("entry") - ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type") - if not cal or not entry or not ticket_type: - return {"id": ""} - - cal_slug = getattr(cal, "slug", "") or "" - day = dctx.get("day") - month = dctx.get("month") - year = dctx.get("year") - - link_href = url_for( - "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get", - calendar_slug=cal_slug, year=year, month=month, day=day, - entry_id=entry.id, ticket_type_id=ticket_type.id, - ) - - return { - "id": str(ticket_type.id), - "name": getattr(ticket_type, "name", "") or "", - "link-href": link_href, - } - - +@register_io_handler("app-url") async def _io_app_url( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: @@ -899,6 +372,7 @@ async def _io_app_url( return app_url(service, path) +@register_io_handler("asset-url") async def _io_asset_url( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> str: @@ -908,6 +382,7 @@ async def _io_asset_url( return asset_url(path) +@register_io_handler("config") async def _io_config( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> Any: @@ -919,6 +394,7 @@ async def _io_config( return cfg.get(str(args[0])) +@register_io_handler("jinja-global") async def _io_jinja_global( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> Any: @@ -931,6 +407,7 @@ async def _io_jinja_global( return current_app.jinja_env.globals.get(key, default) +@register_io_handler("relations-from") async def _io_relations_from( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext ) -> list[dict]: @@ -948,113 +425,78 @@ async def _io_relations_from( ] -async def _io_market_header_ctx( - args: list[Any], kwargs: dict[str, Any], ctx: RequestContext -) -> dict[str, Any]: - """``(market-header-ctx)`` → dict with market header data. +# --------------------------------------------------------------------------- +# Import context handlers (registers into _IO_HANDLERS via decorator) +# --------------------------------------------------------------------------- - Returns plain data (categories list, hrefs, flags) for the - ~market-header-auto macro. Mobile nav is pre-built as SxExpr. - """ - from quart import g, url_for - from shared.config import config as get_config - from .parser import SxExpr - - cfg = get_config() - market_title = cfg.get("market_title", "") - link_href = url_for("defpage_market_home") - - # Get categories if market is loaded - market = getattr(g, "market", None) - categories = {} - if market: - from bp.browse.services.nav import get_nav - nav_data = await get_nav(g.s, market_id=market.id) - categories = nav_data.get("cats", {}) - - # Build minimal ctx for existing helper functions - select_colours = getattr(g, "select_colours", "") - if not select_colours: - from quart import current_app - select_colours = current_app.jinja_env.globals.get("select_colours", "") - rights = getattr(g, "rights", None) or {} - - mini_ctx: dict[str, Any] = { - "market_title": market_title, - "top_slug": "", - "sub_slug": "", - "categories": categories, - "qs": "", - "hx_select_search": "#main-panel", - "select_colours": select_colours, - "rights": rights, - "category_label": "", - } - - # Build header + mobile nav data via new data-driven helpers - from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx - header_data = _market_header_data(mini_ctx) - mobile_nav = _mobile_nav_panel_sx(mini_ctx) - - return { - "market-title": market_title, - "link-href": link_href, - "top-slug": "", - "sub-slug": "", - "categories": header_data.get("categories", []), - "hx-select": header_data.get("hx-select", "#main-panel"), - "select-colours": header_data.get("select-colours", ""), - "all-href": header_data.get("all-href", ""), - "all-active": header_data.get("all-active", False), - "admin-href": header_data.get("admin-href", ""), - "mobile-nav": SxExpr(mobile_nav) if mobile_nav else "", - } +from . import primitives_ctx # noqa: E402, F401 -_IO_HANDLERS: dict[str, Any] = { - "frag": _io_frag, - "query": _io_query, - "action": _io_action, - "current-user": _io_current_user, - "htmx-request?": _io_htmx_request, - "service": _io_service, - "request-arg": _io_request_arg, - "request-path": _io_request_path, - "nav-tree": _io_nav_tree, - "get-children": _io_get_children, - "g": _io_g, - "csrf-token": _io_csrf_token, - "abort": _io_abort, - "url-for": _io_url_for, - "route-prefix": _io_route_prefix, - "root-header-ctx": _io_root_header_ctx, - "post-header-ctx": _io_post_header_ctx, - "select-colours": _io_select_colours, - "account-nav-ctx": _io_account_nav_ctx, - "app-rights": _io_app_rights, - "federation-actor-ctx": _io_federation_actor_ctx, - "request-view-args": _io_request_view_args, - "cart-page-ctx": _io_cart_page_ctx, - "events-calendar-ctx": _io_events_calendar_ctx, - "events-day-ctx": _io_events_day_ctx, - "events-entry-ctx": _io_events_entry_ctx, - "events-slot-ctx": _io_events_slot_ctx, - "events-ticket-type-ctx": _io_events_ticket_type_ctx, - "market-header-ctx": _io_market_header_ctx, - "app-url": _io_app_url, - "asset-url": _io_asset_url, - "config": _io_config, - "jinja-global": _io_jinja_global, - "relations-from": _io_relations_from, -} +# --------------------------------------------------------------------------- +# Auto-derive IO_PRIMITIVES from registered handlers +# --------------------------------------------------------------------------- + +IO_PRIMITIVES: frozenset[str] = frozenset(_IO_HANDLERS.keys()) + + +# --------------------------------------------------------------------------- +# Sync IO bridge primitives +# +# These are declared in boundary.sx (I/O tier), NOT primitives.sx. +# They must be evaluator-visible because they're called inline in .sx +# code (inside let, filter, etc.) where the async IO interceptor can't +# reach them — particularly in sx_ref.py which only intercepts IO at +# the top level. +# --------------------------------------------------------------------------- + +from .primitives import _PRIMITIVES # noqa: E402 + + +def _bridge_app_url(service, *path_parts): + from shared.infrastructure.urls import app_url + path = str(path_parts[0]) if path_parts else "/" + return app_url(str(service), path) + +def _bridge_asset_url(*path_parts): + from shared.infrastructure.urls import asset_url + path = str(path_parts[0]) if path_parts else "" + return asset_url(path) + +def _bridge_config(key): + from shared.config import config + cfg = config() + return cfg.get(str(key)) + +def _bridge_jinja_global(key, *default): + from quart import current_app + d = default[0] if default else None + return current_app.jinja_env.globals.get(str(key), d) + +def _bridge_relations_from(entity_type): + from shared.sx.relations import relations_from + return [ + { + "name": d.name, "from_type": d.from_type, "to_type": d.to_type, + "cardinality": d.cardinality, "nav": d.nav, + "nav_icon": d.nav_icon, "nav_label": d.nav_label, + } + for d in relations_from(str(entity_type)) + ] + +_PRIMITIVES["app-url"] = _bridge_app_url +_PRIMITIVES["asset-url"] = _bridge_asset_url +_PRIMITIVES["config"] = _bridge_config +_PRIMITIVES["jinja-global"] = _bridge_jinja_global +_PRIMITIVES["relations-from"] = _bridge_relations_from + + +# --------------------------------------------------------------------------- +# Validate all IO handlers against boundary.sx +# --------------------------------------------------------------------------- -# Validate all I/O handlers are declared in boundary.sx def _validate_io_handlers() -> None: from .boundary import validate_io for name in _IO_HANDLERS: validate_io(name) - for name in IO_PRIMITIVES: - if name not in _IO_HANDLERS: - validate_io(name) _validate_io_handlers() diff --git a/shared/sx/primitives_stdlib.py b/shared/sx/primitives_stdlib.py new file mode 100644 index 0000000..d019339 --- /dev/null +++ b/shared/sx/primitives_stdlib.py @@ -0,0 +1,131 @@ +""" +Standard library primitives — isomorphic, opt-in modules. + +Augment core with format, text, style, and debug primitives. +These are registered into the same _PRIMITIVES registry as core. +""" +from __future__ import annotations + +from typing import Any + +from .primitives import register_primitive +from .types import NIL + + +# --------------------------------------------------------------------------- +# stdlib.format +# --------------------------------------------------------------------------- + +@register_primitive("format-date") +def prim_format_date(date_str: Any, fmt: str) -> str: + """``(format-date date-str fmt)`` → formatted date string.""" + from datetime import datetime + try: + dt = datetime.fromisoformat(str(date_str)) + return dt.strftime(fmt) + except (ValueError, TypeError): + return str(date_str) if date_str else "" + + +@register_primitive("format-decimal") +def prim_format_decimal(val: Any, places: Any = 2) -> str: + """``(format-decimal val places)`` → formatted decimal string.""" + try: + return f"{float(val):.{int(places)}f}" + except (ValueError, TypeError): + return "0." + "0" * int(places) + + +@register_primitive("parse-int") +def prim_parse_int(val: Any, default: Any = 0) -> int | Any: + """``(parse-int val default?)`` → int(val) with fallback.""" + try: + return int(val) + except (ValueError, TypeError): + return default + + +@register_primitive("parse-datetime") +def prim_parse_datetime(val: Any) -> Any: + """``(parse-datetime "2024-01-15T10:00:00")`` → ISO string or nil.""" + from datetime import datetime + if not val or val is NIL: + return NIL + try: + dt = datetime.fromisoformat(str(val)) + return dt.isoformat() + except (ValueError, TypeError): + return NIL + + +# --------------------------------------------------------------------------- +# stdlib.text +# --------------------------------------------------------------------------- + +@register_primitive("pluralize") +def prim_pluralize(count: Any, singular: str = "", plural: str = "s") -> str: + """``(pluralize count)`` → "s" if count != 1, else "". + ``(pluralize count "item" "items")`` → "item" or "items".""" + try: + n = int(count) + except (ValueError, TypeError): + n = 0 + if singular or plural != "s": + return singular if n == 1 else plural + return "" if n == 1 else "s" + + +@register_primitive("escape") +def prim_escape(s: Any) -> str: + """``(escape val)`` → HTML-escaped string.""" + from markupsafe import escape as _escape + return str(_escape(str(s) if s is not None and s is not NIL else "")) + + +@register_primitive("strip-tags") +def prim_strip_tags(s: str) -> str: + """Strip HTML tags from a string.""" + import re + return re.sub(r"<[^>]+>", "", s) + + +# --------------------------------------------------------------------------- +# stdlib.style +# --------------------------------------------------------------------------- + +@register_primitive("css") +def prim_css(*args: Any) -> Any: + """``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue.""" + from .types import Keyword + from .style_resolver import resolve_style + atoms = tuple( + (a.name if isinstance(a, Keyword) else str(a)) + for a in args if a is not None and a is not NIL and a is not False + ) + if not atoms: + return NIL + return resolve_style(atoms) + + +@register_primitive("merge-styles") +def prim_merge_styles(*styles: Any) -> Any: + """``(merge-styles style1 style2)`` → merged StyleValue.""" + from .types import StyleValue + from .style_resolver import merge_styles + valid = [s for s in styles if isinstance(s, StyleValue)] + if not valid: + return NIL + if len(valid) == 1: + return valid[0] + return merge_styles(valid) + + +# --------------------------------------------------------------------------- +# stdlib.debug +# --------------------------------------------------------------------------- + +@register_primitive("assert") +def prim_assert(condition: Any, message: str = "Assertion failed") -> bool: + if not condition: + raise RuntimeError(f"Assertion error: {message}") + return True diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 88c6239..e62718b 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -996,13 +996,19 @@ ADAPTER_DEPS = { } -def compile_ref_to_js(adapters: list[str] | None = None) -> str: +def compile_ref_to_js( + adapters: list[str] | None = None, + modules: list[str] | None = None, +) -> str: """Read reference .sx files and emit JavaScript. Args: adapters: List of adapter names to include. Valid names: html, sx, dom, engine. None = include all adapters. + modules: List of primitive module names to include. + core.* are always included. stdlib.* are opt-in. + None = include all modules (backward compatible). """ ref_dir = os.path.dirname(os.path.abspath(__file__)) emitter = JSEmitter() @@ -1060,9 +1066,25 @@ def compile_ref_to_js(adapters: list[str] | None = None) -> str: has_parser = "parser" in adapter_set adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only" + # Determine which primitive modules to include + prim_modules = None # None = all + if modules is not None: + prim_modules = [m for m in _ALL_JS_MODULES if m.startswith("core.")] + for m in modules: + if m not in prim_modules: + if m not in PRIMITIVES_JS_MODULES: + raise ValueError(f"Unknown module: {m!r}. Valid: {', '.join(PRIMITIVES_JS_MODULES)}") + prim_modules.append(m) + parts = [] parts.append(PREAMBLE) - parts.append(PLATFORM_JS) + parts.append(PLATFORM_JS_PRE) + parts.append('\n // =========================================================================') + parts.append(' // Primitives') + parts.append(' // =========================================================================\n') + parts.append(' var PRIMITIVES = {};') + parts.append(_assemble_primitives_js(prim_modules)) + parts.append(PLATFORM_JS_POST) # Parser platform must come before compiled parser.sx if has_parser: @@ -1181,7 +1203,235 @@ PREAMBLE = '''\ return arguments.length ? arguments[arguments.length - 1] : false; }''' -PLATFORM_JS = ''' +# --------------------------------------------------------------------------- +# Primitive modules — JS implementations keyed by spec module name. +# core.* modules are always included; stdlib.* are opt-in. +# --------------------------------------------------------------------------- + +PRIMITIVES_JS_MODULES: dict[str, str] = { + "core.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; }; + PRIMITIVES["/"] = function(a, b) { return a / b; }; + PRIMITIVES["mod"] = function(a, b) { return a % b; }; + PRIMITIVES["inc"] = function(n) { return n + 1; }; + PRIMITIVES["dec"] = function(n) { return n - 1; }; + PRIMITIVES["abs"] = Math.abs; + PRIMITIVES["floor"] = Math.floor; + PRIMITIVES["ceil"] = Math.ceil; + 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)); }; +''', + + "core.comparison": ''' + // 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; }; +''', + + "core.logic": ''' + // core.logic + PRIMITIVES["not"] = function(x) { return !isSxTruthy(x); }; +''', + + "core.predicates": ''' + // core.predicates + PRIMITIVES["nil?"] = isNil; + PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; + PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; + PRIMITIVES["list?"] = Array.isArray; + PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; + PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; + PRIMITIVES["contains?"] = function(c, k) { + if (typeof c === "string") return c.indexOf(String(k)) !== -1; + if (Array.isArray(c)) return c.indexOf(k) !== -1; + return k in c; + }; + PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; + PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; + PRIMITIVES["zero?"] = function(n) { return n === 0; }; +''', + + "core.strings": ''' + // 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["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": ''' + // core.collections + PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; + PRIMITIVES["dict"] = function() { + var d = {}; + for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1]; + return d; + }; + PRIMITIVES["range"] = function(a, b, step) { + var r = []; step = step || 1; + for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i); + return r; + }; + PRIMITIVES["get"] = function(c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); }; + PRIMITIVES["len"] = function(c) { return Array.isArray(c) ? c.length : typeof c === "string" ? c.length : Object.keys(c).length; }; + PRIMITIVES["first"] = function(c) { return c && c.length > 0 ? c[0] : NIL; }; + PRIMITIVES["last"] = function(c) { return c && c.length > 0 ? c[c.length - 1] : NIL; }; + PRIMITIVES["rest"] = function(c) { return c ? c.slice(1) : []; }; + 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": ''' + // 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() { + var out = {}; + for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; } + return out; + }; + PRIMITIVES["assoc"] = function(d) { + var out = {}; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1]; + return out; + }; + PRIMITIVES["dissoc"] = function(d) { + var out = {}; for (var k in d) out[k] = d[k]; + for (var i = 1; i < arguments.length; i++) delete out[arguments[i]]; + return out; + }; + 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; + }; +''', + + "stdlib.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["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; +''', + + "stdlib.text": ''' + // 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,"'"); + }; + PRIMITIVES["strip-tags"] = function(s) { return String(s).replace(/<[^>]+>/g, ""); }; +''', + + "stdlib.style": ''' + // stdlib.style + PRIMITIVES["css"] = function() { + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); + }; + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); + return new StyleValue("sx-merged", allDecls, [], [], []); + }; +''', + + "stdlib.debug": ''' + // stdlib.debug + PRIMITIVES["assert"] = function(cond, msg) { + if (!isSxTruthy(cond)) throw new Error("Assertion error: " + (msg || "Assertion failed")); + return true; + }; +''', +} + +# Modules to include by default (all) +_ALL_JS_MODULES = list(PRIMITIVES_JS_MODULES.keys()) + +# Selected primitive modules for current compilation (None = all) + + +def _assemble_primitives_js(modules: list[str] | None = None) -> str: + """Assemble JS primitive code from selected modules. + + If modules is None, all modules are included. + Core modules are always included regardless of the list. + """ + if modules is None: + modules = _ALL_JS_MODULES + parts = [] + for mod in modules: + if mod in PRIMITIVES_JS_MODULES: + parts.append(PRIMITIVES_JS_MODULES[mod]) + return "\n".join(parts) + + +PLATFORM_JS_PRE = ''' // ========================================================================= // Platform interface — JS implementation // ========================================================================= @@ -1305,180 +1555,9 @@ PLATFORM_JS = ''' function error(msg) { throw new Error(msg); } function inspect(x) { return JSON.stringify(x); } - // ========================================================================= - // Primitives - // ========================================================================= - - var PRIMITIVES = {}; - - // 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; }; - PRIMITIVES["/"] = function(a, b) { return a / b; }; - PRIMITIVES["mod"] = function(a, b) { return a % b; }; - PRIMITIVES["inc"] = function(n) { return n + 1; }; - PRIMITIVES["dec"] = function(n) { return n - 1; }; - PRIMITIVES["abs"] = Math.abs; - PRIMITIVES["floor"] = Math.floor; - PRIMITIVES["ceil"] = Math.ceil; - PRIMITIVES["round"] = Math.round; - 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; }; - 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 - 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 - PRIMITIVES["nil?"] = isNil; - PRIMITIVES["number?"] = function(x) { return typeof x === "number"; }; - PRIMITIVES["string?"] = function(x) { return typeof x === "string"; }; - PRIMITIVES["list?"] = Array.isArray; - PRIMITIVES["dict?"] = function(x) { return x !== null && typeof x === "object" && !Array.isArray(x) && !x._sym && !x._kw; }; - PRIMITIVES["empty?"] = function(c) { return isNil(c) || (Array.isArray(c) ? c.length === 0 : typeof c === "string" ? c.length === 0 : Object.keys(c).length === 0); }; - PRIMITIVES["contains?"] = function(c, k) { - if (typeof c === "string") return c.indexOf(String(k)) !== -1; - if (Array.isArray(c)) return c.indexOf(k) !== -1; - return k in c; - }; - PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; }; - PRIMITIVES["even?"] = function(n) { return n % 2 === 0; }; - PRIMITIVES["zero?"] = function(n) { return n === 0; }; - - // Collections - PRIMITIVES["list"] = function() { return Array.prototype.slice.call(arguments); }; - PRIMITIVES["dict"] = function() { - var d = {}; - for (var i = 0; i < arguments.length - 1; i += 2) d[arguments[i]] = arguments[i + 1]; - return d; - }; - PRIMITIVES["range"] = function(a, b, step) { - var r = []; step = step || 1; - for (var i = a; step > 0 ? i < b : i > b; i += step) r.push(i); - return r; - }; - PRIMITIVES["get"] = function(c, k, def) { var v = (c && c[k]); return v !== undefined ? v : (def !== undefined ? def : NIL); }; - PRIMITIVES["len"] = function(c) { return Array.isArray(c) ? c.length : typeof c === "string" ? c.length : Object.keys(c).length; }; - PRIMITIVES["first"] = function(c) { return c && c.length > 0 ? c[0] : NIL; }; - PRIMITIVES["last"] = function(c) { return c && c.length > 0 ? c[c.length - 1] : NIL; }; - PRIMITIVES["rest"] = function(c) { return c ? c.slice(1) : []; }; - 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["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() { - var out = {}; - for (var i = 0; i < arguments.length; i++) { var d = arguments[i]; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; } - return out; - }; - PRIMITIVES["assoc"] = function(d) { - var out = {}; if (d && !isNil(d)) for (var k in d) out[k] = d[k]; - for (var i = 1; i < arguments.length - 1; i += 2) out[arguments[i]] = arguments[i + 1]; - return out; - }; - PRIMITIVES["dissoc"] = function(d) { - var out = {}; for (var k in d) out[k] = d[k]; - 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 - 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,"""); - }; - PRIMITIVES["format-date"] = function(s, fmt) { - if (!s) return ""; - try { - var d = new Date(s); - if (isNaN(d.getTime())) return String(s); - var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; - var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; - return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) - .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) - .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) - .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); - } 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; }); - }; - 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]; - if (isNil(a) || a === false) continue; - atoms.push(isKw(a) ? a.name : String(a)); - } - if (!atoms.length) return NIL; - return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); - }; - PRIMITIVES["merge-styles"] = function() { - var valid = []; - for (var i = 0; i < arguments.length; i++) { - if (isStyleValue(arguments[i])) valid.push(arguments[i]); - } - if (!valid.length) return NIL; - if (valid.length === 1) return valid[0]; - var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); - return new StyleValue("sx-merged", allDecls, [], [], []); - }; +''' +PLATFORM_JS_POST = ''' function isPrimitive(name) { return name in PRIMITIVES; } function getPrimitive(name) { return PRIMITIVES[name]; } @@ -2825,18 +2904,22 @@ if __name__ == "__main__": p = argparse.ArgumentParser(description="Bootstrap-compile SX reference spec to JavaScript") p.add_argument("--adapters", "-a", help="Comma-separated adapter list (html,sx,dom,engine). Default: all") + p.add_argument("--modules", "-m", + help="Comma-separated primitive modules (core.* always included). Default: all") p.add_argument("--output", "-o", help="Output file (default: stdout)") args = p.parse_args() adapters = args.adapters.split(",") if args.adapters else None - js = compile_ref_to_js(adapters) + modules = args.modules.split(",") if args.modules else None + js = compile_ref_to_js(adapters, modules) if args.output: with open(args.output, "w") as f: f.write(js) included = ", ".join(adapters) if adapters else "all" - print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included})", + mods = ", ".join(modules) if modules else "all" + print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods})", file=sys.stderr) else: print(js) diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index af8d831..49b4661 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -801,14 +801,30 @@ ADAPTER_FILES = { } -def compile_ref_to_py(adapters: list[str] | None = None) -> str: +def compile_ref_to_py( + adapters: list[str] | None = None, + modules: list[str] | None = None, +) -> str: """Read reference .sx files and emit Python. Args: adapters: List of adapter names to include. Valid names: html, sx. None = include all server-side adapters. + modules: List of primitive module names to include. + core.* are always included. stdlib.* are opt-in. + None = include all modules (backward compatible). """ + # Determine which primitive modules to include + prim_modules = None # None = all + if modules is not None: + prim_modules = [m for m in _ALL_PY_MODULES if m.startswith("core.")] + for m in modules: + if m not in prim_modules: + if m not in PRIMITIVES_PY_MODULES: + raise ValueError(f"Unknown module: {m!r}. Valid: {', '.join(PRIMITIVES_PY_MODULES)}") + prim_modules.append(m) + ref_dir = os.path.dirname(os.path.abspath(__file__)) emitter = PyEmitter() @@ -849,7 +865,9 @@ def compile_ref_to_py(adapters: list[str] | None = None) -> str: parts = [] parts.append(PREAMBLE) parts.append(PLATFORM_PY) - parts.append(PRIMITIVES_PY) + parts.append(PRIMITIVES_PY_PRE) + parts.append(_assemble_primitives_py(prim_modules)) + parts.append(PRIMITIVES_PY_POST) for label, defines in all_sections: parts.append(f"\n# === Transpiled from {label} ===\n") @@ -1506,29 +1524,14 @@ def aser_special(name, expr, env): return trampoline(result) ''' -PRIMITIVES_PY = ''' -# ========================================================================= -# Primitives -# ========================================================================= +# --------------------------------------------------------------------------- +# Primitive modules — Python implementations keyed by spec module name. +# core.* modules are always included; stdlib.* are opt-in. +# --------------------------------------------------------------------------- -# Save builtins before shadowing -_b_len = len -_b_map = map -_b_filter = filter -_b_range = range -_b_list = list -_b_dict = dict -_b_max = max -_b_min = min -_b_round = round -_b_abs = abs -_b_sum = sum -_b_zip = zip -_b_int = int - -PRIMITIVES = {} - -# Arithmetic +PRIMITIVES_PY_MODULES: dict[str, str] = { + "core.arithmetic": ''' +# core.arithmetic PRIMITIVES["+"] = lambda *args: _b_sum(args) PRIMITIVES["-"] = lambda a, b=None: -a if b is None else a - b PRIMITIVES["*"] = lambda *args: _sx_mul(*args) @@ -1551,37 +1554,25 @@ def _sx_mul(*args): for a in args: r *= a return r +''', -# Comparison + "core.comparison": ''' +# core.comparison PRIMITIVES["="] = lambda a, b: a == b PRIMITIVES["!="] = lambda a, b: a != b PRIMITIVES["<"] = lambda a, b: a < b PRIMITIVES[">"] = lambda a, b: a > b PRIMITIVES["<="] = lambda a, b: a <= b PRIMITIVES[">="] = lambda a, b: a >= b +''', -# Logic + "core.logic": ''' +# core.logic PRIMITIVES["not"] = lambda x: not sx_truthy(x) +''', -# String -PRIMITIVES["str"] = sx_str -PRIMITIVES["upper"] = lambda s: str(s).upper() -PRIMITIVES["lower"] = lambda s: str(s).lower() -PRIMITIVES["trim"] = lambda s: str(s).strip() -PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep) -PRIMITIVES["join"] = lambda sep, coll: sep.join(coll) -PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new) -PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p) -PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p) -PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:] -PRIMITIVES["concat"] = lambda *args: _b_sum((a for a in args if a), []) -PRIMITIVES["strip-tags"] = lambda s: _strip_tags(str(s)) - -import re as _re -def _strip_tags(s): - return _re.sub(r"<[^>]+>", "", s) - -# Predicates + "core.predicates": ''' +# core.predicates PRIMITIVES["nil?"] = lambda x: x is None or x is NIL PRIMITIVES["number?"] = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) PRIMITIVES["string?"] = lambda x: isinstance(x, str) @@ -1598,8 +1589,25 @@ PRIMITIVES["contains?"] = lambda c, k: ( PRIMITIVES["odd?"] = lambda n: n % 2 != 0 PRIMITIVES["even?"] = lambda n: n % 2 == 0 PRIMITIVES["zero?"] = lambda n: n == 0 +''', -# Collections + "core.strings": ''' +# core.strings +PRIMITIVES["str"] = sx_str +PRIMITIVES["upper"] = lambda s: str(s).upper() +PRIMITIVES["lower"] = lambda s: str(s).lower() +PRIMITIVES["trim"] = lambda s: str(s).strip() +PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep) +PRIMITIVES["join"] = lambda sep, coll: sep.join(coll) +PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new) +PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p) +PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p) +PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:] +PRIMITIVES["concat"] = lambda *args: _b_sum((a for a in args if a), []) +''', + + "core.collections": ''' +# core.collections PRIMITIVES["list"] = lambda *args: _b_list(args) PRIMITIVES["dict"] = lambda *args: {args[i]: args[i+1] for i in _b_range(0, _b_len(args)-1, 2)} PRIMITIVES["range"] = lambda a, b, step=1: _b_list(_b_range(_b_int(a), _b_int(b), _b_int(step))) @@ -1611,22 +1619,20 @@ PRIMITIVES["rest"] = lambda c: c[1:] if c else [] PRIMITIVES["nth"] = lambda c, n: c[n] if c and 0 <= n < _b_len(c) else NIL PRIMITIVES["cons"] = lambda x, c: [x] + (c or []) PRIMITIVES["append"] = lambda c, x: (c or []) + [x] +PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)] +PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)] +''', + + "core.dict": ''' +# core.dict PRIMITIVES["keys"] = lambda d: _b_list((d or {}).keys()) PRIMITIVES["vals"] = lambda d: _b_list((d or {}).values()) PRIMITIVES["merge"] = lambda *args: _sx_merge_dicts(*args) PRIMITIVES["assoc"] = lambda d, *kvs: _sx_assoc(d, *kvs) PRIMITIVES["dissoc"] = lambda d, *ks: {k: v for k, v in d.items() if k not in ks} -PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)] -PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)] PRIMITIVES["into"] = lambda target, coll: (_b_list(coll) if isinstance(target, _b_list) else {p[0]: p[1] for p in coll if isinstance(p, _b_list) and _b_len(p) >= 2}) PRIMITIVES["zip"] = lambda *colls: [_b_list(t) for t in _b_zip(*colls)] -# Format -PRIMITIVES["format-decimal"] = lambda v, p=2: f"{float(v):.{p}f}" -PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d) -PRIMITIVES["pluralize"] = lambda n, s="", p="s": s if n == 1 else p -PRIMITIVES["escape"] = escape_html - def _sx_merge_dicts(*args): out = {} for d in args: @@ -1639,13 +1645,80 @@ def _sx_assoc(d, *kvs): for i in _b_range(0, _b_len(kvs) - 1, 2): out[kvs[i]] = kvs[i + 1] return out +''', + + "stdlib.format": ''' +# stdlib.format +PRIMITIVES["format-decimal"] = lambda v, p=2: f"{float(v):.{p}f}" +PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d) +PRIMITIVES["parse-datetime"] = lambda s: str(s) if s else NIL def _sx_parse_int(v, default=0): try: return _b_int(v) except (ValueError, TypeError): return default +''', + "stdlib.text": ''' +# stdlib.text +PRIMITIVES["pluralize"] = lambda n, s="", p="s": s if n == 1 else p +PRIMITIVES["escape"] = escape_html +PRIMITIVES["strip-tags"] = lambda s: _strip_tags(str(s)) + +import re as _re +def _strip_tags(s): + return _re.sub(r"<[^>]+>", "", s) +''', + + "stdlib.style": ''' +# stdlib.style — stubs (CSSX needs full runtime) +''', + + "stdlib.debug": ''' +# stdlib.debug +PRIMITIVES["assert"] = lambda cond, msg="Assertion failed": (_ for _ in ()).throw(RuntimeError(f"Assertion error: {msg}")) if not sx_truthy(cond) else True +''', +} + +_ALL_PY_MODULES = list(PRIMITIVES_PY_MODULES.keys()) + + +def _assemble_primitives_py(modules: list[str] | None = None) -> str: + """Assemble Python primitive code from selected modules.""" + if modules is None: + modules = _ALL_PY_MODULES + parts = [] + for mod in modules: + if mod in PRIMITIVES_PY_MODULES: + parts.append(PRIMITIVES_PY_MODULES[mod]) + return "\n".join(parts) + + +PRIMITIVES_PY_PRE = ''' +# ========================================================================= +# Primitives +# ========================================================================= + +# Save builtins before shadowing +_b_len = len +_b_map = map +_b_filter = filter +_b_range = range +_b_list = list +_b_dict = dict +_b_max = max +_b_min = min +_b_round = round +_b_abs = abs +_b_sum = sum +_b_zip = zip +_b_int = int + +PRIMITIVES = {} +''' + +PRIMITIVES_PY_POST = ''' def is_primitive(name): if name in PRIMITIVES: return True @@ -1811,9 +1884,15 @@ def main(): default=None, help="Comma-separated adapter names (html,sx). Default: all server-side.", ) + parser.add_argument( + "--modules", + default=None, + help="Comma-separated primitive modules (core.* always included). Default: all.", + ) args = parser.parse_args() adapters = args.adapters.split(",") if args.adapters else None - print(compile_ref_to_py(adapters)) + modules = args.modules.split(",") if args.modules else None + print(compile_ref_to_py(adapters, modules)) if __name__ == "__main__": diff --git a/shared/sx/ref/boundary_parser.py b/shared/sx/ref/boundary_parser.py index ade09b2..3613dd9 100644 --- a/shared/sx/ref/boundary_parser.py +++ b/shared/sx/ref/boundary_parser.py @@ -42,17 +42,44 @@ def _extract_keyword_arg(expr: list, key: str) -> Any: def parse_primitives_sx() -> frozenset[str]: """Parse primitives.sx and return frozenset of declared pure primitive names.""" + by_module = parse_primitives_by_module() + all_names: set[str] = set() + for names in by_module.values(): + all_names.update(names) + return frozenset(all_names) + + +def parse_primitives_by_module() -> dict[str, frozenset[str]]: + """Parse primitives.sx and return primitives grouped by module. + + Returns: + Dict mapping module name (e.g. "core.arithmetic") to frozenset of + primitive names declared under that module. + """ source = _read_file("primitives.sx") exprs = parse_all(source) - names: set[str] = set() + modules: dict[str, set[str]] = {} + current_module = "_unscoped" + for expr in exprs: - if (isinstance(expr, list) and len(expr) >= 2 - and isinstance(expr[0], Symbol) - and expr[0].name == "define-primitive"): + if not isinstance(expr, list) or len(expr) < 2: + continue + if not isinstance(expr[0], Symbol): + continue + + if expr[0].name == "define-module": + mod_name = expr[1] + if isinstance(mod_name, Keyword): + current_module = mod_name.name + elif isinstance(mod_name, str): + current_module = mod_name + + elif expr[0].name == "define-primitive": name = expr[1] if isinstance(name, str): - names.add(name) - return frozenset(names) + modules.setdefault(current_module, set()).add(name) + + return {mod: frozenset(names) for mod, names in modules.items()} def parse_boundary_sx() -> tuple[frozenset[str], dict[str, frozenset[str]]]: diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx index efa95b4..d9379a8 100644 --- a/shared/sx/ref/primitives.sx +++ b/shared/sx/ref/primitives.sx @@ -18,13 +18,19 @@ ;; The :body is optional — when provided, it gives a reference ;; implementation in SX that bootstrap compilers MAY use for testing ;; or as a fallback. Most targets will implement natively for performance. +;; +;; Modules: (define-module :name) scopes subsequent define-primitive +;; entries until the next define-module. Bootstrappers use this to +;; selectively include primitive groups. ;; ========================================================================== ;; -------------------------------------------------------------------------- -;; Arithmetic +;; Core — Arithmetic ;; -------------------------------------------------------------------------- +(define-module :core.arithmetic) + (define-primitive "+" :params (&rest args) :returns "number" @@ -115,9 +121,11 @@ ;; -------------------------------------------------------------------------- -;; Comparison +;; Core — Comparison ;; -------------------------------------------------------------------------- +(define-module :core.comparison) + (define-primitive "=" :params (a b) :returns "boolean" @@ -151,9 +159,11 @@ ;; -------------------------------------------------------------------------- -;; Predicates +;; Core — Predicates ;; -------------------------------------------------------------------------- +(define-module :core.predicates) + (define-primitive "odd?" :params (n) :returns "boolean" @@ -209,9 +219,11 @@ ;; -------------------------------------------------------------------------- -;; Logic +;; Core — Logic ;; -------------------------------------------------------------------------- +(define-module :core.logic) + (define-primitive "not" :params (x) :returns "boolean" @@ -219,9 +231,11 @@ ;; -------------------------------------------------------------------------- -;; Strings +;; Core — Strings ;; -------------------------------------------------------------------------- +(define-module :core.strings) + (define-primitive "str" :params (&rest args) :returns "string" @@ -279,9 +293,11 @@ ;; -------------------------------------------------------------------------- -;; Collections — construction +;; Core — Collections ;; -------------------------------------------------------------------------- +(define-module :core.collections) + (define-primitive "list" :params (&rest args) :returns "list" @@ -297,11 +313,6 @@ :returns "list" :doc "Integer range [start, end) with optional step.") - -;; -------------------------------------------------------------------------- -;; Collections — access -;; -------------------------------------------------------------------------- - (define-primitive "get" :params (coll key &rest default) :returns "any" @@ -354,9 +365,11 @@ ;; -------------------------------------------------------------------------- -;; Collections — dict operations +;; Core — Dict operations ;; -------------------------------------------------------------------------- +(define-module :core.dict) + (define-primitive "keys" :params (d) :returns "list" @@ -389,9 +402,11 @@ ;; -------------------------------------------------------------------------- -;; Format helpers +;; Stdlib — Format ;; -------------------------------------------------------------------------- +(define-module :stdlib.format) + (define-primitive "format-date" :params (date-str fmt) :returns "string" @@ -407,11 +422,18 @@ :returns "number" :doc "Parse string to integer with optional default on failure.") +(define-primitive "parse-datetime" + :params (s) + :returns "string" + :doc "Parse datetime string — identity passthrough (returns string or nil).") + ;; -------------------------------------------------------------------------- -;; Text helpers +;; Stdlib — Text ;; -------------------------------------------------------------------------- +(define-module :stdlib.text) + (define-primitive "pluralize" :params (count &rest forms) :returns "string" @@ -429,33 +451,10 @@ ;; -------------------------------------------------------------------------- -;; Date & parsing helpers +;; Stdlib — Style ;; -------------------------------------------------------------------------- -(define-primitive "parse-datetime" - :params (s) - :returns "string" - :doc "Parse datetime string — identity passthrough (returns string or nil).") - -(define-primitive "split-ids" - :params (s) - :returns "list" - :doc "Split comma-separated ID string into list of trimmed non-empty strings.") - - -;; -------------------------------------------------------------------------- -;; Assertions -;; -------------------------------------------------------------------------- - -(define-primitive "assert" - :params (condition &rest message) - :returns "boolean" - :doc "Assert condition is truthy; raise error with message if not.") - - -;; -------------------------------------------------------------------------- -;; CSSX — style system primitives -;; -------------------------------------------------------------------------- +(define-module :stdlib.style) (define-primitive "css" :params (&rest atoms) @@ -467,3 +466,15 @@ :params (&rest styles) :returns "style-value" :doc "Merge multiple StyleValues into one combined StyleValue.") + + +;; -------------------------------------------------------------------------- +;; Stdlib — Debug +;; -------------------------------------------------------------------------- + +(define-module :stdlib.debug) + +(define-primitive "assert" + :params (condition &rest message) + :returns "boolean" + :doc "Assert condition is truthy; raise error with message if not.") diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index a8f3eb8..f052e67 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -656,7 +656,8 @@ _b_int = int PRIMITIVES = {} -# Arithmetic + +# core.arithmetic PRIMITIVES["+"] = lambda *args: _b_sum(args) PRIMITIVES["-"] = lambda a, b=None: -a if b is None else a - b PRIMITIVES["*"] = lambda *args: _sx_mul(*args) @@ -680,7 +681,8 @@ def _sx_mul(*args): r *= a return r -# Comparison + +# core.comparison PRIMITIVES["="] = lambda a, b: a == b PRIMITIVES["!="] = lambda a, b: a != b PRIMITIVES["<"] = lambda a, b: a < b @@ -688,28 +690,12 @@ PRIMITIVES[">"] = lambda a, b: a > b PRIMITIVES["<="] = lambda a, b: a <= b PRIMITIVES[">="] = lambda a, b: a >= b -# Logic + +# core.logic PRIMITIVES["not"] = lambda x: not sx_truthy(x) -# String -PRIMITIVES["str"] = sx_str -PRIMITIVES["upper"] = lambda s: str(s).upper() -PRIMITIVES["lower"] = lambda s: str(s).lower() -PRIMITIVES["trim"] = lambda s: str(s).strip() -PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep) -PRIMITIVES["join"] = lambda sep, coll: sep.join(coll) -PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new) -PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p) -PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p) -PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:] -PRIMITIVES["concat"] = lambda *args: _b_sum((a for a in args if a), []) -PRIMITIVES["strip-tags"] = lambda s: _strip_tags(str(s)) -import re as _re -def _strip_tags(s): - return _re.sub(r"<[^>]+>", "", s) - -# Predicates +# core.predicates PRIMITIVES["nil?"] = lambda x: x is None or x is NIL PRIMITIVES["number?"] = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) PRIMITIVES["string?"] = lambda x: isinstance(x, str) @@ -727,7 +713,22 @@ PRIMITIVES["odd?"] = lambda n: n % 2 != 0 PRIMITIVES["even?"] = lambda n: n % 2 == 0 PRIMITIVES["zero?"] = lambda n: n == 0 -# Collections + +# core.strings +PRIMITIVES["str"] = sx_str +PRIMITIVES["upper"] = lambda s: str(s).upper() +PRIMITIVES["lower"] = lambda s: str(s).lower() +PRIMITIVES["trim"] = lambda s: str(s).strip() +PRIMITIVES["split"] = lambda s, sep=" ": str(s).split(sep) +PRIMITIVES["join"] = lambda sep, coll: sep.join(coll) +PRIMITIVES["replace"] = lambda s, old, new: s.replace(old, new) +PRIMITIVES["starts-with?"] = lambda s, p: str(s).startswith(p) +PRIMITIVES["ends-with?"] = lambda s, p: str(s).endswith(p) +PRIMITIVES["slice"] = lambda c, a, b=None: c[a:b] if b is not None else c[a:] +PRIMITIVES["concat"] = lambda *args: _b_sum((a for a in args if a), []) + + +# core.collections PRIMITIVES["list"] = lambda *args: _b_list(args) PRIMITIVES["dict"] = lambda *args: {args[i]: args[i+1] for i in _b_range(0, _b_len(args)-1, 2)} PRIMITIVES["range"] = lambda a, b, step=1: _b_list(_b_range(_b_int(a), _b_int(b), _b_int(step))) @@ -739,22 +740,19 @@ PRIMITIVES["rest"] = lambda c: c[1:] if c else [] PRIMITIVES["nth"] = lambda c, n: c[n] if c and 0 <= n < _b_len(c) else NIL PRIMITIVES["cons"] = lambda x, c: [x] + (c or []) PRIMITIVES["append"] = lambda c, x: (c or []) + [x] +PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)] +PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)] + + +# core.dict PRIMITIVES["keys"] = lambda d: _b_list((d or {}).keys()) PRIMITIVES["vals"] = lambda d: _b_list((d or {}).values()) PRIMITIVES["merge"] = lambda *args: _sx_merge_dicts(*args) PRIMITIVES["assoc"] = lambda d, *kvs: _sx_assoc(d, *kvs) PRIMITIVES["dissoc"] = lambda d, *ks: {k: v for k, v in d.items() if k not in ks} -PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)] -PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)] PRIMITIVES["into"] = lambda target, coll: (_b_list(coll) if isinstance(target, _b_list) else {p[0]: p[1] for p in coll if isinstance(p, _b_list) and _b_len(p) >= 2}) PRIMITIVES["zip"] = lambda *colls: [_b_list(t) for t in _b_zip(*colls)] -# Format -PRIMITIVES["format-decimal"] = lambda v, p=2: f"{float(v):.{p}f}" -PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d) -PRIMITIVES["pluralize"] = lambda n, s="", p="s": s if n == 1 else p -PRIMITIVES["escape"] = escape_html - def _sx_merge_dicts(*args): out = {} for d in args: @@ -768,12 +766,36 @@ def _sx_assoc(d, *kvs): out[kvs[i]] = kvs[i + 1] return out + +# stdlib.format +PRIMITIVES["format-decimal"] = lambda v, p=2: f"{float(v):.{p}f}" +PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d) +PRIMITIVES["parse-datetime"] = lambda s: str(s) if s else NIL + def _sx_parse_int(v, default=0): try: return _b_int(v) except (ValueError, TypeError): return default + +# stdlib.text +PRIMITIVES["pluralize"] = lambda n, s="", p="s": s if n == 1 else p +PRIMITIVES["escape"] = escape_html +PRIMITIVES["strip-tags"] = lambda s: _strip_tags(str(s)) + +import re as _re +def _strip_tags(s): + return _re.sub(r"<[^>]+>", "", s) + + +# stdlib.style — stubs (CSSX needs full runtime) + + +# stdlib.debug +PRIMITIVES["assert"] = lambda cond, msg="Assertion failed": (_ for _ in ()).throw(RuntimeError(f"Assertion error: {msg}")) if not sx_truthy(cond) else True + + def is_primitive(name): if name in PRIMITIVES: return True @@ -1161,4 +1183,4 @@ def render(expr, env=None): def make_env(**kwargs): """Create an environment dict with initial bindings.""" - return dict(kwargs) + return dict(kwargs) \ No newline at end of file