Introduction

I needed to update some pages on my blog and while looking at it, it feels a bit stale. The site design hasn’t really changed in nearly 15 years. The only real update was in 2019, when I switched from WordPress to Jekyll. They were minor changes and made to accommodate moving to Jekyll. Otherwise, this blog has looked and acted essentially the same for all these years.

Wanting to update the style also meant I should take the time to update things like Javascript libraries, Jekyll plugins and what not. Maintenance items I’ve been neglecting but are needed to properly update the site design.

While I based the site on a standard template, it was heavily customized with CSS, Javascript, and my own page templates. Not to mention I was using a number of plugins to add functionality that isn’t baked into Jekyll. Suffice it to say, I wasn’t looking forward to updating the site.

This got me thinking about Jekyll and what I needed to do in order to modernize. As happens with may projects, my original need of updating a few pages spiraled into something much larger than I anticipated. I ended up switching the static generation engine to Hugo.

Why Switch?

While Jekyll has been much easier to deal with than WordPress, it hasn’t been perfect. The biggest issue I’ve been having with Jekyll is Ruby. Every time I update Ruby something, Jekyll or a plugin, breaks. Typically version mismatches between some component that can’t use the updated Ruby version. Or I’ll update a plugin which uses dependency A version X but other plugin has the same dependency and doesn’t work with the version the other plugin requires. It’s been surprisingly painful doing updating anything related to Jekyll itself.

Another big problem with plugins I also keep running into is lack of continued development. A number of plugins I use were abandoned (I can’t blame the devs). Either I have to keep finding and integrating replacements that are never drop in, or I run maintain my own fork.

When I started looking at what I needed to update, I realized how custom the site had become. Updating would have required touching all CSS and all template pages at a minimum. Probably some plugins and Javascript too.

All of this put me down the path of looking at another tool and using a theme with no or very minimal changes. Enter Hugo. It’s pretty much Jekyll but with 95% of what I want built in instead of being scattered across many plugins. Which alleviates plugins pulling in even more libraries as dependencies. Also, I can install it as a single application with Homebrew. Making updating Hugo much easier than Jekyll.

Hugo does everything I was using Jekyll for but with less work on my part.

Hugo Setup

I decided to use the Hugo PaperMod theme which is still a simple and clean looking design. But it’s more modern looking and feeling.

Config

Starting with the hugo.yaml.

baseURL: https://nachtimwald.com/
languageCode: en-us
title: John's Blog
description: My little blog
theme: PaperMod

enableRobotsTXT: true
buildDrafts: false
buildFuture: false
buildExpired: false
timeout: 120

minify:
  minifyOutput: true

permalinks:
  page:
    posts: /:year/:month/:day/:slug/

params:
  description: My little blog
  homeInfoParams:
    Title: Hi there
    Content: |
      Welcome to my blog and take a look around. Maybe you'll find something you'll like.
      I like to write about a variety of topics but most posts are computer focused.
      While I mainly write for myself, I hope that some of what I post will be useful
      for others.      
    defaultTheme: auto
  socialIcons:
    - name: github
      url: "https://github.com/user-none/"
    - name: linkedin 
      url: "https://linkedin.com/in/john-schember/"
    - name: email 
      url: "mailto:john@nachtimwald.com"
  disableSpecial1stPost: true
  ShowToc: true
  ShowCodeCopyButtons: true
  comments: false
  hidemeta: false
  hideSummary: false
  comments: false

menu:
  main:
    - identifier: Archive
      name: Archive
      url: /archive/
      weight: 10
    - identifier: Tags
      name: Tags
      url: /tags/
      weight: 20
    - identifier: Search
      name: Search
      url: /search/
      weight: 30

I set the timeout option specifically because of image processing, more on that later.

I’m using the permalinks option to allow me to keep all posts in one directory. By default Hugo wants you to put your Markdown files in the same directory layout as the site. I didn’t want to do this because I find it cumbersome for my needs. If I had a lot of auxaliary files with each post it would make more sense. But most of my posts are text and having one images directory isn’t troublesome for the few that use images. This option allows me to tell Hugo to render the site based on the metadata of each post instead of needing to be one to one for the layout.

Customization

The customization to the theme was very minimal with only two files overridden.

I had to modify the search page to use my custom server side search system. This would be needed no matter what theme I decided to use. This theme has a search page (making less work for me) and supports searching using Fuse.js. Client side Javascript based search just doesn’t work with how many posts I have. That’s the reason I made a custom search service to run on my server.

layouts/_default/niwsearch.html

{{- define "main" }}

