styled-ppx needs two things from your build:
preprocess field — this validates your CSS and embeds the extracted rules in the compiled files.rule running styled-ppx.generate — this collects the extracted CSS from your whole project and writes styles.css.This page shows the full dune configuration for both Melange and native projects. If you want to understand why the build is shaped this way, read How it works first.
Every library that uses [%css] / [%styled.<tag>] needs styled-ppx in its pps and styled-ppx.melange in its libraries:
(library
(name ui)
(modes melange)
(libraries styled-ppx.melange reason-react)
(preprocess
(pps styled-ppx melange.ppx reason-react-ppx)))
And the same on your melange.emit stanza:
(melange.emit
(target app)
(libraries ui styled-ppx.melange reason-react)
(preprocess
(pps styled-ppx melange.ppx reason-react-ppx))
(module_systems
(commonjs bs.js)))
The ppx leaves the extracted CSS inside the post-ppx files (*.pp.ml) that dune produces in its build tree. Add a rule that feeds them to styled-ppx.generate:
(rule
(mode promote)
(target styles.css)
(deps
(glob_files_rec *.pp.ml))
(action
(run styled-ppx.generate --output %{target} --log info %{deps})))
(glob_files_rec *.pp.ml) picks up every preprocessed file below the rule’s directory — place the rule at (or above) the root of the code that uses styled-ppx, or point the glob at it (e.g. (glob_files_rec lib/*.pp.ml)).(mode promote) copies styles.css back into your source tree so your dev server / bundler can serve it. Drop it if you consume the file straight from _build.--log info prints the output path on every run; use --log error (the default) for silence.As a reference, the demo in the repository promotes the stylesheet into a public/ directory next to the bundled JS:
(subdir
public
(rule
(mode promote)
(target styles.css)
(deps
(glob_files_rec ../client.re.pp.ml))
(action
(run styled-ppx.generate --output %{target} --log info %{deps}))))
styles.css is a plain static asset. Reference it from your HTML:
<link rel="stylesheet" href="/styles.css" />
or import it through your bundler if that’s how you ship CSS.
Native libraries use the styled-ppx.native runtime and pass the --native flag to the ppx so the generated components target server-reason-react:
(library
(name ui_native)
(libraries
styled-ppx.native
server-reason-react.react
server-reason-react.reactDom)
(preprocess
(pps
styled-ppx
--native
server-reason-react.ppx
server-reason-react.melange_ppx)))
The stylesheet rule is identical — styled-ppx.generate doesn’t care which runtime produced the files:
(rule
(mode promote)
(target styles.css)
(deps
(glob_files_rec *.pp.ml))
(action
(run styled-ppx.generate --output %{target} --log info %{deps})))
Serve the file from your server and link it in the document <head> you render:
<head>
<link rel="stylesheet" href="/styles.css" />
</head>
Sharing styles between client and server? If the same source files are built for both Melange and native (shared UI code), you only need one generate rule: the extracted CSS is identical for both targets.
(pps styled-ppx <flags>))| Flag | Effect |
|---|---|
--native | Generate code for server-reason-react (native/SSR) instead of Melange |
--minify | Minify the generated CSS and drop the readable -<label> suffix from class names (css-1tyndxa instead of css-1tyndxa-layout) |
--dev | Add greppable marker classes (cx-<binding>) to classNames in development; no effect on the extracted CSS |
--debug | Verbose ppx logging |
styled-ppx.generate flags| Flag | Effect |
|---|---|
-o, --output <file> | Write the stylesheet to <file> (default: stdout) |
--log error|warning|info|debug | Diagnostic verbosity (default: error). info prints the output file; debug prints the entire stylesheet |
--debug | Shorthand for --log debug |
--minify | Strip inter-rule newlines from the output |
The ppx --minify and the aggregator --minify are different flags: the
first shortens class names and minifies rule bodies at extraction time, the
second only removes newlines between rules in the final file. For production
you typically want both.
For a release build you want three things: minified class names (ppx --minify), a minified stylesheet (aggregator --minify), and --dev off. Minification never changes the hashes — css-1tyndxa-layout in dev and css-1tyndxa in prod are the same atom, so a class seen in production can be looked up in a dev build.
Dune has no conditional pps flags (variables aren’t allowed in pps arguments), so the supported pattern is duplicating the stanza under mutually-exclusive enabled_if guards:
(library
(name ui)
(modes melange)
(libraries styled-ppx.melange reason-react)
(preprocess
(pps styled-ppx melange.ppx reason-react-ppx))
(enabled_if (= %{profile} dev)))
(library
(name ui)
(modes melange)
(libraries styled-ppx.melange reason-react)
(preprocess
(pps styled-ppx --minify melange.ppx reason-react-ppx))
(enabled_if (= %{profile} release)))
and the release stylesheet rule:
(action
(run styled-ppx.generate --minify --output %{target} %{deps}))
A few more production notes:
(module_systems (es6 mjs)) on your melange.emit stanza: bundlers can only tree-shake ES modules, and with CommonJS output the entire runtime (including the large typed-values module) ships un-shaken.styles.css only changes when styles change; serve it with long-lived cache headers behind a content-hashed filename (your bundler’s asset pipeline, or a small dune rule renaming it by checksum). It can also be <link rel="preload" as="style">-ed — its content doesn’t depend on which components render.[%styled.global]-supplied :root values render inline in the document, not in the file.(mode promote) artifacts checked into the repo.The generated styles.css is empty. The deps glob didn’t match any post-ppx files. Check that the rule sits at (or points to) a directory that contains libraries preprocessed with styled-ppx, and that the glob matches (*.pp.ml; Reason sources produce <name>.re.pp.ml).
Unbound value M.x at compile time. You referenced $(M.x) in a selector but the binding doesn’t exist (or isn’t visible). The compiler reports this before the aggregator even runs, pointing at your source location.
styled-ppx.generate exits 1 with File "...", line N. A cross-module selector reference couldn’t be resolved. The message distinguishes the two causes, pointing at your original source location (in the OCaml compiler’s format, so editors pick it up):
Error: cross-module [%css] selector reference `A.missing` does not resolve.
The target binding is missing from module `A`, or the binding is not
a [%css] expression. Define `A.missing` with [%css "..."], or remove the
reference.
Either the binding doesn’t exist, or its file wasn’t passed to the rule — extend the deps glob. And:
Error: cross-library [%css] selector references are not supported.
The reference `Foreign.thing` resolves to module `Foreign` which is not
part of the current library. Move the [%css] binding into the current
library, or inline the class chain literally.
Selector references can’t cross dune library boundaries — co-locate the coupled styles in one library.
Styles apply in the wrong order. All atomic classes have equal specificity, so the cascade tiebreaker is stylesheet order — and the aggregator preserves your source order. If an override doesn’t stick, make sure the overriding declaration is defined after the base one in your source. Note that with atomic CSS the order of class names on an element does not matter, only the order of rules in styles.css.