nknapp / promised-handlebars

Wrapper for Handlebars that allows helpers returning promises
MIT License
47 stars 14 forks source link

promised-handlebars

NPM version Travis Build Status Coverage Status

Wrapper for Handlebars that allows helpers returning promises

Installation

npm install promised-handlebars

Usage

promised-handlebars creates a new a Handlebars-instance with wrapped compile-method and registerHelper-method to allow helpers that return promises.

As a side-effect (in order to allow asynchronous template execution) the compiled template-function itself always returns a promise instead of a string.

Simple helpers

Simple helpers can just return promises.

var promisedHandlebars = require('promised-handlebars')
var Q = require('q')
var Handlebars = promisedHandlebars(require('handlebars'), { Promise: Q.Promise })

// Register a helper that returns a promise
// Helpers do not have to return a promise of the sane
Handlebars.registerHelper('helper', function (value) {
  return Q.delay(100).then(function () {
    return value
  })
})

var template = Handlebars.compile('123{{helper a}}456{{helper b}}')

// The whole compiled function returns a promise as well
template({
  a: 'abc',
  b: 'xyz'
}).done(console.log)

This will generate the following output: 123abc456xyz

Block helpers

If a block-helper, calls the helper-contents (options.fn) and the else-block (options.inverse) asynchronously, i.e. from within a promise chain those functions may return a promise.

When those methods is called synchronously they return the value as they do in default Handlebars.
This means that helper-libraries written for Handlebars will still work, but you can also write block-helpers that do some asynchronous work before evaluating the block contents, such as:

var promisedHandlebars = require('promised-handlebars')
var Handlebars = promisedHandlebars(require('handlebars'), { Promise: require('q').Promise })
var httpGet = require('get-promise')

// A block helper (retrieve github.com user data for a given username)
// Execute the helper-block with the user data when it resolves
Handlebars.registerHelper('github-user', function (value, options) {
  var url = 'https://api.github.com/users/' + value
  return httpGet(url, { headers: { 'User-Agent': 'Node' } })
    .get('data')
    .then(JSON.parse)
    .then(function (data) {
      // `options.fn` returns a promise. Wrapping brackets must be added after resolving
      return options.fn(data)
    })
})

var template = Handlebars.compile('{{username}}: {{#github-user username}}{{{name}}}{{/github-user}}')

// The whole compiled function returns a promise as well
template({
  username: 'nknapp'
}).done(console.log)

This will generate the following output: nknapp: Nils Knappmeier

Usage with other promise libraries

Starting with v2.x, promised-handlebars, no longer depends on Q. Instead, you will need to ensure that a Promises/A+ implementation is provided. You can do this by passing the promise implementation of your choice in the options.

var promisedHandlebars = require('promised-handlebars')
var Q = require('q')
// Pass Q.Promise to promisedHandlebars in the options.Promise
var Handlebars = promisedHandlebars(require('handlebars'), { Promise: Q.Promise })

promised-handlebars will default to using the global Promise object, so if your environment provides it (e.g if you are using node@^4.x or a modern web browser) or if you are using a promise library as global.Promise, you will not need to pass the Promise constructor in the options.

global.Promise = require('bluebird')
var promisedHandlebars = require('promised-handlebars')
// By default promisedHandlebars uses global.Promise,
var Handlebars = promisedHandlebars(require('handlebars'))

Mixed Promises/A+

When writing helper functions that return promises, it is not necessary to return the same sort of promises that you used when invoking promisedHelper().

var promisedHandlebars = require('promised-handlebars')
var Handlebars = promisedHandlebars(require('handlebars'))

var Q = require('q')

// Register a helper that returns a promise
// Helpers can use any A+ promises type
Handlebars.registerHelper('helper', function (value) {
  return Q.delay(100).then(function () {
    return value
  })
})

var template = Handlebars.compile('ABC{{helper a}}XYZ{{helper b}}')

// The whole compiled function returns a promise as well
template({
  a: '123',
  b: '456'
}).then(console.log)

This works and generates ABC123XYZ456

How it works

Handlebars allows you to register helper functions and that can be called from the template. The template itself is compiled into the function that can be called to render a JSON.

This module wraps the compiled template function and helpers register with registerHelper in order to do the following:

The result is a promise for the finalized template output.

Edge-cases

There are things to think about that are covered by this module:

Caveats / TODOs

Customizable placeholder: The algorithm currently uses the char \u0001 as placeholder in the template. It may happen that the placeholder-sequence occurs in the template, a partial, the input data or that it is generated by helpers. In such a case, it will replaced by a promise result at some point.

This can be ommited by passing an other character in the options parameter, but this is not a complete solution.

Known problems: Although many edge-cases are handled by this module, there are some cases that cannot be handled properly. The following example uses a synchronous block-helper {{#trim}}...{{trim}} to remove whitespace from both ends of the enclosed block and a asynchronous helper {{promise-helper}} that returns a promise for a boolean value:

var promisedHandlebars = require('promised-handlebars')
var Q = require('q')
var Handlebars = promisedHandlebars(require('handlebars'), { Promise: Q.Promise })

Handlebars.registerHelper({
  // Returns a promise for `true`
  'eventually-true': function () {
    return Q.delay(1).then(function () {
      return true
    })
  },
  // Trim whitespaces from block-content result.
  'trim': function (options) {
    return String(options.fn()).trim()
  }
})

var template = Handlebars.compile('{{#trim}}{{#if (eventually-true)}}   abc   {{/if}}{{/trim}}')

// We would expect "abc", but...
template({}).then(JSON.stringify).done(console.log)

The output of the example still contains the surrounding spaces, so the {{#trim}} helper appearently did not work:

" abc "

The problem is, that the {{#if}}-helper cannot be executed until the result of {{eventually-true}} is resolved. This means, that the {{#if}}-helper must return a promise instead of the actual string. Returning a promise means inserting a placeholder, but calling .trim() on the placeholder does not return the whitespaces around the resolved result.

If you are writing the {{#trim}}-helper yourself, you can adjust it so that it uses promises:

'trim': function (options) {
    return Q()
      .then(function () {
        return options.fn()
      })
      .then(function (contents) {
        return contents.trim()
      })
  }

Then, the output will be correct: "abc"

If you cannot easily adapt the {{#trim}}-helper, you have a problem. Suggestions welcome.

See open issues for possible other problems.

Other solutions for async-handlebars

License

promised-handlebars is published under the MIT-license. See LICENSE.md for details.

Release-Notes

For release notes, see CHANGELOG.md

Contributing guidelines

See CONTRIBUTING.md.