burn
Version:
Super lightweight JS module/asset loader
376 lines (332 loc) • 11.4 kB
JavaScript
(function() {
// imports
var fs = require('fs');
var path = require('path');
var assert = require('assert');
var extend = require('extend');
var colors = require('colors');
var glob = require('glob');
var esprima = require('esprima');
var helpers = require('./helpers');
var exceptions = require('./exceptions');
var mkdirp = require('mkdirp');
// shortcuts
var ConfigError = exceptions.ConfigError;
/*
* Parse JS and check for errors
*
* @param content: (string) JS definition
* @param name: (string) Name of JS defintion
*/
var checkFunction = function(content, name) {
// attempt to look for errors
try {
esprima.parse(content);
} catch (e) {
var line = e.lineNumber;
var column = e.column;
var output = "";
var name = name ? name : "(anonymous file)"
output += "%s:%s:%s\n".format(name, line, column)
output += content.split('\n')[line-1] + "\n";
output += Array(column).join(' ') + '^' + "\n";
output += 'ParseError: ' + e.message + "\n";
var exc = new exceptions.CompileError("Function compile error");;
exc.trace = output;
throw exc;
}
}
/*
* BurnJS compiler
*
* Handles construction of bundles and rendering,
* used in conjunction with loader.js
*/
var Compiler = function() {
var self = this;
//var deferred = Q.defer();
self.modules = {}
self.assets = {}
self.entry = {};
self.entry_order = [];
/*
* Adds module to bundle
*
* @param name: (string) Module name
* @param content: (string) Module content
* @param meta: (object) Meta info about module
*/
self.addModule = function(args) {
assert('name' in args, "missing argument: name")
assert('content' in args, "missing argument: content")
assert.equal(typeof args.content, 'string')
assert(!(args.name in self.modules), "Cannot add %s: module already added".format(args.name))
self.modules[args.name] = {content:args.content, name:args.name, meta:args.meta};
}
/*
* Adds asset to bundle
*
* @param name: (string) Asset name
* @param content: (string) Asset content
* @param meta: (object) Meta info about asset
*/
self.addAsset = function(args) {
var name = args.name ? args.name : null;
var content = args.content ? args.content : null;
assert(name !== null, "missing argument: name")
assert(content !== null, "missing argument: content")
assert(!(name in self.assets), "Cannot add %s: asset already added".format(name))
assert.equal(typeof args.content, 'string')
self.assets[name] = {content:content, name:args.name, meta:args.meta};
}
/*
* Adds entry point to bundle
*
* @param name: (string) Entry point name
* @param content: (string) Entry point content
* @param meta: (object) Meta info about entry point
*/
self.addEntry = function(args) {
assert('name' in args, "missing argument: name")
assert('content' in args, "missing argument: content")
assert(!(args.name in self.entry), "Cannot add %s: entry point already added".format(args.name))
self.entry[args.name] = {content:args.content, name:args.name, meta:args.meta};
assert.equal(typeof args.content, 'string')
self.entry_order.push(args.name);
}
/*
* Compile function from string
*
* @param content: (string) Function definition
* @returns Function object
*/
var compileFunction = function(content, ident) {
assert.equal(typeof content, 'string');
try {
return new Function(['require','module','exports','define'], content);
} catch (e) {
throw new Error("Compiler error, this should never happen");
}
}
/*
* Serializable object
*
* THIS IS HERE FOR A REASON. FUCKING JS.
*
* Allows an object to be dumped into its raw JS form
* for injection into the browser side loader. This is
* needed because function objects and others will not
* render to JSON. This is a very specific use case
* and would not advice using this approach unless
* you know what you are doing.
*
* @param obj: (object) Object to serialize
* @returns (string) String of JS code
*/
var serializeObject = function(obj) {
// serializer
var serialize = function(value) {
assert.notEqual(typeof value, 'undefined');
if (value === null) { return null; }
switch(value.constructor) {
case String:
return JSON.stringify(value.toString());
case Function:
return "(%s)".format(value.toString());
case Object:
return serializeObject(value);
case Array:
return serializeObject(value);
default:
throw new Error("cannot serialize type %s".format(value.constructor));
}
}
// handle objects/arrays differently
var data = [];
if (obj.constructor === Object) {
Object.keys(obj).forEach(function(name) {
var value = serialize(obj[name]);
data.push("%s:%s".format(JSON.stringify(name), value));
});
return "{%s}".format(data.join(","))
} else if (obj.constructor === Array) {
obj.forEach(function(value) {
data.push(serialize(value));
});
return "[%s]".format(data.join(","))
}
}
/*
* Render bundle to JS code
*
* @returns (deferred) Deferred result
*/
self.render = function() {
var self = this;
// fetch loader template
var loaderPath = path.join(path.dirname(fs.realpathSync(__filename)), '/loader.browser.js');
var loaderContent = fs.readFileSync(loaderPath).toString();
var compiled = {
modules: {},
assets: {},
entry: {},
entry_order: []
};
// compile modules
helpers.each(self.modules, function(name, module) {
compiled.modules[name] = compileFunction(module.content, module.meta.absolutePath);
});
// compile assets
helpers.each(self.assets, function(name, asset) {
compiled.assets[name] = asset.content;
});
// compile entry points
helpers.each(self.entry, function(name, entry) {
compiled.entry[name] = compileFunction(entry.content, name);
});
// compile entry order
compiled.entry_order = self.entry_order;
// render to loader
var result = serializeObject(compiled);
var loaderRendered = loaderContent.replaceAll('__CONFIG__', result);
return loaderRendered;
}
}
/*
* Compile using options
*
* Tools such as the CLI/Grunt re-use the same
* config as a wrapper around the pipe methods,
* this is centralized to avoid repeating code.
* But no seriously, real men use pipes.
*
* See docs for supported/example config
*/
var compile = function(config) {
// create compiler instance
var compiler = new Compiler();
// default arguments
var options = extend({}, {
'modules': [],
'assets': [],
'entry': [],
'baseDir': "./",
'out': null
}, config);
if (options.entry.constructor == String) {
options.entry = [options.entry, ]
}
// check types
assert.equal(options.modules.constructor, Array, "modules must be array")
assert.equal(options.assets.constructor, Array, "assets must be array")
assert.equal(options.entry.constructor, Array, "entry must be array")
assert.equal(options.baseDir.constructor, String, "baseDir must be string")
// resolve and test basedir
var baseDir = path.resolve(options.baseDir)
if (!options.baseDir)
throw new ConfigError("missing argument: baseDir");
if (!fs.existsSync(baseDir))
throw new ConfigError("baseDir does not exist: %s".format(baseDir));
if (!fs.lstatSync(baseDir).isDirectory())
throw new ConfigError("baseDir is not a directory: %s".format(baseDir));
// test modules and entry points
if (options.modules.length == 0 && options.entry.length == 0)
throw new ConfigError("At least one module or entry point must be given")
/*
* Helper for inspecting items and performing
* deep functionality such as glob()
*
* @param item: Item object
*/
var resolveItems = function(item, isFunction) {
// allow string or object
var item = (typeof item === 'string') ? {'src': item} : item;
assert.equal(item.constructor, Object);
// handle glob expressions
var matchedFiles = glob.sync(item.src, {'cwd': baseDir})
if (!matchedFiles.length)
throw new ConfigError("No such file or directory: %s".format(item.src));
// treat each file as an individual module
if (typeof item.name === 'undefined') {
var found = [];
matchedFiles.forEach(function(fpath) {
// read files from disk
var absolutePath = path.resolve(path.join(baseDir, fpath));
var relativePath = path.normalize(fpath);
var content = fs.readFileSync(absolutePath).toString();
content = (content !== null) ? content : "";
// verify content
if (isFunction)
checkFunction(content, absolutePath);
// construct bundle
found.push({
'name': relativePath,
'content': content,
'meta': [{
'absolutePath': absolutePath,
'relativePath': relativePath
}]
});
});
return found;
}
// merge each file into a single module
else {
var meta = [];
var mergedContent = "";
matchedFiles.forEach(function(fpath) {
// read files from disk
var absolutePath = path.resolve(path.join(baseDir, fpath));
var relativePath = path.normalize(fpath);
var content = fs.readFileSync(absolutePath).toString();
mergedContent += (content !== null) ? content : "";
meta.push({
'absolutePath': absolutePath,
'relativePath': relativePath
})
});
return [{
'name': item.name,
'content': mergedContent,
'meta': meta
}];
}
}
// add modules
options.modules.forEach(function(item) {
resolveItems(item, true).forEach(function(resolvedItem) {
compiler.addModule(resolvedItem);
});
});
// add entry
options.entry.forEach(function(item) {
resolveItems(item, true).forEach(function(resolvedItem) {
compiler.addEntry(resolvedItem);
});
});
// add asset
options.assets.forEach(function(item) {
resolveItems(item, false).forEach(function(resolvedItem) {
compiler.addAsset(resolvedItem);
});
});
// should we output to disk?
var content = compiler.render();
var meta = {};
if (options.out) {
var outPath = path.resolve(options.out);
mkdirp.sync(path.dirname(outPath));
fs.writeFileSync(outPath, content, 'utf8');
meta['outpath'] = outPath;
}
return {
'compiler': compiler,
'result': content,
'meta': meta
}
}
module.exports = {
Compiler: Compiler,
compile: compile
};
})();