Fingerprinting Jekyll SASS Assets
As I’ve been updating the stylesheets on my blog, I ran into an issue with browser caching — changes to my CSS weren’t showing up right away. Since I’m serving assets through AWS CloudFront with a 7-day cache for non-HTML files, this behavior makes sense. While I could disable caching altogether, that feels like a blunt and amateur solution. Instead, I’m implementing asset fingerprinting to keep the performance benefits of caching while ensuring everyone always get the latest version of my styles.
I’m using the built-in jekyll-sass-converter plugin for Jekyll to compile my SASS files into CSS. Unfortunately it doesn’t offer any support for fingerprinting assets. I searched around and found the jekyll-minibundle which supports both minification and fingerprinting, but it doesn’t work with jekyll-sass-converter
. I did stumble upon this gist by Yaroslav Markin which looked appealing - just a few lines of code and you have a hand-rolled digesting solution.
# frozen_string_literal: true
require 'digest'
module Jekyll
# Jekyll assets cachebuster filter
#
# Place this file into `_plugins`.
module CachebusterFilter
# Usage example:
#
# {{ "/style.css" | cachebuster }}
# {{ "/style.css" | cachebuster | absolute_url }}
def cachebuster(filename)
sha256 = Digest::SHA256.file(
File.join(@context.registers[:site].dest, filename)
)
"#{filename}?#{sha256.hexdigest[0, 6]}"
rescue StandardError
# Return filename unmodified if file was not found
filename
end
end
end
Liquid::Template.register_filter(Jekyll::CachebusterFilter)
I ran my site locally and it worked - my css file had a fingerprint appended.
<link rel="stylesheet" href="/css/main.css?b0763bce">
I pushed it to my staging site and it didn’t work - no fingerprint. Which must mean the filename wasn’t found. I then went through a few rounds of debugging with ChatGPT - adding Jekyll logging, etc - and finally concluded that ChatGPT’s initial direction (which I ignored) was correct - when the filter runs the compiled CSS file doesn’t exist yet. It works locally because the previous build had already generated the file.
A quick hacky fix was to run jekyll build
twice in staging, which did work - but is obviously not a great solution. Instead I needed to either get access to the compiled CSS file or generate the digest from the source SASS files. I don’t think it’s possible to access the compiled CSS file - because it doesn’t necessarily exist yet - so instead I started to look at the jekyll-sass-converter code in more detail.
At a high level the filter is invoked on the output of the SASS converter - in my case, /css/main.css
. It then needs to
- Find the source manifest file - in my case,
/css/main.scss
- Find all referenced SASS files
Instead of trying to parse out the SASS files I opted to simply look at all the files in my /_sass
directory. The plugin allows you to alter the source directory for the SASS files (and add others) so ideally I need to interact with the plugin directly and get the config that way - I didn’t want to duplicate the code to parse the plugin’s config. Luckily this is something supported by Jekyll.
# frozen_string_literal: true
require 'digest'
module Jekyll
# Jekyll assets sass_digest filter
module SassDigestFilter
# Usage example:
#
# {{ "/style.css" | sass_digest }}
# {{ "/style.css" | sass_digest | absolute_url }}
def sass_digest(filename)
site = @context.registers[:site]
return site.data['sass_digest'][filename] if site.data.dig('sass_digest', filename)
scss_file = site.in_source_dir("#{File.dirname(filename)}/#{File.basename(filename, '.css')}.scss")
unless File.exist?(scss_file)
Jekyll.logger.warn 'SassDigest:', "#{scss_file} does not exist"
return filename
end
scss_converter = site.find_converter_instance(Jekyll::Converters::Scss)
if scss_converter.nil?
Jekyll.logger.warn 'SassDigest:', "#{Jekyll::Converters::Scss} converter not found"
return filename
end
files = [site.in_source_dir(filename.sub(/\.css$/, '.scss'))]
scss_converter.sass_load_paths.each do |path|
Dir.glob("#{site.in_source_dir(path)}/**/*.scss") do |sass_file|
files << sass_file
end
end
site.data['sass_digest'] ||= {}
site.data['sass_digest'][filename] = "#{filename}?#{digest(files)}"
end
private
def digest(files)
combined = files.sort.map { |f| File.read(f) }.join
Digest::SHA256.hexdigest(combined)[0, 8]
end
end
end
Liquid::Template.register_filter(Jekyll::SassDigestFilter)
The last piece that I had to figure out was how to cache the computed digest within a single build - it turns out the site
object has a data hash that makes this straightforward.
I’m using this for now, but I’m not confident that this is the correct approach. I want to see if it’s possible to add this to the jekyll-sass-converter
plugin directly.