diff --git a/Makefile b/Makefile index cb6f235..a8ed7d6 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,6 @@ test: --harmony-generators \ -R spec \ test/render \ - --bail + test/unit .PHONY: test diff --git a/README.md b/README.md index 47302d1..0a1a37b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,27 @@ koa-hbs ======= -Handlebars Templates via Generators for [Koa](https://github.com/koajs/koa/) +[Handlebars](http://handlebarsjs.com) Templates via Generators for +[Koa](https://github.com/koajs/koa/) -## Foreward -This is package offers minimum viability. Registering partials and synchronous -helpers is supported, but asynchronous helpers and layouts are not. Layouts are -next on the list. +[![Build Status][travis-badge]][repo-url] + +## Forward +Things that are supported: +- Registering helpers +- Registering partials +- Specify a directory or multiple directories of partials to register +- Single default layout +- Alternative layouts +- Template caching (actually, this cannot currently be disabled) + +Things that will be, but are **not** yet supported: +- Asynchronous helpers +- Content blocks ## Usage -koa-hbs is middleware. Configure the default instance by passing an options hash to #middleware, or create an independent instance using #create(). +koa-hbs is middleware. Configure the default instance by passing an options hash +to #middleware or on an independant from #create(). ```javascript var koa = require('koa'); @@ -17,13 +29,13 @@ var hbs = require('koa-hbs'); var app = koa(); -// koa-hbs is middleware. Use it before you want to render a view +// koa-hbs is middleware. `use` it before you want to render a view app.use(hbs.middleware({ viewPath: __dirname + '/views' })); -// Render is attached to the koa context. Call this.render in your middleware -// to attach your rendered html to the response body. +// Render is attached to the koa context. Call `this.render` in your middleware +// to attach rendered html to the koa response body. app.use(function *() { yield this.render('main', {title: 'koa-hbs'}); }) @@ -32,22 +44,94 @@ app.listen(3000); ``` +After a template has been rendered, the template function is cached. `#render# +accepts two arguements - the template to render, and an object containing local +variables to be inserted into the template. The result is assigned to Koa's +`this.response.body`. + +### Registering Helpers +Helpers are registered using the #registerHelper method. Here is an example +using the default instance (helper stolen from official Handlebars +[docs](http://handlebarsjs.com): + +```javascript +hbs = require('koa-hbs'); + +hbs.registerHelper('link', function(text, url) { + text = hbs.Utils.escapeExpression(text); + url = hbs.Utils.escapeExpression(url); + + var result = '' + text + ''; + + return new hbs.SafeString(result); +}); +``` + +registerHelper, Utils, and SafeString all proxy to an internal Handlebars +instance. If passing an alternative instance of Handlebars to the middleware +configurator, make sure to do so before registering your helpers via the koa-hbs +proxies, or just register your helpers directly via your Handlebars instance. + +### Registering Partials +The simple way to register partials is to stick them all in a directory, and +pass the `partialsPath` option when generating the middleware. Say your views +are in `./views`, and your partials are in `./views/partials`. Configuring the +middleware as + +### Layouts +Passing `defaultLayout` with the a layout name will cause all templates to be +inserted into the `{{{body}}}` expression of the layout. This might look like +the following. + +```html + + + + {{title}} + + + {{{body}}} + + +``` + +In addition to, or alternatively, you may specify a layout to render a template +into. Simply specify `{{!< layoutName }}` somewhere in your template. koa-hbs +will load your layout from `layoutsPath` if defined, or from `viewPath` +otherwise. + +At this time, only a single content block (`{{{body}}}`) is supported. Block and +contentFor helpers are on the list of features to implement. + ### Options -The plan for koa-hbs is to offer identical functionality as express-hbs (eventaully). These options are supported _now_. +The plan for koa-hbs is to offer identical functionality as express-hbs +(eventaully). These options are supported _now_. -- `viewPath`: [_required_] Where to load templates from +- `viewPath`: [_required_] Full path from which to load templates + (`Array|String`) - `handlebars`: Pass your own instance of handlebars -- `templateOptions`: Options to pass to `template()` -- `extname`: Alter the default template extension (default: `.hbs`) -- `partialsPath`: Use this directory for partials +- `templateOptions`: Hash of + [options](http://handlebarsjs.com/execution.html#Options) to pass to + `template()` +- `extname`: Alter the default template extension (default: `'.hbs'`) +- `partialsPath`: Full path to partials directory (`Array|String`) +- `defaultLayout`: Name of the default layout +- `layoutsPath`: Full path to layouts directory (`String`) -These options are **NOT** supported (because we don't support layouts ... yet). +These options are **NOT** supported yet. - `contentHelperName`: Alter `contentFor` helper name - `blockHelperName`: Alter `block` helper name -- `defaultLayout`: Name of the default layout -- `layoutsDir`: Load layouts from here + + ## Example -You can run the included example via `npm install koa` and `node --harmony app.js` from the example folder. +You can run the included example via `npm install koa` and +`node --harmony app.js` from the example folder. + +## Credits +Functionality and code were inspired/taken from +[express-hbs](https://github.com/barc/express-hbs/). +[travis-badge]: https://travis-ci.org/jwilm/koa-hbs.png?branch=master +[repo-url]: https://travis-ci.org/jwilm/koa-hbs diff --git a/index.js b/index.js index 3b73dac..3d80c83 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,9 @@ var path = require('path'); var co = require('co'); var readdirp = require('readdirp'); +/* Capture the layout name; thanks express-hbs */ +var rLayoutPattern = /{{!<\s+([A-Za-z0-9\._\-\/]+)\s*}}/; + /** * file reader returning a thunk * @param filename {String} Name of file to read @@ -37,6 +40,11 @@ exports.create = function() { function Hbs() { if(!(this instanceof Hbs)) return new Hbs(); + + this.handlebars = require('handlebars').create(); + + this.Utils = this.handlebars.Utils; + this.SafeString = this.handlebars.SafeString; } /** @@ -52,19 +60,19 @@ Hbs.prototype.configure = function (options) { // Attach options var options = options || {}; this.viewPath = options.viewPath; - this.handlebars = options.handlebars || require('handlebars').create(); + this.handlebars = options.handlebars || this.handlebars; this.templateOptions = options.templateOptions || {}; this.extname = options.extname || '.hbs'; this.partialsPath = options.partialsPath || ''; - - // Support for these options is planned, but not yet supported: this.contentHelperName = options.contentHelperName || 'contentFor'; this.blockHelperName = options.blockHelperName || 'block'; this.defaultLayout = options.defaultLayout || ''; - this.layoutsDir = options.layoutsDir || ''; + this.layoutsPath = options.layoutsPath || ''; - // Register partials in options partialsPath(s) - this.registerPartials(); + this.partialsRegistered = false; + + // Cache templates and layouts + this.cache = {}; return this; }; @@ -74,18 +82,11 @@ Hbs.prototype.configure = function (options) { * * @api public */ + Hbs.prototype.middleware = function(options) { - var hbs = this; this.configure(options); - var render = function *(templateName, args) { - var templatePath = path.join(hbs.viewPath, templateName + hbs.extname); - // No caching yet - var tplFile = yield read(templatePath); - var template = hbs.handlebars.compile(tplFile.toString()); - - this.body = template(args, hbs.templateOptions); - }; + var render = this.createRenderer(); return function *(next) { this.render = render; @@ -93,6 +94,101 @@ Hbs.prototype.middleware = function(options) { }; } +/** + * Create a render generator to be attached to koa context + */ + +Hbs.prototype.createRenderer = function() { + var hbs = this; + + return function *(tpl, locals) { + var tplPath = path.join(hbs.viewPath, tpl + hbs.extname), + template, rawTemplate, layoutTemplate; + + locals = locals || {}; + + // Initialization... move these actions into another function to remove + // unnecessary checks + if(!hbs.partialsRegistered) + yield hbs.registerPartials(); + + if(!hbs.layoutTemplate) + hbs.layoutTemplate = yield hbs.cacheLayout(); + + // Load the template + if(!hbs.cache[tpl]) { + rawTemplate = yield read(tplPath); + hbs.cache[tpl] = { + template: hbs.handlebars.compile(rawTemplate) + } + + // Load layout if specified + if(rLayoutPattern.test(rawTemplate)) { + var layout = rLayoutPattern.exec(rawTemplate)[1]; + console.log(layout); + var rawLayout = yield hbs.loadLayoutFile(layout); + hbs.cache[tpl].layoutTemplate = hbs.handlebars.compile(rawLayout); + } + } + + template = hbs.cache[tpl].template; + layoutTemplate = hbs.cache[tpl].layoutTemplate || hbs.layoutTemplate; + + // Run the compiled templates + locals.body = template(locals, hbs.templateOptions); + this.body = layoutTemplate(locals, hbs.templateOptions); + }; +} + +/** + * Get layout path + */ + +Hbs.prototype.getLayoutPath = function(layout) { + if(this.layoutsPath) + return path.join(this.layoutsPath, layout + this.extname); + + return path.join(this.viewPath, layout + this.extname); +} + +/** + * Get a default layout. If none is provided, make a noop + */ + +Hbs.prototype.cacheLayout = function(layout) { + var hbs = this; + return co(function* () { + // Create a default layout to always use + if(!layout && !hbs.defaultLayout) + return hbs.handlebars.compile("{{{body}}}"); + + // Compile the default layout if one not passed + if(!layout) layout = hbs.defaultLayout; + + var layoutTemplate; + try { + var rawLayout = yield hbs.loadLayoutFile(layout); + layoutTemplate = hbs.handlebars.compile(rawLayout); + } catch (err) { + console.error(err.stack); + } + + return layoutTemplate; + }); +} + +/** + * Load a layout file + */ + +Hbs.prototype.loadLayoutFile = function(layout) { + var hbs = this; + return function(done) { + var file = hbs.getLayoutPath(layout); + read(file)(done); + }; +} + /** * Register helper to internal handlebars instance */ @@ -105,15 +201,15 @@ Hbs.prototype.registerHelper = function() { * Register partial with internal handlebars instance */ - Hbs.prototype.registerPartial = function() { +Hbs.prototype.registerPartial = function() { this.handlebars.registerPartial.apply(this.handlebars, arguments); - } +} /** * Register directory of partials */ -Hbs.prototype.registerPartials = function(cb) { +Hbs.prototype.registerPartials = function (cb) { var self = this, partials, dirpArray, files = [], names = [], partials, rname = /^[a-zA-Z_-]+/, readdir; @@ -131,7 +227,7 @@ Hbs.prototype.registerPartials = function(cb) { }; /* Read in partials and register them */ - co(function *() { + return co(function *() { try { readdirpResults = yield self.partialsPath.map(readdir); @@ -149,9 +245,12 @@ Hbs.prototype.registerPartials = function(cb) { self.registerPartial(names[i], partials[i]); } + self.partialsRegistered = true; } catch(e) { console.error('Error caught while registering partials'); console.error(e); } - })(); -}; \ No newline at end of file + + return cb && cb(null, self); + }); +}; diff --git a/package.json b/package.json index 9f65e3f..3a9ca39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "koa-hbs", - "version": "0.2.0", + "version": "0.3.0", "description": "Handlebars Templates via Generators for Koa", "main": "index.js", "repository": { diff --git a/test/app/assets/layouts/alternative.hbs b/test/app/assets/layouts/alternative.hbs new file mode 100644 index 0000000..139e286 --- /dev/null +++ b/test/app/assets/layouts/alternative.hbs @@ -0,0 +1,12 @@ + + + + Alternative {{title}} + + +

ALTERNATIVE LAYOUT

+
+ {{{body}}} +
+ + \ No newline at end of file diff --git a/test/app/assets/layouts/default.hbs b/test/app/assets/layouts/default.hbs new file mode 100644 index 0000000..8ecf83e --- /dev/null +++ b/test/app/assets/layouts/default.hbs @@ -0,0 +1,10 @@ + + + + {{title}} + + +

DEFAULT LAYOUT

+ {{{body}}} + + \ No newline at end of file diff --git a/test/app/assets/useAlternativeLayout.hbs b/test/app/assets/useAlternativeLayout.hbs new file mode 100644 index 0000000..f6a981a --- /dev/null +++ b/test/app/assets/useAlternativeLayout.hbs @@ -0,0 +1,2 @@ +{{!< alternative }} +

ALTERNATIVE CONTENT

\ No newline at end of file diff --git a/test/app/assets/useDefaultLayout.hbs b/test/app/assets/useDefaultLayout.hbs new file mode 100644 index 0000000..276dc3a --- /dev/null +++ b/test/app/assets/useDefaultLayout.hbs @@ -0,0 +1 @@ +

DEFAULT CONTENT

\ No newline at end of file diff --git a/test/app/index.js b/test/app/index.js index 75251b2..d0f577c 100644 --- a/test/app/index.js +++ b/test/app/index.js @@ -1,7 +1,7 @@ var hbs = require('../../index'); var koa = require('koa'); -var create = function() { +var create = function(opts) { var app = koa(); var _hbs = hbs.create(); @@ -9,10 +9,7 @@ var create = function() { console.error(err.stack); }); - app.use(_hbs.middleware({ - viewPath: __dirname + '/assets', - partialsPath: __dirname + '/assets/partials' - })); + app.use(_hbs.middleware(opts)); app.use(function*(next) { if(this.path == '/') @@ -35,6 +32,20 @@ var create = function() { } }); + app.use(function*(next) { + if(this.path == '/layout') + yield this.render('useDefaultLayout'); + else + yield next; + }); + + app.use(function*(next) { + if(this.path == '/altLayout') + yield this.render('useAlternativeLayout'); + else + yield next; + }); + return app; } diff --git a/test/render.js b/test/render.js index 338bf90..f459671 100644 --- a/test/render.js +++ b/test/render.js @@ -10,11 +10,13 @@ describe('without required options', function() { }); }); -describe('render', function() { +describe('simple render', function() { var app; - before(function(done) { - app = testApp.create(); - setTimeout(done, 200); // hack to make sure partials are loaded + before(function() { + app = testApp.create({ + viewPath: __dirname + '/app/assets', + partialsPath: __dirname + '/app/assets/partials' + }); }); it('should put html in koa response body', function(done) { @@ -41,5 +43,46 @@ describe('render', function() { }); }); }); +}); + +describe('when using layouts', function() { + var app; + before(function() { + // Create app which specifies layouts + app = testApp.create({ + viewPath: __dirname + '/app/assets', + partialsPath: __dirname + '/app/assets/partials', + layoutsPath: __dirname + '/app/assets/layouts', + defaultLayout: 'default' + }); + }); + + describe('with the default layout', function() { + it('should insert rendered content', function(done) { + request(app.listen()) + .get('/layout') + .expect(200) + .end(function(err, content) { + if(err) return done(err); + assert.ok(/DEFAULT LAYOUT/.test(content.text)); + assert.ok(/DEFAULT CONTENT/.test(content.text)); + done(); + }); + }); + + it('should support alternative layouts', function(done) { + request(app.listen()) + .get('/altLayout') + .expect(200) + .end(function(err, content) { + if(err) return done(err); + assert.ok(/ALTERNATIVE LAYOUT/.test(content.text)); + assert.ok(/ALTERNATIVE CONTENT/.test(content.text)); + done(); + }) + }) + }); }); + + diff --git a/test/unit.js b/test/unit.js new file mode 100644 index 0000000..35d44ac --- /dev/null +++ b/test/unit.js @@ -0,0 +1,19 @@ +var assert = require('assert'); +var path = require('path'); + +describe('getLayoutPath', function() { + var hbs; + before(function() { + hbs = require('..').create(); + hbs.middleware({ + viewPath: __dirname + '/app/assets', + layoutsPath: __dirname + '/app/assets/layouts', + partialsPath: __dirname + '/app/assets/partials' + }); + }); + + it('should return the correct path', function() { + var layoutPath = path.join(__dirname, '/app/assets/layouts/default.hbs'); + assert.equal(hbs.getLayoutPath('default'), layoutPath); + }); +});