Sorta scoped styling in Hugo, take two

I had the right idea but not the right approach. Here’s a better one.

2023-01-19

Latest commit: f27b0b5f, 2023-01-27
1,069 words • Reading time: 5 minutes

I gave up on my earlier, Rube Goldberg-esque attempt to achieve scoped styling after finding it too convoluted to maintain. Fortunately, I’ve now stumbled on a much simpler way to get there.

Important: Be sure to check the Updates at the bottom.

Anal-ysis

As I explained in last year’s “Sorta scoped styling in Hugo,” I wanted to achieve some form of scoped styling within this Hugo-powered website. That goal was laudable, but the same couldn’t be said about the sadly anal approach I chose for achieving it:

  • First, I tagged each of my hundreds of posts according to which content types they contained — e.g., code blocks, video embeds, and so on.
  • Next, I broke down my one big Sass styling file, index.scss, into many separate files specific to the content types. And, when I say specific, I mean it: I had one file for a post that had both images and social media embeds, another for a post with all those and tables, and on it went. Going even further down the rabbit hole, I decided that each file would then @use one or more Sass partials to do its work. By the time I’d finished, I had over two dozen Sass files in play. Jeeeeeeez.
  • Finally, within the site repo’s head.html Hugo partial, I added conditionals that would present the tag(s)-appropriate Sass file(s) for each post.

While all of this worked, I soon abandoned it, as I described in a subsequent update:

Consider this now an abandoned experiment. I went with it for a couple of weeks, but, in the end, decided to revert to my previous definitely-not-scoped configuration after seeing that this method hampered attempts to make certain styling changes — that is, without invoking chaos which wasn’t worth my time to resolve. Perhaps you’ll have better luck with it.

Blind squirrel, meet acorn

Then, a few days ago, while experimenting with a Tailwind CSS theme for another post, I stumbled onto a method that also worked yet didn’t require the tags, or the ridiculously large spider’s web of Sass files, or the associated hassle. While I initially did it just to limit the size of the Tailwind file, I soon realized it also was a superior, much more idiot-proof way to get the scoped styling I’d previously tried to achieve in Sass.

It broke down like this.

  • Based on the Sass partials I’d already been using, I created Hugo head partials (subheads, you might say) for each of the following types of content:
    • Code, both code blocks and inline code (like this)
    • Web fonts
    • Footnotes
    • Home page content
    • Embeds of YouTube videos
    • Embeds of Mastodon content
    • The site’s HTML sitemap
    • Tables
  • For each such Hugo partial, I used a conditional to identify the content in question. For example, some partials use Hugo’s findRE function to locate specific HTML output within the .Content, while others (such as the one for the home page) check for a page’s .Title.
  • I converted some Sass partials to regular, standalone Sass files.
  • Each Hugo partial’s conditional, if satisfied, would then call the appropriate standalone Sass file and run it through Hugo Pipes to produce the final CSS for the website.
  • Of course, as before, there would be global styling, supplied by two Sass files — one for the web fonts and one for the remaining globally needed styles. This modular approach will make it easier later, should I decide either to use a different set of web fonts or opt instead for the system fonts stack.

Here’s a simplified1 version of one of the Hugo “subhead” partials, the head-css-social.html file which looks for Mastodon embeds. The only difference in the conditional between the production output and the local-development (if .Site.IsServer) output is that the former is compressed.

{{- $compOutput := (dict "outputStyle" "compressed") -}}

{{- $cssSocial := "" -}}
{{- $optionsSocial := (dict "transpiler" "dartsass" "targetPath" "css/social.css") -}}
{{- $optionsSocialComp := merge $optionsSocial $compOutput -}}

{{- if (findRE `<blockquote class="toot-blockquote"` .Content 1) -}}
	{{- if hugo.IsProduction -}}
		{{- $cssSocial = resources.Get "scss/social.scss" | resources.ToCSS $optionsSocialComp | fingerprint "md5" -}}
		<link rel="preload" as="style" href="{{ $cssSocial.RelPermalink }}">
		<link rel="stylesheet" href="{{ $cssSocial.RelPermalink }}" type="text/css">
	{{- else if .Site.IsServer -}}
		{{- $cssSocial = resources.Get "scss/social.scss" | resources.ToCSS $optionsSocial | fingerprint "md5" -}}
		<link rel="preload" as="style" href="{{ $cssSocial.RelPermalink }}">
		<link rel="stylesheet" href="{{ $cssSocial.RelPermalink }}" type="text/css">
	{{- end }}
{{- end }}

