Creating my first blog with Hakyll and Org!

1 The Beginning

As a complete newcomer to both Haskell and the world of static site generators, Hakyll was definitely not my first choice. The ideal SSG1 is extensible, understandable, and lightweight while Hakyll likely only fills one of these criteria for those unfamiliar with the Haskell ecosystem. So why Hakyll?

It’s because of Pandoc. Hakyll is one of the only SSGs that supports Org natively (thanks to Pandoc!) besides Hugo, and learning Haskell is somehow more approachable and comforting than swimming against the tide that is comprehending Hugo’s templating system. I personally found it quite unintuitive to get started with Hugo. Sometimes the juice is not worth the squeeze. I investigated alternatives such as Zola, which I did enjoy a lot, but found myself fighting with the templating engine once again and missing Org.2

So here I find myself with Hakyll. Note: What I found fascinating is that Org links to other files automatically get corrected to the proper weblinks, it’s a small quality of life feature but it matters a lot to me.

2 Setup

The Git repo for this site is available for curious minds. I run a simple Nix development flake with GHC3 and Cabal, along with the Haskell Language Server for LSP actions. I highly recommend following Hakyll’s beginner tutorials to get started with the actual SSG part. The setup can definitely be improved by integrating the actual Cabal Hakyll install into the Nix environment.4

2.1 Adding metadata to Org

Special thanks to Jacob Boxerman’s blog post on switching to Hakyll for showing how to add metadata to Org documents. You just have to add the same header that you do for Markdown. This is unexpected and does not use Org mode’s default metadata tags, so I am noting it down here. It must be the first thing in the file; otherwise, the post metadata will not generate properly.

---
title: "Example of metadata, even for Org files"
author: jrrom
...
---

2.2 Use cabal run site instead of installing the executable every time

Hakyll is the library that allows us to build the executable that is the actual SSG, cabal install is used to first install the built executable site to a directory. This is not updated with cabal build as I thought, so I was running the old executable again and again until I realised. Now I use cabal run site instead of using install.

3 Development

For general tutorials, refer to the official site! Development with Hakyll in my experience is more like developing your own SSG with the tools that Hakyll provides. It has a lot of power in its customisability.

3.1 Adding collected tags to each post

I followed the helpful tutorial linked here and ended up with a basic tag setup. However, I was unsatisfied by the general lack of HTML modification capabilities. I had to use the library functions to create the markup structure that I wanted

For the index page, I made an indexTagsCtx that collects all the tags from all the posts and arranges them by the number of posts that contain the specified tag.

indexRenderTags :: Tags -> Compiler String
indexRenderTags tags =
  renderTags
  (\tag url count _ _ ->
      "<li class=\"" ++ tag ++ "\"> <a href=\"" ++ url ++ "\">" ++ tag ++ "</a>" ++
      "<i> " ++ (show count) ++ " </i>" ++ "</li>")
  unlines
  (sortTagsBy postNumTagSort tags)


indexTagsCtx :: Tags -> Context String
indexTagsCtx tags =
  field "tags" $ \_ ->
                   indexRenderTags tags

For rendering the tags of a normal post, this is where I found it got a bit strange. Instead of writing HTML with strings, I had to use blaze-html in order to feed the proper input to the library function, but it works nevertheless. It felt weird that the function interfaces are different for this seemingly similar thing.

jrromRenderLink :: String -> (Maybe FilePath) -> Maybe H.Html
jrromRenderLink _ Nothing = Nothing
jrromRenderLink tag (Just filePath) = Just $
  H.li ! A.class_ (toValue tag) $
      H.a ! A.href (toValue $ toUrl filePath) $
          toHtml tag

postCtxWithTags tags =
  dateField "date" "%B %e, %Y" <>
  tagsFieldWith getTags jrromRenderLink (mconcat . intersperse "\n") "tags" tags <>
  defaultContext

3.2 Adding syntax highlighting

I found a few great blogs for advanced syntax highlighting using pygmentize or even GHC itself. However, I decided to just go for the default as it suited my needs. I ran the following code in the repl to generate the syntax highlighting file:

import Text.Pandoc.Highlighting
writeFile "syntax.css" (styleToCss breezeDark)

