PurgeCSS with Hugo

Over the past several months I slowly rebuilt the theme for my site with Pico.css, with the goal of eliminating the div soup I had. This past week, my site stopped building altogether because of a dependency issue[1], and I polished up the theme last night for deployment.

One win from this rebuild is that I finally got PurgeCSS and cssnano working with Hugo as part of my build pipeline. Although the savings were small, in the range of 1-2kb after Brotli compression, it was bugging me that it wasn’t working.

The first insight was finding out I had Hugo’s .Resources.PostProcess and .Resources.PostCSS functions flipped in my head — the correct usage is to run .Resources.PostCSS on your input from Hugo Pipes, and then tag the output with .Resources.PostProcess.

Tagging the output with PostProcess tells Hugo not to run that file through PostCSS until after it’s built the pages, which is important because PurgeCSS runs through the HTML (or hugo_stats.json) to figure out which CSS selectors are used on the pages, and which ones it can get rid of.

The second insight was that Hugo’s built-in selector detector (new band name) wasn’t catching the conditional selectors used in Pico, like :where and :is, resulting in significant savings in file size while leaving my site looking like it drove the wrong lane down a dirt road.

To fix that, I stopped using hugo_stats.json for PurgeCSS and used the output folder of HTML files instead. Then I added a whitelist to my postcss.config.js based on Tom’s post. Here’s the file in full:

const purgecss = require('@fullhuman/postcss-purgecss');

module.exports = {
plugins: [
require("cssnano"),
purgecss({
content: ['./public/**/*.html'],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
safelist: [':hover', ':focus', 'button', 'button:focus', ':where', ':is']
})
]
};

And the Hugo partial for processing the SCSS:

{{ $tocss := dict "enableSourceMap" true }}
{{ if hugo.IsProduction }}
{{ $tocss := dict "enableSourceMap" false "output" "compressed" }}
{{ end }}
{{ $styles := resources.Get "scss/styles.scss" | resources.ToCSS $tocss }}
{{ if not hugo.IsProduction }}
<link href="{{ $styles.RelPermalink }}" rel="stylesheet" />
{{ end }}
{{ if hugo.IsProduction }}
{{ $css := $styles | minify | fingerprint | postCSS | resources.PostProcess }}
<link href="{{ $css.RelPermalink }}" rel="stylesheet" />
{{ end }}

Now the only issue left to solve is figuring out why PostCSS doesn’t work in GitHub Actions.

There’s a few nitpicks I have with Pico.css, I might swap it out for Simple.css now that my site uses semantic HTML. And I’m planning yet another redesign with vanilla CSS — I’ve built two sites that way recently and it’s been wonderful writing plain CSS again, with new features like calc() and variables.


  1. Bootstrap 5 now uses SASS features that are incompatible with Hugo’s SASS processor, and I had no luck pinning the working version. ↩︎