var Gutil = require('gulp-util'); var Merge = require('merge'); var Path = require('path'); var Tool = require('./tool'); var Revisioner = (function () { 'use strict'; var Revisioner = function(options) { var defaults = { 'hashLength': 8, 'dontGlobal': [ /^\/favicon.ico$/g ], 'dontRenameFile': [], 'dontUpdateReference': [], 'dontSearchFile': [], 'fileNameVersion': 'rev-version.json', 'fileNameManifest': 'rev-manifest.json', 'prefix': '', 'referenceToRegexs': referenceToRegexs, 'annotator': annotator, 'replacer': replacer, 'debug': false, 'includeFilesInManifest': ['.css', '.js'] }; this.options = Merge(defaults, options); // File pool, any file passed into the Revisioner is stored in this object this.files = {}; this.filesTemp = []; // Stores the combined hash of all processed files, used to create the version file this.hashCombined = ''; // Stores the before : after path of assets, used to create the manifset file this.manifest = {}; // Enable / Disable logger based on supplied options this.log = (this.options.debug) ? Gutil.log : function () {}; // Make tools available client side callbacks supplied in options this.Tool = Tool; var nonFileNameChar = '[^a-zA-Z0-9\\.\\-\\_\\/]'; var qoutes = '\'|"'; function referenceToRegexs(reference) { var escapedRefPathBase = Tool.path_without_ext(reference.path).replace(/([^0-9a-z])/ig, '\\$1'); var escapedRefPathExt = Path.extname(reference.path).replace(/([^0-9a-z])/ig, '\\$1'); var regExp, regExps = []; var isJSReference = reference.path.match(/\.js$/); // Extensionless javascript file references has to to be qouted if (isJSReference) { regExp = '('+ qoutes +')(' + escapedRefPathBase + ')()('+ qoutes + '|$)'; regExps.push(new RegExp(regExp, 'g')); } // Expect left and right sides of the reference to be a non-filename type character, escape special regex chars regExp = '('+ nonFileNameChar +')(' + escapedRefPathBase + ')(' + escapedRefPathExt + ')('+ nonFileNameChar + '|$)'; regExps.push(new RegExp(regExp, 'g')); return regExps; } function annotator(contents, path) { return [{'contents': contents}]; } function replacer(fragment, replaceRegExp, newReference, referencedFile) { fragment.contents = fragment.contents.replace(replaceRegExp, '$1' + newReference + '$3$4'); } }; Revisioner.prototype.versionFile = function () { var out = { hash: this.hashCombined, timestamp: new Date() }; var file = new Gutil.File({ cwd: this.pathCwd, base: this.pathBase, path: Path.join(this.pathBase, this.options.fileNameVersion), contents: new Buffer(JSON.stringify(out, null, 2)), revisioner: this }); file.revisioner = this; return file; }; Revisioner.prototype.manifestFile = function () { var file = new Gutil.File({ cwd: this.pathCwd, base: this.pathBase, path: Path.join(this.pathBase, this.options.fileNameManifest), contents: new Buffer(JSON.stringify(this.manifest, null, 2)), }); file.revisioner = this; return file; }; /** * Used to feed files into the Revisioner, sets up the original filename and hash. */ Revisioner.prototype.processFile = function (file) { if (!this.pathCwd) { this.pathCwd = file.cwd; } // Chnage relative paths to absolute if (!file.base.match(/^(\/|[a-z]:)/i)) { file.base = Tool.join_path(file.cwd, file.base); } // Normalize the base common to all the files if (!this.pathBase) { this.pathBase = file.base; } else if (file.base.indexOf(this.pathBase) === -1) { var levelsBase = this.pathBase.split(/[\/|\\]/); var levelsFile = file.base.split(/[\/|\\]/); var common = []; for (var level = 0, length = levelsFile.length; level < length; level++) { if (level < levelsBase.length && level < levelsFile.length && levelsBase[level] === levelsFile[level]) { common.push(levelsFile[level]); continue; } } if (common[common.length - 1] !== '') { common.push(''); } this.pathBase = common.join('/'); } // Set original values before any processing occurs file.revPathOriginal = file.revOrigPath = file.path; file.revFilenameExtOriginal = Path.extname(file.path); file.revFilenameOriginal = Path.basename(file.path, file.revFilenameExtOriginal); file.revHashOriginal = this.Tool.md5(String(file.contents)); file.revContentsOriginal = file.contents; this.filesTemp.push(file); }; /** * Resolves references, renames files, updates references. To be called after all the files * have been fed into the Revisioner (ie. At the end of the file stream) */ Revisioner.prototype.run = function () { this.hashCombined = ''; // Go through and correct the base path now that we have proccessed all the files coming in for (var i = 0, length = this.filesTemp.length; i < length; i++) { this.filesTemp[i].base = this.pathBase; var path = this.Tool.get_relative_path(this.pathBase, this.filesTemp[i].path); this.files[path] = this.filesTemp[i]; } // Resolve references to other files for (var path in this.files) { this.resolveReferences(this.files[path]); } // Resolve and set revisioned filename based on hash + reference hashes and ignore rules for (var path in this.files) { this.revisionFilename(this.files[path]); } // Consolidate the concatinated hash of all the files, into a single hash for the version file this.hashCombined = this.Tool.md5(this.hashCombined); // Update references to revisioned filenames for (var path in this.files) { this.updateReferences(this.files[path]); } }; /** * Go through each file in the file pool, search for references to any other file in the pool. */ Revisioner.prototype.resolveReferences = function (fileResolveReferencesIn) { var contents = String(fileResolveReferencesIn.revContentsOriginal); fileResolveReferencesIn.revReferencePaths = {}; fileResolveReferencesIn.revReferenceFiles = {}; var referenceGroupRelative = []; var referenceGroupAbsolute = []; fileResolveReferencesIn.referenceGroupsContainer = { 'relative': referenceGroupRelative, 'absolute': referenceGroupAbsolute }; // Don't try and resolve references in binary files or files that have been blacklisted if (this.Tool.is_binary_file(fileResolveReferencesIn) || !this.shouldSearchFile(fileResolveReferencesIn)) { return; } // For the current file (fileResolveReferencesIn), look for references to any other file in the project for (var path in this.files) { // Organize them by relative vs absolute reference types var fileCurrentReference = this.files[path]; var references; references = this.Tool.get_reference_representations_relative(fileCurrentReference, fileResolveReferencesIn); for (var i = 0, length = references.length; i < length; i++) { referenceGroupRelative.push({ 'file': this.files[path], 'path': references[i] }); } references = this.Tool.get_reference_representations_absolute(fileCurrentReference, fileResolveReferencesIn); for (var i = 0, length = references.length; i < length; i++) { referenceGroupAbsolute.push({ 'file': this.files[path], 'path': references[i] }); } } // Priority relative references higher than absolute for (var referenceType in fileResolveReferencesIn.referenceGroupsContainer) { var referenceGroup = fileResolveReferencesIn.referenceGroupsContainer[referenceType]; for (var referenceIndex = 0, referenceGroupLength = referenceGroup.length; referenceIndex < referenceGroupLength; referenceIndex++) { var reference = referenceGroup[referenceIndex]; var regExps = this.options.referenceToRegexs(reference); for (var j = 0; j < regExps.length; j++) { if (contents.match(regExps[j])) { // Only register this reference if we don't have one already by the same path if (!fileResolveReferencesIn.revReferencePaths[reference.path]) { fileResolveReferencesIn.revReferenceFiles[reference.file.path] = reference.file; fileResolveReferencesIn.revReferencePaths[reference.path] = { 'regExps': [regExps[j]], 'file': reference.file, 'path': reference.path }; this.log('gulp-rev-all:', 'Found', referenceType, 'reference [', Gutil.colors.magenta(reference.path), '] -> [', Gutil.colors.green(reference.file.path), '] in [', Gutil.colors.blue(fileResolveReferencesIn.revPathOriginal), ']'); } else if (fileResolveReferencesIn.revReferencePaths[reference.path].file.revPathOriginal === reference.file.revPathOriginal) { // Append the other regexes to account for inconsitent use fileResolveReferencesIn.revReferencePaths[reference.path].regExps.push(regExps[j]); } else { this.log('gulp-rev-all:', 'Possible ambiguous reference detected [', Gutil.colors.red(fileResolveReferencesIn.revReferencePaths[reference.path].path), ' (', fileResolveReferencesIn.revReferencePaths[reference.path].file.revPathOriginal, ')] <-> [', Gutil.colors.red(reference.path), '(', Gutil.colors.red(reference.file.revPathOriginal), ')]'); } } } } } }; /** * Calculate hash based contents and references. * hash = hash(file hash + hash(hash references 1 + hash reference N)..) */ Revisioner.prototype.calculateHash = function (file, stack) { stack = stack || []; var hash = file.revHashOriginal; stack.push(file); // Resolve hash for child references if (Object.keys(file.revReferenceFiles).length > 0) { for (var key in file.revReferenceFiles) { // Prevent infinite loops caused by circular references, don't recurse if we've already encountered this file if (stack.indexOf(file.revReferenceFiles[key]) === -1) { hash += this.calculateHash(file.revReferenceFiles[key], stack); } } // This file's hash should change if any of its references will be prefixed. if (this.options.prefix && Object.keys(file.referenceGroupsContainer.absolute).length) { hash += this.options.prefix; } // Consolidate many hashes into one hash = this.Tool.md5(hash); } return hash; }; /** * Revision filename based on internal contents + references. */ Revisioner.prototype.revisionFilename = function (file) { var filename = file.revFilenameOriginal; var ext = file.revFilenameExtOriginal; file.revHash = this.calculateHash(file); // Allow the client to transform the final filename if (this.options.transformFilename) { filename = this.options.transformFilename.call(this, file, file.revHash); } else { filename = filename + '.' + file.revHash.substr(0, this.options.hashLength) + ext; } file.revFilename = filename; // file.revFilenameNoExt = Tool.path_without_ext(file.revFilename); if (this.shouldFileBeRenamed(file)) { file.path = this.Tool.join_path(Path.dirname(file.path), filename); } // Maintain the combined hash used in version file this.hashCombined += file.revHash; // Maintain the manifset file var pathOriginal = this.Tool.get_relative_path(this.pathBase, file.revPathOriginal, true); var pathRevisioned = this.Tool.get_relative_path(file.base, file.path, true); // Add only specific file types to the manifest file if (this.options.includeFilesInManifest.indexOf(ext) !== -1) { this.manifest[pathOriginal] = pathRevisioned; } file.revPath = pathRevisioned; }; /** * Update the contents of a file with the revisioned filenames of its references. */ Revisioner.prototype.updateReferences = function (file) { // Don't try and update references in binary files or blacklisted files if (this.Tool.is_binary_file(file) || !this.shouldSearchFile(file)) { return; } var contents = String(file.revContentsOriginal); var annotatedContent = this.options.annotator(contents, file.revPathOriginal); for (var pathReference in file.revReferencePaths) { var reference = file.revReferencePaths[pathReference]; // Replace regular filename with revisioned version var referencePath = reference.path.substr(0, reference.path.length - (reference.file.revFilenameOriginal.length + reference.file.revFilenameExtOriginal.length)); var pathReferenceReplace = referencePath + reference.file.revFilename; if (this.options.transformPath) { // Transform path using client supplied transformPath callback, pathReferenceReplace = this.options.transformPath.call(this, pathReferenceReplace, reference.path, reference.file, file); } else if (this.options.prefix && pathReferenceReplace[0] === '/') { // Append with user supplied prefix pathReferenceReplace = this.Tool.join_path_url(this.options.prefix, pathReferenceReplace); } if (this.shouldUpdateReference(reference.file)) { // The extention should remain constant so we dont add extentions to references without extentions var noExtReplace = Tool.path_without_ext(pathReferenceReplace); for(var i = 0; i < annotatedContent.length; i++){ for(var j = 0; j < reference.regExps.length; j++){ this.options.replacer(annotatedContent[i], reference.regExps[j], noExtReplace, reference.file); } } } } contents = annotatedContent.map(function(annotation) { return annotation.contents; }).join(''); file.contents = new Buffer(contents); }; /** * Determines if a file should be renamed based on dontRenameFile supplied in options. */ Revisioner.prototype.shouldFileBeRenamed = function (file) { var filename = this.Tool.get_relative_path(file.base, file.revPathOriginal); for (var i = this.options.dontGlobal.length; i--;) { var regex = (this.options.dontGlobal[i] instanceof RegExp) ? this.options.dontGlobal[i] : new RegExp(this.options.dontGlobal[i] + '$', 'ig'); if (filename.match(regex)) { return false; } } for (var i = this.options.dontRenameFile.length; i--;) { var regex = (this.options.dontRenameFile[i] instanceof RegExp) ? this.options.dontRenameFile[i] : new RegExp(this.options.dontRenameFile[i] + '$', 'ig'); if (filename.match(regex)) { return false; } } return true; }; /** * Determines if a particular reference should be updated across assets based on dontUpdateReference supplied in options. */ Revisioner.prototype.shouldUpdateReference = function (file) { var filename = this.Tool.get_relative_path(file.base, file.revPathOriginal); for (var i = this.options.dontGlobal.length; i--;) { var regex = (this.options.dontGlobal[i] instanceof RegExp) ? this.options.dontGlobal[i] : new RegExp(this.options.dontGlobal[i] + '$', 'ig'); if (filename.match(regex)) { return false; } } for (var i = this.options.dontUpdateReference.length; i--;) { var regex = (this.options.dontUpdateReference[i] instanceof RegExp) ? this.options.dontUpdateReference[i] : new RegExp(this.options.dontUpdateReference[i] + '$', 'ig'); if (filename.match(regex)) { return false; } } return true; }; /** * Determines if a particular reference should be updated across assets based on dontUpdateReference supplied in options. */ Revisioner.prototype.shouldSearchFile = function (file) { var filename = this.Tool.get_relative_path(file.base, file.revPathOriginal); for (var i = this.options.dontGlobal.length; i--;) { var regex = (this.options.dontGlobal[i] instanceof RegExp) ? this.options.dontGlobal[i] : new RegExp(this.options.dontGlobal[i] + '$', 'ig'); if (filename.match(regex)) { return false; } } for (var i = this.options.dontSearchFile.length; i--;) { var regex = (this.options.dontSearchFile[i] instanceof RegExp) ? this.options.dontSearchFile[i] : new RegExp(this.options.dontSearchFile[i] + '$', 'ig'); if (filename.match(regex)) { return false; } } return true; }; return Revisioner; })(); module.exports = Revisioner;