Using Lightning CSS with Hugo: back to the workaround

Why a clear separation between dev mode and prod mode made sense.

2024-02-18

First, I added Lightning CSS to my Hugo site through a plugin for PostCSS. Then, I opted for using a strictly CLI-based approach. Most recently, I added PurgeCSS to the resulting CLI-powered styling stack.

I’ve now decided to backtrack a bit — I would say, “for sanity’s sake,” except that any of you who are past consumers of my content would recognize that as too ironic in my case, so I won’t. Let’s just say that it was for the sake of easier maintenance.

Before I get into the “why” of this post, let me give you its “what” . . .

I’ve gone back to accessing the powers of Lightning CSS through a PostCSS plugin, and I’m now also bringing in PurgeCSS through a PostCSS pluginbut all of this is in production mode only. On the other hand, during development, I’m keeping things quick by returning to what I previously described as “Embedded Dart Sass, for which Hugo’s built-in asset pipeline is optimized.”

In fact, that quote comes from when I compared the speeds of the three methods I’d tried. For each case, this was in development mode, since the build-time differences among them not only were relatively small but mostly meaningless to me (i.e., since I wasn’t waiting for Hugo to rebuild a page while I changed the styling thereof). It was clear that, in development mode, Hugo and Sass always would be superior. Still, Sass couldn’t match most of Lightning CSS’s neat processing tricks, so my choice seemed clear.

Or, at least, it did until I began thinking about how complicated I’d made things.

As I explained in the earlier posts, the CLI-based method required keeping my real styling files in an assets/css-originals folder for Lightning CSS (and, later, PurgeCSS) to access for processing into the assets/css folder Hugo would “watch” in dev mode. However, because Lightning CSS lacks its own “watch” capabilities, I ended up using the npm-watch package for that.

All of this meant I’d ended up with package.json scripts like this:

"dev:lcss": "lightningcss --bundle --targets \"$npm_package_config_targets\" themes/lcss/assets/css-original/*.css --output-dir themes/lcss/assets/css",
"start": "NODE_ENV=development npm-run-all clean:* dev:lcss --parallel dev:hugo watch"

As I acknowledged, I’d ended up with a “Franken-config”; and, its complexity aside, it also was much more external to normal Hugo workings than I preferred. In fact, in that way, it was even more of a workaround than had been using the PostCSS plugin. But was there a way to go back to a simpler, more “true Hugo” process while still keeping the advantages of Lightning CSS and not incurring the performance slowdowns of PostCSS?

The answer began to reveal itself while I researched the addition of PurgeCSS to the mix.

I ran across a Hugo Discourse forum discussion from last year, in which Joe Mooring of the Hugo project suggested the use of the serve package for local viewing of production-mode Hugo content. Heretofore, I’d always just run hugo server with the --production=environment flag for such a purpose, but it turns out that doesn’t play well with certain Hugo functions (such as resources.PostProcess). Besides, once I tried using serve with a Hugo build, I found I liked it better than my previous method; e.g., it gave me a superior and more easily obtained preview of how my search page was going to look and function when posted to the web.

In short, I’d fully separated development from production. And I liked it better that way.

Then, yesterday, it hit me: just such a separation was the answer. Instead of trying to contort my config so that development mode would have all the styling powers of a production build, as I’d done up to that point, I’d simply handle styling in as bare-bones a way as possible in development, and then add all of the other stuff only in production. Then, with the serve package, I could get an absolutely precise look at the true, final result before actually deploying the site.

So here’s what I’m now doing:

  1. In development, I use Sass for my styling. This eliminates the need for a separate css-original folder, while also providing Sass’s own cool features (and thus not requiring any dev-mode help from PostCSS).
  2. In production, I do a Hugo build, running the Sass-transpiled CSS through PostCSS (also well-wired into Hugo), through which it gets the benefits of both Lightning CSS and PurgeCSS.
  3. Finally, I use the serve package to preview the resulting build before deployment.

In my templating, the dev-prod separation results in something like this:

{{- $scssOptions := dict "transpiler" "dartsass" -}}
{{- $css := resources.Get "scss/index.scss" | toCSS $scssOptions -}}
{{- if hugo.IsProduction -}}
	{{- $css = $css | postCSS | fingerprint -}}
{{- end }}

. . . in concert with the PostCSS config:

postcss.config.js

// all of this is for production only

const purgeCSS = require('@fullhuman/postcss-purgecss')
const postcssLightningcss = require("postcss-lightningcss")

