Wire sexp.js into page template with auto-init and HTMX integration
- Load sexp.js in ~app-layout before body.js - Auto-process <script type="text/sexp"> tags on DOMContentLoaded - Re-process after htmx:afterSwap for dynamic content - Sexp.mount(target, expr, env) for rendering into DOM elements - Sexp.processScripts() picks up data-components and data-mount tags - client_components_tag() Python helper serializes Component objects back to sexp source for client-side consumption - 37 parity tests all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,40 @@ def _get_request_context():
|
|||||||
# Quart integration
|
# Quart integration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def client_components_tag(*names: str) -> str:
|
||||||
|
"""Emit a <script type="text/sexp"> 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.
|
||||||
|
|
||||||
|
Usage in Python::
|
||||||
|
|
||||||
|
body_end_html = client_components_tag("test-filter-card", "test-row")
|
||||||
|
|
||||||
|
Or send all loaded components::
|
||||||
|
|
||||||
|
body_end_html = client_components_tag()
|
||||||
|
"""
|
||||||
|
from .parser import serialize
|
||||||
|
parts = []
|
||||||
|
for key, val in _COMPONENT_ENV.items():
|
||||||
|
if not isinstance(val, Component):
|
||||||
|
continue
|
||||||
|
if names and val.name not in names and key.lstrip("~") not in names:
|
||||||
|
continue
|
||||||
|
# Reconstruct defcomp source from the Component object
|
||||||
|
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})")
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
source = "\n".join(parts)
|
||||||
|
return f'<script type="text/sexp" data-components>{source}</script>'
|
||||||
|
|
||||||
|
|
||||||
def setup_sexp_bridge(app: Any) -> None:
|
def setup_sexp_bridge(app: Any) -> None:
|
||||||
"""Register s-expression helpers with a Quart app's Jinja environment.
|
"""Register s-expression helpers with a Quart app's Jinja environment.
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
(when content-html (raw! content-html))
|
(when content-html (raw! content-html))
|
||||||
(div :class "pb-8"))))))
|
(div :class "pb-8"))))))
|
||||||
(when body-end-html (raw! body-end-html))
|
(when body-end-html (raw! body-end-html))
|
||||||
|
(script :src (str asset-url "/scripts/sexp.js"))
|
||||||
(script :src (str asset-url "/scripts/body.js")))))))
|
(script :src (str asset-url "/scripts/body.js")))))))
|
||||||
|
|
||||||
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
|
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
|
||||||
|
|||||||
@@ -133,6 +133,36 @@ class TestComponents:
|
|||||||
assert html == '<div><span>hi</span></div>'
|
assert html == '<div><span>hi</span></div>'
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientComponentsTag:
|
||||||
|
"""client_components_tag() generates valid sexp for JS consumption."""
|
||||||
|
|
||||||
|
def test_emits_script_tag(self):
|
||||||
|
from shared.sexp.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.endswith('</script>')
|
||||||
|
assert "defcomp ~test-cct" in tag
|
||||||
|
finally:
|
||||||
|
_COMPONENT_ENV.pop("~test-cct", None)
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
py_html = py_render(parse('(~test-rt :title "hello")'), _COMPONENT_ENV)
|
||||||
|
assert js_html == py_html
|
||||||
|
finally:
|
||||||
|
_COMPONENT_ENV.pop("~test-rt", None)
|
||||||
|
|
||||||
|
|
||||||
class TestPythonParity:
|
class TestPythonParity:
|
||||||
"""JS string renderer matches Python renderer output."""
|
"""JS string renderer matches Python renderer output."""
|
||||||
|
|
||||||
|
|||||||
@@ -1141,6 +1141,54 @@
|
|||||||
isTruthy: isSexpTruthy,
|
isTruthy: isSexpTruthy,
|
||||||
isNil: isNil,
|
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: function (target, exprOrText, extraEnv) {
|
||||||
|
var el = typeof target === "string" ? document.querySelector(target) : target;
|
||||||
|
if (!el) return;
|
||||||
|
var node = Sexp.render(exprOrText, extraEnv);
|
||||||
|
el.textContent = "";
|
||||||
|
el.appendChild(node);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all <script type="text/sexp"> 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"]');
|
||||||
|
for (var i = 0; i < scripts.length; i++) {
|
||||||
|
var s = scripts[i];
|
||||||
|
if (s._sexpProcessed) continue;
|
||||||
|
s._sexpProcessed = true;
|
||||||
|
|
||||||
|
var text = s.textContent;
|
||||||
|
if (!text || !text.trim()) continue;
|
||||||
|
|
||||||
|
// data-components: load as component definitions
|
||||||
|
if (s.hasAttribute("data-components")) {
|
||||||
|
Sexp.loadComponents(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// data-mount="<selector>": render into target
|
||||||
|
var mountSel = s.getAttribute("data-mount");
|
||||||
|
if (mountSel) {
|
||||||
|
var target = document.querySelector(mountSel);
|
||||||
|
if (target) Sexp.mount(target, text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: load as components
|
||||||
|
Sexp.loadComponents(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// For testing
|
// For testing
|
||||||
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
|
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
|
||||||
_eval: sexpEval,
|
_eval: sexpEval,
|
||||||
@@ -1150,4 +1198,23 @@
|
|||||||
|
|
||||||
global.Sexp = Sexp;
|
global.Sexp = Sexp;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Auto-init in browser
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
// Process sexp scripts on DOMContentLoaded
|
||||||
|
function init() { Sexp.processScripts(); }
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-process after HTMX swaps (new sexp scripts may have arrived)
|
||||||
|
document.addEventListener("htmx:afterSwap", function (e) {
|
||||||
|
Sexp.processScripts(e.detail.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
})(typeof window !== "undefined" ? window : this);
|
})(typeof window !== "undefined" ? window : this);
|
||||||
|
|||||||
Reference in New Issue
Block a user