Implement CSSX Phase 2: native SX style primitives
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Replace Tailwind class strings with native SX expressions: (css :flex :gap-4 :hover:bg-sky-200) instead of :class "flex gap-4 ..." - Add style_dict.py: 516 atoms, variants, breakpoints, keyframes, patterns - Add style_resolver.py: memoized resolver with variant splitting - Add StyleValue type to types.py (frozen dataclass with class_name, declarations, etc.) - Add css and merge-styles primitives to primitives.py - Add defstyle and defkeyframes special forms to evaluator.py and async_eval.py - Integrate StyleValue into html.py and async_eval.py render paths - Add register_generated_rule() to css_registry.py, fix media query selector - Add style dict JSON delivery with localStorage caching to helpers.py - Add client-side css primitive, resolver, and style injection to sx.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,12 +63,23 @@
|
||||
function RawHTML(html) { this.html = html; }
|
||||
RawHTML.prototype._raw = true;
|
||||
|
||||
/** CSSX StyleValue — generated CSS class with rules. */
|
||||
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 && 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 isStyleValue(x) { return x && x._styleValue === true; }
|
||||
|
||||
// --- Parser ---
|
||||
|
||||
@@ -341,6 +352,227 @@
|
||||
return r;
|
||||
};
|
||||
|
||||
// --- CSSX Style Dictionary + Resolver ---
|
||||
|
||||
var _styleAtoms = {}; // atom → CSS declarations
|
||||
var _pseudoVariants = {}; // variant → CSS pseudo-selector
|
||||
var _responsiveBreakpoints = {}; // variant → media query
|
||||
var _styleKeyframes = {}; // name → @keyframes rule
|
||||
var _arbitraryPatterns = []; // [{re: RegExp, tmpl: string}, ...]
|
||||
var _childSelectorPrefixes = []; // ["space-x-", "space-y-", ...]
|
||||
var _styleCache = {}; // atoms-key → StyleValue
|
||||
var _injectedStyles = {}; // className → true (already in <style>)
|
||||
|
||||
function _loadStyleDict(data) {
|
||||
_styleAtoms = data.a || {};
|
||||
_pseudoVariants = data.v || {};
|
||||
_responsiveBreakpoints = data.b || {};
|
||||
_styleKeyframes = data.k || {};
|
||||
_childSelectorPrefixes = data.c || [];
|
||||
_arbitraryPatterns = [];
|
||||
var pats = data.p || [];
|
||||
for (var i = 0; i < pats.length; i++) {
|
||||
_arbitraryPatterns.push({ re: new RegExp("^" + pats[i][0] + "$"), tmpl: pats[i][1] });
|
||||
}
|
||||
_styleCache = {};
|
||||
}
|
||||
|
||||
function _splitVariant(atom) {
|
||||
// Check responsive prefix first
|
||||
for (var bp in _responsiveBreakpoints) {
|
||||
var prefix = bp + ":";
|
||||
if (atom.indexOf(prefix) === 0) {
|
||||
var rest = atom.substring(prefix.length);
|
||||
for (var pv in _pseudoVariants) {
|
||||
var inner = pv + ":";
|
||||
if (rest.indexOf(inner) === 0) return [bp + ":" + pv, rest.substring(inner.length)];
|
||||
}
|
||||
return [bp, rest];
|
||||
}
|
||||
}
|
||||
for (var pv2 in _pseudoVariants) {
|
||||
var prefix2 = pv2 + ":";
|
||||
if (atom.indexOf(prefix2) === 0) return [pv2, atom.substring(prefix2.length)];
|
||||
}
|
||||
return [null, atom];
|
||||
}
|
||||
|
||||
function _resolveAtom(atom) {
|
||||
var decls = _styleAtoms[atom];
|
||||
if (decls !== undefined) return decls;
|
||||
// Dynamic keyframes: animate-{name} → animation-name:{name}
|
||||
if (atom.indexOf("animate-") === 0) {
|
||||
var kfName = atom.substring(8);
|
||||
if (_styleKeyframes[kfName]) return "animation-name:" + kfName;
|
||||
}
|
||||
for (var i = 0; i < _arbitraryPatterns.length; i++) {
|
||||
var m = atom.match(_arbitraryPatterns[i].re);
|
||||
if (m) {
|
||||
var result = _arbitraryPatterns[i].tmpl;
|
||||
for (var j = 1; j < m.length; j++) result = result.replace("{" + (j - 1) + "}", m[j]);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _isChildSelectorAtom(atom) {
|
||||
for (var i = 0; i < _childSelectorPrefixes.length; i++) {
|
||||
if (atom.indexOf(_childSelectorPrefixes[i]) === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** SHA-256 hash (first 6 hex chars) for content-addressed class names. */
|
||||
function _hashStyle(input) {
|
||||
// Simple FNV-1a 32-bit hash — fast, deterministic, good distribution
|
||||
var h = 0x811c9dc5;
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = (h * 0x01000193) >>> 0;
|
||||
}
|
||||
return h.toString(16).padStart(8, "0").substring(0, 6);
|
||||
}
|
||||
|
||||
function _resolveStyle(atoms) {
|
||||
var key = atoms.join("\0");
|
||||
if (_styleCache[key]) return _styleCache[key];
|
||||
|
||||
var baseDecls = [], mediaRules = [], pseudoRules = [], kfNeeded = [];
|
||||
for (var i = 0; i < atoms.length; i++) {
|
||||
var a = atoms[i];
|
||||
if (!a) continue;
|
||||
if (a.charAt(0) === ":") a = a.substring(1);
|
||||
|
||||
var parts = _splitVariant(a);
|
||||
var variant = parts[0], base = parts[1];
|
||||
var decls = _resolveAtom(base);
|
||||
if (!decls) continue;
|
||||
|
||||
// Check keyframes
|
||||
if (base.indexOf("animate-") === 0) {
|
||||
var kfName = base.substring(8);
|
||||
if (_styleKeyframes[kfName]) kfNeeded.push([kfName, _styleKeyframes[kfName]]);
|
||||
}
|
||||
|
||||
if (variant === null) {
|
||||
baseDecls.push(decls);
|
||||
} else if (_responsiveBreakpoints[variant]) {
|
||||
mediaRules.push([_responsiveBreakpoints[variant], decls]);
|
||||
} else if (_pseudoVariants[variant]) {
|
||||
pseudoRules.push([_pseudoVariants[variant], decls]);
|
||||
} else {
|
||||
// Compound variant: "sm:hover" → split
|
||||
var vparts = variant.split(":");
|
||||
var mediaPart = null, pseudoPart = null;
|
||||
for (var vi = 0; vi < vparts.length; vi++) {
|
||||
if (_responsiveBreakpoints[vparts[vi]]) mediaPart = _responsiveBreakpoints[vparts[vi]];
|
||||
else if (_pseudoVariants[vparts[vi]]) pseudoPart = _pseudoVariants[vparts[vi]];
|
||||
}
|
||||
if (mediaPart) mediaRules.push([mediaPart, decls]);
|
||||
if (pseudoPart) pseudoRules.push([pseudoPart, decls]);
|
||||
if (!mediaPart && !pseudoPart) baseDecls.push(decls);
|
||||
}
|
||||
}
|
||||
|
||||
// Build hash input
|
||||
var hashInput = baseDecls.join(";");
|
||||
for (var mi = 0; mi < mediaRules.length; mi++) hashInput += "@" + mediaRules[mi][0] + "{" + mediaRules[mi][1] + "}";
|
||||
for (var pi = 0; pi < pseudoRules.length; pi++) hashInput += pseudoRules[pi][0] + "{" + pseudoRules[pi][1] + "}";
|
||||
for (var ki = 0; ki < kfNeeded.length; ki++) hashInput += kfNeeded[ki][1];
|
||||
|
||||
var cn = "sx-" + _hashStyle(hashInput);
|
||||
var sv = new StyleValue(cn, baseDecls.join(";"), mediaRules, pseudoRules, kfNeeded);
|
||||
_styleCache[key] = sv;
|
||||
|
||||
// Inject CSS rules into <style id="sx-css">
|
||||
_injectStyleValue(sv, atoms);
|
||||
return sv;
|
||||
}
|
||||
|
||||
function _injectStyleValue(sv, atoms) {
|
||||
if (_injectedStyles[sv.className]) return;
|
||||
_injectedStyles[sv.className] = true;
|
||||
|
||||
var cssTarget = document.getElementById("sx-css");
|
||||
if (!cssTarget) return;
|
||||
|
||||
var rules = [];
|
||||
if (sv.declarations) {
|
||||
// Check if any atoms need child selectors
|
||||
var hasChild = false;
|
||||
if (atoms) {
|
||||
for (var ai = 0; ai < atoms.length; ai++) {
|
||||
if (_isChildSelectorAtom(atoms[ai])) { hasChild = true; break; }
|
||||
}
|
||||
}
|
||||
if (hasChild) {
|
||||
rules.push("." + sv.className + ">:not(:first-child){" + sv.declarations + "}");
|
||||
} else {
|
||||
rules.push("." + sv.className + "{" + sv.declarations + "}");
|
||||
}
|
||||
}
|
||||
for (var pi = 0; pi < sv.pseudoRules.length; pi++) {
|
||||
var sel = sv.pseudoRules[pi][0], decls = sv.pseudoRules[pi][1];
|
||||
if (sel.indexOf("&") >= 0) {
|
||||
rules.push(sel.replace(/&/g, "." + sv.className) + "{" + decls + "}");
|
||||
} else {
|
||||
rules.push("." + sv.className + sel + "{" + decls + "}");
|
||||
}
|
||||
}
|
||||
for (var mi = 0; mi < sv.mediaRules.length; mi++) {
|
||||
rules.push("@media " + sv.mediaRules[mi][0] + "{." + sv.className + "{" + sv.mediaRules[mi][1] + "}}");
|
||||
}
|
||||
for (var ki = 0; ki < sv.keyframes.length; ki++) {
|
||||
rules.push(sv.keyframes[ki][1]);
|
||||
}
|
||||
cssTarget.textContent += rules.join("");
|
||||
}
|
||||
|
||||
function _mergeStyleValues(styles) {
|
||||
if (styles.length === 1) return styles[0];
|
||||
var allDecls = [], allMedia = [], allPseudo = [], allKf = [];
|
||||
var allAtoms = [];
|
||||
for (var i = 0; i < styles.length; i++) {
|
||||
var sv = styles[i];
|
||||
if (sv.declarations) allDecls.push(sv.declarations);
|
||||
allMedia = allMedia.concat(sv.mediaRules);
|
||||
allPseudo = allPseudo.concat(sv.pseudoRules);
|
||||
allKf = allKf.concat(sv.keyframes);
|
||||
}
|
||||
var hashInput = allDecls.join(";");
|
||||
for (var mi = 0; mi < allMedia.length; mi++) hashInput += "@" + allMedia[mi][0] + "{" + allMedia[mi][1] + "}";
|
||||
for (var pi = 0; pi < allPseudo.length; pi++) hashInput += allPseudo[pi][0] + "{" + allPseudo[pi][1] + "}";
|
||||
for (var ki = 0; ki < allKf.length; ki++) hashInput += allKf[ki][1];
|
||||
var cn = "sx-" + _hashStyle(hashInput);
|
||||
var merged = new StyleValue(cn, allDecls.join(";"), allMedia, allPseudo, allKf);
|
||||
_injectStyleValue(merged, allAtoms);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// css primitive: (css :flex :gap-4 :hover:bg-sky-200) → StyleValue
|
||||
PRIMITIVES["css"] = function () {
|
||||
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 _resolveStyle(atoms);
|
||||
};
|
||||
|
||||
// merge-styles: combine multiple StyleValues
|
||||
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];
|
||||
return _mergeStyleValues(valid);
|
||||
};
|
||||
|
||||
// --- Evaluator ---
|
||||
|
||||
/** Unwrap thunks by re-entering the evaluator until we get an actual value. */
|
||||
@@ -583,6 +815,37 @@
|
||||
return value;
|
||||
};
|
||||
|
||||
SPECIAL_FORMS["defstyle"] = function (expr, env) {
|
||||
var name = expr[1].name;
|
||||
var value = sxEval(expr[2], env);
|
||||
env[name] = value;
|
||||
return value;
|
||||
};
|
||||
|
||||
SPECIAL_FORMS["defkeyframes"] = function (expr, env) {
|
||||
var kfName = expr[1].name;
|
||||
var steps = [];
|
||||
for (var i = 2; i < expr.length; i++) {
|
||||
var step = expr[i];
|
||||
var selector = isSym(step[0]) ? step[0].name : String(step[0]);
|
||||
var body = sxEval(step[1], env);
|
||||
var decls = isStyleValue(body) ? body.declarations : String(body);
|
||||
steps.push(selector + "{" + decls + "}");
|
||||
}
|
||||
var kfRule = "@keyframes " + kfName + "{" + steps.join("") + "}";
|
||||
|
||||
// Register in keyframes dict for animate-{name} lookup
|
||||
_styleKeyframes[kfName] = kfRule;
|
||||
_styleCache = {}; // Clear cache so new keyframes are picked up
|
||||
|
||||
// Build a StyleValue for the animation
|
||||
var cn = "sx-" + _hashStyle(kfRule);
|
||||
var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]);
|
||||
_injectStyleValue(sv, []);
|
||||
env[kfName] = sv;
|
||||
return sv;
|
||||
};
|
||||
|
||||
SPECIAL_FORMS["defcomp"] = function (expr, env) {
|
||||
var nameSym = expr[1];
|
||||
var compName = nameSym.name.replace(/^~/, "");
|
||||
@@ -859,6 +1122,8 @@
|
||||
};
|
||||
|
||||
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defstyle"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defkeyframes"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defmacro"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defhandler"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
@@ -1044,6 +1309,7 @@
|
||||
? document.createElementNS(SVG_NS, tag)
|
||||
: document.createElement(tag);
|
||||
|
||||
var extraClass = null;
|
||||
var i = 0;
|
||||
while (i < args.length) {
|
||||
var arg = args[i];
|
||||
@@ -1052,6 +1318,11 @@
|
||||
var attrVal = sxEval(args[i + 1], env);
|
||||
i += 2;
|
||||
if (isNil(attrVal) || attrVal === false) continue;
|
||||
// :style StyleValue → convert to class
|
||||
if (attrName === "style" && isStyleValue(attrVal)) {
|
||||
extraClass = attrVal.className;
|
||||
continue;
|
||||
}
|
||||
if (BOOLEAN_ATTRS[attrName]) {
|
||||
if (attrVal) el.setAttribute(attrName, "");
|
||||
} else if (attrVal === true) {
|
||||
@@ -1068,6 +1339,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Merge StyleValue class into element's class attribute
|
||||
if (extraClass) {
|
||||
var existing = el.getAttribute("class");
|
||||
el.setAttribute("class", existing ? existing + " " + extraClass : extraClass);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -1361,7 +1638,7 @@
|
||||
},
|
||||
|
||||
// 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, StyleValue: StyleValue },
|
||||
_eval: sxEval,
|
||||
_expandMacro: expandMacro,
|
||||
_callLambda: function (fn, args, env) { return trampoline(callLambda(fn, args, env)); },
|
||||
@@ -2664,7 +2941,7 @@
|
||||
|
||||
// --- Auto-init in browser ---
|
||||
|
||||
Sx.VERSION = "2026-03-01c-cssx";
|
||||
Sx.VERSION = "2026-03-04-cssx2";
|
||||
|
||||
// CSS class tracking for on-demand CSS delivery
|
||||
var _sxCssHash = ""; // 8-char hex hash from server
|
||||
@@ -2707,10 +2984,79 @@
|
||||
document.cookie = "sx-comp-hash=;path=/;max-age=0;SameSite=Lax";
|
||||
}
|
||||
|
||||
// --- sx-styles-hash cookie helpers ---
|
||||
|
||||
function _setSxStylesCookie(hash) {
|
||||
document.cookie = "sx-styles-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";
|
||||
}
|
||||
|
||||
function _clearSxStylesCookie() {
|
||||
document.cookie = "sx-styles-hash=;path=/;max-age=0;SameSite=Lax";
|
||||
}
|
||||
|
||||
function _initStyleDict() {
|
||||
var scripts = document.querySelectorAll('script[type="text/sx-styles"]');
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = scripts[i];
|
||||
if (s._sxProcessed) continue;
|
||||
s._sxProcessed = true;
|
||||
|
||||
var text = s.textContent;
|
||||
var hash = s.getAttribute("data-hash");
|
||||
if (!hash) {
|
||||
if (text && text.trim()) {
|
||||
try { _loadStyleDict(JSON.parse(text)); } catch (e) { console.warn("[sx.js] style dict parse error", e); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var hasInline = text && text.trim();
|
||||
try {
|
||||
var cachedHash = localStorage.getItem("sx-styles-hash");
|
||||
if (cachedHash === hash) {
|
||||
if (hasInline) {
|
||||
localStorage.setItem("sx-styles-src", text);
|
||||
_loadStyleDict(JSON.parse(text));
|
||||
console.log("[sx.js] styles: downloaded (cookie stale)");
|
||||
} else {
|
||||
var cached = localStorage.getItem("sx-styles-src");
|
||||
if (cached) {
|
||||
_loadStyleDict(JSON.parse(cached));
|
||||
console.log("[sx.js] styles: cached (" + hash + ")");
|
||||
} else {
|
||||
_clearSxStylesCookie();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hasInline) {
|
||||
localStorage.setItem("sx-styles-hash", hash);
|
||||
localStorage.setItem("sx-styles-src", text);
|
||||
_loadStyleDict(JSON.parse(text));
|
||||
console.log("[sx.js] styles: downloaded (" + hash + ")");
|
||||
} else {
|
||||
localStorage.removeItem("sx-styles-hash");
|
||||
localStorage.removeItem("sx-styles-src");
|
||||
_clearSxStylesCookie();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (hasInline) {
|
||||
try { _loadStyleDict(JSON.parse(text)); } catch (e2) { console.warn("[sx.js] style dict parse error", e2); }
|
||||
}
|
||||
}
|
||||
_setSxStylesCookie(hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
var init = function () {
|
||||
console.log("[sx.js] v" + Sx.VERSION + " init");
|
||||
_initCssTracking();
|
||||
_initStyleDict();
|
||||
Sx.processScripts();
|
||||
Sx.hydrate();
|
||||
SxEngine.process();
|
||||
|
||||
Reference in New Issue
Block a user