Skip to content

Instantly share code, notes, and snippets.

@shaunlebron
Created October 2, 2026 04:24
Show Gist options
  • Select an option

  • Save shaunlebron/2bc477fc8fb52a1b34923ff765a2b30a to your computer and use it in GitHub Desktop.

Select an option

Save shaunlebron/2bc477fc8fb52a1b34923ff765a2b30a to your computer and use it in GitHub Desktop.

Clojure ->css

A small alternative to the Garden CSS compiler that maps to modern CSS:

  1. It compiles to native nested rules.
  2. [:a :b :c {…}] compiles to a b c {…} not a, b, c {…}.
  3. Its grammar is flexible enough to allow arbitrary @-rules.

Sections:

Example

(->css
  [":root" {:color-scheme "dark"
            :background-color "light-dark(#fff, #000)"
            :color "light-dark(#000, #fff)"}]

  "/* NOTE: no commas are inserted between `article` and `p` like Garden does */"
  [:article :p
   [:strong {:border-bottom "2px solid #000"
             :padding-bottom "4px"}]
   ["&::first-line" {:font-size "1.2rem"
                     :font-weight :bold
                     :text-decoration "underline"}]]

  ["blockquote::before" {:content (pr-str "“")
                         :font-size "2rem"
                         :color "#aaa"}]

  ["h1, h2, h3"  ;; <-- you can do `:h1 "," :h2 "," :h3` if you really want to
    {:font-weight :bold
     :letter-spacing "0.02em"}]

  [:h2 '+ :p {:margin-top "0.5rem"
              :font-size "1.1em"}]

  [:nav :> :ul {:list-style :none
                :padding 0}]

  [:button {:background :navy
            :color :white}
   ["&:hover" {:background "#003399"}]
   ["&:disabled" {:opacity "0.5"
                  :cursor :not-allowed}]]

  [:.alert '+ :.alert {:margin-top "8px"}]

  ["a[href^=https]" {:color "#0a0"}]
  ["input[type=text]" {:border "1px solid #ccc"
                       :border-radius "4px"}]
  ["[aria-hidden=true]" {:display :none}]

  "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700');"
  "@layer reset, base, theme, utilities;"

  ["@media (max-width: 768px)"
   [:body {:margin "0 8px"}]]

  [:.foo {:display "grid"}
   ["@media (orientation: landscape)" {:grid-auto-flow :column}
    ["@media (width > 1024px)" {:max-inline-size "1024px"}]]]

  ["@layer utilities"
   [:.padding-sm {:padding "0.5rem"}]
   [:.margin-sm {:margin "0.5rem"}]]

  ["@page" {} ;; <-- empty map required for comment below to stay inside the block
   "/* margin box at top right showing page number */"
   ["@top-right" {:content "\"Page \" counter(pageNumber)"}]]

  "/* Targets all even-numbered pages */"
  ["@page :left" {:margin-top "4in"}])

Output

:root {
  color-scheme: dark;
  background-color: light-dark(#fff, #000);
  color: light-dark(#000, #fff);
}
/* NOTE: no commas are inserted between `article` and `p` like Garden does */
article p {
  strong {
    border-bottom: 2px solid #000;
    padding-bottom: 4px;
  }
  &::first-line {
    font-size: 1.2rem;
    font-weight: bold;
    text-decoration: underline;
  }
}
blockquote::before {
  content: "“";
  font-size: 2rem;
  color: #aaa;
}
h1, h2, h3 {
  font-weight: bold;
  letter-spacing: 0.02em;
}
h2 + p {
  margin-top: 0.5rem;
  font-size: 1.1em;
}
nav > ul {
  list-style: none;
  padding: 0;
}
button {
  background: navy;
  color: white;
  &:hover {
    background: #003399;
  }
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}
.alert + .alert {
  margin-top: 8px;
}
a[href^=https] {
  color: #0a0;
}
input[type=text] {
  border: 1px solid #ccc;
  border-radius: 4px;
}
[aria-hidden=true] {
  display: none;
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700');
@layer reset, base, theme, utilities;
@media (max-width: 768px) {
  body {
    margin: 0 8px;
  }
}
.foo {
  display: grid;
  @media (orientation: landscape) {
    grid-auto-flow: column;
    @media (width > 1024px) {
      max-inline-size: 1024px;
    }
  }
}
@layer utilities {
  .padding-sm {
    padding: 0.5rem;
  }
  .margin-sm {
    margin: 0.5rem;
  }
}
@page {
  /* margin box at top right showing page number */
  @top-right {
    content: "Page " counter(pageNumber);
  }
}
/* Targets all even-numbered pages */
@page :left {
  margin-top: 4in;
}

Grammar

We start by defining the CSS forms we care about:

css       = item*

item      = comment | statement | rule
comment   = "/*" any* "*/"
statement = any* ";"

rule      = prelude+ "{" attr* item* "}"
attr      = key ":" value ";"
prelude   = selector | at-rule

Then, we apply the following overrides to get our Clojure representation:

/* Hiccup-like representation */
rule      = "[" prelude+ map? item* "]"

/* These are just passed through */
comment   = string
statement = string

/* Allows any selector, combinator, at-rule, etc */
prelude   = string | keyword | symbol

Note

For templating:

  1. nil values in a rule are removed, allowing (when ...)
  2. non-vector sequences in a rule are unrolled, allowing (for ...)

Source

The code is short enough to understand and paste into your codebase. License is Zero-Clause BSD.

(defn unroll [xs]
  (mapcat #(cond
             (nil? %) nil ;; removed
             (seq? %) (unroll %)
             :else [%]) xs))

(defn ->css* [item]
  (cond
    ;; for comments or at-rule statements
    (string? item) item

    ;; for rule blocks with curly braces
    (vector? item)
    (let [rule item
          prelude? (some-fn keyword? string? symbol?)
          [preludes body] (->> rule
                               unroll
                               (split-with prelude?))]
      (when-not (seq preludes)
        (throw (ex-info "no prelude found" {:rule rule})))
      (format "%s {%s}"
              (str/join " " (map name preludes))
              (if-not (seq body)
                "\n"
                (let [[m & nested-rules] (if (map? (first body))
                                           body
                                           (cons nil body))]
                  (as-> (concat (for [[k v] m
                                      :when (and (some? k)
                                                 (some? v))]
                                  (format "%s: %s;"
                                          (name k)
                                          (if (keyword? v)
                                            (name v)
                                            (str v))))
                                (map ->css* nested-rules)) _
                        (str/join "\n" _)
                        (str/replace _ #"(?m)^" "  ") ;; indent each line
                        (str "\n" _ "\n"))))))

    :else (throw (ex-info (format "->css* expects a rule vector or string, got %s." (pr-str (type item)))
                          {:item item}))))

(defn ->css [& items]
  (->> items
       unroll
       (map ->css*)
       (str/join "\n")))

Thank you

Donate

Feedback appreciated below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment