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’s cn. gg_base_ui/helpers/cn (used by gg_ui and the components it ships) is clsx + tailwind-merge built on gg_cn, so a consumer’s class override resolves against a component’s raw structural utilities. gg_cn itself 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
Search Document