├── source_maps.jpg ├── release.edn ├── .gitignore ├── shadow-cljs.edn ├── CHANGELOG.md ├── src └── uix │ ├── css.cljs │ ├── css │ ├── adapter │ │ ├── reagent.cljs │ │ └── uix.cljc │ └── lib.cljc │ └── css.clj ├── package.json ├── deps.edn ├── .github └── workflows │ └── test.yml ├── dev └── release.clj ├── pom.xml ├── test ├── core_test_sample.cljc └── core_test.clj ├── logo.svg └── README.md /source_maps.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roman01la/uix.css/master/source_maps.jpg -------------------------------------------------------------------------------- /release.edn: -------------------------------------------------------------------------------- 1 | {:group-id "com.github.roman01la" 2 | :artifact-id "uix.css" 3 | :version "0.4.0" 4 | :scm-url "https://github.com/roman01la/uix.css"} 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | server_render_test 2 | node_modules 3 | *.iml 4 | .idea 5 | .cpcache 6 | .shadow-cljs 7 | .nrepl-port 8 | .styles 9 | public 10 | target 11 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:deps true 2 | :builds {:test 3 | {:target :node-script 4 | :output-dir "out" 5 | :output-to "out/test.js" 6 | :main core-test-sample/-main 7 | :build-hooks [(uix.css/hook)] 8 | :modules {:test {:entries [core-test-sample]}}}}} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4.0 4 | 5 | - Added support for SSR on JVM 6 | 7 | ## 0.3.1 8 | 9 | - Added Reagent adapter 10 | - Added output-dir to build config 11 | 12 | ## 0.3.0 13 | 14 | - Support code splitting (shadow's modules) 15 | 16 | ## 0.2.1 17 | 18 | - Static styles are sorted by location in code 19 | - Fixed pseudo-selectors and media queries in global styles 20 | 21 | ## 0.2.0 22 | 23 | - Support styles composition 24 | - Global styles 25 | 26 | ## 0.1.0 27 | 28 | Initial release 29 | -------------------------------------------------------------------------------- /src/uix/css.cljs: -------------------------------------------------------------------------------- 1 | (ns uix.css 2 | (:require-macros [uix.css]) 3 | (:require [uix.css.lib] 4 | [goog.style]) 5 | (:import [goog.html SafeStyleSheet] 6 | [goog.string Const])) 7 | 8 | (defn load-stylesheet [path] 9 | (js/Promise.resolve 10 | (when-not (js/document.querySelector (str "link[href*='" path "']")) 11 | (let [el (js/document.createElement "link")] 12 | (set! (.-rel el) "stylesheet") 13 | (set! (.-href el) path) 14 | (js/document.head.append el))))) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uix.css", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/roman01la/uix.css.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/roman01la/uix.css/issues" 21 | }, 22 | "homepage": "https://github.com/roman01la/uix.css#readme", 23 | "dependencies": { 24 | "process": "^0.11.10", 25 | "react": "^19.2.1", 26 | "react-dom": "^19.2.1", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :aliases {:dev {:extra-paths ["dev" "test"] 3 | :extra-deps {org.clojure/clojure {:mvn/version "1.12.0"} 4 | org.clojure/clojurescript {:mvn/version "1.11.60"} 5 | thheller/shadow-cljs {:mvn/version "2.25.8"} 6 | clj-diffmatchpatch/clj-diffmatchpatch {:mvn/version "0.0.9.3"} 7 | reagent/reagent {:mvn/version "1.3.0"} 8 | com.pitch/uix.core {:mvn/version "1.4.8"} 9 | com.pitch/uix.dom {:mvn/version "1.4.8"}}} 10 | :release {:extra-paths ["dev"] 11 | :extra-deps {appliedscience/deps-library {:mvn/version "0.3.4"} 12 | org.apache.maven/maven-model {:mvn/version "3.6.3"}} 13 | :main-opts ["-m" "release"]}}} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | name: ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | os: [ubuntu-latest] 19 | 20 | steps: 21 | - name: Install dependencies 22 | run: | 23 | sudo apt-get update 24 | sudo apt-get install -y curl nodejs npm 25 | curl -s "https://get.sdkman.io" | bash 26 | source "/home/runner/.sdkman/bin/sdkman-init.sh" 27 | sdk install java 25.0.1-graalce 28 | curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh 29 | chmod +x linux-install.sh 30 | sudo ./linux-install.sh 31 | 32 | - name: Run tests 33 | run: | 34 | clojure -A:dev -M -m core-test 35 | -------------------------------------------------------------------------------- /dev/release.clj: -------------------------------------------------------------------------------- 1 | (ns release 2 | (:require [clojure.java.io :as io] 3 | [deps-library.release :as dl.release]) 4 | (:import (org.apache.maven.model License) 5 | (org.apache.maven.model.io.xpp3 MavenXpp3Reader MavenXpp3Writer))) 6 | 7 | (defn add-license [] 8 | (let [pom-reader (MavenXpp3Reader.) 9 | pom-writer (MavenXpp3Writer.) 10 | file (io/file "pom.xml") 11 | model (->> (io/reader file) 12 | (.read pom-reader)) 13 | license (License.)] 14 | (.setName license "Eclipse Public License - Version 2.0") 15 | (.setUrl license "https://www.eclipse.org/legal/epl-2.0/") 16 | (.addLicense model license) 17 | (.write pom-writer (io/writer file) model))) 18 | 19 | (alter-var-root #'dl.release/pom 20 | (fn [f] 21 | (fn [& args] 22 | (let [ret (apply f args)] 23 | (add-license) 24 | ret)))) 25 | 26 | (defn -main [& args] 27 | (apply dl.release/main args)) 28 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | com.github.roman01la 6 | uix.css 7 | 0.4.0 8 | uix.css 9 | 10 | 11 | Eclipse Public License - Version 2.0 12 | https://www.eclipse.org/legal/epl-2.0/ 13 | 14 | 15 | 16 | scm:git:git@github.com:roman01la/uix.css.git 17 | scm:git:git@github.com:roman01la/uix.css.git 18 | 28c4754759657c116a189b69ee14477ef27a4ff1 19 | https://github.com/roman01la/uix.css 20 | 21 | 22 | src 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/uix/css/adapter/reagent.cljs: -------------------------------------------------------------------------------- 1 | (ns uix.css.adapter.reagent 2 | (:require [reagent.impl.template :as tmpl])) 3 | 4 | (defonce make-element tmpl/make-element) 5 | 6 | (set! tmpl/make-element 7 | (fn [this argv component jsprops first-child] 8 | (when (js/Array.isArray (.-style jsprops)) 9 | (let [class-names (atom []) 10 | inline-styles (atom {})] 11 | (doseq [style (.-style jsprops)] 12 | (if (:uixCss style) 13 | (let [{:keys [class vars]} (:uixCss style)] 14 | (swap! class-names conj class) 15 | (swap! inline-styles into vars)) 16 | (swap! inline-styles into style))) 17 | (set! (.-className jsprops) 18 | (reagent.core/class-names (.-className jsprops) @class-names)) 19 | (if (empty? @inline-styles) 20 | (js-delete jsprops "style") 21 | (set! (.-style jsprops) 22 | (tmpl/convert-props @inline-styles #js {}))))) 23 | (make-element this argv component jsprops first-child))) 24 | -------------------------------------------------------------------------------- /test/core_test_sample.cljc: -------------------------------------------------------------------------------- 1 | (ns core-test-sample 2 | (:require [uix.core :refer [defui $]] 3 | [uix.dom.server :as dom.server] 4 | [uix.css :refer [css]] 5 | [uix.css.adapter.uix] 6 | #?(:cljs [fs]))) 7 | 8 | (def border-color "#000") 9 | (def hover-bg "yellow") 10 | (def v (atom 90)) 11 | 12 | (def styles 13 | (let [p-xl 32] 14 | (css {:margin 64 15 | :padding (inc p-xl) 16 | :border (str "1px solid " border-color) 17 | :&:hover {:color :blue 18 | :background hover-bg 19 | :width @v} 20 | "&:hover > div" {:border-radius p-xl} 21 | "@media (max-width: 800px)" {:color hover-bg 22 | :width (+ 8 9) 23 | :&:hover {:color hover-bg 24 | :width (+ @v 89)}}}))) 25 | 26 | (defui component [] 27 | ($ :div {:style styles})) 28 | 29 | #?(:cljs 30 | (defn -main [& args] 31 | (let [html (dom.server/render-to-string ($ component)) 32 | path "server_render_test/html/test.html"] 33 | (fs/writeFileSync path html)) 34 | (let [html (dom.server/render-to-static-markup ($ component)) 35 | path "server_render_test/markup/test.html"] 36 | (fs/writeFileSync path html)))) -------------------------------------------------------------------------------- /src/uix/css/lib.cljc: -------------------------------------------------------------------------------- 1 | (ns uix.css.lib 2 | (:require [clojure.string :as str])) 3 | 4 | (def unitless-prop 5 | #{:animation-iteration-count 6 | :border-image-outset 7 | :border-image-slice 8 | :border-image-width 9 | :box-flex 10 | :box-flex-group 11 | :box-ordinal-group 12 | :column-count 13 | :columns 14 | :flex 15 | :flex-grow 16 | :flex-positive 17 | :flex-shrink 18 | :flex-negative 19 | :flex-order 20 | :grid-area 21 | :grid-row 22 | :grid-row-end 23 | :grid-row-span 24 | :grid-row-start 25 | :grid-column 26 | :grid-column-end 27 | :grid-column-span 28 | :grid-column-start 29 | :font-weight 30 | :line-clamp 31 | :line-height 32 | :opacity 33 | :order 34 | :orphans 35 | :tab-size 36 | :widows 37 | :z-index 38 | :zoom 39 | ;; SVG-related properties 40 | :fill-opacity 41 | :flood-opacity 42 | :stop-opacity 43 | :stroke-dasharray 44 | :stroke-dashoffset 45 | :stroke-miterlimit 46 | :stroke-opacity 47 | :stroke-width}) 48 | 49 | (defn interpret-value [k v] 50 | (if (and (number? v) (not (unitless-prop k))) 51 | (str v "px") 52 | v)) 53 | 54 | #?(:clj 55 | (do 56 | (declare class-names) 57 | 58 | (defn class-names-coll [classes] 59 | (let [classes (reduce (fn [a c] 60 | (if c 61 | (->> (if (keyword? c) (name c) (class-names c)) 62 | (conj a)) 63 | a)) 64 | [] 65 | classes)] 66 | (when (pos? (count classes)) 67 | (str/join " " classes)))) 68 | 69 | (defn class-names 70 | "Merges a collection of class names into a string" 71 | ([a] 72 | (cond 73 | (coll? a) (class-names-coll a) 74 | (keyword? a) (name a) 75 | :else a)) 76 | ([a b] 77 | (if a 78 | (if b 79 | (str (class-names a) " " (class-names b)) 80 | (class-names a)) 81 | (class-names b))) 82 | ([a b & rst] 83 | (reduce class-names (class-names a b) rst))))) -------------------------------------------------------------------------------- /src/uix/css/adapter/uix.cljc: -------------------------------------------------------------------------------- 1 | (ns uix.css.adapter.uix 2 | (:require #?@(:cljs [[uix.compiler.alpha] 3 | [uix.compiler.attributes :as uix.attrs]]) 4 | #?@(:clj [[uix.dom.server] 5 | [uix.css.lib]]))) 6 | 7 | ;; uix.css adapter 8 | ;; 1. Intercepts element creation 9 | ;; 2. Extracts `uixCssClass` and `uixCssVars` fields from `props.style` 10 | ;; 3. Merges generated class name with the rest of class names in `props.className` field 11 | ;; 4. Merges generated CSS Vars map with the rest of styles in `props.style` object 12 | 13 | #?(:cljs 14 | (defonce create-element uix.compiler.alpha/create-element)) 15 | 16 | #?(:cljs 17 | (set! uix.compiler.alpha/create-element 18 | (fn [args children] 19 | (when-let [^js props (aget args 1)] 20 | (when (and (uix.compiler.alpha/pojo? props) 21 | (js/Array.isArray (.-style props))) 22 | (let [class-names #js [] 23 | inline-styles (atom {})] 24 | (doseq [style (.-style props)] 25 | (if (:uixCss style) 26 | (let [{:keys [class vars]} (:uixCss style)] 27 | (.push class-names class) 28 | (swap! inline-styles into vars)) 29 | (swap! inline-styles into style))) 30 | (set! (.-className props) 31 | (uix.attrs/class-names (.-className props) class-names)) 32 | (set! (.-style props) 33 | (uix.compiler.attributes/convert-prop-value-shallow @inline-styles))))) 34 | (create-element args children)))) 35 | 36 | #?(:clj 37 | (alter-var-root #'uix.dom.server/render-attrs! 38 | (fn [f] 39 | (fn [tag attrs sb] 40 | (let [props (atom attrs)] 41 | (when (vector? (:style attrs)) 42 | (let [class-names (atom []) 43 | inline-styles (atom {})] 44 | (doseq [style (:style attrs)] 45 | (if (:uixCss style) 46 | (let [{:keys [class vars]} (:uixCss style)] 47 | (swap! class-names conj class) 48 | (swap! inline-styles into vars)) 49 | (swap! inline-styles into style))) 50 | (swap! props assoc :class 51 | (uix.css.lib/class-names (:class attrs) @class-names)) 52 | (swap! props assoc :style @inline-styles))) 53 | (f tag @props sb)))))) -------------------------------------------------------------------------------- /test/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns core-test 2 | (:require [clojure.java.io :as io] 3 | [clojure.java.shell :as shell] 4 | [clojure.string :as str] 5 | [clojure.test :refer :all] 6 | [clj-diffmatchpatch :as diff] 7 | [uix.dom.server :as dom.server] 8 | [uix.core :refer [$]] 9 | [core-test-sample])) 10 | 11 | (defn diff [s1 s2] 12 | (->> (diff/wdiff s1 s2) 13 | (map (fn [[op text]] 14 | (case op 15 | :delete (str "\033[37;41;1m" text "\033[0m") 16 | :insert (str "\033[37;42;1m" text "\033[0m") 17 | :equal text))) 18 | (str/join))) 19 | 20 | (def css-out 21 | ".core-test-sample-14-5{margin:64px;padding:33px;border:1px solid #000;}.core-test-sample-14-5:hover{color:blue;background:yellow;width:var(--core-test-sample-20-0);}.core-test-sample-14-5:hover > div{border-radius:32px;}@media (max-width: 800px){.core-test-sample-14-5{color:yellow;width:17px;}.core-test-sample-14-5:hover{color:yellow;width:var(--core-test-sample-27-0);}}\n/*# sourceMappingURL=main.css.map */") 22 | 23 | (def css-out-release 24 | (->> [".k1{margin:64px;padding:33px;border:1px solid #000;}" 25 | ".k1:hover{color:blue;background:yellow;width:var(--v2);}" 26 | ".k1:hover > div{border-radius:32px;}" 27 | "@media (max-width: 800px){.k1{color:yellow;width:17px;}.k1:hover{color:yellow;width:var(--v3);}}" 28 | "\n" 29 | "/*# sourceMappingURL=main.css.map */"] 30 | (str/join ""))) 31 | 32 | (defn after [] 33 | (.delete (io/file "out/main.css")) 34 | (.delete (io/file "out/main.css.map")) 35 | (run! io/delete-file (reverse (file-seq (io/file ".styles")))) 36 | (run! io/delete-file (reverse (file-seq (io/file ".shadow-cljs")))) 37 | (run! io/delete-file (reverse (file-seq (io/file "out"))))) 38 | 39 | (defn exec [& cmd] 40 | (testing cmd 41 | (println "Running" (str "\"" (str/join " " cmd) "\"")) 42 | (let [{:keys [exit out err]} (apply shell/sh cmd)] 43 | (is (= exit 0)) 44 | (when-not (str/blank? err) 45 | (binding [*out* *err*] 46 | (println err))) 47 | (when-not (str/blank? out) 48 | (println out))))) 49 | 50 | (deftest test-css-compilation 51 | (testing "generated CSS should match snapshot" 52 | (exec "clojure" "-A:dev" "-M" "-m" "shadow.cljs.devtools.cli" "compile" "test") 53 | (let [out-css (slurp "out/main.css")] 54 | (is (= css-out out-css) 55 | (diff css-out out-css))) 56 | (after)) 57 | (testing "generated minified CSS should match snapshot" 58 | (exec "clojure" "-A:dev" "-M" "-m" "shadow.cljs.devtools.cli" "release" "test") 59 | (let [out-css (slurp "out/main.css")] 60 | (is (= css-out-release out-css) 61 | (diff css-out-release out-css))) 62 | (after))) 63 | 64 | (def render-dir "server_render_test") 65 | 66 | (deftest test-ssr-compat-between-jvm-and-js 67 | (doseq [^java.io.File f (reverse (file-seq (io/file render-dir)))] 68 | (when (.exists f) 69 | (.delete f))) 70 | (.mkdir (io/file render-dir)) 71 | (.mkdir (io/file render-dir "html")) 72 | (.mkdir (io/file render-dir "markup")) 73 | (exec "clojure" "-A:dev" "-M" "-m" "shadow.cljs.devtools.cli" "compile" "test") 74 | (exec "node" "out/test.js") 75 | (let [cljs-html (slurp (str render-dir "/html/test.html")) 76 | clj-html (dom.server/render-to-string ($ core-test-sample/component))] 77 | (is (= cljs-html clj-html) (diff cljs-html clj-html))) 78 | (let [cljs-html (slurp (str render-dir "/markup/test.html")) 79 | clj-html (dom.server/render-to-static-markup ($ core-test-sample/component))] 80 | (is (= cljs-html clj-html) (diff cljs-html clj-html))) 81 | (after)) 82 | 83 | (defn -main [& args] 84 | (run-tests 'core-test)) -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Clojars Project](https://img.shields.io/clojars/v/com.github.roman01la/uix.css.svg)](https://clojars.org/com.github.roman01la/uix.css) 4 | 5 | CSS-in-CLJS library 6 | 7 | ```clojure 8 | (ns my.app 9 | (:require [uix.core :as uix :refer [defui $]] 10 | [uix.css :refer [css]] 11 | [uix.css.adapter.uix])) 12 | 13 | (defn button [] 14 | ($ :button {:style (css {:font-size "14px" 15 | :background "#151e2c"})})) 16 | ``` 17 | 18 | - Discuss at #uix on Clojurians Slack 19 | 20 | ## Installation 21 | 22 | ```clojure 23 | {:deps {com.github.roman01la/uix.css {:mvn/version "0.4.0"}}} 24 | ``` 25 | 26 | ## Motivation 27 | 28 | I love inline styles, unfortunately they are quite limited, there's no way to specify states (hover, active, etc) or use media queries. Essentially, inline styles won't let you use all of CSS. 29 | 30 | `uix.css` is a successor of [cljss](https://github.com/clj-commons/cljss), similar library that I created some years ago. 31 | 32 | ## Usage 33 | 34 | The library relies on shadow-cljs to generate and bundle CSS. `css` macro accepts a map of styles and returns a tuple of a class name and a map of dynamic inline styles. 35 | 36 | In the example below I'm using [UIx](https://github.com/pitch-io/uix). The library can be used with any other React wrapper as long as a proper adapter is provided (take a look at `uix.css.adapter.uix` ns to learn how to build adapters). The adapter takes care of applying whatever `css` macro returns. 37 | 38 | ```clojure 39 | (ns my.app 40 | (:require [uix.core :as uix :refer [defui $]] 41 | [uix.css :refer [css]] 42 | [uix.css.adapter.uix])) 43 | 44 | (def border-color "blue") 45 | 46 | (defn button [] 47 | ($ :button {:style (css {:font-size "14px" 48 | :background "#151e2c" 49 | :padding "8px 32px" 50 | :border (str "1px solid " border-color) 51 | :&:hover {:background "green"} 52 | "@media (max-width: 800px)" {:padding "4px 12px"} 53 | "& > strong" {:font-weight 600}})})) 54 | ``` 55 | 56 | `uix.css/hook` build hook takes care of generating CSS and creating a bundle. 57 | 58 | ```clojure 59 | ;; shadow-cljs.edn 60 | {:deps true 61 | :dev-http {8080 "public"} 62 | :builds {:website 63 | {:target :browser 64 | :build-hooks [(uix.css/hook)] 65 | :modules {:website {:entries [my.app]}}}}} 66 | ``` 67 | 68 | When compiled, static part of the styles map is dumped into CSS bundle, but dynamic part of it (see `border-color` example above) is deferred to runtime, where values are assigned via CSS Variables API. 69 | 70 | ### Styles composition 71 | 72 | `css` macro takes arbitrary number of styles 73 | 74 | ```clojure 75 | (defui button [{:keys [style children]}] 76 | ($ :button 77 | {:style (css {:color :red 78 | :padding "8px 16px"} 79 | style)} 80 | children)) 81 | 82 | ($ button {:style (css {:background :yellow})} 83 | "press me") 84 | ``` 85 | 86 | When a map of styles is passed at runtime, it will be applied as normal inline styles. This behaviour exists specifically for a case when you have UI styled with inline CSS and want to migrate to `uix.css` gradually. 87 | 88 | In this example existing `button` component was updated with `css` macro, but all usage places are still passing inline styles, meaning that updating internals of the component won't break its users. 89 | 90 | ```clojure 91 | (defui button [{:keys [style children]}] 92 | ($ :button 93 | {:style (css {:color :red 94 | :padding "8px 16px"} 95 | style)} 96 | children)) 97 | 98 | ;; these styles will be applied as inline styles 99 | ($ button {:style {:background :yellow}} 100 | "press me") 101 | ``` 102 | 103 | ### Global styles 104 | 105 | Styles passed under `:global` keyword are not scoped to current element, also global styles do not support dynamic values. This exists as a convenience, to avoid creating CSS file just for global styles. 106 | 107 | ```clojure 108 | (defui app [] 109 | ($ :div {:style (css {:width "100vw" 110 | :min-height "100vh" 111 | :background "#10121e" 112 | :color "#d7dbf1" 113 | :global {:html {:box-sizing :border-box} 114 | "html *" {:box-sizing :inherit} 115 | :body {:-webkit-font-smoothing :antialiased 116 | :-moz-osx-font-smoothing :grayscale 117 | :-moz-font-feature-settings "\"liga\" on" 118 | :text-rendering :optimizelegibility 119 | :margin 0 120 | :font "400 16px / 1.4 Inter, sans-serif"}}})})) 121 | ``` 122 | 123 | ## Source maps 124 | 125 | While generated class names are quite descriptive (`auix-core-L18-C20` — ns + line + column), we also generate CSS source maps to improve debugging experience. 126 | 127 | ![](/source_maps.jpg) 128 | 129 | ## Evaluators 130 | 131 | `uix.css` tries to inline constant values and pure expressions to reduce the number of dynamic styles, this is especially useful when you have a set of shared design tokens in code, like colors, font sizes, spacing, etc. 132 | 133 | In this example the value of `border-color` var will be inlined, as well as `(str "1px solid " border-color)` expression. `css` macro analyzes the code and evaluates well known functions given that their arguments are constant values. 134 | 135 | ```clojure 136 | (def border-color "blue") 137 | 138 | (def m-xl 64) 139 | 140 | (css {:border (str "1px solid " border-color) 141 | :margin m-xl}) 142 | ``` 143 | 144 | ## Code-splitting 145 | 146 | Starting from v0.3.0 uix.css follows shadow's code splitting via modules. Here's UIx example: 147 | 148 | ```clojure 149 | ;; main module 150 | (ns app.core 151 | (:require [uix.core :as uix :refer [$ defui]] 152 | [uix.css :refer [css]] 153 | [uix.css.adapter.uix] 154 | [shadow.lazy])) 155 | 156 | ;; create loadable var 157 | (def loadable-settings 158 | (shadow.lazy/loadable app.settings/view)) 159 | 160 | (def settings 161 | ;; creates lazy React component 162 | (uix.core/lazy 163 | ;; loads CSS bundle of the settings module 164 | #(uix.css/load-before app.settings 165 | ;; loads settings module 166 | (shadow.lazy/load loadable-settings)))) 167 | 168 | (defui root-layout [] 169 | ($ :div {:style (css {:padding 24})} 170 | ;; Suspense component displays the fallback UI while 171 | ;; lazy component is being loaded 172 | ($ uix.core/suspense {:fallback "loading settings..."} 173 | ($ settings)))) 174 | 175 | (defn init [] 176 | ;; render 177 | ) 178 | 179 | ;; settings module 180 | (ns app.settings 181 | (:require [uix.core :as uix :refer [$ defui]] 182 | [uix.css :refer [css]] 183 | [uix.css.adapter.uix])) 184 | 185 | (defui view [] 186 | ($ :div {:style (css {:padding 16})})) 187 | 188 | ;; shadow-cljs.edn build config 189 | {:app {:target :browser 190 | :module-loader true 191 | :modules {:main {:entries [app.core] 192 | :init-fn app.core/init} 193 | :settings {:entries [app.settings] 194 | :depends-on #{:main}}} 195 | :build-hooks [(uix.css/hook)]}} 196 | ``` 197 | 198 | Building this example will output two CSS bundles next to JavaScript bundles: `main.css` and `settings.css`. 199 | 200 | Same as for splitted JavaScript, you have to load initial CSS bundle explicitly, by declaring it via `` element in HTML. But for dynamically loaded modules you need to use `uix.css/load-before` function to load CSS bundle of the specified module before the module itself. 201 | 202 | ## Server-side rendering 203 | 204 | Since v0.4.0 uix.css supports server rendering on JVM, which plays nicely with [UIx SSR support](https://github.com/pitch-io/uix/blob/master/docs/server-side-rendering.md). 205 | 206 | No extra setup is required. When rendering UIx components in Clojure JVM, the `css` macro won't build styles. Intead it will just emit class names needed at runtime in a browser, where prebuilt CSS is loaded. 207 | -------------------------------------------------------------------------------- /src/uix/css.clj: -------------------------------------------------------------------------------- 1 | (ns uix.css 2 | (:require [cljs.analyzer :as ana] 3 | [cljs.analyzer.api :as ana-api] 4 | [cljs.env :as env] 5 | [cljs.source-map] 6 | [cljs.vendor.clojure.data.json :as json] 7 | [clojure.java.io :as io] 8 | [clojure.string :as str] 9 | [clojure.tools.reader.edn :as edn] 10 | [uix.css.lib :as css.lib]) 11 | (:import (java.io File FileNotFoundException))) 12 | 13 | (defn cljs? [env] 14 | (some? (:ns env))) 15 | 16 | (defn -resolve [env v] 17 | (if (cljs? env) 18 | (ana-api/resolve env v) 19 | (meta (resolve env v)))) 20 | 21 | (defmacro debug [& body] 22 | `(binding [*out* *err*] 23 | ~@body)) 24 | 25 | ;; var name -> ast node 26 | (def defs (atom {})) 27 | 28 | (defonce parse-def (get-method ana/parse 'def)) 29 | 30 | (defmethod ana/parse 'def [op env form name opts] 31 | (let [ast (parse-def op env form name opts)] 32 | (swap! defs assoc (:name ast) ast) 33 | ast)) 34 | 35 | (defn compile-rule [k v] 36 | (assert (not (or (symbol? v) (list? v))) "Value can't be a symbol or expression here, this is a bug") 37 | (str (name k) ":" 38 | (cond 39 | (and (number? v) (not (css.lib/unitless-prop k))) 40 | (str v "px") 41 | 42 | (keyword? v) 43 | (name v) 44 | 45 | :else v) 46 | ";")) 47 | 48 | (defn compile-styles [class-name selector styles & {:keys [global?]}] 49 | (str (if global? 50 | (str/replace (name selector) "&" class-name) 51 | (str/replace (name selector) "&" (str "." class-name))) 52 | "{" 53 | (str/join "" (map #(apply compile-rule %) styles)) 54 | "}")) 55 | 56 | (defn styles-by-type [styles] 57 | (group-by #(let [k (-> % key name)] 58 | (cond 59 | (str/starts-with? k "&") :blocks 60 | (str/starts-with? k"@") :media 61 | (= "global" k) :global 62 | :else :self)) 63 | styles)) 64 | 65 | (defn walk-styles-compile [class-name styles & {:keys [global?]}] 66 | (let [{:keys [self blocks media global]} (styles-by-type styles)] 67 | (str/join "" 68 | (concat 69 | ;; global styles 70 | (->> global 71 | (map second) 72 | (apply merge-with merge) 73 | (mapv (fn [[selector styles]] 74 | (walk-styles-compile (name selector) styles :global? true)))) 75 | ;; element styles 76 | (mapv #(apply compile-styles class-name (concat % [:global? global?])) (into [["&" self]] blocks)) 77 | ;; element media queries 78 | ;; FIXME: media styles should be in the end of file 79 | ;; FIXME: keyframes 80 | (mapv (fn [[media styles]] 81 | (str media "{" (walk-styles-compile class-name styles :global? global?) "}")) 82 | media))))) 83 | 84 | (def ^:dynamic *build-state*) 85 | 86 | (defn release? [] 87 | (-> *build-state* 88 | :mode 89 | (= :release))) 90 | 91 | (defn root-path [] 92 | ;; TODO: include build name dir 93 | (str ".styles/" 94 | (if (release?) "release" "dev"))) 95 | 96 | (defn styles-modules [] 97 | (->> (file-seq (io/file (root-path))) 98 | (filter #(.isFile ^File %)))) 99 | 100 | (defn write-modules! [styles-reg] 101 | (doseq [[file styles] styles-reg] 102 | (let [path (str (root-path) "/" file ".edn")] 103 | (io/make-parents path) 104 | (spit path (str styles))))) 105 | 106 | (defn write-source-map! [styles css-str output-to] 107 | (let [file-name (peek (str/split output-to #"/")) 108 | styles (->> styles 109 | (reduce-kv (fn [ret class v] 110 | (assoc ret class (assoc v :css-loc (.indexOf ^String css-str ^String class)))) 111 | {})) 112 | sm (->> (vals styles) 113 | (reduce (fn [ret {:keys [file line column css-loc]}] 114 | (assoc-in ret [file (dec line) column] [{:gline 0 :gcol css-loc}])) 115 | {})) 116 | sources (->> (vals styles) 117 | (map (fn [{:keys [file]}] 118 | [file (-> file io/resource slurp)])) 119 | (into #{}) 120 | vec) 121 | sources-content (map second sources) 122 | source-files (map first sources) 123 | sm (into (cljs.source-map/encode* sm {:file file-name 124 | :sources-content sources-content}) 125 | {"sources" source-files})] 126 | (spit (str output-to ".map") (json/write-str sm :escape-slash false)))) 127 | 128 | (defn write-bundle! [[output-name styles] output-dir] 129 | (let [styles (into {} styles) 130 | styles-strs (->> styles 131 | (sort-by (comp #(->> (str/split % #"-") (take-last 2) (str/join ".")) 132 | key)) 133 | (map (comp :css-str val))) 134 | sm-path (str output-name ".map") 135 | output-to (str output-dir "/" output-name) 136 | out (str (str/join "" styles-strs) 137 | "\n/*# sourceMappingURL=" sm-path " */")] 138 | (write-source-map! styles out output-to) 139 | (try 140 | (when (not= (slurp output-to) out) 141 | (spit output-to out)) 142 | (catch FileNotFoundException e 143 | (spit output-to out))))) 144 | 145 | (defn write-bundles! [state {:keys [output-dir]}] 146 | (let [output-dir (or output-dir (->> state :shadow.build/config :output-dir)) 147 | build-sources (->> (:build-sources state) 148 | (map second) 149 | (filter string?) 150 | (into #{})) 151 | modules (->> (vals (:shadow.build.modules/modules state)) 152 | (map (fn [{:keys [module-name sources]}] 153 | {:output-name (str/replace module-name #"\.js$" ".css") 154 | :sources (->> sources 155 | (filter (comp #{:shadow.build.classpath/resource} first)) 156 | (keep (comp :ns (:sources state))) 157 | (into #{}))}))) 158 | used (->> (styles-modules) 159 | (filter #(-> (.getPath ^File %) 160 | (str/replace #"^\.styles\/(dev|release)/" "") 161 | (str/replace #"\.edn$" "") 162 | build-sources))) 163 | styles (->> used 164 | (map (comp edn/read-string slurp)) 165 | (apply merge) 166 | (reduce-kv (fn [ret class styles] 167 | (assoc ret class (->> (walk-styles-compile class (:styles styles)) 168 | (assoc styles :css-str)))) 169 | {})) 170 | style-modules (->> styles 171 | (group-by (fn [[_ {:keys [ns]}]] 172 | (->> modules 173 | (reduce #(when (contains? (:sources %2) ns) 174 | (reduced (:output-name %2))) 175 | nil)))))] 176 | (.mkdirs (io/file output-dir)) 177 | (run! #(write-bundle! % output-dir) style-modules))) 178 | 179 | (defn- build-state->styles-reg [{:keys [compiler-env]}] 180 | (->> (:cljs.analyzer/namespaces compiler-env) 181 | vals 182 | (map :uix/css) 183 | (apply merge))) 184 | 185 | (defn write-styles! [state config] 186 | (binding [*build-state* state] 187 | (write-modules! (build-state->styles-reg state)) 188 | (write-bundles! state config))) 189 | 190 | (defn eval-symbol [env v] 191 | (if-not (cljs? env) 192 | (if-let [var (resolve env v)] 193 | (if (or (number? @var) (string? @var)) 194 | @var 195 | ::nothing) 196 | (or (some-> (env v) .init .eval) 197 | ::nothing)) 198 | (let [ast (ana-api/resolve env v)] 199 | (cond 200 | (and (= :local (:op ast)) 201 | (-> ast :init :op (= :const))) 202 | (-> ast :init :val) 203 | 204 | (= :var (:op ast)) 205 | (let [ast (@defs (:name ast))] 206 | (when (-> ast :init :op (= :const)) 207 | (-> ast :init :val))) 208 | 209 | :else ::nothing)))) 210 | 211 | (def evaluators 212 | {'cljs.core/inc inc 213 | 'cljs.core/dec dec 214 | 'cljs.core/+ + 215 | 'cljs.core/- - 216 | 'cljs.core/* * 217 | 'cljs.core// (comp float /) 218 | 'cljs.core/str str 219 | 220 | 'inc inc 221 | 'dec dec 222 | '+ + 223 | '- - 224 | '* * 225 | '/ (comp float /) 226 | 'str str}) 227 | 228 | ;; TODO: add a walker for well known forms: when, if, etc 229 | 230 | (declare eval-css-value) 231 | 232 | (defn eval-expr [env [f & args :as expr]] 233 | (cond 234 | (symbol? f) 235 | (if-let [eval-fn (->> f (-resolve env) :name evaluators)] 236 | (let [args (map #(eval-css-value env %) args)] 237 | (if (every? (complement #{::nothing}) args) 238 | (apply eval-fn args) 239 | ::nothing)) 240 | ::nothing) 241 | 242 | :else ::nothing)) 243 | 244 | (defn eval-value [env v] 245 | (if-not (cljs? env) 246 | (if (or (number? v) (string? v)) 247 | v 248 | ::nothing) 249 | (ana-api/no-warn 250 | (let [ast (ana-api/analyze env v)] 251 | (if (= :const (:op ast)) 252 | (:val ast) 253 | ::nothing))))) 254 | 255 | (def release-counter (atom 0)) 256 | 257 | (defn dyn-var-name [env v line] 258 | (if (release?) 259 | (str "--v" (swap! release-counter inc)) 260 | (let [v (cond-> v 261 | (and (list? v) (= 'clojure.core/deref (first v))) 262 | second) 263 | {:keys [file ns] 264 | :or {file *file* ns *ns*}} 265 | (if (symbol? v) 266 | (-resolve env v) 267 | (meta v)) 268 | column 0 269 | ns (if ns 270 | (-> (str ns) (str/replace #"\." "-")) 271 | (-> file 272 | (str/replace #"\.clj(s|c)?$" "") 273 | (str/replace #"(/|_)" "-")))] 274 | (str "--" ns "-" line "-" column)))) 275 | 276 | (defn eval-css-value [env v] 277 | (or (cond 278 | (symbol? v) (eval-symbol env v) 279 | (list? v) (eval-expr env v) 280 | :else (eval-value env v)) 281 | ::nothing)) 282 | 283 | (def ^:dynamic *global-context?* false) 284 | 285 | (defn walk-map [f m] 286 | (letfn [(walk [x] 287 | (if (map? x) 288 | (into {} (->> x (map (fn [[k v]] 289 | (if-not (and (= :global k) (map? v)) 290 | (f [k (walk v)]) 291 | (binding [*global-context?* true] 292 | (f [k (walk v)]))))))) 293 | x))] 294 | (walk m))) 295 | 296 | (defn- env-with-loc [env form] 297 | (let [loc (select-keys (meta form) [:line :column])] 298 | (cond-> env (seq loc) (into loc)))) 299 | 300 | (defn find-dyn-styles [styles env line] 301 | (let [dyn-input-styles (atom {}) 302 | line (atom line)] 303 | [(walk-map (fn [[k v]] 304 | (swap! line inc) 305 | (if-not (or (symbol? v) (list? v) (instance? clojure.lang.Cons v)) 306 | [k v] 307 | (let [ret (eval-css-value env v)] 308 | (when (and *global-context?* (= ::nothing ret)) 309 | (ana/warning ::global-styles-dynamic-vars (env-with-loc env v) {})) 310 | (if (= ::nothing ret) 311 | (let [var-name (dyn-var-name env v @line)] 312 | (swap! dyn-input-styles assoc var-name `(uix.css.lib/interpret-value ~k ~v)) 313 | [k (str "var(" var-name ")")]) 314 | [k ret])))) 315 | styles) 316 | @dyn-input-styles])) 317 | 318 | (let [env (atom {})] 319 | (defn get-env [] 320 | (or env/*compiler* env))) 321 | 322 | (defn make-styles [styles env form] 323 | (let [ns (or (-> env :ns :name) (ns-name *ns*)) 324 | file (or (-> env :ns :meta :file) *file*) 325 | {:keys [line column]} (meta form) 326 | class (if (release?) 327 | (str "k" (swap! release-counter inc)) 328 | (str (-> ns (str/replace "." "-")) "-" line "-" column)) 329 | [evaled-styles dyn-input-styles] (find-dyn-styles styles env line)] 330 | (swap! (get-env) assoc-in [:cljs.analyzer/namespaces ns :uix/css file class] 331 | {:styles evaled-styles 332 | :file file 333 | :ns ns 334 | :line line 335 | :column column 336 | :dyn-input-styles dyn-input-styles}) 337 | {:uixCss {:class class 338 | :vars dyn-input-styles}})) 339 | 340 | (defmacro css [& styles] 341 | (if (cljs? &env) 342 | (binding [*build-state* (:shadow.build.cljs-bridge/state @env/*compiler*)] 343 | (let [styles (->> styles 344 | (mapv (fn [v] 345 | (if (map? v) 346 | `(cljs.core/array ~(make-styles v &env &form)) 347 | `(let [v# ~v] 348 | (if (map? v#) 349 | (cljs.core/array v#) 350 | v#))))))] 351 | `(.concat ~@styles))) 352 | (binding [*build-state* (atom {})] 353 | (let [styles (->> styles 354 | (mapv (fn [v] 355 | (if (map? v) 356 | [(make-styles v &env &form)] 357 | `(let [v# ~v] 358 | (if (map? v#) 359 | [v#] 360 | v#))))))] 361 | `(vec (concat ~@styles)))))) 362 | 363 | 364 | (defn hook 365 | {:shadow.build/stage :compile-finish} 366 | [build-state & [config]] 367 | (write-styles! build-state config) 368 | build-state) 369 | 370 | (defmethod ana/error-message ::global-styles-dynamic-vars [_ _] 371 | "Global styles can't have dynamic values") 372 | 373 | (defmacro load-before [ns promise] 374 | (let [asset-path (->> (:shadow.build.cljs-bridge/state @env/*compiler*) 375 | :shadow.build/config 376 | :asset-path) 377 | path (->> (:shadow.build.cljs-bridge/state @env/*compiler*) 378 | :shadow.build.modules/modules 379 | vals 380 | (some #(when (contains? (set (:entries %)) ns) 381 | (str asset-path "/" (str/replace (:module-name %) #"\.js$" ".css")))))] 382 | `(-> (load-stylesheet ~path) 383 | (.then (fn [] ~promise))))) 384 | 385 | (comment 386 | (require '[uix.core :refer [$]]) 387 | (require 'uix.dom.server) 388 | 389 | (def xx (atom 90)) 390 | (let [x (atom 1)] 391 | (css {:font-size "14px" :flex (+ @xx 89)}) 392 | (uix.dom.server/render-to-string 393 | ($ :div.flex.flex-col.items-center {:style (css {:font-size "14px" :flex (+ @xx 89)})} 394 | ($ :ul.flex.gap-2.text-sm.py-1.font-medium))))) --------------------------------------------------------------------------------