Optimize your Hugo Blog With Unlighthouse

Recently Google let me see the new Analytics dashboard (they moved from… Something something analytics to something something Analytics 4). And one of they things I saw was that my blog’s performance was bad!

“I have failed you”

In general the blog’s infrastructure didn’t get much love since I opened it a few years ago, so a cleanup for performance and content is probably in order.

Get initial Lighthouse results

What’s Lighthouse? It’s a tool that Google provides to measure the performance of your site. It’s available as a Chrome extension, but also as a CLI tool that you can run on your site. So I thought that the first order of business was to run Lighthouse on my site and see what I’m getting both for Mobile and Desktop.

But wait

Then, thanks to a Fireship video, I found out about Unlighthouse, which seems even better for this use case! Unlighthouse runs Lighthouse reports for all pages of your site, and then generates a report with the results.

So let’s run it! From the getting started guide, looks like we need to use Node.js 16. To make this “clean”, add a .nvmrc file to a new folder in your Hugo site called performance.

nvm use 16
npx unlighthouse --site https://mrnice.dev/

The report is so cool:

Unlighthouse report

Analyzing the results

This is a lot of information. Let’s start looking at it.

UX is more important than the machine

Looking at the homepage itself as it loads, there are too many words. I’ll slim it down - even if the site loads fast, if the homepage is confusing, people will bounce out. So let’s slim it down.

Before

Homepage - before

After

Homepage - after

Much better. Let’s go the analytics themselves now.

Wait, which device and I looking at?

I know that most people are using my site from Desktop AND Mobile.

“Analytics 1”

We need to look at both. Looking at the report, looks like it’s from “emulated mobile”, so we need to re-run it for Desktop. A quick look at the documentation for the configuration and the “selecting device” section shows that we’ll need to set up a configuration file for each option. Since I know that eventually I’ll want to automate this, it’s a good chance to write a shell script to work with this. I went a little overboard as I tern to, and used gum to build a fun CLI tool for this, which I’m dubbing lighthouse-plus.sh.

#!/bin/zsh

# Check that gum is installed
if ! command -v gum &> /dev/null
then
    echo "gum could not be found, run 'brew install gum'"
    exit
fi

# Check that currently using node 16
if [[ $(node --version) != *"v16"* ]]; then
  echo "Please use node 16"
  exit
fi

# Get device name ('desktop' or 'mobile') from command line argument, if not
# provided, use `gum choose` to choose interactively
if [[ -z $1 ]]; then
  device=$(gum choose "desktop" "mobile")
else
  device=$1
fi

npx unlighthouse --config-file "$device"-unlighthouse.config.ts

Now we can run lighthouse-plus.sh and get the results for both Desktop and Mobile. Let’s iterate on Desktop and the Home page first: Many fixes will be relevant to all pages, and it’s the most important page and device. Also, it’s what I use right now for development so iterations will be faster for me πŸ€Έβ€β™€οΈ.

Improving performance

Reduce unused JavaScript

The first thing that jumps out is that I have two unused JavaScript files:

“results 1”

The first one, https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js, is from “How to Add Math Expressions to Hugo Blog - the Shortest Guide Possible”, which I honestly forgot about. I don’t have any math expressions in my blog’s homepage, so why am I loading it here? Let’s look at the old post…

Choose where to include JS scripts

To display math we’re going to use a JS script. We’ll include the JS in our layouts/_default/baseof.html file, to make sure that we have Math support in every single page.

Oh. That’s why. I put it there.

“Performance meme”

To fix that, I tried using page parameters, and only include the script on pages that need it. To debug the templates and figure out which .html file I want to edit (instead of baseof.html), I used a cool trick: I added a {{ printf "%#v" . }} to the top of the file, and then looked at the output of the page. This gave me a bit of useless info:

& hugolib.pageState {
    pageOutputs: [] * hugolib.pageOutput {
        ( * hugolib.pageOutput)(0x14000c4cc80), ( * hugolib.pageOutput)(0x14000c4d040)
    },
    pageOutputTemplateVariationsState: ( * atomic.Uint32)(0x14000238180),
    pageOutput: ( * hugolib.pageOutput)(0x14000c4cc80),
    pageCommon: ( * hugolib.pageCommon)(0x14001177200)
}

I went through a bunch of the html templates in the layouts folder, until I found the right one (for my template, it’s themes/hermit-fork/layouts/posts/single.html, but of course for your site it’s going to probably be different). Then, when I replaced the previous printf with {{ printf "%#v" .Params }}, I saw exactly what I needed:

maps.Params {
    "date": time.Date(2021, time.June, 12, 12, 15, 16, 0, time.Local),
    "draft": false,
    "images": [] string {
        "/images/math-teacher.png"
    },
    "iscjklanguage": false,
    "lastmod": time.Date(2021, time.June, 12, 12, 15, 16, 0, time.Local),
    "publishdate": time.Date(2021, time.June, 12, 12, 15, 16, 0, time.Local),
    "render_math": true,
    "tags": [] string {
        "howto", "hugo", "meta", "math"
    },
    "title": "How to Add Math Expressions to Hugo Blog - the Shortest Guide Possible",
    "toc": false
}

See the render_math there? It’s because I added this parameter to the post’s front matter:

