Logo
Logo

Programming by Stealth

A blog and podcast series by Bart Busschots & Allison Sheridan.

PBS 188 of X: Blogging with Jekyll (GitHub Pages)

24 Oct 2026

Having looked at the big picture of how Jekyll organises your content in the previous instalment, this instalment explores adding a blog to a Jekyll site using the Posts taxonomy.

Matching Podcast Episode

You can also Download the MP3

Read an unedited, auto-generated transcript with chapter marks: PBS_2026_10_24

Worked Examples

Possible Gem Errors

If you check out the example code at a named tag and Jekyll fails to install or run using your preferred method (native Ruby or Docker) with an error containing the following:

 jekyll 3.10.0 | Error:  unknown keyword: create_additions

Reapply the fix described in the previous instalment by running the following command in the checked-out folder:

 bundle add json --version "< 2.8.0"

As a reminder, the worked examples for these Jekyll instalments are built around a demo site versioned in the pbs-jekyll-demoSite GitHub repo. The starting point for this instalment is the commit tagged pbs188-startingPoint. As usual, you can download a ZIP of this commit from GitHub, or fork the repo and then check out the tagged commit on your fork.

Creating Posts

The first step to adding a blog to a Jekyll site is to create a folder named _posts to hold all your blog posts.

File Names and Dates

When adding regular pages, you can name them anything you like, but Jekyll defines a naming scheme for blog posts:

YEAR-MONTH-DAY-slug.md

For example, to create a post dated April Fool’s Day this year (2026) with the slug april-fools, create a file named 2026-04-01-april-fools.md.

Let’s create our first blog post by adding the following to ./docs/_posts/2026-04-01-april-fools.md:

---
title: Some Classic April Fools
---
Over the years, the BBC have done some of my very favourite April Fools gags:

