Generating this website
This site is generated using Hakyll, a Haskell library for generating static websites. The raw version of this file (see the source link at the bottom of this post) is compiled into the executable that generates this entire site, and in turn is presented as this post. This is achieved by writing a literate source file.
Assumptions
This is not intended as a beginner tutorial, so some working working knowledge of Haskell is assumed going forward. As with all Haskell files, we have to import some modules.
First we import the Hakyll
module, which contains the core functionality needed to generate this site.
Next import is Hakyll.Web.Sass
which provides a compiler for scss.
{-# LANGUAGE OverloadedStrings #-}
import Hakyll
import Hakyll.Web.Sass (sassCompiler)
Next is the Monad module, which facilitates composition. If not familiar with function programming concepts, it is worth looking into.
import Control.Monad (msum, forM)
import Data.Monoid ((<>))
The Data
and System
modules provide many of the common functions you would expect in a programming language.
import Data.Char (toLower)
import Data.List (sortBy, intercalate, isInfixOf)
import Data.Maybe (fromMaybe)
import Data.Ord (comparing)
import Data.Time.Clock (UTCTime (..))
import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM)
import System.FilePath (replaceBaseName, splitFileName, takeBaseName, takeDirectory, (</>))
import System.Process (readProcess)
Finally we import some UTF8 encoding to keep things consistent.
import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding, utf8)
Now that we have everything imported we can set some configuration. The providerDirectory is where the built executable will look for source files when building the site. By putting all the files in a sub folder we can keep root folder clean.
config :: Configuration
= defaultConfiguration
config
{= "src/provider"
providerDirectory = "src/_cache"
, storeDirectory = "src/_cache/tmp"
, tmpDirectory = True
, inMemoryCache }
Here we define the patterns we use to find content
A ‘post’ is an article that is ready to be published and included in the sites feed and listing. Grab anything, no matter how deep in the post directory, regardless of the file extension.postsPattern :: Pattern
= "posts/**" postsPattern
A ‘draft’ is an article I am working on, and want rendered, but to not include in any listings. It can be accessed by providing the direct URL to the page. This is useful if I want to provide someone the ability to proof the post before going live. Grab anything, no matter how deep in the drafts directory, regardless of the file extension.
draftsPattern :: Pattern
= "drafts/**" draftsPattern
notes whatever, so I can organize as I see fit.
notesPattern :: Pattern
= "notes/**" notesPattern
Instantiate hakyll with UTF-8 encoding the above configuration.
main :: IO ()
= do
main
setLocaleEncoding utf8
setFileSystemEncoding utf8
setForeignEncoding utf8$ do hakyllWith config
First we need to render our templates so we can apply our content onto them.
"templates/*" $ compile templateBodyCompiler match
Each of the following lines generates a portion of our content. More detail will follow when we look at the function definition.
staticCss
scss
staticAssetsindex
pages
notesIndex
postsIndex
postsAndNotes
secrets secretsStatic
I am using normalize.css, along with a syntax.css file, these get copied directly to the output directory.
staticCss :: Rules ()
= match "css/*.css" $ do
staticCss
route idRoute compile copyFileCompiler
todo: hakyll-sass
scss :: Rules ()
= match "css/*.scss" $ do
scss $ setExtension "css"
route let compressCssItem = fmap compressCss
<$> sassCompiler) compile (compressCssItem
The following files are just copied verbatim due to the idRoute
and copyFileCompiler
combination.
staticAssets :: Rules ()
= match "static/**" $ do
staticAssets $ gsubRoute "static/" (const "")
route compile copyFileCompiler
index has some unique things. Lists of most recent posts, and list of most recently updated notes
todo: context todo: relativizeUrls todo: removeIndexHtml
index :: Rules ()
index = match "index.html" $ do
route idRoute$ do
compile <- fmap (take 3) . recentlyCreatedFirst =<< loadAll postsPattern
posts <- fmap (take 3) . recentlyUpdatedFirst =<< loadAll notesPattern
notes let indexCtx =
"posts" siteContext (return posts) <>
listField "notes" siteContext (return notes) <>
listField "title" "Home" <>
constField
siteContext
getResourceBody>>= applyAsTemplate indexCtx
>>= loadAndApplyTemplate "templates/site.html" indexCtx
>>= relativizeUrls
>>= withItemBody removeIndexHtml
Next we build the static pages.
They live in the subfolder pages
, strip that from the url.
compile the markdown with pandoc.
Drop them into the site template
pages :: Rules ()
= match "pages/*" $ do
pages $ subFolderRoute `composeRoutes` gsubRoute "pages/" (const "")
route $ pandocCompiler
compile >>= loadAndApplyTemplate "templates/content.html" siteContext
>>= loadAndApplyTemplate "templates/site.html" siteContext
>>= relativizeUrls
>>= withItemBody removeIndexHtml
Create the notes index page lexicography Ordered needs a title
notesIndex :: Rules ()
= create ["notes.html"] $ do
notesIndex
route subFolderRoute$ do
compile <- lexicographyOrdered =<< loadAll notesPattern
orderedNotes let ctx = constField "title" "Notes - Alphabetical" <>
"notes" siteContext (return orderedNotes) <>
listField
siteContext""
makeItem >>= loadAndApplyTemplate "templates/notes.html" ctx
>>= loadAndApplyTemplate "templates/site.html" ctx
>>= relativizeUrls
>>= withItemBody removeIndexHtml
The typical ‘archive’ page is just /posts, a list of all posts. One day, when I write a lot, I’ll need to figure out pagination
postsIndex :: Rules ()
= create ["posts.html"] $ do
postsIndex
route subFolderRoute$ do
compile <- recentlyCreatedFirst =<< loadAll postsPattern
posts let ctx = constField "title" "All Posts" <>
"posts" siteContext (return posts) <>
listField
siteContext""
makeItem >>= loadAndApplyTemplate "templates/posts.html" ctx
>>= loadAndApplyTemplate "templates/site.html" ctx
>>= relativizeUrls
>>= withItemBody removeIndexHtml
todo:
posts and notes are treated the same.
Just a logical separation
.||.
is logical OR for the match
postsAndNotes :: Rules ()
= match (postsPattern .||. notesPattern .||. draftsPattern) $ do
postsAndNotes $ metadataRoute titleFromMetadata `composeRoutes` myPostsRoute `composeRoutes` subFolderRoute
route $ pandocCompiler
compile >>= loadAndApplyTemplate "templates/content.html" siteContext
>>= loadAndApplyTemplate "templates/site.html" siteContext
>>= relativizeUrls
>>= withItemBody removeIndexHtml
While I strive to keep everything open and transparent, some things are secret.
These I store in a private git repo and clone it into a folder named secret
under provider.
First we create the secret pages.
secrets :: Rules ()
= match "secrets/*" $ do
secrets $ subFolderRoute `composeRoutes` gsubRoute "secrets/" (const "")
route $ pandocCompiler
compile >>= loadAndApplyTemplate "templates/page.html" siteContext
>>= loadAndApplyTemplate "templates/site.html" siteContext
>>= relativizeUrls
>>= withItemBody removeIndexHtml
As with the static above, just blindly copy everything in the secret folder to the site root. Allows me to host any files arbitrarily. todo: explain gsubRoute
secretsStatic :: Rules ()
= match "secrets/**" $ do
secretsStatic $ gsubRoute "secrets/" (const "")
route compile copyFileCompiler
removeIndexHtml :: String -> Compiler String
= return $ withUrls removeIndexStr body
removeIndexHtml body where
= case splitFileName url of
removeIndexStr url "index.html") | isLocal dir -> init dir
(dir, -> url
_ = not $ "://" `isInfixOf` uri isLocal uri
todo: contexts, what is it?
shortDateFormat :: String
= "%B %e, %Y"
shortDateFormat
siteContext :: Context String
= mconcat
siteContext "created" "createdDateTime" shortDateFormat
[ dateFromMetadata "updated" "updatedDateTime" shortDateFormat
, dateFromMetadata -- , gitHistoryUrl "gitHistoryUrl"
-- , gitCommitUrl "gitCommitUrl"
-- , gitSourceUrl "gitSourceUrl"
"gitEditUrl"
, gitEditUrl
, defaultContext ]
todo: make the below two functions into one
dateFromMetadata :: String -> String -> String -> Context a
= field value $ \i -> do
dateFromMetadata key value format <- getMetadataField' (itemIdentifier i) key
t return $ formatTime defaultTimeLocale format (readTime t)
todo: git things
-- gitLog :: String -> String -> IO String
-- gitLog filePath format =
-- readProcess "git" [
-- "log"
-- , "-1"
-- , "HEAD"
-- , "--pretty=format:" ++ format
-- , "--"
-- , filePath
-- ] ""
--
-- gitBranch :: IO String
-- gitBranch = do
-- branch <-readProcess "git" [
-- "rev-parse"
-- , "--abbrev-ref"
-- , "HEAD"
-- ] ""
-- return $trim branch
--
--
-- gitHistoryUrl :: String -> Context String
-- gitHistoryUrl key = field key $ \item -> do
-- let fp = "provider/" ++ toFilePath (itemIdentifier item)
-- unsafeCompiler $ do
-- sha <- gitLog fp "%h"
-- branch <- gitBranch
-- let github = "https://github.com/kyleondy/kyleondy.com"
-- history = github ++ "/commits/" ++ branch ++ "/" ++ fp
-- return $ if null sha
-- then "Not Committed"
-- else history
The url to an items commit in GitHub
-- gitCommitUrl :: String -> Context String
-- gitCommitUrl key = field key $ \item -> do
-- let fp = "provider/" ++ toFilePath (itemIdentifier item)
-- unsafeCompiler $ do
-- sha <- gitLog fp "%h"
-- let github = "https://github.com/kyleondy/kyleondy.com"
-- commit = github ++ "/commit/" ++ sha
-- return $ if null sha
-- then "Not Committed"
-- else commit
The url to the item at the current time.
Todo: pull current branch out, so links where when I’m in local dev mode.
todo: combine functions
-- gitSourceUrl :: String -> Context String
-- gitSourceUrl key = field key $ \item -> do
-- let fp = "provider/" ++ toFilePath (itemIdentifier item)
-- unsafeCompiler $ do
-- branch <- gitBranch
-- return $ "https://github.com/kyleondy/kyleondy.com/blob/" ++ branch ++"/" ++ fp
--
gitEditUrl :: String -> Context String
= field key $ \item -> do
gitEditUrl key let fp = "provider/" ++ toFilePath (itemIdentifier item)
= "main"
branch do
return $ "https://github.com/kyleondy/kyleondy.com/edit/" ++ branch ++ "/" ++ fp
– todo: below – |
subFolderRoute :: Routes
= customRoute createIndexRoute
subFolderRoute where
= takeDirectory p </> takeBaseName p </> "index.html"
createIndexRoute ident where p = toFilePath ident
dropDateRoute :: Routes
= gsubRoute "/20[0-9]{2}" $ const ""
dropDateRoute
dropSiteRoute :: Routes
= gsubRoute "site" $ const ""
dropSiteRoute
myPostsRoute :: Routes
= dropDateRoute `composeRoutes` dropSiteRoute myPostsRoute
todo: consolidate the below functions
lexicographyOrdered :: [Item a] -> Compiler [Item a]
= return $
lexicographyOrdered items . toFilePath . itemIdentifier)) items
sortBy (comparing (takeBaseName
recentlyUpdatedFirst :: [Item a] -> Compiler [Item a]
= do
recentlyUpdatedFirst items <- forM items $ \item -> do
itemsWithTime <- getMetadataField (itemIdentifier item) "updated"
updateTime return (updateTime,item)
return $ reverse (map snd (sortBy (comparing fst) itemsWithTime))
recentlyCreatedFirst :: [Item a] -> Compiler [Item a]
= do
recentlyCreatedFirst items <- forM items $ \item -> do
itemsWithTime <- getMetadataField (itemIdentifier item) "created"
updateTime return (updateTime,item)
return $ reverse (map snd (sortBy (comparing fst) itemsWithTime))
OK, after that little detour, let’s get back to it! The dateAndTitle
function
above made use of two helper functions which haven’t actually been defined. The
first is readTime
, which we use to normalise the date format. It takes a date
string and converts it to a UTCTime
which we can manipulate.
readTime :: String -> UTCTime
= fromMaybe empty' . msum $ attempts where
readTime t = [parseTimeM True defaultTimeLocale fmt t | fmt <- formats]
attempts = error $ "Could not parse date field: " ++ t
empty' = [ "%a, %d %b %Y %H:%M:%S %Z"
formats "%Y-%m-%dT%H:%M:%S%Z"
, "%Y-%m-%d %H:%M:%S%Z"
, "%Y-%m-%d %H:%M"
, "%Y-%m-%d"
, "%B %e, %Y %l:%M %p"
, "%B %e, %Y"
, "%b %d, %Y"
, ]
todo:
titleFromMetadata :: Metadata -> Routes
= maybe idRoute mkName (getField "title")
titleFromMetadata meta where mkName t = setBaseName $ slugify t
= (`lookupString` meta) getField
The basic idea for the implementation is taken from Hakyll itself, from its
getItemUTC
which is defined in [Hakyll.Web.Template.Context
][hwtc].
Unfortunately, the type signature for that function is quite a lot more
complicated than we need, so I’ve extracted the parts we need into a simple
String -> UTCTime
function here. If the date doesn’t match any of the
supported formats readTime
will simply crash with an error – not the best
error handling but since we’re always going to be running this interactively it
doesn’t really matter.
setBaseName
turns a string into a FilePath
, which it can then manipulate
using Haskell’s native replaceBaseName
functionality.
setBaseName :: String -> Routes
= customRoute $
setBaseName basename `replaceBaseName` basename) . toFilePath (
slugify :: String -> String
= intercalate "-" . words . map (\x -> if x `elem` allowedChars then toLower x else ' ')
slugify where allowedChars = ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ " "