#sass #scss #macro #web

sabry

Syntactically Awesome But RustY - crate that brings SCSS/SASS into rust

9 releases

new 0.0.3 Dec 14, 2024
0.0.3-alpha.1 Dec 13, 2024
0.0.2 Nov 16, 2024
0.0.1-alpha.2 Oct 31, 2024

#677 in Web programming

Download history 453/week @ 2024-10-28 152/week @ 2024-11-04 94/week @ 2024-11-11 28/week @ 2024-11-18 10/week @ 2024-12-02 300/week @ 2024-12-09

369 downloads per month

MIT license

85KB
1K SLoC

🧙🏻 SABRY - Syntactically Awesome, But RustY

Yet another Rusty Boilerplate-free Agnostic Styling crate, which brings your SASS/SCSS style into Rust. Written by a fox this time.

* sabry isn't "syntactically awesome", it refers to SASS abbr expansion.

Project status - early, my team uses it now and again in production, and the most of features/fixes do come on demand. I'm pretty happy with ergonomics and taste of the crate, and I'll do my best to keep DX the way it is between minor versions - but there's no guarantee on backwards-compatibility and no refunds if something breaks.

"master" branch is what's currently on crates.io

each version ever been on crates.io (except the latest release) is available in "vX.X.X" branch

the latest-trashy state is in the "window" branch

Crates.io Docs.rs MIT licensed

At first, I'll show how this crate "tastes". With SABRY, its in your power to:


Write arbitrary SASS/SCSS code and ship it as a crate however you please: with modules, with feature flags, etc. Say "bye" to manual copying, cli-tools and consts.

sabry::scssy!(tokens {"@mixin colored{color: red;}"});

Get the (almost) proper autocompletion for your scope from rust-analyzer

sabry::styly!(scope {".cls1{} #id1{}"});
/* autocompleted for you */
scope::cls1;
scope::theid1;

Code your SASS in separate files to get proper syntax highlighting, or do sass-in-the-rust to keep things in one place

sabry::styly!(component {".btn {color: green;}"});
sabry::styly!(extras "tests/assets/mixin-module.scss");

Depend on styles with cargo, at build time, which brings all the rusty sweeties in: versions, updates, cratesio, local registries, workspaces, etc.

[build-dependencies.your_styles]
registry = "your_registry_isnt_that_cool"
version = "^0.1"
features = ["darkmode", "mobile"]

@use your style-crates in sass code naturally