* [The Spaghetti-tree Hoax](https://en.wikipedia.org/wiki/Spaghetti-tree_hoax) (1957)
* [The Flying penguin hoax](https://en.wikipedia.org/wiki/Flying_penguin_hoax) (2008) 

You can usually find copies of these videos on YouTube, but they come and go, so good luck!

Build your site, and have a look in the generated _site folder, which contains the website built by Jekyll. You’ll see a new folder has been added named 2026, which contains a folder 04, which contains a folder 01, which contains our rendered blog post as april-fools.html.

This means that, when deploying locally, the blog post can now be accessed at http://localhost:4000/2026/04/01/april-fools.html.

The first thing to notice is that the post has been rendered using our default layout; it contains no blog-specific information, just our post’s body.

Creating a Blog Post Layout

To render blog posts appropriately, we need to add a new layout to our theme.

Rather than creating a top-level layout, we just need to create a nested layout that adds the blog-specific metadata into the content region of the default theme.

Jekyll actually assumes that if there’s a layout named post.html, that’s the one it should use for blog posts.

To create our blog post layout, we simply create a new file named ./docs/_layouts/post.html with the following initial content:

---
layout: default
---
<div class="post-heading mb-5">
  <h1 class="display-1 mb-0">Blogging with Jekyll</h1>
  <h2 class="text-muted fw-light"> — October 24, 2026</h2>
</div>

<div class="post-body">
  <h1 class="mb-0">PBS 187 of X: Back to Jekyll (GitHub Pages)</h1>
<small class="mb-3 text-muted d-flex flex-column flex-md-row">
    <div class="pr-3"><i class="fas fa-calendar-day pr-1" aria-label="Date"></i> 10 Oct 2026</div>
    <div class="pr-3"><i class="fas fa-id-card pr-1" aria-label="Creators"></i>
    <a href="/about.html#bart" class="badge badge-pill badge-accent text-white">Bart Busschots</a>
    <a href="/about.html#allison" class="badge badge-pill badge-accent text-white">Allison Sheridan</a>
    </div>
    <div>
    </div>
    </small>

<p>After a hiatus for some related topics and some tidbits, we’re returning to our exploration of using the static site generator Jekyll to build websites on GitHub Pages. In the first part of this series, we focused our attention on the tools needed to control how your Jekyll site looks. We described Jekyll’s build process, then explored the suite of variables Jekyll makes available, and learned about the Liquid templating engine Jekyll uses to render sites.</p>

<p>Now, we’re shifting our focus from controlling a site’s visual appearance to organising its contents.</p>

<h2 id="matching-podcast-episode">Matching Podcast Episode</h2>

<audio controls="" src="https://media.blubrry.com/nosillacast/traffic.libsyn.com/nosillacast/PBS_2026_10_10.mp3?autoplay=0&amp;loop=0&amp;controls=1">Your browser does not support HTML 5 audio 🙁</audio>

<p>You can also <a href="https://media.blubrry.com/nosillacast/traffic.libsyn.com/nosillacast/PBS_2026_10_10.mp3">Download the MP3</a></p>

<p>Read an unedited, auto-generated transcript with chapter marks:  <a href="https://podfeet.com/transcripts/PBS_2026_10_10.html">PBS_2026_10_10</a></p>

<h2 id="demo-site-now-supports-docker">Demo Site Now Supports Docker</h2>

<p>Since we last used our <a href="https://github.com/bartificer/pbs-jekyll-demoSite">Jekyll demo site</a>, <a href="https://github.com/hepabolu">Helma Van Der Linden</a> was kind enough to contribute <a href="./tidbit17">Tidbit 17</a>, teaching us all how to use <a href="https://www.docker.com">Docker</a> to simplify our development environment.</p>

<p>Helma explains Docker in great detail, and the worked example she uses throughout is a custom Docker setup for running Jekyll. If you want to create your own Jekyll Docker image, or indeed any Docker image of your own, you’ll need to understand all three parts of that marathon TidBit, but if you simply want to use Docker to run Jekyll without needing to do battle with your OS’s native install of Ruby, you can copy Helma’s final example into your own Jekyll projects for a bells-and-whistles Docker image that just works!</p>

<p>Since learning this very useful skill from Helma, I’ve been doing all my Jekyll development work using Helma’s example Docker setup, and just like Helma promised, it really does make life a lot easier!</p>

<p>So, for those of you who find the Ruby part of Jekyll as tedious as I do, I’ve merged Helma’s example into the demo site we’ve been building throughout our Jekyll instalments.</p>

<p>To start using Docker to run the demo site, simply:</p>

<ol>
  <li>
    <p>Install Docker Desktop from <a href="https://www.docker.com/products/docker-desktop/">docker.com/…</a></p>
  </li>
  <li>Download or clone any version of the <code class="language-plaintext highlighter-rouge">pbs-jekyll-demoSite</code> repo after the commit tagged <code class="language-plaintext highlighter-rouge">docker</code> <a href="https://github.com/bartificer/pbs-jekyll-demoSite">from GitHub</a></li>
  <li>Open a terminal in the downloaded/cloned folder and execute the command <code class="language-plaintext highlighter-rouge">make install</code> to initialise the Docker image</li>
</ol>

<p>Once you’ve done that, you can start and stop the Docker container hosting the demo site with the commands <code class="language-plaintext highlighter-rouge">make up</code> and <code class="language-plaintext highlighter-rouge">make down</code>, and you can see the Jekyll logs with the command <code class="language-plaintext highlighter-rouge">make logs</code>.</p>

<p>You’ll need to do a fresh <code class="language-plaintext highlighter-rouge">make install</code> each time you download a fresh copy of the repo.</p>

<h2 id="housekeeping--change-to-local-jekyll-installation-instructions-in-pbs-177">Housekeeping — Change to Local Jekyll Installation Instructions in PBS 177</h2>

<p>If you’d rather not go down the Docker route and prefer to keep using your OS’s own copy of Ruby, please note that the instructions for doing so in <a href="./pbs177">instalment 177</a> have needed to be updated to continue to work in October 2026.</p>

<p>If you are still using the same computer you were using before the hiatus, your setup should continue to work just like before, but if not, you’ll need to follow the updated instructions to get Jekyll to work for you on your local machine.</p>

<p>Allison discovered this problem on her fresh installs of macOS 27 and was able to track it down to a breaking change in one of Jekyll’s dependencies. The solution is to peg the dependency to the last version before the breaking change with the command:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle add json <span class="nt">--version</span> <span class="s2">"&lt; 2.8.0"</span>
</code></pre></div></div>

<h2 id="pbs-181-challenge-solution">PBS 181 Challenge Solution</h2>

<p>Before we took our break from the Jekyll mini-series, way back in <a href="./pbs181">instalment 181</a>, we ended with a challenge.</p>

<p>As a reminder, we have been using a separate <a href="https://github.com/bartificer/pbs-jekyll-demoSite">GitHub repository</a> to track the demo Jekyll site we’re building throughout the series. Each important point in our journey has been marked with a tagged commit, so you can download the code at the relevant point in the series from the repo’s tag listing. You can also fork the repo, clone it to your own device, and check out the relevant tagged commits as needed.</p>

<p>The starting point for this challenge was the commit tagged <a href="https://github.com/bartificer/pbs-jekyll-demoSite/releases/tag/pbs181-challenge-startingPoint">pbs181-challenge-startingPoint</a>.</p>

<p>In <a href="https://pbs.bartificer.net/pbs181">PBS 181</a>, we learned to create reusable snippets that can act as template-like additions to the regular markdown in our content files. The challenge was to create such a reusable snippet for adding sidenotes to our content. There were just three criteria:</p>

<ol>
  <li>The snippet needs to receive its contents as an argument named <code class="language-plaintext highlighter-rouge">content</code>.</li>
  <li>Markdown within note contents should be rendered to HTML.</li>
  <li>The note should be rendered using a <a href="https://getbootstrap.com/docs/5.3/components/card/">Bootstrap 5 card</a>.</li>
</ol>

<p>To get a very basic working solution, create a file named <code class="language-plaintext highlighter-rouge">./docs/_includes/note.html</code> and add the following content</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card text-bg-light"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-body"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-text"</span><span class="nt">&gt;</span>{{ include.content | markdownify }}<span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>This is all we need to define a reusable snippet that adds a basic Bootstrap card with one placeholder named <code class="language-plaintext highlighter-rouge">content</code>.</p>

<p>As a refresher, Jekyll’s term for reusable snippets is <em>includes</em>, and within an include you access the values passed to the snippet by prefixing argument names with <code class="language-plaintext highlighter-rouge">include.</code>. So, the argument named <code class="language-plaintext highlighter-rouge">content</code> is available as <code class="language-plaintext highlighter-rouge">include.content</code>.</p>

<p>Because our snippet needs to render Markdown correctly, we need to pipe the value we receive through Jekyll’s <code class="language-plaintext highlighter-rouge">markdownify</code> Liquid filter.</p>

<p>To use the new snippet, add the following to the end of <code class="language-plaintext highlighter-rouge">index.md</code> (leaving a blank line above it):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% include note.html content="This site has no *useful* content, it's all just filler!" %}
</code></pre></div></div>

<p>Allow the site to rebuild, and you should see the note appear under the front page’s content. Notice that the word <em>useful</em> is in italics, proving Markdown rendering is working as expected.</p>

<p>That’s it — that’s all we needed to do for full credit!</p>

<p>But there was some bonus credit on offer, so let’s try to earn that!</p>

<p>The first bonus challenge was to add an optional title to the card. To do that, we’ll use a new argument named <code class="language-plaintext highlighter-rouge">title</code> which we’ll need to treat as optional. That means we need to add a conditional section to our card. Update your <code class="language-plaintext highlighter-rouge">note.html</code> file to the following:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card text-bg-light"</span><span class="nt">&gt;</span>
  {%- if include.title %}
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-header"</span><span class="nt">&gt;</span>
    {{ include.title }}
  <span class="nt">&lt;/div&gt;</span>
  {%- endif %}
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-body"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-text"</span><span class="nt">&gt;</span>{{ include.content | markdownify }}<span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>By wrapping our header inside <code class="language-plaintext highlighter-rouge">{% if %}</code> statement we ensure it only appears when a title is actually specified.</p>

<p>Before adding a title, let the site rebuild and verify that the note remains unchanged.</p>

<p>Now, to add a title, edit the <code class="language-plaintext highlighter-rouge">{% include %}</code> statement in  <code class="language-plaintext highlighter-rouge">index.md</code> to become:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% include note.html title="Note" content="This site has no *useful* content, it's all just filler!" %}
</code></pre></div></div>

<p>Allow the page to rebuild, and notice that our note now has a title telling us it’s a note.</p>

<p>The final extra challenge was to add an optional link for more information.</p>

<p>This doesn’t introduce any new concepts; it just involves applying the same principle again. Update your <code class="language-plaintext highlighter-rouge">note.html</code> to the following:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card text-bg-light"</span><span class="nt">&gt;</span>
  {%- if include.title %}
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-header"</span><span class="nt">&gt;</span>
    {{ include.title }}
  <span class="nt">&lt;/div&gt;</span>
  {%- endif %}
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-body"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-text"</span><span class="nt">&gt;</span>{{ include.content | markdownify }}<span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  {%- if include.info_url %}
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"card-footer text-end"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ include.info_url }}"</span> <span class="na">class=</span><span class="s">"card-link"</span> <span class="na">target=</span><span class="s">"_blank"</span><span class="nt">&gt;</span>more …<span class="nt">&lt;/a&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
  {%- endif %}
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Now, to add a link, edit the <code class="language-plaintext highlighter-rouge">{% include %}</code> statement in  <code class="language-plaintext highlighter-rouge">index.md</code> to become:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% include note.html title="Note" content="This site has no *useful* content, it's all just filler!" info_url="https://typography.guru/video/the-unsolved-mystery-of-lorem-ipsum-r151/" %}
</code></pre></div></div>

<p>Allow the page to regenerate, and notice that there’s now a footer on the note with a link for more information.</p>

