UNPKG

elem

Version:

An asset manager based on custom elements

567 lines (439 loc) 12.4 kB
/** * Elem */ module.exports = Elem; var fs = require('fs'); var path = require('path'); var mkdirp = require('mkdirp'); var rmdir = require('rimraf'); var basename = path.basename; var dirname = path.dirname; var resolve = path.resolve; var exists = fs.existsSync || path.existsSync; var serveStatic = require('serve-static') var util = require('./util'); var uglify = require('uglify-js'); var glob = require('glob'); var uid = require('uid'); /** * Autoloaded file extensions */ var autoload = ['.css', '.html', '.js']; /** * Elem */ function Elem(sourceDir, options) { if (!(this instanceof Elem)) { return new Elem(sourceDir, options); } // Default options options = options || {}; // Allow either Elem(opts) or Elem(src,opts) if (typeof sourceDir === 'object') { options = sourceDir; } else { options.sourceDir = sourceDir; } this.sourceDir = options.sourceDir || '.'; this.buildDir = options.buildDir || path.join(this.sourceDir, '_build'); this.converters = require('./converters'); this.production = options.production; this.tagName = options.tagName || path.basename(this.sourceDir); /** * Information about the last build, per source file. * * Particularly, the last-modified file of the source file * at the time we last built it. * * This allows us to determine if the source needs to be * rebuilt by checking if the last-modified is different. */ this.lastBuild = {}; /** * A filename for persisting lastBuild to disk. */ this.lastBuildFilename = path.join(this.buildDir + "/last_build.json"); /** * Try to load lastBuild from disk. */ try { this.lastBuild = JSON.parse( fs.readFileSync( this.lastBuildFilename ) ); } catch (e) { if (e.code !== 'ENOENT') { throw e; } this.lastBuild = {}; } } /** * Middleware that returns an empty page * with nothing but the loader.js and an * the elem on it. * * Example: * var app = Elem('./app'); * server.use('/app', app.loader()); * server.get('*', app.boot('/app')); * * @param {String} elemuri The URI of the elem to boot */ Elem.prototype.boot = function(elemuri) { var self = this; var src = path.join(elemuri, 'loader.js'); return function(req, res, next) { var html = '<!DOCTYPE html><script src="'+src+'"></script><'+self.tagName+'></'+self.tagName+'>'; res.setHeader('Content-Type', 'text/html'); res.end(html); } } /** * Generate a loader.js * * @param {String} doman domain * @param {String} basepath Base URL that serves _build/ * @returns {String} */ Elem.prototype.generateLoaderJS = function(domain, basePath, mode) { var srcfile = path.join(__dirname,'../boot/loader.js') var src = ''+fs.readFileSync(srcfile); if(this.production) { src = uglify.minify(src, {fromString:true}).code; } mode = mode || (this.production ? 'production' : 'development'); basePath = basePath || '/'; // Make sure the basePath ends in a slash if(basePath[basePath.length-1] != '/') { basePath += '/'; } var config = { domain: domain, basePath: basePath, mode: mode, index: this.lastBuild.index, buildId: this.lastBuild.id, }; var starter = '\n\nelem.start('+JSON.stringify(config)+');\n'; src += starter; // console.log(src); return src; } /** * Generate and write loader.js */ Elem.prototype.buildLoader = function(domain, basepath) { src = this.generateLoaderJS(domain, basepath) out = path.join(this.buildDir, 'loader.js'); fs.writeFileSync(out, src); } /** * Generates a simple index.html * so elem can be used as a static site generator. * * Every build gets this put in for convenience. */ Elem.prototype.buildStaticSiteBootstrap = function(basepath) { var src, out; // index.html src = '<!DOCTYPE HTML><script src="/loader.js"></script>' out = path.join(this.buildDir, 'index.html'); fs.writeFileSync(out, src); } /** * Middleware for serving elements */ Elem.prototype.loader = function(opts) { var self = this; opts = opts || {}; this.production = !!opts.production; self.build(); this.built = true; return function(req,res,next) { if(req.path == '/loader.js') { // In development mode // rebuild every request to loader if(!opts.production) { self.build(); this.loaderValid = false; } if(!this.loaderValid) { var domain = opts.domain ? opts.domain : req.protocol + '://' + req.get('host'); var basepath = path.dirname(req.originalUrl); self.buildLoader(domain, basepath); this.loaderValid = true; } } handleFiles(); function handleFiles() { function setHeaders(res) { if (opts.S_MaxAge) { res.setHeader('Control-Cache', 's-maxage=' + opts.S_MaxAge); } }; serveStatic(self.buildDir, {setHeaders: setHeaders})(req, res, next); } }; } /** * Create an assets.json pack from a collection of built files * * @param {Array} files (relative t * o buildDir) */ Elem.prototype.pack = function(files) { var result = {}; files.forEach(function(file) { result[file] = ''+fs.readFileSync(path.join(this.buildDir, file)); }, this); return JSON.stringify(result); } /** * Check if a source file is outdated * * @param {String} filei Input file path * @returns {Boolean} */ Elem.prototype.isOutdated = function(filei) { var mtime = ''+fs.statSync(filei).mtime; this.lastBuild.mtimes = this.lastBuild.mtimes || {}; if(this.lastBuild.mtimes[filei] == mtime) { return false; } return true; } /** * Add a pre-processor to the build process * for a particular set of double file extensions. * * @param {String} exts ".to.from" extension matcher */ Elem.prototype.prep = function(exts, fn) { this.converters[exts] = fn; } /** * Generate a build path * from a source file path. * * Examples: * a/b.js.html * _build/a/b.js * * @param {String} filei Input file path * @returns {String} The build file path */ Elem.prototype.getBuildPath = function(file, trimExt) { var sourceDir = this.sourceDir; file = path.normalize(file); if(trimExt && path.basename(file).split('.').length > 2) { file = file.split('.').slice(0,-1).join('.'); } file = path.join(this.buildDir, path.relative(sourceDir,file)); return path.normalize(file); } /** * Check if a file is outdated and rebuild it. * * It first attempts to convert anything * that has a converter. Otherwise it creates a * symlink to the original. * * Only rebuilds when the source has been modified * since the last file it was built. * * @param {String} filei Input file path * @returns {String} The built file or symlink path */ Elem.prototype.buildFile = function(filei) { var sourceDir = this.sourceDir; var stat = fs.statSync(filei) if(!filei || stat.isDirectory()) return false; // Extract preprocessor directive from file extensions var ext = util.last2ext(filei); // Find converter for extension var converter = this.converters[ext]; // Get build path, trim extension if converter available var fileo = this.getBuildPath(filei, !!converter); // If not outdated do nothing if(!this.isOutdated(filei)) { return fileo; } // Ensure dirs exist var dir = path.dirname(fileo); mkdirp.sync(dir); // Read in data from source var data = fs.readFileSync(filei); // If converter, apply it to data if (converter) { data = converter.call(this, data, filei); } // Write the output fs.writeFileSync(fileo, data); // Remember the last-modify-time associated with this build this.lastBuild.mtimes = this.lastBuild.mtimes || {}; var mtime = ''+fs.statSync(filei).mtime; this.lastBuild.mtimes[filei] = mtime; console.log('built', path.relative(this.buildDir, fileo)); return fileo; } Elem.prototype.parseComponent = function(fname) { var json = JSON.parse(fs.readFileSync(fname)); var dir = path.dirname(fname); var main = json.main || 'index.js'; var name = json.name; main = path.join(dir,main); main = path.relative(this.sourceDir, main); return { name: name, main: main }; } /** * Deletes the build dir */ Elem.prototype.clean = function() { rmdir.sync(this.buildDir); this.lastBuild = {}; } /** * Builds everything */ Elem.prototype.build = function() { var sourceDir = this.sourceDir; var self = this; // Only build once in production mode. if (this.production && fs.existsSync(this.lastBuildFilename) ) { console.log('Existing elem build detected. Using it.'); return; } var result = []; var modules = {}; var buildDir = this.buildDir; // Recursively find all files var files = glob.sync(sourceDir+"/**"); if(this.production && !this.cleaned) { this.clean(); this.cleaned = true; } // Filter out files we don't want // 1. Directories // 2. Anything that begins with `_` or '.' // 3. Anything empty files = files.filter(function(fname) { if(fname.match(/\/_/)) return false; if(!fname.trim()) return false; if(fs.statSync(fname).isDirectory()) { return false; } return true; }); files = files.filter(function(fname) { // If a component.json file if(fname.match(/component\.json$/)) { // If within a components/ folder if(fname.match(/components\//)) { var component = self.parseComponent(fname); modules[component.name] = component.main; } // Never serve component.json return false; } return true; }); files = files.map( function(fname) { return self.buildFile(fname) }); // Remove any non-autoload extention files = files.filter( function(fname) { var ext = path.extname(fname); return autoload.indexOf(ext) != -1; }); // Remove empties files = files.filter(function(fname) { return fname; }); // Remove duplicates files = files.filter(function(item, i) { return files.indexOf(item) == i; }) // Convert to relative paths files = files.map(function(fname) { return path.relative(self.buildDir, fname); }); // Sort lexicographically files = files.sort(function(a,b) { return a.localeCompare(b); }); var index = { files: files, modules: modules, packages: {} }; if(this.production) { // Build asset packages var dir = this.buildDir; if(!dir) return; // The resulting asset file we are // trying to build var assetfile = dir + '/assets.json'; mkdirp.sync(dir); // Find all files under this directory // AND any files w/ the directory name. // i.e. sourceDir/body.html // sourceDir/body/index.js var files = glob.sync(dir+"{/**,*}") files = files.filter(function(f) { if(fs.statSync(f).isDirectory()) { return false; } return true; }); // Make all paths relative to _build files = files.map(function(fname) { return path.relative(self.buildDir, fname); }); // Filter out any assets which // are not in the index files = files.filter(function(fname) { return index.files.indexOf(fname) !== -1 }); var json = self.pack(files); fs.writeFileSync(assetfile, json); var relassetfile = path.relative(self.buildDir, assetfile); index.files.push(relassetfile); files.forEach(function(fname) { index.packages[fname] = relassetfile; }); } this.lastBuild.id = uid(); this.lastBuild.index = index; // Ensure buildDir exists mkdirp.sync(this.buildDir); // Write last_build.json fs.writeFileSync(this.lastBuildFilename, JSON.stringify(this.lastBuild)); this.buildStaticSiteBootstrap(); } /** * Simulate using JSDOM */ Elem.prototype.simulate = function(html, globals) { var self = this; var result = {}; var jsdom = require('jsdom'); html = html || '<!DOCTYPE html><'+self.tagName+'></'+self.tagName+'>'; var js = this.generateLoaderJS(null, this.buildDir, 'test'); var doc = jsdom.jsdom(html); var el = doc.body.firstElementChild; var window = doc.defaultView; window.nodeRequire = require; window.env = 'test' window.console = console; for (var k in globals) { window[k] = globals[k]; } var vm = require('vm'); var script = new vm.Script(js, {filename: 'boot/loader.js'}); jsdom.evalVMScript(window, script); return el; }