Scoped css (#5087)

* Create new css module parser

* Use css module processing

* Make hot reloading work for css_module macro

* Change css_module macro shape and rename to styles

* Add css_module example

* Update styles macro documentation

* Remove hidden from styles macro

* Remove unneeded param

* Remove unneeded visibility

* Improve `styles!` macro documentation

* Clippy

* Remove unneeded method

* Clippy

* Clippy

* Replace panic with anyhow

* Update css_module macro to attribute macro

* Update macro docs

* chore: Cargo.lock

* Clippy

* respect semver by deprecating old func

* update to latest winnow

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
HenryandJonathan Kelley authored and GitHub committed 2025-12-29 22:41:36 -05:00
1 parent 34554766db
commit 9965220947
14 files changed
+978 -286

No files matched your search

+121 -140
View File
@@ -1,31 +1,21 @@
use crate::{asset::AssetParser, resolve_path};
use macro_string::MacroString;
use manganis_core::{create_module_hash, get_class_mappings};
use proc_macro2::{Span, TokenStream};
use quote::{format_ident, quote, ToTokens, TokenStreamExt};
use syn::{
parse::{Parse, ParseStream},
spanned::Spanned,
token::Comma,
Ident, Token, Visibility,
Ident, ItemStruct,
};
pub(crate) struct CssModuleParser {
/// Whether the ident is const or static.
styles_vis: Visibility,
styles_ident: Ident,
pub(crate) struct CssModuleAttribute {
asset_parser: AssetParser,
}
impl Parse for CssModuleParser {
impl Parse for CssModuleAttribute {
fn parse(input: ParseStream) -> syn::Result<Self> {
// NEW: macro!(pub? STYLES_IDENT = "/path.css");
// pub(x)?
let styles_vis = input.parse::<Visibility>()?;
// Styles Ident
let styles_ident = input.parse::<Ident>()?;
let _equals = input.parse::<Token![=]>()?;
// Asset path "/path.css"
let (MacroString(src), path_expr) = input.call(crate::parse_with_tokens)?;
let asset = resolve_path(&src, path_expr.span());
@@ -44,136 +34,127 @@ impl Parse for CssModuleParser {
options,
};
Ok(Self {
styles_vis,
styles_ident,
asset_parser,
})
Ok(Self { asset_parser })
}
}
impl ToTokens for CssModuleParser {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
// Use the regular asset parser to generate the linker bridge.
let mut linker_tokens = quote! {
/// Auto-generated Manganis asset for css modules.
#[allow(missing_docs)]
const ASSET: manganis::Asset =
};
self.asset_parser.to_tokens(&mut linker_tokens);
let path = match self.asset_parser.asset.as_ref() {
Ok(path) => path,
Err(err) => {
let err = err.to_string();
tokens.append_all(quote! { compile_error!(#err) });
return;
}
};
// Get the file hash
let hash = match crate::hash_file_contents(path) {
Ok(hash) => hash,
Err(err) => {
let err = err.to_string();
tokens.append_all(quote! { compile_error!(#err) });
return;
}
};
// Process css idents
let css = std::fs::read_to_string(path).unwrap();
let (classes, ids) = manganis_core::collect_css_idents(&css);
let mut values = Vec::new();
// Create unique module name based on styles ident.
let styles_ident = &self.styles_ident;
let mod_name = format_ident!("__{}_module", styles_ident);
// Generate id struct field tokens.
for id in ids.iter() {
let as_snake = to_snake_case(id);
let ident = Ident::new(&as_snake, Span::call_site());
values.push(quote! {
pub const #ident: #mod_name::__CssIdent = #mod_name::__CssIdent { inner: manganis::macro_helpers::const_serialize::ConstStr::new(#id).push_str(#mod_name::__ASSET_HASH.as_str()).as_str() };
});
}
// Generate class struct field tokens.
for class in classes.iter() {
let as_snake = to_snake_case(class);
let as_snake = match ids.contains(class) {
false => as_snake,
true => format!("{as_snake}_class"),
};
let ident = Ident::new(&as_snake, Span::call_site());
values.push(quote! {
pub const #ident: #mod_name::__CssIdent = #mod_name::__CssIdent { inner: manganis::macro_helpers::const_serialize::ConstStr::new(#class).push_str(#mod_name::__ASSET_HASH.as_str()).as_str() };
});
}
let options = &self.asset_parser.options;
let styles_vis = &self.styles_vis;
// We use a PhantomData to prevent Rust from complaining about an unused lifetime if a css module without any idents is used.
tokens.extend(quote! {
#[doc(hidden)]
#[allow(missing_docs, non_snake_case)]
mod #mod_name {
#[allow(unused_imports)]
use manganis::{self, CssModuleAssetOptions};
#linker_tokens;
// Get the hash to use when builidng hashed css idents.
const __ASSET_OPTIONS: manganis::AssetOptions = #options.into_asset_options();
pub(super) const __ASSET_HASH: manganis::macro_helpers::const_serialize::ConstStr = manganis::macro_helpers::hash_asset(&__ASSET_OPTIONS, #hash);
// Css ident class for deref stylesheet inclusion.
pub(super) struct __CssIdent { pub inner: &'static str }
use std::ops::Deref;
use std::sync::OnceLock;
use dioxus::document::{document, LinkProps};
impl Deref for __CssIdent {
type Target = str;
fn deref(&self) -> &Self::Target {
static CELL: OnceLock<()> = OnceLock::new();
CELL.get_or_init(move || {
let doc = document();
doc.create_link(
LinkProps::builder()
.rel(Some("stylesheet".to_string()))
.href(Some(ASSET.to_string()))
.build(),
);
});
self.inner
}
}
impl std::fmt::Display for __CssIdent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
self.deref().fmt(f)
}
}
}
/// Auto-generated idents struct for CSS modules.
#[allow(missing_docs, non_snake_case)]
#styles_vis struct #styles_ident {}
impl #styles_ident {
#( #values )*
}
})
pub(crate) fn expand_css_module_struct(
tokens: &mut proc_macro2::TokenStream,
attribute: &CssModuleAttribute,
item_struct: &ItemStruct,
) {
if !item_struct.fields.is_empty() {
let err = syn::Error::new(
item_struct.fields.span(),
"css_module can only be applied to unit structs",
)
.into_compile_error();
tokens.append_all(err);
return;
}
if !item_struct.generics.params.is_empty() {
let err = syn::Error::new(
item_struct.generics.span(),
"css_module cannot be applied to generic structs",
)
.into_compile_error();
tokens.append_all(err);
return;
}
let struct_vis = &item_struct.vis;
let struct_name = &item_struct.ident;
let struct_name_private = format_ident!("__{}", struct_name);
// Use the regular asset parser to generate the linker bridge.
let mut linker_tokens = quote! {
/// Auto-generated Manganis asset for css modules.
#[allow(missing_docs)]
const ASSET: manganis::Asset =
};
attribute.asset_parser.to_tokens(&mut linker_tokens);
let asset = match attribute.asset_parser.asset.as_ref() {
Ok(path) => path,
Err(err) => {
let err = err.to_string();
tokens.append_all(quote! { compile_error!(#err) });
return;
}
};
let css = std::fs::read_to_string(asset).expect("Unable to read css module file");
let mut values = Vec::new();
let hash = create_module_hash(asset);
let class_mappings = get_class_mappings(css.as_str(), hash.as_str()).expect("Invalid css");
// Generate class struct field tokens.
for (old_class, new_class) in class_mappings.iter() {
let as_snake = to_snake_case(old_class);
let ident = Ident::new(&as_snake, Span::call_site());
values.push(quote! {
pub const #ident: #struct_name_private::__CssIdent = #struct_name_private::__CssIdent { inner: #new_class };
});
}
// We use a PhantomData to prevent Rust from complaining about an unused lifetime if a css module without any idents is used.
tokens.extend(quote! {
#[doc(hidden)]
#[allow(missing_docs, non_snake_case)]
mod #struct_name_private {
use dioxus::prelude::*;
#linker_tokens;
// Css ident class for deref stylesheet inclusion.
pub struct __CssIdent { pub inner: &'static str }
use std::ops::Deref;
use std::sync::OnceLock;
use dioxus::document::{document, LinkProps};
impl Deref for __CssIdent {
type Target = str;
fn deref(&self) -> &Self::Target {
static CELL: OnceLock<()> = OnceLock::new();
CELL.get_or_init(move || {
let doc = document();
doc.create_link(
LinkProps::builder()
.rel(Some("stylesheet".to_string()))
.href(Some(ASSET.to_string()))
.build(),
);
});
self.inner
}
}
impl std::fmt::Display for __CssIdent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
self.deref().fmt(f)
}
}
}
/// Auto-generated idents struct for CSS modules.
#[allow(missing_docs, non_snake_case)]
#struct_vis struct #struct_name {}
impl #struct_name {
#( #values )*
}
impl dioxus::core::IntoAttributeValue for #struct_name_private::__CssIdent {
fn into_value(self) -> dioxus::core::AttributeValue {
dioxus::core::AttributeValue::Text(self.to_string())
}
}
})
}
/// Convert camel and kebab case to snake case.
+103 -111
View File
@@ -1,19 +1,14 @@
#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
use std::{
hash::Hasher,
io::Read,
path::{Path, PathBuf},
};
use std::path::PathBuf;
use css_module::CssModuleParser;
use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{quote, ToTokens};
use syn::{
parse::{Parse, ParseStream},
parse_macro_input,
parse_macro_input, ItemStruct,
};
pub(crate) mod asset;
@@ -22,6 +17,8 @@ pub(crate) mod linker;
use linker::generate_link_section;
use crate::css_module::{expand_css_module_struct, CssModuleAttribute};
/// The asset macro collects assets that will be included in the final binary
///
/// # Files
@@ -84,108 +81,149 @@ pub fn option_asset(input: TokenStream) -> TokenStream {
asset.expand_option_tokens().into()
}
/// Generate type-safe and globally-unique CSS identifiers from a CSS module.
/// Generate type-safe styles with scoped CSS class names.
///
/// CSS modules allow you to have unique, scoped and type-safe CSS identifiers. A CSS module is a CSS file with the `.module.css` file extension.
/// The `css_module!()` macro allows you to utilize CSS modules in your Rust projects.
/// The `css_module` attribute macro creates scoped CSS modules that prevent class name collisions
/// by making each class globally unique. It expands the annotated struct to provide type-safe
/// identifiers for your CSS classes, allowing you to reference styles in your Rust code with
/// compile-time guarantees.
///
/// # Syntax
///
/// The `css_module!()` macro takes a few items.
/// - A styles struct identifier. This is the `struct` you use to access your type-safe CSS identifiers in Rust.
/// - The asset string path. This is the absolute path (from the crate root) to your CSS module.
/// - An optional `CssModuleAssetOptions` struct to configure the processing of your CSS module.
/// The `css_module` attribute takes:
/// - The asset string path - the absolute path (from the crate root) to your CSS file.
/// - Optional `AssetOptions` to configure the processing of your CSS module.
///
/// It must be applied to a unit struct:
/// ```rust, ignore
/// css_module!(StylesIdent = "/my.module.css", AssetOptions::css_module());
/// ```
/// #[css_module("/assets/my-styles.css")]
/// struct Styles;
///
/// The styles struct can be made public by appending `pub` before the identifier.
/// Read the [Variable Visibility](#variable-visibility) section for more information.
/// #[css_module("/assets/my-styles.css", AssetOptions::css_module().with_minify(true))]
/// struct Styles;
/// ```
///
/// # Generation
///
/// The `css_module!()` macro does two few things:
/// - It generates an asset using the `asset!()` macro and automatically inserts it into the app meta.
/// - It generates a struct with snake-case associated constants of your CSS idents.
/// The `css_module` attribute macro does two things:
/// - It generates an asset and automatically inserts it as a stylesheet link in the document.
/// - It expands the annotated struct with snake-case associated constants for your CSS class names.
///
/// ```rust, ignore
/// // This macro usage:
/// css_module!(Styles = "/mycss.module.css");
/// #[css_module("/assets/mycss.css")]
/// struct Styles;
///
/// // Will generate this (simplified):
/// // Will expand the struct to (simplified):
/// struct Styles {}
///
/// impl Styles {
/// // This can be accessed with `Styles::your_ident`
/// pub const your_ident: &str = "abc";
/// // Snake-cased class names can be accessed like this:
/// pub const your_class: &str = "your_class-a1b2c3";
/// }
/// ```
///
/// # CSS Identifier Collection
/// The macro will collect all identifiers used in your CSS module, convert them into snake_case, and generate a struct and fields around those identifier names.
/// # CSS Class Name Scoping
///
/// For example, `#fooBar` will become `foo_bar`.
/// **The macro only processes CSS class selectors (`.class-name`).** Other selectors like IDs (`#id`),
/// element selectors (`div`, `p`), attribute selectors, etc. are left unchanged and not exposed as
/// Rust constants.
///
/// Identifier used only inside of a media query, will not be collected (not yet supported). To get around this, you can use an empty block for the identifier:
/// The macro collects all class selectors in your CSS file and transforms them to be globally unique
/// by appending a hash. For example, `.myClass` becomes `.myClass-a1b2c3` where `a1b2c3` is a hash
/// of the file path.
///
/// Class names are converted to snake_case for the Rust constants. For example:
/// - `.fooBar` becomes `Styles::foo_bar`
/// - `.my-class` becomes `Styles::my_class`
///
/// To prevent a class from being scoped, wrap it in `:global()`:
/// ```css
/// /* Empty ident block to ensure collection */
/// #foo {}
/// /* This class will be scoped */
/// .my-class { color: blue; }
///
/// @media ... {
/// #foo { ... }
/// }
/// /* This class will NOT be scoped (no hash added) */
/// :global(.global-class) { color: red; }
///
/// /* Element selectors and other CSS remain unchanged */
/// div { margin: 0; }
/// #my-id { padding: 10px; }
/// ```
///
/// # Variable Visibility
/// If you want your asset or styles constant to be public, you can add the `pub` keyword in front of them.
/// Restricted visibility (`pub(super)`, `pub(crate)`, etc) is also supported.
/// # Using Multiple CSS Modules
///
/// Multiple `css_module` attributes can be used in the same scope by applying them to different structs:
/// ```rust, ignore
/// css_module!(pub Styles = "/mycss.module.css");
/// // First CSS module
/// #[css_module("/assets/styles1.css")]
/// struct Styles;
///
/// // Second CSS module with a different struct name
/// #[css_module("/assets/styles2.css")]
/// struct OtherStyles;
///
/// // Access classes from both:
/// rsx! {
/// div { class: Styles::container }
/// div { class: OtherStyles::button }
/// }
/// ```
///
/// # Asset Options
/// Similar to the `asset!()` macro, you can pass an optional `CssModuleAssetOptions` to configure a few processing settings.
/// ```rust, ignore
/// use manganis::CssModuleAssetOptions;
///
/// css_module!(Styles = "/mycss.module.css",
/// Similar to the `asset!()` macro, you can pass optional `AssetOptions` to configure processing:
/// ```rust, ignore
/// #[css_module(
/// "/assets/mycss.css",
/// AssetOptions::css_module()
/// .with_minify(true)
/// .with_preload(false),
/// );
/// .with_preload(false)
/// )]
/// struct Styles;
/// ```
///
/// # Examples
/// First you need a CSS module:
/// # Example
///
/// First create a CSS file:
/// ```css
/// /* mycss.module.css */
/// /* assets/styles.css */
///
/// #header {
/// padding: 50px;
/// }
///
/// .header {
/// margin: 20px;
/// .container {
/// padding: 20px;
/// }
///
/// .button {
/// background-color: #373737;
/// }
/// ```
/// Then you can use the `css_module!()` macro in your Rust project:
/// ```rust, ignore
/// css_module!(Styles = "/mycss.module.css");
///
/// println!("{}", Styles::header);
/// println!("{}", Styles::header_class);
/// println!("{}", Styles::button);
/// :global(.global-text) {
/// font-weight: bold;
/// }
/// ```
#[proc_macro]
#[doc(hidden)]
pub fn css_module(input: TokenStream) -> TokenStream {
let style = parse_macro_input!(input as CssModuleParser);
quote! { #style }.into_token_stream().into()
///
/// Then use the `css_module` attribute:
/// ```rust, ignore
/// use dioxus::prelude::*;
///
/// fn app() -> Element {
/// #[css_module("/assets/styles.css")]
/// struct Styles;
///
/// rsx! {
/// div { class: Styles::container,
/// button { class: Styles::button, "Click me" }
/// span { class: Styles::global_text, "This uses global class" }
/// }
/// }
/// }
/// ```
#[proc_macro_attribute]
pub fn css_module(input: TokenStream, item: TokenStream) -> TokenStream {
let attribute = parse_macro_input!(input as CssModuleAttribute);
let item_struct = parse_macro_input!(item as ItemStruct);
let mut tokens = proc_macro2::TokenStream::new();
expand_css_module_struct(&mut tokens, &attribute, &item_struct);
tokens.into()
}
fn resolve_path(raw: &str, span: Span) -> Result<PathBuf, AssetParseError> {
@@ -251,46 +289,6 @@ fn resolve_path(raw: &str, span: Span) -> Result<PathBuf, AssetParseError> {
Ok(path)
}
fn hash_file_contents(file_path: &Path) -> Result<u64, AssetParseError> {
// Create a hasher
let mut hash = std::collections::hash_map::DefaultHasher::new();
// If this is a folder, hash the folder contents
if file_path.is_dir() {
let files = std::fs::read_dir(file_path).map_err(|err| AssetParseError::IoError {
err,
path: file_path.to_path_buf(),
})?;
for file in files.flatten() {
let path = file.path();
hash_file_contents(&path)?;
}
return Ok(hash.finish());
}
// Otherwise, open the file to get its contents
let mut file = std::fs::File::open(file_path).map_err(|err| AssetParseError::IoError {
err,
path: file_path.to_path_buf(),
})?;
// We add a hash to the end of the file so it is invalidated when the bundled version of the file changes
// The hash includes the file contents, the options, and the version of manganis. From the macro, we just
// know the file contents, so we only include that hash
let mut buffer = [0; 8192];
loop {
let read = file
.read(&mut buffer)
.map_err(AssetParseError::FailedToReadAsset)?;
if read == 0 {
break;
}
hash.write(&buffer[..read]);
}
Ok(hash.finish())
}
/// Parse `T`, while also collecting the tokens it was parsed from.
fn parse_with_tokens<T: Parse>(input: ParseStream) -> syn::Result<(T, proc_macro2::TokenStream)> {
let begin = input.cursor();
@@ -311,9 +309,7 @@ fn parse_with_tokens<T: Parse>(input: ParseStream) -> syn::Result<(T, proc_macro
#[derive(Debug)]
enum AssetParseError {
AssetDoesntExist { path: PathBuf },
IoError { err: std::io::Error, path: PathBuf },
InvalidPath { path: PathBuf },
FailedToReadAsset(std::io::Error),
RelativeAssetPath,
}
@@ -323,9 +319,6 @@ impl std::fmt::Display for AssetParseError {
AssetParseError::AssetDoesntExist { path } => {
write!(f, "Asset at {} doesn't exist", path.display())
}
AssetParseError::IoError { path, err } => {
write!(f, "Failed to read file: {}; {}", path.display(), err)
}
AssetParseError::InvalidPath { path } => {
write!(
f,
@@ -333,7 +326,6 @@ impl std::fmt::Display for AssetParseError {
path.display()
)
}
AssetParseError::FailedToReadAsset(err) => write!(f, "Failed to read asset: {}", err),
AssetParseError::RelativeAssetPath => write!(f, "Failed to resolve relative asset path. Relative assets are only supported in rust 1.88+."),
}
}