grunt-cachebust-plus
Version:
Bust static assets from the cache using content hashing, inspired by hollandben's grunt-cache-bust plugin
297 lines (232 loc) • 9.98 kB
JavaScript
;
module.exports = function(grunt) {
var fs = require('fs');
var path = require('path');
var crypto = require('crypto');
var cheerio = require('cheerio');
var css = require('css');
var remoteRegex = /http:|https:|\/\/|data:image/;
var extensionRegex = /(\.[a-zA-Z0-9]{2,4})(|\?.*)$/;
var urlFragHintRegex = /'(([^']+)#grunt-cache-bust)'|"(([^"]+)#grunt-cache-bust)"/g;
var filenameSwaps = {};
var regexEscape = function(str) {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
};
var cheerioOptions = {
ignoreWhitespace: true,
lowerCaseTags: true
};
var options = {
algorithm: 'md5',
deleteOriginals: false,
encoding: 'utf8',
// length: 16,
replaceTerms: [],
rename: true,
separator: '.',
ignorePatterns: [],
filters: {},
jsonOutput: false,
jsonOutputFilename: 'cachebuster.json',
assetPathReplacement: []
};
var defaultFilters = {
'script': function() {
return this.attribs['src'];
},
'link[rel="stylesheet"]': function() {
return this.attribs['href'];
}
};
var checkIfRemote = function(path) {
return remoteRegex.test(path);
};
var checkIfHasExtension = function(path) {
return extensionRegex.test(path);
};
var checkIfValidFile = function(path) {
return path !== 'undefined' && path !== undefined && !checkIfRemote(path) && checkIfHasExtension(path);
};
/** @this Object An elem on which attr() may be called for src or href. */
var checkIfElemSrcValidFile = function() {
return checkIfValidFile(this.attr('src')) || checkIfValidFile(this.attr('href'));
};
grunt.file.defaultEncoding = options.encoding;
grunt.registerMultiTask('cacheBustPlus', 'Bust static assets from the cache using content hashing', function() {
var opts = grunt.util._.defaults(this.options(), options);
var filters = grunt.util._.defaults(opts.filters, defaultFilters);
var generateHash = function(fileData) {
return opts.hash || crypto.createHash(opts.algorithm).update(fileData, opts.encoding).digest('hex');
};
var addHash = function(str, hash, extension) {
return str.replace(extension, '') + opts.separator + hash + extension;
};
var findStaticAssets = function(data, filters, isCSS) {
var $ = cheerio.load(data, cheerioOptions);
var paths = [];
if (isCSS) {
var cssObj = css.parse(data);
// Loop through each stylesheet rules
cssObj.stylesheet.rules.forEach(function(rule) {
// Loop through all declarations
if (rule.declarations) {
rule.declarations.forEach(function(declaration) {
// Check if it has a background property, and if so, checkt that it contains a URL
if ((/background/).test(declaration.property) && (/url/).test(declaration.value)) {
paths.push(declaration.value.match(/url\(["|']?(.*?)['|"]?\)/)[1]);
}
});
}
});
} else {
// Add any conditional statements or assets in comments to the DOM
var assets = '';
$('head, body').contents().filter(function() {
return this[0].type === 'comment';
}).each(function(i, e) {
assets += e.data.replace(/\[.*\]>|<!\[endif\]/g, '').trim();
});
$('body').append(assets);
}
Object.keys(filters).forEach(function(key) {
var mappers = filters[key];
var addPaths = function(mapper) {
var i,
item,
foundPaths = $(key)
.filter(checkIfElemSrcValidFile)
.map(mapper)
.filter(function(path, el) {
var rtn = false;
if (el) {
rtn = true;
}
return rtn;
});
for (i = 0; i < foundPaths.length; i++) {
// grunt.log.writeln('foundPaths['+i+']: '+foundPaths[i]);
// for(j in opts.assetPathReplacement){
// foundPaths[i] = foundPaths[i].replace(j, opts.assetPathReplacement[j]);
// }
// opts.assetPathReplacement.forEach(function(rep, j) {
// foundPaths[i] = foundPaths[i].replace(rep, '');
// });
paths = paths.concat(foundPaths[i]);
}
};
if (grunt.util.kindOf(mappers) === 'array') {
mappers.forEach(addPaths);
} else {
addPaths(mappers);
}
});
var match, potentialPath;
// Find any strings containing the hash `#grunt-cache-bust`
while ((match = urlFragHintRegex.exec(data)) != null) {
potentialPath = match[2] || match[4];
if (checkIfValidFile(potentialPath)) {
paths.push(potentialPath);
}
}
grunt.log.writeln(paths.join(';'));
return paths.filter(function(path, index) {
return paths.indexOf(path) === index;
});
};
var processedFileMap = {};
var processedFileHashMap = {};
this.files.forEach(function(file) {
var src = file.src.filter(function(filepath) {
// Warn on and remove invalid source files (if nonull was set).
if (!grunt.file.exists(filepath)) {
grunt.log.warn('Source file "' + filepath + '" not found.');
return false;
}
return true;
}).map(function(filepath) {
var markup = grunt.file.read(filepath);
var isCSS = (/\.css$/).test(filepath);
findStaticAssets(markup, filters, isCSS).forEach(function(reference) {
var newFilename;
var newFilePath;
var newReference;
reference = reference.split('?')[0];
// do the asset path replacement, this is useful in template engine like freemarker
var j,
refReplaced = reference.slice(0);
for(j in opts.assetPathReplacement){
refReplaced = refReplaced.replace(j, opts.assetPathReplacement[j]);
}
grunt.log.writeln('refReplaced: '+refReplaced);
var filePath = (opts.baseDir ? opts.baseDir : path.dirname(filepath)) + '/';
var filename = path.normalize((filePath + refReplaced).split('?')[0]);
var originalFilename = filename;
var originalReference = reference;
if (opts.ignorePatterns) {
var matched = opts.ignorePatterns.some(function(pattern) {
return new RegExp(pattern, 'ig').test(filename);
});
if (matched) {
return false;
}
}
if (opts.rename) {
// If the file has already been cached, use that
if (processedFileMap[filename+reference]) {
markup = markup.replace(new RegExp(regexEscape(reference), 'g'), processedFileMap[filename+reference]);
} else {
var hashReplaceRegex = new RegExp(regexEscape(opts.separator) + '(' + (opts.hash ? opts.hash + '|' : '') + '[a-zA-Z0-9]{' + opts.length + '})', 'ig');
// Get the original filename
filename = filename.replace(hashReplaceRegex, '');
// Replacing specific terms in the import path so renaming files
if (opts.replaceTerms && opts.replaceTerms.length > 0) {
opts.replaceTerms.forEach(function(obj) {
grunt.util._.each(obj, function(replacement, term) {
filename = filename.replace(term, replacement);
reference = reference.replace(term, replacement);
});
});
}
if (!grunt.file.exists(filename)) {
grunt.log.warn('Static asset "' + filename + '" skipped because it wasn\'t found.');
return false;
}
var hash = processedFileHashMap[filename]||(processedFileHashMap[filename] = generateHash(grunt.file.read(filename)));
// Create our new filename
newFilename = addHash(filename, hash, path.extname(filename));
// Create the new reference
newReference = addHash(reference.replace(hashReplaceRegex, ''), hash, path.extname(filename));
// Update the reference in the markup
markup = markup.replace(new RegExp(regexEscape(originalReference), 'g'), newReference);
// Create our new file
grunt.file.copy(filename, newFilename);
}
} else {
newFilename = reference.split('?')[0] + '?' + generateHash(grunt.file.read(filename));
newReference = newFilename;
markup = markup.replace(new RegExp(regexEscape(reference), 'g'), newFilename);
}
if (newFilename) {
processedFileMap[originalFilename+originalReference] = newReference;
}
});
grunt.file.write(filepath, markup);
grunt.log.writeln(['The file ', filepath, ' was busted!'].join(''));
});
});
// Delete the original files, if enabled
if (opts.rename && opts.deleteOriginals) {
for (var file in processedFileMap) {
if (grunt.file.exists(file)) {
grunt.file.delete(file);
}
}
}
// Generate a JSON with the swapped file names if requested
if (opts.jsonOutput) {
var name = typeof opts.jsonOutput === 'string' ? opts.jsonOutput : opts.jsonOutputFilename;
grunt.log.writeln(['File map has been exported to ', opts.baseDir + name, '!'].join(''));
grunt.file.write(path.normalize(opts.baseDir + '/' + name), JSON.stringify(processedFileMap));
}
});
};