You can skip this.

The site you're reading is built by a static site generator I wrote in Haskell. The previous one was MoonBit, and before that SvelteKit, and before that Gatsby, and before that jQuery. This is version five.

The generator is a small Cabal project with five library modules and one executable. It reads Markdown from pages/, turns each file into a complete HTML page, and writes the result to dist/. After that, two build steps add syntax highlighting and compile the CSS. There is no database and no server-side rendering.

The build starts with four inputs

The generator expects this directory structure:

text
config.toml          site title and base URL
layout/default.html  shared HTML shell
pages/*.md           one file per post
dist/                generated output

app/Main.hs is the entry point. It loads the config and layout, finds every Markdown file in pages/, and uses the filename without its extension as the post's slug. pages/haskell-rewrite.md, for example, becomes dist/haskell-rewrite.html.

All file reads and writes are explicitly UTF-8. The generator reads bytes with ByteString, decodes them with decodeUtf8', and reports the filename if decoding fails. That keeps the build independent of the machine's locale, which matters in minimal build containers where the default locale may be C.

A post becomes structured content

Each post starts with TOML front matter followed by Markdown:

toml
---
title = "Post title"
date = "2026-02-15"
---

The post starts here.

Blog.Content separates those two parts. It removes an optional byte-order mark, recognizes either Unix or Windows line endings on the opening fence, and sends the TOML block to toml-reader. The result is a Content value containing a map of metadata and the untouched Markdown body:

haskell
data Content = Content
  { contentFrontMatter :: Map Text Text
  , contentBody        :: Text
  }

The title and date are later pulled from that map for the home page. A file without a front-matter fence is still accepted as a body-only page, although it will have no title or date in the index.

Site-wide configuration follows the same pattern. Blog.Config decodes config.toml into a typed value:

haskell
data Config = Config
  { configBaseURL :: Text
  , configTitle   :: Text
  }

Bad TOML, invalid UTF-8, a missing pages/ directory, or a rendering error stops the build with the path and an explanation.

CommonMark turns the body into HTML

Blog.Markdown passes the body to the Haskell commonmark package. The parser uses the default CommonMark syntax plus footnotes, strikethrough, and fancy lists:

haskell
spec = footnoteSpec
    <> strikethroughSpec
    <> fancyListSpec
    <> defaultSyntaxSpec

The result is an HTML fragment, not a full document. Keeping Markdown rendering in its own module means the rest of the generator only works with Text; it does not need to know anything about Markdown's syntax tree.

The module also exposes a small HTML-escaping function. The Markdown renderer already escapes post content, but the generator uses this separate function for titles and dates inserted into the index it builds itself.

Mustache supplies the page shell

Blog.Template compiles layout/default.html with the mustache package. The template receives three values:

text
site_title  the configured site name
base_url    the configured base URL
content     the rendered HTML fragment

The layout owns the document structure, navigation, fonts, stylesheet link, and the centered content column. It inserts content with triple braces because that value is already-rendered HTML:

html
<main class="max-w-[42rem] mx-auto px-5 py-12 prose">
  {{{content}}}
</main>

For an individual post, Blog.Build is mostly composition: render the Markdown body, then render the shared template around it.

The index is generated from the same posts

While Main builds each page, it also converts its slug, title, and date into a small Post value. Once every file has been processed, Blog.Build sorts those values by descending date and produces an HTML list. That list goes through the same Mustache layout and becomes dist/index.html.

Dates are stored as YYYY-MM-DD text, so lexicographic order is chronological order. The index links directly to each flat output file:

html
<li>
  <a href="haskell-rewrite.html">The blog generates itself from Haskell now</a>
  <time>2026-08-30</time>
</li>

HTML is only the first build stage

The complete build is three commands collected in build.sh:

bash
cabal run -v0 ssg
bun run highlight:code
bun run build:tailwind

The Haskell executable creates the HTML first. A JavaScript script then scans every file in dist/ for the <pre><code class="language-x"> blocks emitted by CommonMark and replaces them with Shiki-highlighted markup. Unknown languages fall back to plain text instead of failing the build.

Finally, Tailwind reads the classes used by the layout, generated pages, and custom prose styles and writes dist/style.css.

The finished site is just index.html, one HTML file per post, and a stylesheet. The generator has already done all of its work by the time a browser requests the page.