Catching up with Zola and tabi

I’m watching the tabi repository on GitHub, getting notification for “All activity”. So I got quite excited to receive a notification saying there’s a new major release: version 5. Although, with a bunch of breaking changes. But how bad can it be, really? I just didn’t expect it would actually break anything for me. I figured, well, I don’t do anything that fancy, how bad can it be?

So I applied the update, and everything broke. Zola couldn’t build the site anymore. That made me look into it a bit more[1], to update whatever needs updating to be compatible again, so I can keep pulling in the latest updates from tabi.

The updates

The tabi updates come in through a GitHub action (I got it for free, with the Tabi start template), which opens a pull request whenever there’s a new version. I usually just read the changelog out of interest, and then blindly click merge without checking any of the code. 99% of the time I don’t really need to do anything.

Which I suppose is a flawed workflow, since the last couple of them got merged and then completely failed to deploy. Because apparently the pipeline had been broken for a long time, and I just never checked it..

What broke

Zola 0.23 changed quite a lot, mostly because of its upgrade to Tera 2, the template engine [2]. For this site, that meant:

  • Syntax highlighting: Zola replaced its highlighter with giallo. I picked the catppuccin-frappe theme, not because I have any idea what it is, but because that’s what was in the Tabi upgrade guide (and it seems to look OK).
  • Date formats: from strftime patterns (%d-%m-%Y) to Unicode ones (dd-MM-y).
  • Shortcodes: tabi moved to Zola’s new component syntax, so {{ remote_text(…) }} became {{< remote_text … />}}.
  • Retired settings: subset_enabled is now enable_subset, and footnote_backlinks and add_src_to_code_block are gone.
  • An old template override: a copy of tabi’s header.html in my own templates/ folder, which replaced the theme’s new version.

Handing it over to Claude

I started on all of this manually, following the excellent upgrade guide that Óscar wrote on the tabi blog. But when I got to the date formats, I figured I’d quickly ask AI to do it, because I couldn’t be bothered to look up what all of these codes mean. I’m not familiar enough with them to just write it from the top of my head, and it seemed like a quick thing to ask, instead of spending a while trying to figure it out.

That’s when I noticed AI is actually pretty good at this entire codebase, and “understands” the migration. So I handed the whole thing to Claude, gave it the upgrade guide, and told it to implement whatever needs implementing, and to ask me when there’s a decision to be made. Claude handled the rest of it, so it was relatively easy to deal with the migration and the breaking changes. It’s a good example of “donkey work” as Sparks would say.

Overrides, and giving back

I didn’t quite remember what was in that old header.html override. It turned out to be two changes I’d made to tabi: an option to turn off the font subset, and removing a nofollow tag. I’d opened pull requests for both, and tabi accepted them since, so they’ve become part of the main theme. I didn’t need the override anymore, so it’s been cleaned up.

That’s something I tend to do with the changes I implement. I make an exception in my own repository, one way or another, and at the same time I open a pull request to ask tabi to make it part of the theme. So if they accept my PR, at some point I can get rid of my exceptions, and just keep following the main theme.

This round of changes produced a couple more of those PR’s. The banner image cache-busting has been merged already, and the favicon type, escaped alt text, page language, and loading the highlighting CSS only where needed are still open. Out of which I’m not sure the page language would even make sense for Tabi, that might just be a thing for me only, but it doesn’t hurt to offer it.

Keeping track of the overrides

That old header.html is a good example of what can go wrong with overrides. Zola uses my copy of a template instead of the theme’s, so when tabi changes that template, I don’t actually benefit from the changes. Nothing tells you, and I don’t want to rely on my memory (it’s definitely not reliable).

While working on these changes, I needed to override a couple of templates again. So I did some brainstorming with Claude, looking for better solutions.

Each override is now listed in a script, as the template and the edits to apply to tabi’s version of it, one sed expression per edit. The script can do two things:

  • update regenerates my copies from tabi’s current templates, with the edits applied.
  • check compares my copies with what update would produce, and fails when they differ. It also fails when an edit doesn’t change anything anymore.

The check runs in the test build, which also runs on the automated tabi update pull requests, and as a git-hook. So when an update touches one of the overridden templates, the pull request fails, and shows me exactly which override needs attention. I could run update, to regenerate the file, or decide that the script needs updating as we no longer need to deal with an exception.

