Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement pure mode lints for CSS Modules #796

Merged
merged 21 commits into from
Aug 31, 2024
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
Loading