├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── internal ├── stylance-core │ ├── Cargo.toml │ └── src │ │ ├── class_name_pattern.rs │ │ ├── lib.rs │ │ └── parse.rs └── stylance-macros │ ├── Cargo.toml │ └── src │ └── lib.rs ├── justfile ├── release.toml ├── rust-toolchain.toml ├── stylance-cli ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ └── main.rs └── stylance ├── Cargo.toml ├── examples └── usage │ ├── main.rs │ ├── module.rs │ ├── style1.module.scss │ └── style2.module.scss ├── release.toml ├── src └── lib.rs └── tests ├── style.module.scss ├── style2.module.scss ├── test_classes.rs └── test_import_styles.rs /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | env: 19 | RUSTFLAGS: -D warnings 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: dtolnay/rust-toolchain@nightly 24 | - name: Run tests 25 | run: cargo test --all-features --locked 26 | - name: Install tools 27 | run: rustup component add clippy rustfmt 28 | - name: Clippy 29 | run: cargo clippy 30 | - name: Cargo fmt 31 | run: cargo fmt --all -- --check 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Stylance changelog 2 | 3 | ## 0.6.0 4 | 5 | - Fix nightly features breakage. [#17](https://github.com/basro/stylance-rs/pull/17). 6 | 7 | ## 0.5.5 8 | 9 | - Added `run_silent` to stylance-cli lib which is the same as the `run` function but it calls a callback instead of printing filenames to stdout. 10 | 11 | ## 0.5.4 12 | 13 | - Exposed stylance-cli run function as a library, allows for programmatic usage of stylance. 14 | 15 | ## 0.5.3 16 | 17 | - Added support for trailing commas in the `classes!` macro. 18 | 19 | ## 0.5.2 20 | 21 | - Added support for any number of arguments to the `classes!` macro. It was previously limited to a maximum of 16 and didn't work for 0 or 1 arguments. 22 | 23 | ## 0.5.1 24 | 25 | - Fix nightly `import_style` macro panic when it is run by rust analyzer (PR #4). 26 | 27 | ## 0.5.0 28 | 29 | - Added support sass interpolation syntax in at rules and many other places. 30 | 31 | ## 0.4.0 32 | 33 | - Added support for @container at rules 34 | 35 | ## 0.3.0 36 | 37 | - Generated class name constants will now properly warn if they are unused. 38 | - Added attributes syntax to `import_style!` and `import_crate_style!` macros. 39 | 40 | ## 0.2.0 41 | 42 | - Add support for @layer at-rules 43 | - Made the order in which the modified css modules are output be well defined; Sorted by (filename, relativepath). This is important for rules with equal specificity or for cascade layers defined in the css modules. 44 | 45 | ## 0.1.1 46 | 47 | - Fixed the parser rejecting syntax of scss variable declarations (eg `$my-var: 10px;`). 48 | 49 | ## 0.1.0 50 | 51 | - Added `hash_len` configuration option that controls the length of the hash in generated class names. 52 | - Added `class_name_pattern` configuration option to control the generated class name pattern. 53 | - Added detection of hash collisions to stylance cli, it will error when detected. This allows reducing the hash_len without fear of it silently colliding. 54 | 55 | ## 0.0.12 56 | 57 | - Fixes cli watched folders not being relative to the manifest dir. 58 | 59 | ## 0.0.11 60 | 61 | - Fixes cli watch mode not printing errors. 62 | - Removes unused features from tokio dependency to improve compilation times. 63 | 64 | ## 0.0.10 65 | 66 | - Added scss_prelude configuration option that lets you prefix text to the generated scss files. 67 | - Added debouncing to the stylance cli --watch mode. 68 | - Fixes an issue where stylance would read files while they were being modified by the text editor resulting in wrong output. 69 | 70 | ## 0.0.9 71 | 72 | - Added classes! utility macro for joining class names 73 | - Added JoinClasses trait 74 | 75 | ## 0.0.8 76 | 77 | - Renamed `output` config option to `output-file` 78 | - Added `output-dir` config option which generates one file per css module and an `_index.scss` file that imports all of them. 79 | - Improved `import_style!` and `import_crate_style!` proc macros error reporting. 80 | - Added support for style declarations inside of media queries (useful for SASS nested media queries) 81 | - Unknown fields in Cargo.toml `[package.metadata.stylance]` will now produce an error. 82 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "anstream" 22 | version = "0.6.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "utf8parse", 32 | ] 33 | 34 | [[package]] 35 | name = "anstyle" 36 | version = "1.0.4" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" 39 | 40 | [[package]] 41 | name = "anstyle-parse" 42 | version = "0.2.3" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" 45 | dependencies = [ 46 | "utf8parse", 47 | ] 48 | 49 | [[package]] 50 | name = "anstyle-query" 51 | version = "1.0.2" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" 54 | dependencies = [ 55 | "windows-sys 0.52.0", 56 | ] 57 | 58 | [[package]] 59 | name = "anstyle-wincon" 60 | version = "3.0.2" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 63 | dependencies = [ 64 | "anstyle", 65 | "windows-sys 0.52.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anyhow" 70 | version = "1.0.79" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" 73 | 74 | [[package]] 75 | name = "backtrace" 76 | version = "0.3.69" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 79 | dependencies = [ 80 | "addr2line", 81 | "cc", 82 | "cfg-if", 83 | "libc", 84 | "miniz_oxide", 85 | "object", 86 | "rustc-demangle", 87 | ] 88 | 89 | [[package]] 90 | name = "bitflags" 91 | version = "1.3.2" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 94 | 95 | [[package]] 96 | name = "bitflags" 97 | version = "2.4.1" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" 100 | 101 | [[package]] 102 | name = "cc" 103 | version = "1.0.83" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 106 | dependencies = [ 107 | "libc", 108 | ] 109 | 110 | [[package]] 111 | name = "cfg-if" 112 | version = "1.0.0" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 115 | 116 | [[package]] 117 | name = "clap" 118 | version = "4.4.12" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "dcfab8ba68f3668e89f6ff60f5b205cea56aa7b769451a59f34b8682f51c056d" 121 | dependencies = [ 122 | "clap_builder", 123 | "clap_derive", 124 | ] 125 | 126 | [[package]] 127 | name = "clap_builder" 128 | version = "4.4.12" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "fb7fb5e4e979aec3be7791562fcba452f94ad85e954da024396433e0e25a79e9" 131 | dependencies = [ 132 | "anstream", 133 | "anstyle", 134 | "clap_lex", 135 | "strsim", 136 | ] 137 | 138 | [[package]] 139 | name = "clap_derive" 140 | version = "4.4.7" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" 143 | dependencies = [ 144 | "heck", 145 | "proc-macro2", 146 | "quote", 147 | "syn", 148 | ] 149 | 150 | [[package]] 151 | name = "clap_lex" 152 | version = "0.6.0" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" 155 | 156 | [[package]] 157 | name = "colorchoice" 158 | version = "1.0.0" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 161 | 162 | [[package]] 163 | name = "equivalent" 164 | version = "1.0.1" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 167 | 168 | [[package]] 169 | name = "filetime" 170 | version = "0.2.23" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" 173 | dependencies = [ 174 | "cfg-if", 175 | "libc", 176 | "redox_syscall", 177 | "windows-sys 0.52.0", 178 | ] 179 | 180 | [[package]] 181 | name = "futures-core" 182 | version = "0.3.30" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" 185 | 186 | [[package]] 187 | name = "gimli" 188 | version = "0.28.1" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" 191 | 192 | [[package]] 193 | name = "hashbrown" 194 | version = "0.14.3" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" 197 | 198 | [[package]] 199 | name = "heck" 200 | version = "0.4.1" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 203 | 204 | [[package]] 205 | name = "indexmap" 206 | version = "2.1.0" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" 209 | dependencies = [ 210 | "equivalent", 211 | "hashbrown", 212 | ] 213 | 214 | [[package]] 215 | name = "inotify" 216 | version = "0.9.6" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" 219 | dependencies = [ 220 | "bitflags 1.3.2", 221 | "inotify-sys", 222 | "libc", 223 | ] 224 | 225 | [[package]] 226 | name = "inotify-sys" 227 | version = "0.1.5" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" 230 | dependencies = [ 231 | "libc", 232 | ] 233 | 234 | [[package]] 235 | name = "itoa" 236 | version = "1.0.10" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" 239 | 240 | [[package]] 241 | name = "kqueue" 242 | version = "1.0.8" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" 245 | dependencies = [ 246 | "kqueue-sys", 247 | "libc", 248 | ] 249 | 250 | [[package]] 251 | name = "kqueue-sys" 252 | version = "1.0.4" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" 255 | dependencies = [ 256 | "bitflags 1.3.2", 257 | "libc", 258 | ] 259 | 260 | [[package]] 261 | name = "libc" 262 | version = "0.2.151" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" 265 | 266 | [[package]] 267 | name = "log" 268 | version = "0.4.20" 269 | source = "registry+https://github.com/rust-lang/crates.io-index" 270 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 271 | 272 | [[package]] 273 | name = "memchr" 274 | version = "2.7.1" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" 277 | 278 | [[package]] 279 | name = "miniz_oxide" 280 | version = "0.7.1" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 283 | dependencies = [ 284 | "adler", 285 | ] 286 | 287 | [[package]] 288 | name = "mio" 289 | version = "0.8.10" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" 292 | dependencies = [ 293 | "libc", 294 | "log", 295 | "wasi", 296 | "windows-sys 0.48.0", 297 | ] 298 | 299 | [[package]] 300 | name = "notify" 301 | version = "6.1.1" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" 304 | dependencies = [ 305 | "bitflags 2.4.1", 306 | "filetime", 307 | "inotify", 308 | "kqueue", 309 | "libc", 310 | "log", 311 | "mio", 312 | "walkdir", 313 | "windows-sys 0.48.0", 314 | ] 315 | 316 | [[package]] 317 | name = "object" 318 | version = "0.32.2" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" 321 | dependencies = [ 322 | "memchr", 323 | ] 324 | 325 | [[package]] 326 | name = "pin-project-lite" 327 | version = "0.2.13" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" 330 | 331 | [[package]] 332 | name = "proc-macro2" 333 | version = "1.0.75" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" 336 | dependencies = [ 337 | "unicode-ident", 338 | ] 339 | 340 | [[package]] 341 | name = "quote" 342 | version = "1.0.35" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 345 | dependencies = [ 346 | "proc-macro2", 347 | ] 348 | 349 | [[package]] 350 | name = "redox_syscall" 351 | version = "0.4.1" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" 354 | dependencies = [ 355 | "bitflags 1.3.2", 356 | ] 357 | 358 | [[package]] 359 | name = "rustc-demangle" 360 | version = "0.1.23" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 363 | 364 | [[package]] 365 | name = "ryu" 366 | version = "1.0.16" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" 369 | 370 | [[package]] 371 | name = "same-file" 372 | version = "1.0.6" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 375 | dependencies = [ 376 | "winapi-util", 377 | ] 378 | 379 | [[package]] 380 | name = "serde" 381 | version = "1.0.194" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773" 384 | dependencies = [ 385 | "serde_derive", 386 | ] 387 | 388 | [[package]] 389 | name = "serde_derive" 390 | version = "1.0.194" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0" 393 | dependencies = [ 394 | "proc-macro2", 395 | "quote", 396 | "syn", 397 | ] 398 | 399 | [[package]] 400 | name = "serde_json" 401 | version = "1.0.111" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" 404 | dependencies = [ 405 | "itoa", 406 | "ryu", 407 | "serde", 408 | ] 409 | 410 | [[package]] 411 | name = "serde_spanned" 412 | version = "0.6.5" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" 415 | dependencies = [ 416 | "serde", 417 | ] 418 | 419 | [[package]] 420 | name = "siphasher" 421 | version = "1.0.0" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "54ac45299ccbd390721be55b412d41931911f654fa99e2cb8bfb57184b2061fe" 424 | 425 | [[package]] 426 | name = "strsim" 427 | version = "0.10.0" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 430 | 431 | [[package]] 432 | name = "stylance" 433 | version = "0.6.0" 434 | dependencies = [ 435 | "stylance-macros", 436 | ] 437 | 438 | [[package]] 439 | name = "stylance-cli" 440 | version = "0.6.0" 441 | dependencies = [ 442 | "anyhow", 443 | "clap", 444 | "notify", 445 | "stylance-core", 446 | "tokio", 447 | "tokio-stream", 448 | "walkdir", 449 | ] 450 | 451 | [[package]] 452 | name = "stylance-core" 453 | version = "0.6.0" 454 | dependencies = [ 455 | "anyhow", 456 | "serde", 457 | "serde_json", 458 | "siphasher", 459 | "toml", 460 | "winnow", 461 | ] 462 | 463 | [[package]] 464 | name = "stylance-macros" 465 | version = "0.6.0" 466 | dependencies = [ 467 | "anyhow", 468 | "proc-macro2", 469 | "quote", 470 | "stylance-core", 471 | "syn", 472 | ] 473 | 474 | [[package]] 475 | name = "syn" 476 | version = "2.0.48" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" 479 | dependencies = [ 480 | "proc-macro2", 481 | "quote", 482 | "unicode-ident", 483 | ] 484 | 485 | [[package]] 486 | name = "tokio" 487 | version = "1.35.1" 488 | source = "registry+https://github.com/rust-lang/crates.io-index" 489 | checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" 490 | dependencies = [ 491 | "backtrace", 492 | "pin-project-lite", 493 | "tokio-macros", 494 | ] 495 | 496 | [[package]] 497 | name = "tokio-macros" 498 | version = "2.2.0" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" 501 | dependencies = [ 502 | "proc-macro2", 503 | "quote", 504 | "syn", 505 | ] 506 | 507 | [[package]] 508 | name = "tokio-stream" 509 | version = "0.1.14" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" 512 | dependencies = [ 513 | "futures-core", 514 | "pin-project-lite", 515 | "tokio", 516 | ] 517 | 518 | [[package]] 519 | name = "toml" 520 | version = "0.8.8" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" 523 | dependencies = [ 524 | "serde", 525 | "serde_spanned", 526 | "toml_datetime", 527 | "toml_edit", 528 | ] 529 | 530 | [[package]] 531 | name = "toml_datetime" 532 | version = "0.6.5" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" 535 | dependencies = [ 536 | "serde", 537 | ] 538 | 539 | [[package]] 540 | name = "toml_edit" 541 | version = "0.21.0" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" 544 | dependencies = [ 545 | "indexmap", 546 | "serde", 547 | "serde_spanned", 548 | "toml_datetime", 549 | "winnow", 550 | ] 551 | 552 | [[package]] 553 | name = "unicode-ident" 554 | version = "1.0.12" 555 | source = "registry+https://github.com/rust-lang/crates.io-index" 556 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 557 | 558 | [[package]] 559 | name = "utf8parse" 560 | version = "0.2.1" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 563 | 564 | [[package]] 565 | name = "walkdir" 566 | version = "2.4.0" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" 569 | dependencies = [ 570 | "same-file", 571 | "winapi-util", 572 | ] 573 | 574 | [[package]] 575 | name = "wasi" 576 | version = "0.11.0+wasi-snapshot-preview1" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 579 | 580 | [[package]] 581 | name = "winapi" 582 | version = "0.3.9" 583 | source = "registry+https://github.com/rust-lang/crates.io-index" 584 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 585 | dependencies = [ 586 | "winapi-i686-pc-windows-gnu", 587 | "winapi-x86_64-pc-windows-gnu", 588 | ] 589 | 590 | [[package]] 591 | name = "winapi-i686-pc-windows-gnu" 592 | version = "0.4.0" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 595 | 596 | [[package]] 597 | name = "winapi-util" 598 | version = "0.1.6" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" 601 | dependencies = [ 602 | "winapi", 603 | ] 604 | 605 | [[package]] 606 | name = "winapi-x86_64-pc-windows-gnu" 607 | version = "0.4.0" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 610 | 611 | [[package]] 612 | name = "windows-sys" 613 | version = "0.48.0" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 616 | dependencies = [ 617 | "windows-targets 0.48.5", 618 | ] 619 | 620 | [[package]] 621 | name = "windows-sys" 622 | version = "0.52.0" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 625 | dependencies = [ 626 | "windows-targets 0.52.0", 627 | ] 628 | 629 | [[package]] 630 | name = "windows-targets" 631 | version = "0.48.5" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 634 | dependencies = [ 635 | "windows_aarch64_gnullvm 0.48.5", 636 | "windows_aarch64_msvc 0.48.5", 637 | "windows_i686_gnu 0.48.5", 638 | "windows_i686_msvc 0.48.5", 639 | "windows_x86_64_gnu 0.48.5", 640 | "windows_x86_64_gnullvm 0.48.5", 641 | "windows_x86_64_msvc 0.48.5", 642 | ] 643 | 644 | [[package]] 645 | name = "windows-targets" 646 | version = "0.52.0" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" 649 | dependencies = [ 650 | "windows_aarch64_gnullvm 0.52.0", 651 | "windows_aarch64_msvc 0.52.0", 652 | "windows_i686_gnu 0.52.0", 653 | "windows_i686_msvc 0.52.0", 654 | "windows_x86_64_gnu 0.52.0", 655 | "windows_x86_64_gnullvm 0.52.0", 656 | "windows_x86_64_msvc 0.52.0", 657 | ] 658 | 659 | [[package]] 660 | name = "windows_aarch64_gnullvm" 661 | version = "0.48.5" 662 | source = "registry+https://github.com/rust-lang/crates.io-index" 663 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 664 | 665 | [[package]] 666 | name = "windows_aarch64_gnullvm" 667 | version = "0.52.0" 668 | source = "registry+https://github.com/rust-lang/crates.io-index" 669 | checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" 670 | 671 | [[package]] 672 | name = "windows_aarch64_msvc" 673 | version = "0.48.5" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 676 | 677 | [[package]] 678 | name = "windows_aarch64_msvc" 679 | version = "0.52.0" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" 682 | 683 | [[package]] 684 | name = "windows_i686_gnu" 685 | version = "0.48.5" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 688 | 689 | [[package]] 690 | name = "windows_i686_gnu" 691 | version = "0.52.0" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" 694 | 695 | [[package]] 696 | name = "windows_i686_msvc" 697 | version = "0.48.5" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 700 | 701 | [[package]] 702 | name = "windows_i686_msvc" 703 | version = "0.52.0" 704 | source = "registry+https://github.com/rust-lang/crates.io-index" 705 | checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" 706 | 707 | [[package]] 708 | name = "windows_x86_64_gnu" 709 | version = "0.48.5" 710 | source = "registry+https://github.com/rust-lang/crates.io-index" 711 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 712 | 713 | [[package]] 714 | name = "windows_x86_64_gnu" 715 | version = "0.52.0" 716 | source = "registry+https://github.com/rust-lang/crates.io-index" 717 | checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" 718 | 719 | [[package]] 720 | name = "windows_x86_64_gnullvm" 721 | version = "0.48.5" 722 | source = "registry+https://github.com/rust-lang/crates.io-index" 723 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 724 | 725 | [[package]] 726 | name = "windows_x86_64_gnullvm" 727 | version = "0.52.0" 728 | source = "registry+https://github.com/rust-lang/crates.io-index" 729 | checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" 730 | 731 | [[package]] 732 | name = "windows_x86_64_msvc" 733 | version = "0.48.5" 734 | source = "registry+https://github.com/rust-lang/crates.io-index" 735 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 736 | 737 | [[package]] 738 | name = "windows_x86_64_msvc" 739 | version = "0.52.0" 740 | source = "registry+https://github.com/rust-lang/crates.io-index" 741 | checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" 742 | 743 | [[package]] 744 | name = "winnow" 745 | version = "0.5.31" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" 748 | dependencies = [ 749 | "memchr", 750 | ] 751 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["stylance", "stylance-cli", "internal/*"] 4 | 5 | [workspace.dependencies] 6 | stylance-core = { path = "./internal/stylance-core", version = "0.6.0" } 7 | stylance-macros = { path = "./internal/stylance-macros", version = "0.6.0" } 8 | 9 | [workspace.package] 10 | authors = ["Mario Carbajal"] 11 | version = "0.6.0" 12 | license = "MIT OR Apache-2.0" 13 | repository = "https://github.com/basro/stylance-rs" 14 | readme = "README.md" 15 | keywords = ["css", "css-modules", "scss", "web"] 16 | categories = ["web-programming"] 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mario Carbajal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stylance [![crates.io](https://img.shields.io/crates/v/stylance.svg)](https://crates.io/crates/stylance) ![tests](https://github.com/basro/stylance-rs/actions/workflows/tests.yml/badge.svg?branch=main) 2 | 3 | Stylance is a library and cli tool for working with scoped CSS in rust. 4 | 5 | **Features:** 6 | 7 | - Import hashed class names from css files into your rust code as string constants. 8 | - Trying to use a class name that doesn't exist in the css file becomes an error. 9 | - Unused class names become warnings. 10 | - Bundle your css module files into a single output css file with all the class names transformed to include a hash (by using stylance cli). 11 | - Class name hashes are deterministic and based on the relative path between the css file and your crate's manifest dir (where the Cargo.toml resides) 12 | - CSS Bundle generation is independent of the rust build process, allowing for blazingly fast iteration when modifying the contents of a css style rule. 13 | 14 | ## Usage 15 | 16 | Stylance is divided in two parts: 17 | 18 | 1. Rust proc macros for importing scoped class names from css files as string constants into your rust code. 19 | 2. A cli tool that finds all css modules in your crate and generates an output css file with hashed class names. 20 | 21 | ## Proc macro 22 | 23 | Add stylance as a dependency: 24 | 25 | ```cli 26 | cargo add stylance 27 | ``` 28 | 29 | Then use the import_crate_style proc macro to read a css/scss file and bring the classes from within that file as constants. 30 | 31 | `src/component/card/card.module.scss` file's content: 32 | 33 | ```css 34 | .header { 35 | background-color: red; 36 | } 37 | ``` 38 | 39 | `src/component/card/card.rs` file's contents: 40 | 41 | ```rust 42 | // Import a css file's classes: 43 | stylance::import_crate_style!(my_style, "src/component/card/card.module.scss"); 44 | 45 | fn use_style() { 46 | // Use the classnames: 47 | println!("{}", my_style::header) // prints header-f45126d 48 | } 49 | ``` 50 | 51 | All class names found inside the file `src/component/card/card.module.scss` will be included as constants inside a module named as the identifier passed as first argument to import_style. 52 | 53 | The proc macro has no side effects, to generate the transformed css file we then use the stylance cli. 54 | 55 | ### Accessing global classnames 56 | 57 | Sometimes you might want to target classnames that are defined globally and outside of your css module. To do this you can wrap them with `:global()` 58 | 59 | ```css 60 | .my_scoped_class :global(.paragraph) { 61 | color: red; 62 | } 63 | ``` 64 | 65 | this will transform to: 66 | 67 | ```css 68 | .my_scoped_class-f45126d .paragraph { 69 | color: red; 70 | } 71 | ``` 72 | 73 | .my_scoped_class got the module hash attached but .paragraph was left alone while the `:global()` was removed. 74 | 75 | ### Unused classname warnings 76 | 77 | The import style macros will crate constants which, if left unused, will produce warnings. 78 | 79 | This is helpful if you don't want to have css classes left unused but you are able to silence this warning by adding `#[allow(dead_code)]` before the module identifier in the macro. 80 | 81 | Example: 82 | ```rust 83 | import_crate_style!(#[allow(dead_code)] my_style, "src/component/card/card.module.scss"); 84 | ``` 85 | 86 | Any attribute is allowed, if you want to deny instead you can do it too: 87 | 88 | ```rust 89 | import_crate_style!(#[deny(dead_code)] my_style, "src/component/card/card.module.scss"); 90 | ``` 91 | 92 | ### Nightly feature 93 | 94 | If you are using rust nightly you can enable the `nightly` feature to get access to the `import_style!` macro which lets you specify the css module file as relative to the current file. 95 | 96 | Enable the nightly feature: 97 | 98 | ```toml 99 | stylance = { version = "", features = ["nightly"] } 100 | ``` 101 | 102 | Then import style as relative: 103 | 104 | `src/component/card/card.rs`: 105 | 106 | ```rust 107 | stylance::import_style!(my_style, "card.module.scss"); 108 | ``` 109 | 110 | ## Stylance cli 111 | 112 | Install stylance cli: 113 | 114 | ```cli 115 | cargo install stylance-cli 116 | ``` 117 | 118 | Run stylance cli: 119 | 120 | ```cli 121 | stylance ./path/to/crate/dir/ --output-file ./bundled.scss 122 | ``` 123 | 124 | The first argument is the path to the directory containing the Cargo.toml of your package/crate. 125 | 126 | This will find all the files ending with `.module.scss` and `.module.css`and bundle them into `./bundled.scss`, all classes will be modified to include a hash that matches the one the `import_crate_style!` macro produces. 127 | 128 | Resulting `./bundled.scss`: 129 | 130 | ```css 131 | .header-f45126d { 132 | background-color: red; 133 | } 134 | ``` 135 | 136 | By default stylance cli will only look for css modules inside the crate's `./src/` folder. This can be [configured](#configuration). 137 | 138 | ### Use `output-dir` for better SASS compatibility 139 | 140 | If you plan to use the output of stylance in a SASS project (by importing it from a .scss file), then I recommend using the `output-dir` option instead of `output-file`. 141 | 142 | ```bash 143 | stylance ./path/to/crate/dir/ --output-dir ./styles/ 144 | ``` 145 | 146 | This will create the folder `./styles/stylance/`. 147 | 148 | When using --output-dir (or output_dir in package.metadata.stylance) stylance will not bundle the transformed module files, instead it will create a "stylance" folder in the specified output-dir path which will contain all the transformed css modules inside as individual files. 149 | 150 | This "stylance" folder also includes an \_index.scss file that imports all the transformed scss modules. 151 | 152 | You can then use `@use "path/to/the/folder/stylance"` to import the css modules into your sass project. 153 | 154 | ### Watching for changes 155 | 156 | During development it is convenient to use sylance cli in watch mode: 157 | 158 | ```cli 159 | stylance --watch --output-file ./bundled.scss ./path/to/crate/dir/ 160 | ``` 161 | 162 | The stylance process will then watch any `.module.css` and `.module.scss` files for changes and automatically rebuild the output file. 163 | 164 | ## Configuration 165 | 166 | Stylance configuration lives inside the Cargo.toml file of your crate. 167 | 168 | All configuration settings are optional. 169 | 170 | ```toml 171 | [package.metadata.stylance] 172 | 173 | # output_file 174 | # When set, stylance-cli will bundle all css module files 175 | # into by concatenating them and put the result in this file. 176 | output_file = "./styles/bundle.scss" 177 | 178 | # output_dir 179 | # When set, stylance-cli will create a folder named "stylance" inside 180 | # the output_dir directory. 181 | # The stylance folder will be populated with one file per detected css module 182 | # and one _all.scss file that contains one `@use "file.module-hash.scss";` statement 183 | # per module file. 184 | # You can use that file to import all your modules into your main scss project. 185 | output_dir = "./styles/" 186 | 187 | # folders 188 | # folders in which stylance cli will look for css module files. 189 | # defaults to ["./src/"] 190 | folders = ["./src/", "./styles/"] 191 | 192 | # extensions 193 | # files ending with these extensions will be considered to be 194 | # css modules by stylance cli and will be included in the output 195 | # bundle 196 | # defaults to [".module.scss", ".module.css"] 197 | extensions = [".module.scss", ".module.css"] 198 | 199 | # scss_prelude 200 | # When generating an scss file stylance-cli will prepend this string 201 | # Useful to include a @use statement to all scss modules. 202 | scss_prelude = '@use "../path/to/prelude" as *;' 203 | 204 | # hash_len 205 | # Controls how long the hash name used in scoped classes should be. 206 | # It is safe to lower this as much as you want, stylance cli will produce an 207 | # error if two files end up with colliding hashes. 208 | # defaults to 7 209 | hash_len = 7 210 | 211 | # class_name_pattern 212 | # Controls the shape of the transformed scoped class names. 213 | # [name] will be replaced with the original class name 214 | # [hash] will be replaced with the hash of css module file path. 215 | # defaults to "[name]-[hash]" 216 | class_name_pattern = "my-project-[name]-[hash]" 217 | ``` 218 | 219 | ## Rust analyzer completion issues 220 | 221 | ### Nightly `import_style!` 222 | 223 | Rust analyzer will not produce any completion for import_style!, this is because it doesn't support the nightly features used to obtain the current rust file path. 224 | 225 | ### Stable `import_crate_style!` 226 | 227 | Rust analyzer will expand the `import_crate_style!(style, "src/mystyle.module.css")` macro properly the first time, which means you'll be able to get completion when typing `style::|`. 228 | 229 | Unfortunately RA will cache the result and will not realize that it needs to reevaluate the proc macro when the contents of `src/mystyle.module.css` change. 230 | 231 | This only affects completion, errors from cargo check will properly update. 232 | 233 | The only way to force RA to reevaluate the macros is to restart the server or to rebuild all proc macros. Sadly this takes a really long time. 234 | 235 | It is my opinion that no completion would be better than outdated completion. 236 | 237 | Supposedly one should be able to disable the expansion of the macro by adding this to `.vscode/settings.json` 238 | 239 | ```json 240 | "rust-analyzer.procMacro.ignored": { 241 | "stylance": ["import_style_classes"] 242 | }, 243 | ``` 244 | 245 | Unfortunately this doesn't seem to work at the moment, this rust analyzer feature might fix the issue: https://github.com/rust-lang/rust-analyzer/pull/15923 246 | 247 | In the meantime the nightly `import_style` is my recommended way to work with this crate. 248 | -------------------------------------------------------------------------------- /internal/stylance-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stylance-core" 3 | edition = "2021" 4 | version.workspace = true 5 | license.workspace = true 6 | repository.workspace = true 7 | description = "Internal crate used by stylance" 8 | 9 | [dependencies] 10 | anyhow = "1.0.79" 11 | winnow = "0.5.31" 12 | toml = "0.8.8" 13 | serde = { version = "1.0.194", features = ["derive"] } 14 | siphasher = "1.0.0" 15 | 16 | [dev-dependencies] 17 | serde_json = "1.0.111" 18 | -------------------------------------------------------------------------------- /internal/stylance-core/src/class_name_pattern.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use serde::{Deserialize, Deserializer}; 4 | 5 | #[derive(Debug, Clone, PartialEq)] 6 | pub enum Fragment { 7 | Str(String), 8 | Name, 9 | Hash, 10 | } 11 | 12 | #[derive(Debug, Clone, PartialEq)] 13 | pub struct ClassNamePattern(Vec); 14 | 15 | impl ClassNamePattern { 16 | pub fn apply(&self, classname: &str, hash: &str) -> String { 17 | self.0 18 | .iter() 19 | .map(|v| match v { 20 | Fragment::Str(s) => s, 21 | Fragment::Name => classname, 22 | Fragment::Hash => hash, 23 | }) 24 | .collect::>() 25 | .join("") 26 | } 27 | } 28 | 29 | impl Default for ClassNamePattern { 30 | fn default() -> Self { 31 | Self(vec![ 32 | Fragment::Name, 33 | Fragment::Str("-".into()), 34 | Fragment::Hash, 35 | ]) 36 | } 37 | } 38 | 39 | impl<'de> Deserialize<'de> for ClassNamePattern { 40 | fn deserialize(deserializer: D) -> Result 41 | where 42 | D: Deserializer<'de>, 43 | { 44 | let s: Cow = Deserialize::deserialize(deserializer)?; 45 | 46 | match parse::parse_pattern(&s) { 47 | Ok(v) => Ok(v), 48 | Err(e) => Err(serde::de::Error::custom(e)), 49 | } 50 | } 51 | } 52 | 53 | mod parse { 54 | use super::*; 55 | use winnow::{ 56 | combinator::{alt, repeat}, 57 | error::{ContextError, ParseError}, 58 | token::take_till, 59 | PResult, Parser, 60 | }; 61 | fn fragment(input: &mut &str) -> PResult { 62 | alt(( 63 | "[name]".value(Fragment::Name), 64 | "[hash]".value(Fragment::Hash), 65 | take_till(1.., '[').map(|s: &str| Fragment::Str(s.into())), 66 | )) 67 | .parse_next(input) 68 | } 69 | 70 | fn pattern(input: &mut &str) -> PResult> { 71 | repeat(0.., fragment).parse_next(input) 72 | } 73 | 74 | pub fn parse_pattern(input: &str) -> Result> { 75 | Ok(ClassNamePattern(pattern.parse(input)?)) 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod test { 81 | 82 | use crate::class_name_pattern::ClassNamePattern; 83 | 84 | #[test] 85 | fn test_pattern_deserialize() { 86 | let pattern: ClassNamePattern = 87 | serde_json::from_str("\"test-[name]-[hash]\"").expect("should deserialize"); 88 | 89 | assert_eq!("test-my-class-12345", pattern.apply("my-class", "12345")); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/stylance-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod class_name_pattern; 2 | mod parse; 3 | 4 | use std::{ 5 | borrow::Cow, 6 | fs, 7 | hash::{Hash as _, Hasher as _}, 8 | path::{Path, PathBuf}, 9 | str::FromStr, 10 | }; 11 | 12 | use anyhow::{anyhow, bail, Context}; 13 | use class_name_pattern::ClassNamePattern; 14 | use parse::{CssFragment, Global}; 15 | use serde::Deserialize; 16 | use siphasher::sip::SipHasher13; 17 | 18 | fn default_extensions() -> Vec { 19 | vec![".module.css".to_owned(), ".module.scss".to_owned()] 20 | } 21 | 22 | fn default_folders() -> Vec { 23 | vec![PathBuf::from_str("./src/").expect("path is valid")] 24 | } 25 | 26 | fn default_hash_len() -> usize { 27 | 7 28 | } 29 | 30 | #[derive(Deserialize, Debug)] 31 | #[serde(deny_unknown_fields)] 32 | pub struct Config { 33 | pub output_file: Option, 34 | pub output_dir: Option, 35 | #[serde(default = "default_extensions")] 36 | pub extensions: Vec, 37 | #[serde(default = "default_folders")] 38 | pub folders: Vec, 39 | pub scss_prelude: Option, 40 | #[serde(default = "default_hash_len")] 41 | pub hash_len: usize, 42 | #[serde(default)] 43 | pub class_name_pattern: ClassNamePattern, 44 | } 45 | 46 | impl Default for Config { 47 | fn default() -> Self { 48 | Self { 49 | output_file: None, 50 | output_dir: None, 51 | extensions: default_extensions(), 52 | folders: default_folders(), 53 | scss_prelude: None, 54 | hash_len: default_hash_len(), 55 | class_name_pattern: Default::default(), 56 | } 57 | } 58 | } 59 | 60 | #[derive(Deserialize)] 61 | pub struct CargoToml { 62 | package: Option, 63 | } 64 | 65 | #[derive(Deserialize)] 66 | pub struct CargoTomlPackage { 67 | metadata: Option, 68 | } 69 | 70 | #[derive(Deserialize)] 71 | pub struct CargoTomlPackageMetadata { 72 | stylance: Option, 73 | } 74 | 75 | pub fn hash_string(input: &str) -> u64 { 76 | let mut hasher = SipHasher13::new(); 77 | input.hash(&mut hasher); 78 | hasher.finish() 79 | } 80 | 81 | pub struct Class { 82 | pub original_name: String, 83 | pub hashed_name: String, 84 | } 85 | 86 | pub fn load_config(manifest_dir: &Path) -> anyhow::Result { 87 | let cargo_toml_contents = 88 | fs::read_to_string(manifest_dir.join("Cargo.toml")).context("Failed to read Cargo.toml")?; 89 | let cargo_toml: CargoToml = toml::from_str(&cargo_toml_contents)?; 90 | 91 | let config = match cargo_toml.package { 92 | Some(CargoTomlPackage { 93 | metadata: 94 | Some(CargoTomlPackageMetadata { 95 | stylance: Some(config), 96 | }), 97 | }) => config, 98 | _ => Config::default(), 99 | }; 100 | 101 | if config.extensions.iter().any(|e| e.is_empty()) { 102 | bail!("Stylance config extensions can't be empty strings"); 103 | } 104 | 105 | Ok(config) 106 | } 107 | 108 | fn normalized_relative_path(base: &Path, subpath: &Path) -> anyhow::Result { 109 | let base = base.canonicalize()?; 110 | let subpath = subpath.canonicalize()?; 111 | 112 | let relative_path_str: String = subpath 113 | .strip_prefix(base) 114 | .context("css file should be inside manifest_dir")? 115 | .to_string_lossy() 116 | .into(); 117 | 118 | #[cfg(target_os = "windows")] 119 | let relative_path_str = relative_path_str.replace('\\', "/"); 120 | 121 | Ok(relative_path_str) 122 | } 123 | 124 | fn make_hash(manifest_dir: &Path, css_file: &Path, hash_len: usize) -> anyhow::Result { 125 | let relative_path_str = normalized_relative_path(manifest_dir, css_file)?; 126 | 127 | let hash = hash_string(&relative_path_str); 128 | let mut hash_str = format!("{hash:x}"); 129 | hash_str.truncate(hash_len); 130 | Ok(hash_str) 131 | } 132 | 133 | pub struct ModifyCssResult { 134 | pub path: PathBuf, 135 | pub normalized_path_str: String, 136 | pub hash: String, 137 | pub contents: String, 138 | } 139 | 140 | pub fn load_and_modify_css( 141 | manifest_dir: &Path, 142 | css_file: &Path, 143 | config: &Config, 144 | ) -> anyhow::Result { 145 | let hash_str = make_hash(manifest_dir, css_file, config.hash_len)?; 146 | let css_file_contents = fs::read_to_string(css_file)?; 147 | 148 | let fragments = parse::parse_css(&css_file_contents).map_err(|e| anyhow!("{e}"))?; 149 | 150 | let mut new_file = String::with_capacity(css_file_contents.len() * 2); 151 | let mut cursor = css_file_contents.as_str(); 152 | 153 | for fragment in fragments { 154 | let (span, replace) = match fragment { 155 | CssFragment::Class(class) => ( 156 | class, 157 | Cow::Owned(config.class_name_pattern.apply(class, &hash_str)), 158 | ), 159 | CssFragment::Global(Global { inner, outer }) => (outer, Cow::Borrowed(inner)), 160 | }; 161 | 162 | let (before, after) = cursor.split_at(span.as_ptr() as usize - cursor.as_ptr() as usize); 163 | cursor = &after[span.len()..]; 164 | new_file.push_str(before); 165 | new_file.push_str(&replace); 166 | } 167 | 168 | new_file.push_str(cursor); 169 | 170 | Ok(ModifyCssResult { 171 | path: css_file.to_owned(), 172 | normalized_path_str: normalized_relative_path(manifest_dir, css_file)?, 173 | hash: hash_str, 174 | contents: new_file, 175 | }) 176 | } 177 | 178 | pub fn get_classes( 179 | manifest_dir: &Path, 180 | css_file: &Path, 181 | config: &Config, 182 | ) -> anyhow::Result<(String, Vec)> { 183 | let hash_str = make_hash(manifest_dir, css_file, config.hash_len)?; 184 | 185 | let css_file_contents = fs::read_to_string(css_file)?; 186 | 187 | let mut classes = parse::parse_css(&css_file_contents) 188 | .map_err(|e| anyhow!("{e}"))? 189 | .into_iter() 190 | .filter_map(|c| { 191 | if let CssFragment::Class(c) = c { 192 | Some(c) 193 | } else { 194 | None 195 | } 196 | }) 197 | .collect::>(); 198 | 199 | classes.sort(); 200 | classes.dedup(); 201 | 202 | Ok(( 203 | hash_str.clone(), 204 | classes 205 | .into_iter() 206 | .map(|class| Class { 207 | original_name: class.to_owned(), 208 | hashed_name: config.class_name_pattern.apply(class, &hash_str), 209 | }) 210 | .collect(), 211 | )) 212 | } 213 | -------------------------------------------------------------------------------- /internal/stylance-core/src/parse.rs: -------------------------------------------------------------------------------- 1 | use winnow::{ 2 | combinator::{alt, cut_err, delimited, fold_repeat, opt, peek, preceded, terminated}, 3 | error::{ContextError, ParseError}, 4 | stream::{AsChar, ContainsToken, Range}, 5 | token::{none_of, one_of, tag, take_till, take_until0, take_while}, 6 | PResult, Parser, 7 | }; 8 | 9 | /// ```text 10 | /// v----v inner span 11 | /// :global(.class) 12 | /// ^-------------^ outer span 13 | /// ``` 14 | #[derive(Debug, PartialEq)] 15 | pub struct Global<'s> { 16 | pub inner: &'s str, 17 | pub outer: &'s str, 18 | } 19 | 20 | #[derive(Debug, PartialEq)] 21 | pub enum CssFragment<'s> { 22 | Class(&'s str), 23 | Global(Global<'s>), 24 | } 25 | 26 | pub fn parse_css(input: &str) -> Result, ParseError<&str, ContextError>> { 27 | style_rule_block_contents.parse(input) 28 | } 29 | 30 | pub fn recognize_repeat<'s, O>( 31 | range: impl Into, 32 | f: impl Parser<&'s str, O, ContextError>, 33 | ) -> impl Parser<&'s str, &'s str, ContextError> { 34 | fold_repeat(range, f, || (), |_, _| ()).recognize() 35 | } 36 | 37 | fn ws<'s>(input: &mut &'s str) -> PResult<&'s str> { 38 | recognize_repeat( 39 | 0.., 40 | alt(( 41 | line_comment, 42 | block_comment, 43 | take_while(1.., (AsChar::is_space, '\n', '\r')), 44 | )), 45 | ) 46 | .parse_next(input) 47 | } 48 | 49 | fn line_comment<'s>(input: &mut &'s str) -> PResult<&'s str> { 50 | ("//", take_while(0.., |c| c != '\n')) 51 | .recognize() 52 | .parse_next(input) 53 | } 54 | 55 | fn block_comment<'s>(input: &mut &'s str) -> PResult<&'s str> { 56 | ("/*", cut_err(terminated(take_until0("*/"), "*/"))) 57 | .recognize() 58 | .parse_next(input) 59 | } 60 | 61 | // matches a sass interpolation of the form #{...} 62 | fn sass_interpolation<'s>(input: &mut &'s str) -> PResult<&'s str> { 63 | ( 64 | "#{", 65 | cut_err(terminated(take_till(1.., ('{', '}', '\n')), '}')), 66 | ) 67 | .recognize() 68 | .parse_next(input) 69 | } 70 | 71 | fn identifier<'s>(input: &mut &'s str) -> PResult<&'s str> { 72 | ( 73 | one_of(('_', '-', AsChar::is_alpha)), 74 | take_while(0.., ('_', '-', AsChar::is_alphanum)), 75 | ) 76 | .recognize() 77 | .parse_next(input) 78 | } 79 | 80 | fn class<'s>(input: &mut &'s str) -> PResult<&'s str> { 81 | preceded('.', identifier).parse_next(input) 82 | } 83 | 84 | fn global<'s>(input: &mut &'s str) -> PResult> { 85 | let (inner, outer) = preceded( 86 | ":global(", 87 | cut_err(terminated( 88 | stuff_till(0.., (')', '(', '{')), // inner 89 | ')', 90 | )), 91 | ) 92 | .with_recognized() // outer 93 | .parse_next(input)?; 94 | Ok(Global { inner, outer }) 95 | } 96 | 97 | fn string_dq<'s>(input: &mut &'s str) -> PResult<&'s str> { 98 | let str_char = alt((none_of(['"']).void(), tag("\\\"").void())); 99 | let str_chars = recognize_repeat(0.., str_char); 100 | 101 | preceded('"', cut_err(terminated(str_chars, '"'))).parse_next(input) 102 | } 103 | 104 | fn string_sq<'s>(input: &mut &'s str) -> PResult<&'s str> { 105 | let str_char = alt((none_of(['\'']).void(), tag("\\'").void())); 106 | let str_chars = recognize_repeat(0.., str_char); 107 | 108 | preceded('\'', cut_err(terminated(str_chars, '\''))).parse_next(input) 109 | } 110 | 111 | fn string<'s>(input: &mut &'s str) -> PResult<&'s str> { 112 | alt((string_dq, string_sq)).parse_next(input) 113 | } 114 | 115 | /// Behaves like take_till except it finds and parses strings and 116 | /// comments (allowing those to contain the end condition characters). 117 | pub fn stuff_till<'s>( 118 | range: impl Into, 119 | list: impl ContainsToken, 120 | ) -> impl Parser<&'s str, &'s str, ContextError> { 121 | recognize_repeat( 122 | range, 123 | alt(( 124 | string.void(), 125 | block_comment.void(), 126 | line_comment.void(), 127 | sass_interpolation.void(), 128 | '/'.void(), 129 | '#'.void(), 130 | take_till(1.., ('\'', '"', '/', '#', list)).void(), 131 | )), 132 | ) 133 | } 134 | 135 | fn selector<'s>(input: &mut &'s str) -> PResult>> { 136 | fold_repeat( 137 | 1.., 138 | alt(( 139 | class.map(|c| Some(CssFragment::Class(c))), 140 | global.map(|g| Some(CssFragment::Global(g))), 141 | ':'.map(|_| None), 142 | stuff_till(1.., ('.', ';', '{', '}', ':')).map(|_| None), 143 | )), 144 | Vec::new, 145 | |mut acc, item| { 146 | if let Some(item) = item { 147 | acc.push(item); 148 | } 149 | acc 150 | }, 151 | ) 152 | .parse_next(input) 153 | } 154 | 155 | fn declaration<'s>(input: &mut &'s str) -> PResult<&'s str> { 156 | ( 157 | (opt('$'), identifier), 158 | ws, 159 | ':', 160 | terminated( 161 | stuff_till(1.., (';', '{', '}')), 162 | alt((';', peek('}'))), // semicolon is optional if it's the last element in a rule block 163 | ), 164 | ) 165 | .recognize() 166 | .parse_next(input) 167 | } 168 | 169 | fn style_rule_block_statement<'s>(input: &mut &'s str) -> PResult>> { 170 | let content = alt(( 171 | declaration.map(|_| Vec::new()), // 172 | at_rule, 173 | style_rule, 174 | )); 175 | delimited(ws, content, ws).parse_next(input) 176 | } 177 | 178 | fn style_rule_block_contents<'s>(input: &mut &'s str) -> PResult>> { 179 | fold_repeat( 180 | 0.., 181 | style_rule_block_statement, 182 | Vec::new, 183 | |mut acc, mut item| { 184 | acc.append(&mut item); 185 | acc 186 | }, 187 | ) 188 | .parse_next(input) 189 | } 190 | 191 | fn style_rule_block<'s>(input: &mut &'s str) -> PResult>> { 192 | preceded( 193 | '{', 194 | cut_err(terminated(style_rule_block_contents, (ws, '}'))), 195 | ) 196 | .parse_next(input) 197 | } 198 | 199 | fn style_rule<'s>(input: &mut &'s str) -> PResult>> { 200 | let (mut classes, mut nested_classes) = (selector, style_rule_block).parse_next(input)?; 201 | classes.append(&mut nested_classes); 202 | Ok(classes) 203 | } 204 | 205 | fn at_rule<'s>(input: &mut &'s str) -> PResult>> { 206 | let (identifier, char) = preceded( 207 | '@', 208 | cut_err(( 209 | terminated(identifier, stuff_till(0.., ('{', '}', ';'))), 210 | alt(('{', ';', peek('}'))), 211 | )), 212 | ) 213 | .parse_next(input)?; 214 | 215 | if char != '{' { 216 | return Ok(vec![]); 217 | } 218 | 219 | match identifier { 220 | "media" | "layer" | "container" => { 221 | cut_err(terminated(style_rule_block_contents, '}')).parse_next(input) 222 | } 223 | _ => { 224 | cut_err(terminated(unknown_block_contents, '}')).parse_next(input)?; 225 | Ok(vec![]) 226 | } 227 | } 228 | // if identifier == "media" { 229 | // cut_err(terminated(style_rule_block_contents, '}')).parse_next(input) 230 | // } else { 231 | // cut_err(terminated(unknown_block_contents, '}')).parse_next(input)?; 232 | // Ok(vec![]) 233 | // } 234 | } 235 | 236 | fn unknown_block_contents<'s>(input: &mut &'s str) -> PResult<&'s str> { 237 | recognize_repeat( 238 | 0.., 239 | alt(( 240 | stuff_till(1.., ('{', '}')).void(), 241 | ('{', cut_err((unknown_block_contents, '}'))).void(), 242 | )), 243 | ) 244 | .parse_next(input) 245 | } 246 | 247 | #[cfg(test)] 248 | mod tests { 249 | use super::*; 250 | 251 | #[test] 252 | fn test_class() { 253 | let mut input = "._x1a2b Hello"; 254 | 255 | let r = class.parse_next(&mut input); 256 | assert_eq!(r, Ok("_x1a2b")); 257 | } 258 | 259 | #[test] 260 | fn test_selector() { 261 | let mut input = ".foo.bar [value=\"fa.sdasd\"] /* .banana */ // .apple \n \t .cry {"; 262 | 263 | let r = selector.parse_next(&mut input); 264 | assert_eq!( 265 | r, 266 | Ok(vec![ 267 | CssFragment::Class("foo"), 268 | CssFragment::Class("bar"), 269 | CssFragment::Class("cry") 270 | ]) 271 | ); 272 | 273 | let mut input = "{"; 274 | 275 | let r = selector.recognize().parse_next(&mut input); 276 | assert!(r.is_err()); 277 | } 278 | 279 | #[test] 280 | fn test_declaration() { 281 | let mut input = "background-color \t : red;"; 282 | 283 | let r = declaration.parse_next(&mut input); 284 | assert_eq!(r, Ok("background-color \t : red;")); 285 | 286 | let r = declaration.parse_next(&mut input); 287 | assert!(r.is_err()); 288 | } 289 | 290 | #[test] 291 | fn test_style_rule() { 292 | let mut input = ".foo.bar { 293 | background-color: red; 294 | .baz { 295 | color: blue; 296 | } 297 | $some-scss-var: 10px; 298 | @some-at-rule blah blah; 299 | @media blah .blah { 300 | .moo { 301 | color: red; 302 | } 303 | } 304 | @container (width > 700px) { 305 | .zoo { 306 | color: blue; 307 | } 308 | } 309 | }END"; 310 | 311 | let r = style_rule.parse_next(&mut input); 312 | assert_eq!( 313 | r, 314 | Ok(vec![ 315 | CssFragment::Class("foo"), 316 | CssFragment::Class("bar"), 317 | CssFragment::Class("baz"), 318 | CssFragment::Class("moo"), 319 | CssFragment::Class("zoo") 320 | ]) 321 | ); 322 | 323 | assert_eq!(input, "END"); 324 | } 325 | 326 | #[test] 327 | fn test_at_rule_simple() { 328 | let mut input = "@simple-rule blah \"asd;asd\" blah;"; 329 | 330 | let r = at_rule.parse_next(&mut input); 331 | assert_eq!(r, Ok(vec![])); 332 | 333 | assert!(input.is_empty()); 334 | } 335 | 336 | #[test] 337 | fn test_at_rule_unknown() { 338 | let mut input = "@unknown blah \"asdasd\" blah { 339 | bunch of stuff { 340 | // things inside { 341 | blah 342 | ' { ' 343 | } 344 | 345 | .bar { 346 | color: blue; 347 | 348 | .baz { 349 | color: green; 350 | } 351 | } 352 | }"; 353 | 354 | let r = at_rule.parse_next(&mut input); 355 | assert_eq!(r, Ok(vec![])); 356 | 357 | assert!(input.is_empty()); 358 | } 359 | 360 | #[test] 361 | fn test_at_rule_media() { 362 | let mut input = "@media blah \"asdasd\" blah { 363 | .foo { 364 | background-color: red; 365 | } 366 | 367 | .bar { 368 | color: blue; 369 | 370 | .baz { 371 | color: green; 372 | } 373 | } 374 | }"; 375 | 376 | let r = at_rule.parse_next(&mut input); 377 | assert_eq!( 378 | r, 379 | Ok(vec![ 380 | CssFragment::Class("foo"), 381 | CssFragment::Class("bar"), 382 | CssFragment::Class("baz") 383 | ]) 384 | ); 385 | 386 | assert!(input.is_empty()); 387 | } 388 | 389 | #[test] 390 | fn test_at_rule_layer() { 391 | let mut input = "@layer test { 392 | .foo { 393 | background-color: red; 394 | } 395 | 396 | .bar { 397 | color: blue; 398 | 399 | .baz { 400 | color: green; 401 | } 402 | } 403 | }"; 404 | 405 | let r = at_rule.parse_next(&mut input); 406 | assert_eq!( 407 | r, 408 | Ok(vec![ 409 | CssFragment::Class("foo"), 410 | CssFragment::Class("bar"), 411 | CssFragment::Class("baz") 412 | ]) 413 | ); 414 | 415 | assert!(input.is_empty()); 416 | } 417 | 418 | #[test] 419 | fn test_top_level() { 420 | let mut input = "// tool.module.scss 421 | 422 | .default_border { 423 | border-color: lch(100% 10 10); 424 | border-style: dashed double; 425 | border-radius: 30px; 426 | 427 | } 428 | 429 | @media testing { 430 | .foo { 431 | color: red; 432 | } 433 | } 434 | 435 | @layer { 436 | .foo { 437 | color: blue; 438 | } 439 | } 440 | 441 | @layer foo; 442 | 443 | @debug 1+2 * 3==1+(2 * 3); // true 444 | 445 | .container { 446 | padding: 1em; 447 | border: 2px solid; 448 | border-color: lch(100% 10 10); 449 | border-style: dashed double; 450 | border-radius: 30px; 451 | margin: 1em; 452 | background-color: lch(45% 9.5 140.4); 453 | 454 | .bar { 455 | color: red; 456 | } 457 | } 458 | 459 | @debug 1+2 * 3==1+(2 * 3); // true 460 | "; 461 | 462 | let r = style_rule_block_contents.parse_next(&mut input); 463 | assert_eq!( 464 | r, 465 | Ok(vec![ 466 | CssFragment::Class("default_border"), 467 | CssFragment::Class("foo"), 468 | CssFragment::Class("foo"), 469 | CssFragment::Class("container"), 470 | CssFragment::Class("bar"), 471 | ]) 472 | ); 473 | 474 | println!("{input}"); 475 | assert!(input.is_empty()); 476 | } 477 | 478 | #[test] 479 | fn test_sass_interpolation() { 480 | let mut input = "#{$test-test}END"; 481 | 482 | let r = sass_interpolation.parse_next(&mut input); 483 | assert_eq!(r, Ok("#{$test-test}")); 484 | 485 | assert_eq!(input, "END"); 486 | 487 | let mut input = "#{$test-test 488 | }END"; 489 | let r = sass_interpolation.parse_next(&mut input); 490 | assert!(r.is_err()); 491 | 492 | let mut input = "#{$test-test"; 493 | let r = sass_interpolation.parse_next(&mut input); 494 | assert!(r.is_err()); 495 | 496 | let mut input = "#{$test-te{st}"; 497 | let r = sass_interpolation.parse_next(&mut input); 498 | assert!(r.is_err()); 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /internal/stylance-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stylance-macros" 3 | edition = "2021" 4 | version.workspace = true 5 | license.workspace = true 6 | repository.workspace = true 7 | description = "Internal crate used by stylance" 8 | 9 | [lib] 10 | proc-macro = true 11 | 12 | [features] 13 | nightly = [] 14 | 15 | [dependencies] 16 | stylance-core = { workspace = true } 17 | anyhow = "1.0.79" 18 | proc-macro2 = "1.0.71" 19 | quote = "1.0.33" 20 | syn = { version = "2.0.43", features = ["extra-traits"] } 21 | -------------------------------------------------------------------------------- /internal/stylance-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(feature = "nightly", feature(proc_macro_span))] 2 | 3 | use std::{env, path::Path}; 4 | 5 | use anyhow::Context as _; 6 | use proc_macro::TokenStream; 7 | use proc_macro2::{Ident, Span}; 8 | use quote::{quote, quote_spanned}; 9 | use syn::{parse_macro_input, LitStr}; 10 | 11 | fn try_import_style_classes_with_path( 12 | manifest_path: &Path, 13 | file_path: &Path, 14 | identifier_span: Span, 15 | ) -> anyhow::Result { 16 | let config = stylance_core::load_config(manifest_path)?; 17 | let (_, classes) = stylance_core::get_classes(manifest_path, file_path, &config)?; 18 | 19 | let binding = file_path.canonicalize().unwrap(); 20 | let full_path = binding.to_string_lossy(); 21 | 22 | let identifiers = classes 23 | .iter() 24 | .map(|class| Ident::new(&class.original_name.replace('-', "_"), identifier_span)) 25 | .collect::>(); 26 | 27 | let output_fields = classes.iter().zip(identifiers).map(|(class, class_ident)| { 28 | let class_str = &class.hashed_name; 29 | quote_spanned!(identifier_span => 30 | #[allow(non_upper_case_globals)] 31 | pub const #class_ident: &str = #class_str; 32 | ) 33 | }); 34 | 35 | Ok(quote! { 36 | const _ : &[u8] = include_bytes!(#full_path); 37 | #(#output_fields )* 38 | } 39 | .into()) 40 | } 41 | 42 | fn try_import_style_classes(input: &LitStr) -> anyhow::Result { 43 | let manifest_dir_env = 44 | env::var_os("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR env var not found")?; 45 | let manifest_path = Path::new(&manifest_dir_env); 46 | let file_path = manifest_path.join(Path::new(&input.value())); 47 | 48 | try_import_style_classes_with_path(manifest_path, &file_path, input.span()) 49 | } 50 | 51 | #[proc_macro] 52 | pub fn import_style_classes(input: TokenStream) -> TokenStream { 53 | let input = parse_macro_input!(input as LitStr); 54 | 55 | match try_import_style_classes(&input) { 56 | Ok(ts) => ts, 57 | Err(err) => syn::Error::new_spanned(&input, err.to_string()) 58 | .to_compile_error() 59 | .into(), 60 | } 61 | } 62 | 63 | #[cfg(feature = "nightly")] 64 | fn try_import_style_classes_rel(input: &LitStr) -> anyhow::Result { 65 | let manifest_dir_env = 66 | env::var_os("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR env var not found")?; 67 | let manifest_path = Path::new(&manifest_dir_env); 68 | 69 | let Some(source_path) = proc_macro::Span::call_site().source().local_file() else { 70 | // It would make sense to error here but currently rust analyzer is returning None when 71 | // the normal build would return the path. 72 | // For this reason we bail silently creating no code. 73 | return Ok(TokenStream::new()); 74 | }; 75 | 76 | let css_path = source_path 77 | .parent() 78 | .expect("Macro source path should have a parent dir") 79 | .join(input.value()); 80 | 81 | try_import_style_classes_with_path(manifest_path, &css_path, input.span()) 82 | } 83 | 84 | #[cfg(feature = "nightly")] 85 | #[proc_macro] 86 | pub fn import_style_classes_rel(input: TokenStream) -> TokenStream { 87 | let input = parse_macro_input!(input as LitStr); 88 | 89 | match try_import_style_classes_rel(&input) { 90 | Ok(ts) => ts, 91 | Err(err) => syn::Error::new_spanned(&input, err.to_string()) 92 | .to_compile_error() 93 | .into(), 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | build-docs: 2 | RUSTDOCFLAGS="--cfg docsrs" cargo doc -p stylance -p stylance-cli --all-features --open -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | tag = false 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /stylance-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stylance-cli" 3 | edition = "2021" 4 | authors.workspace = true 5 | version.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | keywords.workspace = true 9 | categories.workspace = true 10 | readme = "README.md" 11 | description = "Cli tool for bundling stylance scoped CSS files." 12 | 13 | [features] 14 | default = ["binary"] 15 | binary = ["clap", "notify", "tokio", "tokio-stream"] 16 | 17 | [dependencies] 18 | walkdir = "2.4.0" 19 | stylance-core = { workspace = true } 20 | anyhow = "1.0.79" 21 | 22 | clap = { version = "4.4.12", features = ["derive", "cargo"], optional = true } 23 | notify = { version = "6.1.1", default-features = false, features = [ 24 | "macos_kqueue", 25 | ], optional = true } 26 | tokio = { version = "1.35.1", features = [ 27 | "macros", 28 | "rt", 29 | "sync", 30 | "time", 31 | ], optional = true } 32 | tokio-stream = { version = "0.1.14", optional = true } 33 | 34 | [[bin]] 35 | name = "stylance" 36 | path = "./src/main.rs" 37 | doc = false 38 | required-features = ["binary"] 39 | -------------------------------------------------------------------------------- /stylance-cli/README.md: -------------------------------------------------------------------------------- 1 | # Stylance-cli [![crates.io](https://img.shields.io/crates/v/stylance-cli.svg)](https://crates.io/crates/stylance-cli) 2 | 3 | Stylance-cli is the build tool for [Stylance](https://github.com/basro/stylance-rs). 4 | 5 | It reads your css module files and transforms them in the following way: 6 | 7 | - Adds a hash as suffix to every classname found. (`.class` will become `.class-63gi2cY`) 8 | - Removes any instance of `:global(contents)` while leaving contents intact. 9 | 10 | ## Installation 11 | 12 | Install stylance cli: 13 | 14 | ```cli 15 | cargo install stylance-cli 16 | ``` 17 | 18 | ## Usage 19 | 20 | Run stylance cli: 21 | 22 | ```cli 23 | stylance ./path/to/crate/dir/ --output-file ./bundled.scss 24 | ``` 25 | 26 | The first argument is the path to the directory containing the Cargo.toml of your package/crate. 27 | 28 | This will find all the files ending with `.module.scss` and `.module.css`and bundle them into `./bundled.scss`, all classes will be modified to include a hash that matches the one the `import_crate_style!` macro produces. 29 | 30 | Resulting `./bundled.scss`: 31 | 32 | ```css 33 | .header-f45126d { 34 | background-color: red; 35 | } 36 | ``` 37 | 38 | By default stylance cli will only look for css modules inside the crate's `./src/` folder. This can be [configured](#configuration). 39 | 40 | ### Use `output-dir` for better SASS compatibility 41 | 42 | If you plan to use the output of stylance in a SASS project (by importing it from a .scss file), then I recommend using the `output-dir` option instead of `output-file`. 43 | 44 | ```bash 45 | stylance ./path/to/crate/dir/ --output-dir ./styles/ 46 | ``` 47 | 48 | This will create the folder `./styles/stylance/`. 49 | 50 | When using --output-dir (or output_dir in package.metadata.stylance) stylance will not bundle the transformed module files, instead it will create a "stylance" folder in the specified output-dir path which will contain all the transformed css modules inside as individual files. 51 | 52 | This "stylance" folder also includes an \_index.scss file that imports all the transformed scss modules. 53 | 54 | You can then use `@use "path/to/the/folder/stylance"` to import the css modules into your sass project. 55 | 56 | ### Watching for changes 57 | 58 | During development it is convenient to use sylance cli in watch mode: 59 | 60 | ```cli 61 | stylance --watch --output-file ./bundled.scss ./path/to/crate/dir/ 62 | ``` 63 | 64 | The stylance process will then watch any `.module.css` and `.module.scss` files for changes and automatically rebuild the output file. 65 | 66 | ## Configuration 67 | 68 | Stylance configuration lives inside the Cargo.toml file of your crate. 69 | 70 | All configuration settings are optional. 71 | 72 | ```toml 73 | [package.metadata.stylance] 74 | 75 | # output_file 76 | # When set, stylance-cli will bundle all css module files 77 | # into by concatenating them and put the result in this file. 78 | output_file = "./styles/bundle.scss" 79 | 80 | # output_dir 81 | # When set, stylance-cli will create a folder named "stylance" inside 82 | # the output_dir directory. 83 | # The stylance folder will be populated with one file per detected css module 84 | # and one _all.scss file that contains one `@use "file.module-hash.scss";` statement 85 | # per module file. 86 | # You can use that file to import all your modules into your main scss project. 87 | output_dir = "./styles/" 88 | 89 | # folders 90 | # folders in which stylance cli will look for css module files. 91 | # defaults to ["./src/"] 92 | folders = ["./src/", "./styles/"] 93 | 94 | # extensions 95 | # files ending with these extensions will be considered to be 96 | # css modules by stylance cli and will be included in the output 97 | # bundle 98 | # defaults to [".module.scss", ".module.css"] 99 | extensions = [".module.scss", ".module.css"] 100 | 101 | # scss_prelude 102 | # When generating an scss file stylance-cli will prepend this string 103 | # Useful to include a @use statement to all scss modules. 104 | scss_prelude = '@use "../path/to/prelude" as *;' 105 | 106 | # hash_len 107 | # Controls how long the hash name used in scoped classes should be. 108 | # It is safe to lower this as much as you want, stylance cli will produce an 109 | # error if two files end up with colliding hashes. 110 | # defaults to 7 111 | hash_len = 7 112 | 113 | # class_name_pattern 114 | # Controls the shape of the transformed scoped class names. 115 | # [name] will be replaced with the original class name 116 | # [hash] will be replaced with the hash of css module file path. 117 | # defaults to "[name]-[hash]" 118 | class_name_pattern = "my-project-[name]-[hash]" 119 | ``` 120 | -------------------------------------------------------------------------------- /stylance-cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | collections::HashMap, 4 | fs::{self, File}, 5 | io::{BufWriter, Write}, 6 | path::Path, 7 | }; 8 | 9 | use anyhow::bail; 10 | pub use stylance_core::Config; 11 | use stylance_core::ModifyCssResult; 12 | use walkdir::WalkDir; 13 | 14 | pub fn run(manifest_dir: &Path, config: &Config) -> anyhow::Result<()> { 15 | println!("Running stylance"); 16 | run_silent(manifest_dir, config, |file_path| { 17 | println!("{}", file_path.display()) 18 | }) 19 | } 20 | 21 | pub fn run_silent( 22 | manifest_dir: &Path, 23 | config: &Config, 24 | mut file_visit_callback: impl FnMut(&Path), 25 | ) -> anyhow::Result<()> { 26 | let mut modified_css_files = Vec::new(); 27 | 28 | for folder in &config.folders { 29 | for (entry, meta) in WalkDir::new(manifest_dir.join(folder)) 30 | .into_iter() 31 | .filter_map(|e| e.ok()) 32 | .filter_map(|entry| entry.metadata().ok().map(|meta| (entry, meta))) 33 | { 34 | if meta.is_file() { 35 | let path_str = entry.path().to_string_lossy(); 36 | if config.extensions.iter().any(|ext| path_str.ends_with(ext)) { 37 | file_visit_callback(entry.path()); 38 | modified_css_files.push(stylance_core::load_and_modify_css( 39 | manifest_dir, 40 | entry.path(), 41 | config, 42 | )?); 43 | } 44 | } 45 | } 46 | } 47 | 48 | { 49 | // Verify that there are no hash collisions 50 | let mut map = HashMap::new(); 51 | for file in modified_css_files.iter() { 52 | if let Some(previous_file) = map.insert(&file.hash, file) { 53 | bail!( 54 | "The following files had a hash collision:\n{}\n{}\nConsider increasing the hash_len setting.", 55 | file.path.to_string_lossy(), 56 | previous_file.path.to_string_lossy() 57 | ); 58 | } 59 | } 60 | } 61 | 62 | { 63 | // sort by (filename, path) 64 | fn key(a: &ModifyCssResult) -> (&std::ffi::OsStr, &String) { 65 | ( 66 | a.path.file_name().expect("should be a file"), 67 | &a.normalized_path_str, 68 | ) 69 | } 70 | modified_css_files.sort_unstable_by(|a, b| key(a).cmp(&key(b))); 71 | } 72 | 73 | if let Some(output_file) = &config.output_file { 74 | if let Some(parent) = output_file.parent() { 75 | fs::create_dir_all(parent)?; 76 | } 77 | 78 | let mut file = BufWriter::new(File::create(output_file)?); 79 | 80 | if let Some(scss_prelude) = &config.scss_prelude { 81 | if output_file 82 | .extension() 83 | .filter(|ext| ext.to_string_lossy() == "scss") 84 | .is_some() 85 | { 86 | file.write_all(scss_prelude.as_bytes())?; 87 | file.write_all(b"\n\n")?; 88 | } 89 | } 90 | 91 | file.write_all( 92 | modified_css_files 93 | .iter() 94 | .map(|r| r.contents.as_ref()) 95 | .collect::>() 96 | .join("\n\n") 97 | .as_bytes(), 98 | )?; 99 | } 100 | 101 | if let Some(output_dir) = &config.output_dir { 102 | let output_dir = output_dir.join("stylance"); 103 | fs::create_dir_all(&output_dir)?; 104 | 105 | let entries = fs::read_dir(&output_dir)?; 106 | 107 | for entry in entries { 108 | let entry = entry?; 109 | let file_type = entry.file_type()?; 110 | 111 | if file_type.is_file() { 112 | fs::remove_file(entry.path())?; 113 | } 114 | } 115 | 116 | let mut new_files = Vec::new(); 117 | for modified_css in modified_css_files { 118 | let extension = modified_css 119 | .path 120 | .extension() 121 | .map(|e| e.to_string_lossy()) 122 | .filter(|e| e == "css") 123 | .unwrap_or(Cow::from("scss")); 124 | 125 | let new_file_name = format!( 126 | "{}-{}.{extension}", 127 | modified_css 128 | .path 129 | .file_stem() 130 | .expect("This path should be a file") 131 | .to_string_lossy(), 132 | modified_css.hash 133 | ); 134 | 135 | new_files.push(new_file_name.clone()); 136 | 137 | let file_path = output_dir.join(new_file_name); 138 | let mut file = BufWriter::new(File::create(file_path)?); 139 | 140 | if let Some(scss_prelude) = &config.scss_prelude { 141 | if extension == "scss" { 142 | file.write_all(scss_prelude.as_bytes())?; 143 | file.write_all(b"\n\n")?; 144 | } 145 | } 146 | 147 | file.write_all(modified_css.contents.as_bytes())?; 148 | } 149 | 150 | let mut file = File::create(output_dir.join("_index.scss"))?; 151 | file.write_all( 152 | new_files 153 | .iter() 154 | .map(|f| format!("@use \"{f}\";\n")) 155 | .collect::>() 156 | .join("") 157 | .as_bytes(), 158 | )?; 159 | } 160 | 161 | Ok(()) 162 | } 163 | -------------------------------------------------------------------------------- /stylance-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | sync::Arc, 4 | time::Duration, 5 | }; 6 | use stylance_cli::run; 7 | use stylance_core::{load_config, Config}; 8 | 9 | use clap::Parser; 10 | use notify::{Event, RecursiveMode, Watcher}; 11 | use tokio::{sync::mpsc, task::spawn_blocking}; 12 | use tokio_stream::{Stream, StreamExt}; 13 | 14 | #[derive(Parser)] 15 | #[command(author, version, about, long_about = None, arg_required_else_help = true)] 16 | struct Cli { 17 | /// The path where your crate's Cargo toml is located 18 | manifest_dir: PathBuf, 19 | 20 | /// Generate a file with all css modules concatenated 21 | #[arg(long)] 22 | output_file: Option, 23 | 24 | /// Generate a "stylance" directory in this path with all css modules inside 25 | #[arg(long)] 26 | output_dir: Option, 27 | 28 | /// The folders in your crate where stylance will look for css modules 29 | /// 30 | /// The paths are relative to the manifest_dir and must not land outside of manifest_dir. 31 | #[arg(short, long, num_args(1))] 32 | folder: Vec, 33 | 34 | /// Watch the fylesystem for changes to the css module files 35 | #[arg(short, long)] 36 | watch: bool, 37 | } 38 | 39 | struct RunParams { 40 | manifest_dir: PathBuf, 41 | config: Config, 42 | } 43 | 44 | #[tokio::main(flavor = "current_thread")] 45 | async fn main() -> anyhow::Result<()> { 46 | let cli = Cli::parse(); 47 | 48 | let run_params = make_run_params(&cli).await?; 49 | 50 | run(&run_params.manifest_dir, &run_params.config)?; 51 | 52 | if cli.watch { 53 | watch(cli, run_params).await?; 54 | } 55 | 56 | Ok(()) 57 | } 58 | 59 | async fn make_run_params(cli: &Cli) -> anyhow::Result { 60 | let manifest_dir = cli.manifest_dir.clone(); 61 | let mut config = spawn_blocking(move || load_config(&manifest_dir)).await??; 62 | 63 | config.output_file = cli.output_file.clone().or_else(|| { 64 | config 65 | .output_file 66 | .as_ref() 67 | .map(|p| cli.manifest_dir.join(p)) 68 | }); 69 | 70 | config.output_dir = cli 71 | .output_dir 72 | .clone() 73 | .or_else(|| config.output_dir.as_ref().map(|p| cli.manifest_dir.join(p))); 74 | 75 | if !cli.folder.is_empty() { 76 | config.folders.clone_from(&cli.folder); 77 | } 78 | 79 | Ok(RunParams { 80 | manifest_dir: cli.manifest_dir.clone(), 81 | config, 82 | }) 83 | } 84 | 85 | fn watch_file(path: &Path) -> anyhow::Result> { 86 | let (events_tx, events_rx) = mpsc::unbounded_channel(); 87 | let mut watcher = notify::recommended_watcher({ 88 | let events_tx = events_tx.clone(); 89 | move |_| { 90 | let _ = events_tx.send(()); 91 | } 92 | })?; 93 | 94 | watcher.watch(path, RecursiveMode::NonRecursive)?; 95 | 96 | tokio::spawn(async move { 97 | events_tx.closed().await; 98 | drop(watcher); 99 | }); 100 | 101 | Ok(events_rx) 102 | } 103 | 104 | fn watch_folders(paths: &Vec) -> anyhow::Result> { 105 | let (events_tx, events_rx) = mpsc::unbounded_channel(); 106 | let mut watcher = notify::recommended_watcher({ 107 | let events_tx = events_tx.clone(); 108 | move |e: notify::Result| { 109 | if let Ok(e) = e { 110 | for path in e.paths { 111 | if events_tx.send(path).is_err() { 112 | break; 113 | } 114 | } 115 | } 116 | } 117 | })?; 118 | 119 | for path in paths { 120 | watcher.watch(path, RecursiveMode::Recursive)?; 121 | } 122 | 123 | tokio::spawn(async move { 124 | events_tx.closed().await; 125 | drop(watcher); 126 | }); 127 | 128 | Ok(events_rx) 129 | } 130 | 131 | async fn debounced_next(s: &mut (impl Stream + Unpin)) -> Option<()> { 132 | s.next().await; 133 | 134 | loop { 135 | let result = tokio::time::timeout(Duration::from_millis(50), s.next()).await; 136 | match result { 137 | Ok(Some(_)) => {} 138 | Ok(None) => return None, 139 | Err(_) => return Some(()), 140 | } 141 | } 142 | } 143 | 144 | async fn watch(cli: Cli, run_params: RunParams) -> anyhow::Result<()> { 145 | let (run_params_tx, mut run_params) = tokio::sync::watch::channel(Arc::new(run_params)); 146 | 147 | let manifest_dir = cli.manifest_dir.clone(); 148 | 149 | // Watch Cargo.toml to update the current run_params. 150 | let cargo_toml_events = watch_file(&manifest_dir.join("Cargo.toml").canonicalize()?)?; 151 | tokio::spawn(async move { 152 | let mut stream = tokio_stream::wrappers::UnboundedReceiverStream::new(cargo_toml_events); 153 | while debounced_next(&mut stream).await.is_some() { 154 | match make_run_params(&cli).await { 155 | Ok(new_params) => { 156 | if run_params_tx.send(Arc::new(new_params)).is_err() { 157 | return; 158 | }; 159 | } 160 | Err(e) => { 161 | eprintln!("{e}"); 162 | } 163 | } 164 | } 165 | }); 166 | 167 | // Wait for run_events to run the stylance process. 168 | let (run_events_tx, run_events) = mpsc::channel(1); 169 | tokio::spawn({ 170 | let run_params = run_params.clone(); 171 | async move { 172 | let mut stream = tokio_stream::wrappers::ReceiverStream::new(run_events); 173 | while (debounced_next(&mut stream).await).is_some() { 174 | let run_params = run_params.borrow().clone(); 175 | if let Ok(Err(e)) = 176 | spawn_blocking(move || run(&run_params.manifest_dir, &run_params.config)).await 177 | { 178 | eprintln!("{e}"); 179 | } 180 | } 181 | } 182 | }); 183 | 184 | loop { 185 | // Watch the folders from the current run_params 186 | let mut events = watch_folders( 187 | &run_params 188 | .borrow() 189 | .config 190 | .folders 191 | .iter() 192 | .map(|f| manifest_dir.join(f)) 193 | .collect(), 194 | )?; 195 | 196 | // With the events from the watched folder trigger run_events if they match the extensions of the config. 197 | let watch_folders = { 198 | let run_params = run_params.borrow().clone(); 199 | let run_events_tx = run_events_tx.clone(); 200 | async move { 201 | while let Some(path) = events.recv().await { 202 | let str_path = path.to_string_lossy(); 203 | if run_params 204 | .config 205 | .extensions 206 | .iter() 207 | .any(|ext| str_path.ends_with(ext)) 208 | { 209 | let _ = run_events_tx.try_send(()); 210 | break; 211 | } 212 | } 213 | } 214 | }; 215 | 216 | // Run until the config has changed 217 | tokio::select! { 218 | _ = watch_folders => {}, 219 | _ = run_params.changed() => { 220 | let _ = run_events_tx.try_send(()); // Config changed so lets trigger a run 221 | }, 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /stylance/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stylance" 3 | edition = "2021" 4 | authors.workspace = true 5 | version.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | readme.workspace = true 9 | keywords.workspace = true 10 | categories.workspace = true 11 | description = "Scoped CSS for rust projects" 12 | documentation = "https://docs.rs/stylance" 13 | 14 | [lib] 15 | doctest = false 16 | 17 | [features] 18 | nightly = ["stylance-macros/nightly"] 19 | 20 | [dependencies] 21 | stylance-macros = { workspace = true } 22 | 23 | [package.metadata.stylance] 24 | folders = ["examples"] 25 | output_dir = "../styles/" 26 | 27 | [package.metadata.docs.rs] 28 | all-features = true 29 | rustdoc-args = ["--cfg", "docsrs"] 30 | -------------------------------------------------------------------------------- /stylance/examples/usage/main.rs: -------------------------------------------------------------------------------- 1 | mod module; 2 | 3 | use stylance::{classes, import_crate_style}; 4 | 5 | use module::style as module_style; 6 | 7 | import_crate_style!(my_style, "examples/usage/style1.module.scss"); 8 | 9 | fn main() { 10 | println!( 11 | "my_style 'examples/usage/style1.module.scss' \nheader: {}", 12 | my_style::header 13 | ); 14 | println!( 15 | "module_style 'examples/usage/style2.module.scss' \nheader: {}", 16 | module_style::header 17 | ); 18 | 19 | // Easily combine two or more classes using the classes! macro 20 | let active_tab = 0; // set to 1 to disable the active class! 21 | println!( 22 | "The two classes combined: '{}'", 23 | classes!( 24 | "some-global-class", 25 | my_style::header, 26 | my_style::contents, 27 | module_style::header, 28 | (active_tab == 0).then_some(my_style::active) // conditionally activate a global style 29 | ), 30 | ); 31 | 32 | // With the nightly feature you get access to import_style! which uses 33 | // paths relative to the rust file were it is called. 34 | // Requires rust nightly toolchain 35 | #[cfg(feature = "nightly")] 36 | { 37 | stylance::import_style!( 38 | #[allow(dead_code)] 39 | rel_path_style, 40 | "style1.module.scss" 41 | ); 42 | println!( 43 | "rel_path_style 'style1.module.scss' \nheader: {}", 44 | rel_path_style::header 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /stylance/examples/usage/module.rs: -------------------------------------------------------------------------------- 1 | use stylance::import_crate_style; 2 | 3 | // Add pub if you your style module definition to be public. 4 | import_crate_style!(pub style, "examples/usage/style2.module.scss"); 5 | -------------------------------------------------------------------------------- /stylance/examples/usage/style1.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | color: red; 3 | } 4 | 5 | .contents { 6 | background-color: cyan; 7 | 8 | /* Use :global() to address non module-scoped classes. */ 9 | & :global(.paragraph) { 10 | color: orange; 11 | } 12 | } 13 | 14 | .active { 15 | background-color: yellow; 16 | } 17 | 18 | @media only screen and (min-width: 40em) { 19 | .header { 20 | color: blue; 21 | } 22 | 23 | .contents { 24 | background-color: magenta; 25 | } 26 | } -------------------------------------------------------------------------------- /stylance/examples/usage/style2.module.scss: -------------------------------------------------------------------------------- 1 | /* This header will not collide with the one in style1.module.scss */ 2 | .header { 3 | border: 1px solid black; 4 | } -------------------------------------------------------------------------------- /stylance/release.toml: -------------------------------------------------------------------------------- 1 | tag-prefix = "" 2 | tag = true 3 | -------------------------------------------------------------------------------- /stylance/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # About stylance 2 | //! 3 | //! Stylance is a scoped CSS library for rust. 4 | //! 5 | //! Use it in conjunction with [stylance-cli](https://crates.io/crates/stylance-cli). 6 | //! 7 | //! # Feature flags 8 | //! 9 | //! - `nightly`: Enables importing styles with paths relative to the rust file where the macro was called. 10 | //! 11 | //! # Usage 12 | //! 13 | //! Create a .module.css file inside your rust source directory 14 | //! ```scss 15 | //! // src/component1/style.module.css 16 | //! 17 | //! .header { 18 | //! color: red; 19 | //! } 20 | //! 21 | //! .contents { 22 | //! border: 1px solid black; 23 | //! } 24 | //! ``` 25 | //! 26 | //! Then import that file from your rust code: 27 | //! ```rust 28 | //! stylance::import_crate_style!(style, "src/component1/style.module.css"); 29 | //! 30 | //! fn use_style() { 31 | //! println!("{}", style::header); 32 | //! } 33 | //! ``` 34 | //! 35 | //! ### Accessing non-scoped global class names with `:global(.class)` 36 | //! 37 | //! Sometimes you may want to use an external classname in your .module.css file. 38 | //! 39 | //! For this you can wrap the global class name with `:global()`, this instructs stylance to leave that class name alone. 40 | //! 41 | //! ```css 42 | //! .contents :global(.paragraph) { 43 | //! color: blue; 44 | //! } 45 | //! ``` 46 | //! 47 | //! This will expand to 48 | //! ```css 49 | //! .contents-539306b .paragraph { 50 | //! color: blue; 51 | //! } 52 | //! ``` 53 | //! 54 | //! # Transforming and bundling your .module.css files 55 | //! 56 | //! To transform your .module.css and .module.scss into a bundled css file use [stylance-cli](https://crates.io/crates/stylance-cli). 57 | //! 58 | //! 59 | 60 | #![cfg_attr(docsrs, feature(doc_cfg))] 61 | 62 | #[doc(hidden)] 63 | pub mod internal { 64 | /// MaybeStr Wraps an Option<&str> and implements From trait for various 65 | /// types. 66 | /// Used by JoinClasses and the classes! macro to accept various types. 67 | pub struct MaybeStr<'a>(Option<&'a str>); 68 | 69 | pub use stylance_macros::*; 70 | 71 | fn join_opt_str_iter<'a, Iter>(iter: &mut Iter) -> String 72 | where 73 | Iter: Iterator + Clone, 74 | { 75 | let Some(first) = iter.next() else { 76 | return String::new(); 77 | }; 78 | 79 | let size = first.len() + iter.clone().map(|v| v.len() + 1).sum::(); 80 | 81 | let mut result = String::with_capacity(size); 82 | result.push_str(first); 83 | 84 | for v in iter { 85 | result.push(' '); 86 | result.push_str(v); 87 | } 88 | 89 | debug_assert_eq!(result.len(), size); 90 | 91 | result 92 | } 93 | 94 | pub fn join_maybe_str_slice(slice: &[MaybeStr<'_>]) -> String { 95 | let mut iter = slice.iter().flat_map(|c| c.0); 96 | join_opt_str_iter(&mut iter) 97 | } 98 | 99 | impl<'a> From<&'a str> for MaybeStr<'a> { 100 | fn from(value: &'a str) -> Self { 101 | MaybeStr::<'a>(Some(value)) 102 | } 103 | } 104 | 105 | impl<'a> From<&'a String> for MaybeStr<'a> { 106 | fn from(value: &'a String) -> Self { 107 | MaybeStr::<'a>(Some(value.as_ref())) 108 | } 109 | } 110 | 111 | impl<'a, T> From> for MaybeStr<'a> 112 | where 113 | T: AsRef + ?Sized, 114 | { 115 | fn from(value: Option<&'a T>) -> Self { 116 | Self(value.map(AsRef::as_ref)) 117 | } 118 | } 119 | 120 | impl<'a, T> From<&'a Option> for MaybeStr<'a> 121 | where 122 | T: AsRef, 123 | { 124 | fn from(value: &'a Option) -> Self { 125 | Self(value.as_ref().map(AsRef::as_ref)) 126 | } 127 | } 128 | } 129 | 130 | /// Reads a css file at compile time and generates a module containing the classnames found inside that css file. 131 | /// Path is relative to the file that called the macro. 132 | /// 133 | /// ### Syntax 134 | /// ```rust 135 | /// import_style!([#[attribute]] [pub] module_identifier, style_path); 136 | /// ``` 137 | /// - Optionally prefix attributes that will be added to the generated module. Particularly common is `#[allow(dead_code)]` to silence warnings from unused class names. 138 | /// - Optionally add pub keyword before `module_identifier` to make the generated module public. 139 | /// - `module_identifier`: This will be used as the name of the module generated by this macro. 140 | /// - `style_path`: This should be a string literal with the path to a css file inside your rust 141 | /// crate. The path is relative to the file where this macro was called from. 142 | /// 143 | /// ### Example 144 | /// ```rust 145 | /// // style.css is located in the same directory as this rust file. 146 | /// stylance::import_style!(#[allow(dead_code)] pub style, "style.css"); 147 | /// 148 | /// fn use_style() { 149 | /// println!("{}", style::header); 150 | /// } 151 | /// ``` 152 | /// 153 | /// ### Expands into 154 | /// 155 | /// ```rust 156 | /// pub mod style { 157 | /// pub const header: &str = "header-539306b"; 158 | /// pub const contents: &str = "contents-539306b"; 159 | /// } 160 | /// ``` 161 | #[cfg_attr(docsrs, doc(cfg(feature = "nightly")))] 162 | #[macro_export] 163 | macro_rules! import_style { 164 | ($(#[$meta:meta])* $vis:vis $ident:ident, $str:expr) => { 165 | $(#[$meta])* $vis mod $ident { 166 | ::stylance::internal::import_style_classes_rel!($str); 167 | } 168 | }; 169 | } 170 | 171 | /// Reads a css file at compile time and generates a module containing the classnames found inside that css file. 172 | /// 173 | /// ### Syntax 174 | /// ```rust 175 | /// import_crate_style!([#[attribute]] [pub] module_identifier, style_path); 176 | /// ``` 177 | /// - Optionally prefix attributes that will be added to the generated module. Particularly common is `#[allow(dead_code)]` to silence warnings from unused class names. 178 | /// - Optionally add pub keyword before `module_identifier` to make the generated module public. 179 | /// - `module_identifier`: This will be used as the name of the module generated by this macro. 180 | /// - `style_path`: This should be a string literal with the path to a css file inside your rust 181 | /// crate. The path must be relative to the cargo manifest directory (The directory that has Cargo.toml). 182 | /// 183 | /// ### Example 184 | /// ```rust 185 | /// stylance::import_crate_style!(pub style, "path/from/manifest_dir/to/style.css"); 186 | /// 187 | /// fn use_style() { 188 | /// println!("{}", style::header); 189 | /// } 190 | /// ``` 191 | /// 192 | /// ### Expands into 193 | /// 194 | /// ```rust 195 | /// pub mod style { 196 | /// pub const header: &str = "header-539306b"; 197 | /// pub const contents: &str = "contents-539306b"; 198 | /// } 199 | /// ``` 200 | #[macro_export] 201 | macro_rules! import_crate_style { 202 | ($(#[$meta:meta])* $vis:vis $ident:ident, $str:expr) => { 203 | $(#[$meta])* $vis mod $ident { 204 | ::stylance::internal::import_style_classes!($str); 205 | } 206 | }; 207 | } 208 | 209 | /// Utility trait for combining tuples of class names into a single string. 210 | pub trait JoinClasses { 211 | /// Join all elements of the tuple into a single string separating them with a single space character. 212 | /// 213 | /// Option elements of the tuple will be skipped if they are None. 214 | /// 215 | /// ### Example 216 | /// 217 | /// ```rust 218 | /// import_crate_style!(style, "tests/style.module.scss"); 219 | /// let current_page = 10; // Some variable to use in the condition 220 | /// 221 | /// let class_name = ( 222 | /// "header", // Global classname 223 | /// style::style1, // Stylance scoped classname 224 | /// if current_page == 10 { // Conditional class 225 | /// Some("active1") 226 | /// } else { 227 | /// None 228 | /// }, 229 | /// (current_page == 11).then_some("active2"), // Same as above but much nicer 230 | /// ) 231 | /// .join_classes(); 232 | /// 233 | /// // class_name is "header style1-a331da9 active1" 234 | /// ``` 235 | fn join_classes(self) -> String; 236 | } 237 | 238 | impl JoinClasses for &[internal::MaybeStr<'_>] { 239 | fn join_classes(self) -> String { 240 | internal::join_maybe_str_slice(self) 241 | } 242 | } 243 | 244 | macro_rules! impl_join_classes_for_tuples { 245 | (($($types:ident),*), ($($idx:tt),*)) => { 246 | impl<'a, $($types),*> JoinClasses for ($($types,)*) 247 | where 248 | $($types: Into>),* 249 | { 250 | fn join_classes(self) -> String { 251 | internal::join_maybe_str_slice([ 252 | $((self.$idx).into()),* 253 | ].as_slice()) 254 | } 255 | } 256 | }; 257 | } 258 | 259 | impl_join_classes_for_tuples!( 260 | (T1, T2), // 261 | (0, 1) 262 | ); 263 | impl_join_classes_for_tuples!( 264 | (T1, T2, T3), // 265 | (0, 1, 2) 266 | ); 267 | impl_join_classes_for_tuples!( 268 | (T1, T2, T3, T4), // 269 | (0, 1, 2, 3) 270 | ); 271 | impl_join_classes_for_tuples!( 272 | (T1, T2, T3, T4, T5), // 273 | (0, 1, 2, 3, 4) 274 | ); 275 | impl_join_classes_for_tuples!( 276 | (T1, T2, T3, T4, T5, T6), // 277 | (0, 1, 2, 3, 4, 5) 278 | ); 279 | impl_join_classes_for_tuples!( 280 | (T1, T2, T3, T4, T5, T6, T7), // 281 | (0, 1, 2, 3, 4, 5, 6) 282 | ); 283 | impl_join_classes_for_tuples!( 284 | (T1, T2, T3, T4, T5, T6, T7, T8), // 285 | (0, 1, 2, 3, 4, 5, 6, 7) 286 | ); 287 | impl_join_classes_for_tuples!( 288 | (T1, T2, T3, T4, T5, T6, T7, T8, T9), 289 | (0, 1, 2, 3, 4, 5, 6, 7, 8) 290 | ); 291 | impl_join_classes_for_tuples!( 292 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10), 293 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9) 294 | ); 295 | impl_join_classes_for_tuples!( 296 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11), 297 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) 298 | ); 299 | impl_join_classes_for_tuples!( 300 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12), 301 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) 302 | ); 303 | impl_join_classes_for_tuples!( 304 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13), 305 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) 306 | ); 307 | impl_join_classes_for_tuples!( 308 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14), 309 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13) 310 | ); 311 | impl_join_classes_for_tuples!( 312 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15), 313 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) 314 | ); 315 | impl_join_classes_for_tuples!( 316 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16), 317 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) 318 | ); 319 | impl_join_classes_for_tuples!( 320 | (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17), 321 | (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) 322 | ); 323 | 324 | /// Utility macro for joining multiple class names. 325 | /// 326 | /// The macro accepts `&str` `&String` and any refs of `T` where `T` implements `AsRef` 327 | /// 328 | /// It also accepts `Option` of those types, `None` values will be filtered from the list. 329 | /// 330 | /// Example 331 | /// 332 | /// ```rust 333 | /// let active_tab = 0; // set to 1 to disable the active class! 334 | /// let classes_string = classes!( 335 | /// "some-global-class", 336 | /// my_style::header, 337 | /// module_style::header, 338 | /// // conditionally activate a global style 339 | /// if active_tab == 0 { Some(my_style::active) } else { None } 340 | /// // The same can be expressed with then_some: 341 | /// (active_tab == 0).then_some(my_style::active) 342 | /// ); 343 | /// ``` 344 | #[macro_export] 345 | macro_rules! classes { 346 | () => { "" }; 347 | ($($exp:expr),+$(,)?) => { 348 | ::stylance::JoinClasses::join_classes([$($exp.into()),*].as_slice()) 349 | }; 350 | } 351 | -------------------------------------------------------------------------------- /stylance/tests/style.module.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | // Line comment 4 | 5 | /* block comment 6 | .class { 7 | { // Invalid syntax inside the comment to ensure this isn't parsed. 8 | } 9 | */ 10 | 11 | div.style1.style2[value="thing"] { 12 | color: red; // line comment after declaration 13 | background-color: black; 14 | } 15 | 16 | .style-with-dashes { 17 | color: red; 18 | } 19 | 20 | @media (max-width: 600px) { 21 | .style3 { 22 | background-color: #87ceeb; 23 | } 24 | } 25 | 26 | @font-face { 27 | font-family: "Trickster"; 28 | src: 29 | local("Trickster"), 30 | url("trickster-COLRv1.otf") format("opentype") tech(color-COLRv1), 31 | url("trickster-outline.otf") format("opentype"), 32 | url("trickster-outline.woff") format("woff"); 33 | } 34 | 35 | .style4 { 36 | .nested-style { 37 | color: red; 38 | } 39 | 40 | @media (max-width: 600px) { 41 | // scss style nested media query with declarations inside 42 | color: blue; 43 | } 44 | } 45 | 46 | :global(.global-class) { 47 | color: red; 48 | } 49 | 50 | $some-scss-variable: 10px; // Scss variable declarations in top scope. 51 | 52 | .style5 // comment in between selector 53 | 54 | .style6 55 | 56 | /* comment in between selector */ 57 | .style7 { 58 | $some-scss-variable: 10px; // Scss variable declarations inside style rules block. 59 | color: red; 60 | } 61 | 62 | .style1 { 63 | // Repeated style 64 | color: blue; 65 | } 66 | 67 | // scss placeholder class should parse 68 | %scss-class { 69 | color: red; 70 | } 71 | 72 | @layer test-layer { 73 | .style8 { 74 | color: red; 75 | } 76 | } 77 | 78 | @layer test-layer2; 79 | 80 | @layer; 81 | 82 | @container (min-width: #{$screen-md}) { 83 | h2 { 84 | font-size: 1.5em; 85 | } 86 | 87 | .style9 { 88 | font-size: 1.5em; 89 | } 90 | } 91 | 92 | //eof -------------------------------------------------------------------------------- /stylance/tests/style2.module.scss: -------------------------------------------------------------------------------- 1 | .style1 { 2 | color: red; 3 | } 4 | 5 | .different-style { 6 | color: red; 7 | } -------------------------------------------------------------------------------- /stylance/tests/test_classes.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn test_join_classes() { 3 | use stylance::JoinClasses; 4 | 5 | assert_eq!( 6 | ( 7 | "one", 8 | Some("two"), 9 | false.then_some("three"), 10 | true.then_some("four"), 11 | &String::from("five"), 12 | Some(&String::from("six")), 13 | &("seven", "eight").join_classes() 14 | ) 15 | .join_classes(), 16 | "one two four five six seven eight" 17 | ); 18 | } 19 | 20 | #[test] 21 | fn test_classes_macro_none() { 22 | use stylance::classes; 23 | assert_eq!(classes!(), ""); 24 | } 25 | 26 | #[test] 27 | fn test_classes_macro_one() { 28 | use stylance::classes; 29 | assert_eq!(classes!("one"), "one"); 30 | assert_eq!(classes!(Some("one")), "one"); 31 | assert_eq!(classes!(false.then_some("one")), ""); 32 | } 33 | 34 | #[test] 35 | fn test_classes_macro_many() { 36 | use stylance::classes; 37 | assert_eq!( 38 | classes!( 39 | "one", 40 | Some("two"), 41 | false.then_some("three"), 42 | true.then_some("four"), 43 | &String::from("five"), 44 | Some(&String::from("six")), 45 | &classes!("seven", "eight") 46 | ), 47 | "one two four five six seven eight" 48 | ); 49 | } 50 | 51 | #[test] 52 | fn test_classes_macro_trailing_comma() { 53 | use stylance::classes; 54 | assert_eq!(classes!("one", "two", "three",), "one two three"); 55 | } 56 | -------------------------------------------------------------------------------- /stylance/tests/test_import_styles.rs: -------------------------------------------------------------------------------- 1 | use stylance::*; 2 | 3 | #[test] 4 | fn test_import_crate_style() { 5 | import_crate_style!(style, "tests/style.module.scss"); 6 | 7 | assert_eq!(style::style1, "style1-a331da9"); 8 | assert_eq!(style::style2, "style2-a331da9"); 9 | assert_eq!(style::style3, "style3-a331da9"); 10 | assert_eq!(style::style4, "style4-a331da9"); 11 | assert_eq!(style::style5, "style5-a331da9"); 12 | assert_eq!(style::style6, "style6-a331da9"); 13 | assert_eq!(style::style7, "style7-a331da9"); 14 | assert_eq!(style::style8, "style8-a331da9"); 15 | assert_eq!(style::style9, "style9-a331da9"); 16 | 17 | assert_eq!(style::style_with_dashes, "style-with-dashes-a331da9"); 18 | assert_eq!(style::nested_style, "nested-style-a331da9"); 19 | 20 | mod some_module { 21 | stylance::import_crate_style!(#[allow(dead_code)] pub style, "tests/style.module.scss"); 22 | } 23 | 24 | assert_eq!(some_module::style::style1, "style1-a331da9"); 25 | 26 | import_crate_style!(style2, "tests/style2.module.scss"); 27 | assert_eq!(style2::style1, "style1-58ea9e3"); 28 | assert_eq!(style2::different_style, "different-style-58ea9e3"); 29 | } 30 | 31 | #[cfg(feature = "nightly")] 32 | #[test] 33 | fn test_import_style() { 34 | import_style!(style, "style.module.scss"); 35 | 36 | assert_eq!(style::style1, "style1-a331da9"); 37 | assert_eq!(style::style2, "style2-a331da9"); 38 | assert_eq!(style::style3, "style3-a331da9"); 39 | assert_eq!(style::style4, "style4-a331da9"); 40 | assert_eq!(style::style5, "style5-a331da9"); 41 | assert_eq!(style::style6, "style6-a331da9"); 42 | assert_eq!(style::style7, "style7-a331da9"); 43 | assert_eq!(style::style8, "style8-a331da9"); 44 | assert_eq!(style::style9, "style9-a331da9"); 45 | 46 | assert_eq!(style::style_with_dashes, "style-with-dashes-a331da9"); 47 | assert_eq!(style::nested_style, "nested-style-a331da9"); 48 | 49 | mod some_module { 50 | stylance::import_style!(#[allow(dead_code)] pub style, "style.module.scss"); 51 | } 52 | 53 | assert_eq!(some_module::style::style1, "style1-a331da9"); 54 | 55 | import_style!(style2, "style2.module.scss"); 56 | assert_eq!(style2::style1, "style1-58ea9e3"); 57 | assert_eq!(style2::different_style, "different-style-58ea9e3"); 58 | } 59 | --------------------------------------------------------------------------------