Rebrand sexp → sx across web platform (173 files)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
Rename all sexp directories, files, identifiers, and references to sx. artdag/ excluded (separate media processing DSL). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,9 +57,9 @@ def _error_page(message: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _sexp_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
||||
def _sx_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
||||
"""Render an error page via s-expressions. Bypasses Jinja entirely."""
|
||||
from shared.sexp.page import render_page
|
||||
from shared.sx.page import render_page
|
||||
|
||||
return render_page(
|
||||
'(~error-page :title title :message message :image image :asset-url "/static")',
|
||||
@@ -147,24 +147,24 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
|
||||
pass
|
||||
|
||||
# Root header (site nav bar)
|
||||
from shared.sexp.helpers import (
|
||||
root_header_sexp, post_header_sexp,
|
||||
header_child_sexp, full_page_sexp, sexp_call,
|
||||
from shared.sx.helpers import (
|
||||
root_header_sx, post_header_sx,
|
||||
header_child_sx, full_page_sx, sx_call,
|
||||
)
|
||||
hdr = root_header_sexp(ctx)
|
||||
hdr = root_header_sx(ctx)
|
||||
|
||||
# Post breadcrumb if we resolved a post
|
||||
post = (post_data or {}).get("post") or ctx.get("post") or {}
|
||||
if post.get("slug"):
|
||||
ctx["post"] = post
|
||||
post_row = post_header_sexp(ctx)
|
||||
post_row = post_header_sx(ctx)
|
||||
if post_row:
|
||||
hdr = "(<> " + hdr + " " + header_child_sexp(post_row) + ")"
|
||||
hdr = "(<> " + hdr + " " + header_child_sx(post_row) + ")"
|
||||
|
||||
# Error content
|
||||
error_content = sexp_call("error-content", errnum=errnum, message=message, image=image)
|
||||
error_content = sx_call("error-content", errnum=errnum, message=message, image=image)
|
||||
|
||||
return full_page_sexp(ctx, header_rows=hdr, content=error_content)
|
||||
return full_page_sx(ctx, header_rows=hdr, content=error_content)
|
||||
except Exception:
|
||||
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
|
||||
return None
|
||||
@@ -202,7 +202,7 @@ def errors(app):
|
||||
)
|
||||
if html is None:
|
||||
try:
|
||||
html = _sexp_error_page(
|
||||
html = _sx_error_page(
|
||||
"404", "NOT FOUND",
|
||||
image="/static/errors/404.gif",
|
||||
)
|
||||
@@ -224,7 +224,7 @@ def errors(app):
|
||||
)
|
||||
else:
|
||||
try:
|
||||
html = _sexp_error_page(
|
||||
html = _sx_error_page(
|
||||
"403", "FORBIDDEN",
|
||||
image="/static/errors/403.gif",
|
||||
)
|
||||
@@ -244,7 +244,7 @@ def errors(app):
|
||||
messages = getattr(e, "messages", [str(e)])
|
||||
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
from shared.sx.jinja_bridge import render as render_comp
|
||||
items = "".join(
|
||||
render_comp("error-list-item", message=str(escape(m)))
|
||||
for m in messages if m
|
||||
@@ -266,7 +266,7 @@ def errors(app):
|
||||
# Extract service name from "Fragment account/auth-menu failed: ..."
|
||||
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
from shared.sx.jinja_bridge import render as render_comp
|
||||
return await make_response(
|
||||
render_comp("fragment-error", service=str(escape(service))),
|
||||
503,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Watch non-Python files and trigger Hypercorn reload.
|
||||
|
||||
Hypercorn --reload only watches .py files. This script watches .sexp,
|
||||
.sexpr, .js, and .css files and touches a sentinel .py file when they
|
||||
Hypercorn --reload only watches .py files. This script watches .sx,
|
||||
.sx, .js, and .css files and touches a sentinel .py file when they
|
||||
change, causing Hypercorn to restart.
|
||||
|
||||
Usage (from entrypoint.sh, before exec hypercorn):
|
||||
@@ -12,7 +12,7 @@ import os
|
||||
import time
|
||||
import sys
|
||||
|
||||
WATCH_EXTENSIONS = {".sexp", ".sexpr", ".js", ".css"}
|
||||
WATCH_EXTENSIONS = {".sx", ".sx", ".js", ".css"}
|
||||
SENTINEL = os.path.join(os.path.dirname(__file__), "_reload_sentinel.py")
|
||||
POLL_INTERVAL = 1.5 # seconds
|
||||
|
||||
@@ -33,7 +33,7 @@ def _collect_mtimes(roots):
|
||||
|
||||
|
||||
def main():
|
||||
# Watch /app/shared and /app/<service>/sexp plus static dirs
|
||||
# Watch /app/shared and /app/<service>/sx plus static dirs
|
||||
roots = []
|
||||
for entry in os.listdir("/app"):
|
||||
full = os.path.join("/app", entry)
|
||||
|
||||
@@ -18,9 +18,9 @@ from shared.infrastructure.urls import blog_url, market_url, cart_url, events_ur
|
||||
|
||||
|
||||
def _qs_filter_fn():
|
||||
"""Build a qs_filter(dict) wrapper for sexp components, or None.
|
||||
"""Build a qs_filter(dict) wrapper for sx components, or None.
|
||||
|
||||
Sexp components call ``qs_fn({"page": 2})``, ``qs_fn({"sort": "az"})``,
|
||||
Sx components call ``qs_fn({"page": 2})``, ``qs_fn({"sort": "az"})``,
|
||||
``qs_fn({"labels": ["organic", "local"]})``, etc.
|
||||
|
||||
Simple keys (page, sort, search, liked, clear_filters) are forwarded
|
||||
|
||||
@@ -28,9 +28,9 @@ from shared.browser.app.errors import errors
|
||||
|
||||
from .jinja_setup import setup_jinja
|
||||
from .user_loader import load_current_user
|
||||
from shared.sexp.jinja_bridge import setup_sexp_bridge
|
||||
from shared.sexp.components import load_shared_components
|
||||
from shared.sexp.relations import load_relation_registry
|
||||
from shared.sx.jinja_bridge import setup_sx_bridge
|
||||
from shared.sx.components import load_shared_components
|
||||
from shared.sx.relations import load_relation_registry
|
||||
|
||||
|
||||
# Async init of config (runs once at import)
|
||||
@@ -111,16 +111,16 @@ def create_base_app(
|
||||
register_db(app)
|
||||
register_redis(app)
|
||||
setup_jinja(app)
|
||||
setup_sexp_bridge(app)
|
||||
setup_sx_bridge(app)
|
||||
load_shared_components()
|
||||
load_relation_registry()
|
||||
|
||||
# Dev-mode: auto-reload sexp templates when files change on disk
|
||||
# Dev-mode: auto-reload sx templates when files change on disk
|
||||
if os.getenv("RELOAD") == "true":
|
||||
from shared.sexp.jinja_bridge import reload_if_changed
|
||||
from shared.sx.jinja_bridge import reload_if_changed
|
||||
|
||||
@app.before_request
|
||||
async def _sexp_hot_reload():
|
||||
async def _sx_hot_reload():
|
||||
reload_if_changed()
|
||||
errors(app)
|
||||
|
||||
|
||||
@@ -78,15 +78,15 @@ async def fetch_fragment(
|
||||
) -> str:
|
||||
"""Fetch a fragment from another app.
|
||||
|
||||
Returns an HTML string or a ``SexpExpr`` (when the provider responds
|
||||
with ``text/sexp``). When *required* is True (default), raises
|
||||
Returns an HTML string or a ``SxExpr`` (when the provider responds
|
||||
with ``text/sx``). When *required* is True (default), raises
|
||||
``FragmentError`` on network errors or non-200 responses.
|
||||
When *required* is False, returns ``""`` on failure.
|
||||
|
||||
Automatically returns ``""`` when called inside a fragment request
|
||||
to prevent circular dependencies between apps.
|
||||
"""
|
||||
from shared.sexp.parser import SexpExpr
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
if _is_fragment_request():
|
||||
return ""
|
||||
@@ -102,8 +102,8 @@ async def fetch_fragment(
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "text/sexp" in ct:
|
||||
return SexpExpr(resp.text)
|
||||
if "text/sx" in ct:
|
||||
return SxExpr(resp.text)
|
||||
return resp.text
|
||||
msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}"
|
||||
if required:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* sexp.js — S-expression parser, evaluator, and DOM renderer.
|
||||
* sx.js — S-expression parser, evaluator, and DOM renderer.
|
||||
*
|
||||
* Client-side counterpart to shared/sexp/ Python modules.
|
||||
* Client-side counterpart to shared/sx/ Python modules.
|
||||
* Parses s-expression text, evaluates it, and renders to DOM nodes.
|
||||
*
|
||||
* Usage:
|
||||
* Sexp.loadComponents('(defcomp ~card (&key title) (div :class "c" title))');
|
||||
* const node = Sexp.render('(~card :title "Hello")');
|
||||
* Sx.loadComponents('(defcomp ~card (&key title) (div :class "c" title))');
|
||||
* const node = Sx.render('(~card :title "Hello")');
|
||||
* document.body.appendChild(node);
|
||||
*/
|
||||
;(function (global) {
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
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 sexp but we match Python semantics where
|
||||
// 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 isSexpTruthy(x) { return x !== false && !isNil(x); }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
|
||||
function Symbol(name) { this.name = name; }
|
||||
Symbol.prototype.toString = function () { return this.name; };
|
||||
@@ -243,7 +243,7 @@
|
||||
PRIMITIVES["pow"] = Math.pow;
|
||||
|
||||
// Comparison
|
||||
PRIMITIVES["="] = function (a, b) { return a == b; }; // loose, matches Python sexp
|
||||
PRIMITIVES["="] = function (a, b) { return a == b; }; // loose, matches Python sx
|
||||
PRIMITIVES["!="] = function (a, b) { return a != b; };
|
||||
PRIMITIVES["<"] = function (a, b) { return a < b; };
|
||||
PRIMITIVES[">"] = function (a, b) { return a > b; };
|
||||
@@ -251,7 +251,7 @@
|
||||
PRIMITIVES[">="] = function (a, b) { return a >= b; };
|
||||
|
||||
// Logic
|
||||
PRIMITIVES["not"] = function (x) { return !isSexpTruthy(x); };
|
||||
PRIMITIVES["not"] = function (x) { return !isSxTruthy(x); };
|
||||
|
||||
// String
|
||||
PRIMITIVES["str"] = function () {
|
||||
@@ -326,7 +326,7 @@
|
||||
// Evaluator
|
||||
// =========================================================================
|
||||
|
||||
function sexpEval(expr, env) {
|
||||
function sxEval(expr, env) {
|
||||
// Literals
|
||||
if (typeof expr === "number" || typeof expr === "string" || typeof expr === "boolean") return expr;
|
||||
if (isNil(expr)) return NIL;
|
||||
@@ -348,7 +348,7 @@
|
||||
// Dict literal
|
||||
if (expr && typeof expr === "object" && !Array.isArray(expr) && !expr._sym && !expr._kw && !expr._raw) {
|
||||
var d = {};
|
||||
for (var dk in expr) d[dk] = sexpEval(expr[dk], env);
|
||||
for (var dk in expr) d[dk] = sxEval(expr[dk], env);
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
|
||||
// Non-callable head → data list
|
||||
if (!isSym(head) && !isLambda(head) && !Array.isArray(head)) {
|
||||
return expr.map(function (x) { return sexpEval(x, env); });
|
||||
return expr.map(function (x) { return sxEval(x, env); });
|
||||
}
|
||||
|
||||
// Special forms
|
||||
@@ -372,9 +372,9 @@
|
||||
}
|
||||
|
||||
// Function call
|
||||
var fn = sexpEval(head, env);
|
||||
var fn = sxEval(head, env);
|
||||
var args = [];
|
||||
for (var ai = 1; ai < expr.length; ai++) args.push(sexpEval(expr[ai], env));
|
||||
for (var ai = 1; ai < expr.length; ai++) args.push(sxEval(expr[ai], env));
|
||||
|
||||
if (typeof fn === "function") return fn.apply(null, args);
|
||||
if (isLambda(fn)) return callLambda(fn, args, env);
|
||||
@@ -388,7 +388,7 @@
|
||||
}
|
||||
var local = merge({}, fn.closure, callerEnv);
|
||||
for (var i = 0; i < fn.params.length; i++) local[fn.params[i]] = args[i];
|
||||
return sexpEval(fn.body, local);
|
||||
return sxEval(fn.body, local);
|
||||
}
|
||||
|
||||
function callComponent(comp, rawArgs, env) {
|
||||
@@ -396,10 +396,10 @@
|
||||
var i = 0;
|
||||
while (i < rawArgs.length) {
|
||||
if (isKw(rawArgs[i]) && i + 1 < rawArgs.length) {
|
||||
kwargs[rawArgs[i].name] = sexpEval(rawArgs[i + 1], env);
|
||||
kwargs[rawArgs[i].name] = sxEval(rawArgs[i + 1], env);
|
||||
i += 2;
|
||||
} else {
|
||||
children.push(sexpEval(rawArgs[i], env));
|
||||
children.push(sxEval(rawArgs[i], env));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@@ -409,7 +409,7 @@
|
||||
local[p] = (p in kwargs) ? kwargs[p] : NIL;
|
||||
}
|
||||
if (comp.hasChildren) local["children"] = children;
|
||||
return sexpEval(comp.body, local);
|
||||
return sxEval(comp.body, local);
|
||||
}
|
||||
|
||||
// --- Special forms -------------------------------------------------------
|
||||
@@ -417,15 +417,15 @@
|
||||
var SPECIAL_FORMS = {};
|
||||
|
||||
SPECIAL_FORMS["if"] = function (expr, env) {
|
||||
var cond = sexpEval(expr[1], env);
|
||||
if (isSexpTruthy(cond)) return sexpEval(expr[2], env);
|
||||
return expr.length > 3 ? sexpEval(expr[3], env) : NIL;
|
||||
var cond = sxEval(expr[1], env);
|
||||
if (isSxTruthy(cond)) return sxEval(expr[2], env);
|
||||
return expr.length > 3 ? sxEval(expr[3], env) : NIL;
|
||||
};
|
||||
|
||||
SPECIAL_FORMS["when"] = function (expr, env) {
|
||||
if (!isSexpTruthy(sexpEval(expr[1], env))) return NIL;
|
||||
if (!isSxTruthy(sxEval(expr[1], env))) return NIL;
|
||||
var result = NIL;
|
||||
for (var i = 2; i < expr.length; i++) result = sexpEval(expr[i], env);
|
||||
for (var i = 2; i < expr.length; i++) result = sxEval(expr[i], env);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -437,28 +437,28 @@
|
||||
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 sexpEval(clauses[i][1], env);
|
||||
if (isSexpTruthy(sexpEval(test, env))) return sexpEval(clauses[i][1], env);
|
||||
(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 sexpEval(clauses[j + 1], env);
|
||||
if (isSexpTruthy(sexpEval(t, env))) return sexpEval(clauses[j + 1], env);
|
||||
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) {
|
||||
var val = sexpEval(expr[1], env);
|
||||
var val = sxEval(expr[1], env);
|
||||
for (var i = 2; i < expr.length - 1; i += 2) {
|
||||
var t = expr[i];
|
||||
if ((isKw(t) && t.name === "else") || (isSym(t) && (t.name === ":else" || t.name === "else")))
|
||||
return sexpEval(expr[i + 1], env);
|
||||
if (val == sexpEval(t, env)) return sexpEval(expr[i + 1], env);
|
||||
return sxEval(expr[i + 1], env);
|
||||
if (val == sxEval(t, env)) return sxEval(expr[i + 1], env);
|
||||
}
|
||||
return NIL;
|
||||
};
|
||||
@@ -466,8 +466,8 @@
|
||||
SPECIAL_FORMS["and"] = function (expr, env) {
|
||||
var result = true;
|
||||
for (var i = 1; i < expr.length; i++) {
|
||||
result = sexpEval(expr[i], env);
|
||||
if (!isSexpTruthy(result)) return result;
|
||||
result = sxEval(expr[i], env);
|
||||
if (!isSxTruthy(result)) return result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -475,8 +475,8 @@
|
||||
SPECIAL_FORMS["or"] = function (expr, env) {
|
||||
var result = false;
|
||||
for (var i = 1; i < expr.length; i++) {
|
||||
result = sexpEval(expr[i], env);
|
||||
if (isSexpTruthy(result)) return result;
|
||||
result = sxEval(expr[i], env);
|
||||
if (isSxTruthy(result)) return result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -488,18 +488,18 @@
|
||||
// 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] = sexpEval(bindings[i][1], local);
|
||||
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] = sexpEval(bindings[j + 1], local);
|
||||
local[vn] = sxEval(bindings[j + 1], local);
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = NIL;
|
||||
for (var k = 2; k < expr.length; k++) result = sexpEval(expr[k], local);
|
||||
for (var k = 2; k < expr.length; k++) result = sxEval(expr[k], local);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -514,7 +514,7 @@
|
||||
|
||||
SPECIAL_FORMS["define"] = function (expr, env) {
|
||||
var name = expr[1].name;
|
||||
var value = sexpEval(expr[2], env);
|
||||
var value = sxEval(expr[2], env);
|
||||
if (isLambda(value) && !value.name) value.name = name;
|
||||
env[name] = value;
|
||||
return value;
|
||||
@@ -541,29 +541,29 @@
|
||||
|
||||
SPECIAL_FORMS["begin"] = SPECIAL_FORMS["do"] = function (expr, env) {
|
||||
var result = NIL;
|
||||
for (var i = 1; i < expr.length; i++) result = sexpEval(expr[i], env);
|
||||
for (var i = 1; i < expr.length; i++) result = sxEval(expr[i], env);
|
||||
return result;
|
||||
};
|
||||
|
||||
SPECIAL_FORMS["quote"] = function (expr) { return expr[1]; };
|
||||
|
||||
SPECIAL_FORMS["set!"] = function (expr, env) {
|
||||
var v = sexpEval(expr[2], env);
|
||||
var v = sxEval(expr[2], env);
|
||||
env[expr[1].name] = v;
|
||||
return v;
|
||||
};
|
||||
|
||||
SPECIAL_FORMS["->"] = function (expr, env) {
|
||||
var result = sexpEval(expr[1], env);
|
||||
var result = sxEval(expr[1], env);
|
||||
for (var i = 2; i < expr.length; i++) {
|
||||
var form = expr[i];
|
||||
var fn, args;
|
||||
if (Array.isArray(form)) {
|
||||
fn = sexpEval(form[0], env);
|
||||
fn = sxEval(form[0], env);
|
||||
args = [result];
|
||||
for (var j = 1; j < form.length; j++) args.push(sexpEval(form[j], env));
|
||||
for (var j = 1; j < form.length; j++) args.push(sxEval(form[j], env));
|
||||
} else {
|
||||
fn = sexpEval(form, env);
|
||||
fn = sxEval(form, env);
|
||||
args = [result];
|
||||
}
|
||||
if (typeof fn === "function") result = fn.apply(null, args);
|
||||
@@ -578,48 +578,48 @@
|
||||
var HO_FORMS = {};
|
||||
|
||||
HO_FORMS["map"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
return coll.map(function (item) { return isLambda(fn) ? callLambda(fn, [item], env) : fn(item); });
|
||||
};
|
||||
|
||||
HO_FORMS["map-indexed"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
return coll.map(function (item, i) { return isLambda(fn) ? callLambda(fn, [i, item], env) : fn(i, item); });
|
||||
};
|
||||
|
||||
HO_FORMS["filter"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
return coll.filter(function (item) {
|
||||
var r = isLambda(fn) ? callLambda(fn, [item], env) : fn(item);
|
||||
return isSexpTruthy(r);
|
||||
return isSxTruthy(r);
|
||||
});
|
||||
};
|
||||
|
||||
HO_FORMS["reduce"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), acc = sexpEval(expr[2], env), coll = sexpEval(expr[3], env);
|
||||
var fn = sxEval(expr[1], env), acc = sxEval(expr[2], env), coll = sxEval(expr[3], env);
|
||||
for (var i = 0; i < coll.length; i++) acc = isLambda(fn) ? callLambda(fn, [acc, coll[i]], env) : fn(acc, coll[i]);
|
||||
return acc;
|
||||
};
|
||||
|
||||
HO_FORMS["some"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
for (var i = 0; i < coll.length; i++) {
|
||||
var r = isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
|
||||
if (isSexpTruthy(r)) return r;
|
||||
if (isSxTruthy(r)) return r;
|
||||
}
|
||||
return NIL;
|
||||
};
|
||||
|
||||
HO_FORMS["every?"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
for (var i = 0; i < coll.length; i++) {
|
||||
if (!isSexpTruthy(isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]))) return false;
|
||||
if (!isSxTruthy(isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]))) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
HO_FORMS["for-each"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
for (var i = 0; i < coll.length; i++) isLambda(fn) ? callLambda(fn, [coll[i]], env) : fn(coll[i]);
|
||||
return NIL;
|
||||
};
|
||||
@@ -686,7 +686,7 @@
|
||||
if (typeof expr === "number") return document.createTextNode(String(expr));
|
||||
|
||||
// Symbol → evaluate then render
|
||||
if (isSym(expr)) return renderDOM(sexpEval(expr, env), env);
|
||||
if (isSym(expr)) return renderDOM(sxEval(expr, env), env);
|
||||
|
||||
// Keyword → text
|
||||
if (isKw(expr)) return document.createTextNode(expr.name);
|
||||
@@ -707,13 +707,13 @@
|
||||
var RENDER_FORMS = {};
|
||||
|
||||
RENDER_FORMS["if"] = function (expr, env) {
|
||||
var cond = sexpEval(expr[1], env);
|
||||
if (isSexpTruthy(cond)) return renderDOM(expr[2], env);
|
||||
var cond = sxEval(expr[1], env);
|
||||
if (isSxTruthy(cond)) return renderDOM(expr[2], env);
|
||||
return expr.length > 3 ? renderDOM(expr[3], env) : document.createDocumentFragment();
|
||||
};
|
||||
|
||||
RENDER_FORMS["when"] = function (expr, env) {
|
||||
if (!isSexpTruthy(sexpEval(expr[1], env))) return document.createDocumentFragment();
|
||||
if (!isSxTruthy(sxEval(expr[1], env))) return document.createDocumentFragment();
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 2; i < expr.length; i++) frag.appendChild(renderDOM(expr[i], env));
|
||||
return frag;
|
||||
@@ -727,14 +727,14 @@
|
||||
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 (isSexpTruthy(sexpEval(test, env))) 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 (isSexpTruthy(sexpEval(t, env))) return renderDOM(clauses[j + 1], env);
|
||||
if (isSxTruthy(sxEval(t, env))) return renderDOM(clauses[j + 1], env);
|
||||
}
|
||||
}
|
||||
return document.createDocumentFragment();
|
||||
@@ -745,11 +745,11 @@
|
||||
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]] = sexpEval(bindings[i][1], local);
|
||||
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]] = sexpEval(bindings[j + 1], local);
|
||||
local[isSym(bindings[j]) ? bindings[j].name : bindings[j]] = sxEval(bindings[j + 1], local);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -764,11 +764,11 @@
|
||||
return frag;
|
||||
};
|
||||
|
||||
RENDER_FORMS["define"] = function (expr, env) { sexpEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defcomp"] = function (expr, env) { sexpEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
|
||||
|
||||
RENDER_FORMS["map"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < coll.length; i++) {
|
||||
var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
|
||||
@@ -778,7 +778,7 @@
|
||||
};
|
||||
|
||||
RENDER_FORMS["map-indexed"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < coll.length; i++) {
|
||||
var val = isLambda(fn) ? renderLambdaDOM(fn, [i, coll[i]], env) : renderDOM(fn(i, coll[i]), env);
|
||||
@@ -788,12 +788,12 @@
|
||||
};
|
||||
|
||||
RENDER_FORMS["filter"] = function (expr, env) {
|
||||
var result = sexpEval(expr, env);
|
||||
var result = sxEval(expr, env);
|
||||
return renderDOM(result, env);
|
||||
};
|
||||
|
||||
RENDER_FORMS["for-each"] = function (expr, env) {
|
||||
var fn = sexpEval(expr[1], env), coll = sexpEval(expr[2], env);
|
||||
var fn = sxEval(expr[1], env), coll = sxEval(expr[2], env);
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0; i < coll.length; i++) {
|
||||
var val = isLambda(fn) ? renderLambdaDOM(fn, [coll[i]], env) : renderDOM(fn(coll[i]), env);
|
||||
@@ -819,7 +819,7 @@
|
||||
var v = args[i + 1];
|
||||
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
|
||||
typeof v === "boolean" || isNil(v) || isKw(v))
|
||||
? v : (isSym(v) ? sexpEval(v, env) : v);
|
||||
? v : (isSym(v) ? sxEval(v, env) : v);
|
||||
i += 2;
|
||||
} else {
|
||||
children.push(args[i]);
|
||||
@@ -850,7 +850,7 @@
|
||||
if (name === "raw!") {
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var ri = 1; ri < expr.length; ri++) {
|
||||
var val = sexpEval(expr[ri], env);
|
||||
var val = sxEval(expr[ri], env);
|
||||
if (typeof val === "string") {
|
||||
var tpl = document.createElement("template");
|
||||
tpl.innerHTML = val;
|
||||
@@ -883,7 +883,7 @@
|
||||
var comp = env[name];
|
||||
if (isComponent(comp)) return renderComponentDOM(comp, expr.slice(1), env);
|
||||
// Unknown component — render a visible warning, don't crash
|
||||
console.warn("sexp.js: unknown component " + name);
|
||||
console.warn("sx.js: unknown component " + name);
|
||||
var warn = document.createElement("div");
|
||||
warn.setAttribute("style",
|
||||
"background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;" +
|
||||
@@ -893,11 +893,11 @@
|
||||
}
|
||||
|
||||
// Fallback: evaluate then render
|
||||
return renderDOM(sexpEval(expr, env), env);
|
||||
return renderDOM(sxEval(expr, env), env);
|
||||
}
|
||||
|
||||
// Lambda/list head → evaluate
|
||||
if (isLambda(head) || Array.isArray(head)) return renderDOM(sexpEval(expr, env), env);
|
||||
if (isLambda(head) || Array.isArray(head)) return renderDOM(sxEval(expr, env), env);
|
||||
|
||||
// Data list
|
||||
var dl = document.createDocumentFragment();
|
||||
@@ -915,7 +915,7 @@
|
||||
var arg = args[i];
|
||||
if (isKw(arg) && i + 1 < args.length) {
|
||||
var attrName = arg.name;
|
||||
var attrVal = sexpEval(args[i + 1], env);
|
||||
var attrVal = sxEval(args[i + 1], env);
|
||||
i += 2;
|
||||
if (isNil(attrVal) || attrVal === false) continue;
|
||||
if (BOOLEAN_ATTRS[attrName]) {
|
||||
@@ -949,7 +949,7 @@
|
||||
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(sexpEval(expr, env), env);
|
||||
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 "";
|
||||
@@ -968,7 +968,7 @@
|
||||
if (name === "raw!") {
|
||||
var ps = [];
|
||||
for (var ri = 1; ri < expr.length; ri++) {
|
||||
var v = sexpEval(expr[ri], env);
|
||||
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));
|
||||
@@ -981,12 +981,12 @@
|
||||
return fs.join("");
|
||||
}
|
||||
if (name === "if") {
|
||||
return isSexpTruthy(sexpEval(expr[1], env))
|
||||
return isSxTruthy(sxEval(expr[1], env))
|
||||
? renderStr(expr[2], env)
|
||||
: (expr.length > 3 ? renderStr(expr[3], env) : "");
|
||||
}
|
||||
if (name === "when") {
|
||||
if (!isSexpTruthy(sexpEval(expr[1], env))) return "";
|
||||
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("");
|
||||
@@ -996,11 +996,11 @@
|
||||
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]] = sexpEval(bindings[li][1], local);
|
||||
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]] = sexpEval(bindings[lj + 1], local);
|
||||
local[isSym(bindings[lj]) ? bindings[lj].name : bindings[lj]] = sxEval(bindings[lj + 1], local);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1013,11 +1013,11 @@
|
||||
for (var bi = 1; bi < expr.length; bi++) bs.push(renderStr(expr[bi], env));
|
||||
return bs.join("");
|
||||
}
|
||||
if (name === "define" || name === "defcomp") { sexpEval(expr, env); return ""; }
|
||||
if (name === "define" || name === "defcomp") { sxEval(expr, env); return ""; }
|
||||
|
||||
// Higher-order forms — render-aware (lambda bodies may contain HTML/components)
|
||||
if (name === "map") {
|
||||
var mapFn = sexpEval(expr[1], env), mapColl = sexpEval(expr[2], env);
|
||||
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++) {
|
||||
@@ -1027,7 +1027,7 @@
|
||||
return mapParts.join("");
|
||||
}
|
||||
if (name === "map-indexed") {
|
||||
var mixFn = sexpEval(expr[1], env), mixColl = sexpEval(expr[2], env);
|
||||
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++) {
|
||||
@@ -1037,12 +1037,12 @@
|
||||
return mixParts.join("");
|
||||
}
|
||||
if (name === "filter") {
|
||||
var filtFn = sexpEval(expr[1], env), filtColl = sexpEval(expr[2], env);
|
||||
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 (isSexpTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
|
||||
if (isSxTruthy(keep)) filtParts.push(renderStr(filtColl[fli], env));
|
||||
}
|
||||
return filtParts.join("");
|
||||
}
|
||||
@@ -1053,13 +1053,13 @@
|
||||
var comp = env[name];
|
||||
if (isComponent(comp)) return renderStrComponent(comp, expr.slice(1), env);
|
||||
// Unknown component — return visible warning
|
||||
console.warn("sexp.js: unknown component " + name);
|
||||
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(sexpEval(expr, env), env);
|
||||
return renderStr(sxEval(expr, env), env);
|
||||
}
|
||||
|
||||
function renderStrElement(tag, args, env) {
|
||||
@@ -1067,7 +1067,7 @@
|
||||
var i = 0;
|
||||
while (i < args.length) {
|
||||
if (isKw(args[i]) && i + 1 < args.length) {
|
||||
var aname = args[i].name, aval = sexpEval(args[i + 1], env);
|
||||
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); }
|
||||
@@ -1099,7 +1099,7 @@
|
||||
var v = args[i + 1];
|
||||
kwargs[args[i].name] = (typeof v === "string" || typeof v === "number" ||
|
||||
typeof v === "boolean" || isNil(v) || isKw(v))
|
||||
? v : (isSym(v) ? sexpEval(v, env) : v);
|
||||
? v : (isSym(v) ? sxEval(v, env) : v);
|
||||
i += 2;
|
||||
} else { children.push(args[i]); i++; }
|
||||
}
|
||||
@@ -1134,7 +1134,7 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Convert snake_case kwargs to kebab-case for sexp conventions. */
|
||||
/** Convert snake_case kwargs to kebab-case for sx conventions. */
|
||||
function toKebab(s) { return s.replace(/_/g, "-"); }
|
||||
|
||||
// =========================================================================
|
||||
@@ -1187,7 +1187,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
var Sexp = {
|
||||
var Sx = {
|
||||
// Types
|
||||
NIL: NIL,
|
||||
Symbol: Symbol,
|
||||
@@ -1198,7 +1198,7 @@
|
||||
parseAll: parseAll,
|
||||
|
||||
// Evaluator
|
||||
eval: function (expr, env) { return sexpEval(expr, env || _componentEnv); },
|
||||
eval: function (expr, env) { return sxEval(expr, env || _componentEnv); },
|
||||
|
||||
// DOM Renderer
|
||||
render: function (exprOrText, extraEnv) {
|
||||
@@ -1216,7 +1216,7 @@
|
||||
|
||||
/**
|
||||
* Render a named component with keyword args (Python-style API).
|
||||
* Sexp.renderComponent("card", {title: "Hi"})
|
||||
* Sx.renderComponent("card", {title: "Hi"})
|
||||
*/
|
||||
renderComponent: function (name, kwargs, extraEnv) {
|
||||
var fullName = name.charAt(0) === "~" ? name : "~" + name;
|
||||
@@ -1237,52 +1237,52 @@
|
||||
// Component management
|
||||
loadComponents: function (text) {
|
||||
var exprs = parseAll(text);
|
||||
for (var i = 0; i < exprs.length; i++) sexpEval(exprs[i], _componentEnv);
|
||||
for (var i = 0; i < exprs.length; i++) sxEval(exprs[i], _componentEnv);
|
||||
},
|
||||
|
||||
getEnv: function () { return _componentEnv; },
|
||||
|
||||
// Utility
|
||||
isTruthy: isSexpTruthy,
|
||||
isTruthy: isSxTruthy,
|
||||
isNil: isNil,
|
||||
|
||||
/**
|
||||
* Mount a sexp expression into a DOM element, replacing its contents.
|
||||
* Sexp.mount(el, '(~card :title "Hi")')
|
||||
* Sexp.mount("#target", '(~card :title "Hi")')
|
||||
* Sexp.mount(el, '(~card :title name)', {name: "Jo"})
|
||||
* Mount a sx expression into a DOM element, replacing its contents.
|
||||
* Sx.mount(el, '(~card :title "Hi")')
|
||||
* Sx.mount("#target", '(~card :title "Hi")')
|
||||
* Sx.mount(el, '(~card :title name)', {name: "Jo"})
|
||||
*/
|
||||
mount: function (target, exprOrText, extraEnv) {
|
||||
var el = typeof target === "string" ? document.querySelector(target) : target;
|
||||
if (!el) return;
|
||||
var node = Sexp.render(exprOrText, extraEnv);
|
||||
var node = Sx.render(exprOrText, extraEnv);
|
||||
el.textContent = "";
|
||||
el.appendChild(node);
|
||||
// Auto-hoist head elements (meta, title, link, script[ld+json]) to <head>
|
||||
_hoistHeadElements(el);
|
||||
// Process sx- attributes and hydrate the newly mounted content
|
||||
if (typeof SxEngine !== "undefined") SxEngine.process(el);
|
||||
Sexp.hydrate(el);
|
||||
Sx.hydrate(el);
|
||||
},
|
||||
|
||||
/**
|
||||
* Process all <script type="text/sexp"> tags in the document.
|
||||
* Process all <script type="text/sx"> tags in the document.
|
||||
* Tags with data-components load component definitions.
|
||||
* Tags with data-mount="<selector>" render into that element.
|
||||
*/
|
||||
processScripts: function (root) {
|
||||
var scripts = (root || document).querySelectorAll('script[type="text/sexp"]');
|
||||
var scripts = (root || document).querySelectorAll('script[type="text/sx"]');
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = scripts[i];
|
||||
if (s._sexpProcessed) continue;
|
||||
s._sexpProcessed = true;
|
||||
if (s._sxProcessed) continue;
|
||||
s._sxProcessed = true;
|
||||
|
||||
var text = s.textContent;
|
||||
if (!text || !text.trim()) continue;
|
||||
|
||||
// data-components: load as component definitions
|
||||
if (s.hasAttribute("data-components")) {
|
||||
Sexp.loadComponents(text);
|
||||
Sx.loadComponents(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1290,32 +1290,32 @@
|
||||
var mountSel = s.getAttribute("data-mount");
|
||||
if (mountSel) {
|
||||
var target = document.querySelector(mountSel);
|
||||
if (target) Sexp.mount(target, text);
|
||||
if (target) Sx.mount(target, text);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: load as components
|
||||
Sexp.loadComponents(text);
|
||||
Sx.loadComponents(text);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind client-side sexp rendering to elements with data-sexp-* attrs.
|
||||
* Bind client-side sx rendering to elements with data-sx-* attrs.
|
||||
*
|
||||
* Pattern:
|
||||
* <div data-sexp="(~card :title title)" data-sexp-env='{"title":"Hi"}'>
|
||||
* <div data-sx="(~card :title title)" data-sx-env='{"title":"Hi"}'>
|
||||
* <!-- server-rendered HTML (hydration target) -->
|
||||
* </div>
|
||||
*
|
||||
* Call Sexp.update(el, {title: "New"}) to re-render with new data.
|
||||
* Call Sx.update(el, {title: "New"}) to re-render with new data.
|
||||
*/
|
||||
update: function (target, newEnv) {
|
||||
var el = typeof target === "string" ? document.querySelector(target) : target;
|
||||
if (!el) return;
|
||||
var source = el.getAttribute("data-sexp");
|
||||
var source = el.getAttribute("data-sx");
|
||||
if (!source) return;
|
||||
var baseEnv = {};
|
||||
var envAttr = el.getAttribute("data-sexp-env");
|
||||
var envAttr = el.getAttribute("data-sx-env");
|
||||
if (envAttr) {
|
||||
try { baseEnv = JSON.parse(envAttr); } catch (e) { /* ignore */ }
|
||||
}
|
||||
@@ -1325,31 +1325,31 @@
|
||||
el.appendChild(node);
|
||||
if (newEnv) {
|
||||
merge(baseEnv, newEnv);
|
||||
el.setAttribute("data-sexp-env", JSON.stringify(baseEnv));
|
||||
el.setAttribute("data-sx-env", JSON.stringify(baseEnv));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Find all [data-sexp] elements within root and render them.
|
||||
* Useful after HTMX swaps bring in new sexp-enabled elements.
|
||||
* Find all [data-sx] elements within root and render them.
|
||||
* Useful after HTMX swaps bring in new sx-enabled elements.
|
||||
*/
|
||||
hydrate: function (root) {
|
||||
var els = (root || document).querySelectorAll("[data-sexp]");
|
||||
var els = (root || document).querySelectorAll("[data-sx]");
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
if (els[i]._sexpHydrated) continue;
|
||||
els[i]._sexpHydrated = true;
|
||||
Sexp.update(els[i]);
|
||||
if (els[i]._sxHydrated) continue;
|
||||
els[i]._sxHydrated = true;
|
||||
Sx.update(els[i]);
|
||||
}
|
||||
},
|
||||
|
||||
// For testing
|
||||
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
|
||||
_eval: sexpEval,
|
||||
_eval: sxEval,
|
||||
_renderStr: renderStr,
|
||||
_renderDOM: renderDOM,
|
||||
};
|
||||
|
||||
global.Sexp = Sexp;
|
||||
global.Sx = Sx;
|
||||
|
||||
// =========================================================================
|
||||
// SxEngine — native fetch/swap/history engine (replaces HTMX)
|
||||
@@ -1573,12 +1573,12 @@
|
||||
return resp.text().then(function (text) {
|
||||
dispatch(el, "sx:afterRequest", { response: resp });
|
||||
|
||||
// Check for text/sexp content type
|
||||
// Check for text/sx content type
|
||||
var ct = resp.headers.get("Content-Type") || "";
|
||||
if (ct.indexOf("text/sexp") >= 0) {
|
||||
try { text = Sexp.renderToString(text); }
|
||||
if (ct.indexOf("text/sx") >= 0) {
|
||||
try { text = Sx.renderToString(text); }
|
||||
catch (err) {
|
||||
console.error("sexp.js render error:", err);
|
||||
console.error("sx.js render error:", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1594,8 +1594,8 @@
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
|
||||
// Process any sexp script blocks in the response (e.g. cross-domain component defs)
|
||||
Sexp.processScripts(doc);
|
||||
// Process any sx script blocks in the response (e.g. cross-domain component defs)
|
||||
Sx.processScripts(doc);
|
||||
|
||||
// OOB processing: extract elements with sx-swap-oob
|
||||
var oobs = doc.querySelectorAll("[sx-swap-oob]");
|
||||
@@ -1679,8 +1679,8 @@
|
||||
tgt.insertAdjacentHTML("afterend", html);
|
||||
parent.removeChild(tgt);
|
||||
// Process parent to catch all newly inserted siblings
|
||||
Sexp.processScripts(parent);
|
||||
Sexp.hydrate(parent);
|
||||
Sx.processScripts(parent);
|
||||
Sx.hydrate(parent);
|
||||
SxEngine.process(parent);
|
||||
return; // early return — afterSwap handling done inline
|
||||
case "afterend":
|
||||
@@ -1701,8 +1701,8 @@
|
||||
default:
|
||||
target.innerHTML = html;
|
||||
}
|
||||
Sexp.processScripts(target);
|
||||
Sexp.hydrate(target);
|
||||
Sx.processScripts(target);
|
||||
Sx.hydrate(target);
|
||||
SxEngine.process(target);
|
||||
}
|
||||
|
||||
@@ -1863,8 +1863,8 @@
|
||||
var main = document.getElementById("main-panel");
|
||||
if (main) {
|
||||
main.innerHTML = _historyCache[url];
|
||||
Sexp.processScripts(main);
|
||||
Sexp.hydrate(main);
|
||||
Sx.processScripts(main);
|
||||
Sx.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
return;
|
||||
@@ -1885,9 +1885,9 @@
|
||||
return resp.text();
|
||||
}).then(function (text) {
|
||||
var ct = "";
|
||||
// Response content-type is lost here, check for sexp
|
||||
// Response content-type is lost here, check for sx
|
||||
if (text.charAt(0) === "(") {
|
||||
try { text = Sexp.renderToString(text); } catch (e) { /* not sexp */ }
|
||||
try { text = Sx.renderToString(text); } catch (e) { /* not sx */ }
|
||||
}
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(text, "text/html");
|
||||
@@ -1895,8 +1895,8 @@
|
||||
var main = document.getElementById("main-panel");
|
||||
if (main && newMain) {
|
||||
main.innerHTML = newMain.innerHTML;
|
||||
Sexp.processScripts(main);
|
||||
Sexp.hydrate(main);
|
||||
Sx.processScripts(main);
|
||||
Sx.hydrate(main);
|
||||
SxEngine.process(main);
|
||||
dispatch(document.body, "sx:afterSettle", { target: main });
|
||||
}
|
||||
@@ -1975,13 +1975,13 @@
|
||||
// Auto-init in browser
|
||||
// =========================================================================
|
||||
|
||||
Sexp.VERSION = "2026-03-01a";
|
||||
Sx.VERSION = "2026-03-01a";
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
var init = function () {
|
||||
console.log("[sexp.js] v" + Sexp.VERSION + " init");
|
||||
Sexp.processScripts();
|
||||
Sexp.hydrate();
|
||||
console.log("[sx.js] v" + Sx.VERSION + " init");
|
||||
Sx.processScripts();
|
||||
Sx.hydrate();
|
||||
SxEngine.process();
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
@@ -3,11 +3,11 @@ S-expression language core.
|
||||
|
||||
Parse, evaluate, and serialize s-expressions. This package provides the
|
||||
foundation for the composable fragment architecture described in
|
||||
``docs/sexp-architecture-plan.md``.
|
||||
``docs/sx-architecture-plan.md``.
|
||||
|
||||
Quick start::
|
||||
|
||||
from shared.sexp import parse, evaluate, serialize, Symbol, Keyword
|
||||
from shared.sx import parse, evaluate, serialize, Symbol, Keyword
|
||||
|
||||
expr = parse('(let ((x 10)) (+ x 1))')
|
||||
result = evaluate(expr) # → 11
|
||||
@@ -2,18 +2,18 @@
|
||||
Shared s-expression component definitions.
|
||||
|
||||
Loaded at app startup via ``load_shared_components()``. Each component
|
||||
is defined in an external ``.sexp`` file under ``templates/``.
|
||||
is defined in an external ``.sx`` file under ``templates/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .jinja_bridge import load_sexp_dir, watch_sexp_dir
|
||||
from .jinja_bridge import load_sx_dir, watch_sx_dir
|
||||
|
||||
|
||||
def load_shared_components() -> None:
|
||||
"""Register all shared s-expression components."""
|
||||
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
load_sexp_dir(templates_dir)
|
||||
watch_sexp_dir(templates_dir)
|
||||
load_sx_dir(templates_dir)
|
||||
watch_sx_dir(templates_dir)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Shared helper functions for s-expression page rendering.
|
||||
|
||||
These are used by per-service sexp_components.py files to build common
|
||||
These are used by per-service sx_components.py files to build common
|
||||
page elements (headers, search, etc.) from template context.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
from .parser import SexpExpr
|
||||
from .parser import SxExpr
|
||||
|
||||
|
||||
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||
@@ -33,48 +33,48 @@ def get_asset_url(ctx: dict) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sexp-native helper functions — return sexp source (not HTML)
|
||||
# Sx-native helper functions — return sx source (not HTML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _as_sexp(val: Any) -> SexpExpr | None:
|
||||
"""Coerce a fragment value to SexpExpr.
|
||||
def _as_sx(val: Any) -> SxExpr | None:
|
||||
"""Coerce a fragment value to SxExpr.
|
||||
|
||||
If *val* is already a ``SexpExpr`` (from a ``text/sexp`` fragment),
|
||||
If *val* is already a ``SxExpr`` (from a ``text/sx`` fragment),
|
||||
return it as-is. If it's a non-empty string (HTML from a
|
||||
``text/html`` fragment), wrap it in ``~rich-text``. Otherwise
|
||||
return ``None``.
|
||||
"""
|
||||
if not val:
|
||||
return None
|
||||
if isinstance(val, SexpExpr):
|
||||
if isinstance(val, SxExpr):
|
||||
return val
|
||||
html = str(val)
|
||||
escaped = html.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return SexpExpr(f'(~rich-text :html "{escaped}")')
|
||||
return SxExpr(f'(~rich-text :html "{escaped}")')
|
||||
|
||||
|
||||
def root_header_sexp(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the root header row as a sexp call string."""
|
||||
def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the root header row as a sx call string."""
|
||||
rights = ctx.get("rights") or {}
|
||||
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
|
||||
return sexp_call("header-row-sx",
|
||||
cart_mini=_as_sexp(ctx.get("cart_mini")),
|
||||
return sx_call("header-row-sx",
|
||||
cart_mini=_as_sx(ctx.get("cart_mini")),
|
||||
blog_url=call_url(ctx, "blog_url", ""),
|
||||
site_title=ctx.get("base_title", ""),
|
||||
app_label=ctx.get("app_label", ""),
|
||||
nav_tree=_as_sexp(ctx.get("nav_tree")),
|
||||
auth_menu=_as_sexp(ctx.get("auth_menu")),
|
||||
nav_panel=_as_sexp(ctx.get("nav_panel")),
|
||||
nav_tree=_as_sx(ctx.get("nav_tree")),
|
||||
auth_menu=_as_sx(ctx.get("auth_menu")),
|
||||
nav_panel=_as_sx(ctx.get("nav_panel")),
|
||||
settings_url=settings_url,
|
||||
is_admin=is_admin,
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def search_mobile_sexp(ctx: dict) -> str:
|
||||
"""Build mobile search input as sexp call string."""
|
||||
return sexp_call("search-mobile",
|
||||
def search_mobile_sx(ctx: dict) -> str:
|
||||
"""Build mobile search input as sx call string."""
|
||||
return sx_call("search-mobile",
|
||||
current_local_href=ctx.get("current_local_href", "/"),
|
||||
search=ctx.get("search", ""),
|
||||
search_count=ctx.get("search_count", ""),
|
||||
@@ -83,9 +83,9 @@ def search_mobile_sexp(ctx: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def search_desktop_sexp(ctx: dict) -> str:
|
||||
"""Build desktop search input as sexp call string."""
|
||||
return sexp_call("search-desktop",
|
||||
def search_desktop_sx(ctx: dict) -> str:
|
||||
"""Build desktop search input as sx call string."""
|
||||
return sx_call("search-desktop",
|
||||
current_local_href=ctx.get("current_local_href", "/"),
|
||||
search=ctx.get("search", ""),
|
||||
search_count=ctx.get("search_count", ""),
|
||||
@@ -94,8 +94,8 @@ def search_desktop_sexp(ctx: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def post_header_sexp(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the post-level header row as sexp call string."""
|
||||
def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the post-level header row as sx call string."""
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
if not slug:
|
||||
@@ -103,13 +103,13 @@ def post_header_sexp(ctx: dict, *, oob: bool = False) -> str:
|
||||
title = (post.get("title") or "")[:160]
|
||||
feature_image = post.get("feature_image")
|
||||
|
||||
label_sexp = sexp_call("post-label", feature_image=feature_image, title=title)
|
||||
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
|
||||
|
||||
nav_parts: list[str] = []
|
||||
page_cart_count = ctx.get("page_cart_count", 0)
|
||||
if page_cart_count and page_cart_count > 0:
|
||||
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
|
||||
nav_parts.append(sexp_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
|
||||
nav_parts.append(sx_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
|
||||
|
||||
container_nav = ctx.get("container_nav")
|
||||
if container_nav:
|
||||
@@ -140,27 +140,27 @@ def post_header_sexp(ctx: dict, *, oob: bool = False) -> str:
|
||||
if admin_nav:
|
||||
nav_parts.append(admin_nav)
|
||||
|
||||
nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
|
||||
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
|
||||
link_href = call_url(ctx, "blog_url", f"/{slug}/")
|
||||
|
||||
return sexp_call("menu-row-sx",
|
||||
return sx_call("menu-row-sx",
|
||||
id="post-row", level=1,
|
||||
link_href=link_href,
|
||||
link_label_content=SexpExpr(label_sexp),
|
||||
nav=SexpExpr(nav_sexp) if nav_sexp else None,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
child_id="post-header-child",
|
||||
oob=oob, external=True,
|
||||
)
|
||||
|
||||
|
||||
def post_admin_header_sexp(ctx: dict, slug: str, *, oob: bool = False,
|
||||
def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
|
||||
selected: str = "", admin_href: str = "") -> str:
|
||||
"""Post admin header row as sexp call string."""
|
||||
"""Post admin header row as sx call string."""
|
||||
# Label
|
||||
label_parts = ['(i :class "fa fa-shield-halved" :aria-hidden "true")', '" admin"']
|
||||
if selected:
|
||||
label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
|
||||
label_sexp = "(<> " + " ".join(label_parts) + ")"
|
||||
label_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
|
||||
# Nav items
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
@@ -193,77 +193,77 @@ def post_admin_header_sexp(ctx: dict, slug: str, *, oob: bool = False,
|
||||
+ f' :class "{cls} {escape(select_colours)}"'
|
||||
+ f' "{escape(label)}"))'
|
||||
)
|
||||
nav_sexp = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
|
||||
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
|
||||
|
||||
if not admin_href:
|
||||
blog_fn = ctx.get("blog_url")
|
||||
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
|
||||
|
||||
return sexp_call("menu-row-sx",
|
||||
return sx_call("menu-row-sx",
|
||||
id="post-admin-row", level=2,
|
||||
link_href=admin_href,
|
||||
link_label_content=SexpExpr(label_sexp),
|
||||
nav=SexpExpr(nav_sexp) if nav_sexp else None,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
child_id="post-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def oob_header_sexp(parent_id: str, child_id: str, row_sexp: str) -> str:
|
||||
"""Wrap a header row sexp in an OOB swap."""
|
||||
return sexp_call("oob-header-sx",
|
||||
def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
|
||||
"""Wrap a header row sx in an OOB swap."""
|
||||
return sx_call("oob-header-sx",
|
||||
parent_id=parent_id, child_id=child_id,
|
||||
row=SexpExpr(row_sexp),
|
||||
row=SxExpr(row_sx),
|
||||
)
|
||||
|
||||
|
||||
def header_child_sexp(inner_sexp: str, *, id: str = "root-header-child") -> str:
|
||||
"""Wrap inner sexp in a header-child div."""
|
||||
return sexp_call("header-child-sx",
|
||||
id=id, inner=SexpExpr(inner_sexp),
|
||||
def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
|
||||
"""Wrap inner sx in a header-child div."""
|
||||
return sx_call("header-child-sx",
|
||||
id=id, inner=SxExpr(inner_sx),
|
||||
)
|
||||
|
||||
|
||||
def oob_page_sexp(*, oobs: str = "", filter: str = "", aside: str = "",
|
||||
def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
|
||||
content: str = "", menu: str = "") -> str:
|
||||
"""Build OOB response as sexp call string."""
|
||||
return sexp_call("oob-sexp",
|
||||
oobs=SexpExpr(oobs) if oobs else None,
|
||||
filter=SexpExpr(filter) if filter else None,
|
||||
aside=SexpExpr(aside) if aside else None,
|
||||
menu=SexpExpr(menu) if menu else None,
|
||||
content=SexpExpr(content) if content else None,
|
||||
"""Build OOB response as sx call string."""
|
||||
return sx_call("oob-sx",
|
||||
oobs=SxExpr(oobs) if oobs else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
aside=SxExpr(aside) if aside else None,
|
||||
menu=SxExpr(menu) if menu else None,
|
||||
content=SxExpr(content) if content else None,
|
||||
)
|
||||
|
||||
|
||||
def full_page_sexp(ctx: dict, *, header_rows: str,
|
||||
def full_page_sx(ctx: dict, *, header_rows: str,
|
||||
filter: str = "", aside: str = "",
|
||||
content: str = "", menu: str = "",
|
||||
meta_html: str = "", meta: str = "") -> str:
|
||||
"""Build a full page using sexp_page() with ~app-body.
|
||||
"""Build a full page using sx_page() with ~app-body.
|
||||
|
||||
meta_html: raw HTML injected into the <head> shell (legacy).
|
||||
meta: sexp source for meta tags — auto-hoisted to <head> by sexp.js.
|
||||
meta: sx source for meta tags — auto-hoisted to <head> by sx.js.
|
||||
"""
|
||||
body_sexp = sexp_call("app-body",
|
||||
header_rows=SexpExpr(header_rows) if header_rows else None,
|
||||
filter=SexpExpr(filter) if filter else None,
|
||||
aside=SexpExpr(aside) if aside else None,
|
||||
menu=SexpExpr(menu) if menu else None,
|
||||
content=SexpExpr(content) if content else None,
|
||||
body_sx = sx_call("app-body",
|
||||
header_rows=SxExpr(header_rows) if header_rows else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
aside=SxExpr(aside) if aside else None,
|
||||
menu=SxExpr(menu) if menu else None,
|
||||
content=SxExpr(content) if content else None,
|
||||
)
|
||||
if meta:
|
||||
# Wrap body + meta in a fragment so sexp.js renders both;
|
||||
# Wrap body + meta in a fragment so sx.js renders both;
|
||||
# auto-hoist moves meta/title/link elements to <head>.
|
||||
body_sexp = "(<> " + meta + " " + body_sexp + ")"
|
||||
return sexp_page(ctx, body_sexp, meta_html=meta_html)
|
||||
body_sx = "(<> " + meta + " " + body_sx + ")"
|
||||
return sx_page(ctx, body_sx, meta_html=meta_html)
|
||||
|
||||
|
||||
def sexp_call(component_name: str, **kwargs: Any) -> str:
|
||||
def sx_call(component_name: str, **kwargs: Any) -> str:
|
||||
"""Build an s-expression component call string from Python kwargs.
|
||||
|
||||
Converts snake_case to kebab-case automatically::
|
||||
|
||||
sexp_call("test-row", nodeid="foo", outcome="passed")
|
||||
sx_call("test-row", nodeid="foo", outcome="passed")
|
||||
# => '(~test-row :nodeid "foo" :outcome "passed")'
|
||||
|
||||
Values are serialized: strings are quoted, None becomes nil,
|
||||
@@ -312,31 +312,31 @@ def components_for_request() -> str:
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sexp = "(" + " ".join(param_strs) + ")"
|
||||
body_sexp = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defcomp ~{val.name} {params_sexp} {body_sexp})")
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def sexp_response(source_or_component: str, status: int = 200,
|
||||
def sx_response(source_or_component: str, status: int = 200,
|
||||
headers: dict | None = None, **kwargs: Any):
|
||||
"""Return an s-expression wire-format response.
|
||||
|
||||
Can be called with a raw sexp string::
|
||||
Can be called with a raw sx string::
|
||||
|
||||
return sexp_response('(~test-row :nodeid "foo")')
|
||||
return sx_response('(~test-row :nodeid "foo")')
|
||||
|
||||
Or with a component name + kwargs (builds the sexp call)::
|
||||
Or with a component name + kwargs (builds the sx call)::
|
||||
|
||||
return sexp_response("test-row", nodeid="foo", outcome="passed")
|
||||
return sx_response("test-row", nodeid="foo", outcome="passed")
|
||||
|
||||
For SX requests, missing component definitions are prepended as a
|
||||
``<script type="text/sexp" data-components>`` block so the client
|
||||
``<script type="text/sx" data-components>`` block so the client
|
||||
can process them before rendering OOB content.
|
||||
"""
|
||||
from quart import request, Response
|
||||
if kwargs:
|
||||
source = sexp_call(source_or_component, **kwargs)
|
||||
source = sx_call(source_or_component, **kwargs)
|
||||
else:
|
||||
source = source_or_component
|
||||
|
||||
@@ -345,10 +345,10 @@ def sexp_response(source_or_component: str, status: int = 200,
|
||||
if request.headers.get("SX-Request"):
|
||||
comp_defs = components_for_request()
|
||||
if comp_defs:
|
||||
body = (f'<script type="text/sexp" data-components>'
|
||||
body = (f'<script type="text/sx" data-components>'
|
||||
f'{comp_defs}</script>\n{body}')
|
||||
|
||||
resp = Response(body, status=status, content_type="text/sexp")
|
||||
resp = Response(body, status=status, content_type="text/sx")
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
resp.headers[k] = v
|
||||
@@ -357,10 +357,10 @@ def sexp_response(source_or_component: str, status: int = 200,
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sexp wire-format full page shell
|
||||
# Sx wire-format full page shell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEXP_PAGE_TEMPLATE = """\
|
||||
_SX_PAGE_TEMPLATE = """\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -401,19 +401,19 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
<script type="text/sexp" data-components>{component_defs}</script>
|
||||
<script type="text/sexp" data-mount="body">{page_sexp}</script>
|
||||
<script src="{asset_url}/scripts/sexp.js"></script>
|
||||
<script type="text/sx" data-components>{component_defs}</script>
|
||||
<script type="text/sx" data-mount="body">{page_sx}</script>
|
||||
<script src="{asset_url}/scripts/sx.js"></script>
|
||||
<script src="{asset_url}/scripts/body.js"></script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def sexp_page(ctx: dict, page_sexp: str, *,
|
||||
def sx_page(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "") -> str:
|
||||
"""Return a minimal HTML shell that boots the page from sexp source.
|
||||
"""Return a minimal HTML shell that boots the page from sx source.
|
||||
|
||||
The browser loads component definitions and page sexp, then sexp.js
|
||||
The browser loads component definitions and page sx, then sx.js
|
||||
renders everything client-side.
|
||||
"""
|
||||
from .jinja_bridge import client_components_tag
|
||||
@@ -421,7 +421,7 @@ def sexp_page(ctx: dict, page_sexp: str, *,
|
||||
# Extract just the inner source from the <script> tag
|
||||
component_defs = ""
|
||||
if components_tag:
|
||||
# Strip <script type="text/sexp" data-components>...</script>
|
||||
# Strip <script type="text/sx" data-components>...</script>
|
||||
start = components_tag.find(">") + 1
|
||||
end = components_tag.rfind("</script>")
|
||||
if start > 0 and end > start:
|
||||
@@ -431,13 +431,13 @@ def sexp_page(ctx: dict, page_sexp: str, *,
|
||||
title = ctx.get("base_title", "Rose Ash")
|
||||
csrf = _get_csrf_token()
|
||||
|
||||
return _SEXP_PAGE_TEMPLATE.format(
|
||||
return _SX_PAGE_TEMPLATE.format(
|
||||
title=_html_escape(title),
|
||||
asset_url=asset_url,
|
||||
meta_html=meta_html,
|
||||
csrf=_html_escape(csrf),
|
||||
component_defs=component_defs,
|
||||
page_sexp=page_sexp,
|
||||
page_sx=page_sx,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ evaluator and then rendered recursively.
|
||||
|
||||
Usage::
|
||||
|
||||
from shared.sexp import parse, make_env
|
||||
from shared.sexp.html import render
|
||||
from shared.sx import parse, make_env
|
||||
from shared.sx.html import render
|
||||
|
||||
expr = parse('(div :class "card" (h1 "Hello") (p "World"))')
|
||||
html = render(expr)
|
||||
@@ -6,7 +6,7 @@ can coexist during incremental migration:
|
||||
|
||||
**Jinja → s-expression** (use s-expression components inside Jinja templates)::
|
||||
|
||||
{{ sexp('(~link-card :slug "apple" :title "Apple")') | safe }}
|
||||
{{ sx('(~link-card :slug "apple" :title "Apple")') | safe }}
|
||||
|
||||
**S-expression → Jinja** (embed Jinja output inside s-expressions)::
|
||||
|
||||
@@ -14,8 +14,8 @@ can coexist during incremental migration:
|
||||
|
||||
Setup::
|
||||
|
||||
from shared.sexp.jinja_bridge import setup_sexp_bridge
|
||||
setup_sexp_bridge(app) # call after setup_jinja(app)
|
||||
from shared.sx.jinja_bridge import setup_sx_bridge
|
||||
setup_sx_bridge(app) # call after setup_jinja(app)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,42 +43,39 @@ def get_component_env() -> dict[str, Any]:
|
||||
return _COMPONENT_ENV
|
||||
|
||||
|
||||
def load_sexp_dir(directory: str) -> None:
|
||||
"""Load all .sexp and .sexpr files from a directory and register components."""
|
||||
def load_sx_dir(directory: str) -> None:
|
||||
"""Load all .sx files from a directory and register components."""
|
||||
for filepath in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sexp"))
|
||||
+ glob.glob(os.path.join(directory, "*.sexpr"))
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-mode auto-reload of sexp templates
|
||||
# Dev-mode auto-reload of sx templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_watched_dirs: list[str] = []
|
||||
_file_mtimes: dict[str, float] = {}
|
||||
|
||||
|
||||
def watch_sexp_dir(directory: str) -> None:
|
||||
def watch_sx_dir(directory: str) -> None:
|
||||
"""Register a directory for dev-mode file watching."""
|
||||
_watched_dirs.append(directory)
|
||||
# Seed mtimes
|
||||
for fp in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sexp"))
|
||||
+ glob.glob(os.path.join(directory, "*.sexpr"))
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
_file_mtimes[fp] = os.path.getmtime(fp)
|
||||
|
||||
|
||||
def reload_if_changed() -> None:
|
||||
"""Re-read sexp files if any have changed on disk. Called per-request in dev."""
|
||||
"""Re-read sx files if any have changed on disk. Called per-request in dev."""
|
||||
changed = False
|
||||
for directory in _watched_dirs:
|
||||
for fp in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sexp"))
|
||||
+ glob.glob(os.path.join(directory, "*.sexpr"))
|
||||
glob.glob(os.path.join(directory, "*.sx"))
|
||||
):
|
||||
mtime = os.path.getmtime(fp)
|
||||
if fp not in _file_mtimes or _file_mtimes[fp] != mtime:
|
||||
@@ -87,18 +84,18 @@ def reload_if_changed() -> None:
|
||||
if changed:
|
||||
_COMPONENT_ENV.clear()
|
||||
for directory in _watched_dirs:
|
||||
load_sexp_dir(directory)
|
||||
load_sx_dir(directory)
|
||||
|
||||
|
||||
def load_service_components(service_dir: str) -> None:
|
||||
"""Load service-specific s-expression components from {service_dir}/sexp/."""
|
||||
sexp_dir = os.path.join(service_dir, "sexp")
|
||||
if os.path.isdir(sexp_dir):
|
||||
load_sexp_dir(sexp_dir)
|
||||
watch_sexp_dir(sexp_dir)
|
||||
"""Load service-specific s-expression components from {service_dir}/sx/."""
|
||||
sx_dir = os.path.join(service_dir, "sx")
|
||||
if os.path.isdir(sx_dir):
|
||||
load_sx_dir(sx_dir)
|
||||
watch_sx_dir(sx_dir)
|
||||
|
||||
|
||||
def register_components(sexp_source: str) -> None:
|
||||
def register_components(sx_source: str) -> None:
|
||||
"""Parse and evaluate s-expression component definitions into the
|
||||
shared environment.
|
||||
|
||||
@@ -117,26 +114,26 @@ def register_components(sexp_source: str) -> None:
|
||||
from .evaluator import _eval
|
||||
from .parser import parse_all
|
||||
|
||||
exprs = parse_all(sexp_source)
|
||||
exprs = parse_all(sx_source)
|
||||
for expr in exprs:
|
||||
_eval(expr, _COMPONENT_ENV)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sexp() — render s-expression from Jinja template
|
||||
# sx() — render s-expression from Jinja template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def sexp(source: str, **kwargs: Any) -> str:
|
||||
def sx(source: str, **kwargs: Any) -> str:
|
||||
"""Render an s-expression string to HTML.
|
||||
|
||||
Keyword arguments are merged into the evaluation environment,
|
||||
so Jinja context variables can be passed through::
|
||||
|
||||
{{ sexp('(~link-card :title title :slug slug)',
|
||||
{{ sx('(~link-card :title title :slug slug)',
|
||||
title=post.title, slug=post.slug) | safe }}
|
||||
|
||||
This is a synchronous function — suitable for Jinja globals.
|
||||
For async resolution (with I/O primitives), use ``sexp_async()``.
|
||||
For async resolution (with I/O primitives), use ``sx_async()``.
|
||||
"""
|
||||
env = dict(_COMPONENT_ENV)
|
||||
env.update(kwargs)
|
||||
@@ -147,8 +144,8 @@ def sexp(source: str, **kwargs: Any) -> str:
|
||||
def render(component_name: str, **kwargs: Any) -> str:
|
||||
"""Call a registered component by name with Python kwargs.
|
||||
|
||||
Automatically converts Python snake_case to sexp kebab-case.
|
||||
No sexp strings needed — just a function call.
|
||||
Automatically converts Python snake_case to sx kebab-case.
|
||||
No sx strings needed — just a function call.
|
||||
"""
|
||||
name = component_name if component_name.startswith("~") else f"~{component_name}"
|
||||
comp = _COMPONENT_ENV.get(name)
|
||||
@@ -166,13 +163,13 @@ def render(component_name: str, **kwargs: Any) -> str:
|
||||
return _render_component(comp, args, env)
|
||||
|
||||
|
||||
async def sexp_async(source: str, **kwargs: Any) -> str:
|
||||
"""Async version of ``sexp()`` — resolves I/O primitives (frag, query)
|
||||
async def sx_async(source: str, **kwargs: Any) -> str:
|
||||
"""Async version of ``sx()`` — resolves I/O primitives (frag, query)
|
||||
before rendering.
|
||||
|
||||
Use when the s-expression contains I/O nodes::
|
||||
|
||||
{{ sexp_async('(frag "blog" "card" :slug "apple")') | safe }}
|
||||
{{ sx_async('(frag "blog" "card" :slug "apple")') | safe }}
|
||||
"""
|
||||
from .resolver import resolve, RequestContext
|
||||
|
||||
@@ -202,10 +199,10 @@ def _get_request_context():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def client_components_tag(*names: str) -> str:
|
||||
"""Emit a <script type="text/sexp"> tag with component definitions.
|
||||
"""Emit a <script type="text/sx"> tag with component definitions.
|
||||
|
||||
Reads the source definitions from loaded .sexpr files and sends them
|
||||
to the client so sexp.js can render them identically.
|
||||
Reads the source definitions from loaded .sx files and sends them
|
||||
to the client so sx.js can render them identically.
|
||||
|
||||
Usage in Python::
|
||||
|
||||
@@ -226,27 +223,27 @@ def client_components_tag(*names: str) -> str:
|
||||
param_strs = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sexp = "(" + " ".join(param_strs) + ")"
|
||||
body_sexp = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defcomp ~{val.name} {params_sexp} {body_sexp})")
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defcomp ~{val.name} {params_sx} {body_sx})")
|
||||
if not parts:
|
||||
return ""
|
||||
source = "\n".join(parts)
|
||||
return f'<script type="text/sexp" data-components>{source}</script>'
|
||||
return f'<script type="text/sx" data-components>{source}</script>'
|
||||
|
||||
|
||||
def setup_sexp_bridge(app: Any) -> None:
|
||||
def setup_sx_bridge(app: Any) -> None:
|
||||
"""Register s-expression helpers with a Quart app's Jinja environment.
|
||||
|
||||
Call this in your app factory after ``setup_jinja(app)``::
|
||||
|
||||
from shared.sexp.jinja_bridge import setup_sexp_bridge
|
||||
setup_sexp_bridge(app)
|
||||
from shared.sx.jinja_bridge import setup_sx_bridge
|
||||
setup_sx_bridge(app)
|
||||
|
||||
This registers:
|
||||
- ``sexp(source, **kwargs)`` — sync render (components, pure HTML)
|
||||
- ``sexp_async(source, **kwargs)`` — async render (with I/O resolution)
|
||||
- ``sx(source, **kwargs)`` — sync render (components, pure HTML)
|
||||
- ``sx_async(source, **kwargs)`` — async render (with I/O resolution)
|
||||
"""
|
||||
app.jinja_env.globals["sexp"] = sexp
|
||||
app.jinja_env.globals["sx"] = sx
|
||||
app.jinja_env.globals["render"] = render
|
||||
app.jinja_env.globals["sexp_async"] = sexp_async
|
||||
app.jinja_env.globals["sx_async"] = sx_async
|
||||
@@ -5,13 +5,13 @@ Provides ``render_page()`` for rendering a complete HTML page from an
|
||||
s-expression, bypassing Jinja entirely. Used by error handlers and
|
||||
(eventually) by route handlers for fully-migrated pages.
|
||||
|
||||
``render_sexp_response()`` is the main entry point for GET route handlers:
|
||||
``render_sx_response()`` is the main entry point for GET route handlers:
|
||||
it calls the app's context processor, merges in route-specific kwargs,
|
||||
renders the s-expression to HTML, and returns a Quart ``Response``.
|
||||
|
||||
Usage::
|
||||
|
||||
from shared.sexp.page import render_page, render_sexp_response
|
||||
from shared.sx.page import render_page, render_sx_response
|
||||
|
||||
# Error pages (no context needed)
|
||||
html = render_page(
|
||||
@@ -21,14 +21,14 @@ Usage::
|
||||
)
|
||||
|
||||
# GET route handlers (auto-injects app context)
|
||||
resp = await render_sexp_response('(~orders-page :orders orders)', orders=orders)
|
||||
resp = await render_sx_response('(~orders-page :orders orders)', orders=orders)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .jinja_bridge import sexp
|
||||
from .jinja_bridge import sx
|
||||
|
||||
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
|
||||
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
|
||||
@@ -37,10 +37,10 @@ SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
|
||||
def render_page(source: str, **kwargs: Any) -> str:
|
||||
"""Render a full HTML page from an s-expression string.
|
||||
|
||||
This is a thin wrapper around ``sexp()`` — it exists to make the
|
||||
This is a thin wrapper around ``sx()`` — it exists to make the
|
||||
intent explicit in call sites (rendering a whole page, not a fragment).
|
||||
"""
|
||||
return sexp(source, **kwargs)
|
||||
return sx(source, **kwargs)
|
||||
|
||||
|
||||
async def get_template_context(**kwargs: Any) -> dict[str, Any]:
|
||||
@@ -75,7 +75,7 @@ async def get_template_context(**kwargs: Any) -> dict[str, Any]:
|
||||
if key not in ctx:
|
||||
ctx[key] = val
|
||||
|
||||
# Expose request-scoped values that sexp components need
|
||||
# Expose request-scoped values that sx components need
|
||||
from quart import g
|
||||
if "rights" not in ctx:
|
||||
ctx["rights"] = getattr(g, "rights", {})
|
||||
@@ -84,7 +84,7 @@ async def get_template_context(**kwargs: Any) -> dict[str, Any]:
|
||||
return ctx
|
||||
|
||||
|
||||
async def render_sexp_response(source: str, **kwargs: Any) -> str:
|
||||
async def render_sx_response(source: str, **kwargs: Any) -> str:
|
||||
"""Render an s-expression with the full app template context.
|
||||
|
||||
Calls the app's registered context processors (which provide
|
||||
@@ -94,4 +94,4 @@ async def render_sexp_response(source: str, **kwargs: Any) -> str:
|
||||
Returns the rendered HTML string (caller wraps in Response as needed).
|
||||
"""
|
||||
ctx = await get_template_context(**kwargs)
|
||||
return sexp(source, **ctx)
|
||||
return sx(source, **ctx)
|
||||
@@ -22,16 +22,16 @@ from .types import Keyword, Symbol, NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SexpExpr — pre-built sexp source marker
|
||||
# SxExpr — pre-built sx source marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SexpExpr:
|
||||
"""Pre-built sexp source that serialize() outputs unquoted.
|
||||
class SxExpr:
|
||||
"""Pre-built sx source that serialize() outputs unquoted.
|
||||
|
||||
Use this to nest sexp call strings inside other sexp_call() invocations
|
||||
Use this to nest sx call strings inside other sx_call() invocations
|
||||
without them being quoted as strings::
|
||||
|
||||
sexp_call("parent", child=SexpExpr(sexp_call("child", x=1)))
|
||||
sx_call("parent", child=SxExpr(sx_call("child", x=1)))
|
||||
# => (~parent :child (~child :x 1))
|
||||
"""
|
||||
__slots__ = ("source",)
|
||||
@@ -40,16 +40,16 @@ class SexpExpr:
|
||||
self.source = source
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SexpExpr({self.source!r})"
|
||||
return f"SxExpr({self.source!r})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.source
|
||||
|
||||
def __add__(self, other: object) -> "SexpExpr":
|
||||
return SexpExpr(self.source + str(other))
|
||||
def __add__(self, other: object) -> "SxExpr":
|
||||
return SxExpr(self.source + str(other))
|
||||
|
||||
def __radd__(self, other: object) -> "SexpExpr":
|
||||
return SexpExpr(str(other) + self.source)
|
||||
def __radd__(self, other: object) -> "SxExpr":
|
||||
return SxExpr(str(other) + self.source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -261,7 +261,7 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
|
||||
|
||||
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
"""Serialize a value back to s-expression text."""
|
||||
if isinstance(expr, SexpExpr):
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr.source
|
||||
|
||||
if isinstance(expr, list):
|
||||
@@ -303,11 +303,11 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
items.append(serialize(v, indent, pretty))
|
||||
return "{" + " ".join(items) + "}"
|
||||
|
||||
# Catch callables (Python functions leaked into sexp data)
|
||||
# Catch callables (Python functions leaked into sx data)
|
||||
if callable(expr):
|
||||
import logging
|
||||
logging.getLogger("sexp").error(
|
||||
"serialize: callable leaked into sexp data: %r", expr)
|
||||
logging.getLogger("sx").error(
|
||||
"serialize: callable leaked into sx data: %r", expr)
|
||||
return "nil"
|
||||
|
||||
# Fallback for Lambda/Component — show repr
|
||||
@@ -8,7 +8,7 @@ via ``load_relation_registry()``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sexp.types import RelationDef
|
||||
from shared.sx.types import RelationDef
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,8 +94,8 @@ _BUILTIN_RELATIONS = '''
|
||||
|
||||
def load_relation_registry() -> None:
|
||||
"""Parse built-in defrelation s-expressions and populate the registry."""
|
||||
from shared.sexp.evaluator import evaluate
|
||||
from shared.sexp.parser import parse
|
||||
from shared.sx.evaluator import evaluate
|
||||
from shared.sx.parser import parse
|
||||
|
||||
tree = parse(_BUILTIN_RELATIONS)
|
||||
evaluate(tree)
|
||||
@@ -13,8 +13,8 @@ This is the DAG execution engine applied to page rendering. The strategy:
|
||||
|
||||
Usage::
|
||||
|
||||
from shared.sexp import parse
|
||||
from shared.sexp.resolver import resolve, RequestContext
|
||||
from shared.sx import parse
|
||||
from shared.sx.resolver import resolve, RequestContext
|
||||
|
||||
expr = parse('''
|
||||
(div :class "page"
|
||||
@@ -23,7 +23,7 @@
|
||||
(when content content)
|
||||
(div :class "pb-8")))))))
|
||||
|
||||
(defcomp ~oob-sexp (&key oobs filter aside menu content)
|
||||
(defcomp ~oob-sx (&key oobs filter aside menu content)
|
||||
(<>
|
||||
(when oobs oobs)
|
||||
(div :id "filter" :sx-swap-oob "outerHTML"
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sexp.jinja_bridge import sexp, _COMPONENT_ENV
|
||||
from shared.sexp.components import load_shared_components
|
||||
from shared.sx.jinja_bridge import sx, _COMPONENT_ENV
|
||||
from shared.sx.components import load_shared_components
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -19,7 +19,7 @@ def _load_components():
|
||||
|
||||
class TestCartMini:
|
||||
def test_empty_cart_shows_logo(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||
**{"cart-count": 0, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
|
||||
)
|
||||
@@ -29,7 +29,7 @@ class TestCartMini:
|
||||
assert "fa-shopping-cart" not in html
|
||||
|
||||
def test_nonempty_cart_shows_badge(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url)',
|
||||
**{"cart-count": 3, "blog-url": "https://blog.example.com/", "cart-url": "https://cart.example.com/"},
|
||||
)
|
||||
@@ -40,13 +40,13 @@ class TestCartMini:
|
||||
assert "cart.example.com/" in html
|
||||
|
||||
def test_oob_attribute(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "" :oob "true")',
|
||||
)
|
||||
assert 'sx-swap-oob="true"' in html
|
||||
|
||||
def test_no_oob_when_nil(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~cart-mini :cart-count 0 :blog-url "" :cart-url "")',
|
||||
)
|
||||
assert "sx-swap-oob" not in html
|
||||
@@ -58,7 +58,7 @@ class TestCartMini:
|
||||
|
||||
class TestAuthMenu:
|
||||
def test_logged_in(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||
**{"user-email": "alice@example.com", "account-url": "https://account.example.com/"},
|
||||
)
|
||||
@@ -69,7 +69,7 @@ class TestAuthMenu:
|
||||
assert "sign in or register" not in html
|
||||
|
||||
def test_logged_out(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~auth-menu :account-url account-url)',
|
||||
**{"account-url": "https://account.example.com/"},
|
||||
)
|
||||
@@ -77,7 +77,7 @@ class TestAuthMenu:
|
||||
assert "sign in or register" in html
|
||||
|
||||
def test_desktop_has_data_close_details(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~auth-menu :user-email "x@y.com" :account-url "http://a")',
|
||||
)
|
||||
assert "data-close-details" in html
|
||||
@@ -85,7 +85,7 @@ class TestAuthMenu:
|
||||
def test_two_spans_always_present(self):
|
||||
"""Both desktop and mobile spans are always rendered."""
|
||||
for email in ["user@test.com", None]:
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~auth-menu :user-email user-email :account-url account-url)',
|
||||
**{"user-email": email, "account-url": "http://a"},
|
||||
)
|
||||
@@ -99,7 +99,7 @@ class TestAuthMenu:
|
||||
|
||||
class TestAccountNavItem:
|
||||
def test_renders_link(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~account-nav-item :href "/orders/" :label "orders")',
|
||||
)
|
||||
assert 'href="/orders/"' in html
|
||||
@@ -108,7 +108,7 @@ class TestAccountNavItem:
|
||||
assert "sx-disable" in html
|
||||
|
||||
def test_custom_label(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~account-nav-item :href "/cart/orders/" :label "my orders")',
|
||||
)
|
||||
assert ">my orders<" in html
|
||||
@@ -120,7 +120,7 @@ class TestAccountNavItem:
|
||||
|
||||
class TestCalendarEntryNav:
|
||||
def test_renders_entry(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~calendar-entry-nav :href "/events/entry/1/" :name "Workshop" :date-str "Jan 15, 2026 at 14:00" :nav-class "btn")',
|
||||
**{"date-str": "Jan 15, 2026 at 14:00", "nav-class": "btn"},
|
||||
)
|
||||
@@ -135,7 +135,7 @@ class TestCalendarEntryNav:
|
||||
|
||||
class TestCalendarLinkNav:
|
||||
def test_renders_calendar_link(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~calendar-link-nav :href "/events/cal/" :name "Art Events" :nav-class "btn")',
|
||||
**{"nav-class": "btn"},
|
||||
)
|
||||
@@ -150,7 +150,7 @@ class TestCalendarLinkNav:
|
||||
|
||||
class TestMarketLinkNav:
|
||||
def test_renders_market_link(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~market-link-nav :href "/market/farm/" :name "Farm Shop" :nav-class "btn")',
|
||||
**{"nav-class": "btn"},
|
||||
)
|
||||
@@ -165,7 +165,7 @@ class TestMarketLinkNav:
|
||||
|
||||
class TestPostCard:
|
||||
def test_basic_card(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~post-card :title "Hello World" :slug "hello" :href "/hello/"'
|
||||
' :feature-image "/img/hello.jpg" :excerpt "A test post"'
|
||||
' :status "published" :published-at "15 Jan 2026"'
|
||||
@@ -183,7 +183,7 @@ class TestPostCard:
|
||||
assert "A test post" in html
|
||||
|
||||
def test_draft_status(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~post-card :title "Draft" :slug "draft" :href "/draft/"'
|
||||
' :status "draft" :updated-at "15 Jan 2026"'
|
||||
' :hx-select "#main-panel")',
|
||||
@@ -194,7 +194,7 @@ class TestPostCard:
|
||||
assert "Updated:" in html
|
||||
|
||||
def test_draft_with_publish_requested(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~post-card :title "Pending" :slug "pending" :href "/pending/"'
|
||||
' :status "draft" :publish-requested true'
|
||||
' :hx-select "#main-panel")',
|
||||
@@ -204,7 +204,7 @@ class TestPostCard:
|
||||
assert "bg-blue-100" in html
|
||||
|
||||
def test_no_image(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~post-card :title "No Img" :slug "no-img" :href "/no-img/"'
|
||||
' :status "published" :hx-select "#main-panel")',
|
||||
**{"hx-select": "#main-panel"},
|
||||
@@ -212,8 +212,8 @@ class TestPostCard:
|
||||
assert "<img" not in html
|
||||
|
||||
def test_widgets_and_at_bar(self):
|
||||
"""Widgets and at-bar are sexp kwarg slots rendered by the client."""
|
||||
html = sexp(
|
||||
"""Widgets and at-bar are sx kwarg slots rendered by the client."""
|
||||
html = sx(
|
||||
'(~post-card :title "T" :slug "s" :href "/"'
|
||||
' :status "published" :hx-select "#mp")',
|
||||
**{"hx-select": "#mp"},
|
||||
@@ -229,7 +229,7 @@ class TestPostCard:
|
||||
|
||||
class TestBaseShell:
|
||||
def test_renders_full_page(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~base-shell :title "Test" :asset-url "/static" (p "Hello"))',
|
||||
**{"asset-url": "/static"},
|
||||
)
|
||||
@@ -242,7 +242,7 @@ class TestBaseShell:
|
||||
|
||||
class TestErrorPage:
|
||||
def test_404_page(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~error-page :title "404 Error" :message "NOT FOUND" :image "/static/errors/404.gif" :asset-url "/static")',
|
||||
**{"asset-url": "/static"},
|
||||
)
|
||||
@@ -252,7 +252,7 @@ class TestErrorPage:
|
||||
assert "/static/errors/404.gif" in html
|
||||
|
||||
def test_error_page_no_image(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~error-page :title "500 Error" :message "SERVER ERROR" :asset-url "/static")',
|
||||
**{"asset-url": "/static"},
|
||||
)
|
||||
@@ -266,7 +266,7 @@ class TestErrorPage:
|
||||
|
||||
class TestRelationNav:
|
||||
def test_renders_link(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-nav :href "/market/farm/" :name "Farm Shop" :icon "fa fa-shopping-bag")',
|
||||
)
|
||||
assert 'href="/market/farm/"' in html
|
||||
@@ -274,7 +274,7 @@ class TestRelationNav:
|
||||
assert "fa fa-shopping-bag" in html
|
||||
|
||||
def test_no_icon(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-nav :href "/cal/" :name "Events")',
|
||||
)
|
||||
assert 'href="/cal/"' in html
|
||||
@@ -282,7 +282,7 @@ class TestRelationNav:
|
||||
assert "fa " not in html
|
||||
|
||||
def test_custom_nav_class(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-nav :href "/" :name "X" :nav-class "custom-class")',
|
||||
**{"nav-class": "custom-class"},
|
||||
)
|
||||
@@ -295,7 +295,7 @@ class TestRelationNav:
|
||||
|
||||
class TestRelationAttach:
|
||||
def test_renders_button(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-attach :create-url "/market/create/" :label "Add Market" :icon "fa fa-plus")',
|
||||
**{"create-url": "/market/create/"},
|
||||
)
|
||||
@@ -305,7 +305,7 @@ class TestRelationAttach:
|
||||
assert "fa fa-plus" in html
|
||||
|
||||
def test_default_label(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-attach :create-url "/create/")',
|
||||
**{"create-url": "/create/"},
|
||||
)
|
||||
@@ -318,7 +318,7 @@ class TestRelationAttach:
|
||||
|
||||
class TestRelationDetach:
|
||||
def test_renders_button(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-detach :detach-url "/api/unrelate" :name "Farm Shop")',
|
||||
**{"detach-url": "/api/unrelate"},
|
||||
)
|
||||
@@ -327,7 +327,7 @@ class TestRelationDetach:
|
||||
assert "fa fa-times" in html
|
||||
|
||||
def test_default_name(self):
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~relation-detach :detach-url "/api/unrelate")',
|
||||
**{"detach-url": "/api/unrelate"},
|
||||
)
|
||||
@@ -340,7 +340,7 @@ class TestRelationDetach:
|
||||
|
||||
class TestRenderPage:
|
||||
def test_render_page(self):
|
||||
from shared.sexp.page import render_page
|
||||
from shared.sx.page import render_page
|
||||
|
||||
html = render_page(
|
||||
'(~error-page :title "Test" :message "MSG" :asset-url "/s")',
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for the s-expression evaluator."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate, EvalError, Symbol, Keyword, NIL
|
||||
from shared.sexp.types import Lambda, Component
|
||||
from shared.sx import parse, evaluate, EvalError, Symbol, Keyword, NIL
|
||||
from shared.sx.types import Lambda, Component
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for the HSX-style HTML renderer."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate
|
||||
from shared.sexp.html import render, escape_text, escape_attr
|
||||
from shared.sx import parse, evaluate
|
||||
from shared.sx.html import render, escape_text, escape_attr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from shared.sexp.jinja_bridge import (
|
||||
sexp,
|
||||
sexp_async,
|
||||
from shared.sx.jinja_bridge import (
|
||||
sx,
|
||||
sx_async,
|
||||
register_components,
|
||||
get_component_env,
|
||||
_COMPONENT_ENV,
|
||||
@@ -25,33 +25,33 @@ def setup_function():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sexp() — synchronous rendering
|
||||
# sx() — synchronous rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSexp:
|
||||
class TestSx:
|
||||
def test_simple_html(self):
|
||||
assert sexp('(div "Hello")') == "<div>Hello</div>"
|
||||
assert sx('(div "Hello")') == "<div>Hello</div>"
|
||||
|
||||
def test_with_kwargs(self):
|
||||
html = sexp('(p name)', name="Alice")
|
||||
html = sx('(p name)', name="Alice")
|
||||
assert html == "<p>Alice</p>"
|
||||
|
||||
def test_multiple_kwargs(self):
|
||||
html = sexp('(a :href url title)', url="/about", title="About")
|
||||
html = sx('(a :href url title)', url="/about", title="About")
|
||||
assert html == '<a href="/about">About</a>'
|
||||
|
||||
def test_escaping(self):
|
||||
html = sexp('(p text)', text="<script>alert(1)</script>")
|
||||
html = sx('(p text)', text="<script>alert(1)</script>")
|
||||
assert "<script>" in html
|
||||
assert "<script>" not in html
|
||||
|
||||
def test_nested(self):
|
||||
html = sexp('(div :class "card" (h1 title))', title="Hi")
|
||||
html = sx('(div :class "card" (h1 title))', title="Hi")
|
||||
assert html == '<div class="card"><h1>Hi</h1></div>'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register_components() + sexp()
|
||||
# register_components() + sx()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComponents:
|
||||
@@ -60,7 +60,7 @@ class TestComponents:
|
||||
(defcomp ~badge (&key label)
|
||||
(span :class "badge" label))
|
||||
''')
|
||||
html = sexp('(~badge :label "New")')
|
||||
html = sx('(~badge :label "New")')
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
def test_multiple_components(self):
|
||||
@@ -70,33 +70,33 @@ class TestComponents:
|
||||
(defcomp ~pill (&key text)
|
||||
(span :class "pill" text))
|
||||
''')
|
||||
assert '<span class="tag">A</span>' == sexp('(~tag :text "A")')
|
||||
assert '<span class="pill">B</span>' == sexp('(~pill :text "B")')
|
||||
assert '<span class="tag">A</span>' == sx('(~tag :text "A")')
|
||||
assert '<span class="pill">B</span>' == sx('(~pill :text "B")')
|
||||
|
||||
def test_component_with_children(self):
|
||||
register_components('''
|
||||
(defcomp ~box (&key title &rest children)
|
||||
(div :class "box" (h2 title) children))
|
||||
''')
|
||||
html = sexp('(~box :title "Box" (p "Content"))')
|
||||
html = sx('(~box :title "Box" (p "Content"))')
|
||||
assert '<div class="box">' in html
|
||||
assert "<h2>Box</h2>" in html
|
||||
assert "<p>Content</p>" in html
|
||||
|
||||
def test_component_with_kwargs_override(self):
|
||||
"""Kwargs passed to sexp() are available alongside components."""
|
||||
"""Kwargs passed to sx() are available alongside components."""
|
||||
register_components('''
|
||||
(defcomp ~greeting (&key name)
|
||||
(p (str "Hello " name)))
|
||||
''')
|
||||
html = sexp('(~greeting :name user)', user="Bob")
|
||||
html = sx('(~greeting :name user)', user="Bob")
|
||||
assert html == "<p>Hello Bob</p>"
|
||||
|
||||
def test_component_env_persists(self):
|
||||
"""Components registered once are available in subsequent calls."""
|
||||
register_components('(defcomp ~x (&key v) (b v))')
|
||||
assert sexp('(~x :v "1")') == "<b>1</b>"
|
||||
assert sexp('(~x :v "2")') == "<b>2</b>"
|
||||
assert sx('(~x :v "1")') == "<b>1</b>"
|
||||
assert sx('(~x :v "2")') == "<b>2</b>"
|
||||
|
||||
def test_get_component_env(self):
|
||||
register_components('(defcomp ~foo (&key x) (span x))')
|
||||
@@ -127,7 +127,7 @@ class TestLinkCard:
|
||||
''')
|
||||
|
||||
def test_with_image(self):
|
||||
html = sexp('''
|
||||
html = sx('''
|
||||
(~link-card
|
||||
:link "/products/apple/"
|
||||
:title "Apple"
|
||||
@@ -139,7 +139,7 @@ class TestLinkCard:
|
||||
assert "Apple" in html
|
||||
|
||||
def test_without_image(self):
|
||||
html = sexp('''
|
||||
html = sx('''
|
||||
(~link-card
|
||||
:link "/posts/hello/"
|
||||
:title "Hello World"
|
||||
@@ -151,7 +151,7 @@ class TestLinkCard:
|
||||
assert "Hello World" in html
|
||||
|
||||
def test_with_brand(self):
|
||||
html = sexp('''
|
||||
html = sx('''
|
||||
(~link-card
|
||||
:link "/p/x/"
|
||||
:title "Widget"
|
||||
@@ -161,7 +161,7 @@ class TestLinkCard:
|
||||
assert "Acme Corp" in html
|
||||
|
||||
def test_without_brand(self):
|
||||
html = sexp('''
|
||||
html = sx('''
|
||||
(~link-card
|
||||
:link "/p/x/"
|
||||
:title "Widget"
|
||||
@@ -172,7 +172,7 @@ class TestLinkCard:
|
||||
|
||||
def test_kwargs_from_python(self):
|
||||
"""Pass data from Python (like a route handler would)."""
|
||||
html = sexp(
|
||||
html = sx(
|
||||
'(~link-card :link link :title title :image image :icon "fas fa-box")',
|
||||
link="/products/banana/",
|
||||
title="Banana",
|
||||
@@ -183,19 +183,19 @@ class TestLinkCard:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sexp_async() — async rendering (no real I/O, just passthrough)
|
||||
# sx_async() — async rendering (no real I/O, just passthrough)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSexpAsync:
|
||||
class TestSxAsync:
|
||||
def test_simple(self):
|
||||
html = run(sexp_async('(div "Async")'))
|
||||
html = run(sx_async('(div "Async")'))
|
||||
assert html == "<div>Async</div>"
|
||||
|
||||
def test_with_kwargs(self):
|
||||
html = run(sexp_async('(p name)', name="Alice"))
|
||||
html = run(sx_async('(p name)', name="Alice"))
|
||||
assert html == "<p>Alice</p>"
|
||||
|
||||
def test_with_component(self):
|
||||
register_components('(defcomp ~x (&key v) (b v))')
|
||||
html = run(sexp_async('(~x :v "OK")'))
|
||||
html = run(sx_async('(~x :v "OK")'))
|
||||
assert html == "<b>OK</b>"
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Verify every .sexpr and .sexp file in the repo parses without errors."""
|
||||
"""Verify every .sx and .sx file in the repo parses without errors."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from shared.sexp.parser import parse_all
|
||||
from shared.sx.parser import parse_all
|
||||
|
||||
|
||||
def _collect_sexp_files():
|
||||
"""Find all .sexpr and .sexp files under the repo root."""
|
||||
def _collect_sx_files():
|
||||
"""Find all .sx and .sx files under the repo root."""
|
||||
repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)
|
||||
))))
|
||||
@@ -16,19 +16,19 @@ def _collect_sexp_files():
|
||||
if "node_modules" in dirpath or ".git" in dirpath or "artdag" in dirpath:
|
||||
continue
|
||||
for fn in filenames:
|
||||
if fn.endswith((".sexpr", ".sexp")):
|
||||
if fn.endswith((".sx", ".sx")):
|
||||
files.append(os.path.join(dirpath, fn))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
_SEXP_FILES = _collect_sexp_files()
|
||||
_SX_FILES = _collect_sx_files()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _SEXP_FILES, ids=[
|
||||
os.path.relpath(p) for p in _SEXP_FILES
|
||||
@pytest.mark.parametrize("path", _SX_FILES, ids=[
|
||||
os.path.relpath(p) for p in _SX_FILES
|
||||
])
|
||||
def test_parse(path):
|
||||
"""Each sexp file should parse without errors."""
|
||||
"""Each sx file should parse without errors."""
|
||||
with open(path) as f:
|
||||
source = f.read()
|
||||
exprs = parse_all(source)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for the s-expression parser."""
|
||||
|
||||
import pytest
|
||||
from shared.sexp.parser import parse, parse_all, serialize, ParseError
|
||||
from shared.sexp.types import Symbol, Keyword, NIL
|
||||
from shared.sx.parser import parse, parse_all, serialize, ParseError
|
||||
from shared.sx.types import Symbol, Keyword, NIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sexp.evaluator import evaluate, EvalError
|
||||
from shared.sexp.parser import parse
|
||||
from shared.sexp.relations import (
|
||||
from shared.sx.evaluator import evaluate, EvalError
|
||||
from shared.sx.parser import parse
|
||||
from shared.sx.relations import (
|
||||
_RELATION_REGISTRY,
|
||||
clear_registry,
|
||||
get_relation,
|
||||
@@ -13,7 +13,7 @@ from shared.sexp.relations import (
|
||||
relations_to,
|
||||
all_relations,
|
||||
)
|
||||
from shared.sexp.types import RelationDef
|
||||
from shared.sx.types import RelationDef
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -8,9 +8,9 @@ import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from shared.sexp import parse, evaluate
|
||||
from shared.sexp.resolver import resolve, _collect_io, _IONode
|
||||
from shared.sexp.primitives_io import RequestContext, execute_io
|
||||
from shared.sx import parse, evaluate
|
||||
from shared.sx.resolver import resolve, _collect_io, _IONode
|
||||
from shared.sx.primitives_io import RequestContext, execute_io
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -57,7 +57,7 @@ def mock_io(**responses):
|
||||
return val(args, kwargs, ctx)
|
||||
return val
|
||||
|
||||
return patch("shared.sexp.resolver.execute_io", side_effect=side_effect)
|
||||
return patch("shared.sx.resolver.execute_io", side_effect=side_effect)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test sexp.js string renderer matches Python renderer output.
|
||||
"""Test sx.js string renderer matches Python renderer output.
|
||||
|
||||
Runs sexp.js through Node.js and compares output with Python.
|
||||
Runs sx.js through Node.js and compares output with Python.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,21 +10,21 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sexp.parser import parse, parse_all
|
||||
from shared.sexp.html import render as py_render
|
||||
from shared.sexp.evaluator import evaluate
|
||||
from shared.sx.parser import parse, parse_all
|
||||
from shared.sx.html import render as py_render
|
||||
from shared.sx.evaluator import evaluate
|
||||
|
||||
SEXP_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sexp.js"
|
||||
SX_JS = Path(__file__).resolve().parents[2] / "static" / "scripts" / "sx.js"
|
||||
|
||||
|
||||
def _js_render(sexp_text: str, components_text: str = "") -> str:
|
||||
"""Run sexp.js in Node and return the renderToString result."""
|
||||
def _js_render(sx_text: str, components_text: str = "") -> str:
|
||||
"""Run sx.js in Node and return the renderToString result."""
|
||||
# Build a small Node script
|
||||
script = f"""
|
||||
global.document = undefined; // no DOM needed for string render
|
||||
{SEXP_JS.read_text()}
|
||||
if ({json.dumps(components_text)}) Sexp.loadComponents({json.dumps(components_text)});
|
||||
var result = Sexp.renderToString({json.dumps(sexp_text)});
|
||||
{SX_JS.read_text()}
|
||||
if ({json.dumps(components_text)}) Sx.loadComponents({json.dumps(components_text)});
|
||||
var result = Sx.renderToString({json.dumps(sx_text)});
|
||||
process.stdout.write(result);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -134,15 +134,15 @@ class TestComponents:
|
||||
|
||||
|
||||
class TestClientComponentsTag:
|
||||
"""client_components_tag() generates valid sexp for JS consumption."""
|
||||
"""client_components_tag() generates valid sx for JS consumption."""
|
||||
|
||||
def test_emits_script_tag(self):
|
||||
from shared.sexp.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
|
||||
from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
|
||||
# Register a test component
|
||||
register_components('(defcomp ~test-cct (&key label) (span label))')
|
||||
try:
|
||||
tag = client_components_tag("test-cct")
|
||||
assert tag.startswith('<script type="text/sexp" data-components>')
|
||||
assert tag.startswith('<script type="text/sx" data-components>')
|
||||
assert tag.endswith('</script>')
|
||||
assert "defcomp ~test-cct" in tag
|
||||
finally:
|
||||
@@ -150,13 +150,13 @@ class TestClientComponentsTag:
|
||||
|
||||
def test_roundtrip_through_js(self):
|
||||
"""Component emitted by client_components_tag renders identically in JS."""
|
||||
from shared.sexp.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
|
||||
from shared.sx.jinja_bridge import client_components_tag, register_components, _COMPONENT_ENV
|
||||
register_components('(defcomp ~test-rt (&key title) (div :class "rt" title))')
|
||||
try:
|
||||
tag = client_components_tag("test-rt")
|
||||
# Extract the sexp source from the script tag
|
||||
sexp_source = tag.replace('<script type="text/sexp" data-components>', '').replace('</script>', '')
|
||||
js_html = _js_render('(~test-rt :title "hello")', sexp_source)
|
||||
# Extract the sx source from the script tag
|
||||
sx_source = tag.replace('<script type="text/sx" data-components>', '').replace('</script>', '')
|
||||
js_html = _js_render('(~test-rt :title "hello")', sx_source)
|
||||
py_html = py_render(parse('(~test-rt :title "hello")'), _COMPONENT_ENV)
|
||||
assert js_html == py_html
|
||||
finally:
|
||||
@@ -179,11 +179,11 @@ class TestPythonParity:
|
||||
'(table (tr (td "cell")))',
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("sexp_text", CASES)
|
||||
def test_matches_python(self, sexp_text):
|
||||
py_html = py_render(parse(sexp_text))
|
||||
js_html = _js_render(sexp_text)
|
||||
assert js_html == py_html, f"Mismatch for {sexp_text!r}:\n PY: {py_html!r}\n JS: {js_html!r}"
|
||||
@pytest.mark.parametrize("sx_text", CASES)
|
||||
def test_matches_python(self, sx_text):
|
||||
py_html = py_render(parse(sx_text))
|
||||
js_html = _js_render(sx_text)
|
||||
assert js_html == py_html, f"Mismatch for {sx_text!r}:\n PY: {py_html!r}\n JS: {js_html!r}"
|
||||
|
||||
COMP_CASES = [
|
||||
(
|
||||
@@ -74,12 +74,12 @@ class TestFreeze:
|
||||
"blog": "https://blog.rose-ash.com",
|
||||
"market": "https://market.rose-ash.com",
|
||||
},
|
||||
"features": ["sexp", "federation"],
|
||||
"features": ["sx", "federation"],
|
||||
"limits": {"max_upload": 10485760},
|
||||
}
|
||||
frozen = _freeze(raw)
|
||||
assert frozen["app_urls"]["blog"] == "https://blog.rose-ash.com"
|
||||
assert frozen["features"] == ("sexp", "federation")
|
||||
assert frozen["features"] == ("sx", "federation")
|
||||
with pytest.raises(TypeError):
|
||||
frozen["app_urls"]["blog"] = "changed"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for the render() function and component loading in jinja_bridge.
|
||||
|
||||
These test functionality added in recent commits (render() API,
|
||||
load_sexp_dir, snake→kebab conversion) that isn't covered by the existing
|
||||
shared/sexp/tests/test_jinja_bridge.py.
|
||||
load_sx_dir, snake→kebab conversion) that isn't covered by the existing
|
||||
shared/sx/tests/test_jinja_bridge.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,10 +11,10 @@ import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sexp.jinja_bridge import (
|
||||
from shared.sx.jinja_bridge import (
|
||||
render,
|
||||
register_components,
|
||||
load_sexp_dir,
|
||||
load_sx_dir,
|
||||
_COMPONENT_ENV,
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestRender:
|
||||
assert render("pill", text="Hi") == render("~pill", text="Hi")
|
||||
|
||||
def test_snake_to_kebab_conversion(self):
|
||||
"""Python snake_case kwargs should map to sexp kebab-case params."""
|
||||
"""Python snake_case kwargs should map to sx kebab-case params."""
|
||||
register_components('''
|
||||
(defcomp ~card (&key nav-html link-href)
|
||||
(div :class "card" (a :href link-href nav-html)))
|
||||
@@ -95,52 +95,52 @@ class TestRender:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_sexp_dir
|
||||
# load_sx_dir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadSexpDir:
|
||||
def test_loads_sexp_files(self):
|
||||
class TestLoadSxDir:
|
||||
def test_loads_sx_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Write a .sexp file
|
||||
with open(os.path.join(tmpdir, "components.sexp"), "w") as f:
|
||||
# Write a .sx file
|
||||
with open(os.path.join(tmpdir, "components.sx"), "w") as f:
|
||||
f.write('(defcomp ~test-comp (&key msg) (div msg))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
load_sx_dir(tmpdir)
|
||||
html = render("test-comp", msg="loaded!")
|
||||
assert html == "<div>loaded!</div>"
|
||||
|
||||
def test_loads_sexpr_files(self):
|
||||
def test_loads_sx_files_alt(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "nav.sexpr"), "w") as f:
|
||||
with open(os.path.join(tmpdir, "nav.sx"), "w") as f:
|
||||
f.write('(defcomp ~nav-item (&key href label) (a :href href label))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
load_sx_dir(tmpdir)
|
||||
html = render("nav-item", href="/about", label="About")
|
||||
assert 'href="/about"' in html
|
||||
|
||||
def test_loads_multiple_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "a.sexp"), "w") as f:
|
||||
with open(os.path.join(tmpdir, "a.sx"), "w") as f:
|
||||
f.write('(defcomp ~comp-a (&key x) (b x))')
|
||||
with open(os.path.join(tmpdir, "b.sexp"), "w") as f:
|
||||
with open(os.path.join(tmpdir, "b.sx"), "w") as f:
|
||||
f.write('(defcomp ~comp-b (&key y) (i y))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
load_sx_dir(tmpdir)
|
||||
assert render("comp-a", x="A") == "<b>A</b>"
|
||||
assert render("comp-b", y="B") == "<i>B</i>"
|
||||
|
||||
def test_empty_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
load_sexp_dir(tmpdir) # should not raise
|
||||
load_sx_dir(tmpdir) # should not raise
|
||||
|
||||
def test_ignores_non_sexp_files(self):
|
||||
def test_ignores_non_sx_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
|
||||
f.write("not a sexp file")
|
||||
with open(os.path.join(tmpdir, "comp.sexp"), "w") as f:
|
||||
f.write("not a sx file")
|
||||
with open(os.path.join(tmpdir, "comp.sx"), "w") as f:
|
||||
f.write('(defcomp ~real (&key v) (span v))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
load_sx_dir(tmpdir)
|
||||
assert "~real" in _COMPONENT_ENV
|
||||
# txt file should not have been loaded
|
||||
assert len([k for k in _COMPONENT_ENV if k.startswith("~")]) == 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for shared sexp helper functions (call_url, get_asset_url, etc.)."""
|
||||
"""Tests for shared sx helper functions (call_url, get_asset_url, etc.)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sexp.helpers import call_url, get_asset_url
|
||||
from shared.sx.helpers import call_url, get_asset_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Reference in New Issue
Block a user