<header class="page-header">
    <h1>{{- (printf "%s&nbsp;" .Title ) | htmlUnescape -}}
        <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none"
            stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="11" cy="11" r="8"></circle>
            <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
        </svg>
    </h1>
    {{- if .Description }}
    <div class="post-description">
        {{ .Description }}
    </div>
    {{- end }}
    {{- if not (.Param "hideMeta") }}
    <div class="post-meta">
        {{- partial "translation_list.html" . -}}
    </div>
    {{- end }}
</header>

<div id="searchbox">
    <input id="searchInput" autofocus placeholder="{{ .Params.placeholder | default (printf "%s ↵" .Title) }}"
        aria-label="search" type="search" autocomplete="off">
    <ul id="searchResults" aria-label="search results"></ul>
</div>

<nav id="pages" aria-label="Page navigation" class="pagination">
</nav>

<script src="/js/search_box.js" async=""></script>
<script src="/js/search_results.js" async=""></script>

{{- end }}{{/* end main */}}

The biggest change here is removing the Fuse.js, adding the pagination element, and referencing my search Javascript.

The search_box.js got some updates to handle the new element ids. I wanted to keep using the ids set by the theme to ensure I don’t break the theme.

"use strict";

let submit = document.getElementById("searchInput");
submit.addEventListener("keydown", function(even) {
    if (event.key !== "Enter") {
        return;
    }
    let params = new URLSearchParams();
    let query = document.getElementById("searchInput");
    params.set("s", query.value);
    window.location.href = "/search?" + params;
    event.preventDefault();
});

The search_results.js, on the other hand, had quite a few more changes. I decided to change the pagination from a list of pages to a simple previous and next combination. In order to better fit with the rest of the theme. This necessitated changing how the script generates the pagination DOM elements.

"use strict";

setupPage();

function setupPage() {
    let params = new URLSearchParams(window.location.search);
    let search = params.get("s");
    let page = Number(params.get("p"));
    if (page == 0 || page == NaN || page == Infinity) {
        page = 1;
    }
    page = Math.floor(page);

    if (search) {
        // Pull the search results from the server and display them
        runSearch(search, page);
    } else {
        populateNavNoPages();
    }
}

async function runSearch(search, page)
{
    if (runSearch.tries === undefined) {
        runSearch.tries = 0;
    }

    page = page || 1;

    let params = new URLSearchParams();
    params.set("s", search);
    params.set("p", page);

    // fetch the results
    let results = null;
    try {
        let response = await fetch("https://search.nachtimwald.com/?" + params);
        results = await response.json();
    } catch (e) {
        // Retry a few times in case of a hiccup.
        if (runSearch.tries >= 3) {
            populateSearchError();
            return;
        }

        runSearch.tries++;
        setTimeout(runSearch, 250, search, page);
        return;
    }

    if (!results || results.pages == 0) {
        populateNoResults();
        populateNavNoPages()
    } else {
        populateResults(search, results.hits);
        populateNav(search, results.page, results.pages)
    }
}

function populateSearchError()
{
    // Set the no text
    let entry = document.createElement("div");
    entry.textContent = "Search Failed";
    let view = document.getElementById("searchResults");
    view.appendChild(entry);
}

function populateNoResults()
{
    // Set the no text
    let entry = document.createElement("div");
    entry.textContent = "No results";
    let view = document.getElementById("searchResults");
    view.replaceChildren(entry);
}

function populateNavNoPages()
{
    let page_nav = document.getElementById("pages");
    page_nav.replaceChildren();
}

function populateResults(search, results)
{
    let view = document.getElementById("searchResults");
    for (let result of results) {
        let article = document.createElement("article");
        article.classList.add("post-entry");

        let header = document.createElement("header");
        header.classList.add("entry-header");
        let h2 = document.createElement("h2");
        h2.textContent = result.title + "\u00A0»";
        header.appendChild(h2);
        article.appendChild(header);

        let preview = document.createElement("div");
        preview.classList.add("entry-content");
        preview.textContent = `${result.excerpt}... `;
        article.appendChild(preview);

        if (result.post_date) {
            let footer = document.createElement("footer");
            footer.classList.add("entry-footer");
            footer.textContent = result.post_date;
            article.appendChild(footer);
        }

        let link = document.createElement("a");
        link.href = result.uri;
        link.ariaLabel = result.title;
        article.appendChild(link);

        view.appendChild(article);
    }
}

function populateNav(search, page, pages)
{
    let page_params = new URLSearchParams();
    page_params.set("s", search);

    let page_nav = document.getElementById("pages");
    page_nav.replaceChildren();

    if (page != 1) {
        let nav_link = document.createElement("a");
        nav_link.classList.add("prev");
        page_params.set("p", page-1);
        nav_link.href = "/search?" + page_params;
        nav_link.textContent = '«\u00A0Prev';
        page_nav.appendChild(nav_link);
    }
    if (page != pages) {
        let nav_link = document.createElement("a");
        nav_link.classList.add("next");
        page_params.set("p", page+1);
        nav_link.href = "/search?" + page_params;
        nav_link.textContent = 'Next\u00A0»';
        page_nav.appendChild(nav_link);
    }
}

