diff --git a/src/starfederation/datastar/clojure/expressions.clj b/src/starfederation/datastar/clojure/expressions.clj index c10d28c..5ff4d79 100644 --- a/src/starfederation/datastar/clojure/expressions.clj +++ b/src/starfederation/datastar/clojure/expressions.clj @@ -2,14 +2,26 @@ ;; SPDX-License-Identifier: MIT (ns starfederation.datastar.clojure.expressions (:require - [backtick :refer [template]] [starfederation.datastar.clojure.expressions.internal :as impl])) -(defmacro ->expr +(defmacro ->js "Compiles a clojure form into a datastar expression. + Returns an object supporting Object.toString (or just `str`). These objects compose: - TODO docs " + (let [x (->js (.. evt -target -value))] + (str (->js (set! $signal ~x)))) + ;; => $signal = evt.target.value" [& forms] - (let [processed-form (impl/pre-process forms)] - #_(tap> `(template ~processed-form)) - `(impl/compile (template ~processed-form)))) + `(impl/d*js ~@forms)) + +(defmacro ->js-str + "Compiles a clojure form into a datastar expression, as a string. + These strings can be difficult to compose: + + (let [x (->js-str (.. evt -target -value))] + (->js-str (set! $signal ~x))) + ;; => $signal = \"evt.target.value\" ;; Note incorrectly quoted js expression. + + Where this matters, prefer `->js`." + [& forms] + `(impl/d*js-str ~@forms)) diff --git a/src/starfederation/datastar/clojure/expressions/internal.clj b/src/starfederation/datastar/clojure/expressions/internal.clj index b0bc14d..4312424 100644 --- a/src/starfederation/datastar/clojure/expressions/internal.clj +++ b/src/starfederation/datastar/clojure/expressions/internal.clj @@ -5,7 +5,8 @@ (:require [clojure.string :as str] [clojure.walk :as clojure.walk] - [squint.compiler :as squint])) + [squint.compiler :as squint] + [backtick :refer [template]])) (defn bool-expr [e] (if (boolean? e) @@ -94,10 +95,11 @@ (map name)) (tree-seq coll? seq form))) -(= #{"$record-id" "$foo-bar" "$red-panda"} - (collect-kebab-signals - `(do (set! ~'$record-id ~collect-kebab-signals) - (~'@post "/defile-record" {:wut (+ $red-panda ~'$foo-bar)})))) +(comment + (= #{"$record-id" "$foo-bar" "$red-panda"} + (collect-kebab-signals + `(do (set! ~'$record-id ~collect-kebab-signals) + (~'@post "/defile-record" {:wut (+ $red-panda ~'$foo-bar)}))))) (defn restore-signals-casing "Post-processes compiled js to preserve the original casing of kebab cased signals @@ -142,11 +144,12 @@ (defn js* [form] (-> - (squint.compiler/compile-string (str form) {:elide-imports true - :elide-exports true - :top-level false - :context :expr - :macros compiler-macro-options}) + (squint/compile-string (pr-str form) + {:elide-imports true + :elide-exports true + :top-level false + :context :expr + :macros compiler-macro-options}) (replace-deref) (replace-truth) (restore-signals-casing form) @@ -154,11 +157,10 @@ (str/trim))) (defn compile [forms] - (str/join "; " - (map js* - (if (sequential? forms) - forms - (list forms))))) + (->> forms + (remove (fn [x] (= x 'do))) + (map js*) + (str/join "; "))) (defn process-string-concat "This function converts forms whose head is a symbol starting with $ into string concatenation forms" @@ -213,6 +215,23 @@ (cons 'expr/js-template node) node)) form)) +(defprotocol IJSExpression + (clj [this] "Get clojure form of expression") + (js [this] "Get js form of expression")) + +(deftype JSExpression [clj-form] + Object + (toString [this] (js this)) + IJSExpression + (clj [_this] clj-form) + (js [this] (compile (clj this)))) + +(defmethod print-method JSExpression [v w] + (.write w (pr-str (clj v)))) + +(defmethod print-dup JSExpression [v w] + (.write w (pr-str (clj v)))) + (defn pre-process [forms] (-> forms process-not-equals @@ -220,6 +239,13 @@ process-string-concat process-macros)) +(defmacro d*js [& forms] + (let [processed-form (map pre-process forms)] + `(->JSExpression (template (do ~@processed-form))))) + +(defmacro d*js-str [& forms] + `(str (d*js ~@forms))) + (comment (def record {:record-id 1234}) @@ -231,4 +257,28 @@ (let [thing 1234] (process-string-concat `(do ($wut ~thing "bar")))) ;; => (do (clojure.core/unquote (clojure.core/symbol (str "$wut" 1234 "bar")))) + + + ;; basic stuff still works: + (str (d*js (set! $signal 55))) ;; => "$signal = 55" + + ;; Complex stuff works: + (def my-d*js (let [x (d*js (.. evt -target -value)) + num 55 + y (d*js (+ ~num ~x))] + (d*js (set! $signal ~y) + (set! $other-signal "no")))) + (clj my-d*js) ;; => (do (set! $signal (do (+ 55 (do (.. evt -target -value))))) (set! $other-signal "no")) + (js my-d*js) ;; => "$signal = (55) + (evt.target.value); $other-signal = \"no\"" + (str my-d*js) ;; => "$signal = (55) + (evt.target.value); $other-signal = \"no\"" + (pr-str my-d*js) ;; => "(do (set! $signal (do (+ 55 (do (.. evt -target -value))))) (set! $other-signal \"no\"))" + + + ;; Works w/ chassis: + (require '[dev.onionpancakes.chassis.core :as chassis]) + (let [x (d*js (.. evt -target -value))] + (chassis/html [:div {:data-on-whatever (d*js (set! $signal ~x))}])) + ;; => "
" + + ) diff --git a/test/starfederation/datastar/clojure/expressions_test.clj b/test/starfederation/datastar/clojure/expressions_test.clj index a6464d9..163da65 100644 --- a/test/starfederation/datastar/clojure/expressions_test.clj +++ b/test/starfederation/datastar/clojure/expressions_test.clj @@ -3,7 +3,7 @@ (ns starfederation.datastar.clojure.expressions-test (:require [clojure.test :refer [deftest is testing]] - [starfederation.datastar.clojure.expressions :refer [->expr]])) + [starfederation.datastar.clojure.expressions :refer [->js-str]])) (def record {:record-id "1234"}) @@ -11,56 +11,56 @@ (testing "basic value assignment with unquote" (let [val 42] (is (= "$forty-two = 42" - (->expr (set! $forty-two ~val)))))) + (->js-str (set! $forty-two ~val)))))) (testing "uuid assignment with stringification" (let [val (java.util.UUID/fromString "745a9225-890f-41a7-9fc4-008770a68e7e")] (is (= "$forty-two = \"745a9225-890f-41a7-9fc4-008770a68e7e\"" - (->expr (set! $forty-two ~(str val)))))))) + (->js-str (set! $forty-two ~(str val)))))))) (deftest test-case-preservation (testing "kebab case preservation" (is (= "$record-id = \"1234\"" - (->expr (set! $record-id ~(:record-id record)))))) + (->js-str (set! $record-id ~(:record-id record)))))) (testing "snake case preservation" (is (= "$record_id = \"1234\"" - (->expr (set! $record_id ~(:record-id record)))))) + (->js-str (set! $record_id ~(:record-id record)))))) (testing "camelCase preservation" (is (= "$recordId = \"1234\"" - (->expr (set! $recordId ~(:record-id record))))))) + (->js-str (set! $recordId ~(:record-id record))))))) (deftest test-namespaced-signals (testing "namespaced signal assignment" (is (= "$person.first-name = \"alice\"" - (->expr (set! $person.first-name "alice")))))) + (->js-str (set! $person.first-name "alice")))))) (deftest test-arithmetic-operations (testing "addition with unquoted value" (let [val 1] (is (= "$forty-two = (1) + ($forty-one)" - (->expr (set! $forty-two (+ ~val $forty-one)))))))) + (->js-str (set! $forty-two (+ ~val $forty-one)))))))) (deftest test-function-calls (testing "javascript function call" (is (= "pokeBear($bear-id)" - (->expr (pokeBear $bear-id)))))) + (->js-str (pokeBear $bear-id)))))) (deftest test-datastar-actions (testing "@get action" (is (= "@get(\"/poke\")" #_{:clj-kondo/ignore [:type-mismatch]} - (->expr (@get "/poke"))))) + (->js-str (@get "/poke"))))) (testing "@patch action" (is (= "@patch(\"/poke\")" - (->expr (@patch "/poke")))))) + (->js-str (@patch "/poke")))))) (deftest test-multiple-statements (testing "multiple statements in order" (is (= "$bear-id = 1234; pokeBear($bear-id); @post(\"/bear-poked\")" - (->expr + (->js-str (set! $bear-id 1234) (pokeBear $bear-id) (@post "/bear-poked")))))) @@ -69,54 +69,54 @@ (testing "dynamic signal name construction" (let [field-name "name"] (is (= "$bear.name = \"Yogi\"; @post(\"/bear\")" - (->expr + (->js-str (set! ($bear. ~field-name) "Yogi") (@post "/bear"))))))) (deftest test-logical-operations (testing "logical and" (is (= "(($my-signal) === (\"bar\")) && (\"ret-val\")" - (->expr (and (= $my-signal "bar") + (->js-str (and (= $my-signal "bar") "ret-val"))))) (testing "when expression" (is (= "((($my-signal) === (\"bar\")) ? ((\"ret-val\")) : (null))" - (->expr (when (= $my-signal "bar") + (->js-str (when (= $my-signal "bar") "ret-val"))))) (testing "if expression" (is (= "((($my-signal) === (\"bar\")) ? (\"true-val\") : (\"false-val\"))" - (->expr (if (= $my-signal "bar") + (->js-str (if (= $my-signal "bar") "true-val" "false-val"))))) (testing "complex logical operations" (is (= "(((evt.key) === (\"Enter\")) || ((evt.ctrlKey) && ((evt.key) === (\"1\")))) && (alert(\"Key Pressed\"))" - (->expr (&& (or (= evt.key "Enter") + (->js-str (&& (or (= evt.key "Enter") (&& evt.ctrlKey (= evt.key "1"))) (alert "Key Pressed"))))))) (deftest test-when-with-multiple-forms (testing "when with preventDefault and alert" (is (= "(((evt.key) === (\"Enter\")) ? ((evt.preventDefault()), (alert(\"Key Pressed\"))) : (null))" - (->expr (when (= evt.key "Enter") + (->js-str (when (= evt.key "Enter") (evt.preventDefault) (alert "Key Pressed"))))))) (deftest test-data-structures (testing "data-class object" (is (= "({ \"hidden\": ($fetching-bears) && (($bear-id) === (1)) })" - (->expr {"hidden" (&& $fetching-bears + (->js-str {"hidden" (&& $fetching-bears (= $bear-id 1))})))) (testing "edn to json conversion" (is (= "({ \"my-signal\": \"init-value\" })" - (->expr {:my-signal "init-value"}))))) + (->js-str {:my-signal "init-value"}))))) (deftest test-let-blocks (testing "let block with IIFE" (is (= "(() => { const value1 = $my-signal; console.log((value1)); return (($my-signal) === (\"bear\")) && (@post(\"/foo\")); })()" - (->expr + (->js-str (let [value $my-signal] (println value) (and (= $my-signal "bear") @@ -125,77 +125,81 @@ (deftest test-template-strings (testing "javascript template strings" (is (= "@post(\"`/ping/${evt.srcElement.id}`\")" - (->expr + (->js-str (@post "`/ping/${evt.srcElement.id}`")))))) (deftest test-negation (testing "simple negation" (is (= "(!($foo))" - (->expr (not $foo))))) + (->js-str (not $foo))))) (testing "negation of equality" (is (= "(!((1) === (2)))" - (->expr (not (= 1 2)))))) + (->js-str (not (= 1 2)))))) (testing "not equals" (is (= "(!(((1) + (3)) === (4)))" - (->expr (not= (+ 1 3) 4))))) + (->js-str (not= (+ 1 3) 4))))) (testing "not equals" (is (= "(((!(((1) + (3)) === (4)))) ? ((@post(\"/not-equal\"))) : (null))" - (->expr (when (not= (+ 1 3) 4) (@post "/not-equal")))))) + (->js-str (when (not= (+ 1 3) 4) (@post "/not-equal")))))) (testing "toggle with negation" (is (= "$ui._leftnavOpen = (!($ui._leftnavOpen))" - (->expr (set! $ui._leftnavOpen (not $ui._leftnavOpen))))))) + (->js-str (set! $ui._leftnavOpen (not $ui._leftnavOpen))))))) (deftest test-if-expressions (testing "if expression in assignment" (is (= "$ui._leftnavOpen = (($ui._leftnavOpen) ? (false) : (true))" - (->expr (set! $ui._leftnavOpen (if $ui._leftnavOpen false true)))))) + (->js-str (set! $ui._leftnavOpen (if $ui._leftnavOpen false true)))))) (testing "if with assignments in branches" (is (= "(($ui._leftnavOpen) ? ($ui._leftnavOpen = false) : ($ui._leftnavOpen = true))" - (->expr (if $ui._leftnavOpen + (->js-str (if $ui._leftnavOpen (set! $ui._leftnavOpen false) (set! $ui._leftnavOpen true))))))) (deftest test-expr-raw (testing "raw expression with single argument" (is (= "$foo = !$foo" - (->expr (set! $foo (expr/raw "!$foo")))))) + (->js-str (set! $foo (expr/raw "!$foo")))))) (testing "raw expression with multiple statements" (let [we-are "/back-in-string-concat-land"] (is (= "$volume = 11; window.location = /back-in-string-concat-land" - (->expr + (->js-str (set! $volume 11) (expr/raw ~(str "window.location = " we-are))))))) (testing "raw expression with no arguments" (is (= "$foo =" - (->expr (set! $foo (expr/raw))))))) + (->js-str (set! $foo (expr/raw))))))) (deftest test-bare-symbols (testing "bare symbol expression" (is (= "$ui._mainMenuOpen" - (->expr $ui._mainMenuOpen))))) + (->js-str $ui._mainMenuOpen))))) (deftest test-when-not (testing "when-not expression" (is (= "(((1) === (1)) ? (null) : ($ui._mainMenuOpen = true))" - (->expr (when-not (= 1 1) + (->js-str (when-not (= 1 1) (set! $ui._mainMenuOpen true))))))) (deftest test-bare-booleans (testing "when with bare boolean" (is (= "((!!(false)) ? (($foo = true)) : (null))" - (->expr (when false + (->js-str (when false (set! $foo true))))))) (deftest test-known-limitations (testing "generated symbol in template string" (is (= "(() => { const el_id1 = evt.srcElement.id; if (el_id1) { return (@post(\"`/ping/${el-id}`\"))}; })()" - (->expr (let [el-id evt.srcElement.id] + (->js-str (let [el-id evt.srcElement.id] (when el-id (@post "`/ping/${el-id}`")))))))) + +(comment + (clojure.test/test-ns *ns*) + )