As a long-time Ruby and JavaScript user, I’ve seen my share of build tools.
Rake, Jake, Cake, Grunt, Gulp, the Rails asset
pipeline… I’ve even invented one or two of my own. I’ve always wondered
why every language ecosystem feels the need to invent its own build tools, and
I’ve often felt like they get in my way. Too often, Rake tasks are just wrappers
around existing executables like rspec
or cucumber
, or require custom glue
code to hook a tool into the build system – witness the explosion of grunt-*
packages on npm. I find Grunt particularly problematic; its configuration is
verbose and indirect, and its plugins bake in assumptions that you cannot
change. For example, grunt-contrib-handlebars
currently depends on
handlebars~1.1.2
, when the current version of Handlebars is 1.3.0. It seems
strange to have your build tools choose which version of your libraries you must
use.
Most of the tasks we need to build our projects can be run using existing executables in the shell. CoffeeScript, Uglify, Browserify, testing tools, PhantomJS, all have perfectly good executables already and it seems silly to require extra tooling and plugins just so I can glue them together using Grunt. In the Node community we talk a lot about ‘the Unix way’, but the current crop of build tools don’t seem Unixy to me: they’re highly coupled, obfuscatory and verbose, wrapping particular versions of executables in hundreds of lines of custom integration code and configuration.
Fortunately, Unix has had a perfectly good build tool for decades, and it’s called Make. Though widely perceived as being only for C projects, Make is a general-purpose build tool that can be used on any kind of project. I’m currently using it to build my book, generating EPUB, MOBI and PDF from AsciiDoc and checking all the code in the book is successfully tested before any files are generated. (I’ve put my build process on GitHub if you’re interested.) While writing the book, I decided to use Make for the book’s example projects themselves, and found it to be a very quick way to get all the usual JavaScript build steps set up.
In this article I’m going to build a project that uses CoffeeScript source
code, Handlebars templates, and is tested using jstest on
PhantomJS. To start with, let’s cover the project’s source files. The
contents of these files aren’t too important, the important thing is what we
need to do with the files to make them ready to run. There’s a couple of
.coffee
files in the lib
directory, just a small Backbone model and view:
# lib/concert.coffee
Concert = Backbone.Model.extend()
window.Concert = Concert
# lib/concert_view.coffee
ConcertView = Backbone.View.extend
initialize: ->
@render()
@model.on "change", => @render()
render: ->
html = Handlebars.templates.concert(@model.attributes)
@$el.html(html)
window.ConcertView = ConcertView
(You could use CommonJS modules instead of window
globals and build the
project with Browserify; hopefully having read this it’ll be obvious how
to add this to your project.)
And, we have a template for displaying a little information about a concert:
There are also a couple of test suites in spec/*.coffee
that test the template
and view from above:
# spec/concert_template_spec.coffee
JS.Test.describe "templates.concert()", ->
@before ->
@concert =
artist: "Boredoms",
venueName: "The Forum",
cityName: "Kentish Town",
country: "UK"
@html = $(Handlebars.templates.concert(@concert))
@it "renders the artist name", ->
@assertEqual "Boredoms", @html.find(".artist").text()
@it "renders the venue details", ->
@assertEqual "The Forum, Kentish Town, UK", @html.find(".venue").text()
# spec/concert_view_spec.coffee
JS.Test.describe "ConcertView", ->
@before ->
@fixture = $(".fixture").html('<div class="concert"></div>')
@concert = new Concert
artist: "Boredoms",
venueName: "The Forum",
cityName: "Kentish Town",
country: "UK"
new ConcertView(el: @fixture.find(".concert"), model: @concert)
@it "renders the artist name", ->
@assertEqual "Boredoms", @fixture.find(".artist").text()
@it "updates the artist name if it changes", ->
@concert.set "artist", "Low"
@assertEqual "Low", @fixture.find(".artist").text()
Now, to get from these files to working code, we need to do a few things:
- Compile all the CoffeeScript to JavaScript
- Compile all the Handlebars templates to a JS file
- Combine the app’s libraries and compiled source code into a single file
- Minify the bundled app code using Uglify
- Run the tests after making sure all the files are up to date
Make is a great tool for managing these tasks, as they are essentially
relationships between files. Make does not have any baked-in assumptions about
what type of project you have or what languages you’re using, it’s simply a tool
for organising sets of shell commands. It has a very simple model: you describe
which files each build file in your project depends on, and how to regenerate
build files if they are out of date. For example, if file a.txt
is generated
by concatenating b.txt
and c.txt
, then we would write this rule in our
Makefile
:
a.txt: b.txt c.txt
cat b.txt c.txt > a.txt
The first line says that a.txt
(the target) is generated from b.txt
and c.txt
(the dependencies). a.txt
will only be rebuilt if one of
its dependencies was changed since a.txt
was last changed; Make always tries
to skip unnecessary work by checking the last-modified times of files. The
second line (the recipe) says how to regenerate the target if it’s out of
date, which in this case is a simple matter of piping cat
into the target
file. Recipe lines must begin with a tab, not spaces; I deal with this by adding
the following to my .vimrc
:
autocmd filetype make setlocal noexpandtab
Let’s start by installing the dependencies for this project. Add the following
to package.json
and run npm install
:
{
"dependencies": {
"backbone": "~1.1.0",
"underscore": "~1.5.0"
},
"devDependencies": {
"coffee-script": "~1.7.0",
"handlebars": "~1.3.0",
"jstest": "~1.0.0",
"uglify-js": "~2.4.0"
}
}
This installs all the build tools we’ll need, and all of them have executables
that npm places in node_modules/.bin
.
Let’s write a rule for building our Handlebars templates. We want to compile all
the templates – that’s templates/*.handlebars
– into the single file
build/templates.js
. Here’s a rule for this:
PATH := node_modules/.bin:$(PATH)
SHELL := /bin/bash
build/templates.js: templates/*.handlebars
mkdir -p $(dir $@)
handlebars templates/*.handlebars > $@
The first line adds the executables from npm to the Unix $PATH
variable so
that we can refer to, say handlebars
by its name without typing out its full
path. (Installing programs ‘globally’ just means installing them into a
directory that is usually listed in $PATH
by default.) The first line of the
recipe uses mkdir
to make sure the directory we’re compiling the templates
into already exists; $@
is a special Make variable that contains the pathname
of the target we’re trying to build, and the dir
function takes the directory
part of that pathname.
The rule duplicates the names of the source and target files, and we often use variables to remove this duplication:
PATH := node_modules/.bin:$(PATH)
SHELL := /bin/bash
template_source := templates/*.handlebars
template_js := build/templates.js
$(template_js): $(templates_source)
mkdir -p $(dir $@)
handlebars $(templates_source) > $@
With this Makefile, running make
in the shell will generate
build/templates.js
, or do nothing if that file is already up to date.
$ touch templates/concert.handlebars
$ make
mkdir -p build/
handlebars templates/*.handlebars > build/templates.js
$ make
make: `build/templates.js' is up to date.
Next up, we need to compile our CoffeeScript. We want to say that every file
lib/foo.coffee
generates a corresponding file build/lib/foo.js
, and likewise
every file spec/foo_spec.coffee
generates build/spec/foo_spec.js
. In Make,
we can use the wildcard
function to find all the names of the CoffeeScript
files in lib
and spec
, and generate lists of JavaScript files from those
names using pattern substitution. In Make, the expression
$(files:%.coffee=build/%.js)
means for every filename in the list files
,
replace %.coffee
with build/%.js
, for example replace lib/foo.coffee
with
build/lib/foo.js
. We also use a pattern-based rule to describe how to compile
any CoffeeScript file to its JavaScript counterpart. Here are the rules:
source_files := $(wildcard lib/*.coffee)
build_files := $(source_files:%.coffee=build/%.js)
spec_coffee := $(wildcard spec/*.coffee)
spec_js := $(spec_coffee:%.coffee=build/%.js)
build/%.js: %.coffee
coffee -co $(dir $@) $<
We need to generate the names of all the generated JavaScript files because
later targets will depend on them. If a target simply depended on build/*.js
but we’d not built those files yet, the build wouldn’t work correctly. With this
configuration, Make sets the variables to these values:
source_files := lib/concert.coffee lib/concert_view.coffee
build_files := build/lib/concert.js build/lib/concert_view.js
spec_coffee := spec/concert_template_spec.coffee spec/concert_view_spec.coffee
spec_js := build/spec/concert_template_spec.js build/spec/concert_view_spec.js
So, Make now knows the names of all the generated files before they exist. The
recipe for CoffeeScript states the any file build/foo/bar.js
is generated from
foo/bar.coffee
, and uses coffee -co
to make coffee
compile each file into
a given output directory. We use $@
as before to get the name of the current
target, and $<
gives the name of the first dependency of the current target.
These variables are essential when dealing with pattern-based rules like this.
Pattern rules are invoked if Make has not been told explicitly how to build a
particular file. If you run make build/lib/concert.js
you’ll see that it
generates the named file from the pattern rule.
Now that we’ve compiled all our code, we can concatenate and minify it. We want
to take all the files in build_files
, and template_js
, and any third party
libraries we need, and use Uglify to compress them. A rule for this is
straightforward; note how app_bundle
depends on the upstream files so that if
any of them change, Make knows it needs to rebuild app_bundle
.
app_bundle := build/app.js
libraries := vendor/jquery.js \
node_modules/handlebars/dist/handlebars.runtime.js \
node_modules/underscore/underscore.js \
node_modules/backbone/backbone.js
$(app_bundle): $(libraries) $(build_files) $(template_js)
uglifyjs -cmo $@ $^
Here we’ve used another of Make’s automatic variables: $^
is a list of
all the dependencies, separated with spaces. It’s handy when all your recipe
does is combine all the dependencies into an aggregate file.
It’s customary to make a target called all
that depends on all your project’s
compiled files, and make this the first rule in the Makefile
so that running
make
will run this rule. The all
rule is also what’s called ‘phony’: all
is not the name of an actual file, it’s just the name of a task we want to run,
and so Make should not look for a file called all
and check its last-modified
time before proceeding. Targets are marked as phony by making them dependencies
of the special .PHONY
target.
.PHONY: all
all: $(app_bundle)
And finally, we need a task to run our tests. Let’s create a little web page for doing that:
<!-- test.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>jstest</title>
</head>
<body>
<div class="fixture"></div>
<script src="./build/app.js"></script>
<script src="./node_modules/jstest/jstest.js"></script>
<script src="./build/spec/concert_template_spec.js"></script>
<script src="./build/spec/concert_view_spec.js"></script>
<script>
JS.Test.autorun()
</script>
</body>
</html>
and a PhantomJS script for launching this page and displaying the results:
// phantom.js
var JS = require("./node_modules/jstest/jstest")
var reporter = new JS.Test.Reporters.Headless({})
reporter.open("test.html")
and to top it all off, a Make task that runs the tests after making sure all the files are up to date (this task is also phony since it does not generate any files):
test: $(app_bundle) $(spec_js)
phantomjs phantom.js
It’s also customary to add a phony task called clean
that deletes any
generated files from the project, putting it back in its ‘clean’ state:
clean:
rm -rf build
So, the whole finished Makefile
looks like this, containing instructions for
compiling all the source code, building a single app bundle, and running the
tests:
PATH := node_modules/.bin:$(PATH)
SHELL := /bin/bash
source_files := $(wildcard lib/*.coffee)
build_files := $(source_files:%.coffee=build/%.js)
template_source := templates/*.handlebars
template_js := build/templates.js
app_bundle := build/app.js
spec_coffee := $(wildcard spec/*.coffee)
spec_js := $(spec_coffee:%.coffee=build/%.js)
libraries := vendor/jquery.js \
node_modules/handlebars/dist/handlebars.runtime.js \
node_modules/underscore/underscore.js \
node_modules/backbone/backbone.js
.PHONY: all clean test
all: $(app_bundle)
build/%.js: %.coffee
coffee -co $(dir $@) $<
$(template_js): $(template_source)
mkdir -p $(dir $@)
handlebars $(template_source) > $@
$(app_bundle): $(libraries) $(build_files) $(template_js)
uglifyjs -cmo $@ $^
test: $(app_bundle) $(spec_js)
phantomjs phantom.js
clean:
rm -rf build
If you run make test
, you’ll see all the compiled files are generated on the
first run, but not regenerated afterward. They will only be regenerated if the
source files change and you run a task that depends on them. Expressing the
dependencies between files lets Make save you a lot of time waiting for things
to unnecessarily recompile.
$ make test
coffee -co build/lib/ lib/concert.coffee
coffee -co build/lib/ lib/concert_view.coffee
mkdir -p build/
handlebars templates/*.handlebars > build/templates.js
uglifyjs -cmo build/app.js vendor/jquery.js node_modules/handlebars/dist/handlebars.runtime.js \
node_modules/underscore/underscore.js node_modules/backbone/backbone.js \
build/lib/concert.js build/lib/concert_view.js build/templates.js
coffee -co build/spec/ spec/concert_template_spec.coffee
coffee -co build/spec/ spec/concert_view_spec.coffee
phantomjs phantom.js
Loaded suite: templates.concert(), ConcertView
....
Finished in 0.015 seconds
4 tests, 4 assertions, 0 failures, 0 errors
We’ve got rather a lot done with very little configuration, and no need for plugins to glue the programs we want to use into Make. We can use whatever programs we want, Make will happily execute whatever we tell it to without us needing to write any glue code between Make and the compiler tools themselves. That means fewer things to install, audit and keep up to date, and more time getting on with your project.
You can add whatever build steps you like to these recipes, so long as you follow the pattern of describing relationships between files. You can add in other testing tools like JSHint if you like, and even make them into dependencies of other tasks so that the downstream tasks won’t run unless your tests are good. That’s how I build my book: the dependencies are set up so that the book won’t build unless all the example tests pass first.
Having all these steps automated is a big time-saver, and setting them up so quickly without needing to install any tools beyond what comes with Unix means you’ve no excuse not get your project organised. It also encourages you to write functionality you need as generic scripts, rather than hiding it away inside plugins that only work with a particular build system. You save time and the whole community benefits. Not bad for a build tool from the eighties.