Fingerprinting Jekyll SASS Assets

May 7, 2025

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

  1. Find the source manifest file - in my case, /css/main.scss
  2. 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.

Jekyll

OSZAR »