---
title: "How to Add Math Expressions to Hugo Blog - the Shortest Guide Possible"
date: 2021-06-12T12:15:16+03:00
draft: false
toc: false
images:
  - "/images/math-teacher.png" 
tags: 
  - howto
  - hugo
  - meta
  - math
render_math: true
---

I could even live-debug my if statement with {{ if .Params.render_math }} which returned true! The final working solution was to add this to the template:

+       <!-- MathJax start -->
+       {{ if .Params.render_math -}}
+               <script type="text/javascript" id="MathJax-script" async
+                       src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js">
+               </script>
+               <script type="text/x-mathjax-config">
+                       MathJax.Hub.Config({
+                       tex2jax: {
+                               inlineMath: [['\\(','\\)']],
+                               displayMath: [['$$','$$'], ['\[','\]']],
+                               processEscapes: true,
+                               processEnvironments: true,
+                               skipTags: ['script', 'noscript', 'style', 'textarea', 'pre'],
+                               TeX: { equationNumbers: { autoNumber: "AMS" },
+                                       extensions: ["AMSmath.js", "AMSsymbols.js"] }
+                       }
+                       });
+               </script>
+               <script type="text/x-mathjax-config">
+                       MathJax.Hub.Queue(function() {
+                               // Fix <code> tags after MathJax finishes running. This is a
+                               // hack to overcome a shortcoming of Markdown. Discussion at
+                               // https://github.com/mojombo/jekyll/issues/199
+                               var all = MathJax.Hub.getAllJax(), i;
+                               for(i = 0; i < all.length; i += 1) {
+                                       all[i].SourceElement().parentNode.className += ' has-jax';
+                               }
+                       });
+               </script>
+       {{- end -}}
+       <!-- MathJax end -->

Before:

math-before

And after:

math-after

Removing JS from the critical path with Partytown πŸŽ‰

Now the GA-4 GTM script is slowing down the page even more! Damn it!

Damn it

I don’t want to remove it, but I also don’t want it to slow down the page. So I opted to install Partytown, simply following this guide.

There were a few things that didn’t work 1-to-1 from the guide:

  1. I had to replace the internal analytics partial with a custom one that uses type="text/partytown" instead of normal text/javascript. So I copied the original partial from Hugo’s files on GitHub.
  2. Since I deploy to GitHub Pages, I had to cause Jekyll to add the ~partytown directory. Since Jekyll doesn’t include directories that start with certain special characters when building and deploying the site.
  3. I used nvm and an .nvmrc file to make sure I’m using the correct Node.js version.

To fix 2, I added the following _config.yml file to the root of my site (the public directory):

include:
  - "~partytown"

Improving CLS (Cumulative Layout Shift)

This one was simple: Just slapped height and width on the homepage logo. I found out that I’m loading that image from a 3rd party site instead of from my static files, so updated that as well.

Improving accessibility

I only got 74?! That sucks, I really want the site to be accessible. There were a bunch of issues here:

  1. No alt text on the site logo. Added descriptive text
  2. “Background and foreground colors do not have a sufficient contrast ratio”. In the Hermit theme, in _predefined.scss, there’s a @mixin called dimmed, which lowers the opacity by 40%. While it looks cool, the contrast IS hard to read. So I changed it to 90% instead of 60%, and now it’s much more legible, even if it’s not as cool.

Improving SEO

“Document does not have a meta description”

After Googling for a bit, this seems like a known issue that hasn’t been fixed yet, see Pull Request. I just copy pasted the fix from the Pull Request (including the CR comment) by adding the description to the structured-data.html partial and the description to my config.toml file.

In config.toml:

[params]
  description = "..."

In themes/hermit-fork/layouts/partials/structured-data.html:

<meta
  name="description"
  content="{{ with .Description }}{{ . }}{{ else }}{{if .IsPage}}{{ .Summary }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end -}}"
/>

“Structured data is valid”

I didn’t know about this one! Appears that Google has something called “Structured data” which is a standardized format for providing information about a page and classifying the page content. Seems like we want two features here:

  1. Add the site logo as the Structured Data logo using this guide
  2. For each blog post, add the “BlogPosting” structured data using this guide

I started with 1 for the homepage, by adding this into the structured-data.html partial:

<script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "Organization",
    "url": {{ .Site.BaseURL }},
    "logo": {{ (print .Site.BaseURL .Site.Params.siteLogoRelPath) }}
  }
</script>

And this to the config.toml:

[params]
  siteLogoRelPath = "images/site-logo.png"

Then, run your site locally, inspect the <head> until you find the JSON-LD tag, copy it, and paste it into the JSON-LD Playground to test that it worked out correctly.

Re-run and compare

I WIN!

win

Other random cleanups

Get rid of the “tweet” Hugo shortcode warning

Every time I ran the server I saw these warnings: The "tweet" shortcode will soon require two named parameters: user and id.. I decided to fix it instead of continuing to ignore the warnings (I’m not a monster). It’s a simple change:

-{ { < tweet 1388749844240535553 > } }
+{ { < tweet user="ShayNehmad" id="1388749844240535553" > } }

Summary

I learned a TON about Hugo, Jekyll, GitHub, and the Web in general. I am also DONE, with only two side-projects left for later: Automate this with CI, and add structured data to the blog posts. Another day!

Photo generated by DALL-E with the prompt β€œAn expressive oil painting of a lighthouse watching over a sea made from paper”.