github dribbble twitter facebook google arrow-up arrow-down arrow-left arrow-right

Dynamic Menus in Jekyll

October 13, 2016 - Comments

Ever since I started working with Jekyll and publishing projects using GitHub Pages, I’ve struggled to find the best way to manage menus. I was able to pick up a lot of tips and tricks from my Google searches but there really isn’t a one size fits all solution that I could find. I’ve looked at data driven, front matter and url based solutions, but all of them fell short in some way.

The solutions I came up with utilize a number of techniques I picked up with some really nice optional features. These solutions are great for projects that range from simple top level navigation to more complex and deep page schemas—such as documentation sites.

The Basics

The most common thing I do in all Jekyll menus is to iterate through my site pages and output their respective links. The simplest way of doing this is by using site.pages and the for loop.

<ul>
{% for node in site.pages %}
  <li><a href="{{ node.url }}">{{ node.title }}</a></li>
{% endfor %}
</ul>

This isn’t very useful though since it will just output all our pages regardless of their relationship to one another. We can improve this in a few ways. First, we want to have the ability to order our pages in some way. The best way I’ve found to do this is to include a variable in our front matter called order with an integer to sort by (think of this similar to WordPress’s order page attribute). So now we’ll save our pages in a variable and sort them by this new front matter variable.

{% assign pages = site.pages | sort: 'order' %}

<ul>
{% for node in pages %}
  <li><a href="{{ node.url }}">{{ node.title }}</a></li>
{% endfor %}
</ul>

The next way we can improve our menu is to add an active class to the page we’re currently on. This can be done by simply comparing the current page url with our node’s url.

{% if page.url == node.url %} ... {% endif %}

The only issue with this method is when you still want to display a link as active because one of it’s ancestors is selected. But we can accomplish this using the contains operator.

{% if page.url contains node.url %} ... {% endif %}
{% assign pages = site.pages | sort: 'order' %}

<ul>
{% for node in pages %}
  <li{% if page.url contains node.url %} class="active"{% endif %}>
    <a href="{{ node.url }}">{{ node.title }}</a>
  </li>
{% endfor %}
</ul>

Another option that would be great to add is the ability to have custom link text for our menu items since we may not always want to display the full page title in our menu. This can be accomplished by leveraging our front matter again and adding a link variable. But we only want this used for pages that have this set, otherwise lets keep using our page titles as default.

{% if node.link %}
  {{ node.link }}
{% else %}
  {{ node.title }}
{% endif %}
{% assign pages = site.pages | sort: 'order' %}

<ul>
{% for node in pages %}
  <li{% if page.url contains node.url %} class="active"{% endif %}>
    <a href="{{ node.url }}">
      {% if node.link %}
        {{ node.link }}
      {% else %}
        {{ node.title }}
      {% endif %}
    </a>
  </li>
{% endfor %}
</ul>

Now we have a base menu to start manipulating with some basic functionality that will carry us through all our menus. But we still have a bit of work to do. The main issue we have now is that our menu is flat. Sometimes we’ll want to properly display menu parents and their children, while in other cases we may only want to display the pages of a specific parent and depth. Lets tackle the latter next.

Creating Top Level Menus

A lot of times when displaying menus, we don’t actually want to display every item. It’s fairly common, for example, in main header navigations to only show the top level menu items. To achieve this, we’ll need to introduce a depth variable. Within our for iteration, we can check our current node’s depth by examining it’s url.

{% for node in pages %}
  {% assign node_parts = node.url | split: '/' %}
  {% assign node_depth = node_parts | size %}
  {% if node_depth == 2 %}
    ...
  {% endif %}
{% endfor %}

This will get our current nodes depth and won’t output the item unless it matches the exact depth we’re looking for. In this case, the depth 2 represents root pages e.g.: /blog/, /projects/, /about.html, etc but would exclude the home page /. If you wanted to include the home page in your main navigation, you could simply change your operator to <= instead of == to allow depths less than 2.

{% if node_depth <= 2 %} ... {% endif %}

Another option feature we could add is the ability to exclude pages from our menu. This is not an uncommon practice, and there are a few methods of doing this. But I think the best method is to utilize the order front matter we already have. Since the context of this variable would only be useful to menu items we wanted to list, we could simply check that it exists and only list the pages that have it.

