UNPKG

burn

Version:

Super lightweight JS module/asset loader

376 lines (332 loc) 11.4 kB
(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 }; })();