Cascade is a CSS toolkit written in OCaml. One command-line tool, cascade,
formats and minifies stylesheets, compares two stylesheets by what they do
rather than how they are written, inlines a stylesheet into an HTML page, and
removes the rules a set of pages never uses. The OCaml library it is built on
is available too.
Cascade parses CSS into a typed syntax tree and works on that tree rather than on the text. The minifier can therefore reason about the cascade (which rule wins, and whether moving a rule changes anything) and always prints valid CSS. The diff reports changes by rule and declaration, so a refactor that only reorders or regroups rules shows up as no difference. Correctness is checked against Chromium: the test suite renders stylesheets and their minified forms and compares the pages pixel for pixel.
cascade --minify style.css > style.min.css
cascade diff --diff=canonical style.css style.min.cssThe minified output renders the same, but it is not byte-for-byte what you wrote. By default it targets maintained evergreen browsers (Chrome 111, Firefox 128, Safari 16.4), may round a colour by up to 0.002 in the Oklab colour space, and normalises spellings, including custom-property strings a script could read back. For a more conservative result, turn off the rounding and the browser assumptions:
cascade --minify --lossless --enforce-spec style.cssComments, whitespace and the exact source spelling are still not kept.
On the SatCSS corpus of real-world stylesheets, the minifier is competitive with esbuild, Lightning CSS, CSSO and cssnano on both size and speed; BENCHMARKS.md has the numbers.
With Homebrew:
brew install samoht/tap/cascadeWith opam, which also installs the cascade library (OCaml 5.2 or later):
opam install cascadecascade fmt is the default command, so cascade style.css and
cascade fmt style.css are the same. Without options it pretty-prints; with
--minify it produces the smallest CSS it can prove renders the same.
cascade style.css > style.formatted.css
cascade --minify style.css > style.min.css
cat style.css | cascade --minify -The two assumptions described at the top each have a switch:
--enforce-specstops targeting current browsers. Cascade then keeps vendor prefixes and older syntax, and relies only on what the CSS specifications guarantee.--losslessstops rounding. Colours and repeating decimals fromcalc()stay exact, and declarations keep the author's order.
By default the minifier optimises for the size after gzip compression,
because servers usually send CSS compressed. --objective=raw optimises the uncompressed size
instead, for CSS that ships uncompressed such as email HTML; it is noticeably
slower. On the SatCSS corpus, --minify takes about 2.2 ms per KB of input, so
a 200 KB stylesheet takes a few hundred milliseconds.
A few options trade safety for size, and are off by default:
--inline-importsreplaces@importwith the imported file.--import-root=DIRlimits which files it may read, which matters for untrusted CSS.--inline-varsreplacesvar(--name)with its value, assuming no script changes the variables at runtime.--keep-vars=theme,brandkeeps some of them live.--scope=stylesheettells the minifier it sees all the CSS of the page, so it can use shorthands that would otherwise reset properties set elsewhere.--closed-worldtells it that no element ever matches two conflicting selectors, which allows more merging. The page renders wrongly if one does, including an element a script adds later.
cascade fmt --help lists every option.
docs/reference.md describes exactly which
rewrites run and why each one preserves the rendering.
cascade diff parses both files and reports added, removed and changed rules
and declarations. Here a.css is .ocaml{all:unset}.ocaml{visibility:hidden}
and b.css has the same two rules in the other order, so the element ends up
visible:
cascade diff --diff=canonical a.css b.cssCSS: 54 chars vs 54 chars (0.0% diff)
Changes: 1 modified rule
--- a.css
+++ b.css
└─ .ocaml
- visibility: hidden
--diff chooses what counts as a difference:
auto, the default, compares the structure, then falls back to comparing the text so that formatting changes still show.treecompares only the structure, so formatting changes do not count.stringcompares the text.canonicalminifies both sides the same way and compares the results. Two stylesheets are the same when every element would get the same computed values, however the rules are written, grouped or ordered. Like the minifier, it assumes current browsers and allows the same rounding;--enforce-specand--losslessturn those off.
canonical is the right mode for checking that a build step, a minifier or a
refactor did not change the page. It can over-report: a difference it lists is
a candidate, and some of them paint nothing. The exact rules are in
docs/reference.md.
To settle a doubt, --browser renders a page under both stylesheets in
headless Chromium and compares the screenshots, at every viewport width and
interaction state the stylesheets mention. Where pixels differ, it lists the
computed values that changed:
cascade diff --browser --html page.html old.css new.cssThe page's own <style> elements and stylesheet links are removed first, so
each file is judged alone. This needs node and a headless Chromium (set NODE
and CHROME, or install one with npx playwright install chromium-headless-shell).
cascade diff exits with 0 when the files are the same, 1 when they differ,
and 2 when it could not decide: a browser was missing, or a file contained a
rule cascade could not read and the two files dropped different text. A script
should treat 2 as a failure.
cascade apply moves styles from a page's <style> blocks (and an optional
extra stylesheet) into each element's style attribute, as email HTML needs:
cascade apply page.html theme.css > inlined.htmlSelector matching, specificity and !important decide which declaration wins,
as in a browser. Rules that cannot be inlined, such as :hover, @media or
pseudo-elements, stay in a <style> block, along with every declaration they
compete with. For example, with
.btn { color: #fff; padding: .5rem 1rem }
.btn:hover { color: #eee }the padding moves onto the element but both colours stay in CSS, because an
inline colour would always beat the :hover rule. --minimal also drops
declarations that only repeat an inherited value.
cascade prune removes the rules that match no element of the given pages:
cascade prune --dry-run src/*.html style.css # report what would go
cascade prune src/*.html style.css > style.pruned.cssA rule is removed only when cascade can evaluate its selector and no element
matches it. Selectors it cannot evaluate statically (:hover, pseudo-elements,
:lang()) are kept, and so are @keyframes, @font-face and the like.
@media conditions are not evaluated.
Pruning only sees the pages you give it. A rule for a class that a script adds at runtime will be removed, so check what your scripts add before shipping a pruned stylesheet.
A typical setup minifies during the build and uses diff as a check:
# build
cascade --minify src/style.css > dist/style.min.css
# CI: fail if the committed file is not formatted
cascade src/style.css > formatted.css
cascade diff --diff=tree src/style.css formatted.css
# before committing: fail if a change alters what the CSS does
git show HEAD:src/style.css > previous.css
cascade diff --diff=canonical previous.css src/style.cssThe command-line tool is a thin wrapper around the cascade library. CSS is
built from typed values, so an invalid property or value is a compile error:
# open Cascade.Css;;
# let button =
rule ~selector:(Selector.class_ "btn")
[ display Inline_block
; background_color (hex "#3b82f6")
; color (hex "#ffffff")
; padding [ Rem 0.5 ]
; border_radius (radius (Rem 0.375))
]
in to_string (v [ button ]);;
- : string =
".btn {\n display: inline-block;\n background-color: #3b82f6;\n color: #ffffff;\n padding: .5rem;\n border-radius: .375rem;\n}"Css.of_string parses a stylesheet, Css.optimize minifies it, and
Css.inline_imports and Css.resolve_theme do what the matching command-line
options do. The diff lives in the cascade.diff sub-library. The core library
depends only on uri, psq, logs and mtime, and compiles to JavaScript
with js_of_ocaml in under 200 KiB compressed.
docs/reference.md covers parsing modes,
source positions and theme resolution.
- Cascade reads UTF-8 only. Other encodings must be converted first.
- It has no layout engine or DOM. Analyses that depend on the page, such as
applyandprune, work from the HTML they are given. - Comments and source positions are not kept through formatting. Library
callers who need them can parse with
~preserve_source:true. - The values of custom properties are mostly left as written, since their
meaning depends on where they are used. Complete colour values, and
constant
calc()expressions that reduce to an angle or a time, are still shortened.
The CSS modules cascade supports are listed in docs/reference.md.
dune build
dune test # includes the examples in this README
dune fmtThe browser tests need a headless Chromium; without one they are skipped. The test suite also replays the Web Platform Tests for CSS syntax, the Lightning CSS minifier tests with the answers of five other minifiers, and css-minify-tests. docs/reference.md explains each corpus and how to regenerate it. Every user-visible change gets an entry in CHANGES.md.
Lightning CSS,
esbuild,
CSSO, cssnano
and clean-css are the minifiers
cascade is tested against. The rule-merging approach follows
Hague, Lin and Hong (2018), who treat
merging as a graph problem over the cascade's ordering constraints.
Visscher, Punt and Zaytsev (2016)
catalogue patterns where a property is set, overridden and restored, which
make good test input. CILLA finds unused
selectors by running the page, which catches the runtime classes that
cascade prune cannot see.
ISC. See LICENSE.