var ejs = require('ejs')
, fs = require('fs')
, path = require('path')
, exists = fs.existsSync || path.existsSync
, resolve = path.resolve
, extname = path.extname
, dirname = path.dirname
, join = path.join
, basename = path.basename;
/**
* Express 3.x Layout & Partial support for EJS.
*
* The `partial` feature from Express 2.x is back as a template engine,
* along with support for `layout` and `block/script/stylesheet`.
*
*
* Example index.ejs:
*
* <% layout('boilerplate') %>
*
I am the <%=what%> template
* <% script('foo.js') %>
*
*
* Example boilerplate.ejs:
*
*
*
* It's <%=who%>
* <%-scripts%>
*
* <%-body%>
*
*
*
* Sample app:
*
* var express = require('express')
* , app = express();
*
* // use ejs-locals for all ejs templates:
* app.engine('ejs', require('ejs-locals'));
*
* // render 'index' into 'boilerplate':
* app.get('/',function(req,res,next){
* res.render('index', { what: 'best', who: 'me' });
* });
*
* app.listen(3000);
*
* Example output for GET /:
*
*
*
* It's me
*
*
*
I am the best template
*
*
*/
var renderFile = module.exports = function(file, options, fn){
// Express used to set options.locals for us, but now we do it ourselves
// (EJS does some __proto__ magic to expose these funcs/values in the template)
if (!options.locals) {
options.locals = {};
}
if (!options.locals.blocks) {
// one set of blocks no matter how often we recurse
var blocks = { scripts: new Block(), stylesheets: new Block() };
options.locals.blocks = blocks;
options.locals.scripts = blocks.scripts;
options.locals.stylesheets = blocks.stylesheets;
options.locals.block = block.bind(blocks);
options.locals.stylesheet = stylesheet.bind(blocks.stylesheets);
options.locals.script = script.bind(blocks.scripts);
}
// override locals for layout/partial bound to current options
options.locals.layout = layout.bind(options);
options.locals.partial = partial.bind(options);
ejs.renderFile(file, options, function(err, html) {
if (err) {
return fn(err,html);
}
var layout = options.locals._layoutFile;
// for backward-compatibility, allow options to
// set a default layout file for the view or the app
// (NB:- not called `layout` any more so it doesn't
// conflict with the layout() function)
if (layout === undefined) {
layout = options._layoutFile;
}
if (layout) {
// use default extension
var engine = options.settings['view engine'] || 'ejs',
desiredExt = '.'+engine;
// apply default layout if only "true" was set
if (layout === true) {
layout = path.sep + 'layout' + desiredExt;
}
if (extname(layout) !== desiredExt) {
layout += desiredExt;
}
// clear to make sure we don't recurse forever (layouts can be nested)
delete options.locals._layoutFile;
delete options._layoutFile;
// make sure caching works inside ejs.renderFile/render
delete options.filename;
if (layout.length > 0 && layout[0] === path.sep) {
// if layout is an absolute path, find it relative to view options:
layout = join(options.settings.views, layout.slice(1));
} else {
// otherwise, find layout path relative to current template:
layout = resolve(dirname(file), layout);
}
// now recurse and use the current result as `body` in the layout:
options.locals.body = html;
renderFile(layout, options, fn);
} else {
// no layout, just do the default:
fn(null, html);
}
});
};
/**
* Memory cache for resolved object names.
*/
var cache = {};
/**
* Resolve partial object name from the view path.
*
* Examples:
*
* "user.ejs" becomes "user"
* "forum thread.ejs" becomes "forumThread"
* "forum/thread/post.ejs" becomes "post"
* "blog-post.ejs" becomes "blogPost"
*
* @return {String}
* @api private
*/
function resolveObjectName(view){
return cache[view] || (cache[view] = view
.split('/')
.slice(-1)[0]
.split('.')[0]
.replace(/^_/, '')
.replace(/[^a-zA-Z0-9 ]+/g, ' ')
.split(/ +/).map(function(word, i){
return i ? word[0].toUpperCase() + word.substr(1) : word;
}).join(''));
}
/**
* Lookup partial path from base path of current template:
*
* - partial `_`
* - any `/index`
* - non-layout `..//index`
* - any `/`
* - partial `/_`
*
* Options:
*
* - `cache` store the resolved path for the view, to avoid disk I/O
*
* @param {String} root, full base path of calling template
* @param {String} partial, name of the partial to lookup (can be a relative path)
* @param {Object} options, for `options.cache` behavior
* @return {String}
* @api private
*/
function lookup(root, partial, options){
var engine = options.settings['view engine'] || 'ejs'
, desiredExt = '.' + engine
, ext = extname(partial) || desiredExt
, key = [ root, partial, ext ].join('-');
if (options.cache && cache[key]) return cache[key];
// Make sure we use dirname in case of relative partials
// ex: for partial('../user') look for /path/to/root/../user.ejs
var dir = dirname(partial)
, base = basename(partial, ext);
// _ prefix takes precedence over the direct path
// ex: for partial('user') look for /root/_user.ejs
partial = resolve(root, dir,'_'+base+ext);
if( exists(partial) ) return options.cache ? cache[key] = partial : partial;
// Try the direct path
// ex: for partial('user') look for /root/user.ejs
partial = resolve(root, dir, base+ext);
if( exists(partial) ) return options.cache ? cache[key] = partial : partial;
// Try index
// ex: for partial('user') look for /root/user/index.ejs
partial = resolve(root, dir, base, 'index'+ext);
if( exists(partial) ) return options.cache ? cache[key] = partial : partial;
// FIXME:
// * there are other path types that Express 2.0 used to support but
// the structure of the lookup involved View class methods that we
// don't have access to any more
// * we probaly need to pass the Express app's views folder path into
// this function if we want to support finding partials relative to
// it as well as relative to the current view
// * we have no tests for finding partials that aren't relative to
// the calling view
return null;
}
/**
* Render `view` partial with the given `options`. Optionally a
* callback `fn(err, str)` may be passed instead of writing to
* the socket.
*
* Options:
*
* - `object` Single object with name derived from the view (unless `as` is present)
*
* - `as` Variable name for each `collection` value, defaults to the view name.
* * as: 'something' will add the `something` local variable
* * as: this will use the collection value as the template context
* * as: global will merge the collection value's properties with `locals`
*
* - `collection` Array of objects, the name is derived from the view name itself.
* For example _video.html_ will have a object _video_ available to it.
*
* @param {String} view
* @param {Object|Array} options, collection or object
* @return {String}
* @api private
*/
function partial(view, options){
var collection
, object
, locals
, name;
// parse options
if( options ){
// collection
if( options.collection ){
collection = options.collection;
delete options.collection;
} else if( 'length' in options ){
collection = options;
options = {};
}
// locals
if( options.locals ){
locals = options.locals;
delete options.locals;
}
// object
if( 'Object' != options.constructor.name ){
object = options;
options = {};
} else if( options.object !== undefined ){
object = options.object;
delete options.object;
}
} else {
options = {};
}
// merge locals into options
if( locals )
options.__proto__ = locals;
// merge app locals into options
for(var k in this)
options[k] = options[k] || this[k];
// extract object name from view
name = options.as || resolveObjectName(view);
// find view, relative to this filename
// (FIXME: filename is set by ejs engine, other engines may need more help)
var root = dirname(options.filename)
, file = lookup(root, view, options)
, key = file + ':string';
if( !file )
throw new Error('Could not find partial ' + view);
// read view
var source = options.cache
? cache[key] || (cache[key] = fs.readFileSync(file, 'utf8'))
: fs.readFileSync(file, 'utf8');
options.filename = file;
// re-bind partial for relative partial paths
options.partial = partial.bind(options);
// render partial
function render(){
if (object) {
if ('string' == typeof name) {
options[name] = object;
} else if (name === global) {
// wtf?
// merge(options, object);
}
}
// TODO Support other templates (but it's sync now...)
var html = ejs.render(source, options);
return html;
}
// Collection support
if (collection) {
var len = collection.length
, buf = ''
, keys
, prop
, val
, i;
if ('number' == typeof len || Array.isArray(collection)) {
options.collectionLength = len;
for (i = 0; i < len; ++i) {
val = collection[i];
options.firstInCollection = i === 0;
options.indexInCollection = i;
options.lastInCollection = i === len - 1;
object = val;
buf += render();
}
} else {
keys = Object.keys(collection);
len = keys.length;
options.collectionLength = len;
options.collectionKeys = keys;
for (i = 0; i < len; ++i) {
prop = keys[i];
val = collection[prop];
options.keyInCollection = prop;
options.firstInCollection = i === 0;
options.indexInCollection = i;
options.lastInCollection = i === len - 1;
object = val;
buf += render();
}
}
return buf;
} else {
return render();
}
}
/**
* Apply the given `view` as the layout for the current template,
* using the current options/locals. The current template will be
* supplied to the given `view` as `body`, along with any `blocks`
* added by child templates.
*
* `options` are bound to `this` in renderFile, you just call
* `layout('myview')`
*
* @param {String} view
* @api private
*/
function layout(view){
this.locals._layoutFile = view;
}
function Block() {
this.html = [];
}
Block.prototype = {
toString: function() {
return this.html.join('\n');
},
append: function(more) {
this.html.push(more);
},
prepend: function(more) {
this.html.unshift(more);
},
replace: function(instead) {
this.html = [ instead ];
}
};
/**
* Return the block with the given name, create it if necessary.
* Optionally append the given html to the block.
*
* The returned Block can append, prepend or replace the block,
* as well as render it when included in a parent template.
*
* @param {String} name
* @param {String} html
* @return {Block}
* @api private
*/
function block(name, html) {
// bound to the blocks object in renderFile
var blk = this[name];
if (!blk) {
// always create, so if we request a
// non-existent block we'll get a new one
blk = this[name] = new Block();
}
if (html) {
blk.append(html);
}
return blk;
}
// bound to scripts Block in renderFile
function script(path, type) {
if (path) {
this.append('');
}
return this;
}
// bound to stylesheets Block in renderFile
function stylesheet(path, media) {
if (path) {
this.append('');
}
return this;
}