gg_cn
A pure-Gleam tailwind-merge — resolves conflicting Tailwind CSS utility
classes by keeping the last one per conflict group, so px-2 px-4 collapses to
px-4. Ported from cnfast’s logic engine
(itself a faithful reimplementation of tailwind-merge v4).
It is pure Gleam, no FFI, so it compiles and behaves identically on both the JavaScript and Erlang targets.
Backs
gg_ui’scn.gg_base_ui/helpers/cn(used bygg_uiand the components it ships) isclsx + tailwind-mergebuilt ongg_cn, so a consumer’sclassoverride resolves against a component’s raw structural utilities.gg_cnitself is framework-agnostic (no Lustre dependency) and can also be used standalone anywhere you mix raw Tailwind and need conflict resolution.
Usage
import gg_cn
pub fn main() {
// Build the merger once (it compiles regexes + builds the class trie) and
// reuse it across calls.
let merge = gg_cn.new()
gg_cn.tw_merge(merge, "px-2 py-1 px-4")
// -> "py-1 px-4"
// clsx-style conditional joining + merging in one (`cn` = clsx + twMerge):
gg_cn.cn(merge, [
gg_cn.Class("px-2 py-1"),
gg_cn.When(is_active, "px-4"),
gg_cn.When(has_error, "text-red-500"),
])
// -> "py-1 px-4 text-red-500" (when both conditions hold)
// Just the join step (clsx / twJoin), no conflict resolution:
gg_cn.tw_join([gg_cn.Class("text-white"), gg_cn.Class("bg-black")])
// -> "text-white bg-black"
}
new() builds the class trie once — moderately expensive, so bind it once and
reuse it on hot paths rather than rebuilding per merge. For render-time use,
prefer gg_cn.default() — a process-global Merger built once (persistent_term
on the BEAM, a singleton on JS). The configuration is the baked-in Tailwind v4
default; it is not (yet) configurable.
tw_merge also memoizes by input string: a whole-string result LRU (size 500,
two-generation), JS only — that’s where the same class strings get merged
repeatedly (a Lustre view re-running). On the BEAM it recomputes (SSR renders
once). Because the cache only changes speed, JS-cached and BEAM-uncached emit
identical bytes, so it stays dual-target-safe.
What was ported, and what was not
Ported: the full clsx/twJoin join, the class-name parser, modifier sorting,
the class-group trie + conflict tables, the complete Tailwind v4 default config
(theme scales, ~300 class groups, conflict maps), and a whole-string result LRU
(JS only — see above). Behaviour is verified against the upstream
tailwind-merge parity suite (see test/).
Not ported (out of scope): cnfast’s cnfast migrate CLI and its remaining
V8-specific micro-optimizations (the tagged-template identity cache, integer
interning of conflict keys, monomorphic-shape factories). Those are JS-engine
tuning with no meaning in a pure dual-target Gleam library; the merge result is
identical without them.
Development
gleam test # Erlang target
gleam test --target javascript # JS target
gleam format src test