Overall, the search Javascript turned out a bit simpler because didn’t need to calculate page counts and track number as closely.

Picture Tag

One thing Hugo (and Jekyll) doesn’t support by default is the <picture> tag. Which allows multiple size and formats for images to be bundled together as a meta collection. This allows the browser to load the version closest to the screen size to allow better responsiveness.

With Jekyll I was using the jekyll_picture_tag plugin. It’s great and the author did some amazing work. I’ve contributed to it because I liked it so much and found it so useful. But I have run into issues and have had to manually fix things from time to time. One of the things I wanted to get away from with moving away from Jekyll.

jekyll_picture_tag requires ImageMagick and calls out to it for generating the resized images. Hugo has built in image manipulation support. Tying image generation into creating a picture tag was pretty easy using the markdown render hook for image processing. All I needed to do was override the render image template provided by the theme and output my own html with resized images.

layouts/_default/_markup/render-image.html

{{ $url := safeURL .Destination }}
{{ $asset := resources.Get $url }}

{{ if gt $asset.Width 400 }}
{{ $need_wep := or (eq $asset.MediaType.Type "image/jpeg") (eq $asset.MediaType.Type "image/png") }}
<picture>
    {{ $webp := slice }}
    {{ $orig := slice }}

    {{ range slice "400" "600" "800" "1000" }}
        {{ if gt $asset.Width . }}
            {{ if $need_wep }}
                {{ $img := $asset.Resize (printf "%sx webp q100" .) }}
                {{ $webp = $webp | append (printf "%s %sw" $img.RelPermalink . )}}
            {{ end }}

            {{ $img := $asset.Resize (printf "%sx q100" .) }}
            {{ $orig = $orig | append (printf "%s %sw" $img.RelPermalink . ) }}
        {{ end }}
    {{ end }}

    {{ if $need_wep }}
        {{ $srcset := delimit $webp ", "}}
    <source data-srcset="{{ $srcset }}" srcset="{{ $srcset }}" type="image/webp">
    {{ end }}

    {{ $srcset := delimit $orig ", "}}
    <source data-srcset="{{ $srcset }}" srcset="{{ $srcset }}" type="{{ $asset.MediaType.Type }}">

    <img loading="lazy" src="{{ $asset.RelPermalink }}" alt="{{ .Text }}" {{ with .Title}} title="{{ . }}" {{ end }} />
</picture>
{{ else }}
<img loading="lazy" src="{{ $asset.RelPermalink }}" alt="{{ .Text }}" {{ with .Title}} title="{{ . }}" {{ end }} />
{{ end }}

This renderer will generate webp images from png and jpg images in multiple sizes. It also takes the original image and generates scaled versions in the original format. All total, each image will have up to 8 generated images. There are some protections to skip generating larger images than the original.

This was less work than I expected and does everything I need. It’s far smaller and far less complex than, the much larger, jekyll_picture_tag. Again, not to disparage their work, but adding something like this to Hugo is less work and less complex than creating an entire Jekyll plugin for the same functionality.

Performance

One thing people talk about with Hugo is how fast it is. Conversely, one complaint I’ve seen about Jekyll is how slow it is. I took some measurements to see how much faster Hugo is than Jekyll. With both, the biggest amount of time (at least for me) is spent processing images. The picture tag stuff.

toolimage sizestime (sec)
HugoN/A0.537
JekyllN/A17.143
Hugo400 600 800 100069.950
Jekyll400 600 800 100076.466
Hugo400 120036.919

Hugo is quite a bit faster, especially if not processing images. 17 vs half a second is a huge difference in generating the site. That said, the difference with the images isn’t a huge improvement with Hugo. It’s still over a minute for both and is a 6.5 second difference.

I also built Hugo with 400x and 1200x images to see how the number of sizes impacts the build. Unsurprisingly, generating half as many images reduces the time by nearly half.

Conclusion

It took me about a full day to convert my blog to Hugo. That includes learning Hugo, updating existing and writing new code.

The new theme looks good and is providing me a number of new features I like. Dark and light modes, and a table of contents at the top of each post. Oh, and Chrome’s Lighthouse report tool now shows 100% for all categories.

I’ll have to wait a few years and for some Hugo updates to see if I made the right decision moving off of Jekyll. That said, what I’ve seen so far of Hugo has been pretty impressive and painless. Overall, I’m happy with how my blog revamping has turned out. Maybe in another 15 years I’ll update the styling again.