My Website Is Now a Compiler

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

The source language

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 IR: make bad states unrepresentable

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

The emitter

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 build gate

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

← Blog