Building a Better Grunt Plugin

Here at Sprout we’ve been switching over our build process from Phing to Grunt. It was an obvious choice since most of us are mainly JavaScript devs on the front-end side and we’re trying to minimize the amount of PHP we all have to touch.

Grunt Background

Grunt is a task runner framework built on top of Node.js. A Grunt-based build typically involves a Gruntfile.js in the root of your project and a couple Grunt plugins installed via NPM.

Default Plugin Template

The default plugin template is pretty straightforward. It involves a package.json describing your module (like all other NPM modules) and at least a peerDependency on grunt. A typical contrib plugin is structured with a tasks directory containing a single JavaScript file that exports an init function for Grunt.

module.exports = function(grunt) {

  grunt.registerMultiTask('{%= short_name %}', 'Your task description goes here.', function() {
    // Merge task-specific and/or target-specific options with these defaults.
    var options = this.options({
      punctuation: '.',
      separator: ', '
    });

    // Iterate over all specified file groups.
    this.files.forEach(function(f) {
      // Do something to some files...

      // Print a success message.
      grunt.log.writeln('File "' + f.dest + '" created.');
    });
  });

};


 

A Better Plugin Template

I’m not a fan of the single init function. It leaves your code largely untestable and it doesn’t scale well as your task becomes more complicated. I favor a class-based approach that lets us test the code outside of Grunt in an easier fashion. Here is an example of a structure that I use when creating plugins:

/docs
  - intro.md
  - options.md
  - license.md
/lib
  - jsHintTask.js
/tasks
  - jshint2.js
/tests
Gruntfile.js
package.json

You’ll notice from the structure that we’ve separated out the task registration from the actual work being done. The JSHintTask will now hold the logic for our task, and we can require it independent of the task being registered with Grunt.

Now let’s take a look at the shell of our task class.

function JSHintTask(task) {
    this.origTask = task;

    this.options = task.options(JSHintTask.Defaults);
}

JSHintTask.prototype = {
    // Get the party started.
    run: function() {

    }
};

// A static attribute holding our defaults so we can test against them.
JSHintTask.Defaults = {
    something: true
};

// Some static task information
JSHintTask.taskName = "jshint2";
JSHintTask.taskDescription = "A better jshint task";

// A static helper method for registering with Grunt
JSHintTask.registerWithGrunt = function(grunt) {

    grunt.registerMultiTask(JSHintTask.taskName, JSHintTask.taskDescription, function() {
        var task = new JSHintTask(this);

        task.run();
    });
};

module.exports = JSHintTask;


We pass the original task in to the constructor, which allows us to grab the files and options. In addition, we’ve added some static fields on the class that should help with registration and testing. Now all we need in our task registration file is this:

var JSHintTask = require("../lib/jsHintTask");

module.exports = JSHintTask.registerWithGrunt;

Okay, that’s probably over the top as far as keeping our code separated but I really like keeping that registration file small.

Untested Code is Broken Code

Here is a quick example of how to setup your task for testing.

var grunt = require("grunt"),
    should = require("should"),
    _ = grunt.util._;

var JSHintTask = require("../lib/jsHintTask");

describe("JSHintTask", function() {

    var makeMockTask = function(done) {
        return {
            _taskOptions: { bad: "option" },
            filesSrc: grunt.file.expand("test/res/good*.js"),
            options: function(defs) { return _.defaults(this._taskOptions, defs); },
            async: function() {
                return done;
            }
        };
    };

    it("registers itself with grunt", function() {
        should.exist(JSHintTask.registerWithGrunt);

        JSHintTask.registerWithGrunt(grunt);

        // Check that it registered
        should.exist(grunt.task._tasks[JSHintTask.taskName]);
        grunt.task._tasks[JSHintTask.taskName].info.should.equal(JSHintTask.taskDescription);
    });

    it("loads options from a task", function() {
        var task = new JSHintTask(makeMockTask()),
            actual = task.options;

        should.exist(actual);

        actual.something.should.equal(JSHintTask.Defaults.something);
    });
});

Wrapping Up

Now we’ve got ourselves setup on some solid footing and ready to grow as our code needs. I’ll be working on getting this template into a grunt-init version soon, so be on the lookout.