Case Study: From Jekyll to Hugo

Introduction

ipng.nl before

In the before-days, I had a very modest personal website running on [ipng.nl] and [ipng.ch]. Over the years I’ve had quite a few different designs, and although one of them was hosted (on Google Sites) for a brief moment, they were mostly very much web 1.0, “The 90s called, they wanted their website back!” style.

The site didn’t have much other than a little blurb on a few open source projects of mine, and a gallery hosted on PicasaWeb [which Google subsequently turned down], and a mostly empty Blogger page. Would you imagine that I hand-typed the XHTML and CSS for this website, where the menu at the top (thinks like Home - Resume - History - Articles) would just have a HTML page which meticulously linked to the other HTML pages. It was the way of the world, in the 1990s.

Jekyll

Jekyll

My buddy Michal suggested in May of 2021 that, if I was going to write all of the HTML skeleton by hand, I may as well switch to a static website generator. He’s fluent in Ruby, and suggested I take a look at [Jekyll], a static site generator. It takes text written in your favorite markup language and uses layouts to create a static website. You can tweak the site’s look and feel, URLs, the data displayed on the page, and more.

I immediately fell in love! As an experiment, I moved [IPng.ch] to a new webserver, and kept my personal website on [IPng.nl]. I had always wanted to write a little bit more about technology, and since I was working on an interesting project [Linux Control Plane] in VPP, I thought it’d be nice to write a little bit about it, but certainly not while hand-crafting all of the HTML exoskeleton. I just wanted to write Markdown, and this is precisely the raison d’être of Jekyll!

Since April 2021, I wrote in total 67 articles with Jekyll. Some of them proved to become quite popular, and (humblebrag) my website is widely considered one of the best resources for Vector Packet Processing, with my [VPP] series, [MPLS] series and a few others like the [Mastodon] series being amongst some of the top visited articles, with ~7.5-8K monthly unique visitors.

The catalyst

There were two distinct events that lead up to this. Firstly, I started a side project called [Free IX], which I also created in Jekyll. When I did that, I branched the [IPng.ch] site, but the build faild with Ruby errors. My buddy Antonios fixed those, and we were underway. Secondly, later on I attempted to upgrade the IPng website to the same fixes that Antonios had provided for Free-IX, and all hell broke loose (luckily, only in staging environment). I spent several hours pulling my hear out re-assembling the dependencies, downgrading Jekyll, pulling new gems, downgrading ruby. Finally, I got it to work again, only to see after my first production build, that the build immediately failed because the Docker container that does the build no longer liked what I had put in the Gemfile and _config.yml. It was something to do with sass-embedded gem, and I spent waaaay too long fixing this incredibly frustrating breakage.

Hugo

Hugo

When I made my roadtrip from Zurich to the North Cape with my buddy Paul, we took extensive notes on our daily travels, and put them on a [2022roadtripnose] website. At the time, I was looking for a photo caroussel for Jekyll, and while I found a few, none of them really worked in the way I wanted them to. I stumbled across [Hugo], which says on its website that it is one of the most popular open-source static site generators. With its amazing speed and flexibility, Hugo makes building websites fun again. So I dabbled a bit and liked what I saw. I used the [notrack] theme from GitHub user @gevhaz, as they had made a really nice gallery widget (called a shortcode in Hugo).

The main reason for me to move to Hugo is that it is a standalone Go program, with no runtime or build time dependencies. The Hugo [GitHub] delivers ready to go build artifacts, tests amd releases regularly, and has a vibrant user community.

Migrating

I have only a few strong requirements if I am to move my website:

  1. The site’s URL namespace MUST be identical (not just similar) to Jekyll. I do not want to lose my precious ranking on popular search engines.
  2. MUST be built in a CI/CD tool like Drone or Jenkins, and autodeploy
  3. Code MUST be hermetic, not pulling in external dependencies, neither in the build system (eg. Hugo itself) nor the website (eg. dependencies, themes, etc).
  4. Theme MUST support images, videos and SHOULD support asciinema.
  5. Theme SHOULD try to look very similar to the current Jekyll minima theme.

Attempt 1: Auto import ❌

With that in mind, I notice that Hugo has a site importer, that can import a site from Jekyll! I run it, but it produces completely broken code, and Hugo doesn’t even want to compile the site. This turns out to be a theme issue, so I take Hugo’s advice and install the recommended theme. The site comes up, but is pretty screwed up. I now realize that the hugo import jekyll imports the markdown as-is, and only rewrites the frontmatter (the little blurb of YAML metadata at the top of each file). Two notable problems:

1. images - I make liberal use of Markdown images, which in Jekyll can be decorated with CSS styling, like so:

