Browse Source

Manganis CSS Modules Support (#3578)

* feat: type safe css selectors

* feat: css modules support

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
Miles Murgaw 2 months ago
parent
commit
e96550c48c

+ 70 - 2
packages/cli-opt/src/css.rs

@@ -1,6 +1,6 @@
 use std::path::Path;
 
-use anyhow::Context;
+use anyhow::{anyhow, Context};
 use codemap::SpanLoc;
 use grass::OutputStyle;
 use lightningcss::{
@@ -8,7 +8,7 @@ use lightningcss::{
     stylesheet::{MinifyOptions, ParserOptions, StyleSheet},
     targets::{Browsers, Targets},
 };
-use manganis_core::CssAssetOptions;
+use manganis_core::{CssAssetOptions, CssModuleAssetOptions};
 
 pub(crate) fn process_css(
     css_options: &CssAssetOptions,
@@ -43,6 +43,74 @@ pub(crate) fn process_css(
     Ok(())
 }
 
+pub(crate) fn process_css_module(
+    css_options: &CssModuleAssetOptions,
+    source: &Path,
+    final_path: &Path,
+    output_path: &Path,
+) -> anyhow::Result<()> {
+    let mut css = std::fs::read_to_string(source)?;
+
+    // Collect the file hash name.
+    let mut src_name = source
+        .file_name()
+        .and_then(|x| x.to_str())
+        .ok_or(anyhow!("Failed to read name of css module source file."))?
+        .strip_suffix(".css")
+        .unwrap()
+        .to_string();
+
+    src_name.push('-');
+
+    let out_name = final_path
+        .file_name()
+        .and_then(|x| x.to_str())
+        .ok_or(anyhow!("Failed to read name of css module output file."))?
+        .strip_suffix(".css")
+        .unwrap();
+
+    let hash = out_name
+        .strip_prefix(&src_name)
+        .ok_or(anyhow!("Failed to read hash of css module."))?;
+
+    // Rewrite CSS idents with ident+hash.
+    let (classes, ids) = manganis_core::collect_css_idents(&css);
+
+    for class in classes {
+        css = css.replace(&format!(".{class}"), &format!(".{class}{hash}"));
+    }
+
+    for id in ids {
+        css = css.replace(&format!("#{id}"), &format!("#{id}{hash}"));
+    }
+
+    // Minify CSS
+    let css = if css_options.minified() {
+        // Try to minify the css. If we fail, log the error and use the unminified css
+        match minify_css(&css) {
+            Ok(minified) => minified,
+            Err(err) => {
+                tracing::error!(
+                    "Failed to minify css module; Falling back to unminified css. Error: {}",
+                    err
+                );
+                css
+            }
+        }
+    } else {
+        css
+    };
+
+    std::fs::write(output_path, css).with_context(|| {
+        format!(
+            "Failed to write css module to output location: {}",
+            output_path.display()
+        )
+    })?;
+
+    Ok(())
+}
+
 pub(crate) fn minify_css(css: &str) -> anyhow::Result<String> {
     let options = ParserOptions {
         error_recovery: true,

+ 4 - 1
packages/cli-opt/src/file.rs

@@ -2,7 +2,7 @@ use anyhow::Context;
 use manganis_core::{AssetOptions, CssAssetOptions, ImageAssetOptions, JsAssetOptions};
 use std::path::Path;
 
-use crate::css::process_scss;
+use crate::css::{process_css_module, process_scss};
 
 use super::{
     css::process_css, folder::process_folder, image::process_image, js::process_js,
@@ -85,6 +85,9 @@ pub(crate) fn process_file_to_with_options(
         AssetOptions::Css(options) => {
             process_css(options, source, &temp_path)?;
         }
+        AssetOptions::CssModule(options) => {
+            process_css_module(options, source, output_path, &temp_path)?;
+        }
         AssetOptions::Js(options) => {
             process_js(options, source, &temp_path, !in_folder)?;
         }

+ 175 - 2
packages/manganis/manganis-core/src/css.rs

@@ -1,6 +1,6 @@
-use const_serialize::SerializeConst;
-
 use crate::AssetOptions;
+use const_serialize::SerializeConst;
+use std::collections::HashSet;
 
 /// Options for a css asset
 #[derive(
@@ -75,3 +75,176 @@ impl CssAssetOptions {
         AssetOptions::Css(self)
     }
 }
+
+/// Options for a css module asset
+#[derive(
+    Debug,
+    PartialEq,
+    PartialOrd,
+    Clone,
+    Copy,
+    Hash,
+    SerializeConst,
+    serde::Serialize,
+    serde::Deserialize,
+)]
+#[non_exhaustive]
+pub struct CssModuleAssetOptions {
+    minify: bool,
+    preload: bool,
+}
+
+impl Default for CssModuleAssetOptions {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl CssModuleAssetOptions {
+    /// Create a new css asset using the builder
+    pub const fn new() -> Self {
+        Self {
+            preload: false,
+            minify: true,
+        }
+    }
+
+    /// Sets whether the css should be minified (default: true)
+    ///
+    /// Minifying the css can make your site load faster by loading less data
+    ///
+    /// ```rust
+    /// # use manganis::{styles, CssModuleAssetOptions};
+    /// styles!(STYLES, "/assets/style.css", CssModuleAssetOptions::new().with_minify(false));
+    /// ```
+    #[allow(unused)]
+    pub const fn with_minify(self, minify: bool) -> Self {
+        Self { minify, ..self }
+    }
+
+    /// Check if the asset is minified
+    pub const fn minified(&self) -> bool {
+        self.minify
+    }
+
+    /// Make the asset preloaded
+    ///
+    /// Preloading css will make the image start to load as soon as possible. This is useful for css that is used soon after the page loads or css that may not be used immediately, but should start loading sooner
+    ///
+    /// ```rust
+    /// # use manganis::{asset, Asset, CssAssetOptions};
+    /// styles!("/assets/style.css", CssAssetOptions::new().with_preload(true));
+    /// ```
+    #[allow(unused)]
+    pub const fn with_preload(self, preload: bool) -> Self {
+        Self { preload, ..self }
+    }
+
+    /// Check if the asset is preloaded
+    pub const fn preloaded(&self) -> bool {
+        self.preload
+    }
+
+    /// Convert the options into options for a generic asset
+    pub const fn into_asset_options(self) -> AssetOptions {
+        AssetOptions::CssModule(self)
+    }
+}
+
+/// Collect CSS classes & ids.
+///
+/// This is a rudementary css classes & ids collector.
+/// Idents used only in media queries will not be collected. (not support yet)
+///
+/// There are likely a number of edge cases that will show up.
+///
+/// Returns `(HashSet<Classes>, HashSet<Ids>)`
+pub fn collect_css_idents(css: &str) -> (HashSet<String>, HashSet<String>) {
+    const ALLOWED: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
+
+    let mut classes = HashSet::new();
+    let mut ids = HashSet::new();
+
+    // Collected ident name and true for ids.
+    let mut start: Option<(String, bool)> = None;
+
+    // True if we have the first comment start delimiter `/`
+    let mut comment_start = false;
+    // True if we have the first comment end delimiter '*'
+    let mut comment_end = false;
+    // True if we're in a comment scope.
+    let mut in_comment_scope = false;
+
+    // True if we're in a block scope: `#hi { this is block scope }`
+    let mut in_block_scope = false;
+
+    // If we are currently collecting an ident:
+    // - Check if the char is allowed, put it into the ident string.
+    // - If not allowed, finalize the ident string and reset start.
+    // Otherwise:
+    // Check if character is a `.` or `#` representing a class or string, and start collecting.
+    for (_byte_index, c) in css.char_indices() {
+        if let Some(ident) = start.as_mut() {
+            if ALLOWED.find(c).is_some() {
+                // CSS ignore idents that start with a number.
+                // 1. Difficult to process
+                // 2. Avoid false positives (transition: 0.5s)
+                if ident.0.is_empty() && c.is_numeric() {
+                    start = None;
+                    continue;
+                }
+
+                ident.0.push(c);
+            } else {
+                match ident.1 {
+                    true => ids.insert(ident.0.clone()),
+                    false => classes.insert(ident.0.clone()),
+                };
+
+                start = None;
+            }
+        } else {
+            // Handle entering an exiting scopede.
+            match c {
+                // Mark as comment scope if we have comment start: /*
+                '*' if comment_start => {
+                    comment_start = false;
+                    in_comment_scope = true;
+                }
+                // Mark start of comment end if in comment scope: */
+                '*' if in_comment_scope => comment_end = true,
+                // Mark as comment start if not in comment scope and no comment start, mark comment_start
+                '/' if !in_comment_scope => {
+                    comment_start = true;
+                }
+                // If we get the closing delimiter, mark as non-comment scope.
+                '/' if comment_end => {
+                    in_comment_scope = false;
+                    comment_start = false;
+                    comment_end = false;
+                }
+                // Entering & Exiting block scope.
+                '{' => in_block_scope = true,
+                '}' => in_block_scope = false,
+                // Any other character, reset comment start and end if not in scope.
+                _ => {
+                    comment_start = false;
+                    comment_end = false;
+                }
+            }
+
+            // No need to process this char if in bad scope.
+            if in_comment_scope || in_block_scope {
+                continue;
+            }
+
+            match c {
+                '.' => start = Some((String::new(), false)),
+                '#' => start = Some((String::new(), true)),
+                _ => {}
+            }
+        }
+    }
+
+    (classes, ids)
+}

+ 6 - 1
packages/manganis/manganis-core/src/options.rs

@@ -1,6 +1,8 @@
 use const_serialize::SerializeConst;
 
-use crate::{CssAssetOptions, FolderAssetOptions, ImageAssetOptions, JsAssetOptions};
+use crate::{
+    CssAssetOptions, CssModuleAssetOptions, FolderAssetOptions, ImageAssetOptions, JsAssetOptions,
+};
 
 /// Settings for a generic asset
 #[derive(
@@ -23,6 +25,8 @@ pub enum AssetOptions {
     Folder(FolderAssetOptions),
     /// A css asset
     Css(CssAssetOptions),
+    /// A css module asset
+    CssModule(CssModuleAssetOptions),
     /// A javascript asset
     Js(JsAssetOptions),
     /// An unknown asset
@@ -35,6 +39,7 @@ impl AssetOptions {
         match self {
             AssetOptions::Image(image) => image.extension(),
             AssetOptions::Css(_) => Some("css"),
+            AssetOptions::CssModule(_) => Some("css"),
             AssetOptions::Js(_) => Some("js"),
             AssetOptions::Folder(_) => None,
             AssetOptions::Unknown => None,

+ 6 - 88
packages/manganis/manganis-macro/src/asset.rs

@@ -1,89 +1,24 @@
+use crate::{resolve_path, AssetParseError};
 use macro_string::MacroString;
 use manganis_core::hash::AssetHash;
 use proc_macro2::TokenStream as TokenStream2;
 use quote::{quote, ToTokens, TokenStreamExt};
-use std::{iter, path::PathBuf};
+use std::path::PathBuf;
 use syn::{
     parse::{Parse, ParseStream},
     spanned::Spanned as _,
     Token,
 };
 
-#[derive(Debug)]
-pub(crate) enum AssetParseError {
-    AssetDoesntExist { path: PathBuf },
-    InvalidPath { path: PathBuf },
-}
-
-impl std::fmt::Display for AssetParseError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            AssetParseError::AssetDoesntExist { path } => {
-                write!(f, "Asset at {} doesn't exist", path.display())
-            }
-            AssetParseError::InvalidPath { path } => {
-                write!(
-                    f,
-                    "Asset path {} is invalid. Make sure the asset exists within this crate.",
-                    path.display()
-                )
-            }
-        }
-    }
-}
-
-fn resolve_path(raw: &str) -> Result<PathBuf, AssetParseError> {
-    // Get the location of the root of the crate which is where all assets are relative to
-    //
-    // IE
-    // /users/dioxus/dev/app/
-    // is the root of
-    // /users/dioxus/dev/app/assets/blah.css
-    let manifest_dir = dunce::canonicalize(
-        std::env::var("CARGO_MANIFEST_DIR")
-            .map(PathBuf::from)
-            .unwrap(),
-    )
-    .unwrap();
-
-    // 1. the input file should be a pathbuf
-    let input = PathBuf::from(raw);
-
-    // 2. absolute path to the asset
-    let Ok(path) = std::path::absolute(manifest_dir.join(raw.trim_start_matches('/'))) else {
-        return Err(AssetParseError::InvalidPath {
-            path: input.clone(),
-        });
-    };
-
-    // 3. Ensure the path exists
-    let Ok(path) = dunce::canonicalize(path) else {
-        return Err(AssetParseError::AssetDoesntExist {
-            path: input.clone(),
-        });
-    };
-
-    // 4. Ensure the path doesn't escape the crate dir
-    //
-    // - Note: since we called canonicalize on both paths, we can safely compare the parent dirs.
-    //   On windows, we can only compare the prefix if both paths are canonicalized (not just absolute)
-    //   https://github.com/rust-lang/rust/issues/42869
-    if path == manifest_dir || !path.starts_with(manifest_dir) {
-        return Err(AssetParseError::InvalidPath { path });
-    }
-
-    Ok(path)
-}
-
 pub struct AssetParser {
     /// The token(s) of the source string, for error reporting
-    path_expr: proc_macro2::TokenStream,
+    pub(crate) path_expr: proc_macro2::TokenStream,
 
     /// The asset itself
-    asset: Result<PathBuf, AssetParseError>,
+    pub(crate) asset: Result<PathBuf, AssetParseError>,
 
     /// The source of the trailing options
-    options: TokenStream2,
+    pub(crate) options: TokenStream2,
 }
 
 impl Parse for AssetParser {
@@ -107,7 +42,7 @@ impl Parse for AssetParser {
     // But we need to decide the hint first before parsing the options
     fn parse(input: ParseStream) -> syn::Result<Self> {
         // And then parse the options
-        let (MacroString(src), path_expr) = input.call(parse_with_tokens)?;
+        let (MacroString(src), path_expr) = input.call(crate::parse_with_tokens)?;
         let asset = resolve_path(&src);
         let _comma = input.parse::<Token![,]>();
         let options = input.parse()?;
@@ -190,20 +125,3 @@ impl ToTokens for AssetParser {
         })
     }
 }
-
-/// 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();
-    let t: T = input.parse()?;
-    let end = input.cursor();
-
-    let mut cursor = begin;
-    let mut tokens = proc_macro2::TokenStream::new();
-    while cursor != end {
-        let (tt, next) = cursor.token_tree().unwrap();
-        tokens.extend(iter::once(tt));
-        cursor = next;
-    }
-
-    Ok((t, tokens))
-}

+ 193 - 0
packages/manganis/manganis-macro/src/css_module.rs

@@ -0,0 +1,193 @@
+use crate::{asset::AssetParser, resolve_path};
+use macro_string::MacroString;
+use proc_macro2::{Span, TokenStream};
+use quote::{format_ident, quote, ToTokens, TokenStreamExt};
+use syn::{
+    parse::{Parse, ParseStream},
+    token::Comma,
+    Ident, Token, Visibility,
+};
+
+pub(crate) struct CssModuleParser {
+    /// Whether the ident is const or static.
+    styles_vis: Visibility,
+    styles_ident: Ident,
+    asset_parser: AssetParser,
+}
+
+impl Parse for CssModuleParser {
+    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);
+
+        let _comma = input.parse::<Comma>();
+
+        // Optional options
+        let mut options = input.parse::<TokenStream>()?;
+        if options.is_empty() {
+            options = quote! { manganis::CssModuleAssetOptions::new() }
+        }
+
+        let asset_parser = AssetParser {
+            path_expr,
+            asset,
+            options,
+        };
+
+        Ok(Self {
+            styles_vis,
+            styles_ident,
+            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 super::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 )*
+            }
+        })
+    }
+}
+
+/// Convert camel and kebab case to snake case.
+///
+/// This can fail sometimes, for example `myCss-Class`` is `my_css__class`
+fn to_snake_case(input: &str) -> String {
+    let mut new = String::new();
+
+    for (i, c) in input.chars().enumerate() {
+        if c.is_uppercase() && i != 0 {
+            new.push('_');
+        }
+
+        new.push(c.to_ascii_lowercase());
+    }
+
+    new.replace('-', "_")
+}

