Recreating Hashnode Series (Categories) in Jekyll on GitHub Pages

I recently migrated this site from Hashnode to GitHub Pages, and I'm really getting into the flexibility and control that managing the content through Jekyll provides. So, naturally, after finalizing the move I got to work recreating Hashnode's "Series" feature, which lets you group posts together and highlight them as a collection. One of the things I liked about the Series setup was that I could control the order of the collected posts: my posts about building out the vRA environment in my homelab are probably best consumed in chronological order (oldest to newest) since the newer posts build upon the groundwork laid by the older ones, while posts about my other one-off projects could really be enjoyed in any order.

I quickly realized that if I were hosting this pretty much anywhere other than GitHub Pages I could simply leverage the jekyll-archives plugin to manage this for me - but, alas, that's not one of the plugins supported by the platform. I needed to come up with my own solution, and being still quite new to Jekyll (and this whole website design thing in general) it took me a bit of fumbling to get it right.

Reviewing the theme-provided option

The Jekyll theme I'm using (Minimal Mistakes) comes with built-in support for a category archive page, which (like the tags page) displays all the categorized posts on a single page. Links at the top will let you jump to an appropriate anchor to start viewing the selected category, but it's not really an elegant way to display a single category.

Posts by category

It's a start, though, so I took a few minutes to check out how it's being generated. The category archive page lives at _pages/

2title: "Posts by Category"
3layout: categories
4permalink: /categories/
5author_profile: true

The title indicates what's going to be written in bold text at the top of the page, the permalink says that it will be accessible at http://localhost/categories/, and the nice little author_profile sidebar will appear on the left.

This page then calls the categories layout, which is defined in _layouts/categories.html:

 1{% raw %}---
 2layout: archive
 5{{ content }}
 7{% assign categories_max = 0 %}
 8{% for category in site.categories %}
 9  {% if category[1].size > categories_max %}
10    {% assign categories_max = category[1].size %}
11  {% endif %}
12{% endfor %}
14<ul class="taxonomy__index">
15  {% for i in (1..categories_max) reversed %}
16    {% for category in site.categories %}
17      {% if category[1].size == i %}
18        <li>
19          <a href="#{{ category[0] | slugify }}">
20            <strong>{{ category[0] }}</strong> <span class="taxonomy__count">{{ i }}</span>
21          </a>
22        </li>
23      {% endif %}
24    {% endfor %}
25  {% endfor %}
28{% assign entries_layout = page.entries_layout | default: 'list' %}
29{% for i in (1..categories_max) reversed %}
30  {% for category in site.categories %}
31    {% if category[1].size == i %}
32      <section id="{{ category[0] | slugify | downcase }}" class="taxonomy__section">
33        <h2 class="archive__subtitle">{{ category[0] }}</h2>
34        <div class="entries-{{ entries_layout }}">
35          {% for post in category.last %}
36            {% include archive-single.html type=entries_layout %}
37          {% endfor %}
38        </div>
39        <a href="#page-title" class="back-to-top">{{[site.locale].back_to_top | default: 'Back to Top' }} &uarr;</a>
40      </section>
41    {% endif %}
42  {% endfor %}
43{% endfor %}{% endraw %}

I wanted my solution to preserve the formatting that's used by the theme elsewhere on this site so this bit is going to be my base. The big change I'll make is that instead of enumerating all of the categories on one page, I'll have to create a new static page for each of the categories I'll want to feature. And each of those pages will refer to a new layout to determine what will actually appear on the page.

Defining a new layout

I create a new file called _layouts/series.html which will define how these new series pages get rendered. It starts out just like the default categories.html one:

1{% raw %}---
2layout: archive
5{{ content }}{% endraw %}

That {{ content }} block will let me define text to appear above the list of articles - very handy. Much of the original categories.html code has to do with iterating through the list of categories. I won't need that, though, so I'll jump straight to setting what layout the entries on this page will use:

1{% assign entries_layout = page.entries_layout | default: 'list' %}

I'll be including two custom variables in the Front Matter for my category pages: tag to specify what category to filter on, and sort_order which will be set to reverse if I want the older posts up top. I'll be able to access these in the layout as page.tag and page.sort_order, respectively. So I'll go ahead and grab all the posts which are categorized with page.tag, and then decide whether the posts will get sorted normally or in reverse:

1{% raw %}{% assign posts = site.categories[page.tag] %}
2{% if page.sort_order == 'reverse' %}
3    {% assign posts = posts | reverse %}
4{% endif %}{% endraw %}

And then I'll loop through each post (in either normal or reverse order) and insert them into the rendered page:

1{% raw %}<div class="entries-{{ entries_layout }}">
2    {% for post in posts %}
3        {% include archive-single.html type=entries_layout %}
4    {% endfor %}
5</div>{% endraw %}

Putting it all together now, here's my new _layouts/series.html file:

 1{% raw %}---
 2layout: archive
 5{{ content }}
 7{% assign entries_layout = page.entries_layout | default: 'list' %}
 8{% assign posts = site.categories[page.tag] %}
 9{% if page.sort_order == 'reverse' %}
10    {% assign posts = posts | reverse %}
11{% endif %}
12<div class="entries-{{ entries_layout }}">
13    {% for post in posts %}
14        {% include archive-single.html type=entries_layout %}
15    {% endfor %}
16</div>{% endraw %}

Series pages

Since I can't use a plugin to automatically generate pages for each series, I'll have to do it manually. Fortunately this is pretty easy, and I've got a limited number of categories/series to worry about. I started by making a new _pages/ and setting it up thusly:

 1{% raw %}---
 2title: "Adventures in vRealize Automation 8"
 3layout: series
 4permalink: "/series/vra8"
 5tag: vRA8
 6sort_order: reverse
 7author_profile: true
 9    teaser: assets/images/posts-2020/RtMljqM9x.png