. . . and here’s how the main head.html partial calls them all:

{{- partialCached "head-css-fonts.html" . }}
{{- partial "head-css-social.html" . -}}
{{- partial "head-css-code.html" . }}
{{- partial "head-css-tables.html" . }}
{{- partial "head-css-lite-yt.html" . }}
{{- partial "head-css-footnotes.html" . -}}
{{- partial "head-css-home.html" . -}}
{{- partial "head-css-sitemap.html" . }}
{{- partial "head-css-search.html" . }}
{{- partialCached "head-css.html" . }}

(To save some processing power during development, I can use Hugo’s partialCached function with the first and last entries because they apply to every page on the site and, thus, neither have nor need content-seeking conditionals.)

Unlike the ordeal of months ago, putting all this into practice took literally only a few minutes per each separate type of content (the similarities among the various Hugo partials made it even easier to create new ones), thanks in no small part to the always amazing speed and stability of Hugo.

As for whether the results were worth it: use your browser’s Inspector tool as you skim through the site; and notice how the CSS files load, and which CSS files load, based on what’s on each page. While this isn’t (yet) a true critical CSS approach, it shows a dependencies-free way to get closer to one.


Update, 2023-01-23

Over the ensuing weekend, I did some more thinking about this, and came up with what I think is a even better way (hence the strikethroughs, above).

The main problem with what I’d done above was that it would generate and download more external CSS files, which are always render-blocking resources. The answer to that seemed to be obvious — namely, the use of internal CSS, wherein one puts styling in the head section rather than using external files; but going whole-hog with that method would impair the site’s ability to cache for the second load.

I ended up with a hybrid solution I’d seen mentioned elsewhere: put only the critical CSS in one external file, while loading all the conditional styling as internal CSS.2 Thus, now, my head.html template needs only:

{{- partialCached "head-criticalcss.html" . -}}
{{- partial "head-css.html" . -}}

And, as for head-css.html, it puts all those conditionals in one file and gradually builds the internal CSS:

{{- $css := "" -}}
{{- $cssOptions := dict "outputStyle" "compressed" "transpiler" "dartsass" -}}
{{- $condition := "" -}}
{{- $fileName := "" -}}
{{/* initialize, then populate, booleans for `findRE` actions */}}
{{- $conditionSocial := false -}}
{{- $conditionCode := false -}}
{{- $conditionTables := false -}}
{{- $conditionLiteYT := false -}}
{{- $conditionFootnotes := false -}}
{{- $conditionDetails := false -}}
{{- if (findRE `<blockquote class="toot-blockquote"` .Content 1) -}}{{- $conditionSocial = true -}}{{- end -}}
{{- if (findRE `(<pre|<code)` .Content 1) -}}{{- $conditionCode = true -}}{{- end -}}
{{- if (findRE `<table` .Content 1) -}}{{- $conditionTables = true -}}{{- end -}}
{{- if (findRE `<lite-youtube` .Content 1) -}}{{- $conditionLiteYT = true -}}{{- end -}}
{{- if (findRE `class="footnote-ref"` .Content 1) -}}{{- $conditionFootnotes = true -}}{{- end -}}
{{- if (findRE `<details>` .Content 1) -}}{{- $conditionDetails = true -}}{{- end -}}

