Skip to content
Merged
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: 22 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,8 +603,9 @@ decides where code runs, not the syntax.
Compilation happens at macro-expansion time (via the same embedded
[Squint](https://github.com/squint-cljs/squint) compiler that powers
[client components](#client-components-web-components)) — runtime cost is
string interpolation of spliced values only. Output is dependency-free
JavaScript suitable for Datastar's sandboxed evaluator.
string interpolation of spliced values only. Output calls Squint's core
library through the `hyper_sc` global, which hyper serves at
`/hyper/squint-core.js`.

### Actions inside expressions

Expand Down Expand Up @@ -2043,6 +2044,25 @@ inside segments.
For self-hosted/air-gapped deploys, override the Squint runtime CDN URL
with the `:squint-core-url` option on `create-handler`.

### Tree shaking

Pass `:tree-shake? true` to `create-handler` to serve only the Squint core
functions that your `h/expr` expressions use, instead of all of
`/hyper/squint-core.js` (28 KB brotli-compressed):

```clojure
(h/create-handler #'routes :tree-shake? true)
```

Add esbuild to `deps.edn`:

```clojure
org.babashka/esbuild {:mvn/version "0.1.1"}
```

Add `--enable-native-access=ALL-UNNAMED` to your JVM options for esbuild.
Without esbuild on the classpath, `create-handler` throws.

### Editor indentation

`defc`'s `render`/`event`/`mount`/`update`/`unmount` segments are
Expand Down
34 changes: 19 additions & 15 deletions deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,30 @@
cheshire/cheshire {:mvn/version "6.1.0"}

;; Client-side component compilation (CLJS-dialect -> JS)
io.github.squint-cljs/squint {:mvn/version "0.14.203"}
io.github.squint-cljs/squint {:mvn/version "0.14.209"}

;; Logging
com.taoensso/telemere {:mvn/version "1.2.1"}}

:aliases
{:test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.3"}
lambdaisland/kaocha {:mvn/version "1.91.1392"}
nubank/matcher-combinators {:mvn/version "3.10.0"}
io.github.pfeodrippe/wally {:mvn/version "0.0.5"}}
:main-opts ["-m" "kaocha.runner"]
:exec-fn kaocha.runner/exec-fn
:exec-args {:randomize? false
:fail-fast? true}}
:dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"}}}
{:test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.3"}
lambdaisland/kaocha {:mvn/version "1.91.1392"}
nubank/matcher-combinators {:mvn/version "3.10.0"}
io.github.pfeodrippe/wally {:mvn/version "0.0.5"}}
:main-opts ["-m" "kaocha.runner"]
:exec-fn kaocha.runner/exec-fn
:exec-args {:randomize? false
:fail-fast? true}}
:dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"}}}

;; Serves only the squint core functions that h/expr output uses.
:tree-shake {:extra-deps {org.babashka/esbuild {:mvn/version "0.1.1"}}
:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}

;; Build, install, deploy. See dev/build.clj.
;; Invoke with: clj -T:build <task>
:build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.7" :git/sha "573711e"}
slipset/deps-deploy {:mvn/version "0.2.3"}}
:paths ["dev"]
:ns-default build}}}
:build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.7" :git/sha "573711e"}
slipset/deps-deploy {:mvn/version "0.2.3"}}
:paths ["dev"]
:ns-default build}}}
7 changes: 5 additions & 2 deletions src/hyper/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,8 @@
- :squint-core-url — Override of the squint core.js URL used by the client
components bundle (default: version-matched jsDelivr CDN).
Point at a self-hosted copy for offline/air-gapped deploys.
- :tree-shake? - Serve only the squint core functions that h/expr output
uses. Needs babashka.esbuild on the classpath.
- :head — Hiccup nodes appended to the HTML <head>, or (fn [req] ...) -> hiccup
- :webkit-sse-shim? — Inject a small client shim (into <head>, only for WebKit/Safari
user agents) that routes the GET render stream through a native
Expand Down Expand Up @@ -1003,7 +1005,7 @@
(stop! app)"
[routes & {:keys [app-state head static-resources static-dir watches
datastar-script base-path middleware render-middleware
render-error squint-core-url render-guard
render-error squint-core-url tree-shake? render-guard
max-file-size max-file-count upload-expires-in]
:or {app-state (atom (state/init-state))
datastar-script server/default-datastar-script}
Expand All @@ -1017,7 +1019,8 @@
:base-path base-path
:middleware middleware
:render-middleware render-middleware
:squint-core-url squint-core-url}
:squint-core-url squint-core-url
:tree-shake? tree-shake?}
;; Only forward when supplied so server defaults apply.
render-error (assoc :render-error render-error)
render-guard (assoc :render-guard render-guard)
Expand Down
147 changes: 30 additions & 117 deletions src/hyper/expr.clj
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@
Datastar actions (`(@post \"/x\")`), JS interop and operators.