<p>The demo site with the solution merged in is available via the tagged commit <a href="https://github.com/bartificer/pbs-jekyll-demoSite/releases/tag/pbs181-challenge-solution">pbs181-challenge-solution</a></p>

<h2 id="some-housekeeping--bootstrap-53-upgrade">Some Housekeeping — Bootstrap 5.3 Upgrade</h2>

<p>Before we move on to making some more changes to our demo site, I took the time to repeat the instructions for installing Bootstrap from <a href="./pbs177">instalment 177</a> to move the demo site to Bootstrap <code class="language-plaintext highlighter-rouge">5.3.8</code> (the most recent release as of when this instalment was written).</p>

<h2 id="playing-along-with-this-instalment">Playing Along with this Instalment</h2>

<p>If you’d like to work through our changes on your own copy of the code, the starting point for the examples today is the commit for the demo site tagged as <a href="https://github.com/bartificer/pbs-jekyll-demoSite/tree/pbs187-startingPoint">pbs187-startingPoint</a>. From there, click on the green <code class="language-plaintext highlighter-rouge">&lt;&gt; Code</code>button and select Download ZIP from the dropdown.</p>

<p><img src="./assets/pbs187/how-to-download-zip-files-from-tags-in-GitHub.png" alt="how to download zip files from tags in GitHub as described" /></p>

<h2 id="adding-metadata-to-the-demo-site">Adding Metadata to the Demo Site</h2>

<p>Since we last discussed Jekyll in <a href="./pbs181">instalment 181</a>, we have learned about the HTML metadata tags for SEO and the <a href="https://ogp.me">OpenGraph</a> in <a href="./pbs186">instalment 186</a>. Before moving on to learn new things, we should take some time to incorporate that new knowledge into our demo site.</p>

<p>For some of the headers, we’ll use the same value on all pages on the site, and for others we’ll use page-specific values. Because we don’t want to hard-code data into layouts, we should try to source the content for the tags from appropriate variables.</p>

<p>For now, we’ll keep things simple and implement just the basic headers.</p>

<h3 id="site-wide-headers">Site-wide Headers</h3>

<p>The most sensible information source for values that don’t change from page-to-page is the Jekyll configuration file (<code class="language-plaintext highlighter-rouge">./docs/_config.yml</code>). Any values defined in that file are available in our layouts as <code class="language-plaintext highlighter-rouge">site.*</code>.</p>

<p>To avoid our settings file becoming cluttered, we’ll collect the metadata-header-related values in a new dictionary we’ll name <code class="language-plaintext highlighter-rouge">meta</code>.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Description</th>
      <th style="text-align: left">Header</th>
      <th style="text-align: left">Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Site Name</td>
      <td style="text-align: left">OpenGraph: <code class="language-plaintext highlighter-rouge">og:site_name</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">site.title</code> (a standard Jekyll setting, so already defined)</td>
    </tr>
    <tr>
      <td style="text-align: left">Content Author</td>
      <td style="text-align: left">SEO: <code class="language-plaintext highlighter-rouge">author</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">site.meta.author</code></td>
    </tr>
    <tr>
      <td style="text-align: left">Language</td>
      <td style="text-align: left">OpenGraph: <code class="language-plaintext highlighter-rouge">og:locale</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">site.meta.locale</code></td>
    </tr>
    <tr>
      <td style="text-align: left">Site Builder</td>
      <td style="text-align: left">SEO: <code class="language-plaintext highlighter-rouge">generator</code></td>
      <td style="text-align: left">Hard-code this header to <code class="language-plaintext highlighter-rouge">Jekyll</code></td>
    </tr>
    <tr>
      <td style="text-align: left">OpenGraph Content Type</td>
      <td style="text-align: left">OpenGraph: <code class="language-plaintext highlighter-rouge">og:type</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">site.meta.og_type</code></td>
    </tr>
    <tr>
      <td style="text-align: left">Social Media Card Image</td>
      <td style="text-align: left">OpenGraph: <code class="language-plaintext highlighter-rouge">og:image</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">site.meta.og_image</code></td>
    </tr>
  </tbody>
</table>

<h3 id="page-specific-headers">Page-Specific Headers</h3>

<p>For the headers that change from page-to-page, the best place to store the values is the front matter on the pages themselves. That means that within our layouts we’ll access the values as <code class="language-plaintext highlighter-rouge">page.*</code>.</p>

<p>We’ll use as many standard Jekyll page properties as we can, and again, for clarity, we’ll collect any additional values we need to capture in a dictionary named <code class="language-plaintext highlighter-rouge">meta</code>.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Description</th>
      <th style="text-align: left">Header(s)</th>
      <th style="text-align: left">Source</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">Page Title</td>
      <td style="text-align: left">OpenGraph: <code class="language-plaintext highlighter-rouge">og:title</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">page.title</code></td>
    </tr>
    <tr>
      <td style="text-align: left">Page Description</td>
      <td style="text-align: left">SEO: <code class="language-plaintext highlighter-rouge">description</code> &amp; OpenGraph: <code class="language-plaintext highlighter-rouge">og:description</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">page.meta.description</code></td>
    </tr>
    <tr>
      <td style="text-align: left">Page URL</td>
      <td style="text-align: left">OpenGraph: <code class="language-plaintext highlighter-rouge">og:url</code></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">page.url</code></td>
    </tr>
  </tbody>
</table>

<h3 id="add-the-headers-to-the-layout">Add The Headers to the Layout</h3>

<p>Now that we’ve decided on the headers we want to publish and their matching sources, the next question is where best to insert them into our theme.</p>

<p>We currently have two layouts: the default one (<code class="language-plaintext highlighter-rouge">./docs/layouts/default.html</code>), and a dedicated layout for the front page (<code class="language-plaintext highlighter-rouge">docs/_layouts/front_page.html</code>). We need to add these headers within the HTML <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> tag, but we don’t want any code duplication.</p>

<p>Examining both layouts, we see that they both include the same reusable snippet, <code class="language-plaintext highlighter-rouge">html_head_common.html</code>, so this is clearly the most appropriate file to insert the headers into.</p>