![Alt](/path/to/image){: style="width:200px; float: right; margin: 1em;"}

2. post_url - Another widely used feature is cross-linking my own articles, using Jekyll template expansion, like so:

.. Remember in my [[VPP Babel]({% post_url 2024-03-06-vpp-babel-1 %})] ..

I do some grepping, and have 246 such Jekyll template expansions, and 272 images OK, that’s a dud.

Attempt 2: Skeleton ✅

I decide to do this one step at a time. First, I create a completely new website hugo new site ipng.ch, download the notrack theme, and add only the front page index.md from the original IPng site. OK, that renders.

Now comes a fun part: going over the notrack theme’s SCSS to adjust it to look and feel similar to the Jekyll minima theme. I change a bunch of stuff in the skeleton of the website:

First, I take a look at the site media breakpoints, to feel correct for desktop screen, tablet screen and iPhone/Android screens. Then, I inspect the font family, size and H1/H2/H3… magnifications, also scaling them with media size. Finally I notice the footer, which in notrack spans the whole width of the browser. I change it to be as wide as the header and main page.

I go one by one on the site’s main pages and, just as on the Jekyll site, I make them into menu items at the top of the page. The [Services] page serves as my proof of concept, as it has both the image and the post_url pattern in Jekyll. It references six articles and has two images which float on the right side of the canvas. If I can figure out how to rewrite these to fit the Hugo variants of the same pattern, I should be home free.

Hugo: image

The idiomatic way in notrack is an image shortcode. I hope you know where to find the curly braces on your keyboard - because geez, Hugo templating sure does like them!

<figure class="image-shortcode{{ with .Get "class" }} {{ . }}{{ end }}
         {{- with .Get "wide" }}{{- if eq . "true" }} wide{{ end -}}{{ end -}}
         {{- with .Get "frame" }}{{- if eq . "true" }} frame{{ end -}}{{ end -}}
         {{- with .Get "float" }} {{ . }}{{ end -}}" 
         style="
         {{- with .Get "width" }}width: {{ . }};{{ end -}}
         {{- with .Get "height" }}height: {{ . }};{{ end -}}">
    {{- if .Get "link" -}}
        <a href="{{ .Get "link" }}"{{ with .Get "target" }} target="{{ . }}"{{ end -}}
                 {{- with .Get "rel" }} rel="{{ . }}"{{ end }}>
    {{- end }}
    <img src="{{ .Get "src" | relURL }}"
         {{- if or (.Get "alt") (.Get "caption") }}
         alt="{{ with .Get "alt" }}{{ replace . "'" "&#39;" }}{{ else -}}
              {{- .Get "caption" | markdownify| plainify }}{{ end }}"
         {{- end -}}
    /> <!-- Closing img tag -->
    {{- if .Get "link" }}</a>{{ end -}}
    {{- if or (or (.Get "title") (.Get "caption")) (.Get "attr") -}}
        <figcaption>
            {{ with (.Get "title") -}}
                <h4>{{ . }}</h4>
            {{- end -}}
            {{- if or (.Get "caption") (.Get "attr") -}}<p>
                {{- .Get "caption" | markdownify -}}
                {{- with .Get "attrlink" }}
                    <a href="{{ . }}">
                {{- end -}}
                {{- .Get "attr" | markdownify -}}
                {{- if .Get "attrlink" }}</a>{{ end }}</p>
            {{- end }}
        </figcaption>
    {{- end }}
</figure>

From the top - Hugo creates a figure with a certain set of classes, the default image-shortcode but also classes for frame, wide and float to further decorate the image. Then it applies direct styling for width and height, optionally inserts a link (something I had missed out on in Jekyll), then inlines the <img> tag with an alt or (markdown based!) caption. It then reuses the caption or title or attr variables to assemble a <figcaption> block. I absolutely love it!

I’ve rather consistently placed my images by themselves, on a single line, and they all have at least one style (be it width, or float), so it’s really straight forward to rewrite this with a little bit of Python:

def convert_image(line):
  p = re.compile(r'^!\[(.+)\]\((.+)\){:\s*(.*)}')
  m = p.match(line)
  if not m:
    return False

  alt=m.group(1)
  src=m.group(2)
  style=m.group(3)

  image_line = "{{< image "
  if sm := re.search(r'width:\s*(\d+px)', style):
    image_line += f'width="{sm.group(1)}" '
  if sm := re.search(r'float:\s*(\w+)', style):
    image_line += f'float="{sm.group(1)}" '
  image_line += f'src="{src}" alt="{alt}" >}}}}'

  print(image_line)
  return True

with open(sys.argv[1], "r", encoding="utf-8") as file_handle:
    for line in file_handle.readlines():
        if not convert_image(line):
            print(line.rstrip())

