diff --git a/napi/src/lib.rs b/napi/src/lib.rs index 9edfb1ce..18bbd7e4 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -608,6 +608,7 @@ struct CssModulesConfig { animation: Option, grid: Option, custom_idents: Option, + pure: Option, } #[cfg(feature = "bundler")] @@ -719,6 +720,7 @@ fn compile<'i>( animation: c.animation.unwrap_or(true), grid: c.grid.unwrap_or(true), custom_idents: c.custom_idents.unwrap_or(true), + pure: c.pure.unwrap_or_default(), }), } } else { @@ -849,6 +851,7 @@ fn compile_bundle< animation: c.animation.unwrap_or(true), grid: c.grid.unwrap_or(true), custom_idents: c.custom_idents.unwrap_or(true), + pure: c.pure.unwrap_or_default(), }), } } else { diff --git a/src/css_modules.rs b/src/css_modules.rs index 07327315..65e8543a 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -41,6 +41,8 @@ pub struct Config<'i> { /// Whether to scope custom identifiers /// Default is `true`. pub custom_idents: bool, + /// Whether to check for pure CSS modules. + pub pure: bool, } impl<'i> Default for Config<'i> { @@ -51,6 +53,7 @@ impl<'i> Default for Config<'i> { animation: true, grid: true, custom_idents: true, + pure: false, } } } diff --git a/src/error.rs b/src/error.rs index 872de438..96421fed 100644 --- a/src/error.rs +++ b/src/error.rs @@ -356,6 +356,8 @@ pub enum MinifyErrorKind { /// The source location of the `@custom-media` rule with unsupported boolean logic. custom_media_loc: Location, }, + /// A CSS module selector did not contain at least one class or id selector. + ImpureCSSModuleSelector, } impl fmt::Display for MinifyErrorKind { @@ -368,6 +370,10 @@ impl fmt::Display for MinifyErrorKind { f, "Boolean logic with media types in @custom-media rules is not supported by Lightning CSS" ), + ImpureCSSModuleSelector => write!( + f, + "A selector in CSS modules should contain at least one class or ID selector" + ), } } } diff --git a/src/lib.rs b/src/lib.rs index fbe37702..f2b7c969 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,6 +82,7 @@ mod tests { minify_test_with_options(source, expected, ParserOptions::default()) } + #[track_caller] fn minify_test_with_options<'i, 'o>(source: &'i str, expected: &'i str, options: ParserOptions<'o, 'i>) { let mut stylesheet = StyleSheet::parse(&source, options.clone()).unwrap(); stylesheet.minify(MinifyOptions::default()).unwrap(); @@ -94,6 +95,18 @@ mod tests { assert_eq!(res.code, expected); } + fn minify_error_test_with_options<'i, 'o>( + source: &'i str, + error: MinifyErrorKind, + options: ParserOptions<'o, 'i>, + ) { + let mut stylesheet = StyleSheet::parse(&source, options.clone()).unwrap(); + match stylesheet.minify(MinifyOptions::default()) { + Err(e) => assert_eq!(e.kind, error), + _ => unreachable!(), + } + } + fn prefix_test(source: &str, expected: &str, targets: Browsers) { let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); stylesheet @@ -6901,6 +6914,110 @@ mod tests { deep_options.clone(), ); + let pure_css_module_options = ParserOptions { + css_modules: Some(crate::css_modules::Config { + pure: true, + ..Default::default() + }), + ..ParserOptions::default() + }; + + minify_error_test_with_options( + "div {width: 20px}", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + ":global(.foo) {width: 20px}", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + "[foo=bar] {width: 20px}", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + "div, .foo {width: 20px}", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_test_with_options( + ":local(.foo) {width: 20px}", + "._8Z4fiW_foo{width:20px}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + "div.my-class {color: red;}", + "div._8Z4fiW_my-class{color:red}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + "#id {color: red;}", + "#_8Z4fiW_id{color:red}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + "a .my-class{color: red;}", + "a ._8Z4fiW_my-class{color:red}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + ".my-class a {color: red;}", + "._8Z4fiW_my-class a{color:red}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + ".my-class:is(a) {color: red;}", + "._8Z4fiW_my-class:is(a){color:red}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + "div:has(.my-class) {color: red;}", + "div:has(._8Z4fiW_my-class){color:red}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + ".foo { html &:hover { a_value: some-value; } }", + "._8Z4fiW_foo{html &:hover{a_value:some-value}}", + pure_css_module_options.clone(), + ); + minify_test_with_options( + ".foo { span { color: red; } }", + "._8Z4fiW_foo{& span{color:red}}", + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + "html { .foo { span { color: red; } } }", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_test_with_options( + ".foo { div { span { color: red; } } }", + "._8Z4fiW_foo{& div{& span{color:red}}}", + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + "@scope (div) { .foo { color: red } }", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + "@scope (.a) to (div) { .foo { color: red } }", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_error_test_with_options( + "@scope (.a) to (.b) { div { color: red } }", + MinifyErrorKind::ImpureCSSModuleSelector, + pure_css_module_options.clone(), + ); + minify_test_with_options( + "@scope (.a) to (.b) { .foo { color: red } }", + "@scope(._8Z4fiW_a) to (._8Z4fiW_b){._8Z4fiW_foo{color:red}}", + pure_css_module_options.clone(), + ); + error_test( "input.defaultCheckbox::before h1 {width: 20px}", ParserError::SelectorError(SelectorError::UnexpectedSelectorAfterPseudoElement(Token::Ident( diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 89ac3087..4655a6e6 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -488,6 +488,7 @@ pub(crate) struct MinifyContext<'a, 'i> { pub unused_symbols: &'a HashSet, pub custom_media: Option, CustomMediaRule<'i>>>, pub css_modules: bool, + pub pure_css_modules: bool, } impl<'i, T: Clone> CssRuleList<'i, T> { diff --git a/src/rules/scope.rs b/src/rules/scope.rs index e8a8d53b..ab7d1004 100644 --- a/src/rules/scope.rs +++ b/src/rules/scope.rs @@ -5,7 +5,7 @@ use super::{CssRuleList, MinifyContext}; use crate::error::{MinifyError, PrinterError}; use crate::parser::DefaultAtRule; use crate::printer::Printer; -use crate::selector::SelectorList; +use crate::selector::{is_pure_css_modules_selector, SelectorList}; use crate::traits::ToCss; #[cfg(feature = "visitor")] use crate::visitor::Visit; @@ -39,6 +39,26 @@ pub struct ScopeRule<'i, R = DefaultAtRule> { impl<'i, T: Clone> ScopeRule<'i, T> { pub(crate) fn minify(&mut self, context: &mut MinifyContext<'_, 'i>) -> Result<(), MinifyError> { + if context.pure_css_modules { + if let Some(scope_start) = &self.scope_start { + if !scope_start.0.iter().all(is_pure_css_modules_selector) { + return Err(MinifyError { + kind: crate::error::MinifyErrorKind::ImpureCSSModuleSelector, + loc: self.loc, + }); + } + } + + if let Some(scope_end) = &self.scope_end { + if !scope_end.0.iter().all(is_pure_css_modules_selector) { + return Err(MinifyError { + kind: crate::error::MinifyErrorKind::ImpureCSSModuleSelector, + loc: self.loc, + }); + } + } + } + self.rules.minify(context, false) } } diff --git a/src/rules/style.rs b/src/rules/style.rs index 63d8ed7d..d18ec4f9 100644 --- a/src/rules/style.rs +++ b/src/rules/style.rs @@ -12,7 +12,9 @@ use crate::error::{MinifyError, PrinterError, PrinterErrorKind}; use crate::parser::DefaultAtRule; use crate::printer::Printer; use crate::rules::CssRuleList; -use crate::selector::{downlevel_selectors, get_prefix, is_compatible, is_unused, SelectorList}; +use crate::selector::{ + downlevel_selectors, get_prefix, is_compatible, is_pure_css_modules_selector, is_unused, SelectorList, +}; use crate::targets::{should_compile, Targets}; use crate::traits::ToCss; use crate::vendor_prefix::VendorPrefix; @@ -69,6 +71,19 @@ impl<'i, T: Clone> StyleRule<'i, T> { } } + let pure_css_modules = context.pure_css_modules; + if context.pure_css_modules { + if !self.selectors.0.iter().all(is_pure_css_modules_selector) { + return Err(MinifyError { + kind: crate::error::MinifyErrorKind::ImpureCSSModuleSelector, + loc: self.loc, + }); + } + + // Parent rule contained id or class, so child rules don't need to. + context.pure_css_modules = false; + } + context.handler_context.context = DeclarationContext::StyleRule; self .declarations @@ -85,6 +100,7 @@ impl<'i, T: Clone> StyleRule<'i, T> { } } + context.pure_css_modules = pure_css_modules; Ok(false) } } diff --git a/src/selector.rs b/src/selector.rs index 2c7d4340..dfec516d 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -2093,6 +2093,25 @@ pub(crate) fn is_unused( }) } +/// Returns whether the selector has any class or id components. +pub(crate) fn is_pure_css_modules_selector(selector: &Selector) -> bool { + use parcel_selectors::parser::Component; + selector.iter_raw_match_order().any(|c| match c { + Component::Class(_) | Component::ID(_) => true, + Component::Is(s) | Component::Where(s) | Component::Has(s) | Component::Any(_, s) | Component::Negation(s) => { + s.iter().any(is_pure_css_modules_selector) + } + Component::NthOf(nth) => nth.selectors().iter().any(is_pure_css_modules_selector), + Component::Slotted(s) => is_pure_css_modules_selector(&s), + Component::Host(s) => s.as_ref().map(is_pure_css_modules_selector).unwrap_or(false), + Component::NonTSPseudoClass(pc) => match pc { + PseudoClass::Local { selector } => is_pure_css_modules_selector(&*selector), + _ => false, + }, + _ => false, + }) +} + #[cfg(feature = "visitor")] #[cfg_attr(docsrs, doc(cfg(feature = "visitor")))] impl<'i, T: Visit<'i, T, V>, V: ?Sized + Visitor<'i, T>> Visit<'i, T, V> for SelectorList<'i> { diff --git a/src/stylesheet.rs b/src/stylesheet.rs index 7c332b77..bb5a704f 100644 --- a/src/stylesheet.rs +++ b/src/stylesheet.rs @@ -249,6 +249,7 @@ where unused_symbols: &options.unused_symbols, custom_media, css_modules: self.options.css_modules.is_some(), + pure_css_modules: self.options.css_modules.as_ref().map(|c| c.pure).unwrap_or_default(), }; self.rules.minify(&mut ctx, false).map_err(|e| Error { diff --git a/website/pages/css-modules.md b/website/pages/css-modules.md index 4ba646bf..a977e616 100644 --- a/website/pages/css-modules.md +++ b/website/pages/css-modules.md @@ -253,6 +253,26 @@ let { code, map, exports } = transform({ + +### Pure mode + +Just like the `pure` option of the `css-loader` for webpack, Lightning CSS also has a `pure` option that enforces usage of one or more id or class selectors for each rule. + + +```js +let {code, map, exports} = transform({ + // ... + cssModules: { + pure: true, + }, +}); +``` + +If you enable this option, Lightning CSS will throw an error for CSS rules that don't have at least one id or class selector, like `div`. +This is useful because selectors like `div` are not scoped and affects all elements on the page. + + + ## Turning off feature scoping Scoping of grid, animations, and custom identifiers can be turned off. By default all of these are scoped.