Then I imported it into my default.scss. I will expand on my SCSS configuration later in this post. But for now I have left it untouched.

3.2.1 Adding line numbers to the syntax highlighting

In Org, you have to add -n as a header argument to the code block in order to enable line numbers in the generated HTML. Refer to the following:

#+begin_src yaml -n
...
...
#+end_src

3.3 Adding a Table of Contents (TOC)

First, I would like to link to Hakyll’s official website source code where the proper solution to this problem is given. I couldn’t find mentions of this anywhere else. Next, I would like to link to Argumatronic’s blog on Pandoc TOC generation, though it is outdated now, I used the methods described to successfully only generate the TOC for posts with toc in the metadata. As seen below, if toc exists then I pass withToc, else I pass defaultHakyllWriterOptions.

main :: IO ()
main = hakyll $ do
...
  match "posts/*" $ do
    route $ setExtension "html"
    compile $ do
      ident <- getUnderlying
      toc   <- getMetadataField ident "toc"

      let writerSettings = case toc of
            Just _  -> withToc
            Nothing -> defaultHakyllWriterOptions

      pandocCompilerWith defaultHakyllReaderOptions writerSettings
        >>= loadAndApplyTemplate "templates/post.html"    (postCtxWithTags tags)
        >>= loadAndApplyTemplate "templates/default.html" (postCtxWithTags tags)
        >>= relativizeUrls
...

And now, withToc, which doesn’t allow developers to pass a Maybe String to the writerTemplate anymore. Spent a long time fixing this!

withToc :: WriterOptions
withToc = defaultHakyllWriterOptions
        { writerTableOfContents = True
        , writerNumberSections = True
        , writerTOCDepth = 3
        , writerTemplate = Just (
            either error id $ either (error . show) id
          $ runPure
          $ runWithDefaultPartials
          $ compileTemplate "" "$toc$\n$body$"
          )
        }

3.4 Generating sitemap.xml and rss.xml

I used this amazing blog series to guide me in making this. I created the sitemap.xml template and populated it with the required data such as the root and the pages of the site. For the feed it was as easy as saving a snapshot of the posts as “content” and writing the following:

...
  create ["rss.xml"] $ do
    route idRoute
    compile $ do
      let feedCtx = (postCtxWithTags tags)
      posts <- fmap (take 5) . recentFirst =<<
        loadAllSnapshots "posts/*" "content"
      renderRss myFeedConfiguration feedCtx posts
...

3.5 Preloading pages

A few months ago I saw this video about McMaster Carr and their blazingly fast™ website and got inspired. I searched online for a bit and found instant.page which preloads links on hover or touchstart, and it does exactly what it says on the can. So I simply added it to this project. It deals with a lot of edge cases that I don’t really want to think about.

3.5.1 uBlock Origin blocks prefetches

I learnt that my uBlock Origin blocks prefetches by default after wondering why it wasn’t working. So keep that in mind if you plan to experiment with this stuff.

3.6 Adding finishing touches

After all this, I added the Open Graph Protocol5 to my pages. I also added the canonical URL of the page with a quick $root$$url$. One other minor fix is to limit the number of recent posts in index.html to 5, refer to rss.xml as an example of only taking the first 5.

4 Design

4.1 Fun facts

Some fun facts I discovered while working on the design of the website.

4.1.1 Making <head> Visible

While messing around I discovered that the head element and its children can be made visible using CSS. Who knew?6 Try and see it for yourself!

head, head * {
  display: block;
}

link::after {
  content: "Link: " attr(href);
  background-color: #F00;
}

meta::after {
  content: "[" attr(name) attr(property) "]: " attr(content);
  background-color: #0F0;
}

4.1.2 SCSS has first class functions

This really blew my mind. Functions and mixins can be passed as regular old values. I wonder what all kinds of shenanigans can be done with this. I will be looking into this in the future for sure.

@use "sass:meta";

@function fibonacci($x) {
    @if $x <= 1 { @return $x };

    $fib_fn: meta.get-function("fibonacci");
    @return meta.call($fib_fn, $x - 1) + meta.call($fib_fn, $x - 2);
}

@function print-var($x) {
    @debug($x);
    @return 0;
}

main {
    $_1: fibonacci(10);
    $_2: print-var($_1);
}