Compilation happens at macro-expansion time via Squint — runtime cost
is string interpolation of spliced values only. Output is
dependency-free JavaScript for Datastar's sandboxed evaluator:
`and`/`or`/`not`/`str` etc. emit bare JS operators, never library calls.
is string interpolation of spliced values only. Output calls squint
core through the `hyper_sc` global, which hyper serves.

The transpiler core is ported, with gratitude, from Casey Link's
datastar-expressions (https://github.com/outskirtslabs/datastar-expressions,
Expand All @@ -59,123 +58,21 @@
;; ---------------------------------------------------------------------------
;; Squint special-form overrides
;; ---------------------------------------------------------------------------
;; Datastar expressions run in a sandboxed evaluator with no library
;; runtime, so boolean/control/string forms must compile to bare JS
;; operators rather than squint_core calls. These compiler macros
;; (squint's :macros option) replace the defaults.

(defn- bool-expr [e]
(if (boolean? e)
e
(vary-meta e assoc :tag 'boolean)))

(defn- expr-and
([_ _] true)
([_ _ x] x)
([_ _ x & next]
(let [js (str/join " && " (repeat (inc (count next)) "(~{})"))]
(bool-expr (concat (list 'js* js) (cons x next))))))

(defn- expr-or
([_ _] nil)
([_ _ x] x)
([_ _ x & next]
(let [js (str/join " || " (repeat (inc (count next)) "(~{})"))]
(bool-expr (concat (list 'js* js) (cons x next))))))

(defn- expr-not [_ _ x]
(bool-expr (concat (list 'js* "(!(~{}))") (list x))))

(defn- expr-do
([_ _] nil)
([_ _ & exprs]
(let [js (str/join ", " (repeat (count exprs) "(~{})"))]
(concat (list 'js* js) exprs))))

(defn- expr-if [_ _ test & body]
(list 'if (bool-expr test) (first body) (second body)))

(defn- expr-when [_ _ test & body]
(list 'if (bool-expr test) (cons 'expr/do body)))

(defn- expr-when-not [_ _ test & body]
(list 'if-not (bool-expr test) (cons 'expr/do body)))

(defn- expr-eq
([_ _ _x] true)
([_ _ x y]
(bool-expr (concat (list 'js* "(~{}) === (~{})") (list x y))))
([_ _ x y & more]
(let [args (list* x y more)
pairs (partition 2 1 args)
js (str/join " && " (repeat (count pairs) "((~{}) === (~{}))"))]
(bool-expr (concat (list 'js* js) (mapcat identity pairs))))))

(defn- expr-str
([_ _] "")
([_ _ & exprs]
(let [js (str/join " + " (cons "''" (repeat (count exprs) "(~{})")))]
(concat (list 'js* js) exprs))))

(defn- expr-println [_ _ & exprs]
(let [js (str/join "," (repeat (count exprs) "(~{})"))]
(concat (list 'js* (str "console.log(" js ")")) exprs)))
;; Compiler macros (squint's :macros option) that replace the defaults.

(defn- expr-raw
([_ _] (list 'js* ""))
([_ _ x]
(let [js (str/join (repeat (count x) "~{}"))]
(concat (list 'js* js) x))))

(def ^:private macro-replacements
{'and 'expr/and
'&& 'expr/and
'or 'expr/or
'|| 'expr/or
'if 'expr/if
'not 'expr/not
'= 'expr/=
'str 'expr/str
'println 'expr/println
'when 'expr/when
'when-not 'expr/when-not
'expr/raw 'expr/raw})

(def ^:private compiler-macros
{'expr {'and expr-and
'or expr-or
'when expr-when
'when-not expr-when-not
'do expr-do
'if expr-if
'not expr-not
'= expr-eq
'str expr-str
'println expr-println
'raw expr-raw}})
{'expr {'raw expr-raw}})

;; ---------------------------------------------------------------------------
;; Pre-processing (form level)
;; ---------------------------------------------------------------------------

(defn- process-not-equals
"(not= x y) -> (not (= x y)) to avoid a squint_core dependency."
[form]
(walk/postwalk
(fn [node]
(if (and (seq? node) (= 'not= (first node)))
(list 'not (cons '= (rest node)))
node))
form))

(defn- process-macros [form]
(walk/postwalk
(fn [node]
(if (and (seq? node) (contains? macro-replacements (first node)))
(cons (get macro-replacements (first node)) (rest node))
node))
form))

(defn- process-client-params
"Replace client-param symbols ($value, $key, $form-data, … and any
user-registered ones) with their client-side JS, so expr shares the
Expand All @@ -197,7 +94,7 @@
form)))

(defn- pre-process [form]
(-> form process-not-equals process-macros process-client-params))
(process-client-params form))

;; ---------------------------------------------------------------------------
;; Post-processing (compiled JS level)
Expand All @@ -208,13 +105,8 @@
squint_core.deref(get)(...) — restore Datastar's @action syntax."
[js]
(-> js
(str/replace #"squint_core\.deref\(squint_core\.(get)\)\s*\(" "@$1(")
(str/replace #"squint_core\.deref\(([a-zA-Z_$][a-zA-Z0-9_$]*)\)\s*\(" "@$1(")))

(defn- replace-truth
"squint_core.truth_(x) -> !!(x) — no library runtime in the sandbox."
[js]
(str/replace js #"squint_core\.truth_\((.*)\)" "!!($1)"))
(str/replace #"hyper_sc\.deref\(hyper_sc\.(get)\)\s*\(" "@$1(")
(str/replace #"hyper_sc\.deref\(([a-zA-Z_$][a-zA-Z0-9_$]*)\)\s*\(" "@$1(")))

(defn- collect-kebab-signals
"All $signal symbols containing dashes, as name strings."
Expand Down Expand Up @@ -341,6 +233,16 @@
;; Compilation
;; ---------------------------------------------------------------------------

(defonce ^{:doc "Munged squint core names referenced by compiled expressions."}
core-vars*
(atom (sorted-set)))

;; A future squint release adds the used core names to the compiler output, which replaces this regex.
(defn- record-core-vars! [js]
(when-let [names (seq (map second (re-seq #"hyper_sc\.([A-Za-z_$][\w$]*)" js)))]
(swap! core-vars* into names))
js)

(defn compile-form
"Compile a single (boundary-inferred) form to a Datastar expression
string. Public for testing."
Expand All @@ -353,9 +255,10 @@
:elide-exports true
:top-level false
:context :expr
:core-alias "hyper_sc"
:macros compiler-macros})
replace-deref
replace-truth
record-core-vars!
(restore-signal-casing processed)
(str/replace #"\n" " ")
str/trim
Expand Down Expand Up @@ -387,6 +290,16 @@
template
pairs))

;; Datastar adds `return` after the last `;`, even one inside a nested function (datastar#1215).
(defn- join-statements
"Returns jss, the compiled forms, joined with `;`.
Returns the last form through `__hyper_r` if any form contains a `;`."
[jss]
(if (some #(str/includes? % ";") jss)
(str (str/join (map #(str % "; ") (butlast jss)))
"var __hyper_r = (" (last jss) "); return __hyper_r;")
(str/join "; " jss)))

;; ---------------------------------------------------------------------------
;; The macro
;; ---------------------------------------------------------------------------
Expand All @@ -413,7 +326,7 @@
(let [env-locals (set (keys &env))
pairs* (atom [])
forms* (mapv #(infer-boundary % env-locals pairs*) forms)
template (str/join "; " (map compile-form forms*))
template (join-statements (map compile-form forms*))
pairs @pairs*]
(if (empty? pairs)
template
Expand Down
43 changes: 43 additions & 0 deletions src/hyper/expr/bundle.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
(ns ^:no-doc hyper.expr.bundle
"Builds an ES module with the squint core functions that h/expr output uses.
Needs babashka.esbuild on the classpath."
(:require [babashka.esbuild :as esbuild]
[babashka.fs :as fs]
[clojure.java.io :as io]
[clojure.string :as str]
[hyper.brotli :as br]))

(def ^:private core-dir
(delay
(let [dir (fs/create-temp-dir {:prefix "hyper-squint-core"})]
(with-open [in (io/input-stream (io/resource "squint/core.js"))]
(io/copy in (fs/file dir "core.js")))
dir)))

(def ^:private last-build (atom nil))

(defn- build [vars]
(let [entry (fs/file @core-dir "entry.js")]
(spit entry (str "export { " (str/join ", " (sort vars)) " } from './core.js';\n"))
(let [js (-> (esbuild/build {:entry-points [(str entry)]
:bundle true
:format :esm
:minify true})
:outputs first :contents)]
{:js js :br (br/compress js :quality 11)})))

(defn core-js
"Returns a map with :js, minified JS that exports vars, and :br, the same JS brotli-compressed.
vars is a set of munged squint core names.
Keeps only the build for the last vars."
[vars]
(let [[built-vars result] @last-build]
(if (= vars built-vars)
result
(locking last-build
(let [[built-vars result] @last-build]
(if (= vars built-vars)
result
(let [result (build vars)]
(reset! last-build [vars result])
result)))))))
Loading
Loading