Learning Assemble, Step 12 - Navigation Helper

2015-02-06

As part of my recent struggle building a list of 5 recent blog posts, I decided that I should just write a custom Handlebars Helper that did what I wanted. After I wrote the helper, I figured out that there was actually a built-in way to accomplish the task, such that my custom code became an unnecessary and possibly buggy addition. I was very disappointed.

But I felt obligated to flesh out my #withPages helper and share it. It is based on the built-in #withSort helper code, with some basic lodash action on the pages array, and a light topping of error messages. But it could be handy.

A typical usage would be something like the following:

<h2>Recent Posts by Posted Date</h2>
<ul>
    {{#withPages collection="posts" inflection="post" value="all" sortBy="data.posted" dir="desc" limit=5}}
        <li>{{formatDate data.posted "%F"}}: Post - {{data.title}} ({{basename}})</li>
    {{/withPages}}
</ul>

the arguments allow for filtering collections, sorting, and limiting the pages:

Collection Filtering

Filtering pages in a collection requires three arguments. By default, {{#withPages}} uses all pages.

  • collection - the name of the collection defined in the Gruntfile (typically "tags", "posts", "keywords", etc.)
  • inflection - the inflection for the collection, defined in the Gruntfile ("tag", "post", "keyword", etc.)
  • value - the collection value to filter on, defined in the YAML font-matter of pages ("new", "all", "javascript", etc.)

Sorting

There are two arguments for sorting attribute and direction:

  • sortBy - (optional) the page property to sort by, typically defined in the YAML front-matter of pages and prefixed with "data.", such as "data.posted". Default sorting is the collection default.
  • dir - (optional) Sort direction, one of "ascending", "asc", "descending", "desc". Ascending by default.

Limiting

You can limit the number of pages selected as follows:

  • limit- (optional) the maximum number of pages to return

Diagnostics

There are two arguments to help troubleshoot selecting pages:

  • verbose - (optional) true or false if you want verbose console logging. Defaults to false.
  • name - (optional) provide a unique name to this {{#withPages}} tag to help match verbose messages to markup. Defaults to a combination of the arguments.

Installation

For now, copy the code below into the file in your helpers folder. You do have a helpers folder, right? I might make this a plugin in the future.

(function() {
    "use strict";

    var _ = require("lodash");

    function getPropertyFromSpec(obj, propertySpec) {
        var properties = propertySpec.split('.');
        var resultObj = obj;
        _.each(properties, function (property) {
            if (resultObj && resultObj[property]) {
                resultObj = resultObj[property];
            } else {
                resultObj = null;
            }
        });
        return resultObj;
    }

    module.exports.register = function(Handlebars, options) {
        Handlebars.registerHelper("withPages", function(options) {
            var result = "";
            var verbose = options.hash.verbose;
            var collectionName = options.hash.collection;
            var inflection = options.hash.inflection;
            var inflectionValue = options.hash.value;
            var limit = options.hash.limit;
            var sortBy = options.hash.sortBy;
            var sortDir = options.hash.dir;
            var sortDescending = options.hash.dir && (sortDir === "desc" || sortDir === "descending");
            var name = options.hash.name || "" + [collectionName, inflection, inflectionValue, sortBy, sortDir, limit].join("/");
            var sourcePageName = this.page.src.substr(this.page.src.lastIndexOf("/") + 1);
            var logPrefix = "{{#withPages}} " + name + " in " + sourcePageName + ": ";

            function log(level, message, extra) {
                if (verbose || level !== "info") {
                    if (extra) {
                        console[level](logPrefix + message, extra);
                    } else {
                        console[level](logPrefix + message);
                    }
                }
            }

            log("info", "{{withPages}} options:", options.hash);
            if (sortDir && (sortDir !== "asc" && sortDir !== "ascending" && sortDir !== "desc" && sortDir !== "descending")) {
                log("warn", "{{withPages dir parameter '" + sortDir + "' is not recognized.  " +
                    "Use one of 'asc', 'ascending', 'desc', 'descending'");
            }

            var selectedPages = this.pages;

            // Collection filtering
            if (collectionName && inflection && inflectionValue) {
                var collection = this[collectionName];
                if (!collection) {
                    log("error", "collection '" + collectionName + "' not found");
                    return result;
                }
                if (collection.length > 0 && !collection[0][inflection]) {
                    log("warn", "{{withPages}} did not find pages matching inflection '" + inflection +
                        "' in collection '" + collectionName + "'");
                    return result;
                }
                var collectionFilter = {};
                collectionFilter[inflection] = inflectionValue;
                var collectionSubset = _.find(collection, collectionFilter);
                if (collectionSubset && collectionSubset.pages && collectionSubset.pages.length > 0) {
                    log("info", "{{withPages}} found " + collectionSubset.pages.length + " pages in collection '" +
                        collectionName + "' with inflection '" + inflection + "' and value '" + inflectionValue + "'");
                    selectedPages = collectionSubset.pages;
                } else {
                    log("info", "{{withPages}} did not find any pages in collection '" + collectionName +
                        "' with inflection '" + inflection + "' and value '" + inflectionValue + "'");
                    return result;
                }
            } else {
                log("info", "using all " + selectedPages.length + " pages");
            }

            // Sorting
            if (sortBy) {
                var pagesWithSortByValue = 0;
                selectedPages = _.sortBy(selectedPages, function (item) {
                    var sortByValue = getPropertyFromSpec(item, sortBy);
                    if (sortByValue) {
                        pagesWithSortByValue++;
                    }
                    return sortByValue;
                });
                log("info", "{{withPages}} sorted by '" + sortBy + "'. " + pagesWithSortByValue + " of " +
                    selectedPages.length + " pages had '" + sortBy + "' values.");
                if (pagesWithSortByValue < selectedPages.length && sortBy.indexOf("data.") == -1) {
                    log("info", "{{withPages}} Did you mean to use sortBy 'data." + sortBy + "'?");
                }
            }
            if (sortDescending) {
                selectedPages = selectedPages.reverse();
                log("info", "{{withPages}} reversing sort order");
            }

            // Limit
            if (limit && limit >= 0) {
                var limitedPages = _.first(selectedPages, limit);
                log("info", "{{withPages}} limiting to " + limitedPages.length + " pages out of " +
                    selectedPages.length);
                selectedPages = limitedPages;
            }

            // Rendering
            _.each(selectedPages, function (page, index, pages) {
                log("info", "{{withPages}} rendering page: " + page.basename);
                result += options.fn(page);
            });

            return result;
        });
    };

}).call(this);

For the code, please see the learning-assemble repository on GitHub. The branch step12-navigation-helper contains the completed code from this post.