RSS

Hakyll Pt. 5 – Generating Custom Post Filenames From a Title Slug

Info

SummaryLeverage hakyll's own internals to output custom webpage URI routes using any metdata field – in this case, our post title.
Shared2019-03-16
Revised2023-02-11 @ 16:00 UTC

2023-02-03 update: I published the slugger package on Hackage back on 2021-07-28, so that is available for use if you’d rather use a package.

This is part 5 of a multipart series where we will look at getting a website / blog set up with hakyll and customized a fair bit.

Overview

Out of the box, hakyll takes filenames and dates and outputs nice routes for your webpages, but what if you want your routes to be based off of a metadata field like title? In this post we’ll take a title like "Hakyll Pt. 5 – Generating Custom Post Filenames From a Title Slug" and have hakyll output routes like "hakyll-pt-5-generating-custom-post-filenames-from-a-title-slug".

  1. Where Do We Start? Hakyll’s route Function
  2. Looking to idRoute, setExtension and Other Routes Functions for Clues
  3. Leveraging Hakyll’s metadataRoute to Access Title Metadata
  4. Writing Our Own URI Slug Function
  5. Retrieving and Slugifying our Titles

Where Do We Start? Hakyll’s route Function

In the hakyll tutorial on basic routing, as well as other posts in this series, we have come across hakyll’s route function used in conjunction with functions like idRoute and setExtension. Given these functions live in the Hakyll.Core.Routes module, we can bet that other functions for customizing our outputted routes will be found in there. Let’s see what we can find!

Looking to idRoute, setExtension and Other Routes Functions for Clues

When we look at Hakyll.Core.Routes, we can see that idRoute and setExtension, which we know are used with route, both return a type of Routes. The implementation of Routes is not important for us here, for our job now is to see what other functions return Routes, as well, so that we can potentially leverage their functionality.

Doing a quick search in that module reveals to us some very interesting results! * customRoute * constRoute * gsubRoute * metadataRoute

Alright! Now, what does each one do? * customRoute: takes in a function that accepts an Identifier and returns a FilePath and returns that. Sounds like it could be useful, somehow… Let’s keep going. * constRoute: takes in a FilePath, wraps the value in a const function (which will always return the value it was passed) and then passes the function to customRoute! Okay, so this basically means if we say constRoute "foo.html", then that’s what the route will come out as. Makes sense. * gsubRoute: this one’s purpose is to use patterns to replace parts of routes (like transforming "tags/rss/bar.xml" to tags/bar.xml). Useful! But not for our task. * metadataRoute: takes in a function that accepts Metadata and returns Routes, and then this function returns Routes. Since we want to access our title metadata to create a route, something that gives us access to Metadata and returns Routes is exactly what we want!

Leveraging Hakyll’s metadataRoute to Access Title Metadata

As with most things in the Haskell world, let’s allow the types to guide us. What do we know? * route accepts a function whose return value is Routes * metadataRoute ultimately returns Routes (yay!), but it first takes in a function that accepts Metadata and needs to return Routes.

Therefore, our task is to write a function with the signature Metadata -> Routes that finds the title field in the metadata, converts it to a URI slug, and transforms that FilePath into a Routes. Perhaps we could call it titleRoute and then extract the conversion from Metadata to FilePath to something like fileNameFromTitle? Good enough.

Also, what did we see earlier that can take a FilePath and return Routes? constRoute to the rescue! With these initial bits figured out, let’s sketch this out :

main :: IO ()
main = hakyllWith config $ do
    match "posts/*" $ do
        let ctx = constField "type" "article" <> postCtx

        route $ metadataRoute titleRoute -- THIS LINE
        compile $ pandocCompilerCustom
            >>= loadAndApplyTemplate "templates/post.html"    ctx
            >>= saveSnapshot "content"
            >>= loadAndApplyTemplate "templates/default.html" ctx

    -- ...other rules


titleRoute :: Metadata -> Routes
titleRoute = constRoute . fileNameFromTitle


fileNameFromTitle :: Metadata -> FilePath
fileNameFromTitle = undefined -- ???

Great! This is progress! We have the outline of what we need to accomplish. The next task is to find the title, convert it to a slug and return a FilePath. But first, we need to take a detour and write a toSlug function that we can work with.

Writing Our Own URI Slug Function

Taking inspiration from the archived project https://github.com/mrkkrp/slug, we can write a module, Slug.hs, with a main function, toSlug that takes in Text from Data.Text and transforms it from normal text to a slug. For example, "This example isn't good" would be transformed into "this-example-isnt-good".

{-# LANGUAGE OverloadedStrings #-}


module Slug (toSlug) where


import           Data.Char (isAlphaNum)
import qualified Data.Text as T


keepAlphaNum :: Char -> Char
keepAlphaNum x
    | isAlphaNum x = x
    | otherwise    = ' '


clean :: T.Text -> T.Text
clean =
    T.map keepAlphaNum . T.replace "'" "" . T.replace "&" "and"


toSlug :: T.Text -> T.Text
toSlug =
    T.intercalate (T.singleton '-') . T.words . T.toLower . clean

Once you do this, don’t forget to open up your project’s .cabal file, add in this line and run stack build eventually:

executable site
  -- ...
  other-modules:    Slug

Now that this is taken care of, let’s return to the remaining task!

Retrieving and Slugifying our Titles

The last step in our journey is to look up the title in the Metadata, convert it to a slug and return a FilePath. Let’s look at the implementation and then talk about it:

titleRoute :: Metadata -> Routes
titleRoute =
    constRoute . fileNameFromTitle


fileNameFromTitle :: Metadata -> FilePath
fileNameFromTitle =
    T.unpack . (`T.append` ".html") . toSlug . T.pack . getTitleFromMeta


getTitleFromMeta :: Metadata -> String
getTitleFromMeta =
    fromMaybe "no title" . lookupString "title"
  1. getTitleFromMeta: use Metadata’s lookupString function to search for title and handle the Maybe String return value by providing a fallback of "no title"
  2. fileNameFromTitle: once we get the title String, convert it to type Text, pass that to the slugify function, append .html to the slugified title, then convert it back to a String (FilePath is a type alias of String, so no worries here)
  3. titleRoute: once we have a FilePath value, we pass it to constRoute to get back our Routes type that metadataRoute requires, and we’re done!

Wrapping Up

While it would be awesome if this sort of thing were built in to hakyll, this experience has shown me that in a way, the core of hakyll allows people to customize their build to their heart’s delight, and perhaps an implementation such as this would be useful as a hakyll plugin. Maybe!

Next up: Pt. 6 – Pure Builds With Nix


Thank you for reading!
Robert