Static Asset Hashing in Hakyll
A problem I encountered while working on this website is that when I edit one of the CSS files and publish my changes to the Internet, there’s a good chance your browser will have cached the previous CSS files that it served you, and you won’t see the new styles that I have written.
To mitigate this, I decided to hack the compilation step in Hakyll so that my stylesheets are concatenated together, compressed, and then turned into an MD5 hash. This hash is then used as the path to the resulting file which forces the browser to download the new file whenever I update the styles.
The first step is to list the filepaths of the CSS files that should be compiled. The order in which rules are declared in CSS is important, so I prefer to list these filepaths explicitly.
-- | All CSS files which should be compiled
styleSheets :: [FilePath]
styleSheets =
[ "css/normalize.css"
, "css/default.css"
, "css/syntax.css"
]
Next, we need to generate the MD5 hash for the compiled CSS file’s ultimate
filepath. By leveraging Hakyll’s preprocess
function, it’s possible to run
some arbitrary IO effects during the site’s compilation step.
The approach here is to:
- Read the contents of each CSS file.
- Concatenate the CSS files into one big string with
mconcat
. - Compress the string with Hakyll’s default
compressCss
function. - Pack the string into a lazy bytestring, and build an MD5 digest with the
md5
function. - Convert the MD5 digest back into a filepath and return it.
main :: IO ()
main = hakyllWith config $ do
compiledStylesheetPath <- preprocess $ do
styles <- mapM readFile styleSheets
let h = md5 $ fromStrict $ pack $ compressCss $ mconcat styles
pure $ "css/" <> show h <> ".css"
let cssPathCtx = constField "cssPath" compiledStylesheetPath
-- …
The cssPathCtx
binding is a convenience. It’s handy because most pages that
Hakyll generates will want CSS applied, which means most pages will need to
know where to find the compiled stylesheet.
Continuing on in our Rules
monad, we need to add a rule that generates the
file at the filepath we calculated in the preprocessing step. The route to this
file is already correct, so we keep it as is with route idRoute
. In the
compilation step for this rule, we are again loading our list of CSS filepaths
and adding them to some page context. The context here is only used in a
special file that we’ll use in a moment to render all of the CSS.
main :: IO ()
main = hakyllWith config $ do
-- …
create [fromFilePath compiledStylesheetPath] $ do
route idRoute
compile $ do
styles <- mapM (load . fromFilePath) styleSheets
let ctx = listField "styles" pageCtx (pure styles)
makeItem "" >>= loadAndApplyTemplate "templates/all.css" ctx
-- …
The snippet above references a template which doesn’t exist yet. We create that
file, and add a single line of Hakyll’s templating DSL to render all of the
contents of each of the CSS files by iterating over the styles
context we
defined.
$for(styles)$$body$$endfor$
At this point, the compiled stylesheet is being generated correctly, but the
site’s outermost template layer doesn’t reference it yet. Any time you load and
apply the outermost template layer — in this case called
templates/default.html
— you’ll need to pass in the cssPathCtx
bound
earlier, likely monoidally joined with some other context for that template.
main :: IO ()
main = hakyllWith config $ do
-- …
match "index.html" $ do
route idRoute
compile $ do
let ctx = cssPathCtx <> someOtherCtx
getResourceBody
>>= applyAsTemplate (field "posts" (const (recentPostList 3)))
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= cleanIndexUrls
-- …
Now that the default template knows the path to the compiled CSS file, we can update that reference.
<!DOCTYPE html>
<html>
<head>
<title>$title$</title>
<link rel="stylesheet" type="text/css" href="/$cssPath$" />
</head>
<body>
<!-- etc … -->
Everything works as expected when compiling the site from scratch, but a
typical development workflow involves running Hakyll’s filesystem monitor to
watch for changes and incrementally recompile the site. If you run the watch
command from your compiled Hakyll application binary and change one of the
stylesheets, the files referencing the compiled stylesheet don’t yet know that
they also need to be recompiled to reflect the new stylesheet path.
Fortunately, Hakyll provides some building blocks for declaring extra dependencies in rules. Here’s the final necessary change.
main :: IO ()
main = hakyllWith config $ do
-- …
match "index.html" $ do
route idRoute
dep <- makePatternDependency "css/*"
rulesExtraDependencies [dep] $ compile $ do
let ctx = cssPathCtx <> someOtherCtx
getResourceBody
>>= applyAsTemplate (field "posts" (const (recentPostList 3)))
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= cleanIndexUrls
-- …
Now during local development the outermost template layer will always reference the correct compiled stylesheet.
An added benefit of this approach is the concatenation step, which results in only a single HTTP request necessary to fetch all of the styles on page load.
This approach is working nicely on this website. If there’s a neater way to do it, please let me know.