Learning Assemble, Step 7 - Custom Handlebars Helpers

2014-10-29

If you don't like what Assemble does, you can probably change it with custom Handlebars Helpers. The best part is that there are already many good open-source helpers around, either for direct use or for study and modification. We'll look at how to use and author Handlebars helpers in this post.

  • About Handlebars Helpers
  • Writing a Simple Helper
  • Writing a Block Helper for Navigation Links

About Handlebars Helpers

A Handlebars Helper is a Javascript function, registered with the Handlebars engine. Formal distribution of helpers consists of installing them as libraries through NPM, just like the helpers that come built-in to Handlebars and the many Handlebars helpers provided by Assemble. Thankfully, it is also possible to install them as simple scripts in your project, as long as you do two things:

  1. Modify your assemble configuration to specify where helpers are found
  2. Properly format the helper scripts

Helper References

I'm hardly writing the definitive post on Handlebars helpers. You would be well served to take a look at the following references:

  • Handlebars - The mechanics of custom helpers are described briefly in the home page.
  • Handlebars Block Helpers - Nice concise and helpful documentation of the mechanics of block helpers, with samples.
  • Assemble Handlebars Helpers - This project has many helpers from the guys who wrote Assemble, and is a good place to look for ideas and sample code.
  • grunt-init-helper - A good, quick way to get a properly formatted helper started.

Writing a Simple Helper

We'll write a simple "Hello, World!" helper first, continuing the spirit of this series.

Helper Configuration

The first thing we need to do is modify the Assemble configuration in our Gruntfile to define where Assemble should look for and register helpers:


        assemble: {
            hello: {
                options: {
                    data: 'source/data/*.{json,yml}',
                    layoutdir: 'source/templates/layouts',
                    layout: 'default.hbs',
                    partials: 'source/templates/partials/**/*.hbs',
                    collections: [
                        { name: 'navTags', inflection: 'navTag' }
                    ],
                    helpers: ['source/helpers/**/*.js']
                },
                files: [
                    { expand: true, cwd: 'source/templates/pages', src: '**/*.{hbs,md}', dest: 'output/' }
                ]
            }
        },

We should also modify the configuration for Watch to include our helper files.


        watch: {
            templates: {
                files: ['source/templates/**/*.{hbs,md}','source/data/**/*.json','source/helpers/**/*.js'],
                tasks: ['assemble']
            },
            livereload: {
                options: {
                    livereload: '<%= connect.preview.options.livereload %>'
                },
                files: ['output/**.*']
            }
        }

Creating the Hello Helper

I used grunt-init-helper to get started with some boilerplate, then customized it into this awesome "Hello, World!" Handlebars helper. I put the code in source/helpers/hello-helper.js:

(function() {
    module.exports.register = function(Handlebars, options) {

        Handlebars.registerHelper('hello', function(name) {
            return new Handlebars.SafeString("Hello, " + name + "!");
        });

    };
}).call(this);

Testing the Hello Helper

Now you can use this helper in a page with {{hello "World"}}. For example, in our about.hbs page:

---
title: "About Us"
navTags:
 - header
 - footer
navLabel: About
navSort: 70
---
<h1>{{ title }}</h1>
<p>This is the About page content</p>
{{hello "World"}}

Our helper does some simple string concatenation, and "Hello, World!" is injected into the page.

<h1>About Us</h1>
<p>This is the About page content</p>
Hello, World!
<hr/>

You might be underwhelmed by the hello helper. But I hope you agree that it is easy to add new helper logic and customize your Assemble site.

Writing a Block Helper

For added excitement, let's create our own helper to iterate over pages based on our navTag concept introduced in an earlier post. The Handlebars block helper docs provide a starting point for a basic iterator based on the {{#each}} helper:

Handlebars.registerHelper('each', function(context, options) {
  var ret = "";

  for(var i=0, j=context.length; i<j; i++) {
    ret = ret + options.fn(context[i]);
  }

  return ret;
});

But we want to iterate through all of the pages associated with one tag in the navTags collection. For example, all of the pages tagged "header". A first pass at source/helpers/nav-helpers.js looks as follows:

(function() {
    var _ = require("lodash");
    module.exports.register = function(Handlebars, options) {
        Handlebars.registerHelper("navPages", function(navTag, options) {
            var result = "";
            var collectionItem = _.find(this["navTags"], {"navTag": navTag});
            if (collectionItem) {
                _.each(collectionItem.pages, function (page, index, pages) {
                    result += options.fn(page);
                });
            }
            return result;
        });
    };
}).call(this);

The hard part of this code is figuring out the data model of our navTags collection. I discovered that through the use of console.dir(this) and then finding the tag data. The relevant snippet of the data model is this:

  navTags:
   [ { navTag: 'blog', pages: [Object] },
     { navTag: 'footer', pages: [Object] },
     { navTag: 'header', pages: [Object] } ],

There is a top-level navTags array of item objects, where each item having an array of pages. That matches what we know about collections. The code I wrote above hard codes the "navTags" collection and "navTag" item name, but something more generic could be written to traverse any collection, items, and pages.

To test this, let's put this in our header.hbs:

<div>
    {{#navPages "header"}}
        <a href="/{{relativeLink}}">{{data.navLabel}}</a>
    {{/navPages}}
</div>

This works, but it still has some of the problems we experienced in the earlier navigation post. First, the sorting of these pages is not right. Also, we want to separate the links with a pipe ("|"), but there should be no pipe after the last link.

The sorting can be fixed by sorting our array of selected pages by the custom navSort attribute before we render the content of the helper. A revised nav-helpers.js:


(function() {
    var _ = require("lodash");
    module.exports.register = function(Handlebars, options) {
        Handlebars.registerHelper("navPages", function(navTag, options) {
            var result = "";
            var collectionItem = _.find(this["navTags"], {"navTag": navTag});
            if (collectionItem) {
                var sortedPages = _.sortBy(collectionItem.pages, function (page, index, pages) {
                    return page.data.navSort || 0;
                });
                _.each(sortedPages, function (page, index, pages) {
                    result += options.fn(page);
                });
            }
            return result;
        });
    };
}).call(this);

To manage presentation of the list items, we'll add the zero-based index of where the page falls in our nav list to the page data object.


(function() {
    var _ = require("lodash");
    module.exports.register = function(Handlebars, options) {
        Handlebars.registerHelper("navPages", function(navTag, options) {
            var result = "";
            var collectionItem = _.find(this["navTags"], {"navTag": navTag});
            if (collectionItem) {
                var sortedPages = _.sortBy(collectionItem.pages, function (page, index, pages) {
                    return page.data.navSort || 0;
                });
                _.each(sortedPages, function (page, index, pages) {
                    page.navIndex = index;
                    result += options.fn(page);
                });
            }
            return result;
        });
    };
}).call(this);

We can use this in header.hbs similar to the way we once used the built-in @index variable. At the time, we found that @index did not work without the context of filtering applied to the list. Here, we control that context.

<div>
    {{#navPages "header"}}
        {{#if navIndex}} | {{/if}}
        <a href="/{{relativeLink}}">{{data.navLabel}}</a>
    {{/navPages}}
</div>

I'm not sure this is the best solution for navigation, but it's a bit better than our header was at the start of this post. This doesn't feel particularly reusable to me, given the dependence on having the navTags collection and YAML Front-Matter for the tagging, labeling, and sorting. Still, I enjoyed being able to dive into the data and work with it in a custom helper.

For the code, please see the learning-assemble repository on GitHub. The branch step07-handlebars-helpers contains the completed code from this post.

Next: Learning Assemble, Step 8 - Debugging Assemble