Every personal site I built before this one died the same way. A CMS I stopped updating, a database I had to back up, a plugin that broke on a version bump. The fix was to make the site something I already maintain every day: a repository.
There is no server, no database, nothing to patch. The whole thing is text files that get turned into HTML on a machine that lives for forty seconds.
Hugo, because the preview is instant Link to heading
Markdown for the words, YAML and JSON for the structured parts, Go templates for everything else. The full site rebuilds in about four hundred milliseconds.
That number is not vanity. A preview that is instant is a preview you actually look at, so you fix the spacing you would otherwise have shrugged at.
The part that took longest to appreciate is the data/ directory. Anything that
is a list belongs there, not in a template:
# data/post_categories.yml
- id: terminal
label: Terminal
icon: fa-terminal
Adding a category is now a three line diff instead of a hunt through layouts.
Pages that keep themselves current Link to heading
The pages I would forget to update are the ones nobody wants to see out of date: publications, repositories, what I have been reading.
So they update themselves. A handful of Python scripts, standard library only so
CI needs no install step, fetch from ORCID, Semantic Scholar, GitHub and Open
Library and write plain JSON into data/. Hugo reads it at build time.
The tip worth stealing is the failure mode:
- name: Refresh auto-updating data
# Non-fatal: on API failure the committed data/*.json is used as fallback
continue-on-error: true
run: python3 scripts/update_data.py
The generated JSON is committed. If ORCID is down, the step goes red, the build carries on and the site shows yesterday’s data. A stale publication list beats a failed deploy.
The same workflow runs weekly on a cron, so the site refreshes even in a week where I push nothing.
The logos on this page downloaded themselves Link to heading
The tool cards above carry real brand marks, and not one of them costs a
request. A script reads data/tools.json, resolves each entry to a Simple Icons
slug, downloads the SVG into assets/ and records the brand colour next to it:
"githubactions": { "file": "icons/tools/githubactions.svg", "hex": "#2088FF" }
Hugo inlines that file during the build, so the page ships with the icon already in the markup. No CDN, no call from the browser, nothing for a content security policy to argue with. Adding a tool is one JSON entry and one script run.
The photo wall goes further. A script parses the EXIF out of the originals, reverse geocodes the coordinates once, caches the answer so the geocoder is never asked twice, and writes the result as JSON. The source photos keep their metadata in the repository. The published copies do not:
[imaging.exif]
disableLatLong = true
The place name reaches the page. The coordinates stay behind.
Social cards drawn at build time Link to heading
This is my favourite trick in the repository. Every page has its own 1200x630 preview image, and nothing renders a screenshot.
Hugo can draw text onto an image, so the card is a base gradient plus the page title, stamped during the build:
{{ $base := resources.Get "images/og-base.png" }}
{{ $font := resources.Get "fonts/SpaceGrotesk.ttf" }}
{{ $card := $base.Filter (images.Text $title (dict
"x" 80 "y" 160 "size" 68 "color" "#f8fafc" "font" $font)) }}
Two details cost me an afternoon. images.Text does not wrap, so the title has
to be split into lines in the template. And the variable font renders at its
lightest weight, so the text looked anaemic until I drew it three times at one
pixel offsets to fake a bold.
Search is a build step, not a service Link to heading
I wanted search without a third party and without shipping an index of the whole site to every visitor. Pagefind reads the finished HTML after Hugo runs and writes an index next to it:
hugo --gc --minify && npx -y pagefind --site public
One attribute decides what gets indexed, which is also how the pages I do not want in search stay out of it:
<div class="content"{{ if not .Params.noindex }} data-pagefind-body{{ end }}>
Press cmd+K anywhere on the site and it opens.
Copilot, and why the GitHub part matters Link to heading
The useful part was not autocomplete. It was that Copilot talks to GitHub through MCP, so it reads the repository, the issues and the workflow runs directly instead of me pasting context into a chat window.
Asking why a deploy failed and having it read the actual run is a different activity from describing the failure from memory.
It is still a draft machine. It wrote a breadcrumb partial for me and put the styling on a selector that only existed on half the pages, which I found by measuring the rendered page rather than by reading the diff. Verify, then keep.
The crosspost that cannot duplicate itself Link to heading
A post opts in to dev.to with one line of front matter:
devto = true
A script renders it, rewrites the Hugo shortcodes into portable Markdown and
sends it with a canonical_url pointing back here, so the copy never competes
with the original in search results. The version that lands there also gets a
closing link home that this page does not have.
The hard part was running it from CI, where there is no local state. Before sending anything the script asks dev.to what the account already holds and indexes it by canonical URL, so an article that exists is updated and never created twice, even on a fresh runner. The state file is only a cache plus a hash of the Markdown last sent, which is what stops an unchanged post being pushed back to the top of the feed on every deploy.
Two things bit me. dev.to renders Markdown with hard wrapping on, so my eighty
column source lines each became a <br> and the indented continuations of a
list were torn out of their bullets, which is why paragraphs are unwrapped
before they are sent. And the API returns 429 well before its documented limits,
so writes are spaced out and retried on the Retry-After header.
If you are reading this on dev.to, that is how it got here.
What I would tell myself at the start Link to heading
Put the content in data/ before you think you need to. Let the scripts fail
loudly and the site fail quietly. And measure the thing in a browser, because the
bug is usually in the gap between what the template says and what the page does.
Every snippet above is the real thing, copied out of the repository rather than written for the occasion. Take what is useful.