mincer
Version:
Web assets processor. Native JavaScript port of Sprockets.
290 lines (237 loc) • 7.7 kB
JavaScript
/**
* class Manifest
*
* The Manifest logs the contents of assets compiled to a single
* directory. It records basic attributes about the asset for fast
* lookup without having to compile. A pointer from each logical path
* indicates with fingerprinted asset is the current one.
*
* The JSON is part of the public API and should be considered
* stable. This should make it easy to read from other programming
* languages and processes that don't have sprockets loaded. See
* `#assets` and `#files` for more infomation about the structure.
**/
;
// stdlib
var fs = require('fs');
var path = require('path');
var format = require('util').format;
// 3rd-party
var _ = require('lodash');
var pako = require('pako');
var mkdirp = require('mkdirp').sync;
// internal
var prop = require('./common').prop;
var getter = require('./common').getter;
var isAbsolute = require('./common').isAbsolute;
var start_timer = require('./common').timer;
var logger = require('./logger');
/**
* new Manifest(environment, path)
*
* Create new Manifest associated with an `environment`. `path` is
* a full path to the manifest json file. The file may or may not
* already exist. The dirname of the `path` will be used to write
* compiled assets to. Otherwise, if the path is a directory, the
* filename will default to "manifest.json" in that directory.
*
* new Manifest(environment, "./public/assets/manifest.json");
**/
var Manifest = module.exports = function Manifest(environment, pathname) {
var data;
prop(this, 'environment', environment);
if (path.extname(pathname) === '') {
prop(this, 'dir', path.resolve(pathname));
prop(this, 'path', path.join(this.dir, 'manifest.json'));
} else {
prop(this, 'dir', path.dirname(pathname));
prop(this, 'path', path.resolve(pathname));
}
if (fs.existsSync(this.path)) {
try {
data = require(this.path);
} catch (err) {
logger.error(this.path + ' is invalid: ' + err);
}
}
prop(this, 'data', (!!data && _.isObject(data)) ? data : {});
};
/**
* Manifest#assets -> Object
*
* Returns internal assets mapping. Keys are logical paths which
* map to the latest fingerprinted filename.
*
*
* ##### Synopsis:
*
* Logical path (String): Fingerprint path (String)
*
*
* ##### Example:
*
* {
* "application.js" : "application-2e8e9a7c6b0aafa0c9bdeec90ea30213.js",
* "jquery.js" : "jquery-ae0908555a245f8266f77df5a8edca2e.js"
* }
**/
getter(Manifest.prototype, 'assets', function () {
if (!this.data.assets) {
this.data.assets = {};
}
return this.data.assets;
});
/**
* Manifest#files -> Object
*
* Returns internal file directory listing. Keys are filenames
* which map to an attributes array.
*
*
* ##### Synopsis:
*
* Fingerprint path (String):
* logical_path: Logical path (String)
* mtime: ISO8601 mtime (String)
* digest: Base64 hex digest (String)
*
*
* ##### Example:
*
* {
* "application-2e8e9a7c6b0aafa0c9bdeec90ea30213.js" : {
* 'logical_path' : "application.js",
* 'mtime' : "2011-12-13T21:47:08-06:00",
* 'digest' : "2e8e9a7c6b0aafa0c9bdeec90ea30213"
* }
* }
**/
getter(Manifest.prototype, 'files', function () {
if (!this.data.files) {
this.data.files = {};
}
return this.data.files;
});
// Coerce data to buffer
function toBuffer(data) {
return Buffer.isBuffer(data) ? data : new Buffer(data);
}
// Write data String/Buffer into filename
function write(filename, mtime, data) {
var tempname = filename + '+';
try {
mkdirp(path.dirname(filename));
fs.writeFileSync(tempname, toBuffer(data));
fs.renameSync(tempname, filename);
fs.utimesSync(filename, mtime, mtime);
} catch (err) {
// Try to remove tmp file on error.
// Don't check if exists, just suppress errors
try { fs.unlinkSync(tempname); } catch (__) {}
throw err;
}
}
// Compress given String or Buffer
function gzip(data) {
/* global Uint8Array */
return new Buffer(pako.gzip(new Uint8Array(toBuffer(data))));
}
/**
* Manifest#compile(files[, options]) -> Object
* - files (Array):
* - options (Object):
*
* ##### options:
*
* - `sourceMaps` (false) - set `true` to write sourcemap files
* - `embedMappingComments` (false) - set `true` to embed sourcemap url
* into created files
* - `compress` (false) - set `true` to also create gzipped files
* - `noSourceMapProtection` (false) - set `true` to skip adding XSSI protection header
*
* Compile and write asset(s) to directory. The asset is written to a
* fingerprinted filename like `app-2e8e9a7c6b0aafa0c9bdeec90ea30213.js`.
* An entry is also inserted into the manifest file.
*
* Returns manifest content on success. Throws exception on error.
*
* var data = manifest.compile(["app.js"]);
*
* // data => {
* // files: {
* // "app.js" : "app-2e8e9a7c6b0aafa0c9bdeec90ea30213.js",
* // ...
* // },
* // assets: {
* // "app-2e8e9a7c6b0aafa0c9bdeec90ea30213.js" : {
* // "logical_path" : "app.js",
* // "mtime" : "2011-12-13T21:47:08-06:00",
* // "digest" : "2e8e9a7c6b0aafa0c9bdeec90ea30213"
* // },
* // ...
* // }
* // }
**/
Manifest.prototype.compile = function (files, options) {
var self = this,
paths = [];
if (!this.environment) {
throw new Error('Manifest requries environment for compilation');
}
options = options || {};
this.environment.eachLogicalPath(files, function (pathname) {
paths.push(pathname);
});
paths = _.union(paths, _.filter(files, isAbsolute));
paths.forEach(function (logicalPath) {
var timer = start_timer(), asset, target;
asset = self.environment.findAsset(logicalPath, { bundle: true });
if (!asset) {
throw new Error('Can not find asset \'' + logicalPath + '\'');
}
logger.debug(format('Compiled %s (%dms)', logicalPath, timer.stop()));
target = path.join(self.dir, asset.digestPath);
self.assets[asset.logicalPath] = asset.digestPath;
self.files[asset.digestPath] = {
logical_path: asset.logicalPath,
mtime: asset.mtime.toISOString(),
size: asset.length,
digest: asset.digest
};
if (fs.existsSync(target)) {
logger.debug('Skipping ' + target + ', already exists');
self.save();
return;
}
var buffer;
if (options.embedMappingComments && options.sourceMaps && asset.sourceMap) {
buffer = new Buffer(asset.source + asset.mappingUrlComment());
} else {
buffer = asset.buffer;
}
write(target, asset.mtime, buffer);
if (asset.type === 'bundled' && options.compress) {
write(target + '.gz', asset.mtime, gzip(buffer));
}
if (asset.sourceMap) {
// XSSI protection header enabled by default
var header = ')]}\'\n';
// Optionally skip XSSI protection header
if (options.noSourceMapProtection) {
header = '';
}
write(target + '.map', asset.mtime, header + asset.sourceMap);
if (options.compress) {
write(target + '.map.gz', asset.mtime, gzip(header + asset.sourceMap));
}
}
self.save();
logger.info('Writing ' + target);
});
return self.data;
};
// Persist manifest back to FS
Manifest.prototype.save = function () {
mkdirp(this.dir);
fs.writeFileSync(this.path, JSON.stringify(this.data, null, ' '));
};