scalra
Version:
node.js framework to prototype and scale rapidly
493 lines (426 loc) • 12 kB
JavaScript
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') %>
* <h1>I am the <%=what%> template</h1>
* <% script('foo.js') %>
*
*
* Example boilerplate.ejs:
*
* <html>
* <head>
* <title>It's <%=who%></title>
* <%-scripts%>
* </head>
* <body><%-body%></body>
* </html>
*
*
* 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 /:
*
* <html>
* <head>
* <title>It's me</title>
* <script src="foo.js"></script>
* </head>
* <body><h1>I am the best template</h1></body>
* </html>
*
*/
function compile(file, options, cb) {
// 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 = {};
options.locals.blocks = blocks;
options.locals.block = block.bind(blocks);
}
// override locals for layout/partial bound to current options
options.locals.layout = layout.bind(options);
options.locals.partial = partial.bind(options);
try {
var fn = ejs.compile(file, options);
cb(null, fn.toString());
} catch(ex) {
cb(ex);
}
}
function renderFile(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 = {};
options.locals.blocks = blocks;
options.locals.block = block.bind(blocks);
}
// 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) {
var views = options.settings.views;
var l = layout;
if (!Array.isArray(views)) {
views = [views];
}
for (var i = 0; i < views.length; i++) {
layout = join(views[i], l);
// use the first found layout
if (exists(layout)) {
break;
}
}
}
// now recurse and use the current result as `body` in the layout:
options.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 `_<name>`
* - any `<name>/index`
* - non-layout `../<name>/index`
* - any `<root>/<name>`
* - partial `<root>/_<name>`
*
* 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('-')
, partialPath = partial;
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;
// Try relative to the app views
if (!options._isRelativeToViews) {
var views = options.settings.views;
options._isRelativeToViews = true;
if (!Array.isArray(views)) {
views = [views];
}
for (var i = 0; i < views.length; i++) {
partial = lookup(views[i], partialPath, options);
if (partial) {
// reset state for when the partial has a partial lookup of its own
options._isRelativeToViews = false;
return partial;
}
}
}
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 = options.filename?dirname(options.filename):options.settings.views
, 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;
}
renderFile.compile = compile;
renderFile.partial = partial;
renderFile.block = block;
renderFile.layout = layout;
module.exports = renderFile;