{% if node.order %} ... {% endif %}
{% assign pages = site.pages | sort: 'order' %}

<ul>
{% for node in pages %}
  {% assign node_parts = node.url | split: '/' %}
  {% assign node_depth = node_parts | size %}
  {% if node.order %}
    {% if node_depth == 2 %}
      <li{% if page.url contains node.url %} class="active"{% endif %}>
        <a href="{{ node.url }}">
          {% if node.link %}
            {{ node.link }}
          {% else %}
            {{ node.title }}
          {% endif %}
        </a>
      </li>
    {% endif %}
  {% endif %}
{% endfor %}
</ul>

Creating Direct Children Menus

This solution is perfect for top level menus. But you’ll run into issues when you want to list deeper menu items. The problem is that if you wanted to list level 3 depth items, you’ll end up listing all of them, regardless of their parents. So you’ll have the children of /about/ and /projects/ mixed together. What we need, is a way to only display the children of a specific parent page.

A good example of this is when we only want to list the children of our current page. The solution I use is to compare our node’s url with the current page’s url.

{% if node.url contains page.url %} ... {% endif %}

Next, we’ll need a way to dynamically determine the menu depth we need to display. For this example, the depth will always need to be 1 more than the current page’s depth. So we’ll need to calculate the depth of our current page as well as our node’s and that will allow us to only display the correct depth.

{% assign parts = page.url | split: '/' %}
{% assign depth = parts | size %}
{% assign depth = depth | plus: 1 %}

{% for node in pages %}
  {% assign node_parts = node.url | split: '/' %}
  {% assign node_depth = node_parts | size %}

  {% if node_depth == depth %}
    ...
  {% endif %}
{% endfor %}

Combining these two techniques, we can now properly display only the children of our current page, regardless of their depth. Note also that we’ve removed the check for active menu items. Since we only display children of the current page, we’ll never actually have an “active” menu item.

{% assign pages = site.pages | sort: 'order' %}
{% assign parts = page.url | split: '/' %}
{% assign depth = parts | size %}
{% assign depth = depth | plus: 1 %}

<ul>
{% for node in pages %}
  {% if node.url contains page.url %}
    {% assign node_parts = node.url | split: '/' %}
    {% assign node_depth = node_parts | size %}
    {% if node.order %}
      {% if node_depth == depth %}
        <li>
          <a href="{{ node.url }}">
            {% if node.link %}
              {{ node.link }}
            {% else %}
              {{ node.title }}
            {% endif %}
          </a>
        </li>
      {% endif %}
    {% endif %}
  {% endif %}
{% endfor %}
</ul>

The only other way we can improve on this code, is to do a check that the current page actually has children before we output anything. Otherwise, we’ll be outputting an empty <ul> element when children do not exist. To do this, we need to assume there are no children, then loop through all our pages and check if our node’s url contains the page’s url while also not being equal to it. If this check passes, we then flag that the current node has children.

{% assign has_children = false %}

{% for node in pages %}
  {% if node.url contains page.url and node.url != page.url %}
    {% assign has_children = true %}
  {% endif %}
{% endfor %}

{% if has_children == true %}
  <ul> ... </ul>
{% endif %}

The final code for displaying children menus of your current page would now look like this:

{% assign pages = site.pages | sort: 'order' %}
{% assign parts = page.url | split: '/' %}
{% assign depth = parts | size %}
{% assign depth = depth | plus:1 %}

{% assign has_children = false %}
{% for node in pages %}
  {% if node.url contains page.url and node.url != page.url %}
    {% assign has_children = true %}
  {% endif %}
{% endfor %}

{% if has_children == true %}
  <ul>
  {% for node in pages %}
    {% if node.url contains page.url %}
      {% assign node_parts = node.url | split: '/' %}
      {% assign node_depth = node_parts | size %}
      {% if node_depth == depth %}
        <li>
          <a href="{{ node.url }}">
            {% if node.link %}
              {{ node.link }}
            {% else %}
              {{ node.title }}
            {% endif %}
          </a>
        </li>
      {% endif %}
    {% endif %}
  {% endfor %}
  </ul>
{% endif %}

Creating Fully Nested Menus