+ 245 - 1
packages/manganis/manganis-macro/src/lib.rs

@@ -1,11 +1,22 @@
 #![doc = include_str!("../README.md")]
 #![deny(missing_docs)]
 
+use std::{
+    hash::Hasher,
+    io::Read,
+    path::{Path, PathBuf},
+};
+
+use css_module::CssModuleParser;
 use proc_macro::TokenStream;
 use quote::{quote, ToTokens};
-use syn::parse_macro_input;
+use syn::{
+    parse::{Parse, ParseStream},
+    parse_macro_input,
+};
 
 pub(crate) mod asset;
+pub(crate) mod css_module;
 pub(crate) mod linker;
 
 use linker::generate_link_section;
@@ -53,3 +64,236 @@ pub fn asset(input: TokenStream) -> TokenStream {
 
     quote! { #asset }.into_token_stream().into()
 }
+
+/// Generate type-safe and globally-unique CSS identifiers from a CSS module.
+///
+/// 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.
+///
+/// # 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.
+///
+/// ```rust
+/// css_module!(StylesIdent = "/my.module.css", CssModuleAssetOptions::new());
+/// ```
+///
+/// The styles struct can be made public by appending `pub` before the identifier.
+/// Read the [Variable Visibility](#variable-visibility) section for more information.
+///
+/// # 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.
+///
+/// ```rust
+/// // This macro usage:
+/// css_module!(Styles = "/mycss.module.css");
+///
+/// // Will generate this (simplified):
+/// struct Styles {}
+///
+/// impl Styles {
+///     // This can be accessed with `Styles::your_ident`
+///     pub const your_ident: &str = "abc",
+/// }
+/// ```
+///
+/// # 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.
+///
+/// For example, `#fooBar` will become `foo_bar`.
+///
+/// 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:
+/// ```css
+/// /* Empty ident block to ensure collection */
+/// #foo {}
+///
+/// @media ... {
+///     #foo { ... }
+/// }
+/// ```
+///
+/// # 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.
+/// ```rust
+/// css_module!(pub Styles = "/mycss.module.css");
+/// ```
+///
+/// # Asset Options
+/// Similar to the  `asset!()` macro, you can pass an optional `CssModuleAssetOptions` to configure a few processing settings.
+/// ```rust
+/// use manganis::CssModuleAssetOptions;
+///
+/// css_module!(Styles = "/mycss.module.css",
+///     CssModuleAssetOptions::new()
+///         .with_minify(true)
+///         .with_preload(false),
+/// );
+/// ```
+///
+/// # Examples
+/// First you need a CSS module:
+/// ```css
+/// /* mycss.module.css */
+///
+/// #header {
+///     padding: 50px;
+/// }
+///
+/// .header {
+///     margin: 20px;
+/// }
+///
+/// .button {
+///     background-color: #373737;        
+/// }
+/// ```
+/// Then you can use the `css_module!()` macro in your Rust project:
+/// ```rust
+/// css_module!(Styles = "/mycss.module.css");
+///
+/// println!("{}", Styles::header);
+/// println!("{}", Styles::header_class);
+/// println!("{}", Styles::button);
+/// ```
+#[proc_macro]
+pub fn css_module(input: TokenStream) -> TokenStream {
+    let style = parse_macro_input!(input as CssModuleParser);
+
+    quote! { #style }.into_token_stream().into()
+}
+
+fn resolve_path(raw: &str) -> Result<PathBuf, AssetParseError> {
+    // Get the location of the root of the crate which is where all assets are relative to
+    //
+    // IE
+    // /users/dioxus/dev/app/
+    // is the root of
+    // /users/dioxus/dev/app/assets/blah.css
+    let manifest_dir = dunce::canonicalize(
+        std::env::var("CARGO_MANIFEST_DIR")
+            .map(PathBuf::from)
+            .unwrap(),
+    )
+    .unwrap();
+
+    // 1. the input file should be a pathbuf
+    let input = PathBuf::from(raw);
+
+    // 2. absolute path to the asset
+    let Ok(path) = std::path::absolute(manifest_dir.join(raw.trim_start_matches('/'))) else {
+        return Err(AssetParseError::InvalidPath {
+            path: input.clone(),
+        });
+    };
+
+    // 3. Ensure the path exists
+    let Ok(path) = dunce::canonicalize(path) else {
+        return Err(AssetParseError::AssetDoesntExist {
+            path: input.clone(),
+        });
+    };
+
+    // 4. Ensure the path doesn't escape the crate dir
+    //
+    // - Note: since we called canonicalize on both paths, we can safely compare the parent dirs.
+    //   On windows, we can only compare the prefix if both paths are canonicalized (not just absolute)
+    //   https://github.com/rust-lang/rust/issues/42869
+    if path == manifest_dir || !path.starts_with(manifest_dir) {
+        return Err(AssetParseError::InvalidPath { path });
+    }
+
+    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();
+    let t: T = input.parse()?;
+    let end = input.cursor();
+
+    let mut cursor = begin;
+    let mut tokens = proc_macro2::TokenStream::new();
+    while cursor != end {
+        let (tt, next) = cursor.token_tree().unwrap();
+        tokens.extend(std::iter::once(tt));
+        cursor = next;
+    }
+
+    Ok((t, tokens))
+}
+
+#[derive(Debug)]
+enum AssetParseError {
+    AssetDoesntExist { path: PathBuf },
+    IoError { err: std::io::Error, path: PathBuf },
+    InvalidPath { path: PathBuf },
+    FailedToReadAsset(std::io::Error),
+}
+
+impl std::fmt::Display for AssetParseError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            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,
+                    "Asset path {} is invalid. Make sure the asset exists within this crate.",
+                    path.display()
+                )
+            }
+            AssetParseError::FailedToReadAsset(err) => write!(f, "Failed to read asset: {}", err),
+        }
+    }
+}

