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