12*Follow along as I create a flexible VMware vRealize Automation 8 environment for provisioning virtual machines - all from the comfort of my Intel NUC homelab.*{% endraw %}

You can see that this page is referencing the series layout I just created, and it's going to live at http://localhost/series/vra8 - precisely where this series was on Hashnode. I've tagged it with the category I want to feature on this page, and specified that the posts will be sorted in reverse order so that anyone reading through the series will start at the beginning (I hear it's a very good place to start). I also added a teaser image which will be displayed when I link to the series from elsewhere. And I included a quick little italicized blurb to tell readers what the series is about.

Check it out here:

vRA8 series

The other series pages will be basically the same, just without the reverse sort directive. Here's _pages/

 1{% raw %}---
 2title: "Tips & Tricks"
 3layout: series
 4permalink: "/series/tips"
 5tag: Tips
 6author_profile: true
 8    teaser: assets/images/posts-2020/kJ_l7gPD2.png
11*Useful tips and tricks I've stumbled upon.*{% endraw %}

Just in case someone wants to look at all the post series in one place, I'll be keeping the existing category archive page around, but I'll want it to be found at /series/ instead of /categories/. I'll start with going into the _config.yml file and changing the category_archive path:

2  type: liquid
3  # path: /categories/
4  path: /series/
6  type: liquid
7  path: /tags/

I'll also rename _pages/ to _pages/ and update its title and permalink:

1{% raw %}---
2title: "Posts by Series"
3layout: categories
4permalink: /series/
5author_profile: true
6---{% endraw %}

The bottom of each post has a section which lists the tags and categories to which it belongs. Right now, those are still pointing to the category archive page (/series/#vra8) instead of the series feature pages I created (/series/vra8).

Old category link

That works but I'd rather it reference the fancy new pages I created. Tracking down where to make that change was a bit of a journey.

I started with the _layouts/single.html file which is the layout I'm using for individual posts. This bit near the end gave me the clue I needed:

1{% raw %}      <footer class="page__meta">
2        {% if[site.locale].meta_label %}
3          <h4 class="page__meta-title">{{[site.locale].meta_label }}</h4>
4        {% endif %}
5        {% include page__taxonomy.html %}
6        {% include page__date.html %}
7      </footer>{% endraw %}

It looks like page__taxonomy.html is being used to display the tags and categories, so I then went to that file in the _include directory:

1{% raw %}{% if site.tag_archive.type and page.tags[0] %}
2  {% include tag-list.html %}
3{% endif %}
5{% if site.category_archive.type and page.categories[0] %}
6  {% include category-list.html %}
7{% endif %}{% endraw %}

Okay, it looks like _include/category-list.html is what I actually want. Here's that file:

 1{% raw %}{% case site.category_archive.type %}
 2  {% when "liquid" %}
 3    {% assign path_type = "#" %}
 4  {% when "jekyll-archives" %}
 5    {% assign path_type = nil %}
 6{% endcase %}
 8{% if site.category_archive.path %}
 9  {% assign categories_sorted = page.categories | sort_natural %}
11  <p class="page__taxonomy">
12    <strong><i class="fas fa-fw fa-folder-open" aria-hidden="true"></i> {{[site.locale].categories_label | default: "series:" }} </strong>
13    <span itemprop="keywords">
14    {% for category_word in categories_sorted %}
15      <a href="{{ category_word | slugify | prepend: path_type | prepend: site.category_archive.path | relative_url }}" class="page__taxonomy-item p-category" rel="tag">{{ category_word }}</a>{% unless forloop.last %}<span class="sep">, </span>{% endunless %}
16    {% endfor %}
17    </span>
18  </p>
19{% endif %}{% endraw %}

I'm using the liquid archive approach since I can't use the jekyll-archives plugin, so I can see that it's setting the path_type to "#". And near the bottom of the file, I can see that it's assembling the category link by slugifying the category_word, sticking the path_type in front of it, and then putting the site.category_archive.path (which I edited earlier in _config.yml) in front of that. So that's why my category links look like /series/#category. I can just edit the top of this file to statically set path_type = nil and that should clear this up in a jiffy:

1{% raw %}{% assign path_type = nil %}
2{% if site.category_archive.path %}
3  {% assign categories_sorted = page.categories | sort_natural %}
4  [...]{% endraw %}

To sell the series illusion even further, I can pop into _data/ui-text.yml to update the string used for categories_label:

1  meta_label                 :
2  tags_label                 : "Tags:"
3  categories_label           : "Series:"
4  date_label                 : "Updated:"
5  comments_label             : "Leave a comment"

Updated series link

Much better!

Updating the navigation header

And, finally, I'll want to update the navigation links at the top of each page to help visitors find my new featured series pages. For that, I can just edit _data/navigation.yml with links to my new pages:

 2  - title: "vRealize Automation 8"
 3    url: /series/vra8
 4  - title: "Projects"
 5    url: /series/projects
 6  - title: "Scripts"
 7    url: /series/scripts
 8  - title: "Tips & Tricks"
 9    url: /series/tips
10  - title: "Tags"
11    url: /tags/
12  - title: "All Posts"
13    url: /posts/

All done!

Slick series navigation!

I set out to recreate the series setup that I had over at Hashnode, and I think I've accomplished that. More importantly, I've learned quite a bit more about how Jekyll works, and I'm already plotting further tweaks. For now, though, I think this is ready for a git push!

More Tips