diff --git a/.changepacks/changepack_log_selectors.json b/.changepacks/changepack_log_selectors.json new file mode 100644 index 00000000..4b6510b6 --- /dev/null +++ b/.changepacks/changepack_log_selectors.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Selectors: a nested rule without `&` in a styled or css template is scoped to the component instead of becoming a global rule (`.child {}` and `> p {}` select descendants, `:hover {}` and `::before {}` the component), and a rule nested in a selector list applies under each of its selectors; `content` keeps the quotes of its strings (`content: 'x'`, `content: \"\"`); `params` go on the pseudo-class that takes them, so `_groupNthChild` gives `:is(...):nth-child(2n) .a` also when nested; a bare `selectors` key is a pseudo-class name, an attribute of the element or a descendant selector (`'div p'`, `'> p'`), under a parent too, and keeps the case of class names; `::part()`, `::slotted()`, `::highlight()`, `::picker()` and the view-transition pseudo-elements get two colons. New build errors: a `_name` prop or selector key that names nothing (`_notASelector`, `selectors={{ div: ... }}`), and a string under a selector that is not CSS text (`_hover=\"external-class\"`)", + "date": "2026-09-30T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx index ce48b544..cf21bfc8 100644 --- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx @@ -183,6 +183,49 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector, +Any other pseudo-class or pseudo-element works the same way, as `_nthChild` for `:nth-child` or `_placeholder` for `::placeholder`. A name that selects nothing, such as `_notASelector`, is a build error rather than CSS that matches nothing. + +Pseudo-classes that take arguments get them from `params`, also in their group form: + +```tsx +const example = ( + +) +// :is([role=group],[data-group]):nth-child(odd) .a{color:red} +// .b:nth-child(2n){background:gray} +``` + +A selector takes styles: an object, or CSS text such as `_hover="color: red"`. Any other string, such as a class name, is a build error. + +## `selectors` + +`selectors` takes any selector. A key with `&` says where the element goes; a key without `&` is relative to the element: + +- a pseudo-class name, as `hover` or `focusVisible`, is that pseudo-class of the element; a name that selects nothing, such as `div`, is a build error +- a key starting with `:`, `::` or `[` applies to the element itself, as `':hover'` or `'[aria-busy]'` +- any other selector selects descendants, as `'.child'`, `'> p'` or `'div p'` + +```tsx +const example = ( + p': { m: 0 }, + }} + /> +) +// .a:hover{color:red} +// .parent .b{color:red} +// .c[aria-busy]{opacity:.5} +// .d > p{margin:0} +``` + +Nested rules in `styled` and `css` templates follow styled-components and Emotion: `.child { }` and `> p { }` select descendants, `:hover { }` and `::before { }` apply to the component, and a rule nested in a selector list applies under each of its selectors. + ## Media Shorthands These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules. diff --git a/libs/css/src/constant.rs b/libs/css/src/constant.rs index 370ad77c..521cff71 100644 --- a/libs/css/src/constant.rs +++ b/libs/css/src/constant.rs @@ -99,7 +99,6 @@ pub(super) static GLOBAL_ENUM_STYLE_PROPERTY: phf::Map<&str, phf::Map<&str, phf: pub(super) static OPTIMIZE_MULTI_CSS_VALUE_PROPERTY: phf::Set<&str> = phf_set! { "font-family", "src", - "content", "animation-name", }; @@ -115,15 +114,130 @@ pub(super) static DOUBLE_SEPARATOR: phf::Set<&str> = phf_set! { "first-letter", "first-line", "grammar-error", + "highlight", "marker", + "part", + "picker", "picker-icon", "placeholder", "scroll-marker", "scroll-marker-group", "selection", + "slotted", "spelling-error", "target-text", - "view-transition" + "view-transition", + "view-transition-group", + "view-transition-image-pair", + "view-transition-new", + "view-transition-old" +}; + +/// The pseudo-classes and pseudo-elements that take arguments, as `params` +pub(super) static FUNCTIONAL_PSEUDOS: phf::Set<&str> = phf_set! { + "active-view-transition-type", + "cue", + "cue-region", + "dir", + "has", + "highlight", + "host", + "host-context", + "is", + "lang", + "not", + "nth-child", + "nth-last-child", + "nth-last-of-type", + "nth-of-type", + "part", + "picker", + "slotted", + "state", + "view-transition-group", + "view-transition-image-pair", + "view-transition-new", + "view-transition-old", + "where" +}; + +/// The pseudo-classes a `_name` key or a bare `selectors` key can name +pub(super) static PSEUDO_CLASSES: phf::Set<&str> = phf_set! { + "active", + "active-view-transition", + "active-view-transition-type", + "any-link", + "autofill", + "blank", + "buffering", + "checked", + "current", + "default", + "defined", + "dir", + "disabled", + "empty", + "enabled", + "first", + "first-child", + "first-of-type", + "focus", + "focus-visible", + "focus-within", + "fullscreen", + "future", + "has", + "has-slotted", + "host", + "host-context", + "hover", + "in-range", + "indeterminate", + "invalid", + "is", + "lang", + "last-child", + "last-of-type", + "left", + "link", + "local-link", + "modal", + "muted", + "not", + "nth-child", + "nth-last-child", + "nth-last-of-type", + "nth-of-type", + "only-child", + "only-of-type", + "open", + "optional", + "out-of-range", + "past", + "paused", + "picture-in-picture", + "placeholder-shown", + "playing", + "popover-open", + "read-only", + "read-write", + "required", + "right", + "root", + "scope", + "seeking", + "stalled", + "state", + "target", + "target-current", + "target-within", + "user-invalid", + "user-valid", + "valid", + "visited", + "volume-locked", + "where", + "xr-overlay" }; pub(super) static ZERO_PERCENT_FUNCTION: phf::Set<&str> = phf_set! { diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b..5ed3eddc 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -20,7 +20,7 @@ use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{LazyLock, RwLock}; -use crate::constant::{GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY}; +use crate::constant::{FUNCTIONAL_PSEUDOS, GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY}; use crate::debug::is_debug; use crate::file_map::get_file_num_by_filename; use crate::num_to_nm_base::num_to_nm_base; @@ -187,9 +187,9 @@ impl ExactSizeIterator for DisassembleProperty {} #[must_use] pub fn disassemble_property(property: &str) -> DisassembleProperty { - // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties; - // keep them verbatim so class names and case survive. - if property.starts_with(':') || property.contains('&') { + // Nested selector keys (`&:hover`, `:focus`, `.parent &`, `_hover`) are not + // properties; keep them verbatim so class names and case survive. + if property.starts_with([':', '_']) || property.contains('&') { return DisassembleProperty::Fallback(Some(property.to_string())); } if let Some(properties) = HAS_CUSTOM_SHORTHANDS @@ -287,12 +287,49 @@ pub fn get_custom_shorthand_names() -> Vec { ) } +/// `selector` with `params` on the last pseudo-class or pseudo-element that +/// takes them and has none yet, wherever nesting put it: a group form reads +/// `:is(...):nth-child(2n) &`, and a parent's selector may follow it +fn with_params(selector: &str, params: &str) -> String { + let at = params_position(selector).unwrap_or(selector.len()); + format!("{}({params}){}", &selector[..at], &selector[at..]) +} + +fn params_position(selector: &str) -> Option { + let bytes = selector.as_bytes(); + let mut depth = 0usize; + let mut found = None; + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + match byte { + b'(' | b'[' => depth += 1, + b')' | b']' => depth = depth.saturating_sub(1), + b':' if depth == 0 => { + let start = index + 1 + usize::from(bytes.get(index + 1) == Some(&b':')); + let end = selector[start..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) + .map_or(selector.len(), |length| start + length); + if FUNCTIONAL_PSEUDOS.contains(&selector[start..end]) + && bytes.get(end) != Some(&b'(') + { + found = Some(end); + } + index = end; + continue; + } + _ => {} + } + index += 1; + } + found +} + #[must_use] pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelector { match selector { - StyleSelector::Selector(value) => StyleSelector::Selector(format!("{value}({params})")), + StyleSelector::Selector(value) => StyleSelector::Selector(with_params(&value, params)), StyleSelector::Global(value, file) => { - StyleSelector::Global(format!("{value}({params})"), file) + StyleSelector::Global(with_params(&value, params), file) } StyleSelector::At { kind, @@ -303,7 +340,7 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect } => StyleSelector::At { kind, query, - selector: selector.map(|s| format!("{s}({params})")), + selector: selector.map(|s| with_params(&s, params)), outer, file, }, @@ -1199,6 +1236,25 @@ mod tests { add_selector_params(StyleSelector::Selector("hover:is".to_string()), "test"), StyleSelector::Selector("hover:is(test)".to_string()) ); + for (selector, expected) in [ + ( + ":is([role=group],[data-group]):nth-child &", + ":is([role=group],[data-group]):nth-child(2n) &", + ), + ( + ":is([role=group],[data-group]):nth-child &:hover", + ":is([role=group],[data-group]):nth-child(2n) &:hover", + ), + ("&:not(.x):nth-child", "&:not(.x):nth-child(2n)"), + ("&[title=\":not\"]::part", "&[title=\":not\"]::part(2n)"), + ("&:hover", "&:hover(2n)"), + ] { + assert_eq!( + add_selector_params(StyleSelector::Selector(selector.to_string()), "2n"), + StyleSelector::Selector(expected.to_string()), + "{selector}" + ); + } assert_eq!( add_selector_params( StyleSelector::Global("&:is".to_string(), "file.ts".to_string()), diff --git a/libs/css/src/optimize_multi_css_value.rs b/libs/css/src/optimize_multi_css_value.rs index c9d8959d..14081e2d 100644 --- a/libs/css/src/optimize_multi_css_value.rs +++ b/libs/css/src/optimize_multi_css_value.rs @@ -173,7 +173,7 @@ mod tests { #[rstest] #[case("font-family", true)] #[case("src", true)] - #[case("content", true)] + #[case("content", false)] #[case("animation-name", true)] #[case("background", false)] #[case("color", false)] diff --git a/libs/css/src/selector_separator.rs b/libs/css/src/selector_separator.rs index db164cbf..112e9804 100644 --- a/libs/css/src/selector_separator.rs +++ b/libs/css/src/selector_separator.rs @@ -62,12 +62,17 @@ mod tests { "[aria-disabled='true']".into(), SelectorSeparator::None )); + + assert!(matches!(".child".into(), SelectorSeparator::Space)); + assert!(matches!("#id".into(), SelectorSeparator::Space)); + assert!(matches!("*".into(), SelectorSeparator::Space)); } #[test] fn test_display() { assert_eq!(SelectorSeparator::Double.to_string(), "::"); assert_eq!(SelectorSeparator::Single.to_string(), ":"); + assert_eq!(SelectorSeparator::Space.to_string(), " "); assert_eq!(SelectorSeparator::None.to_string(), ""); } } diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index a36bdc0f..fab4dca4 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::{ at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query}, - constant::SELECTOR_ORDER, + constant::{DOUBLE_SEPARATOR, PSEUDO_CLASSES, SELECTOR_ORDER}, selector_separator::SelectorSeparator, to_kebab_case, utils::{collapse_whitespace, to_camel_case}, @@ -91,6 +91,57 @@ fn collapse_owned_selector(s: String) -> String { } } +/// Whether `name`, a style key without its `_` in kebab-case, names what a +/// key can select: a pseudo-class or pseudo-element, its `group-` form, a +/// `theme-` or a media shorthand +#[must_use] +pub fn is_selector_name(name: &str) -> bool { + let pseudo = name.strip_prefix("group-").unwrap_or(name); + name.starts_with("theme-") + || media_shorthand_query(name).is_some() + || PSEUDO_CLASSES.contains(pseudo) + || DOUBLE_SEPARATOR.contains(pseudo) +} + +/// The selectors of a selector list, leaving the commas inside `:is(a, b)` +/// and `[title="a,b"]` alone +#[must_use] +pub fn split_selector_list(selector: &str) -> Vec<&str> { + let mut parts = vec![]; + let mut depth = 0usize; + let mut start = 0; + for (index, c) in selector.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(selector[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + if !selector.is_empty() { + parts.push(selector[start..].trim()); + } + parts +} + +/// `template` with each selector of `parent` in place of its `&`, a list +/// parent giving every combination as CSS nesting does +fn substitute(template: &str, parent: &str) -> String { + let parents = split_selector_list(parent); + if parents.len() > 1 { + parents + .iter() + .map(|parent| template.replace('&', parent)) + .collect::>() + .join(",") + } else { + template.replace('&', parent) + } +} + #[must_use] pub fn optimize_selector(selector: StyleSelector) -> StyleSelector { match selector { @@ -299,9 +350,9 @@ impl StyleSelector { pub fn nest_selector(parent: Option<&Self>, template: &str) -> Self { match parent { None => Self::Selector(template.to_string()), - Some(Self::Selector(selector)) => Self::Selector(template.replace('&', selector)), + Some(Self::Selector(selector)) => Self::Selector(substitute(template, selector)), Some(Self::Global(selector, file)) => { - Self::Global(template.replace('&', selector), file.clone()) + Self::Global(substitute(template, selector), file.clone()) } Some(Self::At { kind, @@ -315,7 +366,7 @@ impl StyleSelector { selector: Some( selector .as_deref() - .map_or_else(|| template.to_string(), |s| template.replace('&', s)), + .map_or_else(|| template.to_string(), |s| substitute(template, s)), ), outer: outer.clone(), file: file.clone(), @@ -733,6 +784,34 @@ mod tests { ); } + #[rstest] + #[case("", vec![])] + #[case("&:hover", vec!["&:hover"])] + #[case(" a > b , i ", vec!["a > b", "i"])] + #[case(":is(a, b), [title=\"a,b\"]", vec![":is(a, b)", "[title=\"a,b\"]"])] + fn test_split_selector_list(#[case] selector: &str, #[case] expected: Vec<&str>) { + assert_eq!(split_selector_list(selector), expected); + } + + #[rstest] + #[case("hover", true)] + #[case("focus-visible", true)] + #[case("nth-child", true)] + #[case("before", true)] + #[case("part", true)] + #[case("group-hover", true)] + #[case("group-first-letter", true)] + #[case("theme-dark", true)] + #[case("motion-reduce", true)] + #[case("print", true)] + #[case("not-a-selector", false)] + #[case("div", false)] + #[case("group-print", false)] + #[case("webkit-autofill", false)] + fn test_is_selector_name(#[case] name: &str, #[case] expected: bool) { + assert_eq!(is_selector_name(name), expected); + } + fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector { StyleSelector::At { kind, @@ -765,6 +844,16 @@ mod tests { "&:hover", at(AtRuleKind::Media, "print", Some("&:focus:hover")) )] + #[case( + Some(StyleSelector::Selector("& h1,&:is(a, b)".to_string())), + "& span,&:hover", + StyleSelector::Selector("& h1 span,& h1:hover,&:is(a, b) span,&:is(a, b):hover".to_string()) + )] + #[case( + Some(StyleSelector::Global("a, b".to_string(), "a.tsx".to_string())), + "& i", + StyleSelector::Global("a i,b i".to_string(), "a.tsx".to_string()) + )] fn test_nest_selector( #[case] parent: Option, #[case] template: &str, diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index f0d67200..6bf1f9b5 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -10,7 +10,7 @@ use css::{ at_rule::split_at_rule_key, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, rm_css_comment::rm_css_comment, - style_selector::StyleSelector, + style_selector::{StyleSelector, split_selector_list}, }; use oxc_allocator::Allocator; use oxc_span::{GetSpan, SPAN}; @@ -579,29 +579,36 @@ fn collect_css_block( } /// The selector a nested block applies to: at-rule preludes wrap `parent`, -/// `&`-selectors substitute it, and a bare selector under a selector nests as -/// a descendant. `None` drops the block (an unknown at-rule, or conditions -/// that can never match together). +/// `&`-selectors substitute it, and a bare selector is made relative to it +/// (the component's own class at the top). `None` drops the block (an unknown +/// at-rule, or conditions that can never match together). fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { if prelude.starts_with('@') { let (kind, query) = split_at_rule_key(prelude)?; return StyleSelector::nest_at_rule(parent, kind, query); } - let parent_selector = match parent { - Some(StyleSelector::Selector(selector) | StyleSelector::Global(selector, _)) => { - Some(selector) - } - Some(StyleSelector::At { selector, .. }) => selector.as_ref(), - None => None, - }; - let template = if prelude.contains('&') || parent_selector.is_none() { - Cow::Borrowed(prelude) - } else { - Cow::Owned(format!("& {prelude}")) - }; + let template = split_selector_list(prelude) + .into_iter() + .map(relative_selector) + .collect::>() + .join(","); Some(StyleSelector::nest_selector(parent, &template)) } +/// A selector of a nested rule made relative to the rule it is in, as +/// styled-components and Emotion read it: one with `&` says where that rule +/// goes, a pseudo-class or pseudo-element applies to that rule's element, and +/// any other selector to its descendants (`> p` to its children) +fn relative_selector(part: &str) -> Cow<'_, str> { + if part.contains('&') { + Cow::Borrowed(part) + } else if part.starts_with(':') { + Cow::Owned(format!("&{part}")) + } else { + Cow::Owned(format!("& {part}")) + } +} + /// Optimize a declaration's value only when its property warrants multi-value /// optimization, borrowing otherwise. Shared by `css_to_style_block` and /// `optimize_css_block`, which both made this identical decision inline. @@ -1171,7 +1178,7 @@ mod tests { #[case( "`ul { font-family: 'Roboto Hello', sans-serif; }`", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( @@ -1610,14 +1617,14 @@ mod tests { #[case( "ul { font-family: 'Roboto Hello', sans-serif; }", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( "div { color: red; ; { background: blue; } }", vec![ - ("color", "red", Some(StyleSelector::Selector("div".to_string()))), - ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ("color", "red", Some(StyleSelector::Selector("& div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), ] )] // As in CSS nesting, only the text after the last `;` is the nested rule's @@ -1626,7 +1633,7 @@ mod tests { "color:red;background:blue { width: 1px; }", vec![ ("color", "red", None), - ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))), ] )] #[case( @@ -1634,7 +1641,7 @@ mod tests { vec![( "width", "1px", - Some(StyleSelector::Selector("color:red".to_string())) + Some(StyleSelector::Selector("& color:red".to_string())) )] )] #[case( diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index a4484b90..92f9f2d8 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -10,25 +10,27 @@ use crate::{ extractor::{ GlobalExtractResult, extract_style_from_expression::{ - LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer, - unreadable, unreadable_key, yield_typography, + LiteralHandling, at_rule_record_kind, extract_style_from_expression, misplaced, + place_in_layer, unreadable, unreadable_key, yield_typography, }, }, utils::{ - get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, - unwrap_syntax_only_mut, + SELECTOR_NAME, get_str_by_property_key, get_string_by_literal_expression, + get_string_by_property_key, unwrap_syntax_only_mut, }, }; use css::{ at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url}, - style_selector::{AtRule, AtRuleKind, StyleSelector}, + style_selector::{AtRule, AtRuleKind, StyleSelector, is_selector_name}, + utils::to_kebab_case, }; use oxc_ast::{ ast::{ArrayExpressionElement, Expression, ObjectPropertyKind}, builder::AstBuilder, }; +use oxc_span::GetSpan; pub fn extract_global_style_from_expression<'a>( ast_builder: &AstBuilder<'a>, @@ -247,8 +249,19 @@ fn collect_global_styles<'a>( }; let global = StyleSelector::Global( - if let Some(name) = name.strip_prefix("_") { - StyleSelector::from(name).to_string().replace('&', "*") + if let Some(pseudo) = name.strip_prefix('_') { + let pseudo = to_kebab_case(pseudo); + if !is_selector_name(&pseudo) { + styles.push(misplaced( + o.key.span().start, + name, + SELECTOR_NAME, + )); + continue; + } + StyleSelector::from(pseudo.as_ref()) + .to_string() + .replace('&', "*") } else { name }, diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index ae468ce3..f5bb82fc 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -33,18 +33,21 @@ pub fn extract_keyframes_from_expression<'a>( let o = match p { ObjectPropertyKind::ObjectProperty(o) => o, ObjectPropertyKind::SpreadProperty(spread) => { - runtime = - runtime.or_else(|| Some(format!("...{}", readable_code(&spread.argument)))); + runtime = runtime + .or_else(|| Some((format!("...{}", readable_code(&spread.argument)), None))); continue; } }; let Some(name) = get_string_by_property_key(&o.key) else { runtime = runtime.or_else(|| { - Some(format!( - "[{}]", - o.key - .as_expression() - .map_or_else(String::new, readable_code) + Some(( + format!( + "[{}]", + o.key + .as_expression() + .map_or_else(String::new, readable_code) + ), + None, )) }); continue; diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27..597beced 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -10,9 +10,9 @@ use crate::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, utils::{ - expression_to_code, get_number_by_literal_expression, get_str_by_property_key, - get_string_by_literal_expression, get_string_by_property_key, is_same_expression, - readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, + CSS_TEXT, SELECTOR_NAME, expression_to_code, get_number_by_literal_expression, + get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, + is_same_expression, readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, }, }; use css::{ @@ -20,7 +20,10 @@ use css::{ at_rule::{media_shorthand_query, split_at_rule_key}, disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property, is_special_property::is_special_property, - style_selector::{AtRuleKind, StyleSelector, optimize_selector}, + rm_css_comment::rm_css_comment, + style_selector::{ + AtRuleKind, StyleSelector, is_selector_name, optimize_selector, split_selector_list, + }, theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token}, utils::to_kebab_case, }; @@ -62,6 +65,7 @@ pub(crate) fn unreadable<'a>(expression: &Expression<'a>) -> ExtractResult<'a> { offset: expression.span().start, code: readable_code(expression), prop: false, + requirement: None, }] }, ..ExtractResult::default() @@ -146,6 +150,21 @@ pub(crate) fn unreadable_key<'a>(key: &PropertyKey<'_>, prop: bool) -> ExtractSt key.as_expression().map_or_else(String::new, readable_code) ), prop, + requirement: None, + } +} + +/// `code` at `offset`, readable but not what its place takes +pub(crate) const fn misplaced<'a>( + offset: u32, + code: String, + requirement: &'static str, +) -> ExtractStyleProp<'a> { + ExtractStyleProp::Unreadable { + offset, + code, + prop: false, + requirement: Some(requirement), } } @@ -345,21 +364,18 @@ pub fn extract_style_from_expression<'a>( if let Some(name) = name && is_nested_selector_key(name) { + let offset = expression.span().start; let mut styles = vec![]; for part in split_selector_list(name) { - if let Some(nested) = nest_selectors_key(selector.as_ref(), part) { - styles.extend( - extract_style_from_expression( - ast_builder, - None, - expression, - level, - &Some(nested), - literal_handling, - ) - .styles, - ); - } + styles.extend(extract_under_key( + ast_builder, + part, + offset, + expression, + level, + selector, + literal_handling, + )); } return ExtractResult { styles, @@ -394,20 +410,17 @@ pub fn extract_style_from_expression<'a>( props.push(unreadable_key(&o.key, false)); continue; }; + let offset = o.key.span().start; for part in split_selector_list(key_name.trim()) { - if let Some(child) = nest_selectors_key(selector.as_ref(), part) { - props.extend( - extract_style_from_expression( - ast_builder, - None, - &mut o.value, - level, - &Some(child), - literal_handling, - ) - .styles, - ); - } + props.extend(extract_under_key( + ast_builder, + part, + offset, + &mut o.value, + level, + selector, + literal_handling, + )); } } } @@ -474,23 +487,20 @@ pub fn extract_style_from_expression<'a>( }; } - let nested = if let Some((kind, query)) = split_at_rule_key(name) { - Some(StyleSelector::nest_at_rule(selector.as_ref(), kind, query)) - } else { - name.strip_prefix('_') - .map(|child| nest_underscore_name(selector.as_ref(), child)) - }; - if let Some(nested) = nested { - return nested.map_or_else(ExtractResult::default, |nested| { - extract_style_from_expression( + if name.starts_with('_') || split_at_rule_key(name).is_some() { + let offset = expression.span().start; + return ExtractResult { + styles: extract_under_key( ast_builder, - None, + name, + offset, expression, level, - &Some(nested), + selector, literal_handling, - ) - }); + ), + ..ExtractResult::default() + }; } typo = name == "typography"; } @@ -540,11 +550,26 @@ pub fn extract_style_from_expression<'a>( ..ExtractResult::default() } } else { + let styles = css_to_style(&value, level, selector); ExtractResult { - styles: css_to_style(&value, level, selector) - .into_iter() - .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex))) - .collect(), + styles: if styles.is_empty() + && !matches!( + unwrap_syntax_only(expression), + Expression::BooleanLiteral(_) + ) + && !rm_css_comment(&value).trim().is_empty() + { + vec![misplaced( + expression.span().start, + readable_code(expression), + CSS_TEXT, + )] + } else { + styles + .into_iter() + .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex))) + .collect() + }, ..ExtractResult::default() } } @@ -1221,36 +1246,57 @@ pub(crate) fn at_rule_record_kind(name: &str) -> Option { } } -fn split_selector_list(key: &str) -> Vec<&str> { - let mut parts = vec![]; - let mut depth = 0usize; - let mut start = 0; - for (index, c) in key.char_indices() { - match c { - '(' => depth += 1, - ')' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - parts.push(key[start..index].trim()); - start = index + 1; - } - _ => {} - } +enum Nested { + Under(StyleSelector), + /// Its conditions can never hold together + Never, + Unknown, +} + +impl From> for Nested { + fn from(selector: Option) -> Self { + selector.map_or(Self::Never, Self::Under) } - if !key.is_empty() { - parts.push(key[start..].trim()); +} + +fn extract_under_key<'a>( + ast_builder: &AstBuilder<'a>, + key: &str, + offset: u32, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + literal_handling: LiteralHandling, +) -> Vec> { + match nest_selectors_key(selector.as_ref(), key) { + Nested::Under(nested) => { + extract_style_from_expression( + ast_builder, + None, + expression, + level, + &Some(nested), + literal_handling, + ) + .styles + } + Nested::Never => vec![], + Nested::Unknown => vec![misplaced(offset, key.to_string(), SELECTOR_NAME)], } - parts } /// Resolve a `_name` style key (without the `_`) under `parent`: media -/// shorthands such as `print` or `motionReduce` wrap it in `@media`, anything -/// else nests a selector. `None` means the styles can never apply. -fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option { +/// shorthands such as `print` or `motionReduce` wrap it in `@media`, a +/// pseudo-class or pseudo-element nests a selector. +fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Nested { let name = to_kebab_case(name); if let Some(query) = media_shorthand_query(&name) { - return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query); + return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query).into(); } - Some(StyleSelector::nest_selector( + if !is_selector_name(&name) { + return Nested::Unknown; + } + Nested::Under(StyleSelector::nest_selector( parent, &StyleSelector::from(name.as_ref()).to_string(), )) @@ -1262,20 +1308,33 @@ fn is_nested_selector_key(key: &str) -> bool { key.starts_with(':') || key.contains('&') } -fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { +/// A key without `&` is relative to the element: a pseudo-class, a +/// pseudo-element or an attribute applies to the element itself, a name such +/// as `hover` is a pseudo-class, and any other selector (`.child`, `> p`) +/// selects descendants +fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Nested { if let Some((kind, query)) = split_at_rule_key(key) { - StyleSelector::nest_at_rule(parent, kind, query) + StyleSelector::nest_at_rule(parent, kind, query).into() } else if let Some(name) = key.strip_prefix('_') { nest_underscore_name(parent, name) - } else if key.starts_with(':') { - Some(StyleSelector::nest_selector(parent, &format!("&{key}"))) - } else if parent.is_some() { - Some(StyleSelector::nest_selector(parent, key)) + } else if key.contains('&') { + Nested::Under(StyleSelector::nest_selector(parent, key)) + } else if key.starts_with([':', '[']) { + Nested::Under(StyleSelector::nest_selector(parent, &format!("&{key}"))) + } else if is_name(key) { + nest_underscore_name(parent, key) } else { - Some(StyleSelector::from(key)) + Nested::Under(StyleSelector::nest_selector(parent, &format!("& {key}"))) } } +fn is_name(key: &str) -> bool { + key.strip_prefix('-') + .unwrap_or(key) + .starts_with(|c: char| c.is_ascii_alphabetic()) + && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle { ExtractStaticStyle { property: "typography".to_string(), diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index b52e3169..62e5c9f1 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -242,6 +242,7 @@ fn runtime_member<'a>( offset, code: readable_code(&member), prop: false, + requirement: None, }, } } diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed024..3afbc467 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -319,11 +319,12 @@ pub fn extract_style_from_styled<'a>( ); let mut unreadable = Vec::new(); unreadable_styles(&styles, true, &mut unreadable); - errors.extend( - unreadable - .into_iter() - .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), - ); + errors.extend(unreadable.into_iter().map(|(offset, code, requirement)| { + ( + offset, + build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)), + ) + })); if let Some(default_class_name) = base.styles.take() { styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); } diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 1b47b978..872388de 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -37,5 +37,5 @@ pub struct GlobalExtractResult<'a> { pub struct KeyframesExtractResult { pub keyframes: ExtractKeyframes, /// A value only known at runtime, which keyframes cannot hold - pub runtime_value: Option, + pub runtime_value: Option, } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36..b000514a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -69,11 +69,13 @@ pub enum ExtractStyleProp<'a> { }, /// Styles written where the build cannot read them, reported as an error; /// `prop` for a computed key among an element's props, which the element - /// takes as it is at runtime + /// takes as it is at runtime; `requirement` for code that is readable but + /// not what the place takes, telling what it must be instead Unreadable { offset: u32, code: String, prop: bool, + requirement: Option<&'static str>, }, } @@ -113,10 +115,16 @@ impl<'a> ExtractStyleProp<'a> { expression: expression.clone_in(alloc), } } - ExtractStyleProp::Unreadable { offset, code, prop } => ExtractStyleProp::Unreadable { + ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } => ExtractStyleProp::Unreadable { offset: *offset, code: code.clone(), prop: *prop, + requirement: *requirement, }, } } @@ -20648,10 +20656,68 @@ const logical = ;" fn test_raw_selector_key_without_parent() { assert_debug_snapshot!(ToBTreeSet::from(extract_tsx( r"import { Box } from '@devup-ui/react'; -const e = b, i': { color: 'blue' } }} />;" +const e = b, > i': { color: 'blue' } }} />;" ))); } + /// A selector key naming nothing, or a string that is not CSS text where a + /// selector takes styles, would give CSS that selects or declares nothing + #[test] + #[serial] + fn test_selectors_that_select_nothing_are_errors() { + let mut errors = vec![]; + for source in [ + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { Box } from '@devup-ui/react';\nconst e = ;", + "import { css } from '@devup-ui/react';\nconst c = css({ _hover: { ':nope, _nope': { color: 'red' } } });", + "import { css } from '@devup-ui/react';\nconst c = css({ _focus: 'red' });", + "import { styled } from '@devup-ui/react';\nconst S = styled.div({ selectors: { nope: { color: 'red' } } });", + "import { globalCss } from '@devup-ui/react';\nglobalCss({ _nope: { color: 'red' } });", + ] { + reset_class_map(); + reset_file_map(); + errors.push( + extract("test.tsx", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(), + ); + } + let name = |location: &str, api: &str, code: &str| { + format!( + "test.tsx:2:{location}: {api} cannot use `{code}` at build time: {}", + utils::SELECTOR_NAME + ) + }; + let text = |location: &str, api: &str, code: &str| { + format!( + "test.tsx:2:{location}: {api} cannot use `{code}` at build time: {}", + utils::CSS_TEXT + ) + }; + assert_eq!( + errors, + [ + name("31", "``", "_notASelector"), + name("29", "``", "definitelyNotASelector"), + format!( + "{}\n{}", + name("29", "``", "div"), + name("29", "``", "i") + ), + text("23", "``", "\"external-class\""), + text("43", "``", "\"external-class\""), + name("11", "`css()`", "_nope"), + text("11", "`css()`", "\"red\""), + name("37", "`styled()`", "nope"), + name("1", "`globalCss()`", "_nope"), + ] + ); + } + #[test] #[serial] fn test_minus_zero_is_normalized() { diff --git a/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap index 3ac2686c..d5615675 100644 --- a/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap +++ b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, i': { color: 'blue' } }} />;\"))" +expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, > i': { color: 'blue' } }} />;\"))" --- ToBTreeSet { styles: { @@ -11,7 +11,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "&:a > b", + "& > i", ), ), style_order: None, @@ -25,7 +25,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "&:i", + "& a > b", ), ), style_order: None, @@ -39,7 +39,7 @@ ToBTreeSet { level: 0, selector: Some( Selector( - "&:div p", + "& div p", ), ), style_order: None, diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 4fa94f72..8fe22b48 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -851,21 +851,25 @@ fn string_class<'a>(ast_builder: &AstBuilder<'a>, value: &Expression<'a>) -> Exp ) } +/// Code the build cannot use where it is written, with what the place takes +/// when that is not the usual requirement +pub(super) type Unused = (String, Option<&'static str>); + /// The first value in `props` that is only known at runtime -pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option { +pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option { let mut unreadable = Vec::new(); unreadable_styles(props, true, &mut unreadable); unreadable .into_iter() .next() - .map(|(_, code)| code) + .map(|(_, code, requirement)| (code, requirement)) .or_else(|| { props .iter() .flat_map(crate::ExtractStyleProp::extract) .find_map(|value| match value { crate::ExtractStyleValue::Dynamic(style) => { - Some(style.identifier().to_string()) + Some((style.identifier().to_string(), None)) } _ => None, }) @@ -875,14 +879,14 @@ pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option]) -> Option { - fn condition(prop: &crate::ExtractStyleProp<'_>) -> Option { +pub(super) fn fixed_value(props: &[crate::ExtractStyleProp<'_>]) -> Option { + fn condition(prop: &crate::ExtractStyleProp<'_>) -> Option { use crate::ExtractStyleProp; match prop { ExtractStyleProp::Conditional { condition, .. } - | ExtractStyleProp::Enum { condition, .. } => Some(readable_code(condition)), + | ExtractStyleProp::Enum { condition, .. } => Some((readable_code(condition), None)), ExtractStyleProp::MemberExpression { expression, .. } => { - Some(readable_code(expression)) + Some((readable_code(expression), None)) } ExtractStyleProp::StaticArray(props) => props.iter().find_map(condition), _ => None, @@ -891,19 +895,24 @@ pub(super) fn fixed_value(props: &[crate::ExtractStyleProp<'_>]) -> Option], keys: bool, - found: &mut Vec<(u32, String)>, + found: &mut Vec<(u32, String, Option<&'static str>)>, ) { use crate::ExtractStyleProp; for prop in props { match prop { - ExtractStyleProp::Unreadable { offset, code, prop } => { + ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } => { if keys || !prop { - found.push((*offset, code.clone())); + found.push((*offset, code.clone(), *requirement)); } } ExtractStyleProp::StaticArray(props) => unreadable_styles(props, keys, found), @@ -949,6 +958,15 @@ pub(super) fn runtime_value_error(api: &str, value: &str) -> String { build_time_error(api, value, COMPUTED_VALUE) } +/// [`runtime_value_error`] for what [`runtime_value`] finds +pub(super) fn unused_error(api: &str, (code, requirement): &Unused) -> String { + build_time_error(api, code, requirement.unwrap_or(COMPUTED_VALUE)) +} + +pub(super) const SELECTOR_NAME: &str = "a selector key names a pseudo-class or pseudo-element, as `_hover` or `hover`, or is a selector, as `&:hover`, `& > p` or `.parent &`"; + +pub(super) const CSS_TEXT: &str = "a selector takes styles, an object such as `{ color: 'red' }` or CSS text such as `color: red`"; + pub(super) fn element_error(component: &str, code: &str, requirement: &str) -> String { format!("`<{component}>` cannot use `{code}` at build time: {requirement}") } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123..83063a48 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -61,7 +61,7 @@ use crate::utils::{ jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly, reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error, spread_error, stays_attribute, style_arguments, uncomposable_error, unplaced_error, - unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, + unreadable_styles, unused_error, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::{GetSpan, SPAN}; @@ -1519,10 +1519,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { runtime_value, } = extract_keyframes_from_expression(&self.ast, arg); if let Some(value) = runtime_value { - self.errors.push(( - call.span.start, - runtime_value_error("stylex.keyframes", &value), - )); + self.errors + .push((call.span.start, unused_error("stylex.keyframes", &value))); } let name = style_property_into_string(keyframes.extract(self.split_filename.as_deref())); @@ -1652,8 +1650,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { LiteralHandling::ExpandResponsiveThemeToken, ); if let Some(value) = runtime_value(&styles) { - self.errors - .push((offset, runtime_value_error("css", &value))); + self.errors.push((offset, unused_error("css", &value))); } if styles.is_empty() { @@ -1691,7 +1688,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); if let Some(value) = runtime_value { self.errors - .push((offset, runtime_value_error("keyframes", &value))); + .push((offset, unused_error("keyframes", &value))); } let name = style_property_into_string( @@ -1720,7 +1717,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); if let Some(value) = fixed_value(&styles) { self.errors - .push((offset, runtime_value_error("globalCss", &value))); + .push((offset, unused_error("globalCss", &value))); } // already set style order let style_order = style_order.unwrap_or(0); @@ -1776,7 +1773,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); if let Some(value) = fixed_value(&styles) { self.errors - .push((offset, runtime_value_error("globalCss", &value))); + .push((offset, unused_error("globalCss", &value))); } let style_order = style_order.unwrap_or(0); self.styles.extend( @@ -1837,7 +1834,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .collect::>(); if let Some(value) = runtime_value(&style_props) { self.errors - .push((tag.span.start, runtime_value_error(api, &value))); + .push((tag.span.start, unused_error(api, &value))); } for index in unplaced { let expression = &tag.quasi.expressions[index]; @@ -2337,8 +2334,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { style_order, } = extract_global_style_from_expression(&self.ast, expression, &self.filename); if let Some(value) = fixed_value(&styles) { - self.errors - .push((offset, element_error(&name, &value, RUNTIME_VALUE))); + self.errors.push(( + offset, + element_error(&name, &value.0, value.1.unwrap_or(RUNTIME_VALUE)), + )); } let style_order = style_order.unwrap_or(0); self.styles.extend( @@ -2656,10 +2655,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .for_each(|style| self.styles.extend(style.into_extract())); } - for (offset, code) in unreadable { + for (offset, code, requirement) in unreadable { self.errors.push(( offset, - element_error(&elem.opening_element.name.to_string(), &code, STYLE_OBJECT), + element_error( + &elem.opening_element.name.to_string(), + &code, + requirement.unwrap_or(STYLE_OBJECT), + ), )); } diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8..ed6e7fc1 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3508,6 +3508,45 @@ mod tests { } } + #[test] + #[serial] + #[allow(clippy::literal_string_with_formatting_args)] + fn test_nested_selector_pipeline() { + for (source, expected) in [ + // Without `&`, a nested rule selects descendants; a pseudo-class or + // pseudo-element selects the component itself. + ( + "const X = styled.div`.child { color: red; } > p { color: blue; &:hover { color: green; } } :hover { color: black; } ::before { content: 'x'; } html.test & { display: none; } & + & { margin: 0; }`", + ".c0 + .c0{margin:0}.c1:hover{color:black}.c2 .c3{color:red}.c4 > p{color:blue}.c5 > p:hover{color:green}.c6::before{content:'x'}html.c7 .c8{display:none}", + ), + // Each selector of a list is relative on its own, and a rule nested + // in a list applies under every selector of it. + ( + "const X = styled.div`h1, &:after { color: red; } h2, h3 { span { color: blue; } }`", + ".c0 h1,.c0:after{color:red}.c1 h2 span,.c1 h3 span{color:blue}", + ), + // `content` keeps its strings as written. + ( + r#"const X = styled.div`&::before { content: 'hello world'; } &::after { content: "x"; } &.empty::after { content: ""; } &.escaped::after { content: "a\"b"; } &.attr::after { content: attr(data-x); }`"#, + r#".c0.c1::after{content:attr(data-x)}.c2.c3::after{content:""}.c4.c5::after{content:"a\"b"}.c6::after{content:"x"}.c7::before{content:'hello world'}"#, + ), + // `params` go on the pseudo-class that takes them, wherever nesting + // put it. + ( + "", + ":is([role=group],[data-group]):nth-child(3n) .c0:hover{color:blue}.c1:not(.c2):nth-child(2n){color:green}:is([role=group],[data-group]):nth-child(2n) .c3{color:red}", + ), + // A bare `selectors` key is a pseudo-class name, an attribute of the + // element, or a selector of its descendants, under a parent too. + ( + " p': { m: 2 }, _focus: { selectors: { focusVisible: { color: 'green' }, '[data-x]': { color: 'black' }, 'b i': { color: 'white' } } } }} _hover=\"color: pink\" _active={false} _focusWithin={[false, null, true]} />", + ".c0:hover{color:pink}.c1:hover{color:red}:is([role=group],[data-group]):hover .c2{color:blue}.c3 .c4{margin:4px}.c5 > p{margin:8px}.c6:focus b i{color:white}.c7:focus:focus-visible{color:green}.c8:focus[data-x]{color:black}.c9[aria-busy]{padding:4px}", + ), + ] { + assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}"); + } + } + #[test] #[serial] #[allow(clippy::literal_string_with_formatting_args)]