diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..801f9fc --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# resmin + +All-in-one minifier/merger/compressor middleware for connect/express. + +## Installation + + $ npm install resmin + +## Dependencies + +- [uglify-js](https://github.com/mishoo/UglifyJS) +- [uglifycss](https://github.com/fmarcia/UglifyCSS) +- [compress](https://github.com/waveto/node-compress) +- [mime](https://github.com/bentomas/node-mime) + +## Basic usage + +Example configuration: + + var resminConfig = { + gzip: true, + merge: true, + compress: true, + js: { + "all": [ + "//ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js", + "//ajax.googleapis.com/ajax/libs/jqueryui/1.8.13/jquery-ui.min.js", + "/js/foo.js", + "/js/bar.js", + "/js/baz.js" + ] + }, + css: { + "all": [ + "//fonts.googleapis.com/css?family=Inconsolata:regular&v1", + "/css/foo.css", + "/css/bar.css", + "/css/baz.css" + ] + } + }; + +Instantiate resmin and add the middleware and dynamic helper to connect/express. + + var resmin = require('resmin'); + app.use(resmin.middleware(__dirname+'/public/'), resminConfig); + app.dynamicHelper(resmin.dynamicHelper); + +The resmin variable is now accessible through your template engine of choice. + +Example when using jade: + + - each url in resmin.js.all + script(src=url) + - each url in resmin.css.all + style(alt='stylesheet', href=url) + +## Additional credits + +Credits and thanks to [tomgallacher](https://github.com/tomgallacher) for [gzippo](https://github.com/tomgallacher/gzippo) and +[bengourley](https://github.com/bengourley) for [minj](https://github.com/bengourley/minj) which resmin is loosely based up. \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..ea3f36e --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require("./lib/resmin.js"); diff --git a/lib/resmin.js b/lib/resmin.js new file mode 100644 index 0000000..c8b2e3a --- /dev/null +++ b/lib/resmin.js @@ -0,0 +1,276 @@ +var fs = require('fs'), + basename = require('path').basename, + join = require('path').join, + parse = require('url').parse, + path = require('path'), + mime = require('mime'), + compress = require('compress'), + parser = require("uglify-js").parser, + uglifyjs = require("uglify-js").uglify, + uglifycss = require("uglifycss"); + +var outputJS = {}, + outputCSS = {}, + gzipJS = {}, + gzipCSS = {}, + staticSend; + +try { + staticSend = require('connect').static.send +} catch (e) { + staticSend = require('express').static.send +} + +var gzipFileCache = {}; + +var gzipFile = function(filename, charset, callback) { + var gzip = new compress.Gzip(); + gzip.init(); + fs.readFile(filename, function (err, data) { + if (err) throw err; + var gzippedData = gzip.deflate(data, charset) + gzip.end(); + callback(gzippedData); + }); +}; + +var Merger = function (output) { + var str = "", + files = [], + current = 0, + end = 0, + self = this, + processor; + + this.outputNames = false; + + var concat = function(append){ + if (append) + if (self.outputNames) + str += "/* "+files[current].split("/").pop()+" */" + append + "\n"; + else + str += append; + current++; + if (current == end) + return self.writeFile(); + self.readFile(files[current], processor); + } + + this.addFile = function(file) { + files.push(file); + } + + this.readFile = function(file, next) { + var contentType = mime.lookup(file), + charset = mime.charsets.lookup(contentType) || 'utf-8'; + fs.readFile(file, charset, function (err, str) { + if (err) { + console.log(err); + } else { + next(str, concat); + } + }); + } + + this.writeFile = function() { + var gzip = new compress.Gzip(); + gzip.init(); + var gzippedData = gzip.deflate(str, 'utf8') + gzip.end(); + fs.writeFile(output, str, 'utf8', function (err) { + if (err) { + console.log(err); + } else { + fs.writeFile(output+'.gz', gzippedData, 'binary', function (err) { + if (err) console.log(err); + }); + } + }); + } + + this.process = function(pc) { + processor = pc; + end = files.length; + if (pc && end) + this.readFile(files[current], processor); + } +}; + +var minifyJS = function(str, concat) { + try { + var ast = parser.parse(str); + ast = uglifyjs.ast_mangle(ast, { except: ["$"] }); + ast = uglifyjs.ast_squeeze(ast); + concat(uglifyjs.gen_code(ast)+";"); + } catch (ex) { + concat(str); + } +} + +var minifyCSS = function(str, concat) { + concat(uglifycss.processString(str)+"\n"); +} + +module.exports.middleware = function(dirPath, options) { + options = options || {}; + var gzip = options.js || true, + js = options.js || {}, + css = options.css || {}, + merge = options.merge || true, + minify = options.minify || true, + maxAge = options.maxAge || 86400000, + outputNames = options.outputNames || false; + + contentTypeMatch = options.contentTypeMatch || /text|javascript|json/; + if (!dirPath) throw new Error('Missing static directory path'); + if (!contentTypeMatch.test) throw new Error('contentTypeMatch: must be a regular expression.'); + + for (var group in js) { + outputJS[group] = []; + gzipJS[group] = []; + var name = dirPath+'/js/'+group+'.min.js', + jsMerge = new Merger(name); + js[group].forEach(function (file, i){ + // if no merge/minify or if file is an uri + if ((!merge && !minify) || file.match(/^(http[s]?:\/\/|\/\/)/i)) { + gzipJS[group].push(file); + outputJS[group].push(file); + return; + } + if (merge) { + jsMerge.addFile(dirPath+file); + } + }); + if (merge) { + gzipJS[group].push('/js/'+group+'.min.js.gz'); + outputJS[group].push('/js/'+group+'.min.js'); + jsMerge.process( + minify ? + minifyJS : + function(str, concat) { + return concat(str); + } + ); + } + } + + for (var group in css) { + outputCSS[group] = []; + gzipCSS[group] = []; + var name = dirPath+'/css/'+group+'.min.css', + cssMerge = new Merger(name); + css[group].forEach(function (file, i) { + if ((!merge && !minify) || file.match(/^(http[s]?:\/\/|\/\/)/)) { + gzipCSS[group].push(file); + outputCSS[group].push(file); + return; + } + if (merge) { + cssMerge.addFile(dirPath+file); + } + }); + if (merge) { + gzipCSS[group].push('/css/'+group+'.min.css.gz'); + outputCSS[group].push('/css/'+group+'.min.css'); + cssMerge.process( + minify ? + minifyCSS : + function(str, concat) { + return concat(str+"\n"); + } + ); + } + } + + return function middleware(req, res, next) { + if (req.method !== 'GET') + return next(); + + var url, filename, contentType, acceptEncoding, charset; + url = parse(req.url); + filename = path.join(dirPath, url.pathname); + contentType = mime.lookup(filename); + charset = mime.charsets.lookup(contentType); + acceptEncoding = req.headers['accept-encoding'] || ''; + + if (filename.substring(filename.length-3) === '.gz') { + contentType = mime.lookup(filename.substring(0, filename.length-3)); + charset = mime.charsets.lookup(contentType); + fs.readFile(filename, function (err, data) { + if (err) throw err; + sendGzipped(data); + }); + } else { + + if (!gzip) + return pass(filename); + + if (!contentTypeMatch.test(contentType)) + return pass(filename); + + if (!~acceptEncoding.indexOf('gzip')) + return pass(filename); + + function pass(name) { + var o = Object.create(options); + o.path = name; + staticSend(req, res, next, o); + } + + function sendGzipped(data) { + contentType = contentType + (charset ? '; charset=' + charset : ''); + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('Vary', 'Accept-Encoding'); + res.setHeader('Content-Length', data.length); + res.end(data, 'binary'); + } + + function gzipAndSend(filename, gzipName, mtime) { + gzipFile(filename, charset, function(gzippedData) { + gzipFileCache[gzipName] = { + 'ctime': Date.now(), + 'mtime': mtime, + 'content': gzippedData + }; + sendGzipped(gzippedData); + }); + } + + fs.stat(filename, function(err, stat) { + if (err || stat.isDirectory()) { + return pass(filename); + } + var base = path.basename(filename), + dir = path.dirname(filename), + gzipName = path.join(dir, base + '.gz'); + + if (typeof gzipFileCache[gzipName] === 'undefined') { + gzipAndSend(filename, gzipName, stat.mtime); + } else { + + if ((gzipFileCache[gzipName].mtime < stat.mtime) || + ((gzipFileCache[gzipName].ctime + maxAge) < Date.now())) { + gzipAndSend(filename, gzipName, stat.mtime); + } else { + sendGzipped(gzipFileCache[gzipName].content); + } + } + }); + } + } +} + +module.exports.dynamicHelper = { + resmin: function(req, res) { + var acceptEncoding = req.headers['accept-encoding'] || ''; + return !~acceptEncoding.indexOf('gzip') ? + { + js: outputJS, + css: outputCSS + } : + { + js: gzipJS, + css: gzipCSS + }; + } +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..42f7c7e --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name" : "resmin", + "version" : "0.0.1", + "author" : "Marcus Ekwall", + "description" : "All-in-one compressor/merger/minifier middleware for connect/express", + "homepage" : "https://github.com/mekwall/resmin", + "repository": + { + "type": "git", + "url": "git://github.com/mekwall/resmin.git" + }, + "engines" : { "node" : ">= 0.4.x" }, + "main" : "./index.js", + "dependencies" : + { + "uglify-js": ">= 1.0.1", + "uglifycss": ">= 0.0.3", + "mime": ">= 1.2.2", + "compress": ">= 0.1.9" + } +}