+ 3 - 3
packages/manganis/manganis/src/lib.rs

@@ -4,9 +4,9 @@
 mod hash;
 #[doc(hidden)]
 pub mod macro_helpers;
-pub use manganis_macro::asset;
+pub use manganis_macro::{asset, css_module};
 
 pub use manganis_core::{
-    Asset, AssetOptions, BundledAsset, CssAssetOptions, FolderAssetOptions, ImageAssetOptions,
-    ImageFormat, ImageSize, JsAssetOptions,
+    Asset, AssetOptions, BundledAsset, CssAssetOptions, CssModuleAssetOptions, FolderAssetOptions,
+    ImageAssetOptions, ImageFormat, ImageSize, JsAssetOptions,
 };

+ 34 - 2
packages/manganis/manganis/src/macro_helpers.rs

@@ -124,6 +124,38 @@ const fn generate_unique_path_with_byte_hash(
     macro_output_path
 }
 
+/// Construct the hash used by manganis and cli-opt to uniquely identify a asset based on its contents
+pub const fn hash_asset(asset_config: &AssetOptions, content_hash: u64) -> ConstStr {
+    let mut string = ConstStr::new("");
+
+    // Hash the contents along with the asset config to create a unique hash for the asset
+    // When this hash changes, the client needs to re-fetch the asset
+    let mut hasher = ConstHasher::new();
+    hasher = hasher.write(&content_hash.to_le_bytes());
+    hasher = hasher.hash_by_bytes(asset_config);
+    let hash = hasher.finish();
+
+    // Then add the hash in hex form
+    let hash_bytes = hash.to_le_bytes();
+    let mut i = 0;
+    while i < hash_bytes.len() {
+        let byte = hash_bytes[i];
+        let first = byte >> 4;
+        let second = byte & 0x0f;
+        const fn byte_to_char(byte: u8) -> char {
+            match char::from_digit(byte as u32, 16) {
+                Some(c) => c,
+                None => panic!("byte must be a valid digit"),
+            }
+        }
+        string = string.push(byte_to_char(first));
+        string = string.push(byte_to_char(second));
+        i += 1;
+    }
+
+    string
+}
+
 const fn bytes_equal(left: &[u8], right: &[u8]) -> bool {
     if left.len() != right.len() {
         return false;
@@ -171,7 +203,7 @@ fn test_unique_path() {
     let asset_config = AssetOptions::Unknown;
     let output_path =
         generate_unique_path(&input_path.to_string_lossy(), content_hash, &asset_config);
-    assert_eq!(output_path.as_str(), "test-c8c4cfad21cac262");
+    assert_eq!(output_path.as_str(), "test-8d6e32dc0b45f853");
 
     // Just changing the content hash should change the total hash
     let mut input_path = PathBuf::from("test");
@@ -181,7 +213,7 @@ fn test_unique_path() {
     let asset_config = AssetOptions::Unknown;
     let output_path =
         generate_unique_path(&input_path.to_string_lossy(), content_hash, &asset_config);
-    assert_eq!(output_path.as_str(), "test-7bced03789ff865c");
+    assert_eq!(output_path.as_str(), "test-40783366737abc4d");
 }
 
 /// Serialize an asset to a const buffer