diff --git a/shared/static/scripts/sx-test.js b/shared/static/scripts/sx-test.js new file mode 100644 index 0000000..859cb20 --- /dev/null +++ b/shared/static/scripts/sx-test.js @@ -0,0 +1,292 @@ +/** + * sx-test.js — String renderer for sx.js (Node-only, used by test harness). + * + * Provides Sx.renderToString() for server-side / test rendering. + * Assumes sx.js is loaded first and Sx global is available. + */ +;(function (Sx) { + "use strict"; + + // Pull references from Sx internals + var NIL = Sx.NIL; + var _eval = Sx._eval; + var _types = Sx._types; + var RawHTML = _types.RawHTML; + + function isNil(x) { return x === NIL || x === null || x === undefined; } + function isSym(x) { return x && x._sym === true; } + function isKw(x) { return x && x._kw === true; } + function isLambda(x) { return x && x._lambda === true; } + function isComponent(x) { return x && x._component === true; } + function isMacro(x) { return x && x._macro === true; } + function isRaw(x) { return x && x._raw === true; } + function isSxTruthy(x) { return x !== false && !isNil(x); } + + function merge(target) { + for (var i = 1; i < arguments.length; i++) { + var src = arguments[i]; + if (src) for (var k in src) target[k] = src[k]; + } + return target; + } + + // Use the same tag/attr sets as sx.js + var HTML_TAGS = Sx._renderDOM ? null : null; // We'll use a local copy + var _HTML_TAGS_STR = + "html head body title meta link style script base noscript " + + "header footer main nav aside section article address hgroup " + + "h1 h2 h3 h4 h5 h6 " + + "div p blockquote pre figure figcaption ul ol li dl dt dd hr " + + "a span em strong small s cite q abbr code var samp kbd sub sup " + + "i b u mark ruby rt rp bdi bdo br wbr time data " + + "ins del " + + "img picture source iframe embed object param video audio track canvas map area " + + "table caption colgroup col thead tbody tfoot tr td th " + + "form input textarea button select option optgroup label fieldset legend " + + "details summary dialog " + + "svg path circle rect line ellipse polyline polygon text g defs use " + + "clippath lineargradient radialgradient stop pattern mask " + + "tspan textpath foreignobject"; + var _VOID_STR = "area base br col embed hr img input link meta param source track wbr"; + var _BOOL_STR = "disabled checked readonly required selected autofocus autoplay " + + "controls loop muted multiple hidden open novalidate"; + + function makeSet(str) { + var s = {}, parts = str.split(/\s+/); + for (var i = 0; i < parts.length; i++) if (parts[i]) s[parts[i]] = true; + return s; + } + + HTML_TAGS = makeSet(_HTML_TAGS_STR); + var VOID_ELEMENTS = makeSet(_VOID_STR); + var BOOLEAN_ATTRS = makeSet(_BOOL_STR); + + // Access expandMacro via Sx._eval on a defmacro — we need to replicate macro expansion + // Actually, we need the internal expandMacro. Let's check if Sx exposes it. + // Sx._eval handles macro expansion internally, so we can call sxEval for macro forms. + var sxEval = _eval; + + // _isRenderExpr — check if an expression is a render-only form + function _isRenderExpr(v) { + if (!Array.isArray(v) || !v.length) return false; + var h = v[0]; + if (!isSym(h)) return false; + var n = h.name; + if (n === "<>" || n === "raw!" || n === "if" || n === "when" || n === "cond" || + n === "case" || n === "let" || n === "let*" || n === "begin" || n === "do" || + n === "map" || n === "map-indexed" || n === "filter" || n === "for-each") return true; + if (n.charAt(0) === "~") return true; + if (HTML_TAGS[n]) return true; + return false; + } + + // --- String Renderer --- + + function escapeText(s) { return s.replace(/&/g, "&").replace(//g, ">"); } + function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } + + function renderStr(expr, env) { + if (isNil(expr) || expr === false || expr === true) return ""; + if (isRaw(expr)) return expr.html; + if (typeof expr === "string") return escapeText(expr); + if (typeof expr === "number") return escapeText(String(expr)); + if (isSym(expr)) return renderStr(sxEval(expr, env), env); + if (isKw(expr)) return escapeText(expr.name); + if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); } + if (expr && typeof expr === "object") return ""; + return escapeText(String(expr)); + } + + function renderStrList(expr, env) { + var head = expr[0]; + if (!isSym(head)) { + var parts = []; + for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env)); + return parts.join(""); + } + var name = head.name; + + if (name === "raw!") { + var ps = []; + for (var ri = 1; ri < expr.length; ri++) { + var v = sxEval(expr[ri], env); + if (isRaw(v)) ps.push(v.html); + else if (typeof v === "string") ps.push(v); + else if (!isNil(v)) ps.push(String(v)); + } + return ps.join(""); + } + if (name === "<>") { + var fs = []; + for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env)); + return fs.join(""); + } + if (name === "if") { + return isSxTruthy(sxEval(expr[1], env)) + ? renderStr(expr[2], env) + : (expr.length > 3 ? renderStr(expr[3], env) : ""); + } + if (name === "when") { + if (!isSxTruthy(sxEval(expr[1], env))) return ""; + var ws = []; + for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env)); + return ws.join(""); + } + if (name === "let" || name === "let*") { + var bindings = expr[1], local = merge({}, env); + if (Array.isArray(bindings)) { + if (bindings.length && Array.isArray(bindings[0])) { + for (var li = 0; li < bindings.length; li++) { + local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sxEval(bindings[li][1], local); + } + } else { + for (var lj = 0; lj < bindings.length; lj += 2) { + local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local); + } + } + } + var ls = []; + for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local)); + return ls.join(""); + } + if (name === "begin" || name === "do") { + var bs = []; + for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env)); + return bs.join(""); + } + if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; } + + // Macro expansion in string renderer + if (name in env && isMacro(env[name])) { + var smExp = Sx._expandMacro(env[name], expr.slice(1), env); + return renderStr(smExp, env); + } + + // Higher-order forms — render-aware + if (name === "map") { + var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env); + if (!Array.isArray(mapColl)) return ""; + var mapParts = []; + for (var mi = 0; mi < mapColl.length; mi++) { + if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env)); + else mapParts.push(renderStr(mapFn(mapColl[mi]), env)); + } + return mapParts.join(""); + } + if (name === "map-indexed") { + var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env); + if (!Array.isArray(mixColl)) return ""; + var mixParts = []; + for (var mxi = 0; mxi < mixColl.length; mxi++) { + if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env)); + else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env)); + } + return mixParts.join(""); + } + if (name === "filter") { + var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env); + if (!Array.isArray(filtColl)) return ""; + var filtParts = []; + for (var fli = 0; fli < filtColl.length; fli++) { + var keep = isLambda(filtFn) ? Sx._callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]); + if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env)); + } + return filtParts.join(""); + } + + if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env); + + if (name.charAt(0) === "~") { + var comp = env[name]; + if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env); + console.warn("sx.js: unknown component " + name); + return '
' + + 'Unknown component: ' + escapeText(name) + '
'; + } + + return renderStr(sxEval(expr, env), env); + } + + function renderStrElement(tag, args, env) { + var attrs = [], children = []; + var i = 0; + while (i < args.length) { + if (isKw(args[i]) && i + 1 < args.length) { + var aname = args[i].name, aval = sxEval(args[i + 1], env); + i += 2; + if (isNil(aval) || aval === false) continue; + if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); } + else if (aval === true) attrs.push(" " + aname); + else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"'); + } else { + children.push(args[i]); + i++; + } + } + var open = "<" + tag + attrs.join("") + ">"; + if (VOID_ELEMENTS[tag]) return open; + var isRawText = (tag === "script" || tag === "style"); + var inner = []; + for (var ci = 0; ci < children.length; ci++) { + var child = children[ci]; + if (isRawText && typeof child === "string") inner.push(child); + else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env))); + else inner.push(renderStr(child, env)); + } + return open + inner.join("") + ""; + } + + function renderLambdaStr(fn, args, env) { + var local = merge({}, fn.closure, env); + for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i]; + return renderStr(fn.body, local); + } + + function renderStrComponent(comp, args, env) { + var kwargs = {}, children = []; + var i = 0; + while (i < args.length) { + if (isKw(args[i]) && i + 1 < args.length) { + var v = args[i + 1]; + if (typeof v === "string" || typeof v === "number" || + typeof v === "boolean" || isNil(v) || isKw(v)) { + kwargs[args[i].name] = v; + } else if (isSym(v)) { + kwargs[args[i].name] = sxEval(v, env); + } else if (Array.isArray(v) && v.length && isSym(v[0])) { + if (_isRenderExpr(v)) { + kwargs[args[i].name] = new RawHTML(renderStr(v, env)); + } else { + kwargs[args[i].name] = sxEval(v, env); + } + } else { + kwargs[args[i].name] = v; + } + i += 2; + } else { children.push(args[i]); i++; } + } + var local = merge({}, comp.closure, env); + for (var pi = 0; pi < comp.params.length; pi++) { + var p = comp.params[pi]; + local[p] = (p in kwargs) ? kwargs[p] : NIL; + } + if (comp.hasChildren) { + var cs = []; + for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env)); + local["children"] = new RawHTML(cs.join("")); + } + return renderStr(comp.body, local); + } + + // --- Public API --- + + Sx.renderToString = function (exprOrText, extraEnv) { + var expr = typeof exprOrText === "string" ? Sx.parse(exprOrText) : exprOrText; + var env = extraEnv ? merge({}, Sx.getEnv(), extraEnv) : Sx.getEnv(); + return renderStr(expr, env); + }; + + Sx._renderStr = renderStr; + +})(Sx); diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index c6769e7..3cb1758 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -12,17 +12,12 @@ ;(function (global) { "use strict"; - // ========================================================================= - // Types - // ========================================================================= + // --- Types --- /** Singleton nil — falsy placeholder. */ var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } }); function isNil(x) { return x === NIL || x === null || x === undefined; } - function isTruthy(x) { return x !== false && !isNil(x) && x !== 0 && x !== ""; } - // Note: 0 and "" are falsy in sx but we match Python semantics where - // only nil/false/None are falsy for control flow. Revisit if needed. function isSxTruthy(x) { return x !== false && !isNil(x); } function Symbol(name) { this.name = name; } @@ -70,9 +65,7 @@ function isMacro(x) { return x && x._macro === true; } function isRaw(x) { return x && x._raw === true; } - // ========================================================================= - // Parser - // ========================================================================= + // --- Parser --- var RE_WS = /\s+/y; var RE_COMMENT = /;[^\n]*/y; @@ -242,9 +235,7 @@ return results; } - // ========================================================================= - // Primitives - // ========================================================================= + // --- Primitives --- var PRIMITIVES = {}; @@ -345,9 +336,7 @@ return r; }; - // ========================================================================= - // Evaluator - // ========================================================================= + // --- Evaluator --- function sxEval(expr, env) { // Literals @@ -444,6 +433,68 @@ return sxEval(comp.body, local); } + // --- Shared helpers for special/render forms --- + + function _processBindings(bindings, env) { + var local = merge({}, env); + if (Array.isArray(bindings)) { + if (bindings.length && Array.isArray(bindings[0])) { + for (var i = 0; i < bindings.length; i++) { + var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]; + local[vname] = sxEval(bindings[i][1], local); + } + } else { + for (var j = 0; j < bindings.length; j += 2) { + var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j]; + local[vn] = sxEval(bindings[j + 1], local); + } + } + } + return local; + } + + function _evalCond(clauses, env) { + if (!clauses.length) return null; + if (Array.isArray(clauses[0]) && clauses[0].length === 2) { + for (var i = 0; i < clauses.length; i++) { + var test = clauses[i][0]; + if ((isSym(test) && (test.name === "else" || test.name === ":else")) || + (isKw(test) && test.name === "else")) return clauses[i][1]; + if (isSxTruthy(sxEval(test, env))) return clauses[i][1]; + } + } else { + for (var j = 0; j < clauses.length - 1; j += 2) { + var t = clauses[j]; + if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else"))) + return clauses[j + 1]; + if (isSxTruthy(sxEval(t, env))) return clauses[j + 1]; + } + } + return null; + } + + function _logParseError(label, text, err, windowSize) { + var colMatch = err.message && err.message.match(/col (\d+)/); + var lineMatch = err.message && err.message.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 start = Math.max(0, pos - windowSize); + var end = Math.min(text.length, pos + windowSize); + console.error("sx.js " + label + ":", err.message, + "\n total length:", text.length, "lines:", lines.length, + "\n error line " + errLine + ":", lines[errLine - 1] ? lines[errLine - 1].substring(0, 200) : "(no such line)", + "\n around error (pos ~" + pos + "):", + "\n «" + text.substring(start, pos) + "⛔" + text.substring(pos, end) + "»"); + } else { + console.error("sx.js " + label + ":", err.message || err); + } + } + // --- Special forms ------------------------------------------------------- var SPECIAL_FORMS = {}; @@ -462,26 +513,8 @@ }; SPECIAL_FORMS["cond"] = function (expr, env) { - var clauses = expr.slice(1); - if (!clauses.length) return NIL; - // Scheme-style - if (Array.isArray(clauses[0]) && clauses[0].length === 2) { - for (var i = 0; i < clauses.length; i++) { - var test = clauses[i][0]; - if ((isSym(test) && (test.name === "else" || test.name === ":else")) || - (isKw(test) && test.name === "else")) return sxEval(clauses[i][1], env); - if (isSxTruthy(sxEval(test, env))) return sxEval(clauses[i][1], env); - } - } else { - // Clojure-style - for (var j = 0; j < clauses.length - 1; j += 2) { - var t = clauses[j]; - if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else"))) - return sxEval(clauses[j + 1], env); - if (isSxTruthy(sxEval(t, env))) return sxEval(clauses[j + 1], env); - } - } - return NIL; + var branch = _evalCond(expr.slice(1), env); + return branch ? sxEval(branch, env) : NIL; }; SPECIAL_FORMS["case"] = function (expr, env) { @@ -514,22 +547,7 @@ }; SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) { - var bindings = expr[1], local = merge({}, env); - if (Array.isArray(bindings)) { - if (bindings.length && Array.isArray(bindings[0])) { - // Scheme-style - for (var i = 0; i < bindings.length; i++) { - var vname = isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]; - local[vname] = sxEval(bindings[i][1], local); - } - } else { - // Clojure-style - for (var j = 0; j < bindings.length; j += 2) { - var vn = isSym(bindings[j]) ? bindings[j].name : bindings[j]; - local[vn] = sxEval(bindings[j + 1], local); - } - } - } + var local = _processBindings(expr[1], env); var result = NIL; for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local); return result; @@ -714,9 +732,7 @@ return NIL; }; - // ========================================================================= - // DOM Renderer - // ========================================================================= + // --- DOM Renderer --- var HTML_TAGS = makeSet( "html head body title meta link style script base noscript " + @@ -813,39 +829,12 @@ }; RENDER_FORMS["cond"] = function (expr, env) { - var clauses = expr.slice(1); - if (!clauses.length) return document.createDocumentFragment(); - if (Array.isArray(clauses[0]) && clauses[0].length === 2) { - for (var i = 0; i < clauses.length; i++) { - var test = clauses[i][0]; - if ((isSym(test) && (test.name === "else" || test.name === ":else")) || - (isKw(test) && test.name === "else")) return renderDOM(clauses[i][1], env); - if (isSxTruthy(sxEval(test, env))) return renderDOM(clauses[i][1], env); - } - } else { - for (var j = 0; j < clauses.length - 1; j += 2) { - var t = clauses[j]; - if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else"))) - return renderDOM(clauses[j + 1], env); - if (isSxTruthy(sxEval(t, env))) return renderDOM(clauses[j + 1], env); - } - } - return document.createDocumentFragment(); + var branch = _evalCond(expr.slice(1), env); + return branch ? renderDOM(branch, env) : document.createDocumentFragment(); }; RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) { - var bindings = expr[1], local = merge({}, env); - if (Array.isArray(bindings)) { - if (bindings.length && Array.isArray(bindings[0])) { - for (var i = 0; i < bindings.length; i++) { - local[isSym(bindings[i][0]) ? bindings[i][0].name : bindings[i][0]] = sxEval(bindings[i][1], local); - } - } else { - for (var j = 0; j < bindings.length; j += 2) { - local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sxEval(bindings[j + 1], local); - } - } - } + var local = _processBindings(expr[1], env); var frag = document.createDocumentFragment(); for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local)); return frag; @@ -1070,216 +1059,7 @@ return el; } - // ========================================================================= - // String Renderer (for SSR parity / testing) - // ========================================================================= - - function escapeText(s) { return s.replace(/&/g, "&").replace(//g, ">"); } - function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } - - function renderStr(expr, env) { - if (isNil(expr) || expr === false || expr === true) return ""; - if (isRaw(expr)) return expr.html; - if (typeof expr === "string") return escapeText(expr); - if (typeof expr === "number") return escapeText(String(expr)); - if (isSym(expr)) return renderStr(sxEval(expr, env), env); - if (isKw(expr)) return escapeText(expr.name); - if (Array.isArray(expr)) { if (!expr.length) return ""; return renderStrList(expr, env); } - if (expr && typeof expr === "object") return ""; - return escapeText(String(expr)); - } - - function renderStrList(expr, env) { - var head = expr[0]; - if (!isSym(head)) { - var parts = []; - for (var i = 0; i < expr.length; i++) parts.push(renderStr(expr[i], env)); - return parts.join(""); - } - var name = head.name; - - if (name === "raw!") { - var ps = []; - for (var ri = 1; ri < expr.length; ri++) { - var v = sxEval(expr[ri], env); - if (isRaw(v)) ps.push(v.html); - else if (typeof v === "string") ps.push(v); - else if (!isNil(v)) ps.push(String(v)); - } - return ps.join(""); - } - if (name === "<>") { - var fs = []; - for (var fi = 1; fi < expr.length; fi++) fs.push(renderStr(expr[fi], env)); - return fs.join(""); - } - if (name === "if") { - return isSxTruthy(sxEval(expr[1], env)) - ? renderStr(expr[2], env) - : (expr.length > 3 ? renderStr(expr[3], env) : ""); - } - if (name === "when") { - if (!isSxTruthy(sxEval(expr[1], env))) return ""; - var ws = []; - for (var wi = 2; wi < expr.length; wi++) ws.push(renderStr(expr[wi], env)); - return ws.join(""); - } - if (name === "let" || name === "let*") { - var bindings = expr[1], local = merge({}, env); - if (Array.isArray(bindings)) { - if (bindings.length && Array.isArray(bindings[0])) { - for (var li = 0; li < bindings.length; li++) { - local[isSym(bindings[li][0]) ? bindings[li][0].name : bindings[li][0]] = sxEval(bindings[li][1], local); - } - } else { - for (var lj = 0; lj < bindings.length; lj += 2) { - local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local); - } - } - } - var ls = []; - for (var lk = 2; lk < expr.length; lk++) ls.push(renderStr(expr[lk], local)); - return ls.join(""); - } - if (name === "begin" || name === "do") { - var bs = []; - for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env)); - return bs.join(""); - } - if (name === "define" || name === "defcomp" || name === "defmacro" || name === "defhandler") { sxEval(expr, env); return ""; } - - // Macro expansion in string renderer - if (name in env && isMacro(env[name])) { - var smExp = expandMacro(env[name], expr.slice(1), env); - return renderStr(smExp, env); - } - - // Higher-order forms — render-aware (lambda bodies may contain HTML/components) - if (name === "map") { - var mapFn = sxEval(expr[1], env), mapColl = sxEval(expr[2], env); - if (!Array.isArray(mapColl)) return ""; - var mapParts = []; - for (var mi = 0; mi < mapColl.length; mi++) { - if (isLambda(mapFn)) mapParts.push(renderLambdaStr(mapFn, [mapColl[mi]], env)); - else mapParts.push(renderStr(mapFn(mapColl[mi]), env)); - } - return mapParts.join(""); - } - if (name === "map-indexed") { - var mixFn = sxEval(expr[1], env), mixColl = sxEval(expr[2], env); - if (!Array.isArray(mixColl)) return ""; - var mixParts = []; - for (var mxi = 0; mxi < mixColl.length; mxi++) { - if (isLambda(mixFn)) mixParts.push(renderLambdaStr(mixFn, [mxi, mixColl[mxi]], env)); - else mixParts.push(renderStr(mixFn(mxi, mixColl[mxi]), env)); - } - return mixParts.join(""); - } - if (name === "filter") { - var filtFn = sxEval(expr[1], env), filtColl = sxEval(expr[2], env); - if (!Array.isArray(filtColl)) return ""; - var filtParts = []; - for (var fli = 0; fli < filtColl.length; fli++) { - var keep = isLambda(filtFn) ? callLambda(filtFn, [filtColl[fli]], env) : filtFn(filtColl[fli]); - if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env)); - } - return filtParts.join(""); - } - - if (HTML_TAGS[name]) return renderStrElement(name, expr.slice(1), env); - - if (name.charAt(0) === "~") { - var comp = env[name]; - if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env); - // Unknown component — return visible warning - console.warn("sx.js: unknown component " + name); - return '
' + - 'Unknown component: ' + escapeText(name) + '
'; - } - - return renderStr(sxEval(expr, env), env); - } - - function renderStrElement(tag, args, env) { - var attrs = [], children = []; - var i = 0; - while (i < args.length) { - if (isKw(args[i]) && i + 1 < args.length) { - var aname = args[i].name, aval = sxEval(args[i + 1], env); - i += 2; - if (isNil(aval) || aval === false) continue; - if (BOOLEAN_ATTRS[aname]) { if (aval) attrs.push(" " + aname); } - else if (aval === true) attrs.push(" " + aname); - else attrs.push(" " + aname + '="' + escapeAttr(String(aval)) + '"'); - } else { - children.push(args[i]); - i++; - } - } - var open = "<" + tag + attrs.join("") + ">"; - if (VOID_ELEMENTS[tag]) return open; - var isRawText = (tag === "script" || tag === "style"); - var inner = []; - for (var ci = 0; ci < children.length; ci++) { - var child = children[ci]; - if (isRawText && typeof child === "string") inner.push(child); - else if (isRawText && isSym(child)) inner.push(String(sxEval(child, env))); - else inner.push(renderStr(child, env)); - } - return open + inner.join("") + ""; - } - - function renderLambdaStr(fn, args, env) { - var local = merge({}, fn.closure, env); - for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i]; - return renderStr(fn.body, local); - } - - function renderStrComponent(comp, args, env) { - var kwargs = {}, children = []; - var i = 0; - while (i < args.length) { - if (isKw(args[i]) && i + 1 < args.length) { - // Evaluate kwarg values eagerly in the caller's env so expressions - // like (get t "src") resolve while lambda params are still bound. - var v = args[i + 1]; - if (typeof v === "string" || typeof v === "number" || - typeof v === "boolean" || isNil(v) || isKw(v)) { - kwargs[args[i].name] = v; - } else if (isSym(v)) { - kwargs[args[i].name] = sxEval(v, env); - } else if (Array.isArray(v) && v.length && isSym(v[0])) { - // Expression with Symbol head — evaluate in caller's env. - // Render-only forms go through renderStr; data exprs through sxEval. - if (_isRenderExpr(v)) { - kwargs[args[i].name] = new RawHTML(renderStr(v, env)); - } else { - kwargs[args[i].name] = sxEval(v, env); - } - } else { - // Data arrays, dicts, etc — pass through as-is - kwargs[args[i].name] = v; - } - i += 2; - } else { children.push(args[i]); i++; } - } - var local = merge({}, comp.closure, env); - for (var pi = 0; pi < comp.params.length; pi++) { - var p = comp.params[pi]; - local[p] = (p in kwargs) ? kwargs[p] : NIL; - } - if (comp.hasChildren) { - var cs = []; - for (var ci = 0; ci < children.length; ci++) cs.push(renderStr(children[ci], env)); - local["children"] = new RawHTML(cs.join("")); - } - return renderStr(comp.body, local); - } - - // ========================================================================= - // Helpers - // ========================================================================= + // --- Helpers --- function merge(target) { for (var i = 1; i < arguments.length; i++) { @@ -1298,15 +1078,11 @@ /** Convert snake_case kwargs to kebab-case for sx conventions. */ function toKebab(s) { return s.replace(/_/g, "-"); } - // ========================================================================= - // Public API - // ========================================================================= + // --- Public API --- var _componentEnv = {}; - // ========================================================================= - // Head auto-hoist: move meta/title/link/script[ld+json] from body to - // ========================================================================= + // --- Head auto-hoist --- var HEAD_HOIST_SELECTOR = "meta, title, link[rel='canonical'], script[type='application/ld+json']"; @@ -1382,13 +1158,6 @@ return renderDOM(exprOrText, env); }, - // String Renderer (matches Python html.render output) - renderToString: function (exprOrText, extraEnv) { - var expr = typeof exprOrText === "string" ? parse(exprOrText) : exprOrText; - var env = extraEnv ? merge({}, _componentEnv, extraEnv) : _componentEnv; - return renderStr(expr, env); - }, - /** * Render a named component with keyword args (Python-style API). * Sx.renderComponent("card", {title: "Hi"}) @@ -1415,26 +1184,7 @@ var exprs = parseAll(text); for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv); } catch (err) { - // Enhanced error logging: show context around parse failure - var colMatch = err.message && err.message.match(/col (\d+)/); - var lineMatch = err.message && err.message.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 li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1; - pos += errCol; - var start = Math.max(0, pos - 120); - var end = Math.min(text.length, pos + 120); - console.error("sx.js loadComponents PARSE ERROR:", err.message, - "\n total length:", text.length, "lines:", lines.length, - "\n error line " + errLine + ":", lines[errLine - 1] ? lines[errLine - 1].substring(0, 200) : "(no such line)", - "\n around error (pos ~" + pos + "):", - "\n «" + text.substring(start, pos) + "⛔" + text.substring(pos, end) + "»"); - } else { - console.error("sx.js loadComponents error:", err, "\ntext first 500:", text ? text.substring(0, 500) : "(empty)"); - } + _logParseError("loadComponents PARSE ERROR", text, err, 120); throw err; } }, @@ -1458,28 +1208,7 @@ try { node = Sx.render(exprOrText, extraEnv); } catch (e) { - if (typeof exprOrText === "string") { - var src = exprOrText; - // Find approx position from error message - var colMatch = e.message && e.message.match(/col (\d+)/); - var lineMatch = e.message && e.message.match(/line (\d+)/); - if (colMatch) { - var errLine = lineMatch ? parseInt(lineMatch[1]) : 1; - var errCol = parseInt(colMatch[1]); - var lines = src.split("\n"); - var pos = 0; - for (var li = 0; li < errLine - 1 && li < lines.length; li++) pos += lines[li].length + 1; - pos += errCol; - var start = Math.max(0, pos - 80); - var end = Math.min(src.length, pos + 80); - console.error("sx.js MOUNT PARSE ERROR:", e.message, - "\n source length:", src.length, - "\n around error (pos ~" + pos + "):", - "\n «" + src.substring(start, pos) + "⛔" + src.substring(pos, end) + "»"); - } else { - console.error("sx.js MOUNT PARSE ERROR:", e.message, "\n first 500:", src.substring(0, 500)); - } - } + if (typeof exprOrText === "string") _logParseError("MOUNT PARSE ERROR", exprOrText, e, 80); throw e; } el.textContent = ""; @@ -1619,18 +1348,17 @@ } }, - // For testing + // For testing / sx-test.js _types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML }, _eval: sxEval, - _renderStr: renderStr, + _expandMacro: expandMacro, + _callLambda: callLambda, _renderDOM: renderDOM, }; global.Sx = Sx; - // ========================================================================= - // SxEngine — native fetch/swap/history engine (replaces HTMX) - // ========================================================================= + // --- SxEngine — native fetch/swap/history engine --- var SxEngine = (function () { if (typeof document === "undefined") return {}; @@ -1889,24 +1617,7 @@ container.appendChild(sxDom); // OOB processing on live DOM nodes - var oobs = container.querySelectorAll("[sx-swap-oob]"); - oobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) _swapDOM(oobTarget, oob, oobSwap); - }); - - // hx-swap-oob compat - var hxOobs = container.querySelectorAll("[hx-swap-oob]"); - hxOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) _swapDOM(oobTarget, oob, oobSwap); - }); + _processOOBSwaps(container, _swapDOM); // sx-select filtering var selectedDOM; @@ -1941,27 +1652,7 @@ Sx.processScripts(doc); // OOB processing - var oobs = doc.querySelectorAll("[sx-swap-oob]"); - oobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); - - var hxOobs = doc.querySelectorAll("[hx-swap-oob]"); - hxOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); + _processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); }); // Build final content var content; @@ -2150,10 +1841,7 @@ } else { _morphDOM(target, newNodes); } - _activateScripts(parent); - Sx.processScripts(parent); - Sx.hydrate(parent); - SxEngine.process(parent); + _postSwap(parent); return; // early return like existing outerHTML case "afterend": target.parentNode.insertBefore(newNodes, target.nextSibling); @@ -2179,14 +1867,26 @@ _morphChildren(target, wrapper); } } - _activateScripts(target); - Sx.processScripts(target); - Sx.hydrate(target); - SxEngine.process(target); + _postSwap(target); } // ---- Swap engine (string-based, kept as fallback) ---------------------- + function _processOOBSwaps(container, swapFn, postSwapFn) { + ["sx-swap-oob", "hx-swap-oob"].forEach(function (attr) { + container.querySelectorAll("[" + attr + "]").forEach(function (oob) { + var swapType = oob.getAttribute(attr) || "outerHTML"; + var target = document.getElementById(oob.id); + oob.removeAttribute(attr); + if (oob.parentNode) oob.parentNode.removeChild(oob); + if (target) { + swapFn(target, oob, swapType); + if (postSwapFn) postSwapFn(target); + } + }); + }); + } + /** Scripts inserted via innerHTML/insertAdjacentHTML don't execute. * Recreate them as live elements so the browser fetches & runs them. */ function _activateScripts(root) { @@ -2201,6 +1901,13 @@ } } + function _postSwap(root) { + _activateScripts(root); + Sx.processScripts(root); + Sx.hydrate(root); + SxEngine.process(root); + } + function _swapContent(target, html, strategy) { switch (strategy) { case "innerHTML": @@ -2211,11 +1918,7 @@ var parent = tgt.parentNode; tgt.insertAdjacentHTML("afterend", html); parent.removeChild(tgt); - // Process parent to catch all newly inserted siblings - _activateScripts(parent); - Sx.processScripts(parent); - Sx.hydrate(parent); - SxEngine.process(parent); + _postSwap(parent); return; // early return — afterSwap handling done inline case "afterend": target.insertAdjacentHTML("afterend", html); @@ -2235,10 +1938,7 @@ default: target.innerHTML = html; } - _activateScripts(target); - Sx.processScripts(target); - Sx.hydrate(target); - SxEngine.process(target); + _postSwap(target); } // ---- Retry system ----------------------------------------------------- @@ -2413,37 +2113,11 @@ popContainer.appendChild(popDom); // Process OOB swaps (sidebar, filter, menu, headers) - var oobs = popContainer.querySelectorAll("[sx-swap-oob]"); - oobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) { - _swapDOM(oobTarget, oob, oobSwap); - Sx.hydrate(oobTarget); - SxEngine.process(oobTarget); - } - }); - var hxOobs = popContainer.querySelectorAll("[hx-swap-oob]"); - hxOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - oob.parentNode.removeChild(oob); - if (oobTarget) { - _swapDOM(oobTarget, oob, oobSwap); - Sx.hydrate(oobTarget); - SxEngine.process(oobTarget); - } - }); + _processOOBSwaps(popContainer, _swapDOM, function (t) { Sx.hydrate(t); SxEngine.process(t); }); var newMain = popContainer.querySelector("#main-panel"); _morphChildren(main, newMain || popContainer); - _activateScripts(main); - Sx.processScripts(main); - Sx.hydrate(main); - SxEngine.process(main); + _postSwap(main); dispatch(document.body, "sx:afterSettle", { target: main }); window.scrollTo(0, e.state && e.state.scrollY || 0); } catch (err) { @@ -2456,34 +2130,12 @@ var doc = parser.parseFromString(text, "text/html"); // Process OOB swaps from HTML response - var hOobs = doc.querySelectorAll("[sx-swap-oob]"); - hOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("sx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("sx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); - var hhOobs = doc.querySelectorAll("[hx-swap-oob]"); - hhOobs.forEach(function (oob) { - var oobSwap = oob.getAttribute("hx-swap-oob") || "outerHTML"; - var oobTarget = document.getElementById(oob.id); - oob.removeAttribute("hx-swap-oob"); - if (oobTarget) { - _swapContent(oobTarget, oob.outerHTML, oobSwap); - } - oob.parentNode.removeChild(oob); - }); + _processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); }); var newMain = doc.getElementById("main-panel"); if (newMain) { _morphChildren(main, newMain); - _activateScripts(main); - Sx.processScripts(main); - Sx.hydrate(main); - SxEngine.process(main); + _postSwap(main); dispatch(document.body, "sx:afterSettle", { target: main }); window.scrollTo(0, e.state && e.state.scrollY || 0); } else { @@ -2561,52 +2213,29 @@ global.SxEngine = SxEngine; - // ========================================================================= - // Auto-init in browser - // ========================================================================= + // --- Auto-init in browser --- Sx.VERSION = "2026-03-01c-cssx"; // CSS class tracking for on-demand CSS delivery - var _sxCssKnown = {}; var _sxCssHash = ""; // 8-char hex hash from server function _initCssTracking() { var meta = document.querySelector('meta[name="sx-css-classes"]'); if (meta) { var content = meta.getAttribute("content"); - if (content) { - // If content is short (≤16 chars), it's a hash from the server - if (content.length <= 16) { - _sxCssHash = content; - } else { - content.split(",").forEach(function (c) { - if (c) _sxCssKnown[c] = true; - }); - } - } + if (content) _sxCssHash = content; } } function _getSxCssHeader() { - // Prefer sending the hash (compact) over the full class list - if (_sxCssHash) return _sxCssHash; - var names = Object.keys(_sxCssKnown); - return names.length ? names.join(",") : ""; + return _sxCssHash; } function _processCssResponse(text, resp) { - // Read SX-Css-Hash response header — replaces local hash var hashHeader = resp.headers.get("SX-Css-Hash"); if (hashHeader) _sxCssHash = hashHeader; - // Merge SX-Css-Add header into known set (kept for debugging/fallback) - var addHeader = resp.headers.get("SX-Css-Add"); - if (addHeader) { - addHeader.split(",").forEach(function (c) { - if (c) _sxCssKnown[c] = true; - }); - } // Extract blocks and inject into