Skip to content

Commit

Permalink
feat: Implement pure mode lints for CSS Modules (#796)
Browse files Browse the repository at this point in the history
  • Loading branch information
kdy1 authored Aug 31, 2024
1 parent 186435f commit 22ef664
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 2 deletions.
3 changes: 3 additions & 0 deletions napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ struct CssModulesConfig {
animation: Option<bool>,
grid: Option<bool>,
custom_idents: Option<bool>,
pure: Option<bool>,
}

#[cfg(feature = "bundler")]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/css_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -51,6 +53,7 @@ impl<'i> Default for Config<'i> {
animation: true,
grid: true,
custom_idents: true,
pure: false,
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
),
}
}
}
Expand Down
117 changes: 117 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ pub(crate) struct MinifyContext<'a, 'i> {
pub unused_symbols: &'a HashSet<String>,
pub custom_media: Option<HashMap<CowArcStr<'i>, CustomMediaRule<'i>>>,
pub css_modules: bool,
pub pure_css_modules: bool,
}

impl<'i, T: Clone> CssRuleList<'i, T> {
Expand Down
22 changes: 21 additions & 1 deletion src/rules/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
}
Expand Down
18 changes: 17 additions & 1 deletion src/rules/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -85,6 +100,7 @@ impl<'i, T: Clone> StyleRule<'i, T> {
}
}

context.pure_css_modules = pure_css_modules;
Ok(false)
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
1 change: 1 addition & 0 deletions src/stylesheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions website/pages/css-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,26 @@ let { code, map, exports } = transform({

</div>


### 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.
Expand Down

0 comments on commit 22ef664

Please sign in to comment.