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:
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:
---
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:
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:
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:
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:
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:
<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:
<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:
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.