From 996522094786e74083574e2202445149f6216c64 Mon Sep 17 00:00:00 2001 From: Henry <56412856+mcmah309@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:11:36 +0930 Subject: [PATCH] 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 --- Cargo.lock | 9 +- Cargo.toml | 6 + examples/03-assets-styling/css_modules.rs | 31 + examples/assets/css_module1.css | 11 + examples/assets/css_module2.css | 9 + packages/cli-opt/src/css.rs | 48 +- packages/cli-opt/src/file.rs | 2 +- packages/manganis/manganis-core/Cargo.toml | 1 + .../manganis/manganis-core/src/css_module.rs | 20 +- .../manganis-core/src/css_module_parser.rs | 645 ++++++++++++++++++ packages/manganis/manganis-core/src/lib.rs | 3 + .../manganis/manganis-macro/src/css_module.rs | 261 ++++--- packages/manganis/manganis-macro/src/lib.rs | 214 +++--- packages/manganis/manganis/src/lib.rs | 4 +- 14 files changed, 978 insertions(+), 286 deletions(-) create mode 100644 examples/03-assets-styling/css_modules.rs create mode 100644 examples/assets/css_module1.css create mode 100644 examples/assets/css_module2.css create mode 100644 packages/manganis/manganis-core/src/css_module_parser.rs diff --git a/Cargo.lock b/Cargo.lock index ff9c6e7e1..870d3db0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11024,6 +11024,7 @@ dependencies = [ "dioxus-core-types", "manganis", "serde", + "winnow 0.7.14", ] [[package]] @@ -17868,7 +17869,7 @@ checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.12.1", "toml_datetime 0.6.11", - "winnow 0.5.40", + "winnow 0.5.31", ] [[package]] @@ -17879,7 +17880,7 @@ checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ "indexmap 2.12.1", "toml_datetime 0.6.11", - "winnow 0.5.40", + "winnow 0.5.31", ] [[package]] @@ -20526,9 +20527,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 cfff7a54f..f3e89d12a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -367,6 +367,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.7.14" # desktop wry = { version = "0.53.5", default-features = false } @@ -597,6 +598,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 000000000..7f6595a79 --- /dev/null +++ b/examples/03-assets-styling/css_modules.rs @@ -0,0 +1,31 @@ +//! This example shows how to use css modules with the `css_module` 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 `css_module` macro will expand the annotated struct in the current scope + #[css_module("/examples/assets/css_module1.css")] + struct Styles; + + #[css_module( + "/examples/assets/css_module2.css", + // `css_module` can take `AssetOptions` as well + AssetOptions::css_module() + .with_minify(true) + .with_preload(false) + )] + struct OtherStyles; + + rsx! { + div { class: Styles::container, + div { class: OtherStyles::test, "Hello, world!" } + div { class: OtherStyles::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 000000000..5f9172ca5 --- /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 000000000..7c2672417 --- /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 b7c250389..789d8b258 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 16ae754e3..1f0820a18 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 9ff9629cc..2db91ae76 100644 --- a/packages/manganis/manganis-core/Cargo.toml +++ b/packages/manganis/manganis-core/Cargo.toml @@ -18,6 +18,7 @@ const-serialize-08 = { workspace = true, features = ["serde", "const-serialize-0 const-serialize-07 = { workspace = true } 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 5e34ea196..8cd930ae5 100644 --- a/packages/manganis/manganis-core/src/css_module.rs +++ b/packages/manganis/manganis-core/src/css_module.rs @@ -1,7 +1,12 @@ +use std::{ + collections::HashSet, + hash::{DefaultHasher, Hash, Hasher}, + path::Path, +}; + use crate::{AssetOptions, AssetOptionsBuilder, AssetVariant}; use const_serialize_07 as const_serialize; use const_serialize_08::SerializeConst; -use std::collections::HashSet; /// Options for a css module asset #[derive( @@ -94,6 +99,15 @@ impl AssetOptionsBuilder { } } +/// Create a hash for a css module based on the file path +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() +} + /// Collect CSS classes & ids. /// /// This is a rudementary css classes & ids collector. @@ -102,6 +116,10 @@ impl AssetOptionsBuilder { /// There are likely a number of edge cases that will show up. /// /// Returns `(HashSet, HashSet)` +#[deprecated( + since = "0.7.3", + note = "This function is no longer used by the css module system and will be removed in a future release." +)] pub fn collect_css_idents(css: &str) -> (HashSet, HashSet) { const ALLOWED: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"; 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 000000000..4bce3f078 --- /dev/null +++ b/packages/manganis/manganis-core/src/css_module_parser.rs @@ -0,0 +1,645 @@ +use std::borrow::Cow; + +use winnow::{ + combinator::{alt, cut_err, delimited, opt, peek, preceded, repeat, terminated}, + error::{ContextError, ErrMode, ParseError}, + prelude::*, + stream::{AsChar, ContainsToken, Range}, + token::{none_of, one_of, take_till, take_until, take_while}, +}; + +/// ```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, ErrMode>, +) -> impl Parser<&'s str, &'s str, ErrMode> { + repeat(range, f).fold(|| (), |_, _| ()).take() +} + +fn ws<'s>(input: &mut &'s str) -> ModalResult<&'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) -> ModalResult<&'s str> { + ("//", take_while(0.., |c| c != '\n')) + .take() + .parse_next(input) +} + +fn block_comment<'s>(input: &mut &'s str) -> ModalResult<&'s str> { + ("/*", cut_err(terminated(take_until(0.., "*/"), "*/"))) + .take() + .parse_next(input) +} + +// matches a sass interpolation of the form #{...} +fn sass_interpolation<'s>(input: &mut &'s str) -> ModalResult<&'s str> { + ( + "#{", + cut_err(terminated(take_till(1.., ('{', '}', '\n')), '}')), + ) + .take() + .parse_next(input) +} + +fn identifier<'s>(input: &mut &'s str) -> ModalResult<&'s str> { + ( + one_of(('_', '-', AsChar::is_alpha)), + take_while(0.., ('_', '-', AsChar::is_alphanum)), + ) + .take() + .parse_next(input) +} + +fn class<'s>(input: &mut &'s str) -> ModalResult<&'s str> { + preceded('.', identifier).parse_next(input) +} + +fn global<'s>(input: &mut &'s str) -> ModalResult> { + let (inner, outer) = preceded( + ":global(", + cut_err(terminated( + stuff_till(0.., (')', '(', '{')), // inner + ')', + )), + ) + .with_taken() // outer + .parse_next(input)?; + Ok(Global { inner, outer }) +} + +fn string_dq<'s>(input: &mut &'s str) -> ModalResult<&'s str> { + let str_char = alt((none_of(['"']).void(), "\\\"".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) -> ModalResult<&'s str> { + let str_char = alt((none_of(['\'']).void(), "\\'".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) -> ModalResult<&'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, ErrMode> { + 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) -> ModalResult>> { + repeat( + 1.., + alt(( + class.map(|c| Some(CssFragment::Class(c))), + global.map(|g| Some(CssFragment::Global(g))), + ':'.map(|_| None), + stuff_till(1.., ('.', ';', '{', '}', ':')).map(|_| None), + )), + ) + .fold(Vec::new, |mut acc, item| { + if let Some(item) = item { + acc.push(item); + } + acc + }) + .parse_next(input) +} + +fn declaration<'s>(input: &mut &'s str) -> ModalResult<&'s str> { + ( + (opt('$'), identifier), + ws, + ':', + terminated( + stuff_till(1.., (';', '{', '}')), + alt((';', peek('}'))), // semicolon is optional if it's the last element in a rule block + ), + ) + .take() + .parse_next(input) +} + +fn style_rule_block_statement<'s>(input: &mut &'s str) -> ModalResult>> { + 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) -> ModalResult>> { + repeat(0.., style_rule_block_statement) + .fold(Vec::new, |mut acc, mut item| { + acc.append(&mut item); + acc + }) + .parse_next(input) +} + +fn style_rule_block<'s>(input: &mut &'s str) -> ModalResult>> { + preceded( + '{', + cut_err(terminated(style_rule_block_contents, (ws, '}'))), + ) + .parse_next(input) +} + +fn style_rule<'s>(input: &mut &'s str) -> ModalResult>> { + 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) -> ModalResult>> { + 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) -> ModalResult<&'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.take().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 = [ + ("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 09a73cbdc..d127c3efa 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 6484811b0..d59e6e2a5 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 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 { - // 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,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. diff --git a/packages/manganis/manganis-macro/src/lib.rs b/packages/manganis/manganis-macro/src/lib.rs index d1e67be77..cd96ee758 100644 --- a/packages/manganis/manganis-macro/src/lib.rs +++ b/packages/manganis/manganis-macro/src/lib.rs @@ -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 { @@ -251,46 +289,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 +309,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 +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+."), } } diff --git a/packages/manganis/manganis/src/lib.rs b/packages/manganis/manganis/src/lib.rs index c7b867665..62059445b 100644 --- a/packages/manganis/manganis/src/lib.rs +++ b/packages/manganis/manganis/src/lib.rs @@ -4,10 +4,8 @@ #[doc(hidden)] 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::option_asset; pub use manganis_core::{ Asset, AssetOptions, AssetVariant, BundledAsset, CssAssetOptions, CssModuleAssetOptions,