Hakyll Pt. 5 – Generating Custom Post Filenames From a Title Slug
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
- (wip) Pt. 7 – Customizing Markdown Compiler Options
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 ()
= hakyllWith config $ do
main "posts/*" $ do
match let ctx = constField "type" "article" <> postCtx
$ metadataRoute titleRoute -- THIS LINE
route $ pandocCompilerCustom
compile >>= loadAndApplyTemplate "templates/post.html" ctx
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/default.html" ctx
-- ...other rules
titleRoute :: Metadata -> Routes
= constRoute . fileNameFromTitle
titleRoute
fileNameFromTitle :: Metadata -> FilePath
= undefined -- ??? fileNameFromTitle
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.replace "'" "" . T.replace "&" "and"
T.map keepAlphaNum
toSlug :: T.Text -> T.Text
=
toSlug '-') . T.words . T.toLower . clean T.intercalate (T.singleton
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-- ...
-modules: Slug other
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 . fileNameFromTitle
constRoute
fileNameFromTitle :: Metadata -> FilePath
=
fileNameFromTitle . (`T.append` ".html") . toSlug . T.pack . getTitleFromMeta
T.unpack
getTitleFromMeta :: Metadata -> String
=
getTitleFromMeta "no title" . lookupString "title" fromMaybe
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