ClojureScript macros run in Clojure, and provide the necessary link between the
two worlds. To access Optimus asset paths in ClojureScript, we will load some
assets, provide a function that finds the preferred path to an asset, and
finally expose it via a ClojureScript macro.
In a Clojure namespace (e.g. in a file with a clj extension), load some assets
with Optimus:
(ns myapp.assets
(:require [optimus.assets :as assets]
[optimus.optimizations :as optimizations]))
(def assets (-> (assets/load-assets "public" [#"/images/.*\..+$"])
optimizations/all))
Next up, we will add a function to find an asset's preferred path - that is,
if the asset is available under a new path, use that, otherwise use the original
path:
(defn preferred-path [path]
(->> assets
(filter #(= path (assets/original-path %)))
first
:path))
Finally, provide a macro that calls the preferred-path
function:
(defmacro asset-path [path]
(preferred-path path))
From a ClojureScript namespace, require and use the macro (make sure that
optimus is on the classpath of the process compiling ClojureScript - e.g. your
build process, your figwheel server, etc):
(ns myapp.core
(:require-macros [myapp.assets :refer [asset-path]]))
(asset-path "/images/logo.png")
Voila! That's pretty much it. Apart from this, there are some gotchas you need
to account for when wiring your app together.
Because ClojureScript macros are resolved compile-time, this only works with
inline paths as shown above. This will not work:
(ns myapp.core
(:require-macros [myapp.assets :refer [asset-path]]))
(let [path "/images/logo.png"]
(asset-path path))
This will literally look up the symbol path
, which will not match anything,
and then path
will be evaluated runtime and resolved to "/images/logo.png"
.
There is no way to fix this, but we can build the macro a little bit differently
and make it work:
(defmacro get-asset-paths []
(->> assets
(filter :original-path)
(map (juxt :original-path :path))
(into {})))
This macro will return a map of asset paths to optimized asset paths (these will
be the same when there are no optimizations). You can then provide a
ClojureScript function to look it up. Create assets.cljs
like so:
(ns ui.assets
(:require-macros [ui.assets :refer [get-asset-paths]]))
(def asset-paths (get-asset-paths))
(defn get-asset-path [asset]
(get asset-paths asset asset))
This get-asset-path
function can be used just like any other function, with
dynamic paths, and what have you. The trade-off is that your build will contain
a list of all the assets you expect to look up, so you want to use this with
care if you have lots of assets.
If you use Optimus to cache-bust your built ClojureScript bundle, you must
exempt it from the assets to refer from ClojureScript, or your project will fail
to build. Personally, I only need to refer to images from ClojureScript, so I
solved this problem by splitting bundles and other assets:
(def assets (assets/load-assets "public" [#"/images/.*\..+$"]))
(def bundles (assets/load-bundles "public" {"styles.css" ["/styles/main.css"]
"app.js" ["/js/app.js"]}))
(def all-assets (concat bundles assets))
The preferred-path
function (and thus, the CLJS macro) will only look up
assets
, while the server middleware will use all-assets
to optimize and
serve all assets.
Because macros run compile-time, data that will be exposed must be available
compile-time. This means that Optimus assets, and what kind of optimizations to
apply to them must be determined with compile-time, not with runtime
configuration. Configuration loaded with
component,
mount, and similar tools cannot be used to
determine what optimizations to enable.
There are many ways to resolve optimizations compile-time. Environment variables
and system properties are two obvious choices. I will detail a solution that
defaults to the production settings, while allowing overrides via system
properties, which can be set via e.g. Leiningen profiles or tools.deps JVM
options.
We will need a function that can read a system property as a boolean, and fall
back to a default if the property is not set:
(defn bool-property [prop default]
(if-let [property (System/getProperty prop)]
(Boolean/parseBoolean property)
default))
We will use it to load the asset configuration:
(def asset-config
{:live? (bool-property "optimus.assets.live" false)
:optimize? (bool-property "optimus.assets.optimize" true)})
Then we define funtions to load assets:
(defn get-assets []
(assets/load-assets "public" [#"/images/.*\..+$"]))
(defn get-bundles []
(assets/load-bundles "public" {"styles.css" ["/styles/main.css"]
"app.js" ["/js/app.js"]}))
(defn get-all-assets []
(concat
(get-bundles)
(get-assets)))
(defn optimize-assets [assets & [options]]
(if (:optimize? asset-config)
(optimizations/all assets options)
(optimizations/none assets options)))
We can use these building blocks to create a Ring middleware to load assets:
(defn wrap-assets [handler]
(optimus/wrap
handler
get-all-assets
optimize-assets
(if (:live? asset-config)
strategies/serve-live-assets
strategies/serve-frozen-assets)))
And finally, we can use the same building-blocks to load assets and provide the
ClojureScript macro to resolve paths:
(def assets (optimize-assets (get-assets)))
(defmacro get-asset-paths []
(->> assets
(map (juxt :original-path :path))
(into {})))
In project.clj
, you can set system properties in the dev profile to skip
costly optimizations during development:
(defproject myapp "0.1.0-SNAPSHOT"
:profiles {:dev { :jvm-opts ["-Doptimus.assets.optimize=false"
"-Doptimus.assets.live=true"]}})
Beware that the dev profile is loaded by default, so you'll want to provide a
profile to use for building your production bundles:
(defproject myapp "0.1.0-SNAPSHOT"
:profiles {:dev { :jvm-opts ["-Doptimus.assets.optimize=false"
"-Doptimus.assets.live=true"]}
:prod { :jvm-opts ["-Doptimus.assets.optimize=true"
"-Doptimus.assets.live=false"]}})
And use that for building:
lein with-profile prod cljsbuild once min
If you use tools.deps, you can set properties like so:
clojure -J'-Doptimus.assets.optimize=false' -A:dev
One drawback with this solution is that optimization settings is a compile-time
decision for ClojureScript, but a runtime decision for Clojure. This means that
you need to take care to run the server with the runtime configuration that
corresponds to how you compiled your ClojureScript. Failing to do so may cause
the frontend to refer to assets that the server does not know about. Shouldn't
be a huge problem, but it is a potential pitfall.
For reference, here is the full src/myapp/assets.clj
:
(ns myapp.assets
(:require [optimus.assets :as assets]
[optimus.optimizations :as optimizations]
[optimus.prime :as optimus]
[optimus.strategies :as strategies]))
(defn bool-property [prop default]
(if-let [property (System/getProperty prop)]
(Boolean/parseBoolean property)
default))
(def asset-config
{:live? (bool-property "optimus.assets.live" false)
:optimize? (bool-property "optimus.assets.optimize" true)})
(defn get-assets []
(assets/load-assets "public" [#"/images/.*\..+$"]))
(defn get-bundles []
(assets/load-bundles "public" {"styles.css" ["/styles/main.css"]
"app.js" ["/js/app.js"]}))
(defn get-all-assets []
(concat
(get-bundles)
(get-assets)))
(defn optimize-assets [assets & [options]]
(if (:optimize? asset-config)
(optimizations/all assets options)
(optimizations/none assets options)))
(defn wrap-assets
[handler]
(optimus/wrap
handler
get-all-assets
optimize-assets
(if (:live? asset-config)
strategies/serve-live-assets
strategies/serve-frozen-assets)))
(def assets (optimize-assets (get-assets)))
(defmacro get-asset-paths []
(->> assets
(map (juxt :original-path :path))
(into {})))
And the full src/myapp/assets.cljs
:
(ns ui.assets
(:require-macros [ui.assets :refer [get-asset-paths]]))
(def asset-paths (get-asset-paths))
(defn get-asset-path [asset]
(get asset-paths asset asset))
Thanks to the ever-awesome Magnar Sveen for
hashing out the idea for this with me.