UNPKG

scalra

Version:

node.js framework to prototype and scale rapidly

493 lines (426 loc) 12 kB
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;