Goodbye Jekyll, Hello Pelican.

I've been using Jekyll to generate this site on GitHub Pages for many years now. It's been a great tool, but I've been working towards simplifying my technical stack and Jekyll is unfortunately the only reason I have Ruby installed on my system.

I've finally settled on Pelican. It checks off quite a few boxes for me:

  • It's written in Python, which is a default install on most systems.
  • It uses Jinja2 for templating, which I already use in many, many projects.
  • 11k stars on GitHub and frequent commits. We won't be stuck maintaining it.
  • I can use Markdown, or I can reStructuredText, which allows me to re-use content I've written for the sphinx documentation generator.
  • I can plug in my own Markdown parser, so I can fix some issues that bug me when trying to style the generated HTML.
  • It's something I haven't used before, and mixing it up sometimes is good for the soul :)

Making a Theme

The old version of this blog used a custom theme I wrote for Jekyll that was fairly minimal. Still, I wasn't happy with it how it ended up looking and a cold page load of the index was a horrifying 76.8kb! Some color contrasts would also be difficult for someone with poor vision to read. So the new theme needs to have meet a couple of requirements:

  • It needs to be small. The initial page load should be ~10kb.
  • It needs to be accessible
    • Colors are high contrast and the font is legible.
    • Screen readers should have no problem navigating the site.
  • It needs to look decent at all screen widths.
  • It needs to be simple to maintain, with minimal design and markup.
  • Should score 100 across the board on Google's Lighthouse audit.

Pelican lets us be very lazy with our theme. I don't write nearly enough to need pagination of the article list, or have separate pages for categories, so we simply...don't write them. We'll create a theme directory with the bare minimum that looks like this:

theme
├── static
│ └── css
│     └── body.css
└── templates
    ├── article.html
    ├── body.html
    ├── header.html
    └── index.html

We create a barebones HTML5 template in body.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{% block title %}{{ SITENAME }}{% endblock %}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="{{ SITEURL }}/theme/css/body.css" rel="stylesheet">
    <script defer data-domain="tkte.ch" src="https://plausible.io/js/script.js"></script>
</head>
<body>
{% include "header.html" %}
{% block content %}
{% endblock %}
</body>

We create a simple header in header.html:

<header class="header">
  <nav>
    <h1><a href="{{ SITEURL }}/">{{ SITENAME }}</a></h1>
  </nav>
</header>

We create a simple article template in article.html:

{% extends "body.html" %}

{% block content %}
<main>
  <article>
    <header>
      <h1>{{ article.title }}</h1>
    </header>
    <section>
      {{ article.content }}
    </section>
  </article>
</main>
{% endblock %}

And finally an index template in index.html:

{% extends "body.html" %}

{% block content %}
<main>
  <article class="intro">
    <section>
      <p>Hi! I'm Tyler Kennedy. You can find me on
        <a href="https://github.com/tktech">GitHub</a>
        or by <a href="mailto:tk@tkte.ch">email</a>.
      </p>
      <p>Looking for my <a href="{{ SITEURL }}/data/pgp.asc">PGP key?</a></p>
    </section>
  </article>
  <section class="articles">
    {% if articles %}
      <ul>
      {% for article in articles_page.object_list %}
        <li>
          <span class="mono">[ {{ article.date.strftime('%Y-%m-%d') }} ]</span>&nbsp;
          <a href="{{ SITEURL }}/{{ article.url }}">{{ article.title }}</a>
        </li>
      {% endfor %}
      </ul>
    {% endif %}
  </section>
</main>
{% endblock %}

Finally, we tell Pelican to use our theme by adding the following to our pelicanconf.py:

THEME = 'theme'

And that's it, we have our custom theme. So far, it looks like I've managed to tick off every box. The initial page load is at 10.2kb, which includes a CSS reset, quite a bit of un-minified CSS, analytics, and a bunch of CSS rules for syntax highlighting with pygments.

Fixing Links

I don't want to break all of the links to my old articles, so I need to make sure that the new site generates the same URLs. This is fairly easy to do with Pelican, as it has a slug setting that lets us set the URL for each article, combined with changing the global pattern for URL generation. We do this by adding a couple of settings to the pelicanconf.py file:

# Change the URL pattern to match the old Jekyll one.
ARTICLE_URL = 'articles/{date:%Y}/{date:%m}/{date:%d}/{slug}.html'
ARTICLE_SAVE_AS = 'articles/{date:%Y}/{date:%m}/{date:%d}/{slug}.html'

Analytics

We don't want full-blown analytics, but it's always nice to know when someone has linked to an article, so you can participate in any conversation. I've decided to give Plausible a try. It's a minimal (<1kb) and self-host-able (but in this case, we'll use their hosted version) analytics tool that doesn't track any personal information (PI).

Deploying

The preferred method of deploying to GitHub pages seems to have changed since I setup this blog. Instead of deploying the gh-pages branch of your git repo, you can use a GitHub action to build the site and deploy it. We'll start with GitHub's template for static HTML and tweak it a bit:

name: Deploy static content to Pages

on:
  push:
    branches: ["master"]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Pages
        uses: actions/configure-pages@v3
      - name: Setup python
        uses: actions/setup-python@v2
        with:
          python-version: "3.10"
      - name: Install python requirements
        run: pip install -r requirements.txt
      - name: Build pelican site
        run: pelican content -o output -s publishconf.py
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v2
        with:
          path: 'output'
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v2

We also need to put our build requirements into a requirements.txt at the root of the site:

blinker==1.6.2
docutils==0.20.1
feedgenerator==2.1.0
Jinja2==3.1.2
Markdown==3.4.4
markdown-it-py==3.0.0
MarkupSafe==2.1.3
mdurl==0.1.2
pelican==4.8.0
Pygments==2.16.1
python-dateutil==2.8.2
pytz==2023.3
rich==13.5.2
six==1.16.0
Unidecode==1.3.6

With this, everytime we push to the master branch, GitHub will build the site, and deploy it to GitHub Pages. If you're reading this, it worked!

TODO

I like to emphasize <code>, <aside>, and <blockquote> elements with a background color that stretches across the entire width of the page. For our syntax highlighted code blocks and asides this is easy, but for blockquotes it's a bit more difficult. By default, the Markdown generator Pelican uses doesn't emit any elements around a blockquote, or any elements within it, so we have no way to style it so that the background stretches the entire page while the content is limited to the column width of 768px. We need to implement our own version of the MarkdownReader class that emits every block element with a <div> wrapper, and then we can style the blockquote as we wish.