2026-07-21
This site used to be PHP. It is now the output of a small OCaml program, and that program is shaped exactly like the thing I spend my days thinking about: a compiler. Source language in, target language out, with a typed intermediate representation in the middle.
The pipeline looks like this:
content/*.md + data/*.toml → parse → typed values → emit → public/*.html
A blog post is a file with TOML frontmatter between +++ delimiters, followed
by a markdown body:
+++
slug = "site-as-a-compiler"
title = "My Website Is Now a Compiler"
date = "2026-07-21"
+++
This site used to be PHP...
So the "frontend" has two stages. First a tiny hand-rolled splitter walks the
lines and separates frontmatter from body — the closest thing this compiler
has to a lexer. Then each half goes to a real parser: otoml for the
frontmatter, omd for the markdown.
The interesting part is what parsing produces. Every post becomes a value of one record type:
type t = {
slug : string;
title : string;
date : string; (* ISO yyyy-mm-dd; sorts correctly as a string *)
tags : string list;
excerpt : string;
body_html : string;
}
And crucially, construction can fail:
val of_file : string -> (t, string) result
A post with a missing slug is not a warning, not a null that explodes
three templates later — it is an Error value carrying the file name and the
missing field. In the PHP version, a malformed entry in the posts array meant
a broken page in production, discovered by whoever clicked the link first.
Here it means the build fails before any HTML exists.
The driver deliberately collects all errors instead of stopping at the first one — the same courtesy any decent compiler extends to its users:
let posts, post_errors = partition post_results in
Code generation is the boring end of most compilers and this one is no exception: pure functions from typed values to HTML strings. The index page function takes the site data record — bio, projects, timeline, each a typed field — and pattern matching does the rest. Add a new section to the record type and the compiler walks you through every place that must render it. Forgetting a case is a type error, not a blank spot on the page.
There is no runtime. The emitter runs once, in CI, and the server serves files. The PHP-FPM workers that used to sit in memory waiting to re-render the same bio on every request are gone; nginx and the kernel's page cache do everything.
The last pipeline stage is the deployment itself. Pushing to main triggers
CI: it builds the generator with dune, runs it, and only if that succeeds
force-pushes the output to a deploy branch that the server checks out as
its web root. A failed build never reaches the publish step, so the live site
can only ever be a state that compiled cleanly. The deploy branch history is
effectively a build log — one commit per successful release.
The whole thing fits in six modules and a few hundred lines.
The old PHP lives on in a legacy-php branch, the way all retired compilers
should: preserved, and no longer executing.
ocaml, compilers, meta