Yes this actually works.

[nix-shell:~/Documents/test]$ sass test.scss 
test.scss:11 Debug: 55

4.2 Dealing with light and dark mode

To implement light and dark mode, I used a combination of data attributes and prefers-color-scheme. The site is dark by default and when the user loads the page there is a simple script in the head which checks if the system default is equal to "light", in which case it sets the document.documentElement.dataset.theme to light. If the theme is already set in local storage then it defaults to the saved value.

document.documentElement.dataset.theme =
    localStorage.getItem("theme") ??
    (matchMedia("(prefers-color-scheme: light)").matches
         ? "light"
         : "dark");

The styling is given below. create-theme is a special mixin which generates all the required CSS variables according to the colour scheme map provided.

:root {
    @include create-theme(dark);
}

[data-theme="light"] {
    @include create-theme(light);
}

The properties are then assigned CSS variables (not Sass ones!) so that they change dynamically with the theme.

4.3 Dealing with CSS custom properties in Sass

In Sass, all CSS custom properties (in the form --value: ...; ) do not evaluate any SassScript values assigned to them. This is because they get passed directly to the final CSS file. We have to use interpolation in the form of #{$var} to get around this issue.

4.4 Dealing with icons

I had a few strict requirements for dealing with icons. Iconify docs were really helpful for listing out all the ways.

  • No inline HTML, this is a static site and it gets evaluated every time.
  • No separate files for each SVG icon, this requires using <img> and then I can’t style it.
  • No SVG in CSS using the d: property since caniuse says only 82% usage.

This left me with placing all my SVGs in a single logos.svg file, adding icons to them and adding them when required with <use>

<svg width="1em" height="1em">
    <use href="/images/logos.svg#email">
</svg>

This is detailed here. This has the added benefit of caching all the icons at once since it is all in a single portable file. I recommend it.

4.5 Dealing with fonts

This was surprisingly the easiest part. Check out google-webfonts-helper. I simply picked out my fonts and installed them in woff2 format. If you install fonts directly from Google then you get ttf files, though I didn’t investigate the matter further. The added benefit of google-webfonts-helper is that it also gives you the CSS to copy and put in your website. Amazing.

5 Hosting

5.1 Checking my statistics

My good friend introduced me to this site which allows me to check if the website is optimised. I found a couple of issues which I had to address. Just keeping it here if anyone is interested and also for my own reference.

5.2 Using a custom domain and enabling HTTPS with GitHub Pages

I followed the tutorial, but was not able to enable HTTPS. This had to do with the fact that I did not account for www being at the beginning of the domain name. After creating a CNAME redirection, I was able to proceed successfully.

5.3 Dealing with docs/

GitHub Pages makes it easy to host by either selecting the root directory, the docs directory in root or a different branch. It was surprisingly easy to tell Hakyll to build to docs/ instead of _site . Just use hakyllWith and you’re done!

main = hakyllWith config $ do
  ...

-- Write to docs/ instead of _site
config :: Configuration
config = defaultConfiguration
  { destinationDirectory = "docs" }

6 Conclusion

I was pretty surprised by Hakyll. It is more of a library that allows you to make your own SSG rather than being an SSG itself. The documentation is opaque and not friendly to beginners, but it is worth trudging through it to find the immense power that lies within.

Thank you for reading! 😁

Final fun fact: The emoji above is GRINNING FACE WITH SMILING EYES, however in the CLDR (Common Locale Data Repository) it is beaming face with smiling eyes. This is because the Unicode Consortium prefers less subjective forms in its naming conventions. I personally find it funny that someone had to decide that beaming is too emotional of a word to apply to an emoji.7

7 Footnotes


  1. Static Site Generator.↩︎

  2. I use Org for journaling, notes, writing, etc. It’s great to be able to use it for my website too.↩︎

  3. For some reason, the way to do this by default is to install Cabal with GHC itself, this may be an interesting rabbit hole if you are so inclined.↩︎

  4. Once again, juice is not worth the squeeze.↩︎

  5. It makes sites look more appealing when they are shared. It’s another neat rabbit hole.↩︎

  6. Turns out DOM elements are really just DOM elements. No magic involved.↩︎

  7. See for yourself.↩︎