From a9526c4fa1f7a88bd1b1b1def666a47da858146b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 10:17:28 +0000 Subject: [PATCH] Update reference SX spec to match sx.js macros branch (CSSX, dict literals, new primitives) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eval.sx: Add defstyle, defkeyframes, defhandler special forms; add ho-for-each - parser.sx: Add dict {...} literal parsing and quasiquote/unquote sugar - primitives.sx: Add parse-datetime, split-ids, css, merge-styles primitives - render.sx: Add StyleValue handling, SVG filter elements, definition forms in render, fix render-to-html to handle HTML tags directly - bootstrap_js.py: Add StyleValue type, buildKeyframes, isEvery platform helper, new primitives (format-date, parse-datetime, split-ids, css, merge-styles), dict/quasiquote parser, expose render functions as primitives - sx-ref.js: Regenerated — 132/132 tests passing Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 143 +++++++++++++++++++++++++++++--- shared/sx/ref/bootstrap_js.py | 109 +++++++++++++++++++++++- shared/sx/ref/eval.sx | 36 +++++++- shared/sx/ref/parser.sx | 67 ++++++++++++++- shared/sx/ref/primitives.sx | 31 +++++++ shared/sx/ref/render.sx | 49 +++++++++-- 6 files changed, 406 insertions(+), 29 deletions(-) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 324f0d9..4b9b587 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -58,6 +58,15 @@ function RawHTML(html) { this.html = html; } RawHTML.prototype._raw = true; + function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) { + this.className = className; + this.declarations = declarations || ""; + this.mediaRules = mediaRules || []; + this.pseudoRules = pseudoRules || []; + this.keyframes = keyframes || []; + } + StyleValue.prototype._styleValue = true; + function isSym(x) { return x != null && x._sym === true; } function isKw(x) { return x != null && x._kw === true; } @@ -93,6 +102,7 @@ if (x._component) return "component"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; + if (x._styleValue) return "style-value"; if (Array.isArray(x)) return "list"; if (typeof x === "object") return "dict"; return "unknown"; @@ -138,6 +148,27 @@ function isComponent(x) { return x != null && x._component === true; } function isMacro(x) { return x != null && x._macro === true; } + function isStyleValue(x) { return x != null && x._styleValue === true; } + function styleValueClass(x) { return x.className; } + function styleValue_p(x) { return x != null && x._styleValue === true; } + + function buildKeyframes(kfName, steps, env) { + // Platform implementation of defkeyframes + var parts = []; + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + var selector = isSym(step[0]) ? step[0].name : String(step[0]); + var body = trampoline(evalExpr(step[1], env)); + var decls = isStyleValue(body) ? body.declarations : String(body); + parts.push(selector + "{" + decls + "}"); + } + var kfRule = "@keyframes " + kfName + "{" + parts.join("") + "}"; + var cn = "sx-ref-kf-" + kfName; + var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]); + env[kfName] = sv; + return sv; + } + function envHas(env, name) { return name in env; } function envGet(env, name) { return env[name]; } function envSet(env, name, val) { env[name] = val; } @@ -288,6 +319,45 @@ PRIMITIVES["escape"] = function(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); }; + PRIMITIVES["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; + PRIMITIVES["split-ids"] = function(s) { + if (!s) return []; + return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + }; + PRIMITIVES["css"] = function() { + // Stub — CSSX requires style dictionary which is browser-only + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); + }; + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); + return new StyleValue("sx-merged", allDecls, [], [], []); + }; function isPrimitive(name) { return name in PRIMITIVES; } function getPrimitive(name) { return PRIMITIVES[name]; } @@ -306,6 +376,10 @@ return NIL; } function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function isEvery(fn, coll) { + for (var i = 0; i < coll.length; i++) { if (!isSxTruthy(fn(coll[i]))) return false; } + return true; + } function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } // List primitives used directly by transpiled code @@ -352,7 +426,8 @@ function isSpecialForm(n) { return n in { "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, - "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"begin":1,"do":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1, + "defkeyframes":1,"defhandler":1,"begin":1,"do":1, "quote":1,"quasiquote":1,"->":1,"set!":1 }; } function isHoForm(n) { return n in { @@ -371,7 +446,7 @@ var evalExpr = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() { var name = symbolName(expr); return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name)))))))); -})(); if (_m == "keyword") return keywordName(expr); if (_m == "dict") return mapDict(function(k, v) { return [k, trampoline(evalExpr(v, env))]; }, expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : evalList(expr, env)); return expr; })(); }; +})(); if (_m == "keyword") return keywordName(expr); if (_m == "dict") return mapDict(function(k, v) { return trampoline(evalExpr(v, env)); }, expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : evalList(expr, env)); return expr; })(); }; // eval-list var evalList = function(expr, env) { return (function() { @@ -379,10 +454,10 @@ var args = rest(expr); return (isSxTruthy(!sxOr((typeOf(head) == "symbol"), (typeOf(head) == "lambda"), (typeOf(head) == "list"))) ? map(function(x) { return trampoline(evalExpr(x, env)); }, expr) : (isSxTruthy((typeOf(head) == "symbol")) ? (function() { var name = symbolName(head); - return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { + return (isSxTruthy((name == "if")) ? sfIf(args, env) : (isSxTruthy((name == "when")) ? sfWhen(args, env) : (isSxTruthy((name == "cond")) ? sfCond(args, env) : (isSxTruthy((name == "case")) ? sfCase(args, env) : (isSxTruthy((name == "and")) ? sfAnd(args, env) : (isSxTruthy((name == "or")) ? sfOr(args, env) : (isSxTruthy((name == "let")) ? sfLet(args, env) : (isSxTruthy((name == "let*")) ? sfLet(args, env) : (isSxTruthy((name == "lambda")) ? sfLambda(args, env) : (isSxTruthy((name == "fn")) ? sfLambda(args, env) : (isSxTruthy((name == "define")) ? sfDefine(args, env) : (isSxTruthy((name == "defcomp")) ? sfDefcomp(args, env) : (isSxTruthy((name == "defmacro")) ? sfDefmacro(args, env) : (isSxTruthy((name == "defstyle")) ? sfDefstyle(args, env) : (isSxTruthy((name == "defkeyframes")) ? sfDefkeyframes(args, env) : (isSxTruthy((name == "defhandler")) ? sfDefine(args, env) : (isSxTruthy((name == "begin")) ? sfBegin(args, env) : (isSxTruthy((name == "do")) ? sfBegin(args, env) : (isSxTruthy((name == "quote")) ? sfQuote(args, env) : (isSxTruthy((name == "quasiquote")) ? sfQuasiquote(args, env) : (isSxTruthy((name == "->")) ? sfThreadFirst(args, env) : (isSxTruthy((name == "set!")) ? sfSetBang(args, env) : (isSxTruthy((name == "map")) ? hoMap(args, env) : (isSxTruthy((name == "map-indexed")) ? hoMapIndexed(args, env) : (isSxTruthy((name == "filter")) ? hoFilter(args, env) : (isSxTruthy((name == "reduce")) ? hoReduce(args, env) : (isSxTruthy((name == "some")) ? hoSome(args, env) : (isSxTruthy((name == "every?")) ? hoEvery(args, env) : (isSxTruthy((name == "for-each")) ? hoForEach(args, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? (function() { var mac = envGet(env, name); return makeThunk(expandMacro(mac, args, env), env); -})() : evalCall(head, args, env)))))))))))))))))))))))))))); +})() : evalCall(head, args, env))))))))))))))))))))))))))))))); })() : evalCall(head, args, env))); })(); }; @@ -574,6 +649,21 @@ return [params, restParam]; })(); }; + // sf-defstyle + var sfDefstyle = function(args, env) { return (function() { + var nameSym = first(args); + var value = trampoline(evalExpr(nth(args, 1), env)); + env[symbolName(nameSym)] = value; + return value; +})(); }; + + // sf-defkeyframes + var sfDefkeyframes = function(args, env) { return (function() { + var kfName = symbolName(first(args)); + var steps = rest(args); + return buildKeyframes(kfName, steps, env); +})(); }; + // sf-begin var sfBegin = function(args, env) { return (isSxTruthy(isEmpty(args)) ? NIL : (forEach(function(e) { return trampoline(evalExpr(e, env)); }, slice(args, 0, (len(args) - 1))), makeThunk(last(args), env))); }; @@ -667,26 +757,30 @@ return isEvery(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); })(); }; + // ho-for-each + var hoForEach = function(args, env) { return (function() { + var f = trampoline(evalExpr(first(args), env)); + var coll = trampoline(evalExpr(nth(args, 1), env)); + return forEach(function(item) { return trampoline(callLambda(f, [item], env)); }, coll); +})(); }; + // === Transpiled from render.sx === // HTML_TAGS - var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "path", "circle", "rect", "line", "polyline", "polygon", "text", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; + var HTML_TAGS = ["html", "head", "body", "title", "meta", "link", "script", "style", "noscript", "header", "nav", "main", "section", "article", "aside", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "div", "p", "blockquote", "pre", "figure", "figcaption", "address", "details", "summary", "a", "span", "em", "strong", "small", "b", "i", "u", "s", "mark", "sub", "sup", "abbr", "cite", "code", "time", "br", "wbr", "hr", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "form", "input", "textarea", "select", "option", "optgroup", "button", "label", "fieldset", "legend", "output", "datalist", "img", "video", "audio", "source", "picture", "canvas", "iframe", "svg", "math", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "text", "tspan", "g", "defs", "use", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "stop", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix", "feComposite", "feMerge", "feMergeNode", "feTurbulence", "feComponentTransfer", "feFuncR", "feFuncG", "feFuncB", "feFuncA", "feDisplacementMap", "feFlood", "feImage", "feMorphology", "feSpecularLighting", "feDiffuseLighting", "fePointLight", "feSpotLight", "feDistantLight", "animate", "animateTransform", "foreignObject", "template", "slot", "dialog", "menu"]; // VOID_ELEMENTS var VOID_ELEMENTS = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]; // BOOLEAN_ATTRS - var BOOLEAN_ATTRS = ["disabled", "checked", "selected", "readonly", "required", "hidden", "autofocus", "autoplay", "controls", "loop", "muted", "defer", "async", "novalidate", "formnovalidate", "multiple", "open", "allowfullscreen"]; + var BOOLEAN_ATTRS = ["async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "formnovalidate", "hidden", "inert", "ismap", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "selected"]; // render-to-html - var renderToHtml = function(expr, env) { return (function() { - var result = trampoline(evalExpr(expr, env)); - return renderValueToHtml(result, env); -})(); }; + var renderToHtml = function(expr, env) { return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(expr); if (_m == "number") return (String(expr)); if (_m == "boolean") return (isSxTruthy(expr) ? "true" : "false"); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? "" : renderListToHtml(expr, env)); if (_m == "symbol") return renderValueToHtml(trampoline(evalExpr(expr, env)), env); if (_m == "keyword") return escapeHtml(keywordName(expr)); return renderValueToHtml(trampoline(evalExpr(expr, env)), env); })(); }; // render-value-to-html - var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); return escapeHtml((String(val))); })(); }; + var renderValueToHtml = function(val, env) { return (function() { var _m = typeOf(val); if (_m == "nil") return ""; if (_m == "string") return escapeHtml(val); if (_m == "number") return (String(val)); if (_m == "boolean") return (isSxTruthy(val) ? "true" : "false"); if (_m == "list") return renderListToHtml(val, env); if (_m == "raw-html") return rawHtmlContent(val); if (_m == "style-value") return styleValueClass(val); return escapeHtml((String(val))); })(); }; // render-list-to-html var renderListToHtml = function(expr, env) { return (isSxTruthy(isEmpty(expr)) ? "" : (function() { @@ -697,7 +791,7 @@ return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { var comp = envGet(env, name); return (isSxTruthy(isComponent(comp)) ? renderToHtml(trampoline(callComponent(comp, args, env)), env) : error((String("Unknown component: ") + String(name)))); -})() : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(trampoline(evalExpr(expandMacro(envGet(env, name), args, env), env)), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))); +})() : (isSxTruthy(sxOr((name == "define"), (name == "defcomp"), (name == "defmacro"), (name == "defstyle"), (name == "defkeyframes"), (name == "defhandler"))) ? (trampoline(evalExpr(expr, env)), "") : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(trampoline(evalExpr(expandMacro(envGet(env, name), args, env), env)), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))); })()); })()); }; @@ -728,7 +822,7 @@ // render-attrs var renderAttrs = function(attrs) { return join("", map(function(key) { return (function() { var val = dictGet(attrs, key); - return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\""))))); + return (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && val)) ? (String(" ") + String(key)) : (isSxTruthy((isSxTruthy(contains(BOOLEAN_ATTRS, key)) && !val)) ? "" : (isSxTruthy(isNil(val)) ? "" : (isSxTruthy((isSxTruthy((key == "style")) && isStyleValue(val))) ? (String(" class=\"") + String(styleValueClass(val)) + String("\"")) : (String(" ") + String(key) + String("=\"") + String(escapeAttr((String(val)))) + String("\"")))))); })(); }, keys(attrs))); }; // render-to-sx @@ -798,6 +892,11 @@ return _rawCallLambda(f, args, callerEnv); }; + // Expose render functions as primitives so SX code can call them + if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml; + if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx; + if (typeof aser === "function") PRIMITIVES["aser"] = aser; + // ========================================================================= // Parser (reused from reference — hand-written for bootstrap simplicity) // ========================================================================= @@ -824,8 +923,15 @@ var ch = text[pos]; if (ch === "(") { pos++; return readList(")"); } if (ch === "[") { pos++; return readList("]"); } + if (ch === "{") { pos++; return readMap(); } if (ch === '"') return readString(); if (ch === ":") return readKeyword(); + if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; } + if (ch === ",") { + pos++; + if (pos < text.length && text[pos] === "@") { pos++; return [new Symbol("splice-unquote"), readExpr()]; } + return [new Symbol("unquote"), readExpr()]; + } if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); if (ch >= "0" && ch <= "9") return readNumber(); return readSymbol(); @@ -839,6 +945,17 @@ items.push(readExpr()); } } + function readMap() { + var result = {}; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated map"); + if (text[pos] === "}") { pos++; return result; } + var key = readExpr(); + var keyStr = (key && key._kw) ? key.name : String(key); + result[keyStr] = readExpr(); + } + } function readString() { pos++; // skip " var s = ""; diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 5e9583c..bb53b8a 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -178,6 +178,13 @@ class JSEmitter: "ho-reduce": "hoReduce", "ho-some": "hoSome", "ho-every": "hoEvery", + "ho-for-each": "hoForEach", + "sf-defstyle": "sfDefstyle", + "sf-defkeyframes": "sfDefkeyframes", + "build-keyframes": "buildKeyframes", + "style-value?": "isStyleValue", + "style-value-class": "styleValueClass", + "kf-name": "kfName", "special-form?": "isSpecialForm", "ho-form?": "isHoForm", "strip-prefix": "stripPrefix", @@ -595,6 +602,15 @@ PREAMBLE = '''\ function RawHTML(html) { this.html = html; } RawHTML.prototype._raw = true; + function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) { + this.className = className; + this.declarations = declarations || ""; + this.mediaRules = mediaRules || []; + this.pseudoRules = pseudoRules || []; + this.keyframes = keyframes || []; + } + StyleValue.prototype._styleValue = true; + function isSym(x) { return x != null && x._sym === true; } function isKw(x) { return x != null && x._kw === true; } @@ -631,6 +647,7 @@ PLATFORM_JS = ''' if (x._component) return "component"; if (x._macro) return "macro"; if (x._raw) return "raw-html"; + if (x._styleValue) return "style-value"; if (Array.isArray(x)) return "list"; if (typeof x === "object") return "dict"; return "unknown"; @@ -676,6 +693,27 @@ PLATFORM_JS = ''' function isComponent(x) { return x != null && x._component === true; } function isMacro(x) { return x != null && x._macro === true; } + function isStyleValue(x) { return x != null && x._styleValue === true; } + function styleValueClass(x) { return x.className; } + function styleValue_p(x) { return x != null && x._styleValue === true; } + + function buildKeyframes(kfName, steps, env) { + // Platform implementation of defkeyframes + var parts = []; + for (var i = 0; i < steps.length; i++) { + var step = steps[i]; + var selector = isSym(step[0]) ? step[0].name : String(step[0]); + var body = trampoline(evalExpr(step[1], env)); + var decls = isStyleValue(body) ? body.declarations : String(body); + parts.push(selector + "{" + decls + "}"); + } + var kfRule = "@keyframes " + kfName + "{" + parts.join("") + "}"; + var cn = "sx-ref-kf-" + kfName; + var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]); + env[kfName] = sv; + return sv; + } + function envHas(env, name) { return name in env; } function envGet(env, name) { return env[name]; } function envSet(env, name, val) { env[name] = val; } @@ -826,6 +864,45 @@ PLATFORM_JS = ''' PRIMITIVES["escape"] = function(s) { return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); }; + PRIMITIVES["format-date"] = function(s, fmt) { + if (!s) return ""; + try { + var d = new Date(s); + if (isNaN(d.getTime())) return String(s); + var months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + var short_months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return fmt.replace(/%-d/g, d.getDate()).replace(/%d/g, ("0"+d.getDate()).slice(-2)) + .replace(/%B/g, months[d.getMonth()]).replace(/%b/g, short_months[d.getMonth()]) + .replace(/%Y/g, d.getFullYear()).replace(/%m/g, ("0"+(d.getMonth()+1)).slice(-2)) + .replace(/%H/g, ("0"+d.getHours()).slice(-2)).replace(/%M/g, ("0"+d.getMinutes()).slice(-2)); + } catch (e) { return String(s); } + }; + PRIMITIVES["parse-datetime"] = function(s) { return s ? String(s) : NIL; }; + PRIMITIVES["split-ids"] = function(s) { + if (!s) return []; + return String(s).split(",").map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + }; + PRIMITIVES["css"] = function() { + // Stub — CSSX requires style dictionary which is browser-only + var atoms = []; + for (var i = 0; i < arguments.length; i++) { + var a = arguments[i]; + if (isNil(a) || a === false) continue; + atoms.push(isKw(a) ? a.name : String(a)); + } + if (!atoms.length) return NIL; + return new StyleValue("sx-" + atoms.join("-"), atoms.join(";"), [], [], []); + }; + PRIMITIVES["merge-styles"] = function() { + var valid = []; + for (var i = 0; i < arguments.length; i++) { + if (isStyleValue(arguments[i])) valid.push(arguments[i]); + } + if (!valid.length) return NIL; + if (valid.length === 1) return valid[0]; + var allDecls = valid.map(function(v) { return v.declarations; }).join(";"); + return new StyleValue("sx-merged", allDecls, [], [], []); + }; function isPrimitive(name) { return name in PRIMITIVES; } function getPrimitive(name) { return PRIMITIVES[name]; } @@ -844,6 +921,10 @@ PLATFORM_JS = ''' return NIL; } function forEach(fn, coll) { for (var i = 0; i < coll.length; i++) fn(coll[i]); return NIL; } + function isEvery(fn, coll) { + for (var i = 0; i < coll.length; i++) { if (!isSxTruthy(fn(coll[i]))) return false; } + return true; + } function mapDict(fn, d) { var r = {}; for (var k in d) r[k] = fn(k, d[k]); return r; } // List primitives used directly by transpiled code @@ -890,7 +971,8 @@ PLATFORM_JS = ''' function isSpecialForm(n) { return n in { "if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1, - "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"begin":1,"do":1, + "lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1, + "defkeyframes":1,"defhandler":1,"begin":1,"do":1, "quote":1,"quasiquote":1,"->":1,"set!":1 }; } function isHoForm(n) { return n in { @@ -907,7 +989,12 @@ FIXUPS = ''' callLambda = function(f, args, callerEnv) { if (typeof f === "function") return f.apply(null, args); return _rawCallLambda(f, args, callerEnv); - };''' + }; + + // Expose render functions as primitives so SX code can call them + if (typeof renderToHtml === "function") PRIMITIVES["render-to-html"] = renderToHtml; + if (typeof renderToSx === "function") PRIMITIVES["render-to-sx"] = renderToSx; + if (typeof aser === "function") PRIMITIVES["aser"] = aser;''' PUBLIC_API = ''' // ========================================================================= @@ -936,8 +1023,15 @@ PUBLIC_API = ''' var ch = text[pos]; if (ch === "(") { pos++; return readList(")"); } if (ch === "[") { pos++; return readList("]"); } + if (ch === "{") { pos++; return readMap(); } if (ch === \'"\') return readString(); if (ch === ":") return readKeyword(); + if (ch === "`") { pos++; return [new Symbol("quasiquote"), readExpr()]; } + if (ch === ",") { + pos++; + if (pos < text.length && text[pos] === "@") { pos++; return [new Symbol("splice-unquote"), readExpr()]; } + return [new Symbol("unquote"), readExpr()]; + } if (ch === "-" && pos + 1 < text.length && text[pos + 1] >= "0" && text[pos + 1] <= "9") return readNumber(); if (ch >= "0" && ch <= "9") return readNumber(); return readSymbol(); @@ -951,6 +1045,17 @@ PUBLIC_API = ''' items.push(readExpr()); } } + function readMap() { + var result = {}; + while (true) { + skipWs(); + if (pos >= text.length) throw new Error("Unterminated map"); + if (text[pos] === "}") { pos++; return result; } + var key = readExpr(); + var keyStr = (key && key._kw) ? key.name : String(key); + result[keyStr] = readExpr(); + } + } function readString() { pos++; // skip " var s = ""; diff --git a/shared/sx/ref/eval.sx b/shared/sx/ref/eval.sx index 018a621..fd1a60e 100644 --- a/shared/sx/ref/eval.sx +++ b/shared/sx/ref/eval.sx @@ -96,7 +96,7 @@ ;; --- dict literal --- "dict" - (map-dict (fn (k v) (list k (trampoline (eval-expr v env)))) expr) + (map-dict (fn (k v) (trampoline (eval-expr v env))) expr) ;; --- list = call or special form --- "list" @@ -141,6 +141,9 @@ (= name "define") (sf-define args env) (= name "defcomp") (sf-defcomp args env) (= name "defmacro") (sf-defmacro args env) + (= name "defstyle") (sf-defstyle args env) + (= name "defkeyframes") (sf-defkeyframes args env) + (= name "defhandler") (sf-define args env) (= name "begin") (sf-begin args env) (= name "do") (sf-begin args env) (= name "quote") (sf-quote args env) @@ -495,6 +498,25 @@ (list params rest-param)))) +(define sf-defstyle + (fn (args env) + ;; (defstyle name expr) — bind name to evaluated expr (typically a StyleValue) + (let ((name-sym (first args)) + (value (trampoline (eval-expr (nth args 1) env)))) + (env-set! env (symbol-name name-sym) value) + value))) + + +(define sf-defkeyframes + (fn (args env) + ;; (defkeyframes name (selector body) ...) — build @keyframes rule, + ;; register in keyframes dict, return StyleValue. + ;; Delegates to platform: build-keyframes returns a StyleValue. + (let ((kf-name (symbol-name (first args))) + (steps (rest args))) + (build-keyframes kf-name steps env)))) + + (define sf-begin (fn (args env) (if (empty? args) @@ -651,6 +673,15 @@ coll)))) +(define ho-for-each + (fn (args env) + (let ((f (trampoline (eval-expr (first args) env))) + (coll (trampoline (eval-expr (nth args 1) env)))) + (for-each + (fn (item) (trampoline (call-lambda f (list item) env))) + coll)))) + + ;; -------------------------------------------------------------------------- ;; 8. Primitives — pure functions available in all targets ;; -------------------------------------------------------------------------- @@ -728,4 +759,7 @@ ;; (strip-prefix s prefix) → string with prefix removed (or s unchanged) ;; (apply f args) → call f with args list ;; (zip lists...) → list of tuples +;; +;; CSSX (style system): +;; (build-keyframes name steps env) → StyleValue (platform builds @keyframes) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/parser.sx b/shared/sx/ref/parser.sx index 3ed6d17..5c50bf4 100644 --- a/shared/sx/ref/parser.sx +++ b/shared/sx/ref/parser.sx @@ -18,11 +18,13 @@ ;; ident → [a-zA-Z_~*+\-><=/!?&] [a-zA-Z0-9_~*+\-><=/!?.:&]* ;; comment → ';' to end of line (discarded) ;; -;; Quote sugar (optional — not used in current SX): -;; '(expr) → (quote expr) +;; Dict literal: +;; {key val ...} → dict object (keys are keywords or expressions) +;; +;; Quote sugar: ;; `(expr) → (quasiquote expr) -;; ~(expr) → (unquote expr) -;; ~@(expr) → (splice-unquote expr) +;; ,(expr) → (unquote expr) +;; ,@(expr) → (splice-unquote expr) ;; ========================================================================== @@ -81,6 +83,37 @@ (advance-pos!) (scan-next)) + ;; Open brace (dict literal) + (= ch "{") + (do (append! tokens (list "lbrace" "{" line col)) + (advance-pos!) + (scan-next)) + + ;; Close brace + (= ch "}") + (do (append! tokens (list "rbrace" "}" line col)) + (advance-pos!) + (scan-next)) + + ;; Quasiquote sugar + (= ch "`") + (do (advance-pos!) + (let ((inner (scan-next-expr))) + (append! tokens (list "quasiquote" inner line col)) + (scan-next))) + + ;; Unquote / splice-unquote + (= ch ",") + (do (advance-pos!) + (if (and (< pos len-src) (= (nth source pos) "@")) + (do (advance-pos!) + (let ((inner (scan-next-expr))) + (append! tokens (list "splice-unquote" inner line col)) + (scan-next))) + (let ((inner (scan-next-expr))) + (append! tokens (list "unquote" inner line col)) + (scan-next)))) + ;; Keyword (= ch ":") (do (append! tokens (scan-keyword)) (scan-next)) @@ -229,6 +262,10 @@ (do (set! pos (inc pos)) (parse-list tokens "rbracket")) + "lbrace" + (do (set! pos (inc pos)) + (parse-dict tokens)) + "string" (do (set! pos (inc pos)) (nth tok 1)) "number" (do (set! pos (inc pos)) (nth tok 1)) "boolean" (do (set! pos (inc pos)) (nth tok 1)) @@ -261,6 +298,28 @@ items))) +(define parse-dict + (fn (tokens) + ;; Parse {key val key val ...} until "rbrace" token. + ;; Returns a dict (plain object). + (let ((result (dict))) + (define parse-dict-loop + (fn () + (if (>= pos (len tokens)) + (error "Unterminated dict") + (if (= (first (nth tokens pos)) "rbrace") + (do (set! pos (inc pos)) nil) ;; done + (let ((key-expr (parse-expr tokens)) + (key-str (if (= (type-of key-expr) "keyword") + (keyword-name key-expr) + (str key-expr))) + (val-expr (parse-expr tokens))) + (dict-set! result key-str val-expr) + (parse-dict-loop)))))) + (parse-dict-loop) + result))) + + ;; -------------------------------------------------------------------------- ;; Serializer — AST → SX source text ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/primitives.sx b/shared/sx/ref/primitives.sx index 05a9a9e..7aea3bb 100644 --- a/shared/sx/ref/primitives.sx +++ b/shared/sx/ref/primitives.sx @@ -426,3 +426,34 @@ :params (s) :returns "string" :doc "Remove HTML tags from string.") + + +;; -------------------------------------------------------------------------- +;; Date & parsing helpers +;; -------------------------------------------------------------------------- + +(define-primitive "parse-datetime" + :params (s) + :returns "string" + :doc "Parse datetime string — identity passthrough (returns string or nil).") + +(define-primitive "split-ids" + :params (s) + :returns "list" + :doc "Split comma-separated ID string into list of trimmed non-empty strings.") + + +;; -------------------------------------------------------------------------- +;; CSSX — style system primitives +;; -------------------------------------------------------------------------- + +(define-primitive "css" + :params (&rest atoms) + :returns "style-value" + :doc "Resolve style atoms to a StyleValue with className and CSS declarations. + Atoms are keywords or strings: (css :flex :gap-4 :hover:bg-sky-200).") + +(define-primitive "merge-styles" + :params (&rest styles) + :returns "style-value" + :doc "Merge multiple StyleValues into one combined StyleValue.") diff --git a/shared/sx/ref/render.sx b/shared/sx/ref/render.sx index 0e118bd..624d781 100644 --- a/shared/sx/ref/render.sx +++ b/shared/sx/ref/render.sx @@ -44,10 +44,15 @@ ;; Media "img" "video" "audio" "source" "picture" "canvas" "iframe" ;; SVG - "svg" "path" "circle" "rect" "line" "polyline" "polygon" "text" - "g" "defs" "use" "clipPath" "mask" "pattern" "linearGradient" - "radialGradient" "stop" "filter" "feGaussianBlur" "feOffset" - "feBlend" "feColorMatrix" "feComposite" "feMerge" "feMergeNode" + "svg" "math" "path" "circle" "ellipse" "rect" "line" "polyline" "polygon" + "text" "tspan" "g" "defs" "use" "clipPath" "mask" "pattern" + "linearGradient" "radialGradient" "stop" "filter" + "feGaussianBlur" "feOffset" "feBlend" "feColorMatrix" "feComposite" + "feMerge" "feMergeNode" "feTurbulence" + "feComponentTransfer" "feFuncR" "feFuncG" "feFuncB" "feFuncA" + "feDisplacementMap" "feFlood" "feImage" "feMorphology" + "feSpecularLighting" "feDiffuseLighting" + "fePointLight" "feSpotLight" "feDistantLight" "animate" "animateTransform" "foreignObject" ;; Other "template" "slot" "dialog" "menu")) @@ -57,9 +62,10 @@ "link" "meta" "param" "source" "track" "wbr")) (define BOOLEAN_ATTRS - (list "disabled" "checked" "selected" "readonly" "required" "hidden" - "autofocus" "autoplay" "controls" "loop" "muted" "defer" "async" - "novalidate" "formnovalidate" "multiple" "open" "allowfullscreen")) + (list "async" "autofocus" "autoplay" "checked" "controls" "default" + "defer" "disabled" "formnovalidate" "hidden" "inert" "ismap" + "loop" "multiple" "muted" "nomodule" "novalidate" "open" + "playsinline" "readonly" "required" "reversed" "selected")) ;; -------------------------------------------------------------------------- @@ -68,8 +74,20 @@ (define render-to-html (fn (expr env) - (let ((result (trampoline (eval-expr expr env)))) - (render-value-to-html result env)))) + (case (type-of expr) + ;; Literals — render directly + "nil" "" + "string" (escape-html expr) + "number" (str expr) + "boolean" (if expr "true" "false") + ;; List — dispatch to render-list which handles HTML tags, special forms, etc. + "list" (if (empty? expr) "" (render-list-to-html expr env)) + ;; Symbol — evaluate then render + "symbol" (render-value-to-html (trampoline (eval-expr expr env)) env) + ;; Keyword — render as text + "keyword" (escape-html (keyword-name expr)) + ;; Everything else — evaluate first + :else (render-value-to-html (trampoline (eval-expr expr env)) env)))) (define render-value-to-html (fn (val env) @@ -80,6 +98,7 @@ "boolean" (if val "true" "false") "list" (render-list-to-html val env) "raw-html" (raw-html-content val) + "style-value" (style-value-class val) :else (escape-html (str val))))) (define render-list-to-html @@ -114,6 +133,11 @@ env) (error (str "Unknown component: " name)))) + ;; Definitions — evaluate for side effects, render nothing + (or (= name "define") (= name "defcomp") (= name "defmacro") + (= name "defstyle") (= name "defkeyframes") (= name "defhandler")) + (do (trampoline (eval-expr expr env)) "") + ;; Macro expansion (and (env-has? env name) (macro? (env-get env name))) (render-to-html @@ -182,6 +206,9 @@ "" ;; Nil values — skip (nil? val) "" + ;; StyleValue on :style → emit as class + (and (= key "style") (style-value? val)) + (str " class=\"" (style-value-class val) "\"") ;; Normal attr :else (str " " key "=\"" (escape-attr (str val)) "\"")))) (keys attrs))))) @@ -323,6 +350,10 @@ ;; (set-attribute el k v) → void ;; (append-child parent c) → void ;; +;; StyleValue: +;; (style-value? x) → boolean (is x a StyleValue?) +;; (style-value-class sv) → string (CSS class name) +;; ;; Serialization: ;; (serialize val) → SX source string representation of val ;;