Well, if you thought that dynamic Jekyll menus were convoluted now, you better brace yourself. Tackling a fully nested menu system in Jekyll without plugins (to allow for GitHub Pages’ auto deploy) we’ll need to step into some menu-ception. Luckily, if you’ve gotten this far, you already know all the techniques we’ll need.

There’s no limit to how deep we can allow our menus to go, but for every depth we allow, we’ll need to do a few things:

  1. Set our node depth.
  2. Check that our node matches the depth we’re looking for. If true, we output our <li> element.
  3. Set our root path. We use this to check if our next page iteration is a child.
  4. Check if our node has children. If true, we initiate our next <ul> element.
  5. Start our next pages loop. This time we’ll be using subnode as our variable so we don’t override the previous iterations.
  6. Check that our node is the child of our previous node and also not the parent itself. If true, we start back from step 1 and continue for every layer of menu depth.
<ul>
<!-- Our initial pages iteration -->
{% for node in pages %}

  <!-- 1. Set our node depth -->
  {% assign node_parts = node.url | split: '/' %}
  {% assign node_depth = node_parts | size %}

  <!-- 2. Check our node depth -->
  {% if node_depth == 3 %}
    <li>
      <a>...</a>

      <!-- 3. Set our root path. This is one part less than the depth we're after -->
      {% assign node_root = '/' | append: node_parts[1] | append: '/' | append: node_parts[2] | append: '/' %}

      <!-- 4. Check if our node has children -->
      {% assign has_children = false %}
      {% for node in pages %}
        {% if node.url contains url_root and node.url != url_root %}
          {% assign has_children = true %}
        {% endif %}
      {% endfor %}

      {% if has_children == true %}
        <ul>
        <!-- 5. Start our next pages loop -->
        <!-- It's important that we use new variables for subnodes so we don't override the previous iterations -->
        {% for subnode in pages %}

          <!-- 6. Check that this is in fact a child of the parent node and also not the parent itself -->
          {% if subnode.url contains node_root and subnode.url != node_root %}

            <!-- Since we're in a new page iteration, we need to find the depth and root in this context -->
            {% assign node_parts = subnode.url | split: '/' %}
            {% assign node_depth = node_parts | size %}

            {% if node_depth == 4 %}
              <li>
                <a>...</a>

                <!-- Repeat the process for each layer of menu depth -->

              </li>
            {% endif %}<!-- End of subnode_depth if -->
          {% endif %}<!-- End of subnode.url contains node_root if -->
        {% endfor %}<!-- End of subnode in pages loop -->
        </ul>
      {% endif %}<!-- End of has_children if -->
    </li>
  {% endif %}<!-- End of node_depth if -->
{% endfor %}<!-- End of node in pages loop -->
</ul>

I think probably the most important aspects of this technique to keep an eye on is the depth, root and current iteration variables since these are probably where you’ll have issues if the output isn’t what you expected. So if we wanted to output the root pages, we’d be checking items on a depth of 2 and creating a root path of:

{% assign node_root = '/' | append: node_parts[1] | append: '/' %}

In contrast, if we wanted the next level of navigation, we’d check items on a depth of 3 and a root path of:

{% assign subnode_root = '/' | append: node_parts[1] | append: '/' | append: node_parts[2] | append: '/' %}

Another thing to note is that we don’t need to set/check depth of an iteration if it’s our last one. We’ll still check that it’s an ancestor item, but unless you want to exclude further nested pages, it’s not necessary.

Dynamic nested menu from root to third level pages

The final working code with all this put into practice is as follows. This will give you a fully nested dynamic menu up to three levels deep, starting from root pages.

{% assign pages = site.pages | sort: 'order' %}

