datastar-expressions

Clojure to Datastar expression transpiler

doc status: experimental nixbot

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.

It is powered by squint, thanks @borkdude.

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.