module.exports = {
	plugins: [
		purgeCSS({
			config: "./purgecss.config.js"
		}),
		postcssLightningcss({
			browsers: "defaults", // per https://browsersl.ist/
			lightningcssOptions: {
				minify: true,
				cssModules: false,
				drafts: {
					nesting: true // for whenever Sass starts "emitting" it (https://www.brycewray.com/posts/2023/03/sass-coming-native-css-nesting/)
				}
			}
		})
	]
}

You’ll note that the separate purgecss.config.js file is still around. It’s grown somewhat since the previous post, as I’ve found more items that need whitelisting, but it otherwise has retained the overall structure I originally described.

As for the package.json scripting, this change has freed me of the gyrations required to build a separate assets/css with the stuff for CLI-based tools to process. Instead, the Hugo/PostCSS connection handles that internally, and only in production.

In fact, if I chose, I now could do without package.json scripting altogether and just use ordinary Hugo commands. That said: for the time being, I’ll stick with what I have. After all, it’s not as if I could lose package.json itself, since I’m using packages that reside in node_modules.

It’s early but, so far, I’m really pleased with how this turned out. It seems to be a true best-of-both-worlds, win-win kind of setup. Not only do I get to continue styling my site with CSS that’s been enhanced by intelligent tools; I also once again have a simpler config and the fast dev-mode operation for which Hugo is renowned, yet with few or no penalties on Hugo’s equally famous build speed.


Update, 2024-02-20

Well, I guess it was too early. A few hours after I initially issued this post, I ran into some aggravations that led to my returning to the “Franken-config” for a while, after which I decided on a strictly CSS/PostCSS approach — i.e., without Lightning CSS. Because I thus could no longer rely on Lightning CSS’s ability to @import separate CSS files into one, I resorted to using Hugo’s resources concatenation feature to eliminate dev-mode slowness:

{{/*
	This is for the "critical," "above-the-fold"
	CSS which is inlined in each page's `head`.
	The name of each constituent partial file
	begins with a three-digit number in the scheme
	of `0xx` (e.g., `001-reset.css` or `005-colors.css`),
	which makes `resources.Match` load them in
	the desired order for concatenation.
*/}}
{{ $css := (resources.Match "css/partials/0*.css") | resources.Concat "css/critical.css" }}
{{- if hugo.IsProduction -}}
	{{- $css = $css | postCSS -}}
{{- end -}}
{{- with $css }}
	<style media="screen">{{ .Content | safeCSS }}</style>
{{- end }}

{{/*
	This is for a site-wide `index.css` which 
	the site uses with **unscoped** CSS.
	For each CSS file used in unscoped mode,
	I appended `-u` to the filename (e.g., `home-u.css`)
	so the `resources.Match` statement would avoid
	the few files intended only for **scoped** mode.
	In this case, the alphabetical order of 
	concatenation is not only irrelevant 
	but also, as it turns out, the same as 
	I'd practiced when using `@import` to
	combine the files together, anyway.
*/}}
{{- $css := resources.Match "css/*-u.css" | resources.Concat "css/index.css" -}}
{{- if hugo.IsProduction -}}
	{{- $css = $css | resources.Copy "css/index.min.css" | postCSS | fingerprint -}}
{{- end -}}
{{- with $css }}
	<link rel="preload" href="{{ $css.RelPermalink }}" as="style"{{- if hugo.IsProduction -}} integrity="{{ $css.Data.Integrity }}" crossorigin{{- end -}}>
	<link rel="stylesheet" href="{{ $css.RelPermalink }}" type="text/css" media="screen"{{- if hugo.IsProduction -}} integrity="{{ $css.Data.Integrity }}" crossorigin{{- end -}}>
{{- end }}

While I could have used Hugo’s inlineImports capability, its requirement for PostCSS would’ve resulted in much slower dev-mode performance. To be specific: with inlineImports and PostCSS, a single-line edit to any of the affected CSS files would cause Hugo to take anywhere from 500 ms to five seconds to do a dev-mode live rebuild; but now, with Hugo’s built-in concatenation on its own with no PostCSS involvement, I’ve seen a live rebuild after the same single-line CSS edit happen in as rapidly as 30 ms.

And, just to be clear: the Sass/PostCSS config I described in this post definitely works, but I un-did it for reasons whose explanation may justify another post somewhere down the line. For now, suffice it to say that I grew unhappy with the apparently lackadaisical pace of updates and releases for several items on which the Sass/PostCSS config depended. I opted instead for a styling stack over which I could exercise more selection of, and control over, certain dependencies I’d otherwise have accessed downstream from other packages.

Reply via email
View comments