Skip to content

Instantly share code, notes, and snippets.

@markrian
Created August 21, 2014 11:07
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save markrian/aa185c5ec66232a38a68 to your computer and use it in GitHub Desktop.
Save markrian/aa185c5ec66232a38a68 to your computer and use it in GitHub Desktop.
Opinionated grunt task to manage filerev and usemin to ensure correct hashes after replacements. See https://github.com/yeoman/grunt-usemin/issues/288.
'use strict';
/**
* Vermin completely manages the running of the filerev and usemin tasks, so
* that assets that reference other assets are correctly hashed after their
* referenced assets are spliced in by usemin.
*
* Vermin also makes various assumptions about how filerev and usemin are set
* up. For instance, it assumes that usemin runs immediately after filerev,
* that filerev renames files in-place, and that usemin relies on
* `grunt.filerev.summary`, as prepared by filerev (which vermin manipulates).
* There may be other implicit assumptions that haven't been identified yet.
*
* Because grunt tasks can only be inserted into the task stack, to be run
* sequentially, vermin recursively adds filerev, usemin and itself to the
* stack to do its work. It maintains its state on the grunt object, and reads
* that on each run to determine where in the process it is.
*/
var fs = require('fs');
var _ = require('lodash');
function renameFiles(mapping) {
for (var from in mapping) {
fs.renameSync(from, mapping[from]);
}
}
function checkConfig(grunt) {
var key;
var config = grunt.config.getRaw();
for (key in config.filerev) {
if (!('src' in config.filerev[key])) {
grunt.fail.fatal(
'vermin requires filerev to have a src list for each target.');
}
if ('dest' in config.filerev[key]) {
grunt.fail.fatal('vermin expects filerev to rename files in-place,' +
' but a dest is specified for ' + key + '.');
}
}
if ('revmap' in config.usemin.options) {
grunt.fail.fatal("vermin expects usemin to use filerev's summary," +
" but a revmap is specified.");
}
}
function RevHistory(initialSummary) {
this.history = _.mapValues(initialSummary, function (v) {
return [v];
});
this.length = 1;
}
_.extend(RevHistory.prototype, {
add: function (summary) {
for (var orig in summary) {
this.history[orig].push(summary[orig]);
}
this.length += 1;
},
origToX: function (index) {
var ret = _.mapValues(this.history, function (list) {
return list[index];
});
return ret;
},
origToNewest: function () {
return this.origToX(this.length - 1);
},
origToPrevious: function () {
return this.origToX(this.length - 2);
},
previousToNewest: function () {
var ret = {};
var newest = this.length - 1;
var previous = newest - 1;
for (var orig in this.history) {
ret[this.history[orig][previous]] = this.history[orig][newest];
}
return ret;
}
});
module.exports = function (grunt) {
grunt.registerTask('vermin',
'Run filerev and usemin until all file hashes stop changing,' +
' or an infinite loop is detected',
function () {
function runTasksThenVermin(taskNames, state) {
vermin.state = state || taskNames[0];
grunt.task.run(taskNames.concat('vermin'));
}
/**
* Detect infinite loops by checking whether the same set of files were
* changed in a previous iteration.
*/
function inInfiniteLoop(files) {
return vermin.changedFilesHistory.some(function (changedFiles) {
if (_.isEqual(changedFiles, files)) {
return true;
}
});
}
var vermin = (grunt.vermin = (grunt.vermin || {
state: 'firstrun',
changedFilesHistory: [],
revHistory: null,
}));
if (vermin.state === 'firstrun') {
checkConfig(grunt);
runTasksThenVermin(['filerev', 'usemin'], 'init');
return;
}
if (vermin.state === 'init') {
vermin.revHistory = new RevHistory(grunt.filerev.summary);
vermin.state = 'usemin';
}
if (vermin.state === 'usemin') {
var toOrig = _.invert(vermin.revHistory.origToNewest());
renameFiles(toOrig);
// filerev appends to its summary, so we must reset it
grunt.filerev.summary = {};
runTasksThenVermin(['filerev']);
return;
}
if (vermin.state === 'filerev') {
vermin.revHistory.add(grunt.filerev.summary);
var origToPrevious = vermin.revHistory.origToPrevious();
var origToNewest = vermin.revHistory.origToNewest();
var lastChanged = [];
for (var orig in origToPrevious) {
var prevRev = origToPrevious[orig];
var newRev = origToNewest[orig];
if (newRev !== prevRev) {
lastChanged.push(orig);
}
}
// Important to sort, so that infinite loop check works.
lastChanged.sort();
if (inInfiniteLoop(lastChanged)) {
grunt.fail.fatal(
"Infinite loop due to circular references in files." +
"File hash change history: ",
JSON.stringify(vermin.changedFilesHistory, null, 2)
);
}
if (lastChanged.length) {
grunt.log.writeln("Changed files: "+lastChanged);
vermin.changedFilesHistory.push(lastChanged);
// so that usemin does the right replacements
grunt.filerev.summary = vermin.revHistory.previousToNewest();
runTasksThenVermin(['usemin']);
} else {
grunt.log.writeln("Vermin done. Rev change log: "+
JSON.stringify(vermin.changedFilesHistory, null, 2));
}
}
});
};
@markrian
Copy link
Author

Note that I wrote this for a specific codebase without no concern for portability or flexibility; only expediency. Feel free to fork and improve!

To use, replace your two consecutive build tasks 'filerev', 'usemin' with 'vermin'. If you run those tasks with explicit targets, you'll need to modify vermin accordingly. In fact, if anything other than the various assumptions are met, you'll have to modify vermin ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment