Update (2018): While the ideas in this post are still valid elm-live will get you started a lot faster. Also check out the Elm guide section on minification.
elm-reactor is good for iterating quickly on self-contained programs. You run elm-reactor
, you navigate to the source file that contains the entry point to your program and you are up and running. elm-reactor
automatically compiles your program and embeds it in an HTML page. You refresh that page when you want to try out modifications to your program or read the compiler errors.
When you start a bigger project you may need to customize that bootstrap page to, say, include external CSS or JavaScript. The Elm 0.17 reactor gives you no control over that. You need to compile your program to a JavaScript file with elm-make
using the --output
flag and then reference that in your custom HTML. Now there is an extra step in the development workflow.
When I started developing a single page application in Elm, I decided to make this workflow as nice as possible for myself by automating the build process. I came up with something that works well for me and that I want to share. Before we get into the specifics, however, let's see what needs my tooling solves.
Requirements and assumptions
- Use
make
. This is a matter of personal preference. You can find other resources if using Gulp or Webpack is more suitable for your project. - Static, self-sufficient build artifact: everything must be contained within a single directory that can be served by a production server or a CDN.
- Compression: minifying the JavaScript output of the Elm compiler is an easy win for loading speed.
- External CSS: elm-css is an interesting way to handle styling, but the warning about it being a beta release helped me decide to stick to what I know for now. This should also be minified.
- External JS: some things are currently not doable directly in Elm, like programmatically setting focus to a form field. JavaScript interoperability solves this.
- Images and web fonts: no processing is necessary for these assets, but they need to be copied to the output directory.
- Development/production build parity: since static files are going to be served in production, you might as well serve the exact same files in development.
- Automatic rebuild on file change to reduce the number of manual steps to what we have with
elm-reactor
. The Elm compiler is fast after the first build since it keeps unchanged modules in cache, therefore this is relatively fast. The minification phase is a potential bottleneck depending on the app size. - Automatic browser refresh: switching to your browser window and refreshing the page are two extra steps that you needn't take.
- Node.js is installed. The easiest way to install Elm is through
npm
and there are a lot ofnpm
packages that address our build needs, making Node.js a good starting point.
Bootstrapping Elm
In a new project directory, create the npm
configuration file package.json
:
npm init
Install Elm as an npm
package.
npm install --save-dev elm
I usually create the Makefile
at this point and add a single target: setup
. That is the convention I use for installing dependencies in projects that use make
.
setup:
npm install
Make sure to document in the project's README
that the project is set up with make setup
(or just npm install
if you didn't add a setup
target in the Makefile
).
Hello, world!
Create a src/
directory to hold your Elm program. Write a simple main module in src/Main.elm
for testing:
module Main exposing (main)
import Html
main =
Html.text "Hello, world!"
Compile it with:
./node_modules/.bin/elm-make src/Main.elm
This will ask to install some Elm packages before it can compile. It will automatically create the default elm-package.json
. Update it to point to src/
as a source directory. Otherwise, once you start adding new modules, elm make
will complain about not finding them. It should look something like this (the important bit is "source-directories"
):
{
"version": "1.0.0",
"summary": "Project summary",
"repository": "https://github.com/user/project.git",
"license": "BSD3",
"source-directories": [
"src/"
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "4.0.3 <= v < 5.0.0",
"elm-lang/html": "1.1.0 <= v < 2.0.0"
},
"elm-version": "0.17.1 <= v < 0.18.0"
}
Now we can add a compilation step to the Makefile
.
BIN = ./node_modules/.bin
SRC = src
BUILD = build
build: build-directory js
build-directory:
mkdir -p $(BUILD)
js:
$(BIN)/elm make src/Main.elm --output $(BUILD)/app.js
I declared some variables at the top, because we are going to use these paths a lot. Now running make
compiles our program and writes it to build/app.js
. This is everything we need for the Elm part even as the project grows. The compiler will bundle all imported modules.
Let's add our custom src/index.html
that imports app.js
.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Elm app</title>
</head>
<body>
</body>
<script src="app.js"></script>
<script>Elm.Main.fullscreen();</script>
</html>
The corresponding Makefile
section simply copies that file to the build
directory.
html:
cp $(SRC)/index.html $(BUILD)/index.html
When adding new build steps, don't forget to add them as dependencies to the build
target in the Makefile
. Right now, it should look like this:
build: build-directory js html
Automatic rebuilds on file changes
The chokidar-cli
package provides a command-line utility that watches files for changes and runs a command in reaction. In this case, we want to run make
when files in the src/
or assets/
directories are modified. src/
contains the source code and all the stylesheets, web fonts and images are stored in assets/
.
watch:
@$(BIN)/chokidar "$(SRC)" "assets" -c "make"
Cool! We can use our elm-reactor
-like workflow by running make watch
and opening build/index.html
. Whenever we save a file, we can see the changes by refreshing the browser page. One difference is that the compiler errors are displayed in the terminal instead of the browser.
Now is a good time to document the effects of make
and make watch
in your README
.
Live reloading
Figwheel provides a wonderful development experience for ClojureScript projects. We can get close to it thanks to BrowserSync.
build: build-directory html js css fonts images
$(BIN)/browser-sync reload
browser-sync:
$(BIN)/browser-sync start --server $(BUILD)
Running make browser-sync
will open the app in your browser and automatically refresh it after each build (note that we added the command browser-sync reload
to the build
target). What's more, if you access the same URL from another browser, even other devices, all instances will be refreshed without manual intervention.
Depending on the way your application derives its state you may lose state between reloads, but that is already the case with manual reloads. How you can achieve seamless reloads is a topic for another time.
Minification
The Google Closure compiler requires Java, so I opted for UglifyJs, which gives good results. I enabled name mangling and compression. This reduces the file size significantly (from 164Kb down to 64Kb for the Hello World example). Gzip reduces the size even further (down to 19Kb), but gzipping can be done by the web server.
js:
$(BIN)/elm make $(SRC)/Main.elm \
--output $(BUILD)/app.js
$(BIN)/uglifyjs --compress --mangle \
--output $(BUILD)/app.min.js \
$(BUILD)/app.js 2> /dev/null
mv $(BUILD)/app.min.js $(BUILD)/app.js
I redirect the UglifyJS error stream to /dev/null, because it just contains warnings about the minification of things in the Elm standard library: "Side effects in initialization of unused variable", "Dropping unused variable", "Dropping unused function". Ignoring warnings is generally not a good idea, but in this case:
- The dead code elimination phase doesn't drop any of the unused variables with "side effects in initialization".
- Minification with
--compress --mangle
doesn't impair the program. Maybe more advanced options do. - Even if something went wrong, we would notice since we are using the production build in development.
Stylesheets
clean-css
works great for my purposes. It takes your concatenated CSS files as input and creates a minified stylesheet. If you need Sass, node-sass
has a command line interface that can be used in a similar way.
CSS = assets/stylesheets
css:
cat $(CSS)/*.css | \
$(BIN)/cleancss --output $(BUILD)/style.css
Import the stylesheet in the <head>
section of src/index.html
:
<link rel="stylesheet" type="text/css" href="style.css" />
Final result
The package.json
configuration file looks something like this:
{
"name": "elm-spa",
"version": "1.0.0",
"private": true,
"description": "Elm single page application",
"dependencies": {},
"devDependencies": {
"browser-sync": "^2.14.0",
"chokidar-cli": "^1.2.0",
"clean-css": "^3.4.18",
"elm": "^0.17.1",
"uglify-js": "^2.7.0"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "UNLICENSED"
}
The build
directory contains the following after I run make
:
$ tree -L 1 build
build
βββ app.js
βββ fonts
βββ images
βββ index.html
βββ setup.js
βββ style.css
2 directories, 4 files
setup.js
contains the JavaScript code that embeds the Elm program and sets up ports. In the final Makefile
below you can see that I also compress setup.js
.
BIN = ./node_modules/.bin
SRC = src
BUILD = build
CSS = assets/stylesheets
FONTS = assets/fonts
IMAGES = assets/images
HTML = src/index.html
build: build-directory html js css fonts images
build-directory:
mkdir -p $(BUILD)
html:
cp $(HTML) $(BUILD)/index.html
js:
$(BIN)/elm make $(SRC)/Main.elm \
--output $(BUILD)/app.js
$(BIN)/uglifyjs --compress --mangle \
--output $(BUILD)/app.min.js \
$(BUILD)/app.js 2> /dev/null
mv $(BUILD)/app.min.js $(BUILD)/app.js
$(BIN)/uglifyjs --compress --mangle \
--output $(BUILD)/setup.js \
$(SRC)/setup.js 2> /dev/null
css:
cat $(CSS)/*.css | \
$(BIN)/cleancss --output $(BUILD)/style.css
fonts:
cp -r $(FONTS) $(BUILD)
images:
cp -r $(IMAGES) $(BUILD)
watch:
$(BIN)/chokidar "$(SRC)" "assets" -c "make"
browser-sync:
$(BIN)/browser-sync start \
--server $(BUILD) --files $(BUILD)
setup:
npm install
Make sure to use tabs for indentation or make
will fail with a cryptic error message.
Possible improvements
Deployment automation
It would be good to be able to deploy the app in a single step by running
make deploy
This depends on where you deploy, so I left it out.
Cleaner make watch
logs
By default make
prints a command before executing it. It also prints "Entering directory" and "Leaving directory" messages. All of this creates a lot of noise and we only care about compiler errors. There are ways to silence make
. For instance, prefixing a Makefile
line with @
prevents make
from printing it before execution. I find it best to leave the defaults for manual builds. For builds triggered by file changes during development I completely disable make
output with the -s
flag:
watch:
$(BIN)/chokidar "$(SRC)" "assets" -c "make -s"
I also like to add some blank lines to distinguish the output of consecutive builds:
build: build-directory html js css fonts images
@$(BIN)/browser-sync reload
@echo -e "\n\n\n\n"