diff --git a/Cargo.lock b/Cargo.lock index 52264ce861..6b0cdd6eaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10988,6 +10988,7 @@ dependencies = [ "dioxus-core-types", "manganis", "serde", + "winnow 0.5.31", ] [[package]] @@ -14202,11 +14203,11 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "regress" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.0", "memchr", ] @@ -17784,7 +17785,7 @@ checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.12.0", "toml_datetime 0.6.11", - "winnow 0.5.40", + "winnow 0.5.31", ] [[package]] @@ -17795,7 +17796,7 @@ checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ "indexmap 2.12.0", "toml_datetime 0.6.11", - "winnow 0.5.40", + "winnow 0.5.31", ] [[package]] @@ -20424,9 +20425,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.5.40" +version = "0.5.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 948a28f09c..5dad7ba96b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -359,6 +359,7 @@ pin-project = { version = "1.1.10" } postcard = { version = "1.1.3", default-features = false } serde_urlencoded = "0.7" form_urlencoded = "1.2.1" +winnow = "0.5.31" # desktop wry = { version = "0.53.5", default-features = false } @@ -589,6 +590,11 @@ name = "svg" path = "examples/02-building-ui/svg.rs" doc-scrape-examples = true +[[example]] +name = "css_modules" +path = "examples/03-assets-styling/css_modules.rs" +doc-scrape-examples = true + [[example]] name = "custom_assets" path = "examples/03-assets-styling/custom_assets.rs" diff --git a/examples/03-assets-styling/css_modules.rs b/examples/03-assets-styling/css_modules.rs new file mode 100644 index 0000000000..f69594b2f1 --- /dev/null +++ b/examples/03-assets-styling/css_modules.rs @@ -0,0 +1,33 @@ +//! This example shows how to use css modules with the `styles!` macro. Css modules convert css +//! class names to unique names to avoid class name collisions. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + // Each `style!` macro will generate a `Styles` struct in the current scope + styles!("/examples/assets/css_module1.css"); + + mod other { + use dioxus::prelude::*; + // Multiple `styles!` macros can be used in the same scope by placing them in modules + styles!( + "/examples/assets/css_module2.css", + // `styles!` can take `AssetOptions` as well + AssetOptions::css_module() + .with_minify(true) + .with_preload(false) + ); + } + + rsx! { + div { class: Styles::container, + div { class: other::Styles::test, "Hello, world!" } + div { class: other::Styles::highlight, "This is highlighted" } + div { class: Styles::global_class, "This uses a global class (no hash)" } + } + } +} diff --git a/examples/assets/css_module1.css b/examples/assets/css_module1.css new file mode 100644 index 0000000000..5f9172ca57 --- /dev/null +++ b/examples/assets/css_module1.css @@ -0,0 +1,11 @@ +.container { + background-color: lightblue; + padding: 20px; + border-radius: 8px; +} + +/* The `:global` selector can be used to define global styles that are not scoped */ +:global(.global-class) { + color: red; + font-weight: bold; +} \ No newline at end of file diff --git a/examples/assets/css_module2.css b/examples/assets/css_module2.css new file mode 100644 index 0000000000..7c26724176 --- /dev/null +++ b/examples/assets/css_module2.css @@ -0,0 +1,9 @@ +.test { + font-size: 24px; + color: darkblue; +} + +.highlight { + background-color: yellow; + padding: 5px; +} \ No newline at end of file diff --git a/packages/cli-opt/src/css.rs b/packages/cli-opt/src/css.rs index b7c2503893..789d8b2586 100644 --- a/packages/cli-opt/src/css.rs +++ b/packages/cli-opt/src/css.rs @@ -8,7 +8,7 @@ use lightningcss::{ stylesheet::{MinifyOptions, ParserOptions, StyleSheet}, targets::{Browsers, Targets}, }; -use manganis_core::{CssAssetOptions, CssModuleAssetOptions}; +use manganis_core::{create_module_hash, transform_css, CssAssetOptions, CssModuleAssetOptions}; pub(crate) fn process_css( css_options: &CssAssetOptions, @@ -46,43 +46,39 @@ pub(crate) fn process_css( 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)?; + let 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."))? + .ok_or_else(|| { + anyhow!( + "Failed to read name of css module file `{}`.", + source.display() + ) + })? .strip_suffix(".css") - .unwrap() + .ok_or_else(|| { + anyhow!( + "Css module file `{}` should end with a `.css` suffix.", + source.display(), + ) + })? .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}")); - } + let hash = create_module_hash(source); + let css = transform_css(css.as_str(), hash.as_str()).map_err(|error| { + anyhow!( + "Invalid css for file `{}`\nError:\n{}", + source.display(), + error + ) + })?; // Minify CSS let css = if css_options.minified() { diff --git a/packages/cli-opt/src/file.rs b/packages/cli-opt/src/file.rs index 16ae754e3d..1f0820a184 100644 --- a/packages/cli-opt/src/file.rs +++ b/packages/cli-opt/src/file.rs @@ -55,7 +55,7 @@ pub(crate) fn process_file_to_with_options( process_css(options, source, &temp_path)?; } ResolvedAssetType::CssModule(options) => { - process_css_module(options, source, output_path, &temp_path)?; + process_css_module(options, source, &temp_path)?; } ResolvedAssetType::Scss(options) => { process_scss(options, source, &temp_path)?; diff --git a/packages/manganis/manganis-core/Cargo.toml b/packages/manganis/manganis-core/Cargo.toml index d1f936d4f2..3ddb18d1d5 100644 --- a/packages/manganis/manganis-core/Cargo.toml +++ b/packages/manganis/manganis-core/Cargo.toml @@ -17,6 +17,7 @@ serde = { workspace = true, features = ["derive"] } const-serialize = { workspace = true, features = ["serde"] } dioxus-core-types = { workspace = true, optional = true } dioxus-cli-config = { workspace = true, optional = true } +winnow = { workspace = true } [dev-dependencies] manganis = { workspace = true } diff --git a/packages/manganis/manganis-core/src/css_module.rs b/packages/manganis/manganis-core/src/css_module.rs index f46ec41c23..e6b25f7d20 100644 --- a/packages/manganis/manganis-core/src/css_module.rs +++ b/packages/manganis/manganis-core/src/css_module.rs @@ -1,6 +1,10 @@ +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + path::Path, +}; + use crate::{AssetOptions, AssetOptionsBuilder, AssetVariant}; use const_serialize::SerializeConst; -use std::collections::HashSet; /// Options for a css module asset #[derive( @@ -91,100 +95,10 @@ impl AssetOptionsBuilder { } } -/// 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, HashSet)` -pub fn collect_css_idents(css: &str) -> (HashSet, HashSet) { - 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) +pub fn create_module_hash(css_path: &Path) -> String { + let path_string = css_path.to_string_lossy(); + let mut hasher = DefaultHasher::new(); + path_string.hash(&mut hasher); + let hash = hasher.finish(); + format!("{:016x}", hash)[..8].to_string() } diff --git a/packages/manganis/manganis-core/src/css_module_parser.rs b/packages/manganis/manganis-core/src/css_module_parser.rs new file mode 100644 index 0000000000..27469f5078 --- /dev/null +++ b/packages/manganis/manganis-core/src/css_module_parser.rs @@ -0,0 +1,650 @@ +use std::borrow::Cow; + +use winnow::{ + combinator::{alt, cut_err, delimited, fold_repeat, opt, peek, preceded, terminated}, + error::{ContextError, ParseError}, + stream::{AsChar, ContainsToken, Range}, + token::{none_of, one_of, tag, take_till, take_until0, take_while}, + PResult, Parser, +}; + +/// ```text +/// v----v inner span +/// :global(.class) +/// ^-------------^ outer span +/// ``` +#[derive(Debug, PartialEq)] +pub struct Global<'s> { + pub inner: &'s str, + pub outer: &'s str, +} + +#[derive(Debug, PartialEq)] +pub enum CssFragment<'s> { + Class(&'s str), + Global(Global<'s>), +} + +//************************************************************************// + +/// Parses and rewrites CSS class selectors with the hash applied. +/// Does not modify `:global(...)` selectors. +pub fn transform_css<'a>( + css: &'a str, + hash: &str, +) -> Result> { + let fragments = parse_css(css)?; + + let mut new_css = String::with_capacity(css.len() * 2); + let mut cursor = css; + + for fragment in fragments { + let (span, replace) = match fragment { + CssFragment::Class(class) => (class, Cow::Owned(apply_hash(class, hash))), + CssFragment::Global(Global { inner, outer }) => (outer, Cow::Borrowed(inner)), + }; + + let (before, after) = cursor.split_at(span.as_ptr() as usize - cursor.as_ptr() as usize); + cursor = &after[span.len()..]; + new_css.push_str(before); + new_css.push_str(&replace); + } + + new_css.push_str(cursor); + Ok(new_css) +} + +/// Gets all the classes in the css files and their rewritten names. +/// Includes `:global(...)` classes where the name is not changed. +#[allow(clippy::type_complexity)] +pub fn get_class_mappings<'a>( + css: &'a str, + hash: &str, +) -> Result)>, ParseError<&'a str, ContextError>> { + let fragments = parse_css(css)?; + let mut result = Vec::new(); + + for c in fragments { + match c { + CssFragment::Class(class) => { + result.push((class, Cow::Owned(apply_hash(class, hash)))); + } + CssFragment::Global(global) => { + let global_classes = resolve_global_inner_classes(global)?; + result.extend( + global_classes + .into_iter() + .map(|class| (class, Cow::Borrowed(class))), + ); + } + } + } + result.sort_by_key(|e| e.0); + result.dedup_by_key(|e| e.0); + Ok(result) +} + +fn resolve_global_inner_classes<'a>( + global: Global<'a>, +) -> Result, ParseError<&'a str, ContextError>> { + let input = global.inner; + let fragments = selector.parse(input)?; + let mut result = Vec::new(); + for c in fragments { + match c { + CssFragment::Class(class) => result.push(class), + CssFragment::Global(_) => { + unreachable!("Top level parser should have already errored if globals are nested") + } + } + } + Ok(result) +} + +fn apply_hash(class: &str, hash: &str) -> String { + format!("{}-{}", class, hash) +} + +//************************************************************************// + +pub fn parse_css(input: &str) -> Result>, ParseError<&str, ContextError>> { + style_rule_block_contents.parse(input) +} + +fn recognize_repeat<'s, O>( + range: impl Into, + f: impl Parser<&'s str, O, ContextError>, +) -> impl Parser<&'s str, &'s str, ContextError> { + fold_repeat(range, f, || (), |_, _| ()).recognize() +} + +fn ws<'s>(input: &mut &'s str) -> PResult<&'s str> { + recognize_repeat( + 0.., + alt(( + line_comment, + block_comment, + take_while(1.., (AsChar::is_space, '\n', '\r')), + )), + ) + .parse_next(input) +} + +fn line_comment<'s>(input: &mut &'s str) -> PResult<&'s str> { + ("//", take_while(0.., |c| c != '\n')) + .recognize() + .parse_next(input) +} + +fn block_comment<'s>(input: &mut &'s str) -> PResult<&'s str> { + ("/*", cut_err(terminated(take_until0("*/"), "*/"))) + .recognize() + .parse_next(input) +} + +// matches a sass interpolation of the form #{...} +fn sass_interpolation<'s>(input: &mut &'s str) -> PResult<&'s str> { + ( + "#{", + cut_err(terminated(take_till(1.., ('{', '}', '\n')), '}')), + ) + .recognize() + .parse_next(input) +} + +fn identifier<'s>(input: &mut &'s str) -> PResult<&'s str> { + ( + one_of(('_', '-', AsChar::is_alpha)), + take_while(0.., ('_', '-', AsChar::is_alphanum)), + ) + .recognize() + .parse_next(input) +} + +fn class<'s>(input: &mut &'s str) -> PResult<&'s str> { + preceded('.', identifier).parse_next(input) +} + +fn global<'s>(input: &mut &'s str) -> PResult> { + let (inner, outer) = preceded( + ":global(", + cut_err(terminated( + stuff_till(0.., (')', '(', '{')), // inner + ')', + )), + ) + .with_recognized() // outer + .parse_next(input)?; + Ok(Global { inner, outer }) +} + +fn string_dq<'s>(input: &mut &'s str) -> PResult<&'s str> { + let str_char = alt((none_of(['"']).void(), tag("\\\"").void())); + let str_chars = recognize_repeat(0.., str_char); + + preceded('"', cut_err(terminated(str_chars, '"'))).parse_next(input) +} + +fn string_sq<'s>(input: &mut &'s str) -> PResult<&'s str> { + let str_char = alt((none_of(['\'']).void(), tag("\\'").void())); + let str_chars = recognize_repeat(0.., str_char); + + preceded('\'', cut_err(terminated(str_chars, '\''))).parse_next(input) +} + +fn string<'s>(input: &mut &'s str) -> PResult<&'s str> { + alt((string_dq, string_sq)).parse_next(input) +} + +/// Behaves like take_till except it finds and parses strings and +/// comments (allowing those to contain the end condition characters). +fn stuff_till<'s>( + range: impl Into, + list: impl ContainsToken, +) -> impl Parser<&'s str, &'s str, ContextError> { + recognize_repeat( + range, + alt(( + string.void(), + block_comment.void(), + line_comment.void(), + sass_interpolation.void(), + '/'.void(), + '#'.void(), + take_till(1.., ('\'', '"', '/', '#', list)).void(), + )), + ) +} + +fn selector<'s>(input: &mut &'s str) -> PResult>> { + fold_repeat( + 1.., + alt(( + class.map(|c| Some(CssFragment::Class(c))), + global.map(|g| Some(CssFragment::Global(g))), + ':'.map(|_| None), + stuff_till(1.., ('.', ';', '{', '}', ':')).map(|_| None), + )), + Vec::new, + |mut acc, item| { + if let Some(item) = item { + acc.push(item); + } + acc + }, + ) + .parse_next(input) +} + +fn declaration<'s>(input: &mut &'s str) -> PResult<&'s str> { + ( + (opt('$'), identifier), + ws, + ':', + terminated( + stuff_till(1.., (';', '{', '}')), + alt((';', peek('}'))), // semicolon is optional if it's the last element in a rule block + ), + ) + .recognize() + .parse_next(input) +} + +fn style_rule_block_statement<'s>(input: &mut &'s str) -> PResult>> { + let content = alt(( + declaration.map(|_| Vec::new()), // + at_rule, + style_rule, + )); + delimited(ws, content, ws).parse_next(input) +} + +fn style_rule_block_contents<'s>(input: &mut &'s str) -> PResult>> { + fold_repeat( + 0.., + style_rule_block_statement, + Vec::new, + |mut acc, mut item| { + acc.append(&mut item); + acc + }, + ) + .parse_next(input) +} + +fn style_rule_block<'s>(input: &mut &'s str) -> PResult>> { + preceded( + '{', + cut_err(terminated(style_rule_block_contents, (ws, '}'))), + ) + .parse_next(input) +} + +fn style_rule<'s>(input: &mut &'s str) -> PResult>> { + let (mut classes, mut nested_classes) = (selector, style_rule_block).parse_next(input)?; + classes.append(&mut nested_classes); + Ok(classes) +} + +fn at_rule<'s>(input: &mut &'s str) -> PResult>> { + let (identifier, char) = preceded( + '@', + cut_err(( + terminated(identifier, stuff_till(0.., ('{', '}', ';'))), + alt(('{', ';', peek('}'))), + )), + ) + .parse_next(input)?; + + if char != '{' { + return Ok(vec![]); + } + + match identifier { + "media" | "layer" | "container" | "include" => { + cut_err(terminated(style_rule_block_contents, '}')).parse_next(input) + } + _ => { + cut_err(terminated(unknown_block_contents, '}')).parse_next(input)?; + Ok(vec![]) + } + } +} + +fn unknown_block_contents<'s>(input: &mut &'s str) -> PResult<&'s str> { + recognize_repeat( + 0.., + alt(( + stuff_till(1.., ('{', '}')).void(), + ('{', cut_err((unknown_block_contents, '}'))).void(), + )), + ) + .parse_next(input) +} + +//************************************************************************// + +#[test] +fn test_class() { + let mut input = "._x1a2b Hello"; + + let r = class.parse_next(&mut input); + assert_eq!(r, Ok("_x1a2b")); +} + +#[test] +fn test_selector() { + let mut input = ".foo.bar [value=\"fa.sdasd\"] /* .banana */ // .apple \n \t .cry {"; + + let r = selector.parse_next(&mut input); + assert_eq!( + r, + Ok(vec![ + CssFragment::Class("foo"), + CssFragment::Class("bar"), + CssFragment::Class("cry") + ]) + ); + + let mut input = "{"; + + let r = selector.recognize().parse_next(&mut input); + assert!(r.is_err()); +} + +#[test] +fn test_declaration() { + let mut input = "background-color \t : red;"; + + let r = declaration.parse_next(&mut input); + assert_eq!(r, Ok("background-color \t : red;")); + + let r = declaration.parse_next(&mut input); + assert!(r.is_err()); +} + +#[test] +fn test_style_rule() { + let mut input = ".foo.bar { + background-color: red; + .baz { + color: blue; + } + $some-scss-var: 10px; + @some-at-rule blah blah; + @media blah .blah { + .moo { + color: red; + } + } + @container (width > 700px) { + .zoo { + color: blue; + } + } + }END"; + + let r = style_rule.parse_next(&mut input); + assert_eq!( + r, + Ok(vec![ + CssFragment::Class("foo"), + CssFragment::Class("bar"), + CssFragment::Class("baz"), + CssFragment::Class("moo"), + CssFragment::Class("zoo") + ]) + ); + + assert_eq!(input, "END"); +} + +#[test] +fn test_at_rule_simple() { + let mut input = "@simple-rule blah \"asd;asd\" blah;"; + + let r = at_rule.parse_next(&mut input); + assert_eq!(r, Ok(vec![])); + + assert!(input.is_empty()); +} + +#[test] +fn test_at_rule_unknown() { + let mut input = "@unknown blah \"asdasd\" blah { + bunch of stuff { + // things inside { + blah + ' { ' + } + + .bar { + color: blue; + + .baz { + color: green; + } + } + }"; + + let r = at_rule.parse_next(&mut input); + assert_eq!(r, Ok(vec![])); + + assert!(input.is_empty()); +} + +#[test] +fn test_at_rule_media() { + let mut input = "@media blah \"asdasd\" blah { + .foo { + background-color: red; + } + + .bar { + color: blue; + + .baz { + color: green; + } + } + }"; + + let r = at_rule.parse_next(&mut input); + assert_eq!( + r, + Ok(vec![ + CssFragment::Class("foo"), + CssFragment::Class("bar"), + CssFragment::Class("baz") + ]) + ); + + assert!(input.is_empty()); +} + +#[test] +fn test_at_rule_layer() { + let mut input = "@layer test { + .foo { + background-color: red; + } + + .bar { + color: blue; + + .baz { + color: green; + } + } + }"; + + let r = at_rule.parse_next(&mut input); + assert_eq!( + r, + Ok(vec![ + CssFragment::Class("foo"), + CssFragment::Class("bar"), + CssFragment::Class("baz") + ]) + ); + + assert!(input.is_empty()); +} + +#[test] +fn test_top_level() { + let mut input = "// tool.module.scss + + .default_border { + border-color: lch(100% 10 10); + border-style: dashed double; + border-radius: 30px; + + } + + @media testing { + .media-foo { + color: red; + } + } + + @layer { + .layer-foo { + color: blue; + } + } + + @include mixin { + border: none; + + .include-foo { + color: green; + } + } + + @layer foo; + + @debug 1+2 * 3==1+(2 * 3); // true + + .container { + padding: 1em; + border: 2px solid; + border-color: lch(100% 10 10); + border-style: dashed double; + border-radius: 30px; + margin: 1em; + background-color: lch(45% 9.5 140.4); + + .bar { + color: red; + } + } + + @debug 1+2 * 3==1+(2 * 3); // true + "; + + let r = style_rule_block_contents.parse_next(&mut input); + assert_eq!( + r, + Ok(vec![ + CssFragment::Class("default_border"), + CssFragment::Class("media-foo"), + CssFragment::Class("layer-foo"), + CssFragment::Class("include-foo"), + CssFragment::Class("container"), + CssFragment::Class("bar"), + ]) + ); + + println!("{input}"); + assert!(input.is_empty()); +} + +#[test] +fn test_sass_interpolation() { + let mut input = "#{$test-test}END"; + + let r = sass_interpolation.parse_next(&mut input); + assert_eq!(r, Ok("#{$test-test}")); + + assert_eq!(input, "END"); + + let mut input = "#{$test-test + }END"; + let r = sass_interpolation.parse_next(&mut input); + assert!(r.is_err()); + + let mut input = "#{$test-test"; + let r = sass_interpolation.parse_next(&mut input); + assert!(r.is_err()); + + let mut input = "#{$test-te{st}"; + let r = sass_interpolation.parse_next(&mut input); + assert!(r.is_err()); +} + +#[test] +fn test_get_class_mappings() { + let css = r#".foo.bar { + background-color: red; + :global(.baz) { + color: blue; + } + :global(.bag .biz) { + color: blue; + } + .zig { + + } + .bong {} + .zig { + color: blue; + } + }"#; + let hash = "abc1234"; + let mappings = get_class_mappings(css, hash).unwrap(); + let expected = vec![ + ("bag", "bag"), + ("bar", "bar-abc1234"), + ("baz", "baz"), + ("biz", "biz"), + ("bong", "bong-abc1234"), + ("foo", "foo-abc1234"), + ("zig", "zig-abc1234"), + ]; + if mappings.len() != expected.len() { + panic!( + "Expected {} mappings, got {}", + expected.len(), + mappings.len() + ); + } + for (i, (original, hashed)) in mappings.iter().enumerate() { + assert_eq!(expected[i].0, *original); + assert_eq!(expected[i].1, *hashed); + } +} + +#[test] +fn test_parser_error_on_nested_globals() { + let css = r#".foo :global(.bar .baz) { + color: blue; + }"#; + let result = parse_css(css); + assert!(result.is_ok()); + let css = r#".foo :global(.bar :global(.baz)) { + color: blue; + }"#; + let result = parse_css(css); + assert!(result.is_err()); +} + +#[test] +#[should_panic] +fn test_resolve_global_inner_classes_nested() { + let global = Global { + inner: ".foo :global(.bar)", + outer: ":global(.foo :global(.bar))", + }; + let _ = resolve_global_inner_classes(global); +} diff --git a/packages/manganis/manganis-core/src/lib.rs b/packages/manganis/manganis-core/src/lib.rs index 09a73cbdc5..d127c3efaf 100644 --- a/packages/manganis/manganis-core/src/lib.rs +++ b/packages/manganis/manganis-core/src/lib.rs @@ -18,3 +18,6 @@ pub use asset::*; mod css_module; pub use css_module::*; + +mod css_module_parser; +pub use css_module_parser::*; diff --git a/packages/manganis/manganis-macro/src/css_module.rs b/packages/manganis/manganis-macro/src/css_module.rs index 6484811b00..e7372b5bd7 100644 --- a/packages/manganis/manganis-macro/src/css_module.rs +++ b/packages/manganis/manganis-macro/src/css_module.rs @@ -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 quote::{quote, ToTokens, TokenStreamExt}; use syn::{ parse::{Parse, ParseStream}, spanned::Spanned, token::Comma, - Ident, Token, Visibility, + Ident, }; 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 { - // NEW: macro!(pub? STYLES_IDENT = "/path.css"); - // pub(x)? - let styles_vis = input.parse::()?; - - // Styles Ident - let styles_ident = input.parse::()?; - let _equals = input.parse::()?; - // Asset path "/path.css" let (MacroString(src), path_expr) = input.call(crate::parse_with_tokens)?; let asset = resolve_path(&src, path_expr.span()); @@ -44,11 +34,7 @@ impl Parse for CssModuleParser { options, }; - Ok(Self { - styles_vis, - styles_ident, - asset_parser, - }) + Ok(Self { asset_parser }) } } @@ -62,7 +48,7 @@ impl ToTokens for CssModuleParser { }; self.asset_parser.to_tokens(&mut linker_tokens); - let path = match self.asset_parser.asset.as_ref() { + let asset = match self.asset_parser.asset.as_ref() { Ok(path) => path, Err(err) => { let err = err.to_string(); @@ -71,69 +57,34 @@ impl ToTokens for CssModuleParser { } }; - // 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 css = std::fs::read_to_string(asset).expect("Unable to read css module file"); 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() }; - }); - } + 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 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"), - }; + 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: #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() }; + pub const #ident: __Styles::__CssIdent = __Styles::__CssIdent { inner: #new_class }; }); } - 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}; + mod __Styles { + use dioxus::prelude::*; #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 } + pub struct __CssIdent { pub inner: &'static str } use std::ops::Deref; use std::sync::OnceLock; @@ -167,11 +118,17 @@ impl ToTokens for CssModuleParser { /// Auto-generated idents struct for CSS modules. #[allow(missing_docs, non_snake_case)] - #styles_vis struct #styles_ident {} + pub struct Styles {} - impl #styles_ident { + impl Styles { #( #values )* } + + impl dioxus::core::IntoAttributeValue for __Styles::__CssIdent { + fn into_value(self) -> dioxus::core::AttributeValue { + dioxus::core::AttributeValue::Text(self.to_string()) + } + } }) } } diff --git a/packages/manganis/manganis-macro/src/lib.rs b/packages/manganis/manganis-macro/src/lib.rs index d1e67be775..410f3893be 100644 --- a/packages/manganis/manganis-macro/src/lib.rs +++ b/packages/manganis/manganis-macro/src/lib.rs @@ -1,11 +1,7 @@ #![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; @@ -84,106 +80,137 @@ 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 `styles!()` macro creates scoped CSS modules that prevent class name collisions by making +/// each class globally unique. It generates a `Styles` struct with 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 `styles!()` macro 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. /// /// ```rust, ignore -/// css_module!(StylesIdent = "/my.module.css", AssetOptions::css_module()); +/// styles!("/assets/my-styles.css"); +/// styles!("/assets/my-styles.css", AssetOptions::css_module().with_minify(true)); /// ``` /// -/// 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. +/// The `styles!()` macro does two things: +/// - It generates an asset and automatically inserts it as a stylesheet link in the document. +/// - It generates a `Styles` struct with snake-case associated constants for your CSS class names. /// /// ```rust, ignore /// // This macro usage: -/// css_module!(Styles = "/mycss.module.css"); +/// styles!("/assets/mycss.css"); /// /// // Will generate this (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 +/// +/// **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. +/// +/// 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. /// -/// For example, `#fooBar` will become `foo_bar`. +/// Class names are converted to snake_case for the Rust constants. For example: +/// - `.fooBar` becomes `Styles::foo_bar` +/// - `.my-class` becomes `Styles::my_class` /// -/// 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: +/// 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 `styles!()` macros can be used in the same file by placing them in different modules: /// ```rust, ignore -/// css_module!(pub Styles = "/mycss.module.css"); +/// // First CSS module creates `Styles` in the current scope +/// styles!("/assets/styles1.css"); +/// +/// mod other { +/// use dioxus::prelude::*; +/// // Second CSS module creates `Styles` in the `other` module +/// styles!("/assets/styles2.css"); +/// } +/// +/// // Access classes from both: +/// rsx! { +/// div { class: Styles::container } +/// div { class: other::Styles::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 +/// styles!( +/// "/assets/mycss.css", /// AssetOptions::css_module() /// .with_minify(true) -/// .with_preload(false), +/// .with_preload(false) /// ); /// ``` /// -/// # Examples -/// First you need a CSS module: -/// ```css -/// /* mycss.module.css */ +/// # Example /// -/// #header { -/// padding: 50px; -/// } +/// First create a CSS file: +/// ```css +/// /* assets/styles.css */ /// -/// .header { -/// margin: 20px; +/// .container { +/// padding: 20px; /// } /// /// .button { /// background-color: #373737; /// } +/// +/// :global(.global-text) { +/// font-weight: bold; +/// } /// ``` -/// Then you can use the `css_module!()` macro in your Rust project: +/// +/// Then use the `styles!()` macro: /// ```rust, ignore -/// css_module!(Styles = "/mycss.module.css"); +/// use dioxus::prelude::*; /// -/// println!("{}", Styles::header); -/// println!("{}", Styles::header_class); -/// println!("{}", Styles::button); +/// fn app() -> Element { +/// styles!("/assets/styles.css"); +/// +/// rsx! { +/// div { class: Styles::container, +/// button { class: Styles::button, "Click me" } +/// span { class: Styles::global_text, "This uses global class" } +/// } +/// } +/// } /// ``` #[proc_macro] -#[doc(hidden)] -pub fn css_module(input: TokenStream) -> TokenStream { +pub fn styles(input: TokenStream) -> TokenStream { let style = parse_macro_input!(input as CssModuleParser); quote! { #style }.into_token_stream().into() } @@ -251,46 +278,6 @@ fn resolve_path(raw: &str, span: Span) -> Result { Ok(path) } -fn hash_file_contents(file_path: &Path) -> Result { - // 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(input: ParseStream) -> syn::Result<(T, proc_macro2::TokenStream)> { let begin = input.cursor(); @@ -311,9 +298,7 @@ fn parse_with_tokens(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 +308,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 +315,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+."), } } diff --git a/packages/manganis/manganis/src/lib.rs b/packages/manganis/manganis/src/lib.rs index c7b8676651..2cff5d6e7c 100644 --- a/packages/manganis/manganis/src/lib.rs +++ b/packages/manganis/manganis/src/lib.rs @@ -5,9 +5,7 @@ pub mod macro_helpers; pub use manganis_macro::asset; pub use manganis_macro::option_asset; - -#[doc(hidden)] -pub use manganis_macro::css_module; +pub use manganis_macro::styles; pub use manganis_core::{ Asset, AssetOptions, AssetVariant, BundledAsset, CssAssetOptions, CssModuleAssetOptions,