The anatomy of a Hakyll site, line by line
This site is generated by a Hakyll program. The source is a set of Markdown, reStructuredText, and HTML files; the build is a Haskell executable that compiles those sources into a static HTML site. This post documents every component of that build. Each listing is numbered as a Block and explained in the text that surrounds it.
Package layout
The project is a single Cabal package, blog, defined in blog.cabal. Block 1
lists its three components and the source files each contains.
library hs-source-dirs: lib
Blog.TikZ build-time TikZ -> SVG filter
Blog.Compilers Pandoc compiler (citations, math, highlighting)
Blog.Context template contexts
Blog.Feed Atom/RSS configuration
Blog.Site the Hakyll rule set
executable blog hs-source-dirs: app
site.hs main; calls Blog.Site.siteRules
test-suite blog-test hs-source-dirs: test
Spec.hs unit tests for the library
Block 1. The three Cabal components and the modules each contains.
The library named in Block 1 holds all build logic, in five modules under lib.
It depends on hakyll >= 4.15, pandoc, pandoc-types >= 1.22.2.1, text,
process, and temporary. The executable under app depends only on hakyll
and the blog library and is compiled with -threaded. The test suite under
test links against the library and exercises the pure functions in Blog.TikZ.
OverloadedStrings is a default extension for every component, and every
component is built with the warning flags from a shared warnings stanza
(-Wall -Wcompat, -Wmissing-export-lists, and others). Placing the build logic
in the library is what allows both the executable and the test suite to import it.
Build vocabulary
Hakyll is a library. A Hakyll program supplies a rule set — a Rules () value
describing which source files exist and how each is compiled and routed — to the
hakyll driver, which produces a command-line build program. The build is
incremental: Hakyll constructs a dependency graph and recompiles only stale
targets.
The core types and combinators used in the blocks below:
Rules— the complete recipe; the sequence ofmatchandcreatedeclarations.match— selects existing source files by a globPattern("posts/*").create— declares output files that have no source (such asarchive.html).route— maps a source identifier to an output path.idRoutekeeps the path unchanged;setExtension "html"replaces the extension.compile— the pipeline producing the output, composed with>>=.Compiler— Hakyll’s build monad. Dependencies are tracked implicitly:loading an item inside a compiler records a dependency edge.Context— the set of$variables$available to a template.Snapshot— a named intermediate state of an item, saved withsaveSnapshotand reloaded withloadSnapshot/loadAllSnapshots.
Figure 1 shows the build at a glance: sources on the left, the rule set in the middle, the generated site on the right.
Figure 1. The build: source files, the siteRules rule set, and the generated
output.
The entry point
Block 2 is the entire executable, app/site.hs. It contains no build logic; it
passes the rule set from the library to hakyll.
module Main (main) where
import Hakyll (hakyll)
import Blog.Site (siteRules)
main :: IO ()
main = hakyll siteRulesBlock 2. The executable entry point.
The single call in Block 2, hakyll :: Rules () -> IO (), constructs the
command-line build program. Its subcommands include build (compile the site to
_site/), watch (rebuild on change and serve locally), clean (remove
generated output and the cache), and rebuild (clean then build). Everything
substantive is in siteRules, covered next.
The rule set
Blog.Site exports a single value, siteRules :: Rules (). Block 3 is its
header: the OverloadedStrings pragma, the module declaration, and the imports.
{-# LANGUAGE OverloadedStrings #-}
module Blog.Site (siteRules) where
import Control.Monad (filterM)
import Hakyll
import Blog.Compilers (bibtexMathCompiler)
import Blog.Context (postCtx)
import Blog.Feed (feedConfiguration, feedCtx)Block 3. Pragma, module declaration, and imports of Blog.Site.
The pragma in Block 3, OverloadedStrings, causes string literals to be
interpreted as whichever Hakyll type the position requires — Pattern for
match, Identifier for template names, Snapshot for snapshot names. The
Hakyll import is unqualified, providing the combinators used throughout.
filterM from Control.Monad is used by the draft filter in Block 12. The three
Blog.* imports bring in the post compiler, the post context, and the feed
configuration from the other library modules. The remaining blocks in this
section are the individual rules inside siteRules, in declaration order.
Block 4 declares the two citation inputs.
match "bib/style.csl" $ compile cslCompiler
match "bib/bibliography.bib" $ compile biblioCompilerBlock 4. Citation style and bibliography database.
The rules in Block 4 have a compile but no route, so neither file is written
to the output. They are compiled to in-memory items that other compilers load.
cslCompiler parses a Citation Style Language file, which determines the rendered
format of citations and the bibliography; biblioCompiler parses the BibTeX
reference database. The post compiler in Block 15 loads both.
Block 5 declares the three classes of static asset.
match "images/*" $ do
route idRoute
compile copyFileCompiler
match "js/*" $ do
route idRoute
compile copyFileCompiler
match "css/*" $ do
route idRoute
compile compressCssCompilerBlock 5. Static assets: images, JavaScript, and CSS.
Each rule in Block 5 uses idRoute, which writes the file to the same path it
occupies in the source. Images and scripts use copyFileCompiler, a byte-for-byte
copy with no parsing. CSS uses compressCssCompiler, which removes whitespace and
comments before writing.
Block 6 compiles the three standalone pages.
match (fromList ["about.rst", "contact.markdown", "colophon.markdown"]) $ do
route $ setExtension "html"
compile $ pandocCompiler
>>= loadAndApplyTemplate "templates/default.html" defaultContext
>>= relativizeUrlsBlock 6. The standalone pages.
fromList in Block 6 builds a Pattern from an explicit list rather than a glob.
The list mixes formats: about.rst is reStructuredText, the other two are
Markdown. pandocCompiler selects the reader from the file extension and renders
an HTML fragment. loadAndApplyTemplate "templates/default.html" defaultContext
inserts that fragment into the site shell, and relativizeUrls rewrites absolute
URLs (/css/...) to paths relative to the current page. defaultContext provides
the built-in fields $body$, $title$ (from metadata), and $url$.
Block 7 is the posts rule, the most involved compiler in the rule set.
match "posts/*" $ do
route $ setExtension "html"
compile $ bibtexMathCompiler "bib/style.csl" "bib/bibliography.bib"
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/post.html" postCtx
>>= loadAndApplyTemplate "templates/default.html" postCtx
>>= relativizeUrlsBlock 7. The posts rule.
In Block 7 each file in posts/ is routed to the same name with an .html
extension. bibtexMathCompiler (Block 15) renders the post with citations, math,
syntax highlighting, and the TikZ filter. saveSnapshot "content" then stores the
rendered body at that point — after content rendering and before any template is
applied — which is the state the feeds reload in Block 10. The two
loadAndApplyTemplate calls apply first the post layout and then the site shell,
each rendered under postCtx (Block 13) rather than defaultContext because
posts require a formatted date field. relativizeUrls finishes the pipeline.
Block 8 generates the archive page.
create ["archive.html"] $ do
route idRoute
compile $ do
posts <- recentFirst =<< loadAllPublished "posts/*"
let archiveCtx =
listField "posts" postCtx (return posts) `mappend`
constField "title" "Archives" `mappend`
defaultContext
makeItem ""
>>= loadAndApplyTemplate "templates/archive.html" archiveCtx
>>= loadAndApplyTemplate "templates/default.html" archiveCtx
>>= relativizeUrlsBlock 8. The archive page.
create in Block 8 declares an output with no source file. loadAllPublished "posts/*" (Block 12) loads every non-draft post, and recentFirst sorts the
result newest-first by date. archiveCtx is the monoidal combination of three
contexts; Context is a left-biased monoid, so a field defined earlier shadows
the same field defined later. listField "posts" postCtx (return posts) exposes
the post list to the template’s $for(posts)$ loop with each item rendered under
postCtx; constField "title" "Archives" sets the page title; defaultContext
supplies the rest. makeItem "" creates an item with an empty body — the visible
content comes from the archive template iterating over $posts$ — and the archive
and default templates are then applied.
Block 9 compiles the home page, which differs from the archive in one respect.
match "index.html" $ do
route idRoute
compile $ do
posts <- recentFirst =<< loadAllPublished "posts/*"
let indexCtx =
listField "posts" postCtx (return posts) `mappend`
constField "title" "Home" `mappend`
defaultContext
getResourceBody
>>= applyAsTemplate indexCtx
>>= loadAndApplyTemplate "templates/default.html" indexCtx
>>= relativizeUrlsBlock 9. The home page.
Unlike the archive in Block 8, index.html exists as a source file, so Block 9
uses getResourceBody to read it and applyAsTemplate indexCtx to treat the file
itself as a template. This lets index.html contain $for(posts)$ ... $endfor$,
which is expanded against the post list before the default template wraps the
page. The context is built the same way as in Block 8, with a "Home" title.
Block 10 generates the Atom and RSS feeds.
create ["atom.xml"] $ do
route idRoute
compile $ do
posts <- fmap (take 20) . recentFirst
=<< loadAllPublishedSnapshots "posts/*" "content"
renderAtom feedConfiguration feedCtx posts
create ["rss.xml"] $ do
route idRoute
compile $ do
posts <- fmap (take 20) . recentFirst
=<< loadAllPublishedSnapshots "posts/*" "content"
renderRss feedConfiguration feedCtx postsBlock 10. The Atom and RSS feeds.
The two create rules in Block 10 are identical except for the renderer.
loadAllPublishedSnapshots "posts/*" "content" (Block 12) reloads the "content"
snapshot saved in Block 7 — the post body before templating — for every non-draft
post. recentFirst sorts the items, take 20 limits each feed to the twenty most
recent, and renderAtom / renderRss emit the feed XML using feedConfiguration
and feedCtx from Block 14.
Block 11 covers the remaining verbatim files and the template compilation.
match "404.html" $ do
route idRoute
compile copyFileCompiler
match "robots.txt" $ do
route idRoute
compile copyFileCompiler
match "templates/*" $ compile templateCompilerBlock 11. Verbatim files and template compilation.
In Block 11, 404.html and robots.txt are copied verbatim. The final rule
compiles every file in templates/ to a Template value with templateCompiler;
it has no route because templates are build inputs rather than outputs. This rule
is required for every loadAndApplyTemplate and applyAsTemplate call in the
preceding blocks to resolve.
Block 12 defines the draft-filtering helpers used by Blocks 8, 9, and 10.
isPublished :: Item a -> Compiler Bool
isPublished item = do
draft <- getMetadataField (itemIdentifier item) "draft"
return (draft /= Just "true")
loadAllPublished :: Pattern -> Compiler [Item String]
loadAllPublished pat = loadAll pat >>= filterM isPublished
loadAllPublishedSnapshots :: Pattern -> Snapshot -> Compiler [Item String]
loadAllPublishedSnapshots pat snap =
loadAllSnapshots pat snap >>= filterM isPublishedBlock 12. Draft filtering.
isPublished in Block 12 reads the draft field from an item’s YAML metadata and
returns False only when its value is exactly "true". loadAllPublished and
loadAllPublishedSnapshots wrap Hakyll’s loadAll and loadAllSnapshots,
applying isPublished with filterM — the monadic form of filter, required
because the predicate runs in the Compiler monad to read metadata. A post with
draft: true in its frontmatter is excluded from the home page, archive, and
feeds, but the match "posts/*" rule in Block 7 still compiles the individual
page, so the post remains reachable at its own URL.
Contexts
Blog.Context exports postCtx, shown in Block 13.
postCtx :: Context String
postCtx =
dateField "date" "%B %e, %Y" `mappend`
defaultContextBlock 13. The post context.
dateField "date" "%B %e, %Y" in Block 13 defines a $date$ variable formatted
as, for example, June 24, 2026. The date is parsed from the item’s metadata or,
failing that, from a date prefix in the filename (2026-06-24-...).
defaultContext is appended for the remaining fields. Because the context monoid
is left-biased, the date field defined here takes precedence over any date in
defaultContext. postCtx is used by Blocks 7, 8, and 9.
Feeds
Blog.Feed exports the feed configuration and the feed context, both in Block 14.
feedConfiguration :: FeedConfiguration
feedConfiguration = FeedConfiguration
{ feedTitle = "noprofits.org"
, feedDescription = "Blogs about science, nonprofits, and other fun stuff."
, feedAuthorName = "Peter Johnston"
, feedAuthorEmail = "peter@noprofits.org"
, feedRoot = "https://blog.noprofits.org"
}
feedCtx :: Context String
feedCtx = postCtx `mappend` bodyField "description"Block 14. Feed configuration and feed context.
FeedConfiguration in Block 14 holds the channel-level metadata for the Atom and
RSS documents. feedCtx extends postCtx (Block 13) with bodyField "description", which maps the $description$ variable required by the feed
renderers to the item body. At feed-render time that body is the "content"
snapshot from Block 7, so each feed entry’s description is the rendered post
content.
The post compiler
Blog.Compilers exports bibtexMathCompiler and wrapTables. pandocCompiler
handles the plain pages in Block 6; posts use bibtexMathCompiler, which adds
citation resolution, math, syntax highlighting, and the TikZ filter. It is
presented across Blocks 15 through 19.
Block 15 is the start of bibtexMathCompiler: loading the citation inputs.
bibtexMathCompiler :: String -> String -> Compiler (Item String)
bibtexMathCompiler cslFileName bibFileName = do
csl <- load $ fromFilePath cslFileName
bib <- load $ fromFilePath bibFileNameBlock 15. Loading the CSL style and BibTeX database.
Block 15 loads the parsed CSL style and BibTeX database produced by Block 4.
Because load runs inside the Compiler monad, this records a dependency from
each post on the bibliography and style files, so editing a reference triggers a
rebuild of the posts.
Block 16 lists the Pandoc extensions enabled for the build.
let mathExtensions =
[ Ext_tex_math_dollars
, Ext_tex_math_double_backslash
, Ext_latex_macros
, Ext_raw_tex
, Ext_raw_html
, Ext_fenced_code_blocks
, Ext_backtick_code_blocks
, Ext_fenced_code_attributes
]Block 16. The enabled Pandoc extensions.
In Block 16, Ext_tex_math_dollars and Ext_tex_math_double_backslash parse
$...$ and \(...\) math; Ext_latex_macros expands user-defined LaTeX macros;
Ext_raw_tex and Ext_raw_html pass raw markup through unchanged; and
Ext_fenced_code_blocks, Ext_backtick_code_blocks, and
Ext_fenced_code_attributes parse fenced code blocks and their attribute lists.
The attribute list is what carries the .tikzpicture class recognized by the
filter in Block 20.
Block 17 assembles the reader and writer options.
defaultExtensions = writerExtensions defaultHakyllWriterOptions
newExtensions = foldr enableExtension defaultExtensions mathExtensions
writerOptions = defaultHakyllWriterOptions
{ writerExtensions = newExtensions
, writerHTMLMathMethod = MathJax ""
, writerHighlightStyle = Just pygments
}
readerOptions = defaultHakyllReaderOptions
{ readerExtensions =
enableExtension Ext_raw_html $
enableExtension Ext_raw_tex pandocExtensions
}Block 17. Reader and writer options.
In Block 17, foldr enableExtension enables every extension from Block 16 on top
of the default writer extension set. The writer options set the HTML math method
to MathJax "" (emit math markup for client-side MathJax rendering) and the
highlight style to pygments. The reader options start from pandocExtensions
and additionally enable raw HTML and raw TeX. Reader and writer extensions are
configured separately because Pandoc reads to an abstract document and then writes
from it.
Block 18 is the compiler pipeline itself.
getResourceBody
>>= readPandocBiblio readerOptions csl bib
>>= \pandoc -> do
transformed <- walkM tikzFilter pandoc
return $ writePandocWith writerOptions (walk wrapTables transformed)Block 18. The post compiler pipeline.
The pipeline in Block 18 runs in five steps. getResourceBody reads the Markdown
source. readPandocBiblio readerOptions csl bib parses the source and resolves
citations against the CSL style and BibTeX database, producing a Pandoc AST with
formatted references and a bibliography. walkM tikzFilter traverses the AST and
applies the TikZ filter (Block 20) to every block; walkM is the monadic
traversal, required because the filter performs IO. walk wrapTables is a pure
traversal applying the function in Block 19. Finally, writePandocWith writerOptions renders the transformed AST to HTML.
Block 19 is the table-wrapping function applied in Block 18.
wrapTables :: Block -> Block
wrapTables t@Table{} = Div ("", ["table-scroll"], []) [t]
wrapTables b = bBlock 19. Wrapping tables for horizontal scrolling.
Block 19 wraps every Table block in a Div with class table-scroll and
returns all other blocks unchanged. The matching CSS makes wide tables scroll
within their own frame rather than overflowing the page.
Figure 2 shows the pipeline of Block 18 as a diagram. The CSL and BibTeX items
feed the parse step; the "content" snapshot is tapped after rendering and before
the templates, and is reloaded by the feeds.
Figure 2. The post compiler pipeline from Block 18, with the snapshot tap to the feeds.
The TikZ filter
Blog.TikZ renders fenced code blocks tagged .tikzpicture to inline SVG during
the build. It exports tikzFilter, renderTikz, inlineSvg, and namespaceIds,
presented across Blocks 20 through 27.
Block 20 is the Pandoc filter invoked from Block 18.
tikzFilter :: Block -> Compiler Block
tikzFilter (CodeBlock (_, classes, _) contents)
| "tikzpicture" `elem` classes = do
result <- unsafeCompiler $ renderTikz (T.unpack contents)
let html = case result of
Right svg -> "<div class=\"tikz-figure\">" ++ inlineSvg svg ++ "</div>"
Left err -> "<div class=\"tikz-error\"><strong>Diagram failed to render.</strong>"
++ "<pre>" ++ escapeHtml err ++ "</pre></div>"
return $ RawBlock (Format "html") (T.pack html)
tikzFilter block = return blockBlock 20. The TikZ Pandoc filter.
Block 20 matches only CodeBlock values whose class list contains tikzpicture;
all other blocks are returned unchanged by the final equation. For a matching
block, renderTikz (Blocks 21–23) is invoked through unsafeCompiler, which
lifts an IO action into Compiler — required because rendering shells out to
external processes. A Right svg result is wrapped in a div.tikz-figure after
the XML prolog is stripped by inlineSvg (Block 24); a Left err result is
wrapped in a div.tikz-error containing text escaped by escapeHtml (Block 27).
Either branch yields a RawBlock of HTML, which the writer emits verbatim. A
failed diagram therefore produces a visible error box rather than aborting the
build, and a post with no .tikzpicture blocks never invokes the external tools.
Block 21 begins renderTikz by setting up a temporary working directory.
renderTikz :: String -> IO (Either String String)
renderTikz tikzCode = withSystemTempDirectory "blog-tikz" $ \dir -> do
let texFile = dir ++ "/tikz.tex"
pdfFile = dir ++ "/tikz.pdf"
svgFile = dir ++ "/tikz.svg"Block 21. Temporary directory and file paths.
withSystemTempDirectory in Block 21 creates a directory that is removed when the
action completes, and the three let bindings name the intermediate files within
it.
Block 22 wraps the snippet and writes the .tex file.
let opensOwnPicture =
any (`isInfixOf` tikzCode) ["\\begin{tikzpicture}", "\\begin{circuitikz}"]
body
| opensOwnPicture = tikzCode
| otherwise = "\\begin{tikzpicture}\n" ++ tikzCode ++ "\n\\end{tikzpicture}"
writeFile texFile $ tikzPreamble ++ body ++ "\n\\end{document}\n"Block 22. Wrapping the snippet and writing the document.
In Block 22, a block containing \begin{tikzpicture} or \begin{circuitikz} is
used verbatim; otherwise it is treated as a picture body and wrapped in a
tikzpicture environment. The body is concatenated with tikzPreamble and the
document terminator and written to the .tex file. The preamble (a module-level
constant not shown here) selects the standalone document class and loads tikz,
pgfplots, amsmath, mhchem, and circuitikz, registers circuitikz with
\standaloneenv so schematics crop correctly, sets pgfplots compatibility, and
loads the TikZ libraries arrows.meta, patterns, patterns.meta,
backgrounds, calc, decorations.pathmorphing, decorations.markings,
matrix, and arrows.
Block 23 runs the two external tools and post-processes the result.
(texCode, texOut, texErr) <- readProcessWithExitCode "lualatex"
["-halt-on-error", "-interaction=nonstopmode", "-output-directory=" ++ dir, texFile]
""
case texCode of
ExitFailure _ -> bail "lualatex" (texOut ++ texErr)
ExitSuccess -> do
(svgCode, svgOut, svgErr) <- readProcessWithExitCode "dvisvgm"
["--pdf", "--no-fonts", "--output=" ++ svgFile, pdfFile]
""
case svgCode of
ExitFailure _ -> bail "dvisvgm" (svgOut ++ svgErr)
ExitSuccess -> do
svg <- TIO.readFile svgFile
return $! Right (T.unpack (namespaceIds (idPrefix tikzCode) svg))Block 23. Running lualatex and dvisvgm.
Block 23 compiles the document with lualatex (-halt-on-error,
-interaction=nonstopmode) to a PDF, then converts the PDF with dvisvgm
(--pdf --no-fonts) to an SVG. A non-zero exit from either tool calls bail,
which logs the tool name and combined output to stderr and returns a Left
diagnostic — the value that becomes the error box in Block 20. On success the SVG
is read strictly with TIO.readFile (before the temporary directory is removed),
passed through namespaceIds (Block 25) with a per-diagram prefix from idPrefix
(Block 26), and returned as Right.
Figure 3 shows the flow of Blocks 20–23: the source block, the two tools, the id-namespacing step, and the success and failure outputs.
Figure 3. The TikZ rendering pipeline from Blocks 20–23, with the failure branch to the error box.
Block 24 strips the SVG prolog for inline embedding.
inlineSvg :: String -> String
inlineSvg svg =
let (_, rest) = T.breakOn "<svg" (T.pack svg)
in if T.null rest then svg else T.unpack restBlock 24. Stripping the XML prolog.
inlineSvg in Block 24 discards the XML prolog and DOCTYPE that dvisvgm emits,
returning the markup from the opening <svg tag onward so it is valid to embed in
an HTML document. If <svg is not found, the input is returned unchanged.
Block 25 namespaces the SVG element ids.
namespaceIds :: T.Text -> T.Text -> T.Text
namespaceIds prefix =
rep "id=\"" ("id=\"" <> prefix)
. rep "id='" ("id='" <> prefix)
. rep "href=\"#" ("href=\"#" <> prefix)
. rep "href='#" ("href='#" <> prefix)
. rep "url(#" ("url(#" <> prefix)
where rep = T.replaceBlock 25. Namespacing element ids.
dvisvgm assigns element ids such as g3-66 and restarts the numbering for each
file, so embedding several SVGs in one HTML page causes id collisions in which
references resolve to the wrong element. namespaceIds in Block 25 prefixes every
id definition (id=", id=') and every internal reference (href="#, href='#,
url(#) with a per-diagram token, making the ids unique within the page.
Block 26 derives the prefix used in Block 25.
idPrefix :: String -> T.Text
idPrefix s = T.pack ('n' : show (foldl' step (5381 :: Integer) s))
where step acc c = (acc * 33 + fromIntegral (ord c)) `mod` 1000000007Block 26. The per-diagram id prefix.
idPrefix in Block 26 derives the token from the diagram source with a djb2 hash,
prefixed with n to form a valid identifier. The hash is deterministic, so the
output is stable across builds.
Block 27 escapes text for the error box in Block 20.
escapeHtml :: String -> String
escapeHtml = concatMap esc
where
esc '&' = "&"
esc '<' = "<"
esc '>' = ">"
esc c = [c]Block 27. HTML escaping for the error box.
escapeHtml in Block 27 escapes &, <, and >. The functions in Blocks 24,
25, and 26 are pure and are covered by the test suite from Block 1.
Templates
Templates are Hakyll templates: HTML with $variable$ interpolation, $if(...)$
conditionals, $for(...)$ loops, and $partial(...)$ includes. There are four.
templates/default.html is the site shell: the <head> (metadata, fonts,
/css/default.css, the Atom and RSS <link rel="alternate"> tags, the MathJax
script, and the theme-toggle, MathJax-config, and code-copy scripts), the header
with navigation, $body$, and the footer. An inline script in the <head>
applies the stored or system color theme before first paint.
templates/post.html is the post layout: a back link, an optional $tags$ chip,
the $title$, a meta line with $author$ and $date$, the $body$, and a
footer with a back link and a print button.
Block 28 is the archive template, which renders the heading and includes the shared list partial.
<div class="archive">
<div class="section-head archive-head">
<span class="tick"></span>
<h1>Archive</h1>
</div>
<p class="archive-sub">Everything published on NoProfits Blog, newest first.</p>
$partial("templates/post-list.html")$
</div>Block 28. templates/archive.html.
Block 28 supplies only the archive heading; the list of posts comes from the
$partial(...)$ include of the file shown in Block 29.
Block 29 is the shared list partial, used by the archive (Block 28) and by
index.html (Block 9).
<div class="post-list">
$for(posts)$
<a class="post-row" href="$url$">
<span class="post-row-date">$date$</span>
<span class="post-row-main">
<span class="post-row-title">$title$</span>
$if(description)$<span class="post-row-excerpt">$description$</span>$endif$
</span>
$if(tags)$<span class="post-row-topic">$tags$</span>$endif$
</a>
$endfor$
</div>Block 29. templates/post-list.html.
Block 29 iterates over the $posts$ list field set up in Blocks 8 and 9, emitting
one linked row per post with the $date$, $title$, optional $description$, and
optional $tags$.
Build commands
The site is built through the executable from Block 2:
stack buildcompiles the package.stack exec blog build(orstack run blog build) generates the site into_site/.stack exec blog watchrebuilds on change and serves the result locally.stack exec blog cleanremoves_site/and the build cache.stack testruns the test suite.
Rendering posts that contain .tikzpicture blocks additionally requires
lualatex and dvisvgm on PATH, as used in Block 23; posts without such blocks
build without a TeX installation.