datastar-expressions
Clojure to Datastar expression transpiler
expressions is a proof-of-concept for writing 🚀
datastar expressions using Clojure without manual string concatenation.
Instead of:
[:button {:data-on-click (str "$person-id" (:id person) " @post('/update-person')" )}]`
Write this:
[:button {:data-on-click (->js
(set! $person-id ~ (:id person))
(@ post "/update-person" ))}]
Use →js when you want a composable expression object that stringifies to
JavaScript, which is ideal for Hiccup attributes. Use →js-str when you need
an immediate string.
Project status: Experimental.
Goal & Non-Goals
Since Clojure does not have string interpolation, writing even simple
Datastar (d*)
expressions can involve a lot of str or format gymnastics.
The goal of expressions is to add a little bit of syntax sugar when
writing d* expressions so that they can be read and manipulated as
s-expressions.
D* expressions are not exactly javascript, though they are interpreted by the js runtime. D* expressions also do not have a grammar or really any formal definition of any kind. Delaney’s official position is that the simplest and obvious expressions a human would write should work.
expressions follows that by trying to provide coverage for 99% of
simple and obvious expressions.
⚠️ You can totally write expressions that result in broken javascript, that is not necessarily a bug.
Install
datastar/expressions {:git/url "https://github.com/outskirtslabs/datastar-expressions/"
:git/sha "431b9a007c07346792ff30d806845610b799e279" }
Status
expressions is experimental and breaking changes will occur as it is
actively being developed. Please share your feedback so we can squash
bugs and arrive at a stable release.
REPL Exploration
To see what this is all about, you can clone this repo and play with the demos:
clojure -M:dev ;; (bring your own repl server)
Check out dev/user.clj and
dev/demo.clj
Composing Expressions
→js returns a small expression object. It stringifies to JavaScript, but it
keeps the original Clojure form so you can unquote it into another expression
without turning generated JavaScript into a string literal.
(ns user
(:require [starfederation.datastar.clojure.expressions :refer [->js ->js-str]]))
;; Reuse a DOM expression in another expression.
(let [input-value (->js (.. evt -target -value))]
(str (->js (set! $search ~ input-value))))
;; => "$search = evt.target.value"
;; Reuse a predicate in a larger expression.
(let [enter? (->js (= evt.key "Enter" ))]
(str (->js (when ~ enter? (@ post "/search" )))))
;; => "(((evt.key === \"Enter\")) ? ((@post(\"/search\"))) : (null))"
;; Build expressions in small named steps.
(let [input-value (->js (.. evt -target -value))
trimmed (->js (.trim ~ input-value))]
(str (->js (set! $search ~ trimmed))))
;; => "$search = evt.target.value.trim()"
;; Plain strings still work, but they compose as string values.
(let [input-value (->js (.. evt -target -value))]
(->js-str (set! $search ~ (str input-value))))
;; => "$search = \"evt.target.value\""
Example Usage
(ns user
(:require [starfederation.datastar.clojure.expressions :refer [->js ->js-str]]))
;; Samples
;; These examples use ->js-str so the REPL prints the generated JavaScript.
;; Prefer ->js for Hiccup attributes and for composing expressions.
(def record {:record-id "1234" })
;; You have to unquote (~) forms you want evaluated
;; Otherwise no quoting is needed!
;; vars and locals are available for evaluation
(let [val 42 ]
(->js-str
(set! $forty-two ~ val)))
;; => "$forty-two = 42"
(let [val (random-uuid)]
(->js-str
(set! $forty-two ~ (str val))))
;; => "$forty-two = \"745a9225-890f-41a7-9fc4-008770a68e7e\""
;; kebab case preservation
(->js-str
(set! $record-id ~ (:record-id record)))
;; => "$record-id = \"1234\""
;; actually... all case preservation :)
(->js-str
(set! $record_id ~ (:record-id record)))
;; => "$record_id = \"1234\""
(->js-str
(set! $recordId ~ (:record-id record)))
;; => "$recordId = \"1234\""
;; namespaced signals work of course
(->js-str
(set! $person.first-name "alice" ))
;; => "$person.first-name = \"alice\""
;; primitive functions work too (squint adds parens, but its ok)
(let [val 1 ]
(->js-str
(set! $forty-two (+ ~ val $forty-one))))
;; => "$forty-two = (1 + $forty-one)"
;; calling js functions:
(->js-str (pokeBear $bear-id))
;; => "pokeBear($bear-id)"
;; actions
(->js-str (@ get "/poke" ))
;; => "@get(\"/poke\")"
(->js-str (@ patch "/poke" ))
;; => "@patch(\"/poke\")"
;; expr with multiple statements are in order like you would expect
(->js-str
(set! $bear-id 1234 )
(pokeBear $bear-id)
(@ post "/bear-poked" ))
;; => "$bear-id = 1234; pokeBear($bear-id); @post(\"/bear-poked\")"
;; You can build dynamic signal names by using the $signal in the first position
(let [field-name "name" ]
(->js-str
(set! ($bear. ~ field-name) "Yogi" )
(@ post "/bear" )))
;; => "$bear.name = \"Yogi\"; @post(\"/bear\")"
;; logical conjunctions and disjunctions
(->js-str (and (= $my-signal "bar" )
"ret-val" ))
;; => "(($my-signal === \"bar\")) && (\"ret-val\")"
;; But you should probably use when/if
(->js-str (when (= $my-signal "bar" )
"ret-val" ))
;; => "((($my-signal === \"bar\")) ? ((\"ret-val\")) : (null))"
(->js-str (if (= $my-signal "bar" )
"true-val"
"false-val" ))
;; => "((($my-signal === \"bar\")) ? (\"true-val\") : (\"false-val\"))"
;; A few other variations
(->js-str (&& (or (= evt.key "Enter" )
(&& evt.ctrlKey (= evt.key "1" )))
(alert "Key Pressed" )))
;; => "(((evt.key === \"Enter\")) || ((evt.ctrlKey) && ((evt.key === \"1\")))) && (alert(\"Key Pressed\"))"
;; This one is interesting, see how it uses the , operator to separate sub-expressions
(->js-str (when (= evt.key "Enter" )
(evt.preventDefault)
(alert "Key Pressed" )))
;; => "(((evt.key === \"Enter\")) ? ((evt.preventDefault()), (alert(\"Key Pressed\"))) : (null))"
;; And here is one for data-class
(->js-str {"hidden" (&& $fetching-bears
(= $bear-id 1 ))})
;; => "({\"hidden\": ($fetching-bears) && (($bear-id === 1))})"
;; It also does edn->json conversion, so setting initial signals is possible
(->js-str {:my-signal "init-value" })
;; => "({\"my-signal\": \"init-value\"})"
(->js-str
(let [value $my-signal]
(println value)
(and (= $my-signal "bear" )
(@ post "/foo" ))))
;; => "(() => { const value1 = $my-signal; console.log((value1)); return (($my-signal === \"bear\")) && (@post(\"/foo\")); })()"
;; JS template strings are supported
;; Since ` is used by the reader, we just wrap the whole thing in quotes
(->js-str
(@ post ("`/ping/${evt.srcElement.id}`" )))
;; => "@post(`/ping/${evt.srcElement.id}`)"
;; Negation
(->js-str (not $foo))
;; => "(!($foo))"
(->js-str (not (= 1 2 )))
;; => "(!((1 === 2)))"
(->js-str (not= (+ 1 3 ) 4 ))
;; => "(!(((1 + 3) === 4)))"
(->js-str (set! $ui._leftnavOpen (not $ui._leftnavOpen)))
;; => "$ui._leftnavOpen = (!($ui._leftnavOpen))"
;; if
(->js-str (set! $ui._leftnavOpen (if $ui._leftnavOpen false true )))
;; => "$ui._leftnavOpen = (($ui._leftnavOpen) ? (false) : (true))"
(->js-str (if $ui._leftnavOpen
(set! $ui._leftnavOpen false )
(set! $ui._leftnavOpen true )))
;; => "(($ui._leftnavOpen) ? ($ui._leftnavOpen = false) : ($ui._leftnavOpen = true))"
;; expr/raw is an escape hatch to emit raw JS
;; raw/1 emits its argument as is
(->js-str (set! $foo (expr/raw "!$foo" )))
;; => "$foo = !$foo"
(let [we-are "/back-in-string-concat-land" ]
(->js-str
(set! $volume 11 )
(expr/raw ~ (str "window.location = " we-are))))
;; => "$volume = 11; window.location = /back-in-string-concat-land"
;; raw/0 emits nothing
(->js-str (set! $foo (expr/raw)))
;; => "$foo ="
;; bare symbols
(->js-str $ui._mainMenuOpen)
;; => "$ui._mainMenuOpen"
;; when-not
(->js-str (when-not (= 1 1 )
(set! $ui._mainMenuOpen true )))
;; => "(((1 === 1)) ? (null) : ($ui._mainMenuOpen = true))"
;; bare booleans
(->js-str (when false
(set! $foo true )))
;; => "((false) ? (($foo = true)) : (null))"
Known Limitations
;; a generated symbol (el-id below) cannot be used in a template string
(->js-str (let [el-id evt.srcElement.id]
(when el-id
(@ post ("`/ping/${el-id}`" )))))
;; => "(() => { const el_id1 = evt.srcElement.id; if (el_id1) { return (@post(`/ping/${el-id}`))}; })()"
;; No condp: Squint emits a larger helper expression and references squint_core.
(->js-str (condp = $ui._mainMenuOpen
true (set! $ui._mainMenuOpen false )
false (set! $ui._mainMenuOpen true )))
License: MIT License
Copyright © 2025 Casey Link casey@outskirtslabs.com
Distributed under the MIT.