<ul>
{% for node in pages %}
  {% assign node_parts = node.url | split: '/' %}
  {% assign node_depth = node_parts | size %}

  {% if node_depth == 2 %}
    <li{% if page.url contains node.url %} class="active"{% endif %}>
      <a href="{{ node.url }}">
        {% if node.link %}
          {{ node.link }}
        {% else %}
          {{ node.title }}
        {% endif %}
      </a>

      {% assign node_root = '/' | append: node_parts[1] | append: '/' %}
      {% assign has_children = false %}
      {% for subnode in pages %}
        {% if subnode.url contains node_root and subnode.url != node_root %}
          {% assign has_children = true %}
        {% endif %}
      {% endfor %}

      {% if has_children == true %}
        <ul>
          {% for subnode in pages %}
            {% if subnode.url contains node_root and subnode.url != node_root %}
              {% assign node_parts = subnode.url | split: '/' %}
              {% assign node_depth = node_parts | size %}

              {% if node_depth == 3 %}
                <li{% if page.url contains subnode.url %} class="active"{% endif %}>
                  <a href="{{ subnode.url }}">
                    {% if subnode.link %}
                      {{ subnode.link }}
                    {% else %}
                      {{ subnode.title }}
                    {% endif %}
                  </a>

                  {% assign subnode_root = '/' | append: node_parts[1] | append: '/' | append: node_parts[2] | append: '/' %}
                  {% assign has_children = false %}
                  {% for subsubnode in pages %}
                    {% if subsubnode.url contains subnode_root and subsubnode.url != subnode_root %}
                      {% assign has_children = true %}
                    {% endif %}
                  {% endfor %}

                  {% if has_children == true %}
                    <ul>
                      {% for subsubnode in pages %}
                        {% if subsubnode.url contains subnode_root and subsubnode.url != subnode_root %}
                          <li{% if page.url contains subsubnode.url %} class="active"{% endif %}>
                            <a href="{{ subsubnode.url }}">
                              {% if subsubnode.link %}
                                {{ subsubnode.link }}
                              {% else %}
                                {{ subsubnode.title }}
                              {% endif %}
                            </a>
                          </li>
                        {% endif %}
                      {% endfor %}
                    </ul>
                  {% endif %}
                </li>
              {% endif %}
            {% endif %}
          {% endfor %}
        </ul>
      {% endif %}
    </li>
  {% endif %}
{% endfor %}
</ul>

Dynamic nested menu from second to third level pages

In some cases, such as sidebar navigation widgets, you’ll only want to list the children of the current page. In these cases, we can alter the above example a little using all the techniques we’ve covered.

{% assign pages = site.pages | sort: 'order' %}

{% assign parts = page.url | split: '/' %}
{% assign root = '/' | append: parts[1] | append: '/' %}
{% assign has_children = false %}
{% for node in pages %}
  {% if node.url contains root and node.url != root %}
    {% assign has_children = true %}
  {% endif %}
{% endfor %}

{% if has_children == true %}
<nav class="sidebar-item sidebar-item-menu">
  <h2>Menu</h2>

  <ul>
    {% for node in pages %}
      {% if node.url contains root %}

        {% assign node_parts = node.url | split: '/' %}
        {% assign node_depth = node_parts | size %}

        {% if node_depth == 3 %}
          <li{% if page.url contains node.url %} class="active"{% endif %}>
            <a href="{{ node.url }}">
              {% if node.link %}
                {{ node.link }}
              {% else %}
                {{ node.title }}
              {% endif %}
            </a>

            {% assign node_root = '/' | append: node_parts[1] | append: '/' | append: node_parts[2] | append: '/' %}
            {% assign has_children = false %}
            {% for subnode in pages %}
              {% if subnode.url contains node_root and subnode.url != node_root %}
                {% assign has_children = true %}
              {% endif %}
            {% endfor %}

            {% if has_children == true %}
              <ul>
                {% for subnode in pages %}
                  {% if subnode.url contains node_root and subnode.url != node_root %}
                    <li{% if page.url contains subnode.url %} class="active"{% endif %}>
                      <a href="{{ subnode.url }}">
                        {% if subnode.link %}
                          {{ subnode.link }}
                        {% else %}
                          {{ subnode.title }}
                        {% endif %}
                      </a>
                    </li>
                  {% endif %}
                {% endfor %}
              </ul>
            {% endif %}
          </li>
        {% endif %}
      {% endif %}
    {% endfor %}
  </ul>
</nav>
{% endif %}

Conclusion

I know there are probably way more elegant ways of creating dynamic menus using plugins, but this seems to be a suitable solution given our constraints within the vanilla liquid environment. If any of you have tackled dynamic menus in Jekyll and came up with a different solution, let me know! I’d love to see what others have done or if there are ways that I can improve on with my current method.

Comments