This brief guide about setting up a new ClojureScript project has been compiled from my notes accumulated on the topic as I’ve worked on Factor and Ulti.
The idea is to cover all the busy-work topics of a new project. We’ll cover all the good stuff, saucing up our projects with:
- Seamless interop with
npm
dependencies - Rapid development with hot module reloading
- Fancy IDE integration (intellisense, REPL integration, etc.)
- Optimized, minified production builds
- Full CI/CD pipeline incl. publishing to Netlify and/or Clojars
- All tooling cross-platform (Windows+Linux)
If you don’t like complex, “fun” build pipelines using lots of tools that will probably be broken in five years: look away now!
But if you’re willing to stick with the tooling, and not afraid of taking an occasional refresher course, ClojureScript provides the best front-end development experience for personal projects!
Requirements
- Git
- Node 6+ w/NPM
- Java 8+ SDK
For publishing as a CLJS library (e.g. to Clojars):
Also, you should install the ClojureScript IDE of your choice. I like Calva, an extremely easy-to-use extension for VS Code.
Overview
Not all projects are alike. I’ve broken this guide into sections from which we can pick-and-choose based on our project’s particular needs.
- ClojureScript Compiling (required!)
- Automated Testing
- Documenting with Devcards
- CI with builds.sr.ht
- Deploying to Netlify
- Publishing as a CLJS Library
ClojureScript Compiling
We’ll be using shadow-cljs
to compile our ClojureScript code. shadow-cljs
has tight integration with the NPM ecosystem, and is itself distributed as an
npm
module.
To create a new project, we can use npx (which comes with NPM):
npx create-cljs-project my-app
Or, if you prefer:
npm install -g create-cljs-project
create-cljs-project my-app
In this and following examples, my-app
is used as a placeholder for our real
app/library name.
In the shadow-cljs.edn
file that was created, we can add:
:dev-http {8080 "public"}
:builds
{:app {:target :browser
:devtools {:after-load my-app.core/after-load}
:modules {:main {:init-fn my-app.core/init}}}}
This defines an app
build that compiles the my-app.core
namespace into a
.js
file that can be loaded directly in a <script>
tag.
When the .js
file is loaded, the my-app.core/init
function will be executed automatically.
Also, when in development, the my-app.core/after-load
function will be called
after every code reload.
For more detail about getting started with shadow-cljs
, read their excellent
Users Guide.
Then, put the following in public/index.html
:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>my-app</title>
</head>
<body>
<div id="app"></div>
<script src="/js/main.js"></script>
</body>
</html>
We could put whatever we want in there, this is just an example. The important
part is that it loads the /js/main.js
file, thereby calling our
my-app.core/init
function to start our application.
Next, we need to create some source code. Put the following in
src/main/my_app/core.cljs
:
(ns my-app.core
(:require [reagent.dom :refer [render]]))
(defn app []
[:h1 "Hello World!"])
(defn render-app []
(render [app] (js/document.getElementById "app")))
(defn init []
(render-app))
(defn after-load []
(render-app))
This is just the “Hello World” for Reagent.
We’ll need to add [reagent "1.0.0"]
to our dependencies in shadow-cljs.edn
in order for the above code to compile.
A complex app will have more complicated initialization, but it should all be
nested under init
and after-load
for hot-reloading to work. Just remember
this simple rule of thumb:
The only top-level code in a namespace is
def
(anddefn
,defcard
, etc.) calls.
Everything that’s not a def
— registering event handlers, doing one-time
setup, etc. — must be wrapped in an init function and explicitly called
within after-load
to re-register itself on code change.
Finally, we can add some scripts to our package.json
. We’ll create a start
script to run a development server, and a build
script to compile a production
release.
"scripts": {
"start": "shadow-cljs watch app",
"build": "shadow-cljs release app"
}
We’re now ready to start running our application. In our project’s working directory, run:
npm install
npm run start
We should now be able to hit localhost:8080
in our browser and see:
Hello World!
Be warned: The first build is a little slow. But, all those built modules are cached. Later builds just need to recompile the namespaces that changed, which is much faster.
Adding Dependencies
To add a CLJS dependency:
- Add it to
shadow-cljs.edn
- Require it in the normal fashion:
(ns my-app.core
(:require [reagent.core :refer [render]]))
To add a NPM dependency:
- Add it to
package.json
- Run
npm install
(NPM dependencies are not installed automatically) - Require it by using a string for the namespace name:
(ns my-app.core
(:require ["react-dom" :refer [render]]))
Automated Testing
Conceptually, an automated test suite is a separate program from our
application. So, we have to define a new build in our shadow-cljs.edn
that
says how to compile our test suite into a program we can run in Node or the
browser.
In fact, for best experience we can add two builds:
:test {:target :node-test
:autorun true
:output-to "test-out/node-test.js"}
:citest {:target :node-test
:output-to "test-out/node-test.js"}
The reason we use two builds is to accommodate the two modes in which tests are run:
- In development, we want the tests to run and re-run automatically when files change.
- In CI/CD, we want to run the tests once, and fail with a nonzero exit code if they fail.
In practice, the only difference between them is the former uses :autorun true
and the latter does not.
Because the citest
build doesn’t use :autorun
, though, it doesn’t actually
run the tests. It just compiles them. We can run the tests with:
node test-out/node-test.js
To keep it simple, let’s update our NPM scripts
with test commands:
"scripts": {
"start": "shadow-cljs watch app test",
"test": "shadow-cljs compile citest && node test-out/node-test.js",
}
Note we created a new test
script for CICD, and we added the test
build to our
start
script so tests are run automatically alongside our development server.
In this example, we use Node to run our tests. But wait — isn’t this a browser application? Does that work?
In fact, running tests in Node will work, as long as we don’t depend on any browser APIs or behaviors in the code under test.
So, if our application is architected to have mostly pure functions with no external dependencies, Node testing may be sufficient.
If we want to test functions that depend on browser APIs, though, we’ll probably need to rope Karma into the picture. See the Karma section in the shadow-cljs docs.
Tests are written using cljs.test and should live in the src/test
directory. For example:
;; src/test/my_app/core_test.cljs
(ns my-app.core-test
(:require [cljs.test :refer [deftest is]]))
(deftest example-test
(is (= 1 1) "one should equal itself"))
This test will be run automatically when you run npm run start
or npm run test
.
We should remember to add test-out
to our .gitignore
so we don’t add the test-out/node-test.js
file to our Git repo.
Documenting with Devcards
If we’re building an application (or library) with a variety of visual components, devcards can be a great way to generate interactive documentation for ourselves and our users.
Getting set up is fairly easy, but there are some weird CLJSJS shims that are
required when using devcards with shadow-cljs
. We’ll get to that in a sec.
First, let’s add devcards to our dependencies in shadow-cljs.edn
:
[devcards "0.2.7"]
Even if we’re publishing a library, we don’t add this dependency to our
project.clj
! This is an example of a development dependency that users of
our library shouldn’t need to download.
Then, because Devcards is yet another application with its own entry point, we
need to define another build to tell shadow-cljs
how to compile our Devcards
stuff:
:builds
{:docs {:target :browser
:devtools {:after-load my-app.docs.core/init!}
:modules {:main {:init-fn my-app.docs.core/init!}}
:compiler-options {:devcards true}
:build-options {:ns-aliases {devcards-marked cljsjs.marked
devcards-syntax-highlighter cljsjs.highlight}}}}
Couple things to unpack here:
- This compiles the
my-app.docs.core
namespace and executes themy-app.docs.core/init!
function when the JS bundle is loaded. That function is responsible for calling into the Devtools initialization code (see below.) - The
:ns-aliases
option is passed, indicating that when thedevcards-marked
ordevcards-syntax-highlighter
namespaces are required, other namespaces should be used instead. This is the CLJSJS shim I mentioned.
In order for our shim to work, we need to create two shim files in our project:
;; in /src/dev/cljsjs/highlight.cljs
(ns cljsjs.highlight
(:require ["highlight.js" :as hljs]))
(js/goog.exportSymbol "hljs" hljs)
(js/goog.exportSymbol "DevcardsSyntaxHighlighter" hljs)
;; in /src/dev/cljsjs/marked.cljs
(ns cljsjs.marked
(:require ["marked" :as marked]))
(js/goog.exportSymbol "marked" marked)
(js/goog.exportSymbol "DevcardsMarked" marked)
This shim approach was copied from: https://github.com/bhauman/devcards/issues/168
Then, we create the my-app.docs.core
namespace:
;; in /src/dev/my_app/docs/core.cljs
(ns my-app.docs.core
(:require [devcards.core :refer [start-devcard-ui!]]
;; you must require all files that include defcards
;; from this file, or the cards won't be included
;; in the bundle.
))
(defn init! []
(start-devcard-ui!))
Finally, we can add NPM scripts for our devcard docs:
"scripts": {
"docs:start": "shadow-cljs watch docs",
"docs:build": "shadow-cljs release docs"
}
CI with builds.sr.ht
A CI/CD pipeline is a huge convenience for personal open-source projects. The time spent futzing with CI pipelines is well worth the satisfying feeling of pushing a commit and watching our site update automatically. Truly a lazy developer’s dream.
For this particular setup, we’ll need accounts for the following services:
- sr.ht for Git hosting and task running
The choice of sr.ht for Git hosting is a personal decision — GitHub or rsync.net would work just as well.
In fact, I like to publish to two remote repositories, just in case one
disappears on me. With some finicky Git commands, we can actually make an
origin
remote where:
- Pushing to
origin
will push to both GitHub and git.sr.ht repositories. - Pulling from
origin
will pull from the first-specified repository only.
If we want the git.sr.ht repository to be the one that’s pulled from, with the Github repository maintained as an alternative, we could do something like this:
git remote add origin $SOURCEHUT_REPO_URL
git remote set-url --push --add origin $SOURCEHUT_REPO_URL
git remote set-url --push --add origin $GITHUB_REPO_URL
Fancy!
Since we’re using git.sr.ht, we have the benefit of automated integrated
with builds.sr.ht, which is pretty cool. We just need to add a .build.yml
file:
image: alpine/edge
packages:
- openjdk8
- nodejs
- npm
sources:
- https://git.sr.ht/~example/my-app
secrets:
tasks:
- install-node-modules: |
cd my-app
npm i --no-progress
- build-app: |
cd my-app
npm run build
rm public/js/manifest.edn
- run-tests: |
cd my-app
npm run test
The up-to-date documentation for builds.sr.ht is available here: https://man.sr.ht/builds.sr.ht/.
Make sure to read the docs before using new tools, especially where secrets are involved. Don’t take my word for it!
We should now be able to run:
git commit -a -m "Testing CI/CD"
git push --set-upstream origin
And watch in builds.sr.ht as our site is built and tested automatically!
Now, as written, all our pipeline is doing is CI (Continuous Integration): compiling code and running tests. Usually the real value for personal projects comes when CD (Continuous Deployment) — automated code deployment — comes into the picture.
To fully realize the utility of the CD part of our new CI/CD pipeline, we should follow at least one of these sections:
Deploying to Netlify
If we’re using CLJS to write a website — a Single Page Application (SPA) — we can easily deploy that SPA using Netlify.
We can add the following script to our package.json
:
"scripts": {
"deploy": "netlify deploy --site \"$NETLIFY_SITE_ID\" --dir=public --prod"
}
This obviously also depends on the netlify
CLI being available. We can install this with:
npm i netlify-cli
However, because the Netlify CLI has so many dependencies, I recommend not
including it as an explicit dependency in our package.json
.
In fact, we don’t need to install netlify locally at all if we prefer to publish from our CI/CD pipeline (which we do)!
CI/CD
To automatically deploy to a Netlify site whenever we push a commit, we can add the following to our .build.yml
file.
image: alpine/edge
secrets:
# ~/netlify_config -- expected to have:
# export NETLIFY_SITE_ID
# export NETLIFY_AUTH_TOKEN
- aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa
tasks:
# Assuming you've already built the code into /public
- install-netlify: |
cd my-app
npm i netlify-cli --quiet --no-progress
- deploy: |
set +x
source ~/netlify_config
cd my-app
npm run deploy >/dev/null 2>&1
set -x
Publishing as a CLJS Library
This section is focused on publishing CLJS libraries for use in other CLJS projects.
shadow-cljs
does make it easy to compile your CLJS project into a Node module
and publish it to NPM, for interop with JS projects. As always, the
shadow-cljs
User Guide has more information if you’re interested. But that’s
not what this section is about.
A CLJS library is published as a JAR file containing ClojureScript source code
(not compiled JavaScript). So, shadow-cljs
, which is a CLJS->JS build
toolchain, isn’t involved.
Instead, we’ll have to reach for another tool, something that knows about making
JARs. Leiningen is a convenient option, recommended in the shadow-cljs
docs.
Unfortunately, using Leiningen means we need to add a project.clj
in addition to the
shadow-cljs.edn
we already have. Dependencies have to be declared in both places.
An example project.clj
for publishing CLJS libraries:
;; Only used for publishing JARs -- use shadow-cljs to build clojurescript
(defproject example/my-app "1.0.0-SNAPSHOT"
:description "my app!"
:url "https://git.sr.ht/~example/my-app"
:license {:name "MIT License"}
:dependencies
[[org.clojure/clojurescript "1.10.520" :scope "provided"]
; list any dependencies needed for our library
]
:deploy-repositories [["local" {:url "file:///~/.m2"
:sign-releases false}]
["clojars" {:username :env/clojars_username
:password :env/clojars_password}]]
:source-paths
["src/main"]) ; Note -- This is just src/main, NOT src/dev or src/test! Library users don't need that code
If our library also has Node dependencies, we can create a src/main/deps.edn
to declare them:
; Should have the same stuff as the package.json -- minus dev dependencies
{:npm-deps {"create-react-class" "15.6.3"
"react-dom" "17.0.1"}}
This method of declaring NPM dependencies works seamlessly for library
consumers, automatically installing the required packages at build time, if
the consumers also use shadow-cljs
. If they don’t, they may have to install
these dependencies manually for our library to work.
Finally, we can add some scripts to our package.json
for publishing:
"scripts": {
"library:local": "lein deploy local",
"library:publish": "lein deploy clojars"
}
With this, npm run library:local
will publish the library to our local
~/.m2
cache for local testing, and npm run library:publish
will publish it
to Clojars using our CLOJARS_USERNAME
and CLOJARS_PASSWORD
environment
variables.
Still, wouldn’t it be nice if we didn’t have to manually run publish commands at all? Let’s put it in CI/CD!
CI/CD
To automatically publish to Clojars whenever we push a commit, add the following
to our .build.yml
file.
image: alpine/edge
packages:
- leiningen
secrets:
# ~/clojars_config -- expected to have:
# export CLOJARS_USERNAME
# export CLOJARS_PASSWORD
- aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa
tasks:
- publish-library: |
set +x
source ~/clojars_config
set -x
cd my-app
npm run library:publish