{{- $cssTypes := slice -}}{{/* init big slice */}}
{{- $cssTypes = append slice (slice $conditionSocial "social") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionCode "code") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionTables "tables") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionLiteYT "lite-yt-embed") $cssTypes -}}
{{- $cssTypes = append slice (slice (or (ne .Title "Home page") (ne .Title "Search the site") (ne .Title "Sitemap") (ne .Title "Posts")) "billboard") $cssTypes -}}
{{- $cssTypes = append slice (slice (or (and (eq .Section "posts") (ne .Title "Posts")) (eq .Title "About me") (eq .Title "Privacy policy") (eq .Title "Want to reach me?")) "article") $cssTypes -}}
{{- $cssTypes = append slice (slice (and (eq .Section "posts") (ne .Title "Posts")) "posts-single") $cssTypes -}}
{{- $cssTypes = append slice (slice (eq .Title "Posts") "posts-list") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionFootnotes "footnotes") $cssTypes -}}
{{- $cssTypes = append slice (slice (eq .Title "Home page") "home") $cssTypes -}}
{{- $cssTypes = append slice (slice (eq .Title "Sitemap (HTML form)") "sitemaphtml") $cssTypes -}}
{{- $cssTypes = append slice (slice (ne .Title .Site.Params.SearchTitle) "search-btn") $cssTypes -}}
{{- $cssTypes = append slice (slice (eq .Title .Site.Params.SearchTitle) "search-form") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionDetails "details") $cssTypes -}}

{{- range $cssTypes -}}
	{{- $condition = index . 0 -}}
	{{- $fileName = index . 1 -}}
	{{- if eq $condition true -}}
		{{- with resources.Get (print "scss/" $fileName ".scss") | resources.ToCSS $cssOptions -}}
			{{- $css = print $css (.Content | safeCSS) -}}
		{{- end -}}
	{{ end -}}
{{- end -}}

{{- if ne $css "" }}
	<style>{{ $css | safeCSS }}</style>
{{- end }}

Update, 2023-01-27

. . . and, if you’re into Tailwind rather than Sass, here’s the TWCSS version of head-css.html. It uses fewer standalone files than does the Sass method because, with a utility-first framework like Tailwind, much of the special styling is in the specific templates.

{{- $css := "" -}}
{{- $condition := "" -}}
{{- $fileName := "" -}}
{{- $conditionCode := false -}}
{{- $conditionTables := false -}}
{{- $conditionLiteYT := false -}}
{{- $conditionFootnotes := false -}}
{{- if (findRE `(<pre|<code)` .Content 1) -}}{{- $conditionCode = true -}}{{- end -}}
{{- if (findRE `<table` .Content 1) -}}{{- $conditionTables = true -}}{{- end -}}
{{- if (findRE `<lite-youtube` .Content 1) -}}{{- $conditionLiteYT = true -}}{{- end -}}
{{- if (findRE `class="footnote-ref"` .Content 1) -}}{{- $conditionFootnotes = true -}}{{- end -}}

{{- $cssTypes := slice -}}{{/* init big slice */}}
{{- $cssTypes = append slice (slice $conditionCode "code") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionTables "tables") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionLiteYT "lite-yt-embed") $cssTypes -}}
{{- $cssTypes = append slice (slice (or (and (eq .Section "posts") (ne .Title "Posts")) (eq .Title "About me") (eq .Title "Privacy policy") (eq .Title "Want to reach me?")) "article") $cssTypes -}}
{{- $cssTypes = append slice (slice $conditionFootnotes "footnotes") $cssTypes -}}
{{- $cssTypes = append slice (slice (eq .Title .Site.Params.SearchTitle) "search-form") $cssTypes -}}

{{- range $cssTypes -}}
	{{- $condition = index . 0 -}}
	{{- $fileName = index . 1 -}}
	{{- if eq $condition true -}}
		{{- with resources.Get (print "css/" $fileName ".css") | resources.PostCSS -}}
			{{- $css = print $css (.Content | safeCSS) -}}
		{{- end -}}
	{{ end -}}
{{- end -}}

{{- if ne $css "" }}
	<style>{{ $css | safeCSS }}</style>
{{- end }}

  1. The real one has stuff specific to my use of a Content Security Policy, so I deleted it from this example in order to limit the visual clutter. ↩︎

  2. Of course, the key to that is identifying which styling truly is critical for every page on the site. I’ll likely refine that over time, but some of the easy choices were the nav bar header, footer, and (as of this writing) web fonts. Beyond that — which is where the ongoing refinements will come into play — it got a bit more complicated. ↩︎

Next: Code, meet mode

Previous: Static Mastodon toots in Eleventy: the Tailwind CSS edition