Hugo: ref

In Hugo, the idiomatic way to reference another document in the corpus is with the builtin ref shortcode, requiring a single argument: the path to a content document, with or without a file extension, with or without an anchor. Paths without a leading / are first resolved relative to the current page, then to the remainder of the site. This is super cool, because I can essentially reference any file by just its name!

for fn in $(find content/ -name \*.md); do
  sed -i -r 's/{%[ ]?post_url (.*)[ ]?%}/{{< ref \1 >}}/' $fn
done

And with that, the converted markdown from Jekyll renders perfectly in Hugo. Of course, other sites may use other templating commands, but for [IPng.ch], these were the only two special cases.

Hugo: URL redirects

It is a hard requirement for me to keep the same URLs that I had from Jekyll. Luckily, this is a trivial matter for Hugo, as it supports URL aliases in the frontmatter. Jekyll will add a file extension to the article slugs, while Hugo uses only the directly and serves an index.html from it. Also, the default for Hugo is to put content in a different directory.

The first change I make is to the main hugo.toml config file:

[permalinks]
  articles = "/s/articles/:year/:month/:day/:slug"

That solves the main directory problem, as back then, I chose s/articles/ in Jekyll. Then, adding the URL redirect is a simple matter of looking up which filename Jekyll ultimately used, and adding a little frontmatter at the top of each article, for example my [VPP #1] article would get this addition:

---
date: "2021-08-12T11:17:54Z"
title: VPP Linux CP - Part1
aliases:
- /s/articles/2021/08/12/vpp-1.html
---

Hugo by default renders it in /s/articles/2021/08/12/vpp-linux-cp-part1/index.html but the addition of the alias makes it also generate a drop-in placeholder HTML page that offers a permanent redirect (cleverly setting noindex for web crawlers and offering the canonical link for the new place, aka a permanent redirect:

$ curl https://ipng.ch/s/articles/2021/08/12/vpp-1.html 
<!DOCTYPE html>
<html lang="en-us">
  <head>
    <title>https://ipng.ch/s/articles/2021/08/12/vpp-linux-cp-part1/</title>
    <link rel="canonical" href="https://ipng.ch/s/articles/2021/08/12/vpp-linux-cp-part1/">
    <meta name="robots" content="noindex">
    <meta charset="utf-8">
    <meta http-equiv="refresh" content="0; url=https://ipng.ch/s/articles/2021/08/12/vpp-linux-cp-part1/">
  </head>
</html>

Hugo: Asciinema

One thing that I always wanted to add is the ability to inline [Asciinema] screen recordings. First, I take a look at what is needed to serve Asciinema: One Javascript file, and one CSS file, followed by a named <div> which invokes the Javascript. Armed with that knowledge, I dive into the shortcode language a little bit:

$ cat themes/hugo-theme-ipng/layouts/shortcodes/asciinema.html 
<div id='{{ .Get "src" | replaceRE "[[:^alnum:]]" "" }}'></div>
<script>
  AsciinemaPlayer.create("{{ .Get "src" }}",
                         document.getElementById('{{ .Get "src" | replaceRE "[[:^alnum:]]" "" }}'));
</script>

This file creates the id of the <div> by means of stripping all non-alphanumeric characters from the src argument of the shortcode. So if I were to create an {{< asciinema src='/casts/my.cast' >}}, the resulting DIV will be uniquely called castsmycast. This way, I can add multiple screencasts in the same document, which is dope.

But, as I now know, I need to load some CSS and JS so that the AsciinemaPlayer class becomes available. For this, I use a realtively new feature in Hugo, which allows for params to be set in the frontmatter, for example in the [VPP OSPF #2] article:

---
date: "2024-06-22T09:17:54Z"
title: VPP with loopback-only OSPFv3 - Part 2
aliases:
- /s/articles/2024/06/22/vpp-ospf-2.html
params:
  asciinema: true
---

The presence of that params.asciinema can be used in any page, including the HTML skeleton of the theme, like so:

$ cat themes/hugo-theme-ipng/layouts/partials/head.html 
<head>
...
    {{ if eq .Params.asciinema true -}}
    <link rel="stylesheet" type="text/css" href="{{ "css/asciinema-player.css" | relURL }}" />
    <script src="{{ "js/asciinema-player.min.js" | relURL }}"></script>
    {{- end }}
</head>

Now all that’s left for me to do is drop the two Asciinema player files in their respective theme directories, and for each article that wants to use an Asciinema, set the param and it’ll ship the CSS and Javascript to the browser. I think I’m going to have a good relationship with Hugo :)

Gitea: Large File Support

One mistake I made with the old Jekyll based website, is that I checked in all of the images and binary files directly into Git. This bloats the repository and is otherwise completely unnecessary. For this new repository, I enable [Git LFS], which is available for OpenBSD (packages), Debian (apt) and MacOS (homebrew). Turning this on is very simple:

$ brew install git-lfs
$ cd ipng.ch
$ git lfs install
$ for i in gz png gif jpg jpeg tgz zip; do \\
   git track "*.$i" \\
   git lfs import --everything --include "*.$i" \\
  done
$ git push --force --all

The force push rewrites the history of the repo to reference the binary blobs in LFS instead of directly in the repo. As a result, the size of the repository greatly shrinks, and handling it becomes easier once it grows. A really nice feature!

Gitea: CI/CD with Drone

At IPng, I run a [Gitea] server, which is one of the coolest pieces of open source that I use on a daily basis. There’s a very clean integration of a continuous integration tool called [Drone] and these two tools are literally made for each other. Drone can be enabled for any Git repo in Gitea, and given the presence of a .drone.yml file, execute a set of steps upon repository events, called triggers. It can then run a sequence of steps, hermetically in a Docker container called a drone-runner, which first checks out the repository at the latest commit, and then does whatever I’d like with it. I’d like to build and distribute a Hugo website, please!

As it turns out, there is a [Drone Hugo] plugin available, but it seems to be very outdated. Luckily, this being open source and all, I can download the source on [GitHub], and in the Dockerfile, bump the Alpine version, the Go version and build the latest Hugo release, which is 0.130.1 at the moment. I really do need this version, because the params feature was introduced in 0.123 and the upstream package is still for 0.77 – which is about four years old. Ouch!

I build a docker image and upload it to my private repo at IPng which is hosted as well on Gitea, by the way. As I said, it really is a great piece of kit! In case anybody else would like to give it a whirl, ping me on Mastodon or e-mail and I’ll upload one to public Docker Hub as well.

Putting it all together

With Drone activated for this repo, and the Drone Hugo plugin built with a new version, I can submit the following file to the root directory of the ipng.ch repository:

$ cat .drone.yml
kind: pipeline
name: default

steps:
  - name: git-lfs
    image: alpine/git
    commands:
      - git lfs install
      - git lfs pull
  - name: build
    image: git.ipng.ch/ipng/drone-hugo:release-0.130.0
    settings:
      hugo_version: 0.130.0
      extended: true
  - name: rsync
    image: drillster/drone-rsync
    settings:
      user: drone
      key:
        from_secret: drone_sshkey
      hosts:
        - nginx0.chrma0.net.ipng.ch
        - nginx0.chplo0.net.ipng.ch
        - nginx0.nlams1.net.ipng.ch
        - nginx0.nlams2.net.ipng.ch
      port: 22
      args: '-6u --delete-after'
      source: public/
      target: /var/www/ipng.ch/
      recursive: true
      secrets: [ drone_sshkey ]

image_pull_secrets:
  - git_ipng_ch_docker

The file is relatively self-explanatory. Before my first step runs, Drone already checks out the repo in the current working directory of the docker container. I then install package alpine/git and run the git lfs install and git lfs pull commands to resolve the LFS symlinks into actual files by pulling those objects that are referenced (and, notably, not all historical versions of any binary file ever added to the repo).

Then, I run a step called build which invokes the Hugo Drone package that I created before.

Finally, I run a step called rsync which uses package drillster/drone-rsync to rsync-over-ssh the files to the four NGINX servers running at IPng: two in Amsterdam, one in Geneva and one in Zurich.

One really cool feature is the use of so called Drone Secrets which are references to locked secrets such as the SSH key, and, notably, the Docker Repository credentials, because Gitea at IPng does not run a public docker repo. Using secrets is nifty, because it allows to safely check in the .drone.yml configuration file without leaking any specifics.

NGINX and SSL

Now that the website is automatically built and rsync’d to the webservers upon every git merge, all that’s left for me to do is arm the webservers with SSL certificates. I actually wrote a whole story about specifically that, as for *.ipng.ch and *.ipng.nl and a bunch of others, periodically there is a background task that retrieves multiple wildcard certificates with Let’s Encrypt, and distributes them to any server that needs them (like the NGINX cluster, or the Postfix cluster). I wrote about the [Frontends], the spiffy [DNS-01] certificate subsystem, and the internal network called [IPng Site Local] each in their own articles, so I won’t repeat that information here.

The Results

The results are really cool, as I’ll demonstrate in this video. I can just submit and merge this change, and it’ll automatically kick off a build and push. Take a look at this video which was performed in real time as I pushed this very article live: