More Build Stuff - Grunt

Behold! The 3rd post on automated build/dev processes for the web.

This time we’re looking at Grunt. We’re looking at Grunt because I blew up by Guard setup on Windows (who knew gem update could be a bad idea) and used the opportunity to give Grunt another look. I poured myself a beer, swaggered over to my standing desk, and didn’t move until I figured it out. I’m really glad I did - Grunt is my new best friend.

Grunt is a JavaScript task runner built on Node.js. Node.js is an event-driven platform built on Chrome’s V8 and is blazingly fast. If you need efficient, uber-fast, real-time multi-user shiny, get to know Node.

To get Grunt up and running you need to install Node, then install Grunt via the node package manager (npm): npm install -g grunt-cli. I’m using 5 modules that can also be installed with npm:

1
npm install grunt-contrib-watch grunt-contrib-uglify grunt-contrib-concat grunt-contrib-less grunt-text-replace --save-dev

You will typically create a package.json file that contains, among other things, your dependencies. If you’ve got one, you can just run npm install and it’ll read it and grab whatever is missing. To create a package.json file from your current dependencies run npm init. A typical package.json file looks like this:

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"name": "geoportal",
"version": "0.0.1",
"description": "Mecklenburg GeoPortal build and watch, including cache busting on build.",
"main": "Gruntfile.js",
"devDependencies": {
"grunt-text-replace": "*",
"grunt-contrib-concat": "*",
"grunt-contrib-less": "*",
"grunt-contrib-watch": "*",
"grunt-contrib-uglify": "*"
},
"scripts": {
"test": "grunt build"
},
"author": "Tobin Bradley",
"license": "MIT",
"readmeFilename": "README.md",
"repository": ""
}

The serious grunt stuff goes in Gruntfile.js. When you look at this one you might throw up in your mouth a little bit. Yes, I was doing all of this in 12 lines of Ruby + 14 lines of shell script. Yes, that’s a lot less than ~120 lines of code. Hang with me here.

Gruntfile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
module.exports = function (grunt) {
// Load plugins
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-text-replace');

// Default task(s).
grunt.registerTask('default', ['watch']);
grunt.registerTask('build', ['less:production', 'uglify:production', 'replace']);

// javascript stack
var jsFiles = [
'assets/scripts/vendor/underscore-min.js',
'assets/scripts/vendor/modernizr.min.js',
'assets/scripts/vendor/bootstrap/bootstrap-tooltip.js',
'assets/scripts/vendor/bootstrap/bootstrap-modal.js',
'assets/scripts/vendor/bootstrap/bootstrap-transition.js',
'assets/scripts/vendor/bootstrap/bootstrap-button.js',
'assets/scripts/vendor/bootstrap/bootstrap-collapse.js',
'assets/scripts/vendor/bootstrap/bootstrap-popover.js',
'assets/scripts/vendor/jquery-ui-1.10.0.custom.min.js',
'assets/scripts/vendor/leaflet.js',
'assets/scripts/functions.js',
'assets/scripts/map.js',
'assets/scripts/page.js'
];

grunt.initConfig({
concat: {
development: {
src: jsFiles,
dest: 'public/js/main.js'
}
},
uglify: {
production: {
options: {
report: 'gzip',
},
files: {
'public/js/main.js': jsFiles
}
}
},
less: {
development: {
files: {
"public/css/main.css": "assets/less/main.less"
}
},
production: {
options: {
yuicompress: true
},
files: {
"public/css/main.css": "assets/less/main.less"
}
}
},
watch: {
options: {
livereload: true
},
less: {
files: ['assets/less/*.less'],
tasks: ['less:development'],
options: {
livereload: false
}
},
asset_javascript: {
files: ['assets/**/*.js'],
tasks: ['concat:development'],
options: {
livereload: false
}
},
templates: {
files: ['public/templates/*.html'],
tasks: ['replace:template'],
options: {
livereload: false
}
},
javascript: {
files: ['public/js/*.js']
},
css: {
files: ['public/css/*.css']
},
html: {
files: ['public/*.html']
}
},
replace: {
foo: {
src: ['public/index.html'],
overwrite: true,
replacements: [{
from: /\?foo=[0-9]*/g,
to: function () {
var cacheBuster = Math.floor((Math.random() * 100000) + 1);
return '?foo=' + cacheBuster;
}
}]
},
template: {
src: ['assets/scripts/functions.js'],
overwrite: true,
replacements: [{
from: /templateVersion: "[0-9]*"/g,
to: function () {
var cacheBuster = Math.floor((Math.random() * 100000) + 1);
return 'templateVersion: "' + cacheBuster + '"';
}
}]
}
}
});

};

