Replacing Hugo with a custom Go static site generator

The old site

The previous version of tteoh.com had been running in some form since 2015. Started on GitHub Pages with Jekyll, migrated to GitLab Pages in 2017, switched to Hugo around the same time.

Over the years it accumulated things. Multiple content types, some custom shortcodes, partial templates that had been patched rather than rethought, config that had grown to handle edge cases for features that probably weren’t worth the effort in the first place. Nothing catastrophic — just gradual accumulation.

The real symptom was posting less. The mental model of “how to add a post” had gotten fuzzy. Steps that should be mechanical required remembering where configuration lived, what the frontmatter format was, whether there was a specific folder structure expected. Scope had crept in enough directions that the whole thing felt like something to maintain rather than something to use.

Why not just clean up Hugo

Hugo is genuinely good. Fast, actively maintained, comprehensive documentation. For most personal sites it is the right answer.

The issue wasn’t Hugo specifically. It was the surface area of a general-purpose tool sitting underneath a site that needed almost none of it. Hugo has archetypes, shortcodes, partials, taxonomy systems, theme inheritance, config merging, asset pipelines. Most of that was sitting unused but still present in the mental model every time the site came up.

The goal was a site where the only overhead is writing markdown. A rebuild offered a chance to design exactly that, rather than trying to carve down something built to cover hundreds of use cases.

The new approach

The new site is a single Go binary called portfolio. Run portfolio build and it reads markdown from content/, renders HTML to public/. That is the whole model.

No theme system. No plugin architecture. No config inheritance. File structure maps directly to URLs: content/posts/my-post.md becomes /posts/my-post/. An index.md in a directory becomes the section index page. Everything else is an article.

Compared to the Hugo setup, the dependency count dropped significantly. Two Go dependencies — goldmark for markdown parsing, yaml.v3 for frontmatter — and one client-side JS library, Fuse.js for search. That is it.

Deploying is a git push. GitLab CI picks it up, builds the binary, runs the site build, validates that expected output files exist, then deploys to GitLab Pages. The site is static files. No server to maintain, no database, nothing to patch.

Architecture

The build runs as a fixed sequence of discrete steps:

flowchart LR
    A[tteoh.yaml] --> B[Parse content/]
    B --> C[Content tree]
    C --> D[Render HTML]
    C --> E[Search index]
    C --> F[JSON API]
    C --> G[Sitemap / Atom]
    D --> H[public/]
    E --> H
    F --> H
    G --> H

Each step is a separate internal package. internal/config loads tteoh.yaml. internal/content walks the content directory, parses frontmatter and markdown, and returns a content tree. internal/renderer takes the tree and templates and writes HTML. The search, API, and SEO packages each consume the same tree independently and write their own outputs.

Content is represented as a Page struct. Fields come from YAML frontmatter. A typical post looks like this:

---
title: An example post
published: 2026-01-15
tags: ['go', 'technology']
description: "A short summary for search results and meta tags."
references:
  - '/posts/related-post/'
---

The references field is worth noting. It is an array of internal paths. At build time each path resolves against the content tree, pulling the actual page title. The result renders as a “see also” section at the bottom of the post. No manual link text to manage, no broken links from renamed pages going undetected.

Markdown is parsed by goldmark with GFM, Footnote, DefinitionList, and Typographer extensions. Heading IDs are generated automatically at parse time, which is what makes section anchor links work. Syntax highlighting uses goldmark-highlighting with Chroma, outputting CSS classes rather than inline styles so light and dark themes can be controlled entirely in CSS without re-rendering content.

There is one HTML template: base.html. All page types — articles, section indexes, tag pages, search, 404 — are handled by {{if}} branches in the same file. This keeps template logic in one place and removes any ambiguity about which template applies to a given page type.

The dev server (portfolio serve) builds the site on start and serves public/ over HTTP.

Design decisions

The brief was an ereader aesthetic: reading-first, distraction-free, minimal interface. That shaped almost every decision.

Navigation lives in the footer, not the header. The top of each page is the title and then the content. Reading starts immediately. The footer has what you need once you are done.

The palette is monochrome. Off-white background in light mode, near-black in dark mode. Links are distinguished by underline rather than colour. No accent colours anywhere. Hierarchy comes from spacing and typography alone.

Fonts are the system stack: Georgia for body text, monospace for code. No web font downloads. Fast initial load, no layout shift, no third-party requests.

Dark mode defaults to prefers-color-scheme with a manual toggle in the footer that persists to localStorage. Theme initialisation is an inline <script> in <head> to prevent any flash of the wrong theme on return visits.

Type scale uses clamp() throughout for fluid scaling without explicit breakpoints. Max content width is clamp(65ch, 72vw, 110ch) — narrow on mobile, expanding to fill more of a large screen without becoming hard to read. The goal was a layout that felt natural at any size rather than snapping between fixed widths.

The principle throughout: if it does not serve reading, remove it.

What’s shipped

Initial feature set as of this rebuild: