I previously posted about using GNU Make to manage front-end assets for a website. A colleague suggested that I should check out Grunt as it does everything I need to do and more. So here it is.
I have the same goals as I did last week:
- concatenate an arbitrary combination of js files, minifying them in the process
- preprocess css with sass
- copy directories i and lib untouched
- run a watch process to update files as they’re changed
Installing grunt
Grunt is part of the node.js ecosystem, and as such is available via the node package manager (npm). Npm is available on OS X via Homebrew.
Basic npm concepts
There are a few things that we need to understand about npm. The biggest headache was recognizing the difference between local and global installs and knowing when to use which.
- Npm installs packages into a project (unless the
-g
global option is specified, more on that later) and needs to be run in project root. Packages then go into a subdirectory callednode_packages
. - If you’re in some other directory when running npm, the packages will go into a
node_packages
subdirectory there and confuse you. - Npm expects to see a file called
package.json
in the project root directory and complains if it’s not there.package.json
includes a list of packages that the project depends on, and the defaultnpm install
without any parameters installs those packages. - When installing a package explicitly, there is in an option to add an entry to
package.json
so that someone else will be able to usenpm install
and get everything. Note that this is an option and not the default behavior.
Creating the package.json file
According to the documentation,
the command to use is npm init
, and it must be run in project root. Running it starts a dialog on the terminal, asking some mostly irrelevant questions: name (defaults to the name of the project directory), version (defaults to 1.0.0), description, entry point (defaults to index.js), test command, git repository, keywords, author, and license (defaults to ISC). These questions can be suppressed by using npm init --yes
, which defaults everything.
Unfortunately, npm will complain if it doesn’t see a description, a repository field and a license field. The defaults only cover the license field, leaving the description blank and the repository field missing altogether.
The minimum package.json
has just a name and a version.
But since I’m a stickler for getting rid of warnings, I’m going to have to create my own package.json
that includes name, version, description, repository and license. None of this information is relevant; its only purpose is to make the warnings go away.
{
"name": "taco",
"version": "1.0.0",
"description": "xyz",
"repository": {
"type": "git",
"url": "xyz"
},
"license": "ISC"
}
Unfortunately there’s one warning I can’t get rid of. At the time of this writing, npm install grunt
produces this:
npm WARN deprecated lodash@0.9.2: lodash@<2.0.0 is no longer maintained. Upgrade to lodash@^3.0.0
According to the changelog for lodash, version 0.9.2 was released in 2012, and the current version is 4.0.0. Even the “upgrade to” version of 3.0.0 is a year old already. This is a red flag; how and why are these dependencies not getting maintained? That said, it appears that an update is on the way. Will have to ignore this warning for now.
Grunt plugins
Grunt itself is just the overlord; to do any real work we’re going to need some plugins. After a lot of googling, I’ve come up with this list:
- To minify and combine javascript files, we can use
grunt-contrib-uglify
. - To compile scss into css, we can use
grunt-contrib-sass
. - To copy directories, we can use
grunt-contrib-copy
. - To delete old files, we can use
grunt-contrib-clean
. - To watch for changes and recompile, we can use
grunt-contrib-watch
.
All of these are marked as officially maintained, giving us the warm, fuzzy feeling that everything is going to work.
We can now install grunt and the plugins.
npm install grunt grunt-contrib-uglify grunt-contrib-sass grunt-contrib-copy grunt-contrib-clean grunt-contrib-watch --save-dev
Grunt command line
There is one more install required if we are to be able to run grunt from the command line. The package is grunt-cli
, and needs to be installed globally so that the grunt executable goes into /usr/local/bin and is available in the system path.
npm install grunt-cli -g
It’s possible to install grunt-cli
in the project directory, but then the executable will be in node_modules/.bin instead of /usr/local/bin, and that makes more headaches for us
One gotcha is that the global grunt-cli requires a local grunt or it will fail. Grunt-cli is a wrapper to find the locally installed grunt to whatever project you’re in. The global grunt-cli will not find a global grunt.
Summary of grunt installation
- Install npm (e.g.
brew install npm
). - Create the package.json file shown above.
npm install grunt grunt-contrib-uglify grunt-contrib-sass grunt-contrib-copy grunt-contrib-clean grunt-contrib-* watch --save-dev
npm install grunt-cli -g
package.json
should go into source control, and node_modules
should be excluded from source control with the appropriate entry in .gitignore
.
Once we have package.json
as updated by the npm install –save-dev command, steps 2 and 3 can be replaced by a simple npm install
. We still need to keep step 4; global packages can’t go into package.json
(npm will ignore --save-dev
when -g
is specified).
Optionally installing grunt-cli locally
Installing grunt-cli
locally instead of globally will allow it to be included in package.json
, but it has the side effect of not having the grunt executable in the path. A possible workaround to this side effect is to add a script section to package.json
with all the grunts you want to do.
"scripts": { "watch": "grunt watch" }
Then you can type npm run watch
instead of grunt watch
. This may or may not be worth the trouble.
Writing a gruntfile
Basic gruntfile concept
The gruntfile is a bit of javascript initialization that gets run whenever grunt is invoked. The gruntfile needs to define an initialization function and assign that to the global module.exports
. Within the initialization function, we’ll need to list the modules we need (grunt-contrib-uglify, etc.), specify some configuration for each module, define the default task, and optionally define additional tasks.
Each plugin defines a task of the same name as the plugin (e.g. grunt-contrib-uglify defines an “uglify” task, under which any number of subtasks may be defined).
The gruntfile is named Gruntfile.js
and resides in project root. The basic gruntfile structure is:
module.exports = function(grunt) {
grunt.initConfig({
pluginname: { ... } // one of these for each plugin
};
grunt.loadNpmTasks( ... ); // one of these for each plugin
grunt.registerTask('default', ... ); // define the default behavior of `grunt` with no parameters
grunt.registerTask( ... ); // optional additional tasks
}
Each plugin defines a task of the same name as the plugin (e.g. grunt-contrib-uglify defines an “uglify” task, under which any number of subtasks may be defined). Defining additional tasks is useful for combining tasks into a single command.
A thorough read of the docs along with some examples gives us enough information to build a single gruntfile, giving us the following commands:
grunt
does a clean build, deletingpub
if it exists and building everything fromsrc
.grunt build
does an incremental build of js and css files, updating only those files whose source has changed.grunt copy
syncs the directoriesi
andlib
fromsrc
topub
.grunt watch
runs until you kill it, watching for changes insrc
and updatingpub
as necessary.
Note that grunt
is short for grunt all
, which does grunt clean
+ grunt copy
+ grunt build
.
Observations
- Overall, the quality of documentation is poor. I had to resort to copying examples and then modifying them by trial and error until I got the results I wanted. There are many alternate syntaxes, causing further confusion.
- Could not find a way to do incremental updates with uglify. The entire js collection is rebuilt whenever any js source file changes.
- The sass plugin depends on having command-line sass installed as a ruby gem, a dependency that I grudgingly accepted when writing the previous makefile and was hoping to avoid.
- Dependencies from
@import
statements in scss source files are handled nicely; the dependencies are honored when doing an incremental build and don’t need to be included in the gruntfile. This is nice. - The
grunt-contrib-copy
plugin doesn’t know how to sync. Thei
andlib
directories are copied in their entireties every time there’s a change. There is another plugin which claims to know how to sync, but I haven’t tested it.
Conclusion
This was a whole lot of trouble to set up a relatively simple build system. Grunt is a powerful tool, and I can see the value of using it when you’re already in a node-based project, but it to use it as an isolated build tool is not worth the effort.
The only thing we gained with Grunt is the ability to auto-detect imports in .scss files and do incremental updates accordingly. At the same time we lost the ability to incremental updates of the Javascript files, at least with the standard plugin.
I was also hoping to avoid the ruby sass dependency by using the plugin, but no luck there since the plugin is just a wrapper for the command line sass.