Refactor sx.js: extract string renderer, deduplicate helpers, remove dead code

Extract Node-only string renderer (renderToString, renderStr, etc.) to
sx-test.js. Add shared helpers (_processOOBSwaps, _postSwap, _processBindings,
_evalCond, _logParseError) replacing duplicated logic. Remove dead isTruthy
and _sxCssKnown class-list fallback. Compress section banners. sx.js goes
from 2652 to 2279 lines (-14%) with zero browser-side behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 23:00:58 +00:00
parent 4e5f9ff16c
commit 0554f8a113
4 changed files with 414 additions and 493 deletions

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function escapeAttr(s) { return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
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("") + "</" + tag + ">";
}
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);

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function escapeAttr(s) { return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 '<div style="background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;' +
'padding:4px 8px;margin:2px;border-radius:4px;font-size:12px;font-family:monospace">' +
'Unknown component: ' + escapeText(name) + '</div>';
}
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("") + "</" + tag + ">";
}
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>
// =========================================================================
// --- 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 <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
var cssTarget = document.getElementById("sx-css");
if (cssTarget) {
@@ -2619,9 +2248,7 @@
return text;
}
// ---------------------------------------------------------------------------
// sx-comp-hash cookie helpers (component caching)
// ---------------------------------------------------------------------------
// --- sx-comp-hash cookie helpers ---
function _setSxCompCookie(hash) {
document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";

View File

@@ -15,14 +15,16 @@ from shared.sx.html import render as py_render
from shared.sx.evaluator import evaluate
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
SX_TEST_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx-test.js"
def _js_render(sx_text: str, components_text: str = "") -> str:
"""Run sx.js in Node and return the renderToString result."""
"""Run sx.js + sx-test.js in Node and return the renderToString result."""
# Build a small Node script
script = f"""
global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()}
{SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result);

View File

@@ -167,7 +167,7 @@ JS_API = [
("Sx.parseAll(text)", "Parse multiple s-expressions from text"),
("Sx.eval(expr, env)", "Evaluate an expression in the given environment"),
("Sx.render(expr, env)", "Render an expression to DOM nodes"),
("Sx.renderToString(expr, env)", "Render an expression to an HTML string"),
("Sx.renderToString(expr, env)", "Render an expression to an HTML string (requires sx-test.js)"),
("Sx.renderComponent(name, kwargs, env)", "Render a named component with keyword arguments"),
("Sx.loadComponents(text)", "Parse and register component definitions"),
("Sx.getEnv()", "Get the current component environment"),