So now I can have the best of both worlds. Make exceptions when necessary, and then easily revert them and benefit from the community’s updates to Tabi when I no longer need them.

#!/usr/bin/env bash
# Site copies of tabi templates that need a small change, kept as "tabi's file
# plus a sed edit" so they can follow tabi updates:
#
#   scripts/template-overrides.sh check    fail if a copy differs from tabi + edit
#   scripts/template-overrides.sh update   regenerate the copies from tabi
#
# `check` runs in the test build, so a tabi update PR shows when a copy is out
# of date (or when tabi changed the line the edit applies to).

set -euo pipefail
cd "$(dirname "$0")/.."

# Template path (relative to templates/) and the sed edits applied to tabi's
# copy, one per line; each must still change tabi's file.
# The first two use a post's `[extra] lang` (e.g. "nl" for Dutch posts) for its
# language metadata, while the rest of the site stays in the default language.
OVERRIDES=(
    'base.html'
    's|<html lang="{{ lang }}"|<html lang="{{ page?.extra?.lang \| default(value=lang) }}"|'
    'atom.xml'
    's|<entry xml:lang="{{ page.lang }}">|<entry xml:lang="{{ page.extra.lang \| default(value=page.lang) }}">|'
    # The h-card links the same profile picture with a cache-busting hash;
    # without one here too, the home page downloads it twice.
    'partials/home_banner.html'
    's|get_url(path=header.img, trailing_slash=false)|get_url(path=header.img, trailing_slash=false, cachebust=true)|'
    # 1. tabi declares every favicon as a PNG; the site's is an SVG
    #    (img/profile.svg). Cache-busted like the banner and h-card, which use
    #    the same file.
    # 2. tabi loads the code highlighting stylesheet on every page; only load it
    #    on pages that have a highlighted code block.
    'partials/header.html'
    's|<link rel="icon" type="image/png" href="{{ get_url(path=config.extra.favicon) }}"/>|<link rel="icon" type="{% if config.extra.favicon is ending_with(pat=".svg") %}image/svg+xml{% else %}image/png{% endif %}" href="{{ get_url(path=config.extra.favicon, cachebust=true) }}"/>|
s|{%- set stylesheets = \[...stylesheets, "giallo.css"\] -%}|{%- if (page?.content \| default(value="")) is containing(pat="giallo z-code") or (section?.content \| default(value="")) is containing(pat="giallo z-code") -%}{%- set stylesheets = [...stylesheets, "giallo.css"] -%}{%- endif -%}|'
    # Tag pages list their posts as an archive (by year, titles only), like
    # the blog archive, instead of with summaries. Keeps tabi's header and feed
    # icon. See templates/components/tag_archive.html.
    'tags/single.html'
    's|{{< list_posts posts={term.pages} max={max} />}}|{{< tag_archive term={term} />}}|'
)

mode="${1:-check}"
status=0
for ((i = 0; i < ${#OVERRIDES[@]}; i += 2)); do
    file="${OVERRIDES[i]}" edits="${OVERRIDES[i + 1]}"
    theme="themes/tabi/templates/$file" site="templates/$file"

    sed_args=() stale=0
    while IFS= read -r edit; do
        if [ "$(sed -e "$edit" "$theme")" = "$(cat "$theme")" ]; then
            echo "$file: an edit no longer applies to tabi's template; update OVERRIDES: $edit" >&2
            stale=1
        fi
        sed_args+=(-e "$edit")
    done <<< "$edits"
    expected=$(sed "${sed_args[@]}" "$theme")

    if [ "$stale" = 1 ]; then
        status=1
    elif [ "$mode" = update ]; then
        mkdir -p "$(dirname "$site")"
        printf '%s\n' "$expected" > "$site"
        echo "$file: updated"
    elif ! diff -u --label "tabi + edit" --label "$site" <(printf '%s\n' "$expected") "$site"; then
        echo "$file: out of date; run scripts/template-overrides.sh update" >&2
        status=1
    fi
done
exit "$status"
  1. Which I suppose is a good thing, as I haven’t really posted anything new on here for ages. ↩

  2. Scary changes, as the template engine (and the whole Go syntax) was one of the primary reasons I switched away from Hugo to Zola. ↩