Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/starfederation/datastar/clojure/expressions.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))
80 changes: 65 additions & 15 deletions src/starfederation/datastar/clojure/expressions/internal.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -142,23 +144,23 @@

(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)
(str/replace #"\n" " ")
(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"
Expand Down Expand Up @@ -213,13 +215,37 @@
(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
process-interpolation
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})

Expand All @@ -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))}]))
;; => "<div data-on-whatever=\"$signal = evt.target.value\"></div>"


)
76 changes: 40 additions & 36 deletions test/starfederation/datastar/clojure/expressions_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,64 @@
(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"})

(deftest test-basic-assignments
(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"))))))
Expand All @@ -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")
Expand All @@ -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*)
)
Loading