↓ Skip to main content
  1. Posts/

Customizing Blowfish without forking it

·933 words·5 mins·
Table of Contents

The tempting move with a Hugo theme is to fork it. You need one thing changed, the theme is right there in themes/, and editing it works immediately. Then six months later there’s an upstream release with a fix you want, and you discover you can’t take it, because you no longer know which lines are yours.

This site runs Blowfish as a git submodule, unmodified. Everything I’ve changed lives in my own repo, in four tiers that cost progressively more to maintain. Knowing which tier you’re in is most of the skill.

Tier 1: config params, which cost nothing
#

The cheapest customization is the one the theme already anticipated. Blowfish exposes a large params surface, and the answer to a surprising number of “how do I change X” questions is a line in config/_default/params.toml.

An example from this site. Blowfish defaults to opening external links in a new tab, which I didn’t want:

[article]
  externalLinkForceNewTab = false

That’s the whole change. It survives every upgrade, it’s self-documenting, and if the theme ever reworks link rendering, the param keeps meaning what it meant.

Before writing any template code, search the theme’s config.toml and docs for a param that already does it. It is an annoying way to discover you’ve written a template override for something that was already a setting.

Tier 2: the extension points the theme built for you
#

Blowfish looks for two optional partials and injects them into <head> if they exist: layouts/partials/extend-head.html and layouts/partials/extend-head-uncached.html. You create the file, it gets included. Nothing in the theme is touched.

I use these for things the theme has no opinion about. extend-head.html carries rel="me" identity links for IndieWeb and search-entity verification, plus the Plausible Analytics snippet, since Blowfish ships integrations for Fathom, Umami and Seline but not Plausible.

The gotcha is in the two file names, and it will bite you. The theme includes them differently:

{{ partialCached "extend-head.html" .Site }}
{{ partial "extend-head-uncached.html" . }}

The cached one renders once and reuses that output on every page, which is correct and fast for site-wide content like rel="me" links. Note what it’s handed, though: .Site, not the page. So page variables in there aren’t stale, they’re absent.

So anything that varies per page belongs in the uncached file. Mine holds a preload hint for the homepage avatar:

{{ if .IsHome }}
  ...
  <link rel="preload" as="image" href="{{ $avatar.RelPermalink }}" fetchpriority="high">
{{ end }}

.IsHome in the cached partial has nothing to resolve against. The split exists for a reason, and the reason is not obvious from the names.

Tier 3: replacing a partial outright
#

Hugo’s lookup order means any file you put at layouts/partials/x.html wins over the theme’s themes/blowfish/layouts/partials/x.html. No configuration, no build step. This is where it gets expensive, because you’ve now silently opted out of upstream changes to that file.

I’ve done this once, for schema.html. Blowfish emits a name-only author on each article and a separate site-level identity with nothing tying them together, so a search engine can’t tell that the person writing the articles and the person the site is about are the same entity. My version defines one canonical Person, anchored by @id to /about/, and points the homepage WebSite, the About ProfilePage, and every article’s author at it.

Two things I’d pass on from writing it.

Build the JSON with dict and jsonify rather than hand-rolling the string, so Hugo handles escaping. And then pipe the result through safeJS, because inside a <script type="application/ld+json"> element Go’s html/template detects a JSON context and re-encodes your already-valid JSON, producing a double-wrapped string that no validator will parse. That failure is invisible until you test the output, since the page renders fine.

Because this one replaces upstream, it carries a note at the top recording what the theme does today, which upstream issue would change that, and why the override would still be needed afterward. When the theme improves, I want to be able to decide rather than archaeologize.

Tier 4: vendoring a theme file you mostly agree with
#

The most expensive tier is copying a theme file to change one line. I have exactly one, article-link/simple.html, and the change is a single conditional: render the curated front-matter description in list summaries rather than .Summary, which is empty on this site because summaryLength = 0 and nothing sets a summary field.

One line of intent, sixty lines of copied theme code I now own by accident. There’s no Hugo mechanism to inherit a partial and change one branch, so this is the available move. What makes it survivable is the comment at the top:

If you bump the vendored Blowfish, diff this file against themes/blowfish/layouts/partials/article-link/simple.html and re-apply just the .Description change.

That sentence is the actual deliverable. The override is trivial; the note is what turns a future upgrade from an investigation into a diff.

The discipline, such as it is
#

Prefer the lowest tier that works, and when you go higher, leave a note at the top of the file saying what you changed, why the theme’s version didn’t do it, and what to re-check on upgrade.

The cost of theme customization is never paid on the day you make the change. It’s paid on the day you upgrade, by whoever has to work out which deviations were deliberate. On a personal site that person is you, roughly a year later, with no memory of any of it.

Four files and a handful of params. That’s small enough that the next Blowfish release is a git submodule update and one diff, rather than a project.

Chandler Thompson
Author
Chandler Thompson
I lead engineering teams and coach the people who run them. This is where I write down what actually worked.

Related