Hakyll Pt. 5 – Generating Custom Post Filenames From a Title Slug
Info
Summary | Leverage hakyll's own internals to output custom webpage URI routes using any metdata field – in this case, our post title. |
---|---|
Shared | 2019-03-16 |
Revised | 2023-02-11T16:00:00Z |
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.
- Pt. 1 – Setup & Initial Customization
- Pt. 2 – Generating a Sitemap XML File
- Pt. 3 – Generating RSS and Atom XML Feeds
- Pt. 4 – Copying Static Files For Your Build
- Pt. 5 – Generating Custom Post Filenames From a Title Slug
- Pt. 6 – Pure Builds With Nix
- The hakyll-nix-template Tutorial
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"
.
- Where Do We Start? Hakyll’s
route
Function - Looking to
idRoute
,setExtension
and OtherRoutes
Functions for Clues - Leveraging Hakyll’s
metadataRoute
to Access Title Metadata - Writing Our Own URI Slug Function
- 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"
getTitleFromMeta
: useMetadata
’slookupString
function to search fortitle
and handle theMaybe String
return value by providing a fallback of"no title"
fileNameFromTitle
: once we get thetitle
String
, convert it to typeText
, pass that to the slugify function, append.html
to the slugifiedtitle
, then convert it back to aString
(FilePath
is a type alias ofString
, so no worries here)titleRoute
: once we have aFilePath
value, we pass it toconstRoute
to get back ourRoutes
type thatmetadataRoute
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