I started building this ste with hakyll from a clean slate and added bits and peaces I found and liked on the web and from the hakyll template. Alexander Batischev wrote about precompressing files on his hakyll blog and also published the source. I slightly rewrote the compression function to use hopfli and brotli.
brotli :: Rules()
brotli = do
route $ customRoute $ (<.> "br") . toFilePath
compile $ do
identifier <- getUnderlying
body <- loadBody (setVersion Nothing identifier)
makeItem body
>>= brotliCompiler
brotliCompiler :: Item String -> Compiler (Item LBS.ByteString)
brotliCompiler = withItemBody (return . Brotli.compress . LBS.fromStrict . TE.encodeUtf8 . T.pack)
hopfli :: Rules ()
hopfli = do
route $ customRoute $ (<.> "gz") . toFilePath
compile $ do
identifier <- getUnderlying
body <- loadBody (setVersion Nothing identifier)
makeItem body
>>= hopfliCompiler
hopfliCompiler :: Item String -> Compiler (Item BS.ByteString)
hopfliCompiler = withItemBody (return
. Hopfli.compressWith (defaultCompressOptions {numIterations = 100}) GZIP
. TE.encodeUtf8 . T.pack)Then I can use the compression rules like this:
match "css/*" $ do
route idRoute
compile compressCssCompiler
match "css/*" $ version "gzip" $ hopfli
match "css/*" $ version "brotli" $ brotliSimilarly for a create rule:
create ["archive/index.html"] $ version "brotli" $ brotli
create ["archive/index.html"] $ version "gzip" $ hopfliUnfortunately, the haskell brotli library needs brotli as a system dependency, which I provide with devenv, which also provides the haskell environment. I also added minhtml from nixpkgs to compress html by piping it through minhtml:
minhtml :: Item String -> Compiler (Item String)
minhtml = withItemBody (unixFilter "minhtml" [])which is to be used like
match "posts/**.md" $ do
route $ setExtension "html"
compile $ pandocCompiler
>>= loadAndApplyTemplate "templates/post.html" postCtx
>>= loadAndApplyTemplate "templates/default.html" postCtx
>>= relativizeUrls
>>= minhtmlThe match rule looks like that because i chose to author my posts like posts/2026/07/28/starting-with-hakyll/index.md. This is slightly more cumbersome to author, but produces clean URLs without .html for free and allows me to keep images local to the directory of the article they are used in. Hakyll still parses the date with that directory schema.
One catch with using index.html for clean URls is that Hakyll also uses the Route, that determines where files are written, in a url field in the Context that is used to generate internal links. Currently that happens in the postCtx Context that is used on the archive and front page from the Hakyll template to render a list of posts.
I adapted the function to clean up the Context from Andreas Herrmann, who also wrote about it in his hakyll blog. I have to keep the trailing slash, so that images resolve to the local directory and not the parent directory. I’l accept the little clutter of a trailing slash for much higher authoring convenience.
postCtx :: Context String
postCtx =
stripIndexHtml `mappend`
dateField "date" "%B %e, %Y" `mappend`
defaultContext
stripIndexHtml :: Context a
stripIndexHtml = mapContext removeIndexStr (urlField "url")
where
-- Credit to Yann Esposito, with minor changes
-- http://yannesposito.com/Scratch/en/blog/Hakyll-setup/
removeIndexStr :: String -> String
removeIndexStr url =
case splitFileName url of
(dir, "index.html") -> dir
_ -> urlNext, I added some minimal CSS to support dark and light mode. To get dark and light versions of code blocks with highlighting, I used the create ["css/syntax.css"] rule by Rebecca Skinner to dump the CSS for the breezeDark and tango styles and manually copying it to my default.css, wrapped in media queries:
@media (prefers-color-scheme: dark) { /* breezeDark */ }
@media (prefers-color-scheme: light) { /* tango */ }That shall be all for now.