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) { ;(function (global) {
"use strict"; "use strict";
// ========================================================================= // --- Types ---
// Types
// =========================================================================
/** Singleton nil — falsy placeholder. */ /** Singleton nil — falsy placeholder. */
var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } }); var NIL = Object.freeze({ _nil: true, toString: function () { return "nil"; } });
function isNil(x) { return x === NIL || x === null || x === undefined; } 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 isSxTruthy(x) { return x !== false && !isNil(x); }
function Symbol(name) { this.name = name; } function Symbol(name) { this.name = name; }
@@ -70,9 +65,7 @@
function isMacro(x) { return x && x._macro === true; } function isMacro(x) { return x && x._macro === true; }
function isRaw(x) { return x && x._raw === true; } function isRaw(x) { return x && x._raw === true; }
// ========================================================================= // --- Parser ---
// Parser
// =========================================================================
var RE_WS = /\s+/y; var RE_WS = /\s+/y;
var RE_COMMENT = /;[^\n]*/y; var RE_COMMENT = /;[^\n]*/y;
@@ -242,9 +235,7 @@
return results; return results;
} }
// ========================================================================= // --- Primitives ---
// Primitives
// =========================================================================
var PRIMITIVES = {}; var PRIMITIVES = {};
@@ -345,9 +336,7 @@
return r; return r;
}; };
// ========================================================================= // --- Evaluator ---
// Evaluator
// =========================================================================
function sxEval(expr, env) { function sxEval(expr, env) {
// Literals // Literals
@@ -444,6 +433,68 @@
return sxEval(comp.body, local); 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 ------------------------------------------------------- // --- Special forms -------------------------------------------------------
var SPECIAL_FORMS = {}; var SPECIAL_FORMS = {};
@@ -462,26 +513,8 @@
}; };
SPECIAL_FORMS["cond"] = function (expr, env) { SPECIAL_FORMS["cond"] = function (expr, env) {
var clauses = expr.slice(1); var branch = _evalCond(expr.slice(1), env);
if (!clauses.length) return NIL; return branch ? sxEval(branch, env) : 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;
}; };
SPECIAL_FORMS["case"] = function (expr, env) { SPECIAL_FORMS["case"] = function (expr, env) {
@@ -514,22 +547,7 @@
}; };
SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) { SPECIAL_FORMS["let"] = SPECIAL_FORMS["let*"] = function (expr, env) {
var bindings = expr[1], local = merge({}, env); var local = _processBindings(expr[1], 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 result = NIL; var result = NIL;
for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local); for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local);
return result; return result;
@@ -714,9 +732,7 @@
return NIL; return NIL;
}; };
// ========================================================================= // --- DOM Renderer ---
// DOM Renderer
// =========================================================================
var HTML_TAGS = makeSet( var HTML_TAGS = makeSet(
"html head body title meta link style script base noscript " + "html head body title meta link style script base noscript " +
@@ -813,39 +829,12 @@
}; };
RENDER_FORMS["cond"] = function (expr, env) { RENDER_FORMS["cond"] = function (expr, env) {
var clauses = expr.slice(1); var branch = _evalCond(expr.slice(1), env);
if (!clauses.length) return document.createDocumentFragment(); return branch ? renderDOM(branch, env) : 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();
}; };
RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) { RENDER_FORMS["let"] = RENDER_FORMS["let*"] = function (expr, env) {
var bindings = expr[1], local = merge({}, env); var local = _processBindings(expr[1], 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 frag = document.createDocumentFragment(); var frag = document.createDocumentFragment();
for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local)); for (var k = 2; k < expr.length; k++) frag.appendChild(renderDOM(expr[k], local));
return frag; return frag;
@@ -1070,216 +1059,7 @@
return el; return el;
} }
// ========================================================================= // --- Helpers ---
// 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
// =========================================================================
function merge(target) { function merge(target) {
for (var i = 1; i < arguments.length; i++) { for (var i = 1; i < arguments.length; i++) {
@@ -1298,15 +1078,11 @@
/** Convert snake_case kwargs to kebab-case for sx conventions. */ /** Convert snake_case kwargs to kebab-case for sx conventions. */
function toKebab(s) { return s.replace(/_/g, "-"); } function toKebab(s) { return s.replace(/_/g, "-"); }
// ========================================================================= // --- Public API ---
// Public API
// =========================================================================
var _componentEnv = {}; var _componentEnv = {};
// ========================================================================= // --- Head auto-hoist ---
// Head auto-hoist: move meta/title/link/script[ld+json] from body to <head>
// =========================================================================
var HEAD_HOIST_SELECTOR = var HEAD_HOIST_SELECTOR =
"meta, title, link[rel='canonical'], script[type='application/ld+json']"; "meta, title, link[rel='canonical'], script[type='application/ld+json']";
@@ -1382,13 +1158,6 @@
return renderDOM(exprOrText, env); 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). * Render a named component with keyword args (Python-style API).
* Sx.renderComponent("card", {title: "Hi"}) * Sx.renderComponent("card", {title: "Hi"})
@@ -1415,26 +1184,7 @@
var exprs = parseAll(text); var exprs = parseAll(text);
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv); for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
} catch (err) { } catch (err) {
// Enhanced error logging: show context around parse failure _logParseError("loadComponents PARSE ERROR", text, err, 120);
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)");
}
throw err; throw err;
} }
}, },
@@ -1458,28 +1208,7 @@
try { try {
node = Sx.render(exprOrText, extraEnv); node = Sx.render(exprOrText, extraEnv);
} catch (e) { } catch (e) {
if (typeof exprOrText === "string") { if (typeof exprOrText === "string") _logParseError("MOUNT PARSE ERROR", exprOrText, e, 80);
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));
}
}
throw e; throw e;
} }
el.textContent = ""; 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 }, _types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
_eval: sxEval, _eval: sxEval,
_renderStr: renderStr, _expandMacro: expandMacro,
_callLambda: callLambda,
_renderDOM: renderDOM, _renderDOM: renderDOM,
}; };
global.Sx = Sx; global.Sx = Sx;
// ========================================================================= // --- SxEngine — native fetch/swap/history engine ---
// SxEngine — native fetch/swap/history engine (replaces HTMX)
// =========================================================================
var SxEngine = (function () { var SxEngine = (function () {
if (typeof document === "undefined") return {}; if (typeof document === "undefined") return {};
@@ -1889,24 +1617,7 @@
container.appendChild(sxDom); container.appendChild(sxDom);
// OOB processing on live DOM nodes // OOB processing on live DOM nodes
var oobs = container.querySelectorAll("[sx-swap-oob]"); _processOOBSwaps(container, _swapDOM);
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);
});
// sx-select filtering // sx-select filtering
var selectedDOM; var selectedDOM;
@@ -1941,27 +1652,7 @@
Sx.processScripts(doc); Sx.processScripts(doc);
// OOB processing // OOB processing
var oobs = doc.querySelectorAll("[sx-swap-oob]"); _processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); });
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);
});
// Build final content // Build final content
var content; var content;
@@ -2150,10 +1841,7 @@
} else { } else {
_morphDOM(target, newNodes); _morphDOM(target, newNodes);
} }
_activateScripts(parent); _postSwap(parent);
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
return; // early return like existing outerHTML return; // early return like existing outerHTML
case "afterend": case "afterend":
target.parentNode.insertBefore(newNodes, target.nextSibling); target.parentNode.insertBefore(newNodes, target.nextSibling);
@@ -2179,14 +1867,26 @@
_morphChildren(target, wrapper); _morphChildren(target, wrapper);
} }
} }
_activateScripts(target); _postSwap(target);
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
} }
// ---- Swap engine (string-based, kept as fallback) ---------------------- // ---- 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. /** Scripts inserted via innerHTML/insertAdjacentHTML don't execute.
* Recreate them as live elements so the browser fetches & runs them. */ * Recreate them as live elements so the browser fetches & runs them. */
function _activateScripts(root) { 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) { function _swapContent(target, html, strategy) {
switch (strategy) { switch (strategy) {
case "innerHTML": case "innerHTML":
@@ -2211,11 +1918,7 @@
var parent = tgt.parentNode; var parent = tgt.parentNode;
tgt.insertAdjacentHTML("afterend", html); tgt.insertAdjacentHTML("afterend", html);
parent.removeChild(tgt); parent.removeChild(tgt);
// Process parent to catch all newly inserted siblings _postSwap(parent);
_activateScripts(parent);
Sx.processScripts(parent);
Sx.hydrate(parent);
SxEngine.process(parent);
return; // early return — afterSwap handling done inline return; // early return — afterSwap handling done inline
case "afterend": case "afterend":
target.insertAdjacentHTML("afterend", html); target.insertAdjacentHTML("afterend", html);
@@ -2235,10 +1938,7 @@
default: default:
target.innerHTML = html; target.innerHTML = html;
} }
_activateScripts(target); _postSwap(target);
Sx.processScripts(target);
Sx.hydrate(target);
SxEngine.process(target);
} }
// ---- Retry system ----------------------------------------------------- // ---- Retry system -----------------------------------------------------
@@ -2413,37 +2113,11 @@
popContainer.appendChild(popDom); popContainer.appendChild(popDom);
// Process OOB swaps (sidebar, filter, menu, headers) // Process OOB swaps (sidebar, filter, menu, headers)
var oobs = popContainer.querySelectorAll("[sx-swap-oob]"); _processOOBSwaps(popContainer, _swapDOM, function (t) { Sx.hydrate(t); SxEngine.process(t); });
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);
}
});
var newMain = popContainer.querySelector("#main-panel"); var newMain = popContainer.querySelector("#main-panel");
_morphChildren(main, newMain || popContainer); _morphChildren(main, newMain || popContainer);
_activateScripts(main); _postSwap(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main }); dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0); window.scrollTo(0, e.state && e.state.scrollY || 0);
} catch (err) { } catch (err) {
@@ -2456,34 +2130,12 @@
var doc = parser.parseFromString(text, "text/html"); var doc = parser.parseFromString(text, "text/html");
// Process OOB swaps from HTML response // Process OOB swaps from HTML response
var hOobs = doc.querySelectorAll("[sx-swap-oob]"); _processOOBSwaps(doc, function (t, o, s) { _swapContent(t, o.outerHTML, s); });
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);
});
var newMain = doc.getElementById("main-panel"); var newMain = doc.getElementById("main-panel");
if (newMain) { if (newMain) {
_morphChildren(main, newMain); _morphChildren(main, newMain);
_activateScripts(main); _postSwap(main);
Sx.processScripts(main);
Sx.hydrate(main);
SxEngine.process(main);
dispatch(document.body, "sx:afterSettle", { target: main }); dispatch(document.body, "sx:afterSettle", { target: main });
window.scrollTo(0, e.state && e.state.scrollY || 0); window.scrollTo(0, e.state && e.state.scrollY || 0);
} else { } else {
@@ -2561,52 +2213,29 @@
global.SxEngine = SxEngine; global.SxEngine = SxEngine;
// ========================================================================= // --- Auto-init in browser ---
// Auto-init in browser
// =========================================================================
Sx.VERSION = "2026-03-01c-cssx"; Sx.VERSION = "2026-03-01c-cssx";
// CSS class tracking for on-demand CSS delivery // CSS class tracking for on-demand CSS delivery
var _sxCssKnown = {};
var _sxCssHash = ""; // 8-char hex hash from server var _sxCssHash = ""; // 8-char hex hash from server
function _initCssTracking() { function _initCssTracking() {
var meta = document.querySelector('meta[name="sx-css-classes"]'); var meta = document.querySelector('meta[name="sx-css-classes"]');
if (meta) { if (meta) {
var content = meta.getAttribute("content"); var content = meta.getAttribute("content");
if (content) { if (content) _sxCssHash = 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;
});
}
}
} }
} }
function _getSxCssHeader() { function _getSxCssHeader() {
// Prefer sending the hash (compact) over the full class list return _sxCssHash;
if (_sxCssHash) return _sxCssHash;
var names = Object.keys(_sxCssKnown);
return names.length ? names.join(",") : "";
} }
function _processCssResponse(text, resp) { function _processCssResponse(text, resp) {
// Read SX-Css-Hash response header — replaces local hash
var hashHeader = resp.headers.get("SX-Css-Hash"); var hashHeader = resp.headers.get("SX-Css-Hash");
if (hashHeader) _sxCssHash = hashHeader; 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"> // Extract <style data-sx-css>...</style> blocks and inject into <style id="sx-css">
var cssTarget = document.getElementById("sx-css"); var cssTarget = document.getElementById("sx-css");
if (cssTarget) { if (cssTarget) {
@@ -2619,9 +2248,7 @@
return text; return text;
} }
// --------------------------------------------------------------------------- // --- sx-comp-hash cookie helpers ---
// sx-comp-hash cookie helpers (component caching)
// ---------------------------------------------------------------------------
function _setSxCompCookie(hash) { function _setSxCompCookie(hash) {
document.cookie = "sx-comp-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax"; 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 from shared.sx.evaluator import evaluate
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js" 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: 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 # Build a small Node script
script = f""" script = f"""
global.document = undefined; // no DOM needed for string render global.document = undefined; // no DOM needed for string render
{SX_JS.read_text()} {SX_JS.read_text()}
{SX_TEST_JS.read_text()}
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)}); if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
var result = Sx.renderToString({json.dumps(sx_text)}); var result = Sx.renderToString({json.dumps(sx_text)});
process.stdout.write(result); process.stdout.write(result);

View File

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