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