With this build script I’m able to do everything I was doing before - watch, livereload, preprocessing, concatenation, minification, uglification, and cache busting (string replacement). It’s all fairly readable JavaScript and easily customizable across projects (this one is for the new version of GeoPortal I’m working on). And it’s node, so it hauls ass.

Let’s break it down.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Load plugins
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-text-replace');

// Default task(s).
grunt.registerTask('default', ['watch']);
grunt.registerTask('build', ['less:production', 'uglify:production', 'replace']);

// javascript stack
var jsFiles = [
'assets/scripts/vendor/underscore-min.js',
'assets/scripts/vendor/modernizr.min.js',
'assets/scripts/vendor/bootstrap/bootstrap-tooltip.js',
'assets/scripts/vendor/bootstrap/bootstrap-modal.js',
'assets/scripts/vendor/bootstrap/bootstrap-transition.js',
'assets/scripts/vendor/bootstrap/bootstrap-button.js',
'assets/scripts/vendor/bootstrap/bootstrap-collapse.js',
'assets/scripts/vendor/bootstrap/bootstrap-popover.js',
'assets/scripts/vendor/jquery-ui-1.10.0.custom.min.js',
'assets/scripts/vendor/leaflet.js',
'assets/scripts/functions.js',
'assets/scripts/map.js',
'assets/scripts/page.js'
];

Here we’re doing some basic setup. We’re requiring 5 grunt modules - watch, uglify, concat, less, and text-replace. We register two tasks: default (simple watch setup) and build (make production ready). So you could type grunt to start the watch process, or grunt build to build that sucker. Last I’m setting an array of the JavaScript files in the order they need to be put together. That’ll get used next.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
concat: {
development: {
src: jsFiles,
dest: 'public/js/main.js'
}
},
uglify: {
production: {
options: {
report: 'min',
},
files: {
'public/js/main.js': jsFiles
}
}
}

This section is for JavaScript. concat simply concatenates JavaScript files for development so they’re easy to debug. uglify turns it into the compact unreadable bandwidth-friendly stuff for production. Note that if you ever want to run one of these by itself, just reference it via something like grunt concat:development.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
less: {
development: {
files: {
"public/css/main.css": "assets/less/main.less"
}
},
production: {
options: {
yuicompress: true
},
files: {
"public/css/main.css": "assets/less/main.less"
}
}
},

LESS is a CSS preprocessor, and here we’re giving it development (just concatenate), and production (concatenate and compress) options.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
watch: {
options: {
livereload: true
},
less: {
files: ['assets/less/*.less'],
tasks: ['less:development'],
options: {
livereload: false
}
},
asset_javascript: {
files: ['assets/**/*.js'],
tasks: ['concat:development'],
options: {
livereload: false
}
},
templates: {
files: ['public/templates/*.html'],
tasks: ['replace:template'],
options: {
livereload: false
}
},
javascript: {
files: ['public/js/*.js']
},
css: {
files: ['public/css/*.css']
},
html: {
files: ['public/*.html']
}
}

Watch does just that - it watches files and runs tasks when they change. It also can do livereload, which if you’re not doing…I just don’t know what to say to you. Start doing it. Here we’re compiling LESS when *.less changes, concatenating JavaScript when an asset changes, and doing some text replacement when a underscore template changes. Note none of that triggers livereload directly. Instead, livereload watches our public folder for when the changed stuff shows up. That way reload events aren’t firing multiple times.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
replace: {
foo: {
src: ['public/index.html'],
overwrite: true,
replacements: [{
from: /\?foo=[0-9]*/g,
to: function () {
var cacheBuster = Math.floor((Math.random() * 100000) + 1);
return '?foo=' + cacheBuster;
}
}]
},
template: {
src: ['assets/scripts/functions.js'],
overwrite: true,
replacements: [{
from: /templateVersion: "[0-9]*"/g,
to: function () {
var cacheBuster = Math.floor((Math.random() * 100000) + 1);
return 'templateVersion: "' + cacheBuster + '"';
}
}]
}
}

Finally, some cache busting. These tasks replace a version number with a large random number in a couple of different places so your clients don’t get crap stuck in their cache.