<p>As a naïve first attempt, add the following to the bottom of <code class="language-plaintext highlighter-rouge">./docs/includes/html_head_common.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  {%- comment %}Add the SEO <span class="err">&amp;</span> OpenGraph meta tags{% endcomment %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:site_name"</span> <span class="na">content=</span><span class="s">"{{ site.title }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"author"</span> <span class="na">content=</span><span class="s">"{{ site.meta.author }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:locale"</span> <span class="na">content=</span><span class="s">"{{ site.meta.locale }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"generator"</span> <span class="na">content=</span><span class="s">"Jekyll"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:type"</span> <span class="na">content=</span><span class="s">"{{ site.meta.og_type }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:image"</span> <span class="na">content=</span><span class="s">"{{ site.meta.og_image | absolute_url }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"{{ page.title }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">"{{ page.meta.description }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:description"</span> <span class="na">content=</span><span class="s">"{{ page.meta.description }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"{{ page.url | absolute_url }}"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Note that the values for both <code class="language-plaintext highlighter-rouge">og:image</code> and <code class="language-plaintext highlighter-rouge">og:url</code> are piped through the <code class="language-plaintext highlighter-rouge">absolute_url</code> filter to convert them from paths within our Jekyll folder to full URLs.</p>

<p>Allow the site to build, and check the headers on the resulting home page:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:site_name"</span> <span class="na">content=</span><span class="s">"PBS Jekyll Demo Site"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"author"</span> <span class="na">content=</span><span class="s">""</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:locale"</span> <span class="na">content=</span><span class="s">""</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"generator"</span> <span class="na">content=</span><span class="s">"Jekyll"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:type"</span> <span class="na">content=</span><span class="s">""</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:image"</span> <span class="na">content=</span><span class="s">""</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"Home"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">""</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:description"</span> <span class="na">content=</span><span class="s">""</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"http://0.0.0.0:4000/"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Because we haven’t added our own <code class="language-plaintext highlighter-rouge">meta</code> dictionary to the site config or any of our pages, lots of the headers have no values. This is very realistic, because reusable layouts are generally designed to support as many headers as possible, leaving the decisions on which headers to use to the site owners. To avoid the headers we’ve not defined values being rendered with blank values we need to wrap the headers within <code class="language-plaintext highlighter-rouge">{% if %}</code> statements.</p>

<p>Replace the code above with the following updated code:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  {%- comment %}Add the SEO <span class="err">&amp;</span> OpenGraph meta tags{% endcomment %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:site_name"</span> <span class="na">content=</span><span class="s">"{{ site.title }}"</span><span class="nt">&gt;</span>
  {%- if site.meta.author %}
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"author"</span> <span class="na">content=</span><span class="s">"{{ site.meta.author }}"</span><span class="nt">&gt;</span>
  {%- endif %}
  {%- if site.meta.og_locale %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:locale"</span> <span class="na">content=</span><span class="s">"{{ site.meta.og_locale }}"</span><span class="nt">&gt;</span>
  {%- endif %}
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"generator"</span> <span class="na">content=</span><span class="s">"Jekyll"</span><span class="nt">&gt;</span>
  {%- if site.meta.og_type %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:type"</span> <span class="na">content=</span><span class="s">"{{ site.meta.og_type }}"</span><span class="nt">&gt;</span>
  {%- endif %}
  {%- if site.meta.og_image %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:image"</span> <span class="na">content=</span><span class="s">"{{ site.meta.og_image | absolute_url }}"</span><span class="nt">&gt;</span>
  {%- endif %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"{{ page.title }}"</span><span class="nt">&gt;</span>
  {%- if page.meta.description %}
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">"{{ page.meta.description }}"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:description"</span> <span class="na">content=</span><span class="s">"{{ page.meta.description }}"</span><span class="nt">&gt;</span>
  {%- endif %}
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"{{ page.url | absolute_url }}"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Allow the page to regenerate, and now we have better headers:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:site_name"</span> <span class="na">content=</span><span class="s">"PBS Jekyll Demo Site"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"generator"</span> <span class="na">content=</span><span class="s">"Jekyll"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"Home"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"http://0.0.0.0:4000/"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>We still have a problem though — our Open Graph page titles are not actually very good! As the title text on a social media link card, <em>‘Home’</em> is pretty useless!</p>

<p>We need to add the site title to the page title to add some context. Replace the line that generates the <code class="language-plaintext highlighter-rouge">og:title</code> header with the following updated version:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"{{ site.title }}: {{ page.title }}"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Allow the page to generate again, and now we have a meaningful title:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"PBS Jekyll Demo Site: Home"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>The next step is to add the missing data so the remaining supported headers appear.</p>

<h3 id="add-the-site-wide-extra-data">Add the Site-wide Extra Data</h3>

<p>Let’s start with the headers whose value doesn’t change from page to page. These headers are populated from the <code class="language-plaintext highlighter-rouge">site.meta</code> variable, so add something like the following to the end of <code class="language-plaintext highlighter-rouge">./docs/_config.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Set the site-wide SEO and OpenGraph metadata</span>
<span class="na">meta</span><span class="pi">:</span>
  <span class="na">author</span><span class="pi">:</span> <span class="s">Bart Busschots</span>
  <span class="na">locale</span><span class="pi">:</span> <span class="s">en_IE</span>
  <span class="na">og_type</span><span class="pi">:</span> <span class="s">website</span>
</code></pre></div></div>

<p>We’re still missing one header value, though: an image to use for the social media link cards (<code class="language-plaintext highlighter-rouge">og:image</code>).</p>

<p>In the real world, we would spend some time crafting an image that meets the current best practices for this kind of use (as of this year, my research led me to choose 1,200x630px PNGs), but let’s keep things simple and reuse the graphic from the front page — <code class="language-plaintext highlighter-rouge">./docs/assets/siteIllustration.png</code>.</p>

<p>Add the following to the end of the <code class="language-plaintext highlighter-rouge">meta</code> dictionary:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">og_image</span><span class="pi">:</span> <span class="s">assets/siteIllustration.png</span>
</code></pre></div></div>

<p>You’ll notice that after allowing the site to rebuild automatically, these headers did not get updated in our browser. This is because the Jekyll config file is not monitored by Jekyll’s watch mode. Jekyll only loads the config file once, when it initially starts. This means that we need to completely stop and restart Jekyll to see the effects of our changes. If you’re using Docker, that means running <code class="language-plaintext highlighter-rouge">make restart</code>.</p>

<p>We now get our expected headers:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:site_name"</span> <span class="na">content=</span><span class="s">"PBS Jekyll Demo Site"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"author"</span> <span class="na">content=</span><span class="s">"Bart Busschots"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"generator"</span> <span class="na">content=</span><span class="s">"Jekyll"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:type"</span> <span class="na">content=</span><span class="s">"website"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:image"</span> <span class="na">content=</span><span class="s">"http://0.0.0.0:4000/assets/siteIllustration.png"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:title"</span> <span class="na">content=</span><span class="s">"PBS Jekyll Demo Site: Home"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:url"</span> <span class="na">content=</span><span class="s">"http://0.0.0.0:4000/"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<h3 id="populate-the-page-specific-headers">Populate the Page-Specific Headers</h3>

<p>The page-specific data is read from the front matter on the individual pages, so to test our tags, add the following to the end of the front matter in <code class="language-plaintext highlighter-rouge">./docs/index.md</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">meta</span><span class="pi">:</span>
  <span class="na">description</span><span class="pi">:</span> <span class="s">The landing page for a demo website with no meaningful content!</span>
</code></pre></div></div>

<p>Allow the site to rebuild, and notice the following two headers have now been added:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">"The landing page for a demo website with no meaningful content!"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">property=</span><span class="s">"og:description"</span> <span class="na">content=</span><span class="s">"The landing page for a demo website with no meaningful content!"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>For completeness, add the following to the front matter in <code class="language-plaintext highlighter-rouge">./docs/about.md</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">meta</span><span class="pi">:</span>
  <span class="na">description</span><span class="pi">:</span> <span class="s">An explanation of the PBS Jekyll demo site.</span>
</code></pre></div></div>

<p>And the following to the front matter in <code class="language-plaintext highlighter-rouge">./docs.links.md</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">meta</span><span class="pi">:</span>
  <span class="na">description</span><span class="pi">:</span> <span class="s">A list of links that might be useful when developing a Jekyll site on GitHub Pages.</span>
</code></pre></div></div>

<h2 id="organising-our-content">Organising our Content</h2>

<p>We’ll wrap up this instalment by laying some groundwork for the remainder of the series.</p>

<p>Our focus is now shifting from controlling how our sites look to organising the content our sites publish. In other words, to use the appropriate jargon,  <em>information architecture</em>.</p>

<p>I asked Lumo to give me a short, pithy description of what that means for a content management system, and the essence of the response was excellent:</p>

<blockquote>
  <p><em>“… structuring, organizing, labeling, and connecting content so that users can find what they need and authors can manage it efficiently — essentially the blueprint for how information lives, relates, and flows within the system …”</em> — Lumo AI</p>
</blockquote>

<p>The fundamental building blocks for information architectures are <em>taxonomies</em>.</p>

<p>I also asked Lumo for a short description of this piece of jargon:</p>

<blockquote>
  <p><em>“Taxonomy is a structured system for classifying and categorizing content using shared terms—like categories, tags, or custom fields.”</em> — Lumo AI</p>
</blockquote>

<h3 id="jekyll-taxonomies">Jekyll Taxonomies</h3>

<p>Since we’re working in Jekyll, we’ll focus on the different taxonomies Jekyll supports.</p>

<p>Jekyll’s default taxonomy is the one we have been using to date: <em>pages</em>. Markdown files placed into folders that don’t start with an underscore (<code class="language-plaintext highlighter-rouge">_</code>) are pages.</p>

<p>Jekyll supports blogging with the built-in <em>posts</em> taxonomy. We’ll look at this in detail in the next instalment, but for now, you’ll recognise blog posts as markdown files under the special <code class="language-plaintext highlighter-rouge">_posts</code> folder.</p>

<p>Jekyll allows you to define your own content types using <em>collections</em>. These are simply custom site-specific taxonomies you design yourself.</p>

<p>Putting it all together, you have the following information architecture tools at your disposal:</p>

<table>
  <thead>
    <tr>
      <th>Taxonomy</th>
      <th>Description</th>
      <th>Use Case</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pages</td>
      <td>The default taxonomy, contains all markdown files not associated with another taxonomy.</td>
      <td>Generic pages about a specific topic that don’t evolve. For example, your front page, about pages, and contact pages.</td>
    </tr>
    <tr>
      <td>Posts</td>
      <td>Pages associated with specific dates.</td>
      <td>Blog posts, new items, etc.</td>
    </tr>
    <tr>
      <td>Collections</td>
      <td>Site-specific taxonomies.</td>
      <td>Any related collections of content that are not pages or posts, for example, author profiles.</td>
    </tr>
  </tbody>
</table>

<p>In addition to these top-level taxonomies, Jekyll also provides two additional sub-taxonomies which can be used across any of the top-level taxonomies. Specifically, <em>categories</em> and <em>tags</em>.</p>

<table>
  <thead>
    <tr>
      <th>Sub-Taxonomy</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Categories</td>
      <td>Each piece of content can only be assigned to a single category, so they are intended for high-level content groupings, like separating recipes from opinion pieces, etc.</td>
    </tr>
    <tr>
      <td>Tags</td>
      <td>Each piece of content can have arbitrarily many tags, so they are intended for more fine-grained groupings, and groupings that span across multiple categories or even multiple taxonomies.</td>
    </tr>
  </tbody>
</table>

<p>Out of the box, Jekyll’s collection and tagging features are quite limited when compared to dedicated blogging platforms like WordPress. There are plugins that can be added to bring the desired missing features, but they come with a big caveat — they don’t work with the default GitHub Pages setup. That doesn’t mean you can’t use them with GitHub Pages, but it means that to use them you need to develop your own custom GitHub action to run Jekyll, rather than relying on GitHub’s default action.</p>

<p>The two biggest caveats are:</p>

<ol>
  <li>Jekyll has one flat list of categories; they can’t be nested</li>
  <li>Jekyll doesn’t natively auto-create a listing of pages for each existing category or tag, but this functionality can be added using the <a href="https://jekyll.github.io/jekyll-archives/">jekyll-archives</a> plugin.</li>
</ol>

<p>Over the next few instalment we’ll learn how to use these taxonomies to put structure on our information.</p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>Now that we understand how Jekyll goes about organising content, we’re ready to explore the <em>posts</em> and <em>collections</em> taxonomies. We’ll start in the next instalment by exploring how the <em>posts</em> taxonomy is used to blog with Jekyll.</p>



<div class="text-center mt-5">
    <div class="mx-5 mb-1 text-center">
        <div class="btn-group btn-group-sm w-100 px-5 text-center" role="group" aria-label="Miniseries Navigation">
          <a href="/pbs186" class="btn btn-outline-secondary" role="button"><i class="far fa-arrow-alt-circle-left" aria-label="Previous"></i>
              Web Metadata — the Search and OpenGraph HTML Headers
          </a><a href="#" class="btn btn-outline-secondary disabled" role="button" aria-disabled="true">GitHub Pages Miniseries</a><a href="/pbs188" class="btn btn-outline-secondary" role="button">
              Blogging with Jekyll <i class="far fa-arrow-alt-circle-right" aria-label="Next"></i>
          </a></div>
    </div><div class="btn-group w-100 px-5 text-center" role="group" aria-label="Instalment Navigation">
        <a href="/pbs186" class="btn btn-outline-primary" role="button"><i class="far fa-arrow-alt-circle-left" aria-label="Previous"></i> 186. Web Metadata — the Search and OpenGraph HTML Headers
        </a><a href="/#pbs" class="btn btn-outline-primary" role="button">Programming by Stealth</a><a href="/pbs188" class="btn btn-outline-primary" role="button">188. Blogging with Jekyll <i class="far fa-arrow-alt-circle-right" aria-label="Next"></i>
        </a></div>
</div>
</div>

I want to draw your attention to a few things about this file.

Firstly, we use the layout front matter key to nest our new layout inside the default layout. Just a reminder that Jekyll themes work like Russian nesting dolls, so what this means is that our new layout will be injected into the default layout (defined in ./docs/_layouts/default.html) at the {{ content }} Liquid tag.

When you create a blog post, Jekyll determines that date from the filename and makes it available as the Liquid variable page.date. We need to format this date in a human-friendly way, so we use the Liquid date filter to make that conversion.

Our site already stores the author’s name in the _config.yml file (we added it in the previous instalment to facilitate our metadata tags), so we can access it via site.meta.author.

To facilitate blog-post-specific styling, I wrapped both the blog post heading information and the blog post body in <div> tags with the classes post-heading and post-body.

Finally, I added some standard Bootstrap classes to control the look of the header.

Allow the site to rebuild, and you should see our blog post now has its title, date, and author name.

We now have blog posts that look like blog posts.

Housekeeping — Adding a Dedicated Layout for Pages

Now that we are using two taxonomies, we should update our site to have dedicated layouts for both. We’ve already created one for blog posts, we should do the same for pages. Again, Jekyll looks for a layout named page.html and automatically uses it.

Our current default layout has a shortcoming that we should fix for our pages — it doesn’t support page titles properly!

At the moment, our two standard pages, about.md and links.md define their titles in unusual ways. Our pages have title properties defined in their front matter, but those are short, one-word titles that we currently use in the nav bar, but nowhere else. This is not best practice. The title attribute is what Jekyll considers the canonical title, so it really should be the title we want to show people on the pages.

We can keep both our short nav bar and our longer visual titles by using separate front matter for each.

We’ll move the long title into the standard title key, and add a new key of our own named short_title for use in our nav bar. With both titles then captured in the front matter, we’ll remove the unneeded leading Markdown top-level header.

Edit ./docs/about.md so the start of the file becomes:

---
title: About this Site
short_title: About
meta:
  description: An explanation of the PBS Jekyll demo site.
---
This site is part of the Programming by Stealth tutorial and podcast series co-created by [Bart Busschots](https://www.bartb.ie/) and [Allison Sheridan](https://www.podfeet.com/). You'll find the series itself at [pbs.bartificer.net](https://pbs.bartificer.net/).

Next, repeat the process and edit ./docs/links.md so the start of the file becomes:

---
title: Useful Links
short_title: Links
meta:
  description: A list of links that might be useful when developing a Jekyll site on GitHub Pages.
---
Some links you may find useful while working with Jekyll on GitHub Pages

Allowing the site to rebuild will show we’ve created two new problems to solve:

  1. The About and Links pages no longer have visible headings
  2. The nav bar now has the long titles

Let’s start by fixing the nav bar. Edit ./docs/_layouts/default.html and replace the line that renders each link inside the loop with the following version (line 33):

<a class="nav-link {%- if p.url == page.url %} active{% endif %}" {%- if p.url == page.url %} aria-current="page"{% endif %} href="{{ p.url | relative_url }}">{{ p.short_title | p.title }}</a>

The only change is that the content of the <a> tag has been changed from p.title to p.short_title | default p.title.

Allow the site to rebuild, and notice that the nav bar has now been corrected.

We’re now ready to fix our visible heading by creating our dedicated layout for pages.

Create a file named ./docs/_layouts/page.html with the following contents:

---
layout: default
---
<h1 class="display-1 mb-5">{{ page.title }}</h1>

{{ content}}

This is a very simple nested layout that inserts the page title followed by the page body into the default theme’s {{ content }} placeholder.

If you found all that a little hard to follow, I’ve tagged the final commit with all these changes as pbs188-afterHousekeeping.

Adding a Blog Index

As things stand, the only way our visitors can find our blog posts is if we give them the URLs to the posts. We need to add a landing page for our blog to our site.

This will be a regular page that loops over all the entries in the posts taxonomy and lists them.

Jekyll exposes the posts taxonomy through a variable named posts. This is a list of objects that contain the information about each post. Jekyll also, very considerately, sorts the list in reverse-chronological order for us!

We could name our landing page anything we like, but let’s keep it simple and use blog.md.

Create a file named ./docs/blog.md with the following content:

---
title: Blog
---
This is a demo Jekyll blog, it contains nothing but nonsense 🙂

<ul>
  {% for post in site.posts %}
    <li>
      <strong>{{ post.date | date_to_string }}:</strong> <a href="{{ post.url | relative_url }}">{{ post.title }}</a>
    </li>
  {% endfor %}
</ul>

This is simply a regular page with a Liquid loop that iterates over site.posts to add items to a list.

Post Summaries

Something else that might be nice to do is add a most recent post callout to the front page.

For this kind of featured link, we want more than just a title; we also want a short snippet from the post. Jekyll automatically creates excerpts for each post, and by default they consist of the first paragraph. If you prefer to use the WordPress convention of using <!-- more -->, you can add the following to your _config.yml file:

excerpt_separator: '<!-- more -->'

Regardless of how you create the snippet, Jekyll stores it as rendered HTML (not Markdown) in a key named excerpt in the post’s object.

Because Jekyll automatically sorts the posts list in reverse-chronological order, the newest blog post is easily available as posts | first.

To make our little latest post widget more reusable, let’s add it as a snippet.

Create a file named ./docs/_includes/latest_post_card.html with the following contents:

{%- comment %}Store the latest post in a variable{% endcomment %}
{%- assign latest_post = site.posts | first %}

{%- comment %}Build the card{% endcomment %}
<div class="card text-bg-light">
  <div class="card-header">
    Latest Blog Post — {{ latest_post.date | date: "%B %d, %Y" }}
  </div>
  <div class="card-body">
    <h1 class="card-title h5">{{ latest_post.title }}</h1>
    <p class="card-text">{{ latest_post.excerpt }}</p>
    <a href="{{ latest_post.url | relative_url }}" class="card-link">more …</a>
  </div>
</div>

This builds a Bootstrap card using the details from the latest post.

Let’s replace the silly note we added to the end of the front page for the PBS 181 challenge with our new card.

Open ./docs/index.md , and replace the existing {% include %} with this new one:

{% include latest_post_card.html %}

Allow the site to render, and then have a look at the front page. You’ll see we now have a nice little card showing the excerpt for our latest post with a link to view the full post.

Using Categories

If your site is only using posts for some sort of single-topic content like a latest news feed, rather than as a full blog, you don’t need to worry about categories. The same might also be true if your entire blog is focused on a single topic, like Daring Fireball, but most blogs cover more than one broad topic, so that’s where categories make sense.

It’s possible to define categories using the file system, but it’s messy — you end up needing multiple _posts folders!

For our example, we’re going to keep things simple and work with just two basic categories, humour and science.

If we were to do this using the file system, we would need to replace our single _posts folder with the following two:

I really don’t like spreading my blog posts around like that, so I prefer to use the other option for controlling categories — front matter within the posts themselves.

A Note On Nested Categories

At first glance, you can also create nested categories using the file system. For example, we could create a third folder named ./docs/science/physics/_posts, and it would get published. However, the categories will not work as you expect. Your posts will not be classified into a single nested category, but into two top-level categories named science and physics.

This is where Jekyll’s out-of-the-box blogging features fall short of what you get with dedicated blogging platforms like WordPress.

However, for a lot of sites, a single flat set of categories is all that’s needed. If you do need more, you can implement as complex a taxonomy as you can imagine if you move from the pre-provided posts taxonomy to a custom taxonomy implemented as a Jekyll collection (a much more advanced feature beyond the scope of this instalment).

I implemented a nested taxonomy with podcasts containing podcast episodes using Jekyll’s collections feature on the Let’s Talk Podcasts website.

There are also various plugins available for adding more advanced blogging features, but they generally don’t work with the standard GitHub Pages setup. Again, that doesn’t mean you can’t use them on GitHub, but doing so requires you to create your own GitHub action for building and invoking Jekyll rather than simply using the built-in one.

Assigning Categories with Front Matter

If you choose not to use the file system option for categorising posts, then you add them to the post’s front matter.

Because posts can be in more than one category, Jekyll will store them in a key named categories as an array of strings. If you only want to assign your post to a single category (the norm!), then you can use the shortcut front matter key category, which expects a single string.

When you create a post with the following in the front matter:

category: science

Jekyll silently converts that to the following for you:

categories: [ science ]

Let’s add a category to our existing post.

Open ./docs/_posts/2026-04-01-april-fools.md and update the front matter to be:

---
title: Some Classic April Fools
category: humour
---

We also need a post in our science category, so create a file named ./docs/_posts/2026-07-15-female-scientists.md with the following content:

---
title: Some Amazing Female Scientists
category: science
---
If you ask a random stranger on the street to name a female scientist, you'll almost always get the same answer: *Marie Curie*. She was, without a doubt, a great scientist, and indeed a female, but she's just one of countless many super scientists who happen to be female.

Why not spend some time learning a little more about:

* Computer scientists [Grace Hopper](https://en.wikipedia.org/wiki/Grace_Hopper) & [Emily Anderson](https://en.wikipedia.org/wiki/Emily_Anderson) (🎧 [Listen to her story on the Irish History Podcast](https://shows.acast.com/irishhistory/episodes/emily-anderson-the-forgotten-irish-bletchley-park-code-break))
* Astronomers [Henrietta Swan Leavitt](https://en.wikipedia.org/wiki/Henrietta_Swan_Leavitt), [Nancy Grace Roman](https://en.wikipedia.org/wiki/Nancy_Roman), and [Caroline Herschel](https://en.wikipedia.org/wiki/Caroline_Herschel)

Allow the site to rebuild, and refresh the blog index.

Notice we now have two posts, but also that the URL for our original post has changed to include its category:

http://localhost:4000/humour/2026/04/01/april-fools.html

Accessing Category Information

Now that our posts have categories, we can use those categories in two contexts:

  1. In our post.html layout
  2. In our blog index

Adding the Category to the Posts Layout

Within an individual post, the list of categories it belongs to is available via the page.categories Liquid variable. This is always an array, so when working with single categories like we are, we need to remember to pipe it through the first Liquid filter to reduce it to a simple string.

Because I don’t capitalise the categories themselves, I also pipe the value through the capitalize Liquid filter when using it in layouts.

We could display the current category in all sorts of ways — we could prefix it to the title, or we could add a badge after the title, but I don’t like either of those options.

Instead, let’s use Bootstrap’s Breadcrumb component to visually show the post’s location.

Update ./docs/_layouts/post.html to the following:

---
layout: default
---
<div class="post-heading mb-5">
  <h1 class="display-1 mb-0">{{ page.title }}</h1>
  <h2 class="text-muted fw-light">{{ site.meta.author }} — {{ page.date | date: "%B %d, %Y" }}</h2>
  <nav aria-label="breadcrumb">
    <ol class="breadcrumb" style="--bs-breadcrumb-divider: '>';">
      <li class="breadcrumb-item"><a href="{{ '/blog' | relative_url }}">Blog</a></li>
      <li class="breadcrumb-item">{{ page.categories | first | capitalize }}</li>
    </ol>
  </nav>
</div>

<div class="post-body">
  {{ content }}
</div>

The only addition is the following:

<nav aria-label="breadcrumb">
  <ol class="breadcrumb" style="--bs-breadcrumb-divider: '>';">
    <li class="breadcrumb-item"><a href="{{ '/blog' | relative_url }}">Blog</a></li>
    <li class="breadcrumb-item">{{ page.categories | first | capitalize }}</li>
  </ol>
</nav>

Notice that I’m displaying the category name with {{ page.categories | first | capitalize }}.

Allow the site to rebuild, and we now have breadcrumbs at the top of each blog post showing their category.

Adding Categories to the Blog Index

Now that we have categories, we can break our index up into separate cards.

The key to doing this is the site.categories variable, which aggregates information about all our categories.

This variable is not a list like site.posts, but a dictionary. In Liquid, when you loop over a dictionary, you get a 2-item list at each iteration of the loop: the key and the value.

When you loop over site.categories, the keys are the category names, and the values are lists of posts.

In other words, when you execute a loop of this form:

{% for category in site.categories %}

Then for each iteration of the loop, category[0] is the category name, and category[1] is the list of posts in that category.

Armed with this information, we can rewrite our blog index as a nested loop, looping over site.categories at the top level, and category[1] at the second level. In other words, we need to change from a loop of the form:

{% for post in site.posts %}
  …
{% endfor %}

To a loop of the form:

{% for category in site.categories %}
  {% for post in category[1] %}
    …
  {% endfor %}
{% endfor %}

But before we do, we have some technical debt to pay down from earlier in this instalment.

At the moment, we’re creating our posts listing right within the markdown file blog.md. This was quick and easy to do, but definitely not best practice. Why? Because we’re blurring the line between our theme and our content!

Adding categories would add even more Liquid code into our Markdown file, and this would push our implementation from not quite best practice to definitely bad practice!

If adding the index directly within the Markdown file is the wrong thing to do, what would be the right thing to do?

The appropriate place for this theming work is in a new special layout for the blog index.

Like the other two new layouts we’ve created previously in this instalment, this new layout should be nested within the default layout.

Finally, rather than creating the categories as a list of lists, I’m going to create them as a grid of Bootstrap cards where the body of each card is a Bootstrap list group.

Create a new file named ./docs/_layouts/blog.html with the following content:

---
layout: default
---
<h1 class="display-1 mb-5">{{ page.title }}</h1>

{{ content}}

{%- comment %}
Loop over the existing blog categories and create a card for each.
{%- endcomment %}
<div class="row row-cols-1 row-cols-md-2 g-4">
  {% for category in site.categories %}
  <div class="col">
    <div class="card text-bg-light">
      <div class="card-header d-flex justify-content-between align-items-start">
        <span>{{ category[0] | capitalize }}</span>
        <span class="badge text-bg-secondary rounded-pill">{{ category[1].size }}</span>
      </div>
      <div class="list-group list-group-flush">
        {%- for post in category[1] %}
        <a href="{{ post.url | relative_url }}" class="list-group-item list-group-item-action">
          <small class="text-muted">{{ post.date | date_to_string }}</small><br>
          <span class="fs-5">{{ post.title }}</span>
        </a>
        {%- endfor %}
      </div>
    </div>
  </div>
  {% endfor %}
</div>

Note that the big picture looping structure is exactly as described previously.

Also notice that we can access the number of posts in a category using category[1].size within the inner loop.

Finally, notice that I’ve made the card grid responsive by giving it the classes row-cols-1 and row-cols-md-2. This means that for breakpoints smaller than md, there will be one card per row, and for wider screens there will be two cards per row.

To apply this layout, we need to update our blog.md file to remove the Liquid code for the manual listing, and add the appropriate front matter to apply the new layout.

Replace the current ./docs/blog.md with the much simpler:

---
title: Blog
layout: blog
---
This is a demo Jekyll blog, it contains nothing but nonsense 🙂

Allow the site to rebuild, and reload the blog index to see a grid of two cards, one for each of our categories.

Using Tags

Categories are intended for long-term broad topic areas your blog covers, but tags are more like keywords — simple terms that give a quick idea of what kind of post you’re publishing. Each post can have many tags, and those individual tags can be used rarely or often.

In Jekyll, tags are always added in the front matter using the tags key, which expects a list of strings.

Let’s add some tags to our two posts.

Update the front matter in ./docs/_posts/2026-04-01-april-fools.md to be:

---
title: Some Classic April Fools
category: humour
tags: [ satire ]
---

And the front matter in ./docs/_posts/2026-07-15-female-scientists.md to be:

---
title: Some Amazing Female Scientists
category: science
tags: [ scientists, diversity ]
---

Allowing the site to regenerate will have no visual effect yet, because we’re not using our tags in any of our layouts.

Let’s add tags to our posts layout.

Update the contents of ./docs/_layouts/post.html to:

---
layout: default
---
<div class="post-heading mb-5">
  <h1 class="display-1 mb-0">{{ page.title }}</h1>
  <h2 class="text-muted fw-light d-flex justify-content-start align-items-center">
    <span>{{ site.meta.author }} — {{ page.date | date: "%B %d, %Y" }}</span>
    {%- if page.tags %}
    <span class="ms-4">
      {% for tag in page.tags %}
      <span class="badge bg-info-subtle text-dark rounded-pill fs-6 fw-light">🏷️ {{ tag | capitalize }}</span>
      {% endfor %}
    </span>
    {%- endif %}
  </h2>
  <nav aria-label="breadcrumb">
    <ol class="breadcrumb" style="--bs-breadcrumb-divider: '>';">
      <li class="breadcrumb-item"><a href="{{ '/blog' | relative_url }}">Blog</a></li>
      <li class="breadcrumb-item">{{ page.categories | first | capitalize }}</li>
    </ol>
    
  </nav>
</div>

<div class="post-body">
  {{ content }}
</div>

The change is entirely contained within the <div class="post-heading"> tag.

The visual effect is that if there are tags defined, they will appear as Bootstrap pill badges after the date line.

To enable this kind of side-by-side behaviour, I used Bootstrap’s flex utility to convert the <h2> header into a Flexbox, then moved the existing content into a <span>, and added a new <span> to contain the tags. The <span> for the tags is wrapped in a Liquid conditional statement so it only gets added when there actually are tags.

Finally, we should update our blog index to display our tags.

Update the contents of ./docs/_layouts/blog.html to become:

---
layout: default
---
<h1 class="display-1 mb-5">{{ page.title }}</h1>

{{ content}}

{%- comment %}
Loop over the existing blog categories and create a card for each.
{%- endcomment %}
<div class="row row-cols-1 row-cols-md-2 g-4">
  {% for category in site.categories %}
  <div class="col">
    <div class="card text-bg-light">
      <div class="card-header d-flex justify-content-between align-items-start">
        <span>{{ category[0] | capitalize }}</span>
        <span class="badge text-bg-secondary rounded-pill">{{ category[1].size }}</span>
      </div>
      <div class="list-group list-group-flush">
        {%- for post in category[1] %}
        <a href="{{ post.url | relative_url }}" class="list-group-item list-group-item-action">
          <small class="text-muted">{{ post.date | date_to_string }}</small>
          {%- if post.tags %}
          <span class="ms-2">
            {% for tag in post.tags %}
            <span class="badge bg-info-subtle text-dark rounded-pill fw-light">🏷️ {{ tag | capitalize }}</span>
            {% endfor %}
          {%- endif %}
          <br>
          <span class="fs-5">{{ post.title }}</span>
        </a>
        {%- endfor %}
      </div>
    </div>
  </div>
  {% endfor %}
</div>

The only addition is the following new code after the date part of each blog post’s entry in the appropriate card:

{%- if post.tags %}
<span class="ms-2">
  {% for tag in post.tags %}
  <span class="badge bg-info-subtle text-dark rounded-pill fw-light">🏷️ {{ tag | capitalize }}</span>
  {% endfor %}
{%- endif %}

This is a simple loop over the post’s tags.

Allow the site to rebuild, and notice that we now have tags displayed on our posts, both on the index page and within the posts themselves.

So far we’ve only accessed the tags from the objects representing the individual posts themselves. But, like with categories, there’s also a site-level object that aggregates all the posts for each tag. As you might expect, this variable is named site.tags, and like site.categories, is a dictionary with tag names mapping to lists of posts.

This variable could be used to build a tag cloud or similar representation of the overall topics the blog covers, but that’s beyond the scope of this series.

Instead, we’ll demonstrate the use of this variable by adding a little statistics card to the bottom of our blog index describing the blog’s aggregate content.

Add the following to the bottom of ./docs/_layouts/blog.html:

{%- comment %}Add a statistics card{% endcomment %}
<div class="card mt-5 text-bg-light text-muted small">
  <div class="card-body"> ℹ️
    There are {{ site.posts | size }} posts in {{ site.categories | size }} categories with {{ site.tags | size }} unique tags.
    {%- assign first_post = site.posts | last %}
    {%- assign recent_post = site.posts | first %}
    The first post was published on {{ first_post.date | date: "%B %d, %Y" }}, and the most recent on {{ recent_post.date | date: "%B %d, %Y" }}.
  </div>
</div>

There are just two notable things to draw your attention to. The use of the size Liquid filter for counting the number of posts in site.posts, categories in site.categories, and tags in site.tags. And the use of the first and last Liquid filters for extracting the oldest and newest posts from site.posts.

And beyond …

This has been an introduction to the basics of creating a blog with Jekyll, and theming it to your liking. It illustrates the fundamental concepts, and hopefully gives you an idea of what’s possible.

For a long-lived real-world blog with thousands of posts, more work would be needed to create a nice user experience.

Some avenues to explore for enhancement would include:

Final Thoughts

Now that we have seen how to add generic blog-like content to a Jekyll site, we’re ready to explore the most versatile of all Jekyll’s taxonomies, collections.

Join the Community

Find us in the PBS channel on the Podfeet Slack.

Podfeet Slack