Rebrand sexp → sx across web platform (173 files)
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:
2026-03-01 11:06:57 +00:00
parent 17cebe07e7
commit e8bc228c7f
174 changed files with 3126 additions and 2952 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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") {

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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"

View File

@@ -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")',

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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 "&lt;script&gt;" 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>"

View File

@@ -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)

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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)

View File

@@ -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)
# ---------------------------------------------------------------------------

View File

@@ -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 = [
(

View File

@@ -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"

View File

@@ -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

View File

@@ -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
# ---------------------------------------------------------------------------