Learning Assemble, Step 5 - Page Navigation

2014-10-27

We used hard-coded navigation links earlier, and I promised that we would come back and build navigation links dynamically from the Assemble page data. Let's do that now. Some of the topics we'll cover include:

  • Simple page links in Assemble
  • Solutions for navigation link problems
    • Custom page collections with custom YAML Front-Matter
    • Custom nav data
    • Back to hard-coding?

Simple Page Navigation in Assemble

Assemble supports creation of page lists and navigation through the concept of collections, with a Pages Collection provided by default. In the simplest form, you iterate over the pages collection using the {{#each pages}} Handlebars syntax.

Our original navigation list was hard-coded in our header.hbs partial template:

<div>
    <a href="/">Home</a> |
    <a href="/features.html">Features</a> |
    <a href="/pricing.html">Pricing</a> |
    <a href="/about.html">About</a>
</div>

Re-writing this to use {{#each pages}}, header.hbs would look like this:

<div>
    {{#each pages}}
        <a href="{{relativeLink}}">{{data.title}}</a>
    {{/each}}
</div>

Inside the {{#each pages}} loop, we can use {{relativeLink}} and other built-in pages variables. The page title is arbitrary metadata we added in the YAML Front-Matter. Within the pages loop, this data is prefixed with data. to reference the custom data. Let's see what HTML that results in:

<!DOCTYPE html>
<html>
<head>
    <title>Home | Awesome Static Site</title>
</head>
<body>
<div>
    <a href="about.html">About Us</a>
    <a href="features.html">Features</a>
    <a href="index.html">Home</a>
    <a href="pricing.html">Pricing</a>
</div>
<h1>Home</h1>
<p>We have lots of cool stuff</p>
<ul>
    <li>Promotion 1</li>
    <li>Promotion 2</li>
    <li>Promotion 3</li>
</ul>
</body>
</html>

That seems to work for our simple list, but there are a couple of things we lost in the transition. We lost our custom sort order (Home, Features, Pricing, About) in favor of lexicographical sorting based on the basename (about, features, index, pricing).

We also lost the pipe separator between entries, but that is a more general Handlebars formatting issue rather than a navigation issue. There are several ways to address this type of Handlebars iteration issue in Assemble pages. One solution is provided by Assemble, through the last true/false attribute on each item of the pages collection. A solution using that looks like this:

<div>
    {{#each pages}}
        <a href="{{relativeLink}}">{{data.title}}</a>{{#unless last}} |{{/unless}}
    {{/each}}
</div>

I found another workable solution on Stack Overflow using Handlebars {{#if}} and the @index built-in Handlebars index counter (thanks, Nahkala):

<div>
    {{#each pages}}
        {{#if @index}}| {{/if}}<a href="{{relativeLink}}">{{data.title}}</a>
    {{/each}}
</div>

There is also the problem that our page list is unfiltered and includes all pages. That might not be obvious in our site yet because we have so little content, but real sites have error pages, privacy policies, terms and conditions, etc. These pages should be built and deployed, but might or might not be in a navigation list. Let's try adding a privacy page, privacy.hbs, to demonstrate this problem.

---
title: "Privacy Policy"
---
<h1>{{ title }}</h1>
<p>We keep your data "safe".</p>

Sure enough, the Privacy Policy page now appears in our list of nav links. A smaller issue might be the displayed text, "Privacy Policy", which while being the correct title for the page is a bit long for a list of links. We might prefer something snappier like just "Privacy". However, this shows some incompatible usage with the YAML Front Matter title variable, where in fact we might like a separate page title and link title.

Summary of Basic Page Navigation Issues

Ringing up the list of issues we encountered so far:

  1. Filtering the complete list of pages down to those that should appear in a navigation list
  2. Sorting the pages by some arbitrary criteria
  3. Customizing the text displayed for a link (just "Privacy" instead of "Privacy Policy")

Working with collections appears to be somewhat awkward in Assemble. I recommend reading several documentation pages to get a fuller picture of the possibilities:

Now let's look at some potential solutions to the problems we encountered selecting, sorting, and formatting links in a navigation list.

Custom Collection

Assemble supports the notion of a custom Collection defined in the options of pages. Collections are modeled on the tag concept, where any one page may belong to zero or more tags. Setting up a custom collection means a parallel tagging scheme. We can adapt this to our navigation needs by setting up the collection, 'tagging' each page with the navigation lists it should belong to, and then using the navigation 'tags' to generate lists of links. This has the advantage of not only solving our header nav, it would provide for a separate list to be used in the footer.

Setting up a Custom Collection

We define a custom collection in the Assemble configuration in our Gruntfile:


        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' }
                    ]
                },
                files: [
                    { expand: true, cwd: 'source/templates/pages', src: '**/*.hbs', dest: 'output/' }
                ]
            }
        },

You can see we've added the collections option on line 10, and defined one collection named 'navTags'. According to the Assemble documentation, custom collections also support custom sort options, but that has not been my experience. I have omitted the sortby and sortorder options in favor of using {{#withSort}} against custom YAML data.

Tagging Pages for Navigation Lists

Each of our pages now needs to be 'tagged' for inclusion in a navigation list in the YAML Front-Matter. Additionally, we want to add a sort order and a label to be used in the navigation scheme. For example, here is what the about.hbs page looks like now:

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

We added tags for both the header navigation list and a footer list, when we get to that. We also have a navigation label separate from the formal page title. Last, we added a sort value to be used in navigation lists.

In contrast, privacy.hbs only has tags for the footer nav:

---
title: "Privacy Policy"
navTags:
 - footer
navLabel: Privacy
navSort: 100
---
<h1>{{ title }}</h1>
<p>We keep your data "safe".</p>

Using Navigation Lists

Our new navigation tagging works by enumerating the list of navigation tags, then iterating over the list of pages within that tag, if tag is 'header'. This seems very clumsy compared to a richer programming language, but it works and appears to be the recommended solution for Assemble. I also use the {{#withSort}} Handlebars helper to take advantage of our custom navSort property.

<div>
    {{#each navTags}}
        {{#is navTag "header"}}
            {{#withSort pages 'data.navSort'}}
                <a href="{{relativeLink}}">{{data.navLabel}}</a>{{#unless last}} |{{/unless}}
            {{/withSort}}
        {{/is}}
    {{/each}}
</div>

There are some drawbacks. Our pipe separators are now broken again, because the last variable refers to the entire list of pages rather than the extract we want to work with. Fundamentally, we are still using the complete list of pages, we are just conditionally printing HTML as certain pages go by. There is also a theoretical performance difference iterating over the entire list, but I expect that to be irrelevant in practice unless you have a huge static site.

This may be a workable approach for many static sites. It allows separate navigation schemes, with custom labeling and sorting. Much of the data is contained in YAML Front-Matter of specific pages, which should make it easier to maintain as you go. Obviously some decent CSS could cover up the pipe-delimited mess we have now.

Custom Navigation Data

Since wrestling with Assemble's page model was kind of awkward, you might be tempted to just throw that out and use custom data instead. Let's try it out in a separate footer navigation scheme.

First, we'll create a new custom data file, nav.json, in our source/data folder. This will get added at the top level of the custom data model.

{
    "footer" : [
        {
            "label" : "Home",
            "href" : "/index.html"
        },
        {
            "label" : "Features",
            "href" : "/features.html"
        },
        {
            "label" : "Pricing",
            "href" : "/pricing.html"
        },
        {
            "label" : "About",
            "href" : "/about.html"
        },
        {
            "label" : "Privacy",
            "href" : "/privacy.html"
        }
    ]
}

Simple enough. Now we can iterate through this in our new footer.hbs file:

<div>
    {{#each nav.header}}
        {{#if @index}} | {{/if}}<a href="{{href}}">{{label}}</a>
    {{/each}}
</div>

And then include the new footer from the default.hbs template:

<!DOCTYPE html>
<html>
<head>
    <title>{{title}} | {{site.title}}</title>
</head>
<body>
{{> header }}
{{> body }}
{{> footer }}
</body>
</html>

This is comparatively nice and clean, much closer to where we were at the start of this post. The custom list is exactly as we want it, with the correct links included, in the correct order, and with the correct labels. Because the custom data is extensible, we could add extras like custom icons, images, styling, etc.

This definitely worked better in terms of selecting pages, customizing labels, and customizing the sort order. And the programming model seems more natural. But the maintenance burden has been shifted, you need to update not only the page files themselves, but also the navigation data. If you do a lot of nav code generation, using more than one scheme, this probably is the best solution.

Hard-Coding Revisted

I saved this one for last, because we would have laughed it off at the top. Our initial header.hbs was just a hard-coded HTML list of links:

<div>
    <a href="/">Home</a> |
    <a href="/features.html">Features</a> |
    <a href="/pricing.html">Pricing</a> |
    <a href="/about.html">About</a>
</div>

Earlier, we thought that was dumb, that we needed to dynamically generate the links as pages were added. But then we discovered how awkward that is in Assemble, and how much work might be required to overcome the challenges in selection, sorting, and display of page links. At this point, does hard-coding seem so bad? Is it really that different than the custom JSON file?

I know, I know, hard-coding is morally wrong, and we'll get sent to Computer Science prison or something. But look how long we spent going through this post. If you really want just a customized list of 4 or 5 links, type it out and move on with life.

For the code, please see the learning-assemble repository on GitHub. The branch step05-page-nav contains the completed code from this post.

Next: Learning Assemble, Step 6 - Blogging with Markdown