var pathUtil = require('path') , dirname = pathUtil.dirname , basename = pathUtil.basename , join = pathUtil.join , exists = pathUtil.exists , relative = pathUtil.relative , fs = require('fs') , crypto = require('crypto') , stylus = require('stylus') , nib = require('nib') , less = require('less') , racer = require('racer') , Promise = racer.util.Promise , finishAfter = racer.util.async.finishAfter , asyncForEach = racer.util.async.forEach , htmlUtil = require('html-util') , parseHtml = htmlUtil.parse , minifyHtml = htmlUtil.minify , styleCompilers = { stylus: stylusCompiler , less: lessCompiler } , onlyWhitespace = /^[\s\n]*$/; exports.css = css; exports.templates = templates; exports.js = js; exports.library = library; exports.parseName = parseName; exports.hashFile = hashFile; exports.writeJs = writeJs; exports.watch = watch; function css(root, clientName, compress, callback) { // TODO: Set default configuration options in a single place var styles = require('./derby').settings.styles || ['less', 'stylus'] , compiled = [] , finish; if (!Array.isArray(styles)) styles = [styles]; finish = finishAfter(styles.length, function(err) { callback(err, compiled.join('')); }); styles.forEach(function(style, i) { var compiler = styleCompilers[style]; if (!compiler) finish(new Error('Unable to find compiler for: ' + style)); compiler(root, clientName, compress, function(err, value) { compiled[i] = value || ''; finish(err); }); }); } function stylusCompiler(root, clientName, compress, callback) { findPath(root + '/styles', clientName, '.styl', function(path) { if (!path) return callback(''); fs.readFile(path, 'utf8', function(err, styl) { if (err) return callback(err); stylus(styl) .use(nib()) .set('filename', path) .set('compress', compress) .render(callback); }); }); } function lessCompiler(root, clientName, compress, callback) { findPath(root + '/styles', clientName, '.less', function(path) { if (!path) return callback(''); fs.readFile(path, 'utf8', function(err, lessFile) { if (err) return callback(err); var parser = new less.Parser({ paths: [root + '/styles'] , filename: path }); parser.parse(lessFile, function(err, tree) { var compiled; if (err) return callback(err); try { compiled = tree.toCSS({compress: compress}); } catch (err) { return callback(err); } callback(null, compiled); }); }); }); } function templates(root, clientName, callback) { loadTemplates(root + '/views', clientName, callback); } function js(parentFilename, options, callback) { var finish, inline, inlineFile, js; // Needed for tests if (!parentFilename) return; if (typeof options === 'function') { callback = options; options = {}; } // TODO: Move this to config: // Express will try to include mime, which won't work in the browser // It doesn't actually need this for routing, so we just ignore it if (options.ignore) { options.ignore.push('mime'); } else { options.ignore = ['mime']; } if (options.require) { options.require.push(parentFilename); } else { options.require = [parentFilename]; } inlineFile = join(dirname(parentFilename), 'inline.js'); finish = finishAfter(2, function(err) { callback(err, js, inline); }); racer.js(options, function(err, value) { js = value; finish(err); }); fs.readFile(inlineFile, 'utf8', function(err, value) { inline = value; // Ignore file not found error if (err && err.code === 'ENOENT') err = null; finish(err); }); } function library(root, callback) { var components = {}; fs.readdir(root, function(err, files) { if (err) return callback(err); asyncForEach(files, libraryFile, function(err) { if (err) return callback(err); callback(null, components); }); }); function libraryFile(file, callback) { var path = root + '/' + file fs.stat(path, function(err, stats) { if (err) return callback(err); if (stats.isDirectory()) { return addComponent(root, file, callback); } if (extensions['html'].test(file)) { file = file.replace(extensions['html'], ''); return addComponent(root, file, callback); } callback(); }); } function addComponent(root, name, callback) { loadTemplates(root, name, function(err, templates, instances) { components[name] = { templates: templates , instances: instances }; callback(err); }); } } function parseName(parentFilename, options) { var parentDir = dirname(parentFilename) , root = parentDir , base = basename(parentFilename, '.js'); if (base === 'index') { base = basename(parentDir); root = dirname(dirname(parentDir)); } else if (basename(parentDir) === 'lib') { root = dirname(parentDir); } return { root: root , clientName: options.name || base , require: './' + basename(parentFilename) }; } function hashFile(file) { var hash = crypto.createHash('md5').update(file).digest('base64'); // Base64 uses characters reserved in URLs and adds extra padding charcters. // Replace "/" and "+" with the unreserved "-" and "_" and remove "=" padding return hash.replace(/[\/\+=]/g, function(match) { switch (match) { case '/': return '-'; case '+': return '_'; case '=': return ''; } }); } function writeJs(root, js, options, callback) { var staticRoot = options.staticRoot || join(root, 'public') , staticDir = options.staticDir || 'gen' , staticPath = join(staticRoot, staticDir) , hash = hashFile(js) , filename = hash + '.js' , jsFile = join('/', staticDir, filename) , filePath = join(staticPath, filename); function finish() { fs.writeFile(filePath, js, function(err) { callback(err, jsFile, hash); }); } exists(staticPath, function(value) { if (value) return finish(); exists(staticRoot, function(value) { if (value) { fs.mkdir(staticPath, '0777', function(err) { finish(); }) return; } fs.mkdir(staticRoot, '0777', function(err) { fs.mkdir(staticPath, '0777', function(err) { finish(); }); }); }); }); } function watch(dir, type, onChange) { var extension = extensions[type] , watchFn = fs.watch ? systemWatch : pollWatch; files(dir, extension).forEach(watchFn); function systemWatch(file) { fs.watch(file, function() { onChange(file); }); } function pollWatch(file) { fs.watchFile(file, {interval: 100}, function(curr, prev) { if (prev.mtime < curr.mtime) { onChange(file); } }); } } function findPath(root, name, extension, callback) { if (name.charAt(0) !== '/') { name = join(root, name); } var path = name + extension; exists(path, function(value) { if (value) return callback(path); path = join(name, 'index' + extension); exists(path, function(value) { callback(value ? path : null); }); }); } function loadTemplates(root, fileName, callback) { var count = 0 , calls = {incr: incr, finish: finish}; function incr() { count++; } function finish(err, templates, instances) { if (err) { calls.finish = function() {}; return callback(err); } --count || callback(null, templates, instances); } forTemplate(root, fileName, 'import', calls); } function forTemplate(root, fileName, get, calls, files, templates, instances, alias, currentNs) { if (currentNs == null) currentNs = ''; calls.incr(); findPath(root, fileName, '.html', function(path) { var getCount, got, matchesGet, promise; if (path === null) { if (!files) { // Return without doing anything if the path isn't found, and this is the // initial automatic lookup based on the clientName return calls.finish(null, {}, {}); } else { return calls.finish(new Error("Can't find file " + fileName)); } } files || (files = {}); templates || (templates = {}); instances || (instances = {}); got = false; if (get === 'import') { matchesGet = function() { return got = true; } } else if (Array.isArray(get)) { getCount = get.length; matchesGet = function(name) { --getCount || (got = true); return ~get.indexOf(name); } } else { matchesGet = function(name) { got = true; return get === name; } } promise = files[path]; if (!promise) { promise = files[path] = new Promise; fs.readFile(path, 'utf8', function(err, file) { promise.resolve(err, file); }); } promise.on(function(err, file) { if (err) calls.finish(err); parseTemplateFile(root, dirname(path), path, calls, files, templates, instances, alias, currentNs, matchesGet, file); if (!got) { calls.finish(new Error("Can't find template '" + get + "' in " + path)); } calls.finish(null, templates, instances); }); }); } function parseTemplateFile(root, dir, path, calls, files, templates, instances, alias, currentNs, matchesGet, file) { var relativePath = relative(root, path) , as, importTemplates, name, ns, src, templateOptions; parseHtml(file, { // Force template tags to be treated as raw tags, // meaning their contents are not parsed as HTML rawTags: /^(?:[^\s=\/>]+:|style|script)$/i , matchEnd: matchEnd , start: start , text: text }); function matchEnd(tagName) { if (tagName.slice(-1) === ':') { return /<\/?[^\s=\/>]+:[\s>]/; } return new RegExp(' 1 && (as != null)) { calls.finish(new Error("Template import of '" + src + "' in " + path + " can't specify multiple 'template' values with 'as'")); } } if ('ns' in attrs) { if (as) calls.finish(new Error("Template import of '" + src + "' in " + path + " can't specifiy both 'ns' and 'as' attributes")); // Import into the namespace specified via 'ns' underneath // the current namespace ns = ns ? currentNs ? currentNs + ':' + ns : ns : currentNs; } else if (as) { // If 'as' is specified, import into the current namespace ns = currentNs; } else { // If no namespace is specified, use the src as a namespace. // Remove leading '.' and '/' characters srcNs = src.replace(/^[.\/]*/, ''); ns = currentNs ? currentNs + ':' + srcNs : srcNs; } ns = ns.toLowerCase(); } else { templateOptions = attrs; } } function text(text, isRawText) { var instanceName, templateName, toGet; if (!matchesGet(name)) return; if (src) { if (!onlyWhitespace.test(text)) { calls.finish(new Error("Template import of '" + src + "' in " + path + " can't contain content: " + text)); } toGet = importTemplates || 'import'; return forTemplate(root, join(dir, src), toGet, calls, files, templates, instances, as, ns); } templateName = relativePath + ':' + name; instanceName = alias || name; if (currentNs) { instanceName = currentNs + ':' + instanceName; } instances[instanceName] = [templateName, templateOptions]; if (templates[templateName]) return; if (!(name && isRawText)) { if (onlyWhitespace.test(text)) return; calls.finish(new Error("Can't read template in " + path + " near the text: " + text)); } templates[templateName] = minifyHtml(text); } } // TODO: These should be set as configuration options var extensions = { html: /\.html$/i , css: /\.styl$|\.css|\.less$/i , js: /\.js$/i }; var ignoreDirectories = ['node_modules', '.git', 'gen']; function ignored(path) { return ignoreDirectories.indexOf(path) === -1; } function files(dir, extension, out) { if (out == null) out = []; fs.readdirSync(dir).filter(ignored).forEach(function(p) { p = join(dir, p); if (fs.statSync(p).isDirectory()) { files(p, extension, out); } else if (extension.test(p)) { out.push(p); } }); return out; }