diff --git a/shared/sexp/jinja_bridge.py b/shared/sexp/jinja_bridge.py
index fd62049..0d9b076 100644
--- a/shared/sexp/jinja_bridge.py
+++ b/shared/sexp/jinja_bridge.py
@@ -201,6 +201,40 @@ def _get_request_context():
# Quart integration
# ---------------------------------------------------------------------------
+def client_components_tag(*names: str) -> str:
+ """Emit a '
+
+
def setup_sexp_bridge(app: Any) -> None:
"""Register s-expression helpers with a Quart app's Jinja environment.
diff --git a/shared/sexp/templates/layout.sexp b/shared/sexp/templates/layout.sexp
index 416c640..42924f3 100644
--- a/shared/sexp/templates/layout.sexp
+++ b/shared/sexp/templates/layout.sexp
@@ -70,6 +70,7 @@
(when content-html (raw! content-html))
(div :class "pb-8"))))))
(when body-end-html (raw! body-end-html))
+ (script :src (str asset-url "/scripts/sexp.js"))
(script :src (str asset-url "/scripts/body.js")))))))
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
diff --git a/shared/sexp/tests/test_sexp_js.py b/shared/sexp/tests/test_sexp_js.py
index a783116..c2e4fd9 100644
--- a/shared/sexp/tests/test_sexp_js.py
+++ b/shared/sexp/tests/test_sexp_js.py
@@ -133,6 +133,36 @@ class TestComponents:
assert html == '
hi
'
+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('')
+ 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('', '')
+ 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:
"""JS string renderer matches Python renderer output."""
diff --git a/shared/static/scripts/sexp.js b/shared/static/scripts/sexp.js
index 8a8a991..3537af6 100644
--- a/shared/static/scripts/sexp.js
+++ b/shared/static/scripts/sexp.js
@@ -1141,6 +1141,54 @@
isTruthy: isSexpTruthy,
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