I have rewritten my website in OCaml more times than I can count
(from ocaml-cow to
Canopy to custom
MirageOS unikernels). This time,
the styling was the problem: I use
Tailwind CSS, and I wanted the entire
pipeline (Markdown to styled HTML to CSS) to be a single dune build
with no Node.js dependency.
That meant porting Tailwind's CSS generation to OCaml. To make sure
the port was correct, I needed to compare its CSS output against the
JavaScript original. The existing CSS diff tools are either abandoned or limited to CSS
2/3, and none of them handle @layer, container queries, nesting, or
the modern colour spaces that Tailwind v4 generates. So I started writing one. Which needed a
complete parser. Which grew into a typed AST and an optimiser, and ended up being
useful on its own.
The result is Cascade, a
30,000-line OCaml library for parsing, generating, optimising, and
diffing CSS. One of the tools it ships is cssdiff, a structural
CSS comparison tool. Since Cascade is pure OCaml, it can be compiled to
JavaScript via js_of_ocaml and
run in the browser. Try it, or pick one of the examples below:
The toolkit
Cascade parses CSS strings into a typed AST, and provides a small eDSL
to build the same AST from OCaml code. From that AST, it can render
(pretty-print or minify), optimise (deduplicate and merge rules), and
structurally diff two stylesheets. The parser uses hand-written recursive descent
(does Claude have hands?) covering
CSS Syntax Level 3 through
Level 4 and 5 (modern
selectors, colour spaces, @layer, container queries, nesting).
let css = Cascade.Css.of_string {|
.btn {
display: inline-block;
background-color: #3b82f6;
color: white;
padding: 0.5rem;
}
.btn {
background-color: #2563eb;
}
|}
Parse. The input has a duplicate .btn rule (common in generated
CSS). The parser preserves both and returns a typed AST.
open Cascade.Css
let btn = Selector.class_ "btn"
let rules =
[ rule ~selector:btn
[ display Inline_block
; background_color (hex "#3b82f6")
; color (hex "#ffffff")
; padding (Rem 0.5) ]
; rule ~selector:btn
[ background_color (hex "#2563eb") ] ]
Generate. The same two rules, built from OCaml values. A typo like
dsiplay is a compile error. Passing a colour to padding is a type
error.
.btn {
display: inline-block;
background-color: #3b82f6;
color: #fff;
padding: 0.5rem;
}
.btn {
background-color: #2563eb;
}
Print. Css.to_string (Css.v rules) renders the AST back to CSS.
Both rules are preserved. The printer shortens white to #fff.
.btn {
display: inline-block;
background-color: #2563eb;
color: #fff;
padding: 0.5rem;
}
Optimise. Css.to_string ~optimize:true (Css.v rules) merges the
two .btn rules, keeping the last background-color (cascade order).
It respects !important and preserves intentional patterns like
content fallbacks.
The .btn example uses a few properties, but the library has 100+
typed constructors covering layout (box model, flexbox, grid), visual
(typography, transforms, animations, filters), and logical properties.
Testing against Tailwind
How do you know a CSS parser is correct? The usual answer is "read the spec", and the W3C test suite covers parsing for individual features. But those tests check that browsers apply CSS correctly, not that a tool can safely transform it. How do you know that merging two rules across cascade layers or deduplicating a property does not change what the page looks like?
One approach that I found works well in practice is differential comparison against a reference implementation. For instance, to port Tailwind to OCaml, I run the JavaScript Tailwind CLI on the same input, then run the OCaml port, and compare the two CSS outputs byte for byte. The comparison is strict: same characters, same order, same whitespace. Not "structurally equivalent" (that would hide bugs in the comparison tool itself).
When the outputs diverge (and they do, constantly, during
development), I need to know which rule changed and how. A
50,000-line string diff is unreadable. That is what cssdiff is
for: a structural diff that says "rule .mt-4 changed margin-top
from 1rem to 16px" is actionable. It works at the AST level, so
a rule that moved from line 10 to line 200 shows as reordered, not as
a deletion plus an addition. It handles @media, @layer,
@supports, and @container blocks. This is the same tool running
in the demo above.
The byte-for-byte target also keeps the optimiser honest. It would be easy to produce "correct" CSS that differs in harmless ways (different property order, different hex capitalisation). But allowing those differences means the diff tool would need to know which differences are harmless, which is exactly the kind of subtle bug that hides semantic errors. Matching the reference output exactly eliminates that class of problems.
Getting started
Two CLI tools ship with the library: cascade (format, minify,
optimise CSS files) and cssdiff (structural comparison).
brew install samoht/tap/cascade
Or via opam:
opam pin add cascade https://github.com/samoht/cascade.git
The library requires OCaml 4.14 or later. The README has the full API overview and CSS specification coverage table.
I wrote Cascade because I needed it for my blog. It turned out to be useful beyond that: any OCaml project that generates, parses, or compares CSS now has a typed alternative to string manipulation. The source is on GitHub.
