ClojureScript Project Quickstart

[Luke Turner]
2021-03-28
ver. 1
home
 
+post +webdev +tutorial +cljs

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

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, you 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 you 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:

The only top-level code in a namespace is def (and defn, 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:

  1. Add it to shadow-cljs.edn
  2. Require it in the normal fashion:
(ns my-app.core
 (:require [reagent.core :refer [render]]))

To add a NPM dependency:

  1. Add it to package.json
  2. Run npm install (NPM dependencies are not installed automatically)
  3. 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:

  1. In development, we want the tests to run and re-run automatically when files change.
  2. 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 you don’t depend on any browser APIs or behaviors in your code.

So, if your application is architected to have mostly pure functions with no external dependencies, Node testing may be sufficient.

If you want to test functions that depend on browser APIs, though, you’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.

Don’t forget to add test-out to your .gitignore to avoid committing compiled test code.

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:

  1. This compiles the my-app.docs.core namespace and executes the my-app.docs.core/init! function when the JS bundle is loaded. That function is responsible for calling into the Devtools initialization code (see below.)
  2. The :ns-aliases option is passed, indicating that when the devcards-marked or devcards-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 your 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:

  1. Pushing to origin will push to both GitHub and git.sr.ht repositories.
  2. 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!

Assuming we did go with 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.

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!

Put this in your .build.yml:

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      

You 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 your site is built and tested automatically!

Now, as written, all our pipeline is doing is CI: compiling code and running tests. Usually the real value comes when CD – automated code deployment – comes into the picture.

To fully realize the utility 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 you push a commit, add the following to your .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