674 lines
20 KiB
JavaScript
674 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
var fs = require('fs');
|
|
var path = require('path');
|
|
var readdirp = require('readdirp');
|
|
var handlebars = require('handlebars');
|
|
var resolver = require('./resolver');
|
|
var _ = require('lodash');
|
|
|
|
/**
|
|
* Regex pattern for layout directive. {{!< layout }}
|
|
*/
|
|
var layoutPattern = /{{!<\s+([A-Za-z0-9\._\-\/]+)\s*}}/;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
var ExpressHbs = function() {
|
|
this.handlebars = handlebars.create();
|
|
this.SafeString = this.handlebars.SafeString;
|
|
this.Utils = this.handlebars.Utils;
|
|
this.beautify = null;
|
|
this.beautifyrc = null;
|
|
this.cwd = process.cwd();
|
|
};
|
|
|
|
/**
|
|
* Defines content for a named block declared in layout.
|
|
*
|
|
* @example
|
|
*
|
|
* {{#contentFor "pageStylesheets"}}
|
|
* <link rel="stylesheet" href='{{{URL "css/style.css"}}}' />
|
|
* {{/contentFor}}
|
|
*/
|
|
ExpressHbs.prototype.content = function(name, options, context) {
|
|
var block = options.data.root.blockCache[name] || (options.data.root.blockCache[name] = []);
|
|
block.push(options.fn(context));
|
|
};
|
|
|
|
/**
|
|
* Returns the layout filepath given the template filename and layout used.
|
|
* Backward compatible with specifying layouts in locals like 'layouts/foo',
|
|
* but if you have specified a layoutsDir you can specify layouts in locals with just the layout name.
|
|
*
|
|
* @param {String} filename Path to template file.
|
|
* @param {String} layout Layout path.
|
|
*/
|
|
ExpressHbs.prototype.layoutPath = function(filename, layout) {
|
|
var dirs,
|
|
layoutPath;
|
|
if (layout[0] === '.') {
|
|
dirs = path.dirname(filename);
|
|
} else if (this.layoutsDir) {
|
|
dirs = this.layoutsDir;
|
|
} else {
|
|
dirs = this.viewsDir;
|
|
}
|
|
[].concat(dirs).forEach(function (dir) {
|
|
if (!layoutPath) {
|
|
layoutPath = path.resolve(dir, layout);
|
|
}
|
|
});
|
|
return layoutPath;
|
|
};
|
|
|
|
/**
|
|
* Find the path of the declared layout in `str`, if any
|
|
*
|
|
* @param {String} str The template string to parse
|
|
* @param {String} filename Path to template
|
|
* @returns {String|undefined} Returns the path to layout.
|
|
*/
|
|
ExpressHbs.prototype.declaredLayoutFile = function(str, filename) {
|
|
var matches = str.match(layoutPattern);
|
|
if (matches) {
|
|
var layout = matches[1];
|
|
// behave like `require`, if '.' then relative, else look in
|
|
// usual location (layoutsDir)
|
|
if (this.layoutsDir && layout[0] !== '.') {
|
|
layout = path.resolve(this.layoutsDir, layout);
|
|
}
|
|
return path.resolve(path.dirname(filename), layout);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Compiles a layout file.
|
|
*
|
|
* The function checks whether the layout file declares a parent layout.
|
|
* If it does, the parent layout is loaded recursively and checked as well
|
|
* for a parent layout, and so on, until the top layout is reached.
|
|
* All layouts are then returned as a stack to the caller via the callback.
|
|
*
|
|
* @param {String} layoutFile The path to the layout file to compile
|
|
* @param {Boolean} useCache Cache the compiled layout?
|
|
* @param {Function} cb Callback called with layouts stack
|
|
*/
|
|
ExpressHbs.prototype.cacheLayout = function(layoutFile, useCache, cb) {
|
|
var self = this;
|
|
|
|
if (this.restrictLayoutsTo) {
|
|
if (!layoutFile.startsWith(this.restrictLayoutsTo)) {
|
|
var err = new Error('Cannot read ' + layoutFile + ' it does not reside in ' + this.restrictLayoutsTo);
|
|
return cb(err, null);
|
|
}
|
|
}
|
|
|
|
// assume hbs extension
|
|
if (path.extname(layoutFile) === '') layoutFile += this._options.extname;
|
|
|
|
// path is relative in directive, make it absolute
|
|
var layoutTemplates = this.cache[layoutFile];
|
|
if (layoutTemplates) return cb(null, layoutTemplates);
|
|
|
|
fs.readFile(layoutFile, 'utf8', function(err, str) {
|
|
if (err) return cb(err);
|
|
|
|
// File path of eventual declared parent layout
|
|
var parentLayoutFile = self.declaredLayoutFile(str, layoutFile);
|
|
|
|
// This function returns the current layout stack to the caller
|
|
var _returnLayouts = function(layouts) {
|
|
var currentLayout;
|
|
layouts = layouts.slice(0);
|
|
currentLayout = self.compile(str, layoutFile);
|
|
layouts.push(currentLayout);
|
|
if (useCache) {
|
|
self.cache[layoutFile] = layouts.slice(0);
|
|
}
|
|
cb(null, layouts);
|
|
};
|
|
|
|
if (parentLayoutFile) {
|
|
// Recursively compile/cache parent layouts
|
|
self.cacheLayout(parentLayoutFile, useCache, function(err, parentLayouts) {
|
|
if (err) return cb(err);
|
|
_returnLayouts(parentLayouts);
|
|
});
|
|
} else {
|
|
// No parent layout: return current layout with an empty stack
|
|
_returnLayouts([]);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Cache partial templates found under directories configure in partialsDir.
|
|
*/
|
|
ExpressHbs.prototype.cachePartials = function(cb) {
|
|
var self = this;
|
|
|
|
if (!(this.partialsDir instanceof Array)) {
|
|
this.partialsDir = [this.partialsDir];
|
|
}
|
|
|
|
// Use to iterate all folder in series
|
|
var count = 0;
|
|
|
|
function readNext() {
|
|
readdirp(self.partialsDir[count], {fileFilter: '*' + self._options.extname})
|
|
.on('warn', function(err) {
|
|
console.warn('Non-fatal error in express-hbs cachePartials.', err);
|
|
})
|
|
.on('error', function(err) {
|
|
console.error('Fatal error in express-hbs cachePartials', err);
|
|
return cb(err);
|
|
})
|
|
.on('data', function(entry) {
|
|
if (!entry) return;
|
|
var source = fs.readFileSync(entry.fullPath, 'utf8');
|
|
var dirname = path.dirname(entry.path);
|
|
dirname = dirname === '.' ? '' : dirname + '/';
|
|
|
|
var name = dirname + path.basename(entry.basename, self._options.extname);
|
|
// fix the path in windows
|
|
name = name.split('\\').join('/');
|
|
self.registerPartial(name, source, entry.fullPath);
|
|
})
|
|
.on('end', function() {
|
|
count += 1;
|
|
|
|
// If all directories aren't read, read the next directory
|
|
if (count < self.partialsDir.length) {
|
|
readNext();
|
|
} else {
|
|
self.isPartialCachingComplete = true;
|
|
if (cb) cb(null, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
readNext();
|
|
};
|
|
|
|
/**
|
|
* Express 3.x template engine compliance.
|
|
*
|
|
* @param {Object} options = {
|
|
* handlebars: "override handlebars",
|
|
* defaultLayout: "path to default layout",
|
|
* partialsDir: "absolute path to partials (one path or an array of paths)",
|
|
* layoutsDir: "absolute path to the layouts",
|
|
* extname: "extension to use",
|
|
* contentHelperName: "contentFor",
|
|
* blockHelperName: "block",
|
|
* beautify: "{Boolean} whether to pretty print HTML",
|
|
* onCompile: function(self, source, filename) {
|
|
* return self.handlebars.compile(source);
|
|
* }
|
|
* }
|
|
*/
|
|
ExpressHbs.prototype.express3 = function(options) {
|
|
var self = this;
|
|
|
|
// Set defaults
|
|
if (!options) options = {};
|
|
if (!options.extname) options.extname = '.hbs';
|
|
if (!options.contentHelperName) options.contentHelperName = 'contentFor';
|
|
if (!options.blockHelperName) options.blockHelperName = 'block';
|
|
if (!options.templateOptions) options.templateOptions = {};
|
|
if (options.handlebars) this.handlebars = options.handlebars;
|
|
if (options.onCompile) this.onCompile = options.onCompile;
|
|
|
|
this._options = options;
|
|
if (this._options.handlebars) this.handlebars = this._options.handlebars;
|
|
|
|
if (options.i18n) {
|
|
var i18n = options.i18n;
|
|
this.handlebars.registerHelper('__', function() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
var options = args.pop();
|
|
return i18n.__.apply(options.data.root, args);
|
|
});
|
|
this.handlebars.registerHelper('__n', function() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
var options = args.pop();
|
|
return i18n.__n.apply(options.data.root, args);
|
|
});
|
|
}
|
|
|
|
this.handlebars.registerHelper(this._options.blockHelperName, function(name, options) {
|
|
var val = options.data.root.blockCache[name];
|
|
if (val === undefined && typeof options.fn === 'function') {
|
|
val = options.fn(this);
|
|
}
|
|
if (Array.isArray(val)) {
|
|
val = val.join('\n');
|
|
}
|
|
return val;
|
|
});
|
|
|
|
// Pass 'this' as context of helper function to don't lose context call of helpers.
|
|
this.handlebars.registerHelper(this._options.contentHelperName, function(name, options) {
|
|
return self.content(name, options, this);
|
|
});
|
|
|
|
// Absolute path to partials directory.
|
|
this.partialsDir = this._options.partialsDir;
|
|
|
|
// Absolute path to the layouts directory
|
|
this.layoutsDir = this._options.layoutsDir;
|
|
|
|
this.restrictLayoutsTo = this._options.restrictLayoutsTo;
|
|
|
|
// express passes this through ___express func, gulp pass in an option
|
|
this.viewsDir = null;
|
|
this.viewsDirOpt = this._options.viewsDir;
|
|
|
|
// Cache for templates, express 3.x doesn't do this for us
|
|
this.cache = {};
|
|
|
|
// Holds the default compiled layout if specified in options configuration.
|
|
this.defaultLayoutTemplates = null;
|
|
|
|
// Keep track of if partials have been cached already or not.
|
|
this.isPartialCachingComplete = false;
|
|
|
|
return this.___express.bind(this);
|
|
};
|
|
|
|
/**
|
|
* Express 4.x template engine compliance.
|
|
*
|
|
* @param {Object} options = {
|
|
* handlebars: "override handlebars",
|
|
* defaultLayout: "path to default layout",
|
|
* partialsDir: "absolute path to partials (one path or an array of paths)",
|
|
* layoutsDir: "absolute path to the layouts",
|
|
* extname: "extension to use",
|
|
* contentHelperName: "contentFor",
|
|
* blockHelperName: "block",
|
|
* beautify: "{Boolean} whether to pretty print HTML"
|
|
* }
|
|
*/
|
|
ExpressHbs.prototype.express4 = ExpressHbs.prototype.express3;
|
|
|
|
/**
|
|
* Tries to load the default layout.
|
|
*
|
|
* @param {Boolean} useCache Whether to cache.
|
|
*/
|
|
ExpressHbs.prototype.loadDefaultLayout = function(useCache, cb) {
|
|
var self = this;
|
|
if (!this._options.defaultLayout) return cb();
|
|
if (useCache && this.defaultLayoutTemplates) return cb(null, this.defaultLayoutTemplates);
|
|
|
|
this.cacheLayout(this._options.defaultLayout, useCache, function(err, templates) {
|
|
if (err) return cb(err);
|
|
self.defaultLayoutTemplates = templates.slice(0);
|
|
return cb(null, templates);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Expose useful methods.
|
|
*/
|
|
ExpressHbs.prototype.registerHelper = function(name, fn) {
|
|
this.handlebars.registerHelper(name, fn);
|
|
};
|
|
|
|
/**
|
|
* Registers a partial.
|
|
*
|
|
* @param {String} name The name of the partial as used in a template.
|
|
* @param {String} source String source of the partial.
|
|
*/
|
|
ExpressHbs.prototype.registerPartial = function(name, source, filename) {
|
|
this.handlebars.registerPartial(name, this.compile(source, filename));
|
|
};
|
|
|
|
/**
|
|
* Compiles a string.
|
|
*
|
|
* @param {String} source The source to compile.
|
|
* @param {String} filename The path used to embed into __filename for errors.
|
|
*/
|
|
ExpressHbs.prototype.compile = function(source, filename) {
|
|
// Handlebars has a bug with comment only partial causes errors. This must
|
|
// be a string so the block below can add a space.
|
|
if (typeof source !== 'string') {
|
|
throw new Error('registerPartial must be a string for empty comment workaround');
|
|
}
|
|
if (source.indexOf('}}') === source.length - 2) {
|
|
source += ' ';
|
|
}
|
|
|
|
var compiled;
|
|
if (this.onCompile) {
|
|
compiled = this.onCompile(this, source, filename);
|
|
} else {
|
|
compiled = this.handlebars.compile(source);
|
|
}
|
|
|
|
if (filename) {
|
|
if (Array.isArray(this.viewsDir) && this.viewsDir.length > 0) {
|
|
compiled.__filename = path.relative(this.cwd, filename).replace(path.sep, '/');
|
|
} else {
|
|
compiled.__filename = path.relative(this.viewsDir || '', filename).replace(path.sep, '/');
|
|
}
|
|
}
|
|
return compiled;
|
|
};
|
|
|
|
/**
|
|
* Registers an asynchronous helper.
|
|
*
|
|
* @param {String} name The name of the partial as used in a template.
|
|
* @param {String} fn The `function(options, cb)`
|
|
*/
|
|
ExpressHbs.prototype.registerAsyncHelper = function(name, fn) {
|
|
this.handlebars.registerHelper(name, function(context, options) {
|
|
var resolverCache = this.resolverCache ||
|
|
_.get(context, 'data.root.resolverCache') ||
|
|
_.get(options, 'data.root.resolverCache');
|
|
if (!resolverCache) {
|
|
throw new Error('Could not find resolver cache in async helper ' + name + '.');
|
|
}
|
|
if (options && fn.length > 2) {
|
|
var resolveFunc = function(arr, cb) {
|
|
return fn.call(this, arr[0], arr[1], cb);
|
|
};
|
|
|
|
return resolver.resolve(
|
|
resolverCache,
|
|
resolveFunc.bind(this),
|
|
[context, options]
|
|
);
|
|
}
|
|
return resolver.resolve(
|
|
resolverCache,
|
|
fn.bind(this),
|
|
context
|
|
);
|
|
});
|
|
};
|
|
|
|
ExpressHbs.prototype.getTemplateOptions = function() {
|
|
return this._options.templateOptions;
|
|
};
|
|
|
|
ExpressHbs.prototype.updateTemplateOptions = function(templateOptions) {
|
|
this._options.templateOptions = templateOptions;
|
|
};
|
|
|
|
ExpressHbs.prototype.getLocalTemplateOptions = function(locals) {
|
|
return locals._templateOptions || {};
|
|
};
|
|
|
|
ExpressHbs.prototype.updateLocalTemplateOptions = function(locals, localTemplateOptions) {
|
|
return locals._templateOptions = localTemplateOptions;
|
|
};
|
|
|
|
/**
|
|
* Creates a new instance of ExpressHbs.
|
|
*/
|
|
ExpressHbs.prototype.create = function() {
|
|
return new ExpressHbs();
|
|
};
|
|
|
|
/**
|
|
* express 3.x, 4.x template engine compliance
|
|
*
|
|
* @param {String} filename Full path to template.
|
|
* @param {Object} options Is the context or locals for templates. {
|
|
* {Object} settings - subset of Express settings, `settings.views` is
|
|
* the views directory
|
|
* }
|
|
* @param {Function} cb The callback expecting the rendered template as a string.
|
|
*
|
|
* @example
|
|
*
|
|
* Example options from express
|
|
*
|
|
* {
|
|
* settings: {
|
|
* 'x-powered-by': true,
|
|
* env: 'production',
|
|
* views: '/home/coder/barc/code/express-hbs/example/views',
|
|
* 'jsonp callback name': 'callback',
|
|
* 'view cache': true,
|
|
* 'view engine': 'hbs'
|
|
* },
|
|
* cache: true,
|
|
*
|
|
* // the rest are app-defined locals
|
|
* title: 'My favorite veggies',
|
|
* layout: 'layout/veggie'
|
|
* }
|
|
*/
|
|
ExpressHbs.prototype.___express = function ___express(filename, source, options, cb) {
|
|
// support running as a gulp/grunt filter outside of express
|
|
if (arguments.length === 3) {
|
|
cb = options;
|
|
options = source;
|
|
source = null;
|
|
}
|
|
|
|
options.blockCache = {};
|
|
options.resolverCache = {};
|
|
|
|
this.viewsDir = options.settings.views || this.viewsDirOpt;
|
|
var self = this;
|
|
|
|
/**
|
|
* Allow a layout to be declared as a handlebars comment to remain spec
|
|
* compatible with handlebars.
|
|
*
|
|
* Valid directives
|
|
*
|
|
* {{!< foo}} # foo.hbs in same directory as template
|
|
* {{!< ../layouts/default}} # default.hbs in parent layout directory
|
|
* {{!< ../layouts/default.html}} # default.html in parent layout directory
|
|
*/
|
|
function parseLayout(str, filename, cb) {
|
|
var layoutFile = self.declaredLayoutFile(str, filename);
|
|
if (layoutFile) {
|
|
self.cacheLayout(layoutFile, options.cache, cb);
|
|
} else {
|
|
cb(null, null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders `template` with given `locals` and calls `cb` with the
|
|
* resulting HTML string.
|
|
*
|
|
* @param template
|
|
* @param locals
|
|
* @param cb
|
|
*/
|
|
function renderTemplate(template, locals, cb) {
|
|
var res;
|
|
|
|
try {
|
|
var localTemplateOptions = self.getLocalTemplateOptions(locals);
|
|
var localsClone = _.extend({}, locals);
|
|
self.updateLocalTemplateOptions(localsClone, undefined);
|
|
res = template(localsClone, _.merge({}, self._options.templateOptions, localTemplateOptions));
|
|
} catch (err) {
|
|
if (err.message) {
|
|
err.message = '[' + template.__filename + '] ' + err.message;
|
|
} else if (typeof err === 'string') {
|
|
return cb('[' + template.__filename + '] ' + err, null);
|
|
}
|
|
return cb(err, null);
|
|
}
|
|
cb(null, res);
|
|
}
|
|
|
|
|
|
/**
|
|
* Renders `template` with an optional set of nested `layoutTemplates` using
|
|
* data in `locals`.
|
|
*/
|
|
function render(template, locals, layoutTemplates, cb) {
|
|
if (!layoutTemplates) layoutTemplates = [];
|
|
|
|
// We'll render templates from bottom to top of the stack, each template
|
|
// being passed the rendered string of the previous ones as `body`
|
|
var i = layoutTemplates.length - 1;
|
|
|
|
var _stackRenderer = function(err, htmlStr) {
|
|
if (err) return cb(err);
|
|
|
|
if (i >= 0) {
|
|
locals.body = htmlStr;
|
|
renderTemplate(layoutTemplates[i--], locals, _stackRenderer);
|
|
} else {
|
|
cb(null, htmlStr);
|
|
}
|
|
};
|
|
|
|
// Start the rendering with the innermost page template
|
|
renderTemplate(template, locals, _stackRenderer);
|
|
}
|
|
|
|
|
|
/**
|
|
* Lazy loads js-beautify, which should not be used in production env.
|
|
*/
|
|
function loadBeautify() {
|
|
if (!self.beautify) {
|
|
self.beautify = require('js-beautify').html;
|
|
var rc = path.join(process.cwd(), '.jsbeautifyrc');
|
|
if (fs.existsSync(rc)) {
|
|
self.beautifyrc = JSON.parse(fs.readFileSync(rc, 'utf8'));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the source and compiled template for filename either from the cache
|
|
* or compiling it on the fly.
|
|
*/
|
|
function getSourceTemplate(cb) {
|
|
if (options.cache) {
|
|
var info = self.cache[filename];
|
|
if (info) {
|
|
return cb(null, info.source, info.template);
|
|
}
|
|
}
|
|
|
|
fs.readFile(filename, 'utf8', function(err, source) {
|
|
if (err) return cb(err);
|
|
|
|
var template = self.compile(source, filename);
|
|
if (options.cache) {
|
|
self.cache[filename] = {
|
|
source: source,
|
|
template: template
|
|
};
|
|
}
|
|
return cb(null, source, template);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compiles a file into a template and a layoutTemplate, then renders it above.
|
|
*/
|
|
function compileFile(locals, cb) {
|
|
getSourceTemplate(function(err, source, template) {
|
|
if (err) return cb(err);
|
|
|
|
// Try to get the layout
|
|
parseLayout(source, filename, function(err, layoutTemplates) {
|
|
if (err) return cb(err);
|
|
|
|
function renderIt(layoutTemplates) {
|
|
if (self._options.beautify) {
|
|
return render(template, locals, layoutTemplates, function(err, html) {
|
|
if (err) return cb(err);
|
|
loadBeautify();
|
|
return cb(null, self.beautify(html, self.beautifyrc));
|
|
});
|
|
}
|
|
return render(template, locals, layoutTemplates, cb);
|
|
}
|
|
|
|
// Determine which layout to use
|
|
|
|
if (typeof options.layout !== 'undefined' && !options.layout) {
|
|
// If options.layout is falsy, behave as if no layout should be used - suppress defaults
|
|
renderIt(null);
|
|
} else if (layoutTemplates) {
|
|
// 1. Layout specified in template
|
|
renderIt(layoutTemplates);
|
|
} else if (typeof options.layout !== 'undefined' && options.layout) {
|
|
// 2. Layout specified by options from render
|
|
var layoutFile = self.layoutPath(filename, options.layout);
|
|
self.cacheLayout(layoutFile, options.cache, function(err, layoutTemplates) {
|
|
if (err) return cb(err);
|
|
renderIt(layoutTemplates);
|
|
});
|
|
} else if (self.defaultLayoutTemplates) {
|
|
// 3. Default layout specified when middleware was configured.
|
|
renderIt(self.defaultLayoutTemplates);
|
|
} else {
|
|
// render without a template
|
|
renderIt(null);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function replaceValue(values, text) {
|
|
if (typeof text === 'string') {
|
|
Object.keys(values).forEach(function(id) {
|
|
text = text.replace(id, function() {
|
|
return values[id];
|
|
});
|
|
text = text.replace(self.Utils.escapeExpression(id), function() {
|
|
return self.Utils.escapeExpression(values[id]);
|
|
});
|
|
});
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Handles waiting for async helpers
|
|
function handleAsync(err, res) {
|
|
if (err) return cb(err);
|
|
resolver.done(options.resolverCache, function(err, values) {
|
|
if (err) return cb(err);
|
|
Object.keys(values).forEach(function(key) {
|
|
values[key] = replaceValue(values, values[key]);
|
|
});
|
|
res = replaceValue(values, res);
|
|
if (resolver.hasResolvers(res)) {
|
|
return handleAsync(null, res);
|
|
}
|
|
cb(null, res);
|
|
});
|
|
}
|
|
|
|
// kick it off by loading default template (if any)
|
|
this.loadDefaultLayout(options.cache, function(err) {
|
|
if (err) return cb(err);
|
|
|
|
// Force reloading of all partials if caching is not used. Inefficient but there
|
|
// is no loading partial event.
|
|
if (self.partialsDir && (!options.cache || !self.isPartialCachingComplete)) {
|
|
return self.cachePartials(function(err) {
|
|
if (err) return cb(err);
|
|
return compileFile(options, handleAsync);
|
|
});
|
|
}
|
|
|
|
return compileFile(options, handleAsync);
|
|
});
|
|
};
|
|
|
|
module.exports = new ExpressHbs();
|