About the website
This website is created with a Haskell program which compiles pages from Markdown to HTML using the Hakyll static website generator and the Pandoc document converter. The program, presented below in literate form, assembles a navigation menu from YAML metadata headers, resolves relative references, translates mathematical equations from LaTeX to MathML, and optionally includes a bibliography and table of contents.
The source of the website is published at https://git.colberg.org/peter/peter.colberg.org.
1 Prerequisites
{-# LANGUAGE OverloadedStrings #-}
import Control.Applicative (Alternative (empty))
import Data.Char (isLower, toLower)
import Hakyll
import Skylighting (styleToCss, pygments)
import System.FilePath
import Text.Pandoc.Citeproc (processCitations)
import Text.Pandoc.Definition
import Text.Pandoc.Options
import Text.Pandoc.Walk (walkM)
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Scientific as Scientific
import qualified Data.Text as T
import qualified Data.Yaml as Yaml
import qualified Network.URI as URI
import qualified Text.Pandoc as Pandoc
On Debian 12 “Bookworm”, the following packages are required:
apt install libghc-hakyll-dev libghc-io-streams-dev pandoc-data
The Makefile
compiles the site
program and deploys the website to a
separate git branch.
If building the site fails with the error
invalid byte sequence
, ensure the locale supports Unicode,
e.g.,
export LANG=C.UTF-8
2 Pandoc compiler
A custom Pandoc compiler converts pages from Markdown to HTML.
main :: IO ()
= do
main let pageCompiler = getResourceBody
>>= readPandoc
>>= withItemBody (walkM $ transformRelativeReference)
>>= withItemBody (unsafeCompiler . Pandoc.runIOorExplode . processCitations)
>>= writePandocWithToc
The Markdown is parsed into a Pandoc AST, which is processed with a chain of Pandoc filters. A bibliography is created with Pandoc citeproc from references given in a separate YAML metadata block that is parsed by Pandoc (see, e.g., nano-dimer/README.md). The AST is rendered as HTML, optionally prepending a table of contents.
3 Hakyll rules
Hakyll is invoked with a set of routes and compilation rules for the site’s resources, where a route is the path of a resource in the output directory. Hakyll tracks dependencies between resources and recompiles pages when the underlying sources are updated, which is particularly useful when previewing drafts with the built-in webserver.
$ do
hakyll "*.asc" .||. "*.js" .||. "fonts/*") $ do
match (
route idRoute compile copyFileCompiler
The style sheet is created by concatenating and minimizing all CSS source files.
"site.css"] $ do
create [
route idRoute$ makeItem =<< compressCss . concatMap itemBody <$> loadAll "css/*.css"
compile
"css/*.css" $ compile getResourceBody
match
"css/highlight.css"] $
create [$ makeItem =<< return (styleToCss pygments) compile
After Pandoc compilation, the page template adds the title, navigation menu and footer.
"*.md" .||. "*.lhs") $ do
match ($ cleanRoute
route $ do
compile <- getUnderlying
identifier let context = bodyField "body" <>
"home" identifier "index.md" <>
homeField
pageContext
>>= loadAndApplyTemplate "templates/page.html" context
pageCompiler
"templates/*" $ compile templateBodyCompiler match
Projects have their own home page and navigation menu.
"*/*.md" .||. "*/doc/*.md") $ do
match ($ gsubRoute "/doc/" (const "/") `composeRoutes` cleanRoute
route $ do
compile <- getUnderlying
identifier let project = fromFilePath . (</> "README.md") . head . splitPath . toFilePath
= bodyField "body" <>
context "home" identifier "index.md" <>
homeField "project" identifier (project identifier) <>
homeField
pageContext
>>= loadAndApplyTemplate "templates/page.html" context
pageCompiler
"*/doc/*.svg" .||. "*/doc/*.png") $ do
match ($ gsubRoute "/doc/" (const "/")
route compile copyFileCompiler
4 Clean routes
For user-friendly URLs, each page is stored in a separate directory
with the filename index.html
.
cleanRoute :: Routes
= customRoute $
cleanRoute . map allUpperToLower . splitPath . addIndexHtml . toFilePath
joinPath
addIndexHtml :: FilePath -> FilePath
addIndexHtml path| takeBaseName path `elem` ["index", "README"] = replaceFileName path "index.html"
| otherwise = dropExtension path </> "index.html"
All-uppercase path elements are transformed to lowercase, e.g.,
NEWS.md
becomes news/index.html
.
allUpperToLower :: String -> String
allUpperToLower s| any isLower s = s
| otherwise = map toLower s
The filename index.html
is stripped when referencing
pages within the website.
cleanUrl :: String -> String
cleanUrl url| takeFileName url == "index.html" = takeDirectory url
| otherwise = url
cleanUrlField :: String -> Context a
= mapContext cleanUrl . urlField cleanUrlField
6 Relative references
Images and links to internal resources are given as relative references. A Pandoc filter transforms the relative references to the corresponding routes, preserving the fragment identifier that links to a section of a page.
transformRelativeReference :: Inline -> Compiler Inline
Image attr text (url, title)) = do
transformRelativeReference (<- T.pack <$> (relativeReference $ T.unpack url)
url' return $ Image attr text (url', title)
Link attr text (url, title)) = do
transformRelativeReference (<- T.pack <$> (relativeReference $ T.unpack url)
url' return $ Link attr text (url', title)
= return x
transformRelativeReference x
relativeReference :: String -> Compiler String
= case URI.parseRelativeReference url of
relativeReference url Just uri@URI.URI { URI.uriPath = path@(_:_) } -> do
<- getUnderlying
identifier <- getRoute $ relativeIdentifier path identifier
value case value of
Just path' -> return $ show $ uri { URI.uriPath = cleanUrl $ toUrl path' }
Nothing -> fail $ "invalid reference " ++ url
-> return url
_
relativeIdentifier :: FilePath -> Identifier -> Identifier
=
relativeIdentifier path . dropDrive . URI.uriPath . URI.relativeTo (uri path) . uri . toFilePath
fromFilePath where uri p = URI.nullURI { URI.uriPath = p }
7 Table of contents
If the metadata contains a toc
field, a table of
contents is generated from headings down to the given depth.
writePandocWithToc :: Item Pandoc -> Compiler (Item String)
= do
writePandocWithToc item <- getMetadata (itemIdentifier item)
metadata `writePandocWith` item) <$>
(case KeyMap.lookup "toc" metadata of
Just (Yaml.Number value) -> case Scientific.toBoundedInteger value of
Just depth -> return $ writerOptionsWithToc depth
Nothing -> fail $ "invalid toc depth " ++ show value
-> return writerOptions
_
writerOptionsWithToc :: Int -> WriterOptions
= writerOptions
writerOptionsWithToc depth = True
{ writerTableOfContents = depth
, writerTOCDepth = Just tocTemplate
, writerTemplate = True
, writerNumberSections = MathML
, writerHTMLMathMethod
}where
= either error id $ either (error . show) id $
tocTemplate $ Pandoc.runWithDefaultPartials $
Pandoc.runPure "" "$if(toc)$<nav class=\"toc\">\n$toc$\n</nav>\n$endif$$body$"
Pandoc.compileTemplate
writerOptions :: WriterOptions
= def
writerOptions = True
{ writerSectionDivs }