Introduction
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
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
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:
- 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.
- MUST be built in a CI/CD tool like Drone or Jenkins, and autodeploy
- Code MUST be hermetic, not pulling in external dependencies, neither in the build system (eg. Hugo itself) nor the website (eg. dependencies, themes, etc).
- Theme MUST support images, videos and SHOULD support asciinema.
- 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 . "'" "'" }}{{ 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: