Perfect Image Processing with Hugo
I use Hugo to build this little blog and it frequently gets incredibly frustrating. My safe space in technology is tagging BGP routes with regex matched communities, not web development. There are tutorials, guides, and support threads on a variety of topics for basically anything you could think of for Hugo. But the time sink to first conceptualize a problem and thereafter apply a best practice fix is very long. In most cases it takes me a long Saturday afternoon to do a simple task like setting up an RSS feed. It has been months of sporadic reading and tinkering to find a suitable image processing script I am happy with.
In the past I was extremely spoiled and babied by content management systems (CMS) like Ghost. A few clicks here and there made everything come together easily. There was a problem with all of that capability. It added a great deal of unnecessary bloat that bogged down page requests and added tens of megabytes to simple text and image pages. Also, dealing with a database on Ghost’s backend was entirely overkill for my goal of having a basic static website. While these behaviors aren’t specific to Ghost or any other CMS it is certainly the trending average for them. To get a CMS paired down appropriately would require a great deal of time and I could spend the same time learning basic web design and be farther ahead in the end.
Instead, I embraced Hugo with the direct goal of learning the fundamentals of web design while keeping my blog simple and fast. Not to create something fancy or awe inspiring. But to just be decent enough to operate the fundamentals of a text and image static site.
Ironically, while Hugo aims to be an ultra fast static site generator it doesn’t specifically share an all encompassing image processing script, which is arguably the biggest burden on site data transfer usage. There is a thorough Image Processing guide from Hugo but putting all the pieces together is something for serious web developer folks. I have instead relied on smashing together several other people’s scripts in order to get something I am comfortable running. However, this might be the fundamental goal of the Hugo developers. To give an array of appropriate tools and leave the overall crafting of image processing up to each project owner.
While the script I built has some elements from various authors, the biggest inspiration came from Michael Welford.1 I didn’t take his same approach for inserting gradient image placeholders (GIP), however, his logical approach to skipping SVG and GIF files to the collection of breakpoint sizes was very helpful.
This script relies on overriding the default behavior of Hugo for calling out images with Markdown. This is easily done by placing a file in layouts/_default/_markup/render-image.html
. In the past I had used shortcodes to perform the same image processing but the syntax in Markdown documents was jarring and incompatible. By using regular Markdown to call out images you can draft documents much easier in Visual Studio Code, iA Writer, or anything else that suits your fancy. You will also future proof your Markdown documents in case you decide to move to something else besides Hugo.
To insert an image in a post I would use the normal Markdown syntax for an image:
![A dirt road with a steep trail going up to Mount Baldy.](speedgoat-50k-2024-mount-baldy-ascent.webp "The turn off before I started climbing Mount Baldy.")
The text “A dirt road with a steep trail going up to Mount Baldy.” will serve as the image’s alt attribute unless overridden by the last text field. Since I’m using Hugo’s Page Bundles, I specify the image file “speedgoat-50k-2024-mount-baldy-ascent.webp” without any sort of folder structure prefix. The last text parameter “The turn off before I started climbing Mount Baldy.” will become the figcaption, title attribute, and accessibility aria-label. There is certainly a limitation to compressing all these fields into a limited number of inputs. Yet, you have to decide if the trade off is worth it for the wider compatibility of using Markdown. The only alternative is to spread this out through a dedicated Hugo shortcode as you see fit. But if there comes a time in the future where you have to migrate away from Hugo this will become a pain in the ass to convert.
The Markdown callout will build the below HTML for the img wrapped inside a figure element. Remember that the alt attribute is replaced by the last text field, if present. I formatted it all for easier reading:
<figure role="figure" aria-label="The turn off before I started climbing Mount Baldy.">
<img
alt="The turn off before I started climbing Mount Baldy."
title="The turn off before I started climbing Mount Baldy."
width="1600" height="1200"
srcset="
/blog/speedgoat-50k-2024-race-report/speedgoat-50k-2024-mount-baldy-ascent_hu10284923237444045690.webp 420w,
/blog/speedgoat-50k-2024-race-report/speedgoat-50k-2024-mount-baldy-ascent_hu8804836215186072207.webp 789w,
/blog/speedgoat-50k-2024-race-report/speedgoat-50k-2024-mount-baldy-ascent_hu10352609384891324939.webp 1019w,
/blog/speedgoat-50k-2024-race-report/speedgoat-50k-2024-mount-baldy-ascent_hu14272288209653111312.webp 1430w,"
sizes="(min-width: 800px) 50vw, 100vw"
src="/blog/speedgoat-50k-2024-race-report/speedgoat-50k-2024-mount-baldy-ascent_hu8823523890708859362.jpg"
decoding="async"
loading="lazy"
>
<figcaption>The turn off before I started climbing Mount Baldy.</figcaption>
</figure>
As you can see there is a fallback JPG image availability for legacy browsers but WEBP is now widely supported2 by anything recently up to date.
Many resampling filters are available through Hugo image processing but I chose Lanczos due to the superior results it was giving with high resolution images. I don’t specify a quality value so Hugo defaults to 75 out of a scale of 100. Another thing to note is that despite trying all resampling filters I had issues with charts and maps becoming discolored or added compression artifacts. After further experimentation I realized this was only happening when those screenshots I had taken were WEBP prior to being processed by Hugo. If I fed them in as JPG they were perfectly fine afterwards as WEBP. This must have something to do with Hugo’s image processing on Windows since I could not replicate the issue by performing the same WEBP to WEBP conversion with Adobe Photoshop.
The sizes attribute within the img tag of the script specifies how the browser should choose the appropriate image size to load based on the layout of the webpage. With the growing adoption of high resolution retina screens and the variability of mobile browsing it gets very complicated to efficiently manage an image’s size as it’s offered up to the browser. One major improvement in managing sizes was from some advice from Chris Coyier3 to effectively let the browser figure it out. In my script, I adopt Mr. Coyier’s advice and the sizes attribute is set to (min-width: 800px) 50vw, 100vw
.
Overall, this works as a set of conditions and values to determine the correct image to provide to the browser requesting the webpage. The first part is a condition (min-width: 800px)
, which defines a media query. If the viewport width is at least 800 pixels, the condition is true. The second part is the value 50vw. If the condition of viewport width ≥ 800px is true, the browser will calculate the image size to be 50% of the viewport’s width. The last part determines if a default of 100vw will come into play into the browser’s calculations. If the condition of viewport width < 800px is false, the browser will use the fallback value of 100vw, meaning the image should take up the full width of the viewport.
This attribute works in conjunction with the srcset attribute, which provides a list of available image versions in different resolutions or widths. The browser uses the sizes attribute to determine how large the image will appear in the layout and then selects the most appropriate image from the srcset options. For example, on a desktop with a screen width of 1024px the image might be displayed at 512px wide, which would make it 50% of the viewport width. On a smaller device, like a smartphone with a width of 400px, the image would instead be displayed at 400px wide being 100% of the viewport width. This ensures the best quality image is loaded without unnecessary file size overhead, saving bandwidth and improving load times.
Additional features of the script include eager loading and sync decoding for image files that have “featured” in the file name. This is especially helpful for images that appear at the top of a web page. If you relied on lazy loading and async decoding for these front and center images you might get a lower PageSpeed score. And to be frank, that isn’t best practice. Those deferment parameters are meant for images that load later in a web page.
Lastly, it is very helpful to specifically define an image’s width and height to a numeric value. This helps the browser appropriately render the page since it knows the dimensions of all elements.
This script relies on overriding the default behavior of Hugo for calling out images with Markdown. You can do this by creating a file at layouts/_default/_markup/render-image.html
. Otherwise, you could theoretically create your own shortcode and reference it by whichever name you chose. I would also recommend manually deleting the contents of public
prior to rebuilding your site’s images with this or any other script, as per Hugo best practice. You don’t want it cluttered with a huge arrangement of stale srcset junk built from an old script.
{{- $image_sizes := slice "420" "789" "1019" "1430" "2048" -}}
{{- $image_quality := "Lanczos" -}}
{{- $alt := .Text -}}
{{- $label := .Text -}}
{{- $caption := "" -}}
{{- if ne .Title "" -}}
{{- $caption = .Title | $.Page.RenderString -}}
{{- $label = $caption -}}
{{- end -}}
{{- $image := .Page.Resources.GetMatch .Destination -}}
{{- if and (not $image) .Page.File -}}
{{- $image = resources.Get (path.Join .Page.File.Dir .Destination) -}}
{{- else -}}
{{- $image := resources.Get $image -}}
{{- end -}}
{{- with $image -}}
{{- $image_width := $image.Width -}}
{{- $image_height := $image.Height -}}
{{- if strings.Contains $image "_2x" -}}
{{- $image_width = math.Floor (math.Div $image_width 2) -}}
{{- $image_height = math.Floor (math.Div $image_height 2) -}}
{{- end -}}
{{- $fallback_image := ($image.Resize (print "789x jpg " $image_quality)) -}}
<figure role="figure" aria-label="{{- $label | plainify -}}">
<img
alt="{{- $caption | markdownify | plainify -}}"
title="{{- $label | plainify -}}"
width="{{- $image_width -}}"
height="{{- $image_height -}}"
{{- if (or (strings.HasSuffix $image ".gif") (strings.HasSuffix $image ".svg")) -}}
src="{{- $image.RelPermalink -}}"
{{- else -}}
srcset="
{{- if le $image.Width (index $image_sizes 0) }}
{{- with $image.Resize (print $image.Width "x webp " $image_quality) -}}
{{- print .RelPermalink " " .Width "w, " -}}
{{- end -}}
{{- end -}}
{{- with $image_sizes -}}
{{- range $index, $size := . -}}
{{- if ge $image.Width $size -}}
{{- with $image.Resize (print $size "x webp " $image_quality) -}}
{{- print .RelPermalink " " $size "w, " -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}"
sizes="(min-width: 800px) 50vw, 100vw"
src="{{- $fallback_image.RelPermalink -}}"
{{- end -}}
{{- if strings.Contains $image "featured" -}}
loading="eager"
decoding="sync"
{{- else -}}
decoding="async"
loading="lazy"
{{- end -}}
/>
</a>
<figcaption>{{- $caption -}}</figcaption>
</figure>
{{- end -}}
Michael Welford, “Responsive Image Management With Hugo," Michael Welford (blog), May 31, 2024, https://its.mw/posts/hugo-responsive-images/. ↩︎
Alexis Deveria, “WebP Image Format," Can I Use…, April 7, 2024, https://caniuse.com/webp. ↩︎
Chris Coyier, “Responsive Images: If You’Re Just Changing Resolutions, Use Srcset,” CSS-Tricks (blog), September 7, 2015, https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-srcset/. ↩︎