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>
293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
/**
|
|
* 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, "<").replace(/>/g, ">"); }
|
|
function escapeAttr(s) { return s.replace(/&/g, "&").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 '<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);
|