← All posts
haskell, hakyll, static site generator, pandoc, functional programming, build systems, tikz

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 of match and create declarations.
  • match — selects existing source files by a glob Pattern ("posts/*").
  • create — declares output files that have no source (such as archive.html).
  • route — maps a source identifier to an output path. idRoute keeps 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 with saveSnapshot and reloaded with loadSnapshot / 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 siteRules

Block 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 biblioCompiler

Block 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 compressCssCompiler

Block 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
        >>= relativizeUrls

Block 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
        >>= relativizeUrls

Block 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
            >>= relativizeUrls

Block 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
            >>= relativizeUrls

Block 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 posts

Block 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 templateCompiler

Block 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 isPublished

Block 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`
  defaultContext

Block 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 bibFileName

Block 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         = b

Block 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 block

Block 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 rest

Block 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.replace

Block 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` 1000000007

Block 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 '&' = "&amp;"
    esc '<' = "&lt;"
    esc '>' = "&gt;"
    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 build compiles the package.
  • stack exec blog build (or stack run blog build) generates the site into _site/.
  • stack exec blog watch rebuilds on change and serves the result locally.
  • stack exec blog clean removes _site/ and the build cache.
  • stack test runs 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.

← All posts