Files
2012-05-30 23:00:06 -04:00

461 lines
13 KiB
JavaScript

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('</' + tagName, 'i');
}
function start(tag, tagName, attrs) {
var i = tagName.length - 1
, srcNs, template;
as, importTemplates, ns, src = null;
name = (tagName.charAt(i) === ':' ? tagName.slice(0, i) : '').toLowerCase();
if (name === 'import') {
src = attrs.src, ns = attrs.ns, as = attrs.as, template = attrs.template;
if (!src) {
calls.finish(new Error("Template import in " + path +
" must have a 'src' attribute"));
}
if (template) {
importTemplates = template.toLowerCase().split(' ');
if (importTemplates.length > 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;
}