sabry::styly!(breadbadge {"
    @use 'tokens';
    .scope {@include tokens.badge(primary);}
"});

Keep things as private and modular as you wish

sabry::styly!(pub cats "tests/assets/mixin-module.scss");
sabry::styly!(dogs {"#howl {border: none;}"});
/// something like render function
{
    <div class={cats::meow}>
        <ul id={dogs::thehowl}></ul>
    </div>
}

Compile all the sweet SASS/SCSS into the optimized CSS bundle, ship it in CSS shunks or even include the compiled style into the binary

sabry::styly!(cssbundle {".c1 {color: white;}"});
sabry::styly!(const binary {".c2 {color: black;}"});

Sabry will gladly:

  • scope and hash your styles, so they won't conflict, with amazing raffia parser
  • compile SASS/SCSS into CSS with grass
  • optimize produced CSS with lightningcss
  • prepare ready CSS bundle and put it wherever you wish
  • include CSS into the build artifact - if you really want it

Also, just about everything is pub-available in this crate (with internals feature flag) - Sabry is ready for experiments.

Usage

Feel free to check out examples:

crate of styles style usage leptos-axum with sabry leptos components

Create a crate full of arbitrary SASS

The only need is the dependency

#Cargo.toml

[dependencies]
sabry = {version = "0.0.3"}

And a proc-macro

// lib.rs
use sabry::scssy;

scssy!(mixins "tests/assets/mixin-module.scss");
scssy!(styles {"
    $primary-color: black;
    @mixin colored($col: primary) {
        @if $col == primary {
            color: $primary-color;
        } @else {
            color: $col;
        }
    }
"});

* Unlike most of other crates that do sass-in-the-rust, sabry currently does not allow unquoted sass/scss. You still have to write it in a string-quotes. Unquoted sass/scss is reserved for the future, where, hopefully, I'll find more exciting usage for it. In the meantime it does look like Zed, for example, still highlights your code (:

Now you can build and see two ready for export macros: mixins! and styles!. These are usefull on their own, as invocation of mixins!() or styles!() - both shall give you the code literal.

However there's more sweet use case for them, which is covered below.

Write scoped styles

Depend on sabry

# Cargo.toml

[dependencies]
sabry = {version = "0.0.3"}

And create a style scope wherever you want:

// breadbadgelist.rs
use sabry::styly;

styly!(const styles {"
    .badges {
        display: flex;
        &__list {
            display: flex;
        }
    }
    #wolf {
        color: white;
    }
"});

/* Now you can use all scoped selectors: */
let badges_scoped_class = styles::badges;
let wolf_scoped_id = styles::thewolf;
let badges__list_scoped_class = styles::___list(styles::badges);

That const usage is covered in the following section. In this example we dont invoke sabry build-magic, so, to be as close to real life as possible, I used a const.

Every selector, if that does make sense, now available for you as a member of styles scope. In this example - styles::badges, styles::thewolf and styles::_list(). More about scoping and member names you can read here.

Use styles earlier created in another crate

Tip | If you use leptos - check this section as well

The combination of previous two, with some additional work to do and some extra sugar to enjoy.

Sabry is needed as both dependency and build-dependency. To be able to compile all styles sabry also needs the build feature flag:

# Cargo.toml

[dependencies]
sabry = {version = "0.0.3"}

[build-dependencies]
sabry = {version = "0.0.3", features = ["build"]}

If you do use some non-default feature flags make sure to keep them in sync between sabry-dependency and sabry-build-dependency.

Then you have to tell sabry when code should be compiled. We'll do this in build.rs file.

// build.rs
fn main(){
    sabry::buildy(
        sabry::usey!(
            mixins!(),
            styles!()
        )
    ).expect("Failed to build sabry styles");
}

buildy is the entry function of sabry build-time process. The handy usey! macro will do just proper handling of our style-macros for it.

Now lets get back to the code and use the mixin defined in another crate:

// breadbadgelist.rs
use sabry::styly;

styly!(styles {"
    // 'mixins' is available beacuse we did call mixins!() macro in the example below
    @use 'mixins';
    .badges {
        display: flex;
        &__list {
            display: flex;
        }
    }
    #wolf {
        @include mixins.colored(white);
    }
"});

So the mixins! macro we just passed to the usey! macro inside of buildy function call is now accessible with simple and natural @use "mixins" SASS rule!

Create crate of styled components

This is currently an alpha-testing-early-concept feature which comes with some things to avoid.

While the process depends much on the framework you prefer, there are some limitations and essential recommendations:

  • Sabry will scope (hash) your styles at build time, which means higher collision probability between the component crate and the main application, which can not be currently detected by sabry.
  • You have to write style scopes for components with const styly macro flavour: styly!(const whatever {""})
    • And then you have to solve the task of injecting those generated CSS for your component manually.
  • If you want to write styles in separate SASS/SCSS files, you need the nightly rust and the 'nightly' feature flag set for sabry, so you can use styles from relative paths styly!(const comp "./style.scss")
    • Which will give you an error from rust-analyzer wether file exists or not. However, if the path is correct, it will build fine.
I'd suggest something like this
// lib.rs
pub mod utils;
pub mod form;

pub const fn css() -> &'static str {
    concatcp!(utils::css(), form::css())
}
// utils.rs
use sabry::styly;

styly!(pub const scope:scss {"
    .whatever {
        &__code {}
        &[you-please] {}
    }
"});

styly!(pub const another:scss {"
    .whatever {
        &__code {}
        &[you-please] {}
    }
"});

pub const fn css() -> &'static str {
    concatcp!(SCOPE, ANOTHER)
}
// form.rs
use sabry::styly;

styly!(pub const scope:scss {"
    .whatever {
        &__code {}
        &[you-please] {}
    }
"});

styly!(pub const another:scss {"
    .whatever {
        &__code {}
        &[you-please] {}
    }
"});

pub const fn css() -> &'static str {
    concatcp!(SCOPE, ANOTHER)
}

So I'd say - do the const function back-propogation and let the consumer decide how to include the CSS of the component crate.

Leptos specials

With leptos framework you can do the trick to apply some class to most of HtmlElements for the entire component/island:

use leptos::prelude::*;

#[component]
fn component() -> impl IntoView {
    view! {class="cls1", /* <- here */
        <h1>"Head"</h1>
        <p>"text"</p>
    }
}

... and have "cls1" on both h1 and p auto-assigned. Isn't that cool?

So, if you're on leptos, and don't mind to take this approach, I'd highly recommend turning the lepty-scoping feature on:

# Cargo.toml

[dependencies]
sabry = {version = "0.0.3", features = ["lepty-scoping"]}

[build-dependencies]
sabry = {version = "0.0.3", features = ["build", "lepty-scoping"]}
use leptos::prelude::*;
use sabry::styly;

styly!(scope {"
    // assuming we have tokens!() macro generated by `scssy!`, which is handled by `buildy`
    @use 'tokens';
    h1 {
        @include tokens.sectionhead();
        img {
            @include tokens.sectionimg();
        }
    }
    .breadcumbs {
        @include tokens.badgelist();
        &__item {
            @include tokens.badge(secondary);
        }
    }
"});

#[component]
fn component() -> impl IntoView {
    view! {class=SCOPE,
        <h1>
            "Head"
            <img src="whatever"/>
        </h1>
        <ul class="breadcumbs">
            <li class="breadcumbs__item">
                "Home page"
            </li>
        </ul>
    }
}

That will perform much better:

  • All scope members - like scope::breadcumbs - wont contain repeating scope hash, just the original class/id selector
    • So instead of scope members (which aren't autocompleted for sass-in-the-rust yet) you could just write bare selectors, as you do, for example, with svelte: <li class="breadcumbs__item">
    • You'll still have the rusty mod generated: its just the scope::breadcumbs is the "breadcumbs" without the hash
  • You will not encounter the catch with nested tagname selectors

Also, this isn't really exclusive leptos-supporting feature. It just changes scoping behavour.

Configuration

Sabry configuration lives in [package.metadata.sabry] table of the manifest file.

All configurations are optional, but with default configuration sabry won't produce any CSS files.

Full example, close to defaults:

# Cargo.toml

[package.metadata.sabry]
css.bundle = "target/static/style.css"
css.prelude = ["assets/prelude.css"]
css.scopes = "target/statis/scopes/"
css.minify = true

sass.intermediate_dir = "target/.sabry/sass"
sass.module_name_collision = "merge"
sass.modules = ["assets/sass/mod1.scss"]
sass.prelude = ["assets/sass/prelude.scss"]
sass.scanroot = "src"

hash.size = 6
hash.collision = "error"
hash.use_scope_name = true
hash.use_code_size = true
hash.use_item_names = false
hash.use_code_text = false

[package.metadata.sabry.lightningcss.targets]
chrome = "120"
safari = "13.2"
ie = "6"

sabry.css

bundle (no default) - file path ro write CSS bundle into, relative to crate root

prelude (no default) - collection of CSS files, relative to the crate root, which content will be inserted before the compiled style into the bundle file if any. Does not affect generated CSS scopes if any.

scopes (no default) - dir path to put separate CSS for every scope into, relative to crate root

minify (default true) - print compressed CSS output and do the lightningcss thing

sabry.sass

intermediate_dir (default "target/.sabry/sass") - file to put SASS/SCSS modules into so they are available with @use in code

scanroot (default "src") - root directory to start scanning "rs" files from. Used in build function

modules (no default) - collection of SASS/SCSS files, relative to the crate root, which should be available as modules as well

prelude (no default) - collection of SASS/SCSS files, relative to the crate root, which content will be compiled into CSS and then inserted into the CSS bundle if any. Does not affect generated CSS scopes if any.

module_name_collision (default "merge") - how to handle similary named modules.

merge - merge content

error - break building process with an error

sabry.hash

size (default 6) - size of hash in bytes. Feel free to increase/decrease.

use_scope_name (default true) - wether to use scope identifier to calculate hash

use_code_size (default true) - wether to use scope code size to calculate hash

use_item_names (default false) - wether to use all scoped item idents to calculate hash

use_code_text (default false) - wether to use the scope code text to calculate hash

collision (default "ignore") - how to handle similarity of generated hashes

ignore - dont do anything

error - break building process with an error

sabry.lightningcss.targets

Does require css.minify to be true.

Empty by default.

Available keys: chrome, firefox, edge, safari, saf_ios, samsung, android, ie

Value - minimal browser version to support in "M.m.p" format, where:

  • M - major
  • m - minor
  • p - patch

For example {ie = "9", saf_ios = "13.2"} will try to generate CSS supported on both IE 9 and Safari-on-ios 13.2

Detailed guide

Style definition with scssy!

The scssy! macro is available with procmacro feature which is enabled by default.

It does accept the following syntax: $name(:$syntax)? ({ $code })|($filename), where

  • $name is any identifier valid for macro_rules!
  • $syntax is either sass or scss
  • $code is valid arbitrary style code in specified syntax
  • $filename is a string literal which contains path to the file relative to package root

Examples:

use sabry::scssy;

scssy!(module1 {"$primary-color: red;"});
scssy!(module2 "tests/assets/mixin-module.scss");
scssy!(module3:sass "tests/assets/mixin-module.sass");
// works, but there are catches.
scssy!(module4:sass {"
    @mixin colored($col: primary)
        @if $col == primary
            color: white
        @else
            color: red
"});

You may omit the syntax specifier - sabry uses SCSS as the default one.

The given code to scssy! is not checked to be valid code in given syntax (wip).

SASS support inside of rust files is experimental. If you do want to use SASS tabbed syntax - consider to use files path instead of sass-in-rust option.

With nightly feature flag if using the relative path like scssy!(module "./module.scss") you'll get false-positive error even if file exists. Also you won't get autocompletion and rust-analyzer will complain on module! macro. WIP.

Scoping with styly!

The styly! macro is available with procmacro feature flag which is enabled by default.

It does accept the following syntax: pub? const? $ident(:$syntax)? ({ $code })|($filename), where

  • pub is explained here
  • const is explained here
  • $ident is any identifier valid for mod
  • $syntax is either sass or scss
  • $code is arbitrary style code valid with given syntax
  • $filename is a string literal which contains path to the file relative to package root

Examples

use sabry::styly;

styly!(private_fox {".fur {color: red; &-dark {color: black;}}"});
styly!(pub public_fox {".fur {color: red; &-dark {color: black;}}"});
styly!(pub const pub_compiletime_fox:sass {"
    .fur
        color: red
        &-dark
            color: black
"});

Every of those calls will produce the styling scope as a module. Differences are explained right below.

In general the scope does look like this:

const FOX: &str = "J9k_s9";
mod fox {
    pub const fur: &str = "J9k_s9 fur";
    pub fn _dark(c: &str) -> String {format!("{c}-dark")}
}

Styly scopes

Styly macro itself does not generate the scope. It is done in the sabry_intrnl::scoper. However, as a result, you will have the following:

  • const with the UPPER_CASE name of the scope, which contains its hash
  • mod with exact scope name which is the scope you're going to use
    • for simple selectors, like .fur you will have a const members with hashed original selectors to use wherever you need a class/id/etc.
    • for parent-selectors like &-dark you will have a non-const function, which will create matching the selector from any other.

You can read more about scoping and hashing in the scoping section.

Public styly scopes

By default generated mod is private. You can make both mod and wrapper style constant public by adding the pub to macro call:

sabry::styly!(pub whatever "tests/assets/mixin-module.scss");

Constant styly scopes

As you've seen above, scope doe not contain any style code by itself. That's the use case i advise mostly.

However you could still compile styles into the artifact by simply adding the const to the macro call:

sabry::styly!(const scope "tests/assets/mixin-module.scss");

Which results in following:

const SCOPE: &str = /* scope hash */;
const SCOPE_CSS: &str = /* compiled from src/assets/scope.scss */;
mod scope {/* selector collection */}

There is a catch | with the const modifier macro must compile CSS at compile-time. That results in several game changers:

First. You could avoid the "build magic". Sabry will just compile given styles with procmacro at compile time.

Second. If you @use something inside of constant-flavored scope, you can only success if sabry did the build magic before compilation of that macro call. So you still can compile the styles into the artifact and enjoy mixins from other crates, but, in general, you are going to receive some false-positives from editor.

Worth of notice: sabry will still include const-flavored styles into the CSS bundle during build time.

With nightly feature flag if using the relative path like styly!(const scope "./sctyle.scss") you'll get false-positive error even if file exists. Also you won't get autocompletion and rust-analyzer will complain on SCOPE_CSS, scope::whatever etc. WIP.

Building with buildy and usey!

The buildy function is available with build feature which needs to be enabled explicitly.

This function accepts an iterator of pairs: (file_name, code) in form of (String, String) type. File name should have an extension, so grass can infere syntax during CSS compilation.

Each of those pairs is processed as a file which sabry needs to write into the configured intermediate_dir and then passed into the CSS compiler.

You could, for example, define the module "mixin_a":

buildy(vec![("mixin_a".to_string(), "@mixin a(){}".to_string())]);

However there's a usey macro, which handles this for you:

buildy(
    usey!(mixins!(), utils!())
);

The usey! macro accepts the following syntax: #($macro,)*, where

  • $macro is a macro which handles two expansions:
    • () => { $code }
    • (syntax) => { $syntax }

Where for the $macro:

  • $code is a source code of style as a string literal
  • $name is a name of module without extension as a string literal
  • $syntax is either "sass" or "scss"

usey! macro will put the module full name from macro call identifier and the syntax expansion, so you could resolve potential naming conflicts:

use tgk_brandstyle::theme;
use basestyle::theme as base_theme;

sabry::buildy(
    sabry::usey!(
        theme!(),
        base_theme!()
    )
)?;

Exactly this kind of macros is produced by scssy!.

Scoping

Sabry handles scoping by restriction or mutation of existing selectors with the hash. Hash is calculated for the entire scope by the styly! macro.

Currently the following types of selectors are scoped:

  • class
  • id
  • SASS parent selectors (see below, there is a catch)
  • tagname (see below, there is a catch)

Sabry does not make difference between top-level and nested selectors, also selector complexity isn't taken into account: sabry simply walks through all compound selectors and apply scoping for supported ones.

Different selector types are scoped differently:

  • class selectors are restricted with scope hash: .class -> .HASH.class
  • id selector are mutated with scope hash: #id -> #id-HASH
  • tagname selectors are changed into indirect descendance: div -> .HASH div

There is a catch | currently tagname selectors are scoped with indirect descendancy hash restriction. That means - if you do use tagnames in scoped stylesheets, you have to wrap your markdown with SCOPE class generated by styly! macro.

Also if there are nested tagname selectors - sabry won't take this into account and you'll have to wrap them with SCOPE class as well

However If you are able to apply some class to all html elements you have - like view!{class=CLASS...} with Leptos - you could use lepty-scoping feature flag for sabry and get rid of this catch! See more here.

As for SASS parent selectors: they are currently handled in different way. Instead of walking up the syntax tree sabry just creates function member for the scope and leave the rest to grass:

sabry::styly!(scope {"
    .cls1 {
        &-dark {}
    }
"});

is something like

const SCOPE: &str = "Ut8CskJ";
mod scope {
    pub const cls1: &str = "Ut8CskJ cls1";
    pub fn _dark(c: &str) -> String {format!("{c}-dark")}
}

So you still can get the scoped variant of cls1-dark class: scope::_dark(scope::cls1).

This isn't very handy, also isn't strict enough, and is a high-priority subject to change. wip.

Scope member naming rules

Not any valid CSS selector is a valid rust identifier. In general this section should not be needed, as you should receive autocompletion from the editor. However it doesn't seem to work properly. Check the wip section out.

Notable feature flags

build - turns on the sabry::buildy function, along with the entire sabry_build crate where it lives

internals - exposes majority of internal stuff for you to experiment or build own workflow

lepty-scoping - overhauls the scope generation logic, best suitable for the leptos. Check out the section and an example

nightly - allows relative path selection with scssy! and styly! macros. However rust-analyzer will raise false-positives for reachable files as well.

WIP

(sorted by my own priority), "dones" are excluded

  • Somehow achieve the autocompletion for scopes. The problem is explained in details here
    • some weird unrelated stuff I can see in autocompletion (still investigating btw)
  • Support for direct CSS syntax
  • Currently the crate causes "dependency inheritance" infection. We cant get rid of it, however should be doable to at least get rid of flag inheritance
  • Experience with cargo-leptos is fine, and we do use it, however its a bit "raughy". Need to do something about it:
    • We have to save file twice with cargo leptos for changed styles to take effect
    • Seems like turning on the "hashing" with leptos requires a hard-restart of cargo-leptos

Contributions

Any contributions are always welcome!

If you find this crate usefull, wanna stick with it in some project, but do miss some features - feel free to submit a PR.

If you'd like to fork for the PR - please, use the "window" branch, not the "master".

If you encounter any bugs/problems or have use case where things dont work as they should for the latest version - please, open an issue!

If you'd like to lend a paw - feel free to check the WIP section out, or to search for "TODO" comments.

MSRV

Sabry passes its own tests on 1.78 nightly/stable.

Examples are on 1.82.

License

MIT.

Dependencies

~18–27MB
~385K SLoC