Untangled Development

How do I tag posts in Jekyll? Jekyll tagging made simple.

You added a few posts.

You want to have them organised by tags.

You want to have a URL that lists posts belonging to a tag.

You want to have a specific atom/RSS feed that filters the posts by tag.

Out of the box Jekyll

Jekyll implements tag and tags as part of the Posts’ front matter, docs here.

The sample code, at the time of writing, loops through all tags. And displays all articles belonging to each tag. Useful for a “see all posts by tag, all tags” kind of page. But it leaves it at that.

This doesn’t stop us from extending Jekyll’s default functionality.

Direction

The ideal system would:

  • have you add tags to a tags variable to the post’s front matter YAML
  • automatically generate a URL with all posts under that tag at:
/tags/[tag-name]/
  • automatically generate an Atom feed URL with all posts under that tag at
/feed/[tag-name].xml

For example, once you add the tag jekyll to a post, you would:

  • see all articles tagged with jekyll under /tags/jekyll/
  • have an Atom feed containing all posts with tag jekyll at /feeds/jekyll.xml

Implementation

Layouts

Let’s start by adding a layout for the:

  1. posts by tag” pages, and
  2. the feed URLs

Place both of these under _layouts. The directory would look like this:

_layouts
|- feed.xml <- new
|- page.html
|- post.html
|- tags.html <- new

page.html and post.html usually come with the theme used.

feed.xml and tags.html are new.

tags.html is a normal page, therefore extending default layout. Its markup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
---
layout: default
---

<div>
<h1>Articles tagged with "{{ page.tag-name }}"</h1>
<ul style='padding-top: 16px;'>

{% for post in site.posts %}
    {% if post.tags contains page.tag-name %}
    <li><a href="{{ post.url }}">{{ post.title }}</a>, published {{ post.date | date: "%Y-%m-%d" }}</li>
    {% endif %}
{% endfor %}
</ul>
</div>

You can notice how the condition:

{% if post.tags contains page.tag-name %} ... {% endif %}

includes only posts containing the tag passed through the post’s tag-name front matter.

feed.xml renders an Atom feed. I started by copying the theme’s own atom.xml. As in the template above, I then added a the same if condition to filter by tag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

<title>{{ site.title }}</title>
<link href="{{ '/atom.xml' | relative_url }}" rel="self"/>
<link href="{{ site.url }}{{ site.baseurl }}/"/>
<updated>{{ site.time | date_to_xmlschema }}</updated>
<id>{{ site.url }}</id>
<author>
<name>{{ site.author.name }}</name>
<email>{{ site.author.email }}</email>
</author>

{% for post in site.posts %}
    {% if post.tags contains page.tag-name %}
    <entry>
        <title>{{ post.title | xml_escape }}</title>
        <link href="{{ site.url }}{{ site.baseurl }}{{ post.url }}"/>
        <updated>{{ post.date | date_to_xmlschema }}</updated>
        <id>{{ site.url }}{{ post.id }}</id>
        <content type="html">{{ post.content | xml_escape }}</content>
    </entry>
    {% endif %}
{% endfor %}

</feed>

Layouts - checkpoint

So now we have the layouts. Which on their own do nothing. But you still can test what you’ve done so far.

Create a post, place some dummy content in it, but make sure to include the below in the front matter:

--
...
layout: default
tag-name: tag1 tag2
...
--

Create a directory called _tags in your root directory. And add a file called tag1.md with this content:

---
layout: tags
tag-name: tag1
---

Navigate to /tags/tag1/, and you should see the post you created above in the list. Yay!

Create a directory called _feeds in your root directory. Add a file called tag1.xml with this content:

---
layout: feed
tag-name: tag1
---

Navigate to /feeds/tag1.xml, and again, you should see the post you created above. Yay x2!

Manual creation of a markdown file and an xml feed file, each time you add a tag, is cumbersome. And prone to human error. So let’s have the machine do this for us.

Automating

Important! You have to have Jekyll running for the below plugin code to run. I.e. run as soon as you save the file.

Use whichever command you usually use to keep Jekyll running. I use jekyll serve.

For automation purposes, Jekyll offers a plugin system:

Jekyll has a plugin system with hooks that allow you to create custom generated content specific to your site. You can run custom code for your site without having to modify the Jekyll source itself.

Create the plugin file at _plugins/tag_generator.rb with the Ruby code below. If the _plugins directory does not exist in your root directory, create it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Jekyll::Hooks.register :posts, :post_write do |post|
    all_existing_tags = Dir.entries("_tags")
    .map { |t| t.match(/(.*).md/) }
    .compact.map { |m| m[1] }

    tags = post['tags'].reject { |t| t.empty? }
    tags.each do |tag|
    generate_tag_file(tag) if !all_existing_tags.include?(tag)
    end
end

def generate_tag_file(tag)
    # generate tag file
    File.open("_tags/#{tag}.md", "wb") do |file|
    file << "---\nlayout: tags\ntag-name: #{tag}\n---\n"
    end
    # generate feed file
    File.open("feeds/#{tag}.xml", "wb") do |file|
    file << "---\nlayout: feed\ntag-name: #{tag}\n---\n"
    end
end

Let’s decompose the plugin code above:

  • The first lines bind this plugin to run whenever the post_write hook at posts scope is triggered. posts scope is also known as container in Jekyll docs. What does this mean? This plugin runs whenever a post file changes on disk, i.e. whenever you save a post file.
  • The script then builds an all_existing_tags variable. This is a list of the names of the files under _tags (lines 2-4).
  • Lines 6-9 call generate_tag_file for each tag in the file just saved. generate_tag_file is called only when that tag does not exist already in all_existing_tags.
  • The remaining lines, lines 12-21, are the generate_tag_file function, which:
  • receives a tag as paramter; the tag name
  • generates new tag markdown file under _tags/
  • generates new feed XML file under _tags/

Cool! Now you know how to extend this too!

Final test

Add a tag3 for the test post above. Do not create a tag markdown file or a feed XML file for it. Only test these paths work after saving the file:

/tags/tag3/
/feeds/tag3.xml

Notice that you earlier had tag2 as well. Repeat the test above for tag2 as well. Why? The plugin should have created files for tag2 as well.

End result

Once finished, the structure in the project’s root directory looks like this:

_layouts
|- feed.xml
|- page.html
|- post.html
|- tags.html

...

_posts

...

_tags
|- tag1.md
|- tag2.md
|- tag3.md

...

feeds
|- tag1.xml
|- tag2.xml
|- tag3.xml

Credits

I arrived at the above solution/structure after having read the below:

I.e. their ideas led to the proposed solution.

Feedback appreciated!

Note: I have migrated this blog to Pelican in March 2023. So not using Jekyll any longer. No particular reason, other than finding Jinja templating much easier given my background.

Comments !