diff --git a/.changepacks/changepack_log_at_rule_composition.json b/.changepacks/changepack_log_at_rule_composition.json
new file mode 100644
index 000000000..9c7889f27
--- /dev/null
+++ b/.changepacks/changepack_log_at_rule_composition.json
@@ -0,0 +1,8 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/react/package.json": "Patch"
+ },
+ "note": "Fix at-rule composition and cascade: `_print`/`_media`/`@media …` nest correctly with selectors in both directions and inside `globalCss`, nested media queries merge (`print and (…)`), breakpoints put media types first, distribute query lists, and nest `not …` queries, and at-rules are emitted after all breakpoint values while pseudo selectors keep `SELECTOR_ORDER` across breakpoints. Add `_motionReduce`, `_motionSafe`, `_portrait`, `_landscape`, `_contrastMore`, `_contrastLess` and `_forcedColors`, remove the never-matching `_speech`, normalize query whitespace, support Emotion-style `'@media …'`/`'&:hover'` object keys, fix template literals losing declarations before `@media` or selectors around nested at-rules, apply `typography` conditionally under selectors, and drop stale global at-rules on re-extraction",
+ "date": "2026-09-26T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_css_output_fixes.json b/.changepacks/changepack_log_css_output_fixes.json
new file mode 100644
index 000000000..1cdced775
--- /dev/null
+++ b/.changepacks/changepack_log_css_output_fixes.json
@@ -0,0 +1,8 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/eslint-plugin/package.json": "Patch"
+ },
+ "note": "Fix the full WASM build panicking on every vanilla-extract `.css.ts` file (Boa read the clock without its `js` feature), resolve vanilla-extract references to other styles, keyframes, fonts and themes in selectors, values and `globalStyle`, keep `globalCss` pseudo selectors in `SELECTOR_ORDER` across breakpoints, merge adjacent rules with the same selector, and let declarations written next to a conditional `typography` preset win over it. Add the `prefer-media-shorthand` and `no-typography-token-prefix` ESLint rules",
+ "date": "2026-09-27T00:00:00.000Z"
+}
diff --git a/Cargo.lock b/Cargo.lock
index 59af8ade9..bfd4eb3fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -160,6 +160,7 @@ dependencies = [
"float16",
"futures-concurrency",
"futures-lite",
+ "getrandom",
"hashbrown 0.17.1",
"icu_calendar",
"icu_normalizer",
@@ -188,6 +189,7 @@ dependencies = [
"thiserror",
"time",
"timezone_provider",
+ "web-time",
"xsum",
]
@@ -582,6 +584,7 @@ name = "devup-ui-wasm"
version = "0.1.0"
dependencies = [
"bimap",
+ "boa_engine",
"console_error_panic_hook",
"css",
"extractor",
@@ -2380,6 +2383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
+ "js-sys",
"libc",
"num-conv",
"num_threads",
@@ -2624,6 +2628,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
diff --git a/README.md b/README.md
index d51c35d6b..14c0d1658 100644
--- a/README.md
+++ b/README.md
@@ -186,7 +186,7 @@ const example = (
```tsx
// Type-safe theme tokens
const textExample =
-const boxExample =
+const boxExample =
```
**Custom shorthands:**
diff --git a/README_ko.md b/README_ko.md
index 095a0183c..1f97db94c 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -186,7 +186,7 @@ const generated = (
```tsx
// 타입 세이프 테마 토큰
const textExample =
-const boxExample =
+const boxExample =
```
**사용자 정의 shorthand:**
diff --git a/SKILL.md b/SKILL.md
index ade2c1c06..cdb07c166 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -215,8 +215,23 @@ Available: `_groupHover`, `_groupFocus`, `_groupActive`, `_groupDisabled`.
// @ prefix syntax (equivalent)
+
+// Query in the key (Emotion style), also inside `selectors` and css()/styled()
+
+```
+
+Media shorthands wrap styles in a fixed query and nest in either direction with selectors:
+`_print`, `_screen`, `_all`, `_portrait`, `_landscape`, `_motionReduce` (`prefers-reduced-motion: reduce`), `_motionSafe` (`no-preference`), `_contrastMore`, `_contrastLess`, `_forcedColors`.
+
+```tsx
+
+
+globalCss({ _motionReduce: { "*, *::before, *::after": { transition: "none" } } })
```
+- Conditions beat breakpoint values: at-rule styles are emitted after every responsive rule.
+- Nested `@media` rules merge into one query (`print and (prefers-reduced-motion:reduce)`); ones that can never match together (`_print` inside `_screen`) are dropped.
+
### Custom Selectors
```tsx
@@ -365,7 +380,7 @@ const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rot
```
- **Colors**: Use with `$` prefix in JSX props: ``
-- **Typography**: Use with `$` prefix: ``
+- **Typography**: Use the preset name without `$`: ``. Under selectors or at-rules (`_hover={{ typography: "heading" }}`) the preset applies only under that condition.
- **Length**: Responsive length tokens: ``, ``
- **Shadow**: Responsive shadow tokens: ``
- **extends**: Inherit from base config files (deep merge, last wins)
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 f48115420..ce48b5440 100644
--- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
@@ -182,3 +182,129 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector,
+
+## 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.
+
+
+
+
+
+
+ | Selector |
+ Media Query |
+
+
+
+
+
+ _print
+ |
+
+ print
+ |
+
+
+
+ _screen
+ |
+
+ screen
+ |
+
+
+
+ _all
+ |
+
+ all
+ |
+
+
+
+ _portrait
+ |
+
+ (orientation:portrait)
+ |
+
+
+
+ _landscape
+ |
+
+ (orientation:landscape)
+ |
+
+
+
+ _motionReduce
+ |
+
+ (prefers-reduced-motion:reduce)
+ |
+
+
+
+ _motionSafe
+ |
+
+ (prefers-reduced-motion:no-preference)
+ |
+
+
+
+ _contrastMore
+ |
+
+ (prefers-contrast:more)
+ |
+
+
+
+ _contrastLess
+ |
+
+ (prefers-contrast:less)
+ |
+
+
+
+ _forcedColors
+ |
+
+ (forced-colors:active)
+ |
+
+
+
+
+```tsx
+const example = (
+
+)
+```
+
+## At-Rules
+
+`_media`, `_supports` and `_container` take a record of queries to styles. A query can also sit in the key itself, as in Emotion, and `globalCss` accepts at-rules around whole selector maps.
+
+```tsx
+const example = (
+
+)
+
+globalCss({
+ _motionReduce: { '*, *::before, *::after': { transition: 'none' } },
+})
+```
+
+Conditions always win over breakpoint values: at-rule styles come after every responsive rule. Among themselves they are ordered by condition, with width queries mobile-first (`min-width` ascending, then `max-width` descending), then media types, other features, and user preferences such as `prefers-*` last.
diff --git a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx
index 205036bb9..77195034b 100644
--- a/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/breakpoints/page.mdx
@@ -172,7 +172,7 @@ Or use responsive typography from `devup.json`:
```
```tsx
-Responsive Heading
+Responsive Heading
```
## Responsive with Pseudo Selectors
diff --git a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx
index 9344e90d1..462776cec 100644
--- a/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/devup-json/page.mdx
@@ -223,8 +223,8 @@ See [Shadow](/docs/devup/shadow) for more details.
### Typography
```tsx
-const headingExample = Heading
-const bodyExample = Body text
+const headingExample = Heading
+const bodyExample = Body text
```
### Length
diff --git a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
index a356fffd6..9df1f4f6f 100644
--- a/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/devup/typography/page.mdx
@@ -60,10 +60,10 @@ Define typography styles in `devup.json`:
Use typography with the `typography` prop:
```tsx
-const h1Example = Heading 1
-const h2Example = Heading 2
-const bodyExample = Body text
-const captionExample = Caption text
+const h1Example = Heading 1
+const h2Example = Heading 2
+const bodyExample = Body text
+const captionExample = Caption text
```
## Typography Properties
@@ -298,17 +298,17 @@ Here's a recommended typography scale:
```tsx
// With Text component
-const titleExample = Main Title
-const bodyExample = Paragraph text goes here.
+const titleExample = Main Title
+const bodyExample = Paragraph text goes here.
// With Box component
-const boxExample = Caption in a box
+const boxExample = Caption in a box
```
```tsx
// With styled components
const Title = styled('h1', {
- typography: '$h1',
+ typography: 'h1',
color: '$text',
})
```
@@ -319,7 +319,7 @@ Typography can be combined with other style props:
```tsx
const example = (
-
+
Centered Title
)
@@ -330,9 +330,9 @@ const example = (
Typography tokens are fully type-safe:
```tsx
-// TypeScript autocomplete shows: $h1, $h2, $body, etc.
-const example =
+// TypeScript autocomplete shows: h1, h2, body, etc.
+const example =
// Invalid tokens show TypeScript errors
-const invalidExample = // Error!
+const invalidExample = // Error!
```
diff --git a/apps/landing/src/app/(detail)/docs/features/page.mdx b/apps/landing/src/app/(detail)/docs/features/page.mdx
index 26c9e0071..ebb85645c 100644
--- a/apps/landing/src/app/(detail)/docs/features/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/features/page.mdx
@@ -162,7 +162,7 @@ Define themes in `devup.json` with full TypeScript support.
```tsx
// Type-safe theme tokens with autocomplete
const textExample =
-const boxExample =
+const boxExample =
```
Theme switching happens via CSS variables — zero JavaScript execution required.
diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml
index 9b61daaf9..aecbd6c33 100644
--- a/bindings/devup-ui-wasm/Cargo.toml
+++ b/bindings/devup-ui-wasm/Cargo.toml
@@ -15,7 +15,11 @@ crate-type = ["cdylib"]
[features]
default = ["vanilla-extract"]
-vanilla-extract = ["extractor/vanilla-extract", "sheet/vanilla-extract"]
+vanilla-extract = [
+ "extractor/vanilla-extract",
+ "sheet/vanilla-extract",
+ "dep:boa_engine",
+]
[dependencies]
wasm-bindgen = "0.2.128"
@@ -39,6 +43,10 @@ serde-wasm-bindgen = "0.6.5"
# `getrandom_backend="wasm_js"` cfg requires on wasm32 targets.
# Keep this major version in sync with boa_engine's transitive getrandom.
getrandom = { version = "0.4", features = ["wasm_js"] }
+# Not used directly. The extractor evaluates `.css.ts` modules with Boa, whose
+# `Context::default()` reads the clock. Without the `js` feature that clock is
+# `std::time::Instant`, which panics on wasm32-unknown-unknown.
+boa_engine = { version = "0.22", optional = true, features = ["js"] }
[dev-dependencies]
wasm-bindgen-test = "0.3.78"
diff --git a/bindings/devup-ui-wasm/tests/wasm.rs b/bindings/devup-ui-wasm/tests/wasm.rs
index 677639f4f..51176bdda 100644
--- a/bindings/devup-ui-wasm/tests/wasm.rs
+++ b/bindings/devup-ui-wasm/tests/wasm.rs
@@ -46,3 +46,16 @@ fn test_object_to_typography() {
assert_eq!(typography.line_height.unwrap(), "1.5");
assert_eq!(typography.letter_spacing.unwrap(), "1px");
}
+
+#[allow(dead_code)]
+#[wasm_bindgen_test]
+fn test_extract_vanilla_extract_file() {
+ let output = extractor::extract(
+ "styles.css.ts",
+ "import { style } from '@devup-ui/react'\nexport const box = style({ color: 'red' })\n",
+ extractor::ExtractOption::default(),
+ )
+ .unwrap();
+ assert_eq!(output.styles.len(), 1);
+ assert!(!output.code.contains("style("));
+}
diff --git a/libs/css/src/at_rule.rs b/libs/css/src/at_rule.rs
new file mode 100644
index 000000000..55b0c8e41
--- /dev/null
+++ b/libs/css/src/at_rule.rs
@@ -0,0 +1,570 @@
+//! At-rule preludes: media shorthands, query normalization, media query
+//! combination, and the deterministic order at-rule blocks are emitted in.
+
+use crate::style_selector::AtRuleKind;
+use crate::utils::to_kebab_case;
+
+/// Media shorthand props and the media query each one stands for, keyed by the
+/// kebab-cased name shared with the matching Tailwind variant.
+const MEDIA_SHORTHANDS: [(&str, &str); 10] = [
+ ("all", "all"),
+ ("contrast-less", "(prefers-contrast:less)"),
+ ("contrast-more", "(prefers-contrast:more)"),
+ ("forced-colors", "(forced-colors:active)"),
+ ("landscape", "(orientation:landscape)"),
+ ("motion-reduce", "(prefers-reduced-motion:reduce)"),
+ ("motion-safe", "(prefers-reduced-motion:no-preference)"),
+ ("portrait", "(orientation:portrait)"),
+ ("print", "print"),
+ ("screen", "screen"),
+];
+
+/// The media query a shorthand prop (`print`, `motionReduce`, `motion-reduce`, …)
+/// stands for.
+#[must_use]
+pub fn media_shorthand_query(name: &str) -> Option<&'static str> {
+ let name = to_kebab_case(name);
+ MEDIA_SHORTHANDS
+ .iter()
+ .find(|(key, _)| *key == name)
+ .map(|(_, query)| *query)
+}
+
+/// Split an at-rule key such as `@media print` into its kind and prelude.
+#[must_use]
+pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> {
+ let rest = key.trim().strip_prefix('@')?;
+ [
+ ("media", AtRuleKind::Media),
+ ("supports", AtRuleKind::Supports),
+ ("container", AtRuleKind::Container),
+ ]
+ .into_iter()
+ .find_map(|(name, kind)| {
+ let query = rest.strip_prefix(name)?;
+ (query.starts_with([' ', '(']) && !query.trim().is_empty()).then_some((kind, query.trim()))
+ })
+}
+
+const fn is_word_char(c: char) -> bool {
+ c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '%') || !c.is_ascii()
+}
+
+fn leading_word(text: &str) -> &str {
+ let end = text
+ .char_indices()
+ .find(|(_, c)| !is_word_char(*c))
+ .map_or(text.len(), |(index, _)| index);
+ &text[..end]
+}
+
+fn trailing_word(text: &str) -> &str {
+ let start = text
+ .char_indices()
+ .rev()
+ .take_while(|(_, c)| is_word_char(*c))
+ .last()
+ .map_or(text.len(), |(index, _)| index);
+ &text[start..]
+}
+
+fn is_keyword(word: &str) -> bool {
+ ["and", "or", "not", "only"]
+ .iter()
+ .any(|keyword| word.eq_ignore_ascii_case(keyword))
+}
+
+/// Whether `text` ends with a standalone `and`/`or`/`not`/`only` keyword, which
+/// needs a space before a following `(` so it is not read as a function.
+fn ends_with_keyword(text: &str) -> bool {
+ let word = trailing_word(text);
+ is_keyword(word)
+ && text[..text.len() - word.len()]
+ .chars()
+ .next_back()
+ .is_none_or(|c| matches!(c, ' ' | '(' | ')' | ','))
+}
+
+/// Whether the whitespace between `prev` and `next` must survive as one space.
+const fn keeps_space(prev: char, next: char, in_function: bool) -> bool {
+ if matches!(prev, '(' | ',') || matches!(next, ')' | ',') {
+ return false;
+ }
+ in_function
+ || !(matches!(prev, ':' | '<' | '>' | '=' | ')') || matches!(next, ':' | '<' | '>' | '='))
+}
+
+/// Rewrite an at-rule prelude into its shortest equivalent spelling.
+///
+/// Whitespace is dropped wherever the tokens stay unambiguous (`(min-width: 1px)`
+/// becomes `(min-width:1px)`, `) and (` becomes `)and (`) and kept where CSS
+/// needs it: between words and before a `(` that follows a word, since `and(`
+/// or `sidebar(` would tokenize as a function. A missing space in `and(` is
+/// added back. Function arguments (`calc(1px + 2px)`, `selector(a :hover)`)
+/// and quoted strings keep their significant whitespace.
+#[must_use]
+pub fn normalize_query(query: &str) -> String {
+ let mut out = String::with_capacity(query.len());
+ // One entry per open parenthesis: `true` when it opened a function, whose
+ // arguments can contain significant whitespace.
+ let mut parens: Vec = Vec::new();
+ let mut pending_space = false;
+ let mut chars = query.chars();
+ while let Some(c) = chars.next() {
+ if c.is_whitespace() {
+ pending_space = true;
+ continue;
+ }
+ let in_function = parens.last().copied().unwrap_or(false);
+ let keyword_paren = c == '(' && !in_function && ends_with_keyword(&out);
+ let function_paren = c == '('
+ && !pending_space
+ && !keyword_paren
+ && out.chars().next_back().is_some_and(is_word_char);
+ if let Some(prev) = out.chars().next_back()
+ && (keyword_paren || (pending_space && keeps_space(prev, c, in_function)))
+ {
+ out.push(' ');
+ }
+ pending_space = false;
+ out.push(c);
+ match c {
+ '(' => parens.push(function_paren),
+ ')' => {
+ parens.pop();
+ }
+ '"' | '\'' => {
+ let mut escaped = false;
+ for inner in chars.by_ref() {
+ out.push(inner);
+ if escaped {
+ escaped = false;
+ } else if inner == '\\' {
+ escaped = true;
+ } else if inner == c {
+ break;
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ out
+}
+
+/// Result of folding two media queries into one.
+#[derive(Debug, PartialEq, Eq)]
+pub enum MediaCombination {
+ /// Both queries hold exactly when this single query list holds.
+ Merged(String),
+ /// The queries can never hold together (e.g. `print` inside `screen`).
+ Never,
+ /// No single query expresses both; emit nested `@media` blocks instead.
+ Nest,
+}
+
+struct MediaQuery<'a> {
+ negated: bool,
+ only: bool,
+ media_type: Option<&'a str>,
+ condition: Option<&'a str>,
+}
+
+fn split_list(query: &str) -> Vec<&str> {
+ let mut parts = Vec::new();
+ let mut depth = 0usize;
+ let mut start = 0;
+ for (index, c) in query.char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ ',' if depth == 0 => {
+ parts.push(query[start..index].trim());
+ start = index + 1;
+ }
+ _ => {}
+ }
+ }
+ parts.push(query[start..].trim());
+ parts
+}
+
+fn parse_media_query(query: &str) -> Option> {
+ let condition_only = MediaQuery {
+ negated: false,
+ only: false,
+ media_type: None,
+ condition: Some(query),
+ };
+ if query.starts_with('(') {
+ return Some(condition_only);
+ }
+ let first = leading_word(query);
+ let rest = &query[first.len()..];
+ let (negated, only, typed) = if first.eq_ignore_ascii_case("not") {
+ if rest.starts_with(" (") {
+ return Some(condition_only);
+ }
+ (true, false, rest.strip_prefix(' ')?)
+ } else if first.eq_ignore_ascii_case("only") {
+ (false, true, rest.strip_prefix(' ')?)
+ } else {
+ (false, false, query)
+ };
+ let media_type = leading_word(typed);
+ if media_type.is_empty() || is_keyword(media_type) {
+ return None;
+ }
+ let after = &typed[media_type.len()..];
+ let condition = if after.is_empty() {
+ None
+ } else {
+ Some(
+ after
+ .strip_prefix(" and ")
+ .filter(|condition| condition.starts_with('(') || condition.starts_with("not "))?,
+ )
+ };
+ Some(MediaQuery {
+ negated,
+ only,
+ media_type: Some(media_type),
+ condition,
+ })
+}
+
+/// Turn `not print` into `screen` (and back), the only negations with a
+/// positive spelling; anything else cannot be folded into another query.
+const fn resolve_negation(query: MediaQuery<'_>) -> Result, MediaCombination> {
+ if !query.negated {
+ return Ok(query);
+ }
+ if query.condition.is_some() {
+ return Err(MediaCombination::Nest);
+ }
+ let complement = match query.media_type {
+ Some(media_type) if media_type.eq_ignore_ascii_case("print") => "screen",
+ Some(media_type) if media_type.eq_ignore_ascii_case("screen") => "print",
+ Some(media_type) if media_type.eq_ignore_ascii_case("all") => {
+ return Err(MediaCombination::Never);
+ }
+ _ => return Err(MediaCombination::Nest),
+ };
+ Ok(MediaQuery {
+ negated: false,
+ only: false,
+ media_type: Some(complement),
+ condition: None,
+ })
+}
+
+fn has_top_level_or(condition: &str) -> bool {
+ let mut depth = 0usize;
+ let mut previous = ' ';
+ for (index, c) in condition.char_indices() {
+ match c {
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ _ if depth == 0
+ && !is_word_char(previous)
+ && leading_word(&condition[index..]).eq_ignore_ascii_case("or") =>
+ {
+ return true;
+ }
+ _ => {}
+ }
+ previous = c;
+ }
+ false
+}
+
+fn specific_type(media_type: Option<&str>) -> Option<&str> {
+ media_type.filter(|media_type| !media_type.eq_ignore_ascii_case("all"))
+}
+
+fn combine_single(outer: MediaQuery<'_>, inner: MediaQuery<'_>) -> MediaCombination {
+ let (outer, inner) = match (resolve_negation(outer), resolve_negation(inner)) {
+ (Ok(outer), Ok(inner)) => (outer, inner),
+ (Err(MediaCombination::Never), _) | (_, Err(MediaCombination::Never)) => {
+ return MediaCombination::Never;
+ }
+ _ => return MediaCombination::Nest,
+ };
+ let media_type = match (
+ specific_type(outer.media_type),
+ specific_type(inner.media_type),
+ ) {
+ (None, media_type) | (media_type, None) => media_type,
+ (Some(a), Some(b)) if a.eq_ignore_ascii_case(b) => Some(a),
+ _ => return MediaCombination::Never,
+ };
+ let conditions: Vec<&str> = [outer.condition, inner.condition]
+ .into_iter()
+ .flatten()
+ .collect();
+ let joined = conditions.len() > 1;
+
+ let mut result = String::new();
+ if let Some(media_type) = media_type {
+ if outer.only || inner.only {
+ result.push_str("only ");
+ }
+ result.push_str(media_type);
+ if !conditions.is_empty() {
+ result.push_str(" and ");
+ }
+ }
+ for (index, condition) in conditions.iter().enumerate() {
+ if index > 0 {
+ result.push_str("and ");
+ }
+ let wrap = (has_top_level_or(condition) && (joined || media_type.is_some()))
+ || (joined && leading_word(condition).eq_ignore_ascii_case("not"));
+ if wrap {
+ result.push('(');
+ result.push_str(condition);
+ result.push(')');
+ } else {
+ result.push_str(condition);
+ }
+ }
+ if result.is_empty() {
+ result.push_str("all");
+ }
+ MediaCombination::Merged(result)
+}
+
+/// Fold `inner` into `outer` so one `@media` holds exactly when both would.
+///
+/// Both preludes must already be normalized. Query lists distribute over each
+/// other, `not print`/`not screen` become `screen`/`print`, conflicting media
+/// types collapse to [`MediaCombination::Never`], and anything that has no
+/// single-query spelling (such as `not print and (color)`) asks for nesting.
+#[must_use]
+pub fn combine_media_queries(outer: &str, inner: &str) -> MediaCombination {
+ let mut merged: Vec = Vec::new();
+ for outer_query in split_list(outer) {
+ for inner_query in split_list(inner) {
+ let (Some(outer_query), Some(inner_query)) = (
+ parse_media_query(outer_query),
+ parse_media_query(inner_query),
+ ) else {
+ return MediaCombination::Nest;
+ };
+ match combine_single(outer_query, inner_query) {
+ MediaCombination::Merged(query) => {
+ if !merged.contains(&query) {
+ merged.push(query);
+ }
+ }
+ MediaCombination::Never => {}
+ MediaCombination::Nest => return MediaCombination::Nest,
+ }
+ }
+ }
+ if merged.is_empty() {
+ MediaCombination::Never
+ } else {
+ MediaCombination::Merged(merged.join(","))
+ }
+}
+
+fn length_px(value: &str) -> Option {
+ let number_end = value
+ .char_indices()
+ .find(|(_, c)| !(c.is_ascii_digit() || *c == '.'))
+ .map_or(value.len(), |(index, _)| index);
+ let number: f64 = value[..number_end].parse().ok()?;
+ let unit = &value[number_end..];
+ let scale = if unit.eq_ignore_ascii_case("em") || unit.eq_ignore_ascii_case("rem") {
+ 16.0
+ } else {
+ 1.0
+ };
+ Some((number * scale * 100.0).round() as i64)
+}
+
+/// The first width bound of a normalized query: `(true, px)` for a lower bound
+/// (`min-width`, `width>=`), `(false, px)` for an upper one (`max-width`, `width<=`).
+fn width_bound(query: &str) -> Option<(bool, i64)> {
+ query.split(['(', ')']).find_map(|term| {
+ if let Some(value) = term.strip_prefix("min-width:") {
+ return Some((true, length_px(value)?));
+ }
+ if let Some(value) = term.strip_prefix("max-width:") {
+ return Some((false, length_px(value)?));
+ }
+ let split = term.find(['<', '>'])?;
+ let (left, right) = term.split_at(split);
+ let lower = right.starts_with('>');
+ let right = right.trim_start_matches(['<', '>', '=']);
+ if left == "width" {
+ Some((lower, length_px(right)?))
+ } else if right == "width" || right.starts_with("width<") || right.starts_with("width>") {
+ Some((!lower, length_px(left)?))
+ } else {
+ None
+ }
+ })
+}
+
+/// Sort key giving at-rule blocks a deterministic cascade.
+///
+/// Width queries come first, mobile-first (`min-width` ascending, then
+/// `max-width` descending), then bare media types, then other media features,
+/// then user preferences (`prefers-*`, `forced-colors`) so those win last.
+#[must_use]
+pub fn query_order(kind: AtRuleKind, query: &str) -> (u8, i64) {
+ if matches!(kind, AtRuleKind::Media | AtRuleKind::Container)
+ && let Some((lower, px)) = width_bound(query)
+ {
+ return if lower { (0, px) } else { (1, -px) };
+ }
+ if kind != AtRuleKind::Media {
+ (3, 0)
+ } else if query.contains("prefers-") || query.contains("forced-colors") {
+ (4, 0)
+ } else if query.contains('(') {
+ (3, 0)
+ } else {
+ (2, 0)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rstest::rstest;
+
+ #[rstest]
+ #[case("print", Some("print"))]
+ #[case("screen", Some("screen"))]
+ #[case("all", Some("all"))]
+ #[case("motionReduce", Some("(prefers-reduced-motion:reduce)"))]
+ #[case("motion-reduce", Some("(prefers-reduced-motion:reduce)"))]
+ #[case("motionSafe", Some("(prefers-reduced-motion:no-preference)"))]
+ #[case("portrait", Some("(orientation:portrait)"))]
+ #[case("landscape", Some("(orientation:landscape)"))]
+ #[case("contrastMore", Some("(prefers-contrast:more)"))]
+ #[case("contrastLess", Some("(prefers-contrast:less)"))]
+ #[case("forcedColors", Some("(forced-colors:active)"))]
+ #[case("speech", None)]
+ #[case("hover", None)]
+ fn test_media_shorthand_query(#[case] name: &str, #[case] expected: Option<&str>) {
+ assert_eq!(media_shorthand_query(name), expected);
+ }
+
+ #[rstest]
+ #[case("@media print", Some((AtRuleKind::Media, "print")))]
+ #[case("@media(min-width: 1px)", Some((AtRuleKind::Media, "(min-width: 1px)")))]
+ #[case(" @supports (display: grid) ", Some((AtRuleKind::Supports, "(display: grid)")))]
+ #[case("@container sidebar (min-width: 1px)", Some((AtRuleKind::Container, "sidebar (min-width: 1px)")))]
+ #[case("@media", None)]
+ #[case("@media ", None)]
+ #[case("@mediaprint", None)]
+ #[case("@layer reset", None)]
+ #[case("media print", None)]
+ fn test_split_at_rule_key(#[case] key: &str, #[case] expected: Option<(AtRuleKind, &str)>) {
+ assert_eq!(split_at_rule_key(key), expected);
+ }
+
+ #[rstest]
+ #[case("print", "print")]
+ #[case(" print ", "print")]
+ #[case("(prefers-reduced-motion: reduce)", "(prefers-reduced-motion:reduce)")]
+ #[case("( min-width : 768px )", "(min-width:768px)")]
+ #[case(
+ "(min-width: 768px) and (max-width: 1024px)",
+ "(min-width:768px)and (max-width:1024px)"
+ )]
+ #[case("screen and (min-width: 500px)", "screen and (min-width:500px)")]
+ #[case(
+ "only screen and(min-width:768px)",
+ "only screen and (min-width:768px)"
+ )]
+ #[case("not (display: grid)", "not (display:grid)")]
+ #[case("not(display:grid)", "not (display:grid)")]
+ #[case("(a) or(b)", "(a)or (b)")]
+ #[case("sidebar (min-width: 400px)", "sidebar (min-width:400px)")]
+ #[case(
+ "(orientation: portrait), (hover: none)",
+ "(orientation:portrait),(hover:none)"
+ )]
+ #[case("(width >= 600px)", "(width>=600px)")]
+ #[case("(400px <= width <= 700px)", "(400px<=width<=700px)")]
+ #[case("(min-width: calc(100px + 2em))", "(min-width:calc(100px + 2em))")]
+ #[case("selector(a :hover)", "selector(a :hover)")]
+ #[case("selector(:not(a))", "selector(:not(a))")]
+ #[case("style(--label: \"a b\")", "style(--label: \"a b\")")]
+ #[case("style(--label: 'it\\'s x')", "style(--label: 'it\\'s x')")]
+ #[case("(not (color)) or (hover)", "(not (color))or (hover)")]
+ #[case("", "")]
+ fn test_normalize_query(#[case] query: &str, #[case] expected: &str) {
+ assert_eq!(normalize_query(query), expected);
+ }
+
+ #[rstest]
+ #[case("(min-width:768px)", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("(min-width:768px)and (prefers-reduced-motion:reduce)".to_string()))]
+ #[case("(min-width:768px)", "print", MediaCombination::Merged("print and (min-width:768px)".to_string()))]
+ #[case("print", "(prefers-reduced-motion:reduce)", MediaCombination::Merged("print and (prefers-reduced-motion:reduce)".to_string()))]
+ #[case("(min-width:768px)", "screen and (min-width:100px)", MediaCombination::Merged("screen and (min-width:768px)and (min-width:100px)".to_string()))]
+ #[case("(min-width:768px)", "only screen", MediaCombination::Merged("only screen and (min-width:768px)".to_string()))]
+ #[case("(min-width:768px)", "not print", MediaCombination::Merged("screen and (min-width:768px)".to_string()))]
+ #[case("(min-width:768px)", "not screen", MediaCombination::Merged("print and (min-width:768px)".to_string()))]
+ #[case("(min-width:768px)", "not all", MediaCombination::Never)]
+ #[case("(min-width:768px)", "not tv", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "not print and (color)", MediaCombination::Nest)]
+ #[case("print", "screen", MediaCombination::Never)]
+ #[case("print", "PRINT", MediaCombination::Merged("print".to_string()))]
+ #[case("all", "print", MediaCombination::Merged("print".to_string()))]
+ #[case("all", "all", MediaCombination::Merged("all".to_string()))]
+ #[case("(min-width:768px)", "(orientation:portrait),(hover:none)", MediaCombination::Merged("(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none)".to_string()))]
+ #[case("(min-width:768px)", "print,screen", MediaCombination::Merged("print and (min-width:768px),screen and (min-width:768px)".to_string()))]
+ #[case("print", "print,screen", MediaCombination::Merged("print".to_string()))]
+ #[case("(min-width:768px)", "(a)or (b)", MediaCombination::Merged("(min-width:768px)and ((a)or (b))".to_string()))]
+ #[case("print", "(a)or (b)", MediaCombination::Merged("print and ((a)or (b))".to_string()))]
+ #[case("(min-width:768px)", "not (color)", MediaCombination::Merged("(min-width:768px)and (not (color))".to_string()))]
+ #[case("print", "not (color)", MediaCombination::Merged("print and not (color)".to_string()))]
+ #[case("(a)", "(b)", MediaCombination::Merged("(a)and (b)".to_string()))]
+ #[case("(min-width:768px)", "print and speech", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "only", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "not", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "and", MediaCombination::Nest)]
+ #[case("(min-width:768px)", "", MediaCombination::Nest)]
+ fn test_combine_media_queries(
+ #[case] outer: &str,
+ #[case] inner: &str,
+ #[case] expected: MediaCombination,
+ ) {
+ assert_eq!(combine_media_queries(outer, inner), expected);
+ }
+
+ #[rstest]
+ #[case(AtRuleKind::Media, "(min-width:500px)", (0, 50_000))]
+ #[case(AtRuleKind::Media, "(min-width:30em)", (0, 48_000))]
+ #[case(AtRuleKind::Media, "(min-width:2rem)", (0, 3_200))]
+ #[case(AtRuleKind::Media, "(width>=600px)", (0, 60_000))]
+ #[case(AtRuleKind::Media, "(width>600px)", (0, 60_000))]
+ #[case(AtRuleKind::Media, "(600px<=width)", (0, 60_000))]
+ #[case(AtRuleKind::Media, "(400px<=width<=700px)", (0, 40_000))]
+ #[case(AtRuleKind::Media, "(max-width:1000px)", (1, -100_000))]
+ #[case(AtRuleKind::Media, "(width<=900px)", (1, -90_000))]
+ #[case(AtRuleKind::Media, "(900px>=width)", (1, -90_000))]
+ #[case(AtRuleKind::Media, "screen and (min-width:10px)", (0, 1_000))]
+ #[case(AtRuleKind::Media, "(min-width:abc)", (3, 0))]
+ #[case(AtRuleKind::Media, "(height>=10px)", (3, 0))]
+ #[case(AtRuleKind::Media, "print", (2, 0))]
+ #[case(AtRuleKind::Media, "(orientation:portrait)", (3, 0))]
+ #[case(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", (4, 0))]
+ #[case(AtRuleKind::Media, "(forced-colors:active)", (4, 0))]
+ #[case(AtRuleKind::Container, "(min-width:400px)", (0, 40_000))]
+ #[case(AtRuleKind::Container, "sidebar (orientation:portrait)", (3, 0))]
+ #[case(AtRuleKind::Supports, "(display:grid)", (3, 0))]
+ fn test_query_order(
+ #[case] kind: AtRuleKind,
+ #[case] query: &str,
+ #[case] expected: (u8, i64),
+ ) {
+ assert_eq!(query_order(kind, query), expected);
+ }
+}
diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs
index f9479f02e..bdd33131e 100644
--- a/libs/css/src/lib.rs
+++ b/libs/css/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod at_rule;
pub mod atom_hoist;
pub mod class_map;
mod constant;
@@ -186,6 +187,11 @@ 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('&') {
+ return DisassembleProperty::Fallback(Some(property.to_string()));
+ }
if let Some(properties) = HAS_CUSTOM_SHORTHANDS
.load(Ordering::Relaxed)
.then(|| {
@@ -292,10 +298,14 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect
kind,
query,
selector,
+ outer,
+ file,
} => StyleSelector::At {
kind,
query,
selector: selector.map(|s| format!("{s}({params})")),
+ outer,
+ file,
},
}
}
@@ -1120,7 +1130,9 @@ mod tests {
Some(&StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: None
+ selector: None,
+ outer: vec![],
+ file: None,
})
),
".cls"
@@ -1132,7 +1144,9 @@ mod tests {
Some(&StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: Some("&:hover".to_string())
+ selector: Some("&:hover".to_string()),
+ outer: vec![],
+ file: None,
})
),
".cls:hover"
@@ -1197,14 +1211,18 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: Some("&:is".to_string())
+ selector: Some("&:is".to_string()),
+ outer: vec![],
+ file: None,
},
"test"
),
StyleSelector::At {
kind: AtRuleKind::Media,
query: "print".to_string(),
- selector: Some("&:is(test)".to_string())
+ selector: Some("&:is(test)".to_string()),
+ outer: vec![],
+ file: None,
}
);
}
diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs
index ff839bb3c..a36bdc0f4 100644
--- a/libs/css/src/style_selector.rs
+++ b/libs/css/src/style_selector.rs
@@ -7,6 +7,7 @@ use std::{
use serde::{Deserialize, Serialize};
use crate::{
+ at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query},
constant::SELECTOR_ORDER,
selector_separator::SelectorSeparator,
to_kebab_case,
@@ -47,12 +48,25 @@ impl From<&str> for AtRuleKind {
}
}
+#[derive(Debug, PartialEq, PartialOrd, Ord, Clone, Hash, Eq, Serialize, Deserialize)]
+pub struct AtRule {
+ pub kind: AtRuleKind,
+ pub query: String,
+}
+
#[derive(Debug, PartialEq, Clone, Hash, Eq, Serialize, Deserialize)]
pub enum StyleSelector {
At {
kind: AtRuleKind,
query: String,
selector: Option,
+ /// At-rules enclosing this one, outermost first. Only set when the
+ /// rules cannot be folded into a single `@media` query.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ outer: Vec,
+ /// Source file of a `globalCss` rule, so re-extracting that file drops it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ file: Option,
},
Selector(String),
// selector, file
@@ -84,10 +98,14 @@ pub fn optimize_selector(selector: StyleSelector) -> StyleSelector {
kind,
query,
selector,
+ outer,
+ file,
} => StyleSelector::At {
kind,
query,
selector: selector.map(collapse_owned_selector),
+ outer,
+ file,
},
StyleSelector::Selector(selector) => {
StyleSelector::Selector(collapse_owned_selector(selector))
@@ -111,20 +129,22 @@ impl Ord for StyleSelector {
kind: ka,
query: a,
selector: aa,
+ outer: oa,
+ file: fa,
},
StyleSelector::At {
kind: kb,
query: b,
selector: bb,
+ outer: ob,
+ file: fb,
},
- ) => {
- let k = (*ka as u8).cmp(&(*kb as u8));
- if k != Ordering::Equal {
- return k;
- }
- let c = a.cmp(b);
- if c == Ordering::Equal { aa.cmp(bb) } else { c }
- }
+ ) => (*ka as u8)
+ .cmp(&(*kb as u8))
+ .then_with(|| a.cmp(b))
+ .then_with(|| aa.cmp(bb))
+ .then_with(|| oa.cmp(ob))
+ .then_with(|| fa.cmp(fb)),
(StyleSelector::Selector(a), StyleSelector::Selector(b)) => {
let order_cmp = get_selector_order(a).cmp(&get_selector_order(b));
if order_cmp == Ordering::Equal {
@@ -181,11 +201,13 @@ impl From<&str> for StyleSelector {
} else if let Some(s) = value.strip_prefix("theme-") {
// first character should lower case
StyleSelector::Selector(format!(":root[data-theme={}] &", to_camel_case(s)))
- } else if matches!(value.as_ref(), "print" | "screen" | "speech" | "all") {
+ } else if let Some(query) = media_shorthand_query(&value) {
StyleSelector::At {
kind: AtRuleKind::Media,
- query: value.into_owned(),
+ query: query.to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}
} else {
let post = to_kebab_case(&value);
@@ -214,23 +236,19 @@ impl From<[&str; 2]> for StyleSelector {
))
}
}
-impl From<(&StyleSelector, &str)> for StyleSelector {
- fn from(value: (&StyleSelector, &str)) -> Self {
- if let StyleSelector::Global(_, file) = value.0 {
- let post = to_kebab_case(value.1);
- StyleSelector::Global(
- format!(
- "{}{}{}",
- value.0,
- SelectorSeparator::from(post.as_ref()),
- post
- ),
- file.clone(),
- )
- } else {
- StyleSelector::from([&value.0.to_string(), value.1])
- }
+
+/// Write an at-rule prelude (`@media print`, `@media(min-width:1px)`), spacing
+/// the query only when it does not open with a parenthesis.
+pub fn write_at_rule(
+ out: &mut impl std::fmt::Write,
+ kind: AtRuleKind,
+ query: &str,
+) -> std::fmt::Result {
+ write!(out, "@{kind}")?;
+ if !query.starts_with('(') {
+ out.write_char(' ')?;
}
+ out.write_str(query)
}
impl Display for StyleSelector {
@@ -241,12 +259,14 @@ impl Display for StyleSelector {
kind,
query,
selector,
+ outer,
+ ..
} => {
- write!(f, "@{kind}")?;
- if !query.starts_with('(') {
+ for rule in outer {
+ write_at_rule(f, rule.kind, &rule.query)?;
f.write_str(" ")?;
}
- f.write_str(query)?;
+ write_at_rule(f, *kind, query)?;
if let Some(selector) = selector {
write!(f, " {selector}")?;
}
@@ -272,6 +292,87 @@ impl StyleSelector {
StyleSelector::At { .. } => std::borrow::Cow::Owned(self.to_string()),
}
}
+
+ /// Nest a selector `template` (e.g. `&:hover`, `:root[data-theme=dark] &`)
+ /// inside `parent`, substituting the parent's selector for `&`.
+ #[must_use]
+ 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::Global(selector, file)) => {
+ Self::Global(template.replace('&', selector), file.clone())
+ }
+ Some(Self::At {
+ kind,
+ query,
+ selector,
+ outer,
+ file,
+ }) => Self::At {
+ kind: *kind,
+ query: query.clone(),
+ selector: Some(
+ selector
+ .as_deref()
+ .map_or_else(|| template.to_string(), |s| template.replace('&', s)),
+ ),
+ outer: outer.clone(),
+ file: file.clone(),
+ },
+ }
+ }
+
+ /// Wrap `parent` (the bare class when `None`) in an at-rule. Nested
+ /// `@media` rules fold into one query where possible; `None` means the
+ /// combined condition can never match, so the styles must be dropped.
+ #[must_use]
+ pub fn nest_at_rule(parent: Option<&Self>, kind: AtRuleKind, query: &str) -> Option {
+ let query = normalize_query(query);
+ let (selector, outer, file) = match parent {
+ None => (None, vec![], None),
+ Some(Self::Selector(selector)) => (Some(selector.clone()), vec![], None),
+ Some(Self::Global(selector, file)) => {
+ (Some(selector.clone()), vec![], Some(file.clone()))
+ }
+ Some(Self::At {
+ kind: parent_kind,
+ query: parent_query,
+ selector,
+ outer,
+ file,
+ }) => {
+ if *parent_kind == AtRuleKind::Media && kind == AtRuleKind::Media {
+ match combine_media_queries(parent_query, &query) {
+ MediaCombination::Merged(query) => {
+ return Some(Self::At {
+ kind,
+ query,
+ selector: selector.clone(),
+ outer: outer.clone(),
+ file: file.clone(),
+ });
+ }
+ MediaCombination::Never => return None,
+ MediaCombination::Nest => {}
+ }
+ }
+ let mut outer = outer.clone();
+ outer.push(AtRule {
+ kind: *parent_kind,
+ query: parent_query.clone(),
+ });
+ (selector.clone(), outer, file.clone())
+ }
+ };
+ Some(Self::At {
+ kind,
+ query,
+ selector,
+ outer,
+ file,
+ })
+ }
}
/// Rank a `Global` selector's pseudo suffix by scanning `SELECTOR_ORDER` once.
@@ -355,6 +456,7 @@ mod tests {
#[case("group-focus-visible", StyleSelector::Selector(":is([role=group],[data-group]):focus-visible &".to_string()))]
#[case("group-1", StyleSelector::Selector(":is([role=group],[data-group]):1 &".to_string()))]
#[case(["theme-dark", "placeholder"], StyleSelector::Selector(":root[data-theme=dark] &::placeholder".to_string()))]
+ #[case(["theme-dark", "&:hover"], StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string()))]
#[case("theme-light", StyleSelector::Selector(":root[data-theme=light] &".to_string()))]
#[case("*[aria=disabled='true'] &:hover", StyleSelector::Selector("*[aria=disabled='true'] &:hover".to_string()))]
fn test_style_selector(
@@ -369,28 +471,28 @@ mod tests {
#[case(StyleSelector::At {
kind: AtRuleKind::Media,
query: "screen and (max-width: 600px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@media screen and (max-width: 600px)"
)]
#[case(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display: grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@supports(display: grid)"
)]
#[case(StyleSelector::At {
kind: AtRuleKind::Container,
query: "(min-width: 768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@container(min-width: 768px)"
)]
#[case(StyleSelector::At {
kind: AtRuleKind::Container,
query: "sidebar (min-width: 400px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@container sidebar (min-width: 400px)"
)]
@@ -398,7 +500,7 @@ mod tests {
#[case(StyleSelector::At {
kind: AtRuleKind::Layer,
query: "reset".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
"@layer reset"
)]
@@ -412,7 +514,7 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "screen".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
StyleSelector::Selector("&:hover".to_string()),
std::cmp::Ordering::Greater
@@ -426,12 +528,12 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "a".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
StyleSelector::At {
kind: AtRuleKind::Media,
query: "b".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
std::cmp::Ordering::Less
)]
@@ -439,12 +541,12 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width: 768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display: grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
std::cmp::Ordering::Less
)]
@@ -463,7 +565,7 @@ mod tests {
StyleSelector::At {
kind: AtRuleKind::Media,
query: "screen".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
},
std::cmp::Ordering::Less
)]
@@ -631,6 +733,180 @@ mod tests {
);
}
+ fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector {
+ StyleSelector::At {
+ kind,
+ query: query.to_string(),
+ selector: selector.map(str::to_string),
+ outer: vec![],
+ file: None,
+ }
+ }
+
+ #[rstest]
+ #[case(None, "&:hover", StyleSelector::Selector("&:hover".to_string()))]
+ #[case(
+ Some(StyleSelector::Selector("&:hover".to_string())),
+ ":root[data-theme=dark] &",
+ StyleSelector::Selector(":root[data-theme=dark] &:hover".to_string())
+ )]
+ #[case(
+ Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())),
+ "&:hover",
+ StyleSelector::Global("body:hover".to_string(), "a.tsx".to_string())
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", None)),
+ "&:hover",
+ at(AtRuleKind::Media, "print", Some("&:hover"))
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", Some("&:focus"))),
+ "&:hover",
+ at(AtRuleKind::Media, "print", Some("&:focus:hover"))
+ )]
+ fn test_nest_selector(
+ #[case] parent: Option,
+ #[case] template: &str,
+ #[case] expected: StyleSelector,
+ ) {
+ assert_eq!(
+ StyleSelector::nest_selector(parent.as_ref(), template),
+ expected
+ );
+ }
+
+ #[rstest]
+ #[case(
+ None,
+ AtRuleKind::Media,
+ "(prefers-reduced-motion: reduce)",
+ Some(at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None))
+ )]
+ #[case(
+ Some(StyleSelector::Selector("&:hover".to_string())),
+ AtRuleKind::Media,
+ "print",
+ Some(at(AtRuleKind::Media, "print", Some("&:hover")))
+ )]
+ #[case(
+ Some(StyleSelector::Global("body".to_string(), "a.tsx".to_string())),
+ AtRuleKind::Media,
+ "print",
+ Some(StyleSelector::At {
+ kind: AtRuleKind::Media,
+ query: "print".to_string(),
+ selector: Some("body".to_string()),
+ outer: vec![],
+ file: Some("a.tsx".to_string()),
+ })
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", Some("&:hover"))),
+ AtRuleKind::Media,
+ "(prefers-reduced-motion:reduce)",
+ Some(at(
+ AtRuleKind::Media,
+ "print and (prefers-reduced-motion:reduce)",
+ Some("&:hover")
+ ))
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", None)),
+ AtRuleKind::Media,
+ "screen",
+ None
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "print", None)),
+ AtRuleKind::Supports,
+ "(display: grid)",
+ Some(StyleSelector::At {
+ kind: AtRuleKind::Supports,
+ query: "(display:grid)".to_string(),
+ selector: None,
+ outer: vec![AtRule { kind: AtRuleKind::Media, query: "print".to_string() }],
+ file: None,
+ })
+ )]
+ #[case(
+ Some(at(AtRuleKind::Media, "not print and (color)", None)),
+ AtRuleKind::Media,
+ "(hover:none)",
+ Some(StyleSelector::At {
+ kind: AtRuleKind::Media,
+ query: "(hover:none)".to_string(),
+ selector: None,
+ outer: vec![AtRule { kind: AtRuleKind::Media, query: "not print and (color)".to_string() }],
+ file: None,
+ })
+ )]
+ fn test_nest_at_rule(
+ #[case] parent: Option,
+ #[case] kind: AtRuleKind,
+ #[case] query: &str,
+ #[case] expected: Option,
+ ) {
+ assert_eq!(
+ StyleSelector::nest_at_rule(parent.as_ref(), kind, query),
+ expected
+ );
+ }
+
+ #[test]
+ fn test_nested_at_rule_display_and_order() {
+ let nested = StyleSelector::At {
+ kind: AtRuleKind::Supports,
+ query: "(display:grid)".to_string(),
+ selector: Some("&:hover".to_string()),
+ outer: vec![AtRule {
+ kind: AtRuleKind::Media,
+ query: "print".to_string(),
+ }],
+ file: None,
+ };
+ assert_eq!(
+ nested.to_string(),
+ "@media print @supports(display:grid) &:hover"
+ );
+ assert_eq!(
+ nested.as_class_str(),
+ "@media print @supports(display:grid) &:hover"
+ );
+
+ let plain = at(AtRuleKind::Supports, "(display:grid)", Some("&:hover"));
+ assert_eq!(plain.cmp(&nested), std::cmp::Ordering::Less);
+ let global = StyleSelector::At {
+ kind: AtRuleKind::Supports,
+ query: "(display:grid)".to_string(),
+ selector: Some("&:hover".to_string()),
+ outer: vec![],
+ file: Some("a.tsx".to_string()),
+ };
+ assert_eq!(plain.cmp(&global), std::cmp::Ordering::Less);
+ assert_eq!(
+ optimize_selector(global.clone()),
+ global,
+ "optimizing keeps the enclosing rules and file"
+ );
+ }
+
+ #[test]
+ fn test_media_shorthand_selector() {
+ assert_eq!(
+ StyleSelector::from("motion-reduce"),
+ at(AtRuleKind::Media, "(prefers-reduced-motion:reduce)", None)
+ );
+ assert_eq!(
+ StyleSelector::from("print"),
+ at(AtRuleKind::Media, "print", None)
+ );
+ assert_eq!(
+ StyleSelector::from("speech"),
+ StyleSelector::Selector("&:speech".to_string())
+ );
+ }
+
#[rstest]
#[case(AtRuleKind::Media, "media")]
#[case(AtRuleKind::Supports, "supports")]
diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs
index 961e8c1f4..d98859d87 100644
--- a/libs/css/src/theme_tokens.rs
+++ b/libs/css/src/theme_tokens.rs
@@ -5,6 +5,7 @@ use std::sync::{LazyLock, RwLock};
struct ThemeTokenRegistry {
length: BTreeMap>,
shadow: BTreeMap>,
+ typography: Vec,
}
static TOKEN_REGISTRY: LazyLock> =
@@ -20,6 +21,21 @@ pub fn set_theme_token_levels(
}
}
+pub fn set_typography_keys(keys: Vec) {
+ if let Ok(mut registry) = TOKEN_REGISTRY.write() {
+ registry.typography = keys;
+ }
+}
+
+/// Typography names defined by the registered theme, so a dynamic
+/// `typography` value under a selector can resolve to one class per name.
+pub fn get_typography_keys() -> Vec {
+ TOKEN_REGISTRY
+ .read()
+ .map(|registry| registry.typography.clone())
+ .unwrap_or_default()
+}
+
/// Look up a `$token` in the length and shadow registries.
/// Returns the responsive breakpoint levels if the token is defined
/// with more than one level, regardless of which CSS property it's used on.
@@ -94,6 +110,14 @@ mod tests {
assert!(!is_responsive_theme_token("noprefix"));
}
+ #[test]
+ fn test_typography_keys() {
+ set_typography_keys(vec!["body".to_string(), "title".to_string()]);
+ assert_eq!(get_typography_keys(), vec!["body", "title"]);
+ set_typography_keys(vec![]);
+ assert!(get_typography_keys().is_empty());
+ }
+
#[test]
fn test_is_responsive_theme_token_without_prefix() {
let value = std::hint::black_box("noprefix");
diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs
index be4b5e863..b594560af 100644
--- a/libs/extractor/src/css_utils.rs
+++ b/libs/extractor/src/css_utils.rs
@@ -4,9 +4,10 @@ use std::fmt::Write as _;
use crate::utils::{get_str_by_property_key, get_string_by_literal_expression, wrap_direct_call};
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::{AtRuleKind, StyleSelector},
+ style_selector::StyleSelector,
};
use oxc_allocator::Allocator;
use oxc_span::SPAN;
@@ -421,238 +422,13 @@ pub fn css_to_style_literal(
styles
}
-const AT_RULES: [(&str, AtRuleKind); 3] = [
- ("@media", AtRuleKind::Media),
- ("@supports", AtRuleKind::Supports),
- ("@container", AtRuleKind::Container),
-];
-
pub fn css_to_style(
css: &str,
level: u8,
selector: &Option,
) -> Vec {
let mut styles = vec![];
- let mut input = css;
-
- // Split by at-rules (@media, @supports, @container) to handle multiple at-rules in a single input.
- // Every at-rule prefix begins with `@`, so a single `@` byte scan is a sound necessary-condition
- // guard: if the input has no `@` at all (the overwhelmingly common declaration block), none of the
- // three prefixes can match, so skip all three `input.contains(at_rule)` substring scans entirely.
- if input.contains('@') {
- // Classify which of the three at-rule prefixes are present in ONE pass over the
- // `@`-anchored positions, instead of re-scanning the whole input with a separate
- // `input.contains(at_rule)` per prefix inside the loop. For an `@`-bearing block
- // carrying just one kind (the common single-`@media`/`@supports`/`@container` case),
- // the two absent kinds previously each paid a full substring scan; now every
- // `@`-run is examined once here and the loop reads a precomputed `bool`. The
- // presence check keys off the byte right after `@` (`m`/`s`/`c`), matching the
- // prefixes' first distinguishing letter, so it is byte-identical to `contains`.
- let mut present = [false; AT_RULES.len()];
- for (pos, _) in input.match_indices('@') {
- let rest = &input[pos..];
- for (i, (at_rule, _)) in AT_RULES.iter().enumerate() {
- if !present[i] && rest.starts_with(at_rule) {
- present[i] = true;
- }
- }
- }
- for (idx, (at_rule, _)) in AT_RULES.iter().enumerate() {
- // Only the multi-segment case recurses. Walk the non-empty trimmed `@rule` segments with
- // a single `split` pass (dropping the earlier separate `count` scan + identical `collect`
- // scan) via a peekable iterator: confirm a *second* non-empty segment before allocating,
- // so the common single-`@media`/`@supports`/`@container` block (already dispatched by an
- // outer recursion level) still skips materializing a throwaway `Vec`.
- // Absent prefixes are skipped via the precomputed `present` flags (no re-scan).
- if !present[idx] {
- continue;
- }
- let mut segments = input.split(at_rule).filter_map(|s| {
- let s = s.trim();
- (!s.is_empty()).then_some(s)
- });
- if let Some(first) = segments.next()
- && let Some(second) = segments.next()
- {
- // Re-attach the known `at_rule` prefix to each segment with a presized
- // `String` + two `push_str` instead of `format!`, which pulls in the
- // `Arguments` formatting machinery and its grow path. Both lengths are
- // known up front, so a single exact allocation suffices. Byte-identical
- // to `format!("{at_rule}{seg}")`.
- let join_at = |seg: &str| {
- let mut s = String::with_capacity(at_rule.len() + seg.len());
- s.push_str(at_rule);
- s.push_str(seg);
- s
- };
- styles.extend(css_to_style(&join_at(first), level, selector));
- styles.extend(css_to_style(&join_at(second), level, selector));
- for rest in segments {
- styles.extend(css_to_style(&join_at(rest), level, selector));
- }
- return styles;
- }
- }
- }
-
- if input.contains('{') {
- while let Some(start) = input.find('{') {
- // Check if there are properties before the selector
- let before_brace = &input[..start].trim();
-
- // The overwhelmingly common case has no `;`-separated plain props before the
- // selector (e.g. `&:hover { ... }`), which maps to the single-part `else`
- // branch below. Only build the `Vec<&str>` split when a `;` is actually present.
- let (plain_props, selector_part): (&str, &str) = if before_brace.contains(';') {
- // Split by semicolon to find the last part which should be the selector
- let parts: Vec<&str> = before_brace.split(';').map(str::trim).collect();
-
- // Find the selector part (the last part that doesn't contain ':')
- // or if all parts contain ':', then the last part is the selector
- // `before_brace` contains `;`, so `split` always yields at least two parts.
- // Check if any part doesn't contain ':' (which would be a selector).
- let mut selector_idx = parts.len();
- for (i, part) in parts.iter().enumerate().rev() {
- if !part.contains(':') || part.starts_with('&') || part.starts_with('@') {
- selector_idx = i;
- break;
- }
- }
-
- // Borrow the props/selector partition directly from `before_brace`
- // instead of allocating two `join(";")` Strings. The split boundary is the
- // byte offset of the `selector_idx`-th `;` (parts are `;`-separated), so
- // `[..boundary]` is the props run and `[boundary + 1..]` the selector run.
- // `css_to_style_block` re-splits/re-trims each `;` part on the props side and
- // the selector side is `.trim()`ed downstream, so the parsed output stays
- // byte-identical to the previous `join(";")` form.
- // `selector_idx` is 0 when the very first `;`-part is the selector
- // (e.g. `foo;color:red { .. }`), leaving no props run before it.
- // `checked_sub` yields `None` there, which the match below already
- // treats as "the whole run is the selector" - the same result release
- // builds reached by wrapping, without the debug-build overflow panic.
- let boundary = selector_idx
- .checked_sub(1)
- .and_then(|nth| before_brace.match_indices(';').nth(nth).map(|(idx, _)| idx));
- match boundary {
- Some(b) => (before_brace[..b].trim(), before_brace[b + 1..].trim()),
- // No selector boundary: whole `before_brace` is the selector.
- None => ("", before_brace),
- }
- } else {
- ("", before_brace)
- };
-
- // Process plain properties if any
- if !plain_props.is_empty() {
- styles.extend(css_to_style_block(plain_props, level, selector));
- }
-
- let rest = &input[start + 1..];
-
- // Find the matching closing brace by counting braces
- let mut brace_count = 1;
- let mut end = 0;
- for (i, byte) in rest.bytes().enumerate() {
- match byte {
- b'{' => brace_count += 1,
- b'}' => {
- brace_count -= 1;
- if brace_count == 0 {
- end = i;
- break;
- }
- }
- _ => {}
- }
- }
-
- // If we didn't find a matching brace, use the first '}' as fallback
- if brace_count > 0 {
- end = rest.find('}').unwrap_or(rest.len());
- }
- let block = &rest[..end];
- let sel = &if let Some(StyleSelector::At { kind, query, .. }) = selector {
- let local_sel = selector_part.trim().to_string();
- Some(StyleSelector::At {
- kind: *kind,
- query: query.clone(),
- selector: if local_sel == "&" {
- None
- } else {
- Some(local_sel)
- },
- })
- } else {
- let sel = selector_part.trim().to_string();
- if let Some((prefix, kind)) =
- AT_RULES.iter().find(|(prefix, _)| sel.starts_with(prefix))
- {
- // The prefix contains neither spaces nor "and(", so slicing it off
- // first is equivalent to slicing after the replaces. Strip spaces into
- // one pre-sized buffer, then a single "and(" -> "and (" normalization.
- let rest = &sel[prefix.len()..];
- let mut query = String::with_capacity(rest.len());
- for ch in rest.chars() {
- if ch != ' ' {
- query.push(ch);
- }
- }
- // `and(` only appears in multi-condition media queries. Skip the
- // second-buffer allocation of `replace` on the common
- // single-condition path where the normalization is a no-op.
- let query = if query.contains("and(") {
- query.replace("and(", "and (")
- } else {
- query
- };
- Some(StyleSelector::At {
- kind: *kind,
- query,
- selector: None,
- })
- } else if sel.is_empty() {
- selector.clone()
- } else {
- Some(StyleSelector::Selector(sel))
- }
- };
- let block = if block.contains('{') {
- css_to_style(block, level, sel)
- } else {
- css_to_style_block(block, level, sel)
- };
-
- // Find the matching closing brace
- let closing_brace_pos = start + 1 + end;
-
- // Process the block
- styles.extend(block);
-
- // Update input to continue processing after the closing brace
- // Check if there's more content after the closing brace
- if closing_brace_pos + 1 < input.len() {
- let remaining = &input[closing_brace_pos + 1..].trim();
- if remaining.is_empty() {
- break;
- }
- // If there's remaining text after the closing brace, process it
- // This handles cases like "} color: blue;"
- if remaining.contains('{') {
- // If it contains '{', continue the loop
- input = remaining;
- } else {
- // If it doesn't contain '{', process it as a block and break
- styles.extend(css_to_style_block(remaining, level, selector));
- break;
- }
- } else {
- break;
- }
- }
- } else {
- styles.extend(css_to_style_block(input, level, selector));
- }
+ collect_css_block(&rm_css_comment(css), level, selector, &mut styles);
// A single declaration (or none) is trivially ordered, so skip the comparison
// sort's setup entirely for the very common single-property case. The multi-source
@@ -664,6 +440,70 @@ pub fn css_to_style(
styles
}
+/// Walk one level of CSS text: declarations apply to `selector`, and every
+/// nested `prelude { … }` block recurses with its prelude composed onto it, so
+/// `&:hover { @media print { … } }` keeps both the hover and the media query.
+fn collect_css_block(
+ css: &str,
+ level: u8,
+ selector: &Option,
+ styles: &mut Vec,
+) {
+ let mut rest = css;
+ while let Some(open) = rest.find('{') {
+ let head = &rest[..open];
+ let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head));
+ styles.extend(css_to_style_block(declarations, level, selector));
+
+ let body_start = open + 1;
+ let mut depth = 1usize;
+ let body_end = rest[body_start..]
+ .char_indices()
+ .find_map(|(index, c)| {
+ match c {
+ '{' => depth += 1,
+ '}' => depth -= 1,
+ _ => {}
+ }
+ (depth == 0).then_some(body_start + index)
+ })
+ .unwrap_or(rest.len());
+ let body = &rest[body_start..body_end];
+ let prelude = prelude.trim();
+ if prelude.is_empty() || prelude == "&" {
+ collect_css_block(body, level, selector, styles);
+ } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) {
+ collect_css_block(body, level, &Some(nested), styles);
+ }
+ rest = rest.get(body_end + 1..).unwrap_or_default();
+ }
+ styles.extend(css_to_style_block(rest, level, selector));
+}
+
+/// 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).
+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}"))
+ };
+ Some(StyleSelector::nest_selector(parent, &template))
+}
+
/// 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.
@@ -868,6 +708,7 @@ fn remove_semicolon_before_closing_brace(value: &mut String) {
mod tests {
use super::*;
+ use css::style_selector::AtRuleKind;
use oxc_allocator::Allocator;
use oxc_ast::ast::{Expression, Statement};
use oxc_parser::Parser;
@@ -942,12 +783,12 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -965,22 +806,22 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -999,22 +840,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1033,22 +874,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1077,42 +918,42 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1131,22 +972,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1169,12 +1010,12 @@ mod tests {
("display", "grid", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display:grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("grid-template-columns", "1fr 1fr", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display:grid)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1188,7 +1029,7 @@ mod tests {
("display", "flex", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
query: "(display:flex)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1199,8 +1040,8 @@ mod tests {
vec![
("display", "block", Some(StyleSelector::At {
kind: AtRuleKind::Supports,
- query: "not(display:grid)".to_string(),
- selector: None,
+ query: "not (display:grid)".to_string(),
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1213,7 +1054,7 @@ mod tests {
("padding", "10px", Some(StyleSelector::At {
kind: AtRuleKind::Container,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1224,8 +1065,8 @@ mod tests {
vec![
("display", "flex", Some(StyleSelector::At {
kind: AtRuleKind::Container,
- query: "sidebar(min-width:400px)".to_string(),
- selector: None,
+ query: "sidebar (min-width:400px)".to_string(),
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1451,12 +1292,12 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1474,22 +1315,22 @@ mod tests {
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)and (max-width:1024px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1508,22 +1349,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover,&:active,&:nth-child(2)".to_string()),
+ selector: Some("&:hover,&:active,&:nth-child(2)".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1542,22 +1383,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1586,42 +1427,42 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: Some("&:hover".to_string()),
+ selector: Some("&:hover".to_string()), outer: vec![], file: None,
})),
]
)]
@@ -1640,22 +1481,22 @@ mod tests {
("border", "1px solid #FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#FFF", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(min-width:768px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("border", "1px solid #000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
("color", "#000", Some(StyleSelector::At {
kind: AtRuleKind::Media,
query: "(max-width:768px)and (min-width:480px)".to_string(),
- selector: None,
+ selector: None, outer: vec![], file: None,
})),
]
)]
@@ -1681,29 +1522,30 @@ mod tests {
("background", "blue", Some(StyleSelector::Selector("div".to_string()))),
]
)]
+ // As in CSS nesting, only the text after the last `;` is the nested rule's
+ // selector; everything before it is declarations.
#[case(
"color:red;background:blue { width: 1px; }",
- vec![(
- "width",
- "1px",
- Some(StyleSelector::Selector(
- "color:red;background:blue".to_string()
- ))
- )]
+ vec![
+ ("color", "red", None),
+ ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))),
+ ]
)]
- // The selector is the very first `;`-part, so there is no props run before it.
#[case(
"foo;color:red { width: 1px; }",
vec![(
"width",
"1px",
- Some(StyleSelector::Selector("foo;color:red".to_string()))
+ Some(StyleSelector::Selector("color:red".to_string()))
)]
)]
#[case(
"color: red;;invalid;display: block;",
vec![("color", "red", None), ("display", "block", None)]
)]
+ // Blocks that can never apply produce nothing: unknown at-rules and
+ // mutually exclusive media types.
+ #[case("@layer base { color: red; } @media print { @media screen { color: red; } }", vec![])]
fn test_css_to_style(
#[case] input: &str,
#[case] expected: Vec<(&str, &str, Option)>,
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 15ba1c04f..fd8a70208 100644
--- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs
@@ -9,7 +9,9 @@ use crate::{
},
extractor::{
GlobalExtractResult,
- extract_style_from_expression::{LiteralHandling, extract_style_from_expression},
+ extract_style_from_expression::{
+ LiteralHandling, at_rule_record_kind, extract_style_from_expression,
+ },
},
utils::{
get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key,
@@ -17,9 +19,10 @@ use crate::{
},
};
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::StyleSelector,
+ style_selector::{AtRule, AtRuleKind, StyleSelector},
};
use oxc_ast::{
ast::{ArrayExpressionElement, Expression, ObjectPropertyKind},
@@ -32,6 +35,35 @@ pub fn extract_global_style_from_expression<'a>(
file: &str,
) -> GlobalExtractResult<'a> {
let mut styles = vec![];
+ collect_global_styles(ast_builder, expression, file, &[], &mut styles);
+ GlobalExtractResult {
+ styles,
+ style_order: None,
+ }
+}
+
+/// A top-level at-rule key wrapping a selector map: `'@media print'` or a
+/// media shorthand such as `_print` / `_motionReduce`.
+fn global_at_rule_key(name: &str) -> Option {
+ let (kind, query) = split_at_rule_key(name).or_else(|| {
+ let query = media_shorthand_query(name.strip_prefix('_')?)?;
+ Some((AtRuleKind::Media, query))
+ })?;
+ Some(AtRule {
+ kind,
+ query: query.to_string(),
+ })
+}
+
+/// Collect a `globalCss` selector map whose rules sit inside `at_rules`
+/// (outermost first).
+fn collect_global_styles<'a>(
+ ast_builder: &AstBuilder<'a>,
+ expression: &mut Expression<'a>,
+ file: &str,
+ at_rules: &[AtRule],
+ styles: &mut Vec>,
+) {
let expression = unwrap_syntax_only_mut(expression);
if let Expression::ObjectExpression(obj) = expression {
@@ -39,7 +71,29 @@ pub fn extract_global_style_from_expression<'a>(
match p {
ObjectPropertyKind::ObjectProperty(o) => {
if let Some(name) = get_string_by_property_key(&o.key) {
- if name == "imports" {
+ if let Some(kind) = at_rule_record_kind(&name)
+ && let Expression::ObjectExpression(record) = &mut o.value
+ {
+ for entry in &mut record.properties {
+ if let ObjectPropertyKind::ObjectProperty(entry) = entry
+ && let Some(query) = get_string_by_property_key(&entry.key)
+ {
+ let mut nested = at_rules.to_vec();
+ nested.push(AtRule { kind, query });
+ collect_global_styles(
+ ast_builder,
+ &mut entry.value,
+ file,
+ &nested,
+ styles,
+ );
+ }
+ }
+ } else if let Some(at_rule) = global_at_rule_key(&name) {
+ let mut nested = at_rules.to_vec();
+ nested.push(at_rule);
+ collect_global_styles(ast_builder, &mut o.value, file, &nested, styles);
+ } else if name == "imports" {
if let Expression::ArrayExpression(arr) = &o.value {
for p in &arr.elements {
// `...spread` elements carry no statically readable url.
@@ -169,21 +223,30 @@ pub fn extract_global_style_from_expression<'a>(
None
};
- let extracted = extract_style_from_expression(
- ast_builder,
- None,
- &mut o.value,
- 0,
- &Some(StyleSelector::Global(
- if let Some(name) = name.strip_prefix("_") {
- StyleSelector::from(name).to_string().replace('&', "*")
- } else {
- name
- },
- file.to_string(),
- )),
- LiteralHandling::ExpandResponsiveThemeToken,
+ let global = StyleSelector::Global(
+ if let Some(name) = name.strip_prefix("_") {
+ StyleSelector::from(name).to_string().replace('&', "*")
+ } else {
+ name
+ },
+ file.to_string(),
);
+ // `None` when the enclosing at-rules can never match together.
+ let selector = at_rules.iter().try_fold(global, |selector, rule| {
+ StyleSelector::nest_at_rule(Some(&selector), rule.kind, &rule.query)
+ });
+ let extracted = selector
+ .map(|selector| {
+ extract_style_from_expression(
+ ast_builder,
+ None,
+ &mut o.value,
+ 0,
+ &Some(selector),
+ LiteralHandling::ExpandResponsiveThemeToken,
+ )
+ })
+ .unwrap_or_default();
// Filter out @layer property from styles and set layer on remaining styles
for mut style in extracted.styles {
@@ -206,20 +269,15 @@ pub fn extract_global_style_from_expression<'a>(
}
}
ObjectPropertyKind::SpreadProperty(o) => {
- styles.extend(
- extract_global_style_from_expression(
- ast_builder,
- o.argument.get_inner_expression_mut(),
- file,
- )
- .styles,
+ collect_global_styles(
+ ast_builder,
+ o.argument.get_inner_expression_mut(),
+ file,
+ at_rules,
+ styles,
);
}
}
}
}
- GlobalExtractResult {
- styles,
- style_order: None,
- }
}
diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs
index cc4a9311b..2d98df84d 100644
--- a/libs/extractor/src/extractor/extract_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_style_from_expression.rs
@@ -16,11 +16,12 @@ use crate::{
},
};
use css::{
- add_selector_params, disassemble_property, get_enum_property_map, get_enum_property_value,
- is_enum_property,
+ add_selector_params,
+ 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::StyleSelector,
- theme_tokens::{get_responsive_theme_token, is_responsive_theme_token},
+ style_selector::{AtRuleKind, StyleSelector, optimize_selector},
+ theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token},
utils::to_kebab_case,
};
use oxc_allocator::{CloneIn, GetAllocator};
@@ -220,6 +221,31 @@ pub fn extract_style_from_expression<'a>(
};
}
+ if let Some(name) = name
+ && is_nested_selector_key(name)
+ {
+ 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,
+ );
+ }
+ }
+ return ExtractResult {
+ styles,
+ ..ExtractResult::default()
+ };
+ }
+
if let Some(name) = name
// First-byte dispatch: only names beginning with `a` (`as`), `s`
// (`selectors`), `@`/`_` (at-rule + `_`-selector prefixes) or `t`
@@ -242,76 +268,23 @@ pub fn extract_style_from_expression<'a>(
{
let mut props = vec![];
for p in &mut obj.properties {
- if let ObjectPropertyKind::ObjectProperty(o) = p {
- let Some(key_name) = o.key.name() else {
- continue;
- };
- let name = key_name.trim();
- let mut part_of_selector = vec![];
-
- let mut level = 0;
- let mut last_idx = 0;
- for (idx, c) in name.char_indices() {
- if c == '(' {
- level += 1;
- }
- if c == ')' {
- level -= 1;
- }
- if c == ',' && level == 0 {
- part_of_selector.push(name[last_idx..idx].trim());
- last_idx = idx + 1;
- }
- }
- if !name.is_empty() {
- part_of_selector.push(name[last_idx..].trim());
- }
-
- // The parent selector's owned string is invariant across every
- // comma-part, so materialize it ONCE here instead of calling
- // `selector.to_string()` up to 3× per part inside the closure.
- let parent_sel = selector.as_ref().map(ToString::to_string);
- for sel in part_of_selector.iter().map(|name| {
- if let Some(parent_sel) = parent_sel.as_deref() {
- if name.starts_with('_') {
- if name.starts_with("_theme") {
- StyleSelector::from([
- to_kebab_case(name.strip_prefix("_").unwrap_or(name))
- .as_ref(),
- parent_sel,
- ])
- .to_string()
- } else {
- StyleSelector::from([
- parent_sel,
- to_kebab_case(name.strip_prefix("_").unwrap_or(name))
- .as_ref(),
- ])
- .to_string()
- }
- } else {
- name.replace('&', parent_sel)
- }
- } else if name.starts_with('_') {
- StyleSelector::from(
- to_kebab_case(name.strip_prefix("_").unwrap_or(name)).as_ref(),
- )
- .to_string()
- } else {
- StyleSelector::from(name.strip_prefix("_").unwrap_or(name)).to_string()
+ if let ObjectPropertyKind::ObjectProperty(o) = p
+ && let Some(key_name) = o.key.name()
+ {
+ 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_style_from_expression(
- ast_builder,
- None,
- &mut o.value,
- level,
- &Some(StyleSelector::Selector(sel)),
- literal_handling,
- )
- .styles,
- );
}
}
}
@@ -321,31 +294,16 @@ pub fn extract_style_from_expression<'a>(
};
}
- // Handle at-rules: @media, @supports, @container (or _media, _supports, _container)
- let at_rule_name = name
- .strip_prefix("@")
- .or_else(|| name.strip_prefix("_"))
- .filter(|n| matches!(*n, "media" | "supports" | "container"));
-
- if let Some(at_rule) = at_rule_name
+ if let Some(kind) = at_rule_record_kind(name)
&& let Expression::ObjectExpression(obj) = expression
{
let mut props = vec![];
- // The parent selector renders identically for every query in this
- // at-rule object: format it once and clone the resulting `String`
- // per iteration instead of re-running `Display` formatting. `query`
- // is moved into the selector (it was previously cloned even though
- // the original was dropped immediately after).
- let parent = selector.as_ref().map(ToString::to_string);
for p in &mut obj.properties {
if let ObjectPropertyKind::ObjectProperty(o) = p
&& let Some(query) = get_string_by_property_key(&o.key)
+ && let Some(at_selector) =
+ StyleSelector::nest_at_rule(selector.as_ref(), kind, &query)
{
- let at_selector = StyleSelector::At {
- kind: at_rule.into(),
- query,
- selector: parent.clone(),
- };
props.extend(
extract_style_from_expression(
ast_builder,
@@ -365,19 +323,23 @@ pub fn extract_style_from_expression<'a>(
};
}
- if let Some(new_selector) = name.strip_prefix("_") {
- return extract_style_from_expression(
- ast_builder,
- None,
- expression,
- level,
- &Some(if let Some(selector) = selector {
- (selector, new_selector).into()
- } else {
- new_selector.into()
- }),
- literal_handling,
- );
+ 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(
+ ast_builder,
+ None,
+ expression,
+ level,
+ &Some(nested),
+ literal_handling,
+ )
+ });
}
typo = name == "typography";
}
@@ -385,9 +347,7 @@ pub fn extract_style_from_expression<'a>(
if let Some(name) = name {
ExtractResult {
styles: if typo {
- vec![ExtractStyleProp::Static(ExtractStyleValue::Typography(
- value.into_owned(),
- ))]
+ vec![typography_style(value.into_owned(), level, selector)]
} else if matches!(
literal_handling,
LiteralHandling::ExpandResponsiveThemeToken
@@ -508,7 +468,12 @@ pub fn extract_style_from_expression<'a>(
// typo branch is safe. The non-typo branch must handle the
// `name.is_none()` case (pseudo-selector recursion) by
// returning empty styles.
- styles: if typo {
+ styles: if typo
+ && let Some(style) =
+ conditional_typography(ast_builder, expression, level, selector)
+ {
+ vec![style]
+ } else if typo {
vec![ExtractStyleProp::Expression {
expression: Expression::new_template_literal(
SPAN,
@@ -566,6 +531,18 @@ pub fn extract_style_from_expression<'a>(
// like any other non-extracted style prop.
if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) {
ExtractResult::default()
+ } else if typo
+ && let Some(style) = conditional_typography(
+ ast_builder,
+ &Expression::new_identifier(SPAN, identifier.name.as_str(), ast_builder),
+ level,
+ selector,
+ )
+ {
+ ExtractResult {
+ styles: vec![style],
+ ..ExtractResult::default()
+ }
} else if let Some(name) = name {
if typo {
ExtractResult {
@@ -841,6 +818,120 @@ pub fn extract_style_from_expression<'a>(
}
}
+pub(crate) fn at_rule_record_kind(name: &str) -> Option {
+ match name.strip_prefix('@').or_else(|| name.strip_prefix('_'))? {
+ "media" => Some(AtRuleKind::Media),
+ "supports" => Some(AtRuleKind::Supports),
+ "container" => Some(AtRuleKind::Container),
+ _ => None,
+ }
+}
+
+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;
+ }
+ _ => {}
+ }
+ }
+ if !key.is_empty() {
+ parts.push(key[start..].trim());
+ }
+ 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 {
+ let name = to_kebab_case(name);
+ if let Some(query) = media_shorthand_query(&name) {
+ return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query);
+ }
+ Some(StyleSelector::nest_selector(
+ parent,
+ &StyleSelector::from(name.as_ref()).to_string(),
+ ))
+}
+
+/// Emotion/styled-components style object keys that are nested selectors
+/// (`'&:hover'`, `':hover'`, `'.parent &'`) rather than CSS properties.
+fn is_nested_selector_key(key: &str) -> bool {
+ key.starts_with(':') || key.contains('&')
+}
+
+fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option {
+ if let Some((kind, query)) = split_at_rule_key(key) {
+ StyleSelector::nest_at_rule(parent, kind, query)
+ } 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 {
+ Some(StyleSelector::from(key))
+ }
+}
+
+fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle {
+ ExtractStaticStyle {
+ property: "typography".to_string(),
+ value: name.to_string(),
+ level,
+ selector: selector.clone().map(optimize_selector),
+ style_order: None,
+ layer: None,
+ theme_token_resolution: ThemeTokenResolution::default(),
+ }
+}
+
+/// A `typography` preset outside any selector or breakpoint uses the theme's
+/// shared `typo-*` class. Under a condition the class would apply
+/// unconditionally, so it becomes a `typography` atom the sheet expands into
+/// the preset's declarations under that condition.
+fn typography_style<'a>(
+ name: String,
+ level: u8,
+ selector: &Option,
+) -> ExtractStyleProp<'a> {
+ ExtractStyleProp::Static(if selector.is_none() && level == 0 {
+ ExtractStyleValue::Typography(name)
+ } else {
+ ExtractStyleValue::Static(typography_atom(&name, level, selector))
+ })
+}
+
+/// A dynamic `typography` value under a condition picks, at runtime, one
+/// conditional atom per typography preset registered in the theme.
+fn conditional_typography<'a>(
+ ast_builder: &AstBuilder<'a>,
+ condition: &Expression<'a>,
+ level: u8,
+ selector: &Option,
+) -> Option> {
+ if selector.is_none() && level == 0 {
+ return None;
+ }
+ let condition = condition.clone_in(ast_builder.allocator());
+ let map = get_typography_keys()
+ .into_iter()
+ .map(|key| {
+ let atom = ExtractStyleValue::Static(typography_atom(&key, level, selector));
+ (key, vec![ExtractStyleProp::Static(atom)])
+ })
+ .collect();
+ Some(ExtractStyleProp::Enum { condition, map })
+}
+
pub fn dynamic_style<'a>(
ast_builder: &AstBuilder<'a>,
name: &str,
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index 62018cfa3..9b3759ae4 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -269,37 +269,28 @@ fn extract_with_source_map(
match vanilla_extract::execute_vanilla_extract(&transformed_code, &option.package, filename)
{
Ok(collected) => {
- // Check if any styles are referenced in selectors
- let referenced = vanilla_extract::find_selector_references(&collected);
-
- if referenced.is_empty() {
- // No selector references, use simple code generation
- Some(vanilla_extract::collected_styles_to_code(
- &collected,
- &option.package,
- ))
+ // Keyframes names are generated, so extract the referenced ones
+ // first and substitute their names into the styles using them.
+ let referenced = vanilla_extract::referenced_keyframes(&collected);
+ let keyframes_names = if referenced.is_empty() {
+ FxHashMap::default()
} else {
- // Two-pass extraction: first extract referenced styles to get their class names
- let partial_code = vanilla_extract::collected_styles_to_code_partial(
- &collected,
- &option.package,
+ extract_class_map_from_code(
+ filename,
+ &vanilla_extract::keyframes_to_code(
+ &collected,
+ &option.package,
+ &referenced,
+ ),
+ &option,
&referenced,
- );
-
- // Build class map by extracting the partial code
- let class_map = if partial_code.is_empty() {
- FxHashMap::default()
- } else {
- extract_class_map_from_code(filename, &partial_code, &option, &referenced)?
- };
-
- // Generate full code with class names substituted into selectors
- Some(vanilla_extract::collected_styles_to_code_with_classes(
- &collected,
- &option.package,
- &class_map,
- ))
- }
+ )?
+ };
+ Some(vanilla_extract::collected_styles_to_code_with_keyframes(
+ &collected,
+ &option.package,
+ &keyframes_names,
+ ))
}
// Fall back to treating as regular file if execution fails
Err(_) => None,
@@ -11077,14 +11068,14 @@ const color = "red";
.unwrap()
));
- // Test _speech media query selector
+ // Test _motionReduce media query shorthand
reset_class_map();
reset_file_map();
assert_debug_snapshot!(ToBTreeSet::from(
extract(
"test.tsx",
r#"import {Box} from '@devup-ui/core'
-
+
"#,
ExtractOption {
package: "@devup-ui/core".to_string(),
diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs
index bf26a8bb6..07cd67a71 100644
--- a/libs/extractor/src/prop_modify_utils.rs
+++ b/libs/extractor/src/prop_modify_utils.rs
@@ -2,7 +2,9 @@ use crate::extract_style::ExtractStyleProperty;
use crate::extract_style::style_property::StyleProperty;
use crate::gen_class_name::gen_class_names;
use crate::gen_style::gen_styles;
-use crate::tailwind::{has_tailwind_classes, parse_single_class, parse_tailwind_to_styles};
+use crate::tailwind::{
+ TailwindClass, has_tailwind_classes, parse_single_class, parse_tailwind_to_styles,
+};
use crate::utils::{get_str_by_property_key, merge_object_expressions};
use crate::{ExtractStyleProp, ExtractStyleValue};
use oxc_allocator::{CloneIn, FromIn, GetAllocator};
@@ -432,8 +434,10 @@ fn extract_tailwind_from_class_name<'a>(
let mut tailwind_styles: Vec =
Vec::with_capacity(all_classes.bytes().filter(u8::is_ascii_whitespace).count() + 1);
for class in all_classes.split_whitespace() {
- if let Some(parsed) = parse_single_class(class) {
- let mut static_style = parsed.to_static_style();
+ if let Some(mut static_style) = parse_single_class(class)
+ .as_ref()
+ .and_then(TailwindClass::to_static_style)
+ {
if let Some(order) = style_order {
static_style.style_order = Some(order);
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap
index 64b06f5e4..d997635d3 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-2.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap
index 76e04e7ef..3c0446efc 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix-3.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: flex)",
+ query: "(display:flex)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap
index 4955f592a..50e7177f0 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_at_prefix.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap
index 6838840f4..0519ae097 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-2.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap
index c8acba680..831bd6971 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-3.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap
index 46797c5fa..5784017e3 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix-4.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "sidebar (min-width: 400px)",
+ query: "sidebar (min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap
index 24ed3c839..ebb729213 100644
--- a/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__at_rules_underscore_prefix.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap
index 83b4b00f5..b22b41278 100644
--- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css-2.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 500px)",
+ query: "(min-width:500px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap
index 039f02dde..d5654738f 100644
--- a/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__container_at_rule_in_css.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap
index aaee60699..6d92afc50 100644
--- a/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__css_container_at_rule_with_selector.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 300px)",
+ query: "(min-width:300px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap
index fbecc3169..98721d76f 100644
--- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-2.snap
@@ -16,6 +16,8 @@ ToBTreeSet {
selector: Some(
"&:active",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -34,6 +36,8 @@ ToBTreeSet {
selector: Some(
"&:hover",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap
index 36e107415..fe6259b5e 100644
--- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query-3.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap
index 2077d8c2d..ec26c961a 100644
--- a/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__extract_static_css_with_media_query.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap
index 5d0a57e40..d0c56ad6b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-2.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: Some(
".grid-container",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap
index b8b66f73a..87e38938b 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-3.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: Some(
".card",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap
index 111d8b3ac..603a5090c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules-4.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(prefers-color-scheme: dark)",
+ query: "(prefers-color-scheme:dark)",
selector: Some(
"body",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
@@ -49,10 +53,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: Some(
"body",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
@@ -69,10 +77,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: Some(
".container",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap
index 6344e3cac..22912fd62 100644
--- a/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__global_css_at_rules.snap
@@ -12,10 +12,14 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: Some(
"body",
),
+ outer: [],
+ file: Some(
+ "test.tsx",
+ ),
},
),
style_order: Some(
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap
index 55c45fd83..35ea4ba4c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-2.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "screen",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap
index 65ff6056f..6eed386ef 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-3.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "speech",
+ query: "(prefers-reduced-motion:reduce)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap
index 53fca335f..98fafeea5 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-4.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "all",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap
index d0dbe75ae..7f9d008aa 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors-5.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "screen",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -40,6 +42,8 @@ ToBTreeSet {
kind: Media,
query: "print",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap
index b344f636e..748b4a0d2 100644
--- a/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__media_query_selectors.snap
@@ -14,6 +14,8 @@ ToBTreeSet {
kind: Media,
query: "print",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -30,6 +32,8 @@ ToBTreeSet {
kind: Media,
query: "print",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap
index 3fb1e1961..bb890d2f8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__selector_refs_with_global_theme.snap
@@ -55,7 +55,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -63,5 +63,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\nexport const vars = { \"colors\": {\n\t\"primary\": \"var(--colors-primary-global_theme_0-0)\",\n\t\"secondary\": \"var(--colors-secondary-global_theme_0-1)\"\n} };\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap
index 28cdad898..f5a9bc039 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_media_query.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(max-width: 600px)",
+ query: "(max-width:600px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap
index 61381013d..fd53e2ba7 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_hover_in_media.snap
@@ -12,10 +12,12 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(hover: hover)",
+ query: "(hover:hover)",
selector: Some(
"&:hover",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap
index f03c0266e..02e2c49cd 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_nested_media_then_hover.snap
@@ -12,10 +12,12 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: Some(
"&:hover",
),
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -30,8 +32,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap
index fcfcdda02..bd72492ed 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_supports_query.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap
index 2488f50ad..3dc45fd4e 100644
--- a/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__stylex_types_in_condition.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(min-width: 768px)",
+ query: "(min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap
index 6c1e22b62..9f4b29a89 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_all_exports_combined.snap
@@ -114,5 +114,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myLayer = \"--var-0\";\nexport const myVar = \"__devup_font_0_0\";\nexport const myFont = \"__container_0__\";\nexport const vars = \"components\";\nexport const myContainer = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const box = \"b\";\n;\n;\n;\nexport const fade = \"c\";\nexport const buttons = {\n\tprimary: \"a d\",\n\tsecondary: \"e\"\n};\nexport const myVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"components\";\nexport const vars = { \"color\": { \"primary\": \"var(--color-primary-global_theme_0-0)\" } };\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap
index 070fd6a42..563c444df 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_container.snap
@@ -32,8 +32,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "__container_0__ (min-width: 400px)",
+ query: "__container_0__ (min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap
index 7c1f58e30..695a64586 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_imports_combination.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "animation",
- value: "myFont",
+ value: "a",
level: 0,
selector: None,
style_order: None,
@@ -17,7 +17,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "font-family",
- value: "__font_0__",
+ value: "__devup_font_0_0",
level: 0,
selector: Some(
Global(
@@ -84,5 +84,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"a\";\n;\n;\nexport const myFont = \"b\";\nexport const fadeIn = \"__devup_font_0_0\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const animated = \"b\";\n;\n;\nexport const fadeIn = \"a\";\nexport const myFont = \"__devup_font_0_0\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap
index 4f9210114..2cf86d4c6 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_keyframes_export.snap
@@ -7,7 +7,7 @@ ToBTreeSet {
Static(
ExtractStaticStyle {
property: "animation",
- value: "spin",
+ value: "a",
level: 0,
selector: None,
style_order: None,
@@ -67,5 +67,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"a\";\nconst internal = \"b\";\nexport const spin = \"c\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const spinner = \"b\";\nconst internal = \"c\";\nexport const spin = \"a\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap
index b90173c4b..b693d81a3 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_media_queries.snap
@@ -12,8 +12,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(prefers-color-scheme: dark)",
+ query: "(prefers-color-scheme:dark)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -28,8 +30,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "(prefers-color-scheme: dark)",
+ query: "(prefers-color-scheme:dark)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -54,8 +58,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "screen and (min-width: 768px)",
+ query: "screen and (min-width:768px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -70,8 +76,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Media,
- query: "screen and (min-width: 1024px)",
+ query: "screen and (min-width:1024px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap
index 86bf61d85..8ef25f573 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_class_replacement.snap
@@ -11,7 +11,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -29,5 +29,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap
index c591ffd98..e7f52c8e8 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_references.snap
@@ -21,7 +21,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -35,7 +35,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a + &",
+ ".f0_parent + &",
),
),
style_order: None,
@@ -43,5 +43,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\nexport const sibling = \"c\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\nexport const sibling = \"c\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap
index 833027ad5..66d773c71 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selector_refs_triggers_with_classes.snap
@@ -11,7 +11,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -81,5 +81,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b\";\nexport const parent = \"a\";\n;\n;\nexport const fade = \"c\";\nexport const myContainer = \"--var-0\";\nexport const colorVar = \"__devup_font_0_0\";\nexport const myLayer = \"__container_0__\";\nexport const myFont = \"ui\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a\";\nexport const parent = \"b\" + \" f0_parent\";\n;\n;\nexport const fade = \"c\";\nexport const colorVar = \"--var-0\";\nexport const myFont = \"__devup_font_0_0\";\nexport const myContainer = \"__container_0__\";\nexport const myLayer = \"ui\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap
index 97d273b2f..c6d642b08 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_selectors-2.snap
@@ -21,7 +21,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "a:hover &",
+ ".f0_parent:hover &",
),
),
style_order: None,
@@ -43,5 +43,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"b c\";\nexport const parent = \"a\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nexport const child = \"a b\";\nexport const parent = \"c\" + \" f0_parent\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap
index d2aa8abd1..d68c6b0da 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_supports.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Supports,
- query: "(display: grid)",
+ query: "(display:grid)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap
index 20ef3082f..096ea417c 100644
--- a/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__vanilla_extract_with_at_container_selector.snap
@@ -22,8 +22,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
@@ -38,8 +40,10 @@ ToBTreeSet {
selector: Some(
At {
kind: Container,
- query: "(min-width: 400px)",
+ query: "(min-width:400px)",
selector: None,
+ outer: [],
+ file: None,
},
),
style_order: None,
diff --git a/libs/extractor/src/stylex.rs b/libs/extractor/src/stylex.rs
index 9f3519ab0..10cddf7c3 100644
--- a/libs/extractor/src/stylex.rs
+++ b/libs/extractor/src/stylex.rs
@@ -165,20 +165,16 @@ pub fn decompose_value_conditions(
) -> Vec {
// String literal → leaf
if let Some(s) = get_string_by_literal_expression(value) {
- return vec![DecomposedStyle {
- property: css_property.to_string(),
- value: Some(s.into_owned()),
- selector: compose_selectors(parent_selectors),
- }];
+ return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors)
+ .into_iter()
+ .collect();
}
// NullLiteral → tracked but no CSS
if matches!(value, Expression::NullLiteral(_)) {
- return vec![DecomposedStyle {
- property: css_property.to_string(),
- value: None,
- selector: compose_selectors(parent_selectors),
- }];
+ return decomposed_leaf(css_property, None, parent_selectors)
+ .into_iter()
+ .collect();
}
// CallExpression: firstThatWorks() → multiple fallback values with current selectors
@@ -190,11 +186,11 @@ pub fn decompose_value_conditions(
if let Some(arg_expr) = arg.as_expression()
&& let Some(s) = get_string_by_literal_expression(arg_expr)
{
- results.push(DecomposedStyle {
- property: css_property.to_string(),
- value: Some(s.into_owned()),
- selector: compose_selectors(parent_selectors),
- });
+ results.extend(decomposed_leaf(
+ css_property,
+ Some(s.into_owned()),
+ parent_selectors,
+ ));
}
}
return results;
@@ -206,11 +202,9 @@ pub fn decompose_value_conditions(
&& let Some(inner) = call.arguments.first().and_then(|arg| arg.as_expression())
{
if let Some(s) = get_string_by_literal_expression(inner) {
- return vec![DecomposedStyle {
- property: css_property.to_string(),
- value: Some(s.into_owned()),
- selector: compose_selectors(parent_selectors),
- }];
+ return decomposed_leaf(css_property, Some(s.into_owned()), parent_selectors)
+ .into_iter()
+ .collect();
}
return vec![];
}
@@ -258,14 +252,15 @@ pub fn decompose_value_conditions(
results
}
-/// Compose a list of selector parts into a single `StyleSelector`.
-fn compose_selectors(parts: &[SelectorPart]) -> Option {
- if parts.is_empty() {
- return None;
- }
-
+/// A leaf style under `parent_selectors`, or `None` when those conditions can
+/// never match together (e.g. `@media print` inside `@media screen`).
+fn decomposed_leaf(
+ css_property: &str,
+ value: Option,
+ parent_selectors: &[SelectorPart],
+) -> Option {
let mut pseudo_str: Option = None;
- for p in parts {
+ for p in parent_selectors {
if let SelectorPart::Pseudo(s) = p {
pseudo_str
.get_or_insert_with(|| String::from("&"))
@@ -273,21 +268,23 @@ fn compose_selectors(parts: &[SelectorPart]) -> Option {
}
}
- // Last at-rule wins; read it with a reverse scan (no intermediate Vec).
- let last_at_rule = parts.iter().rev().find_map(|p| match p {
- SelectorPart::AtRule { kind, query } => Some((*kind, query.as_str())),
- SelectorPart::Pseudo(_) => None,
- });
-
- if let Some((kind, query)) = last_at_rule {
- Some(StyleSelector::At {
- kind,
- query: query.to_string(),
- selector: pseudo_str,
- })
- } else {
- pseudo_str.map(StyleSelector::Selector)
+ // Every enclosing at-rule applies, outermost first.
+ let mut selector = pseudo_str.map(StyleSelector::Selector);
+ for p in parent_selectors {
+ if let SelectorPart::AtRule { kind, query } = p {
+ selector = Some(StyleSelector::nest_at_rule(
+ selector.as_ref(),
+ *kind,
+ query,
+ )?);
+ }
}
+
+ Some(DecomposedStyle {
+ property: css_property.to_string(),
+ value,
+ selector,
+ })
}
/// Parse an at-rule key like `"@media (max-width: 600px)"` into kind + query.
@@ -319,4 +316,32 @@ mod tests {
assert_eq!(normalize_stylex_property("color"), "color");
assert_eq!(normalize_stylex_property("zIndex"), "z-index");
}
+
+ #[test]
+ fn test_decompose_folds_every_at_rule() {
+ let allocator = oxc_allocator::Allocator::default();
+ let source = "({ default: 'red', '@media print': { ':hover': { '@media (prefers-reduced-motion: reduce)': 'blue', '@media screen': 'green' } } })";
+ let program = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::ts())
+ .parse()
+ .program;
+ let oxc_ast::ast::Statement::ExpressionStatement(statement) = &program.body[0] else {
+ panic!("expected expression statement");
+ };
+
+ let value = statement.expression.without_parentheses();
+ let styles: Vec<_> = decompose_value_conditions("color", value, &[])
+ .into_iter()
+ .map(|style| (style.value, style.selector.map(|s| s.to_string())))
+ .collect();
+ assert_eq!(
+ styles,
+ vec![
+ (Some("red".to_string()), None),
+ (
+ Some("blue".to_string()),
+ Some("@media print and (prefers-reduced-motion:reduce) &:hover".to_string())
+ ),
+ ]
+ );
+ }
}
diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs
index 26ecb8357..87e9f5178 100644
--- a/libs/extractor/src/tailwind.rs
+++ b/libs/extractor/src/tailwind.rs
@@ -179,51 +179,16 @@ impl TailwindVariant {
TailwindVariant::PeerInvalid => {
StyleSelector::Selector(".peer:invalid ~ &".to_string())
}
- TailwindVariant::Print => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "print".to_string(),
- selector: None,
- },
- TailwindVariant::Screen => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "screen".to_string(),
- selector: None,
- },
- TailwindVariant::Portrait => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(orientation: portrait)".to_string(),
- selector: None,
- },
- TailwindVariant::Landscape => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(orientation: landscape)".to_string(),
- selector: None,
- },
- TailwindVariant::MotionReduce => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-reduced-motion: reduce)".to_string(),
- selector: None,
- },
- TailwindVariant::MotionSafe => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-reduced-motion: no-preference)".to_string(),
- selector: None,
- },
- TailwindVariant::ContrastMore => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-contrast: more)".to_string(),
- selector: None,
- },
- TailwindVariant::ContrastLess => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(prefers-contrast: less)".to_string(),
- selector: None,
- },
- TailwindVariant::ForcedColors => StyleSelector::At {
- kind: css::style_selector::AtRuleKind::Media,
- query: "(forced-colors: active)".to_string(),
- selector: None,
- },
+ // Media variants share the `_print`/`_motionReduce`/… prop table.
+ TailwindVariant::Print => StyleSelector::from("print"),
+ TailwindVariant::Screen => StyleSelector::from("screen"),
+ TailwindVariant::Portrait => StyleSelector::from("portrait"),
+ TailwindVariant::Landscape => StyleSelector::from("landscape"),
+ TailwindVariant::MotionReduce => StyleSelector::from("motion-reduce"),
+ TailwindVariant::MotionSafe => StyleSelector::from("motion-safe"),
+ TailwindVariant::ContrastMore => StyleSelector::from("contrast-more"),
+ TailwindVariant::ContrastLess => StyleSelector::from("contrast-less"),
+ TailwindVariant::ForcedColors => StyleSelector::from("forced-colors"),
TailwindVariant::Rtl => StyleSelector::Selector("[dir=rtl] &".to_string()),
TailwindVariant::Ltr => StyleSelector::Selector("[dir=ltr] &".to_string()),
}
@@ -331,8 +296,9 @@ fn remove_all_substr(haystack: &mut String, needle: &str) {
}
impl TailwindClass {
- /// Convert to `ExtractStaticStyle`
- pub fn to_static_style(&self) -> ExtractStaticStyle {
+ /// Convert to `ExtractStaticStyle`; `None` when the variants' media
+ /// conditions exclude each other (e.g. `print:screen:`).
+ pub fn to_static_style(&self) -> Option {
// For transform property, negative is already incorporated into the value
// (e.g., translateX(-1rem)), so don't add prefix again. Only the negative
// branch needs an owned String for the `-` prefix; the common non-negative
@@ -350,22 +316,27 @@ impl TailwindClass {
None
} else {
// Combine multiple variants into a single selector
- Some(self.combine_selectors())
+ Some(self.combine_selectors()?)
};
- ExtractStaticStyle::new(self.property, value.as_ref(), self.responsive, selector)
+ Some(ExtractStaticStyle::new(
+ self.property,
+ value.as_ref(),
+ self.responsive,
+ selector,
+ ))
}
- /// Combine multiple variant selectors
- fn combine_selectors(&self) -> StyleSelector {
+ /// Combine multiple variant selectors; `None` when they can never match together.
+ fn combine_selectors(&self) -> Option {
if self.variants.len() == 1 {
- return self.variants[0].to_selector();
+ return Some(self.variants[0].to_selector());
}
// For multiple variants, combine them
// e.g., dark:hover: becomes :root[data-theme=dark] &:hover
let mut selector_str = String::new();
- let mut has_at_rule = None;
+ let mut at_rules = vec![];
for variant in &self.variants {
let sel = variant.to_selector();
@@ -394,9 +365,7 @@ impl TailwindClass {
}
}
}
- StyleSelector::At { kind, query, .. } => {
- has_at_rule = Some((kind, query));
- }
+ StyleSelector::At { kind, query, .. } => at_rules.push((kind, query)),
// SAFETY: TailwindVariant::to_selector() never produces Global.
// This arm exists only for exhaustive matching. If reached, it indicates
// a bug where a new TailwindVariant was added that produces Global.
@@ -406,19 +375,18 @@ impl TailwindClass {
}
}
- if let Some((kind, query)) = has_at_rule {
- StyleSelector::At {
+ // Every media variant applies, so fold them all in order (`print:motion-reduce:`
+ // becomes `@media print and (prefers-reduced-motion:reduce)`).
+ let mut selector =
+ (!selector_str.is_empty()).then_some(StyleSelector::Selector(selector_str));
+ for (kind, query) in at_rules {
+ selector = Some(StyleSelector::nest_at_rule(
+ selector.as_ref(),
kind,
- query,
- selector: if selector_str.is_empty() {
- None
- } else {
- Some(selector_str)
- },
- }
- } else {
- StyleSelector::Selector(selector_str)
+ &query,
+ )?);
}
+ selector
}
}
@@ -1292,8 +1260,10 @@ pub fn parse_tailwind_to_styles(class_str: &str) -> Vec {
Vec::with_capacity(class_str.bytes().filter(u8::is_ascii_whitespace).count() + 1);
for class in class_str.split_whitespace() {
- if let Some(parsed) = parse_single_class(class) {
- let static_style = parsed.to_static_style();
+ if let Some(static_style) = parse_single_class(class)
+ .as_ref()
+ .and_then(TailwindClass::to_static_style)
+ {
styles.push(ExtractStyleValue::Static(static_style));
}
}
@@ -3715,7 +3685,7 @@ mod tests {
assert_eq!(parsed.value, "1rem");
assert!(parsed.negative);
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.value(), "-1rem");
}
@@ -3749,7 +3719,7 @@ mod tests {
#[test]
fn test_to_static_style() {
let parsed = parse_single_class("bg-red-500").expect("Should parse");
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.property(), "background-color");
// ExtractStaticStyle::new() uses optimize_value() which uppercases hex colors
@@ -3761,7 +3731,7 @@ mod tests {
#[test]
fn test_to_static_style_with_responsive() {
let parsed = parse_single_class("md:p-4").expect("Should parse");
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.property(), "padding");
assert_eq!(static_style.value(), "1rem");
@@ -3771,7 +3741,7 @@ mod tests {
#[test]
fn test_to_static_style_with_variant() {
let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse");
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
assert_eq!(static_style.property(), "background-color");
assert!(static_style.selector().is_some());
@@ -4072,14 +4042,15 @@ mod tests {
// Wave 1.4: Media variant selectors (lines 153-162)
#[rstest]
+ #[case(TailwindVariant::Print, "print")]
#[case(TailwindVariant::Screen, "screen")]
- #[case(TailwindVariant::Portrait, "(orientation: portrait)")]
- #[case(TailwindVariant::Landscape, "(orientation: landscape)")]
- #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion: reduce)")]
- #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion: no-preference)")]
- #[case(TailwindVariant::ContrastMore, "(prefers-contrast: more)")]
- #[case(TailwindVariant::ContrastLess, "(prefers-contrast: less)")]
- #[case(TailwindVariant::ForcedColors, "(forced-colors: active)")]
+ #[case(TailwindVariant::Portrait, "(orientation:portrait)")]
+ #[case(TailwindVariant::Landscape, "(orientation:landscape)")]
+ #[case(TailwindVariant::MotionReduce, "(prefers-reduced-motion:reduce)")]
+ #[case(TailwindVariant::MotionSafe, "(prefers-reduced-motion:no-preference)")]
+ #[case(TailwindVariant::ContrastMore, "(prefers-contrast:more)")]
+ #[case(TailwindVariant::ContrastLess, "(prefers-contrast:less)")]
+ #[case(TailwindVariant::ForcedColors, "(forced-colors:active)")]
fn test_variant_to_selector_media_queries(
#[case] variant: TailwindVariant,
#[case] expected_query: &str,
@@ -4132,13 +4103,14 @@ mod tests {
assert_eq!(parsed.variants[0], TailwindVariant::Print);
assert_eq!(parsed.variants[1], TailwindVariant::Hover);
- let static_style = parsed.to_static_style();
+ let static_style = parsed.to_static_style().expect("style");
let selector = static_style.selector().expect("Should have selector");
// Should combine into At rule with nested hover selector
if let StyleSelector::At {
kind,
query,
selector: nested,
+ ..
} = selector
{
assert_eq!(*kind, css::style_selector::AtRuleKind::Media);
@@ -4149,6 +4121,29 @@ mod tests {
}
}
+ #[rstest]
+ #[case(
+ "print:motion-reduce:hidden",
+ Some("@media print and (prefers-reduced-motion:reduce)")
+ )]
+ #[case(
+ "motion-reduce:landscape:hover:hidden",
+ Some("@media(prefers-reduced-motion:reduce)and (orientation:landscape) &:hover")
+ )]
+ #[case("print:screen:hidden", None)]
+ fn test_combine_selectors_folds_every_media_variant(
+ #[case] class: &str,
+ #[case] expected: Option<&str>,
+ ) {
+ let parsed = parse_single_class(class).expect("Should parse");
+ assert_eq!(
+ parsed
+ .to_static_style()
+ .and_then(|style| style.selector().map(ToString::to_string)),
+ expected.map(str::to_string)
+ );
+ }
+
#[test]
fn test_remove_all_substr_matches_str_replace() {
for (haystack, needle) in [
@@ -4221,7 +4216,7 @@ mod tests {
negative: false,
};
let expected = old_combine(variants);
- match cls.combine_selectors() {
+ match cls.combine_selectors().expect("selector") {
StyleSelector::Selector(actual) => {
assert_eq!(actual, expected, "variants={variants:?}");
}
diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs
index 7c0bd3cef..276369a98 100644
--- a/libs/extractor/src/vanilla_extract.rs
+++ b/libs/extractor/src/vanilla_extract.rs
@@ -94,6 +94,22 @@ pub struct CollectedStyles {
pub themes: FxHashMap,
/// Non-style constant exports: `variable_name` -> value (as code string)
pub constant_exports: FxHashMap,
+ /// `__style_N__` placeholder -> what it refers to, for placeholders left in
+ /// selectors and values
+ pub references: FxHashMap,
+}
+
+/// Target of a placeholder that a selector or value interpolated
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum Reference {
+ /// `style()`: `variable_name` and the unique class it gains when referenced
+ Style { name: String, class_name: String },
+ /// `keyframes()`: `variable_name`, resolved to the generated keyframes name
+ Keyframes(String),
+ /// `createTheme()`: its generated class name
+ Class(String),
+ /// `fontFace()`: its generated font-family name
+ Font(String),
}
/// Internal state for collecting styles during JS execution
@@ -387,7 +403,7 @@ pub fn execute_vanilla_extract(
#[derive(Debug, Clone)]
enum VarInfo {
/// A style API call (style, keyframes, createContainer, etc.)
- StyleApi { exported: bool },
+ StyleApi { exported: bool, api: StyleApi },
/// A regular constant export with its original code
Constant(String),
/// The vars object from createTheme array destructuring [themeClass, vars]
@@ -409,12 +425,12 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> {
// Check for array destructuring: const [themeClass, vars] = createTheme(...)
if let oxc_ast::ast::BindingPattern::ArrayPattern(array_pat) = &decl.id {
- if is_style_api_call(init) {
+ if let Some(api) = style_api_call(init) {
// First element is the theme class
if let Some(Some(first)) = array_pat.elements.first()
&& let oxc_ast::ast::BindingPattern::BindingIdentifier(id) = first
{
- vars.push((id.name.to_string(), VarInfo::StyleApi { exported }));
+ vars.push((id.name.to_string(), VarInfo::StyleApi { exported, api }));
}
// Second element is the vars object - mark as ThemeVars
if let Some(Some(second)) = array_pat.elements.get(1)
@@ -424,8 +440,8 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> {
}
}
} else if let Some(name) = decl.id.get_identifier_name() {
- if is_style_api_call(init) {
- vars.push((name.to_string(), VarInfo::StyleApi { exported }));
+ if let Some(api) = style_api_call(init) {
+ vars.push((name.to_string(), VarInfo::StyleApi { exported, api }));
} else if exported {
// Extract the original init expression using span
let span = init.span();
@@ -460,26 +476,28 @@ fn extract_var_names(code: &str, _package: &str) -> Vec<(String, VarInfo)> {
vars
}
-/// Check if an expression is a call to a style API (style, keyframes, styleVariants, etc.)
-fn is_style_api_call(expr: &oxc_ast::ast::Expression) -> bool {
+/// Which placeholder counter a style API call draws from
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum StyleApi {
+ FontFace,
+ GlobalTheme,
+ Other,
+}
+
+/// The style API an expression calls (style, keyframes, styleVariants, etc.)
+fn style_api_call(expr: &oxc_ast::ast::Expression) -> Option {
if let oxc_ast::ast::Expression::CallExpression(call) = expr
&& let oxc_ast::ast::Expression::Identifier(id) = &call.callee
{
- let name = id.name.as_str();
- return matches!(
- name,
- "style"
- | "keyframes"
- | "styleVariants"
- | "fontFace"
- | "createVar"
- | "createContainer"
- | "layer"
- | "createGlobalTheme"
- | "createTheme"
- );
- }
- false
+ return match id.name.as_str() {
+ "fontFace" => Some(StyleApi::FontFace),
+ "createGlobalTheme" => Some(StyleApi::GlobalTheme),
+ "style" | "keyframes" | "styleVariants" | "createVar" | "createContainer" | "layer"
+ | "createTheme" => Some(StyleApi::Other),
+ _ => None,
+ };
+ }
+ None
}
/// Remap style placeholder IDs to original variable names
@@ -495,7 +513,6 @@ fn remap_style_names(
// Build mapping from placeholder ID to original name
// The order of style() calls matches the order of variable declarations
let mut placeholder_to_name: FxHashMap = FxHashMap::default();
- let mut font_placeholder_to_name: FxHashMap = FxHashMap::default();
let mut new_styles = FxHashMap::default();
let mut new_keyframes = FxHashMap::default();
let mut new_style_variants = FxHashMap::default();
@@ -524,35 +541,54 @@ fn remap_style_names(
for (name, info) in vars {
match info {
- VarInfo::StyleApi { exported } => {
- // First check if this is a fontFace (uses __font_N__ placeholder)
+ VarInfo::StyleApi {
+ exported,
+ api: StyleApi::FontFace,
+ } => {
let font_placeholder = format!("__font_{font_idx}__");
if let Some((json, font_family, _)) = old_font_faces.remove(&font_placeholder) {
- font_placeholder_to_name.insert(font_placeholder, name.clone());
+ collected
+ .references
+ .insert(font_placeholder, Reference::Font(font_family.clone()));
new_font_faces.insert(name.clone(), (json, font_family, *exported));
font_idx += 1;
- continue;
}
-
- // Check if this is a createGlobalTheme (uses __global_theme_N__ placeholder)
+ }
+ VarInfo::StyleApi {
+ exported,
+ api: StyleApi::GlobalTheme,
+ } => {
let global_theme_placeholder = format!("__global_theme_{global_theme_idx}__");
if let Some(mut entry) = old_global_themes.remove(&global_theme_placeholder) {
entry.exported = *exported;
new_global_themes.insert(name.clone(), entry);
global_theme_idx += 1;
- continue;
}
-
+ }
+ VarInfo::StyleApi {
+ exported,
+ api: StyleApi::Other,
+ } => {
let placeholder = format!("__style_{style_idx}__");
placeholder_to_name.insert(placeholder.clone(), name.clone());
if let Some(mut entry) = old_styles.remove(&placeholder) {
entry.exported = *exported;
new_styles.insert(name.clone(), entry);
+ collected.references.insert(
+ placeholder,
+ Reference::Style {
+ name: name.clone(),
+ class_name: format!("{file_prefix}_{name}"),
+ },
+ );
style_idx += 1;
} else if let Some(mut entry) = old_keyframes.remove(&placeholder) {
entry.exported = *exported;
new_keyframes.insert(name.clone(), entry);
+ collected
+ .references
+ .insert(placeholder, Reference::Keyframes(name.clone()));
style_idx += 1;
} else if let Some((variants, _)) = old_style_variants.remove(&placeholder) {
new_style_variants.insert(name.clone(), (variants, *exported));
@@ -591,6 +627,9 @@ fn remap_style_names(
.push((format!(".{class_name}"), vars_json));
}
+ collected
+ .references
+ .insert(placeholder, Reference::Class(class_name.clone()));
entry.exported = *exported;
entry.vars_name = None;
entry.class_name = class_name;
@@ -641,23 +680,6 @@ fn remap_style_names(
.collect();
}
- // Replace font placeholders in style JSONs with actual font-family names
- // Build a mapping from placeholder to font-family name
- let font_family_map: FxHashMap<&str, &str> = font_placeholder_to_name
- .iter()
- .filter_map(|(placeholder, name)| {
- new_font_faces
- .get(name)
- .map(|(_, font_family, _)| (placeholder.as_str(), font_family.as_str()))
- })
- .collect();
-
- // Replace placeholders in a single pass per style JSON.
- // This is needed for font-family references and selectors like `${parent}:hover &`.
- for entry in new_styles.values_mut() {
- replace_placeholders_in_json(&mut entry.json, &font_family_map, &placeholder_to_name);
- }
-
collected.styles = new_styles;
collected.keyframes = new_keyframes;
collected.style_variants = new_style_variants;
@@ -669,12 +691,12 @@ fn remap_style_names(
collected.themes = new_themes;
}
-fn replace_placeholders_in_json(
- json: &mut String,
- font_family_map: &FxHashMap<&str, &str>,
- placeholder_to_name: &FxHashMap,
-) {
- let source = json.as_str();
+/// Replace every `__name__` token that `lookup` resolves; `None` when nothing
+/// was replaced.
+fn replace_placeholders(
+ source: &str,
+ mut lookup: impl FnMut(&str, &str) -> Option,
+) -> Option {
let mut search_start = 0;
let mut last_copied = 0;
let mut output = None::;
@@ -685,16 +707,10 @@ fn replace_placeholders_in_json(
break;
};
let end = start + 2 + relative_end + 2;
- let placeholder = &source[start..end];
- let replacement = font_family_map
- .get(placeholder)
- .copied()
- .or_else(|| placeholder_to_name.get(placeholder).map(String::as_str));
-
- if let Some(replacement) = replacement {
+ if let Some(replacement) = lookup(&source[start..end], &source[end..]) {
let output = output.get_or_insert_with(|| String::with_capacity(source.len()));
output.push_str(&source[last_copied..start]);
- output.push_str(replacement);
+ output.push_str(&replacement);
last_copied = end;
search_start = end;
} else {
@@ -702,12 +718,11 @@ fn replace_placeholders_in_json(
}
}
- if let Some(mut output) = output {
+ output.map(|mut output| {
output.push_str(&source[last_copied..]);
- *json = output;
- }
+ output
+ })
}
-
/// Convert TypeScript to JavaScript using Oxc Transformer and replace imports
fn preprocess_typescript(code: &str, package: &str) -> String {
let allocator = Allocator::default();
@@ -1165,83 +1180,178 @@ fn register_vanilla_extract_apis(
Ok(())
}
-/// Find all style names that are referenced in selectors of other styles
-/// Returns a set of style names that need to be extracted first
-pub fn find_selector_references(collected: &CollectedStyles) -> FxHashSet {
- let mut referenced = rustc_hash::FxHashSet::default();
- // Build the probe strings once per style name instead of per (entry, name) pair
- let probes: Vec<(String, String, &str)> = collected
- .styles
- .keys()
- .map(|name| (format!("\"{name}:"), format!("\"{name} "), name.as_str()))
- .collect();
+/// Placeholders interpolated into a selector end inside a JSON key.
+fn in_json_key(rest: &str) -> bool {
+ rest.find('"')
+ .is_some_and(|i| rest[i + 1..].starts_with(':'))
+}
- for entry in collected.styles.values() {
- // Cheap necessary-condition gate: a `":"` / `" "` selector reference can
- // only appear inside a `"selectors":{...}` block, so a JSON with no `selectors` object
- // cannot match ANY probe. Skip the whole K-probe inner loop for those entries (the
- // common case — most style JSONs carry no cross-references). Byte-identical to probing
- // every entry: the skipped entries provably contribute nothing to `referenced`.
- if !entry.json.contains("selectors") {
- continue;
- }
- // Check if this style's JSON contains references to other style names
- for (probe_colon, probe_space, style_name) in &probes {
- // Look for patterns like "stylename:" or "stylename " in selectors
- // The JSON has selectors like {"selectors":{"parent:hover &":{...}}}
- if entry.json.contains(probe_colon.as_str())
- || entry.json.contains(probe_space.as_str())
- {
- referenced.insert((*style_name).to_string());
- }
+impl CollectedStyles {
+ fn resolve(
+ &self,
+ source: &str,
+ keyframes_names: &FxHashMap,
+ selector: impl Fn(&str) -> bool,
+ ) -> String {
+ replace_placeholders(source, |token, rest| {
+ Some(match self.references.get(token)? {
+ Reference::Style { class_name, .. } | Reference::Class(class_name) => {
+ if selector(rest) {
+ format!(".{class_name}")
+ } else {
+ class_name.clone()
+ }
+ }
+ Reference::Keyframes(name) => keyframes_names.get(name)?.clone(),
+ Reference::Font(family) => family.clone(),
+ })
+ })
+ .unwrap_or_else(|| source.to_string())
+ }
+
+ fn resolve_json(&self, json: &str, keyframes_names: &FxHashMap) -> String {
+ self.resolve(json, keyframes_names, in_json_key)
+ }
+
+ /// Every text a placeholder can be interpolated into.
+ fn texts(&self) -> impl Iterator- {
+ self.styles
+ .values()
+ .map(|entry| entry.json.as_str())
+ .chain(self.keyframes.values().map(|entry| entry.json.as_str()))
+ .chain(
+ self.style_variants
+ .values()
+ .flat_map(|(variants, _)| variants.values())
+ .map(|variant| variant.styles_json.as_str()),
+ )
+ .chain(
+ self.global_styles
+ .iter()
+ .flat_map(|(selector, json)| [selector.as_str(), json.as_str()]),
+ )
+ }
+
+ fn referenced(&self) -> FxHashSet<&Reference> {
+ let mut referenced = FxHashSet::default();
+ for text in self.texts() {
+ replace_placeholders(text, |token, _| {
+ if let Some(reference) = self.references.get(token) {
+ referenced.insert(reference);
+ }
+ None
+ });
}
+ referenced
}
- referenced
+ /// `variable_name` -> unique class of every style some selector or value refers to
+ fn referenced_classes(&self) -> FxHashMap<&str, &str> {
+ self.referenced()
+ .into_iter()
+ .filter_map(|reference| match reference {
+ Reference::Style { name, class_name } => Some((name.as_str(), class_name.as_str())),
+ _ => None,
+ })
+ .collect()
+ }
}
-/// Generate code only for specific styles (used for first-pass extraction)
-pub fn collected_styles_to_code_partial(
+/// Names of the keyframes some style refers to; their generated names have to be
+/// known before the styles referring to them are generated.
+pub fn referenced_keyframes(collected: &CollectedStyles) -> FxHashSet {
+ collected
+ .referenced()
+ .into_iter()
+ .filter_map(|reference| match reference {
+ Reference::Keyframes(name) => Some(name.clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+/// Code declaring only the keyframes in `names`, extracted first to learn their
+/// generated names
+pub fn keyframes_to_code(
collected: &CollectedStyles,
package: &str,
- style_names: &FxHashSet,
+ names: &FxHashSet,
) -> String {
- let mut output = String::new();
-
- if !style_names.is_empty() {
- output.push_str("import { css } from '");
- output.push_str(package);
- output.push('\'');
- }
-
- // Generate only the specified styles
- let mut styles: Vec<_> = collected
- .styles
+ let mut keyframes: Vec<_> = collected
+ .keyframes
.iter()
- .filter(|(name, _)| style_names.contains(*name))
+ .filter(|(name, _)| names.contains(*name))
.collect();
- styles.sort_by_key(|(name, _)| *name);
-
- for (name, entry) in styles {
- // Generate as non-exported for first pass
- if !output.is_empty() {
- output.push('\n');
- }
- output.push_str("const ");
+ keyframes.sort_by_key(|(name, _)| *name);
+ let mut output = format!("import {{ keyframes }} from '{package}'");
+ for (name, entry) in keyframes {
+ output.push_str("\nconst ");
output.push_str(name);
- output.push_str(" = css(");
- output.push_str(&entry.json);
+ output.push_str(" = keyframes(");
+ output.push_str(&collected.resolve_json(&entry.json, &FxHashMap::default()));
output.push(')');
}
-
output
}
-/// Convert collected styles to code with selector references replaced by class names
-pub fn collected_styles_to_code_with_classes(
+fn inner_json(json: &str) -> &str {
+ json.trim()
+ .trim_start_matches('{')
+ .trim_end_matches('}')
+ .trim()
+}
+
+/// `css(...)` of `own` composed onto `bases`, plus the unique classes that let
+/// selectors target them
+fn composed_css(
+ collected: &CollectedStyles,
+ keyframes_names: &FxHashMap,
+ referenced_classes: &FxHashMap<&str, &str>,
+ bases: &[&str],
+ own_name: Option<&str>,
+ own_json: &str,
+) -> String {
+ let mut parts = Vec::with_capacity(bases.len() + 1);
+ let mut classes = Vec::new();
+ for base in bases {
+ if let Some(entry) = collected.styles.get(*base) {
+ let json = collected.resolve_json(&entry.json, keyframes_names);
+ let inner = inner_json(&json);
+ if !inner.is_empty() {
+ parts.push(inner.to_string());
+ }
+ }
+ classes.extend(referenced_classes.get(base));
+ }
+ let own = collected.resolve_json(own_json, keyframes_names);
+ let css = if bases.is_empty() {
+ format!("css({own})")
+ } else {
+ let inner = inner_json(&own);
+ if !inner.is_empty() {
+ parts.push(inner.to_string());
+ }
+ format!("css({{{}}})", parts.join(","))
+ };
+ classes.extend(own_name.and_then(|name| referenced_classes.get(name)));
+ if classes.is_empty() {
+ css
+ } else {
+ let mut unique = String::new();
+ for class in classes {
+ unique.push(' ');
+ unique.push_str(class);
+ }
+ format!("{css} + \"{unique}\"")
+ }
+}
+
+/// Convert collected styles to code, with keyframes references replaced by
+/// `keyframes_names` (`variable_name` -> generated name)
+pub fn collected_styles_to_code_with_keyframes(
collected: &CollectedStyles,
package: &str,
- class_map: &FxHashMap,
+ keyframes_names: &FxHashMap,
) -> String {
let mut code_parts = Vec::with_capacity(collected.styles.len() + 4);
@@ -1271,73 +1381,23 @@ pub fn collected_styles_to_code_with_classes(
));
}
- // Generate style declarations with selector references replaced
- let style_json_map: FxHashMap<&str, &str> = collected
- .styles
- .iter()
- .map(|(name, entry)| (name.as_str(), entry.json.as_str()))
- .collect();
-
- // Pre-build search/replace pairs to avoid format!() per iteration
- let replacements: Vec<_> = class_map
- .iter()
- .map(|(style_name, class_name)| {
- (
- format!("\"{style_name}:"),
- format!("\"{class_name}:"),
- format!("\"{style_name} "),
- format!("\"{class_name} "),
- )
- })
- .collect();
-
+ let referenced_classes = collected.referenced_classes();
let mut styles: Vec<_> = collected.styles.iter().collect();
styles.sort_by_key(|(name, _)| *name);
for (name, entry) in styles {
let prefix = if entry.exported { "export " } else { "" };
-
- // Replace style name references with class names in JSON
- let mut json = entry.json.clone();
- for (search_colon, replace_colon, search_space, replace_space) in &replacements {
- if json.contains(search_colon.as_str()) {
- json = json.replace(search_colon, replace_colon);
- }
- if json.contains(search_space.as_str()) {
- json = json.replace(search_space, replace_space);
- }
- }
-
- if entry.bases.is_empty() {
- code_parts.push(format!("{prefix}const {name} = css({json})"));
- } else {
- // Composition: merge all base styles
- let mut merged_parts = Vec::new();
- for base_name in &entry.bases {
- if let Some(base_json) = style_json_map.get(base_name.as_str()) {
- let inner = base_json
- .trim()
- .trim_start_matches('{')
- .trim_end_matches('}')
- .trim();
- if !inner.is_empty() {
- merged_parts.push(inner.to_string());
- }
- }
- }
- let own_inner = json
- .trim()
- .trim_start_matches('{')
- .trim_end_matches('}')
- .trim();
- if !own_inner.is_empty() {
- merged_parts.push(own_inner.to_string());
- }
- let merged_json = format!("{{{}}}", merged_parts.join(","));
- code_parts.push(format!("{prefix}const {name} = css({merged_json})"));
- }
+ let bases: Vec<&str> = entry.bases.iter().map(String::as_str).collect();
+ let css = composed_css(
+ collected,
+ keyframes_names,
+ &referenced_classes,
+ &bases,
+ Some(name),
+ &entry.json,
+ );
+ code_parts.push(format!("{prefix}const {name} = {css}"));
}
-
// Generate createTheme exports (class name and optionally vars object)
// Note: CSS variables are added to global_styles during remapping
let mut themes: Vec<_> = collected.themes.iter().collect();
@@ -1364,8 +1424,12 @@ pub fn collected_styles_to_code_with_classes(
}
}
- // Add remaining code generation (globalCss, keyframes, etc.) - call original function's logic
- append_non_style_code(collected, package, &mut code_parts);
+ append_non_style_code(
+ collected,
+ keyframes_names,
+ &referenced_classes,
+ &mut code_parts,
+ );
code_parts.join("\n")
}
@@ -1373,12 +1437,17 @@ pub fn collected_styles_to_code_with_classes(
/// Append non-style code parts (globalCss, keyframes, fontFaces, etc.)
fn append_non_style_code(
collected: &CollectedStyles,
- _package: &str,
+ keyframes_names: &FxHashMap,
+ referenced_classes: &FxHashMap<&str, &str>,
code_parts: &mut Vec,
) {
- // Generate globalCss calls
for (selector, json) in &collected.global_styles {
- code_parts.push(format!("globalCss({{ \"{selector}\": {json} }})"));
+ let selector = collected.resolve(selector, keyframes_names, |_| true);
+ let json = collected.resolve_json(json, keyframes_names);
+ code_parts.push(format!(
+ "globalCss({{ {}: {json} }})",
+ serde_json::Value::String(selector)
+ ));
}
// Generate @font-face rules
@@ -1426,61 +1495,38 @@ fn append_non_style_code(
let prefix = if entry.exported { "export " } else { "" };
code_parts.push(format!(
"{}const {} = keyframes({})",
- prefix, name, entry.json
+ prefix,
+ name,
+ collected.resolve_json(&entry.json, keyframes_names)
));
}
- // Generate styleVariants
let mut variants: Vec<_> = collected.style_variants.iter().collect();
- if !variants.is_empty() {
- let style_json_map: FxHashMap<&str, &str> = collected
- .styles
- .iter()
- .map(|(name, entry)| (name.as_str(), entry.json.as_str()))
- .collect();
- variants.sort_by_key(|(name, _)| *name);
- for (name, (variant_map, exported)) in variants {
- let mut variant_entries: Vec<_> = variant_map.iter().collect();
- variant_entries.sort_by_key(|(k, _)| *k);
- let mut object_parts = Vec::new();
- for (variant_key, variant) in variant_entries {
- let value = if let Some(base_name) = &variant.base {
- let mut merged_parts = Vec::new();
- if let Some(base_json) = style_json_map.get(base_name.as_str()) {
- let inner = base_json
- .trim()
- .trim_start_matches('{')
- .trim_end_matches('}')
- .trim();
- if !inner.is_empty() {
- merged_parts.push(inner.to_string());
- }
- }
- let own_inner = variant
- .styles_json
- .trim()
- .trim_start_matches('{')
- .trim_end_matches('}')
- .trim();
- if !own_inner.is_empty() {
- merged_parts.push(own_inner.to_string());
- }
- format!("css({{{}}})", merged_parts.join(","))
- } else {
- format!("css({})", variant.styles_json)
- };
- object_parts.push(format!(" {variant_key}: {value}"));
- }
- let prefix = if *exported { "export " } else { "" };
- code_parts.push(format!(
- "{}const {} = {{\n{}\n}}",
- prefix,
- name,
- object_parts.join(",\n")
- ));
+ variants.sort_by_key(|(name, _)| *name);
+ for (name, (variant_map, exported)) in variants {
+ let mut variant_entries: Vec<_> = variant_map.iter().collect();
+ variant_entries.sort_by_key(|(k, _)| *k);
+ let mut object_parts = Vec::new();
+ for (variant_key, variant) in variant_entries {
+ let bases: Vec<&str> = variant.base.iter().map(String::as_str).collect();
+ let value = composed_css(
+ collected,
+ keyframes_names,
+ referenced_classes,
+ &bases,
+ None,
+ &variant.styles_json,
+ );
+ object_parts.push(format!(" {variant_key}: {value}"));
}
+ let prefix = if *exported { "export " } else { "" };
+ code_parts.push(format!(
+ "{}const {} = {{\n{}\n}}",
+ prefix,
+ name,
+ object_parts.join(",\n")
+ ));
}
-
// Generate createVar declarations
let mut vars: Vec<_> = collected.vars.iter().collect();
vars.sort_by_key(|(name, _)| *name);
@@ -1529,8 +1575,9 @@ fn append_non_style_code(
}
/// Convert collected styles to code that can be processed by existing extract logic
+#[cfg(test)]
pub fn collected_styles_to_code(collected: &CollectedStyles, package: &str) -> String {
- collected_styles_to_code_with_classes(collected, package, &FxHashMap::default())
+ collected_styles_to_code_with_keyframes(collected, package, &FxHashMap::default())
}
/// Parse a styleVariants object and extract variant info
@@ -1935,8 +1982,11 @@ export const lightTheme = createTheme(vars, {
.into_iter()
.collect();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("import { css } from '@devup-ui/react'"));
// The composed style should have both base and own styles merged
assert!(code.contains("padding"));
@@ -2638,66 +2688,99 @@ export const box = style({ padding: 8 })";
// handle the target package imports
#[test]
- fn test_find_selector_references_no_refs() {
- // Test find_selector_references with no selector references
+ fn test_referenced_keyframes_without_references() {
let mut collected = CollectedStyles::default();
collected.styles.insert(
"box".to_string(),
StyleEntry {
- json: r#"{"padding":"8px"}"#.to_string(),
- exported: true,
- bases: SmallVec::new(),
- },
- );
- collected.styles.insert(
- "text".to_string(),
- StyleEntry {
- json: r#"{"color":"blue"}"#.to_string(),
+ json: r#"{"padding":"8px","animation":"__style_9__ 1s"}"#.to_string(),
exported: true,
bases: SmallVec::new(),
},
);
-
- let refs = super::find_selector_references(&collected);
- assert!(refs.is_empty());
+ assert!(super::referenced_keyframes(&collected).is_empty());
}
#[test]
- fn test_collected_styles_to_code_partial_empty() {
- // Test collected_styles_to_code_partial with empty style_names
- let collected = CollectedStyles::default();
- let empty_set = rustc_hash::FxHashSet::default();
- let code =
- super::collected_styles_to_code_partial(&collected, "@devup-ui/react", &empty_set);
- assert!(code.is_empty());
+ fn test_keyframes_to_code_only_referenced() {
+ let mut collected = CollectedStyles::default();
+ for name in ["fade", "spin"] {
+ collected.keyframes.insert(
+ name.to_string(),
+ StyleEntry {
+ json: r#"{"from":{"opacity":0}}"#.to_string(),
+ exported: false,
+ bases: SmallVec::new(),
+ },
+ );
+ }
+ let names = std::iter::once("fade".to_string()).collect();
+ assert_eq!(
+ super::keyframes_to_code(&collected, "@devup-ui/react", &names),
+ "import { keyframes } from '@devup-ui/react'\nconst fade = keyframes({\"from\":{\"opacity\":0}})"
+ );
}
#[test]
- fn test_collected_styles_to_code_with_classes_selector_replacement() {
- // Test selector class name replacement in collected_styles_to_code_with_classes
+ fn test_collected_styles_to_code_resolves_references() {
let mut collected = CollectedStyles::default();
- collected.styles.insert(
- "parent".to_string(),
- StyleEntry {
- json: r#"{"background":"white"}"#.to_string(),
- exported: true,
- bases: SmallVec::new(),
- },
- );
+ let entry = |json: &str| StyleEntry {
+ json: json.to_string(),
+ exported: true,
+ bases: SmallVec::new(),
+ };
+ collected
+ .styles
+ .insert("parent".to_string(), entry(r#"{"background":"white"}"#));
collected.styles.insert(
"child".to_string(),
- StyleEntry {
- json: r#"{"selectors":{"parent:hover &":{"color":"blue"}}}"#.to_string(),
- exported: true,
- bases: SmallVec::new(),
+ entry(r#"{"animation":"__style_2__ 1s","selectors":{"__style_0__:hover &":{"color":"blue"}}}"#),
+ );
+ collected
+ .keyframes
+ .insert("fade".to_string(), entry(r#"{"from":{"opacity":0}}"#));
+ collected.global_styles.push((
+ "__style_0__ > a".to_string(),
+ r#"{"color":"__style_1__"}"#.to_string(),
+ ));
+ collected.references.insert(
+ "__style_0__".to_string(),
+ Reference::Style {
+ name: "parent".to_string(),
+ class_name: "f0_parent".to_string(),
},
);
+ collected.references.insert(
+ "__style_1__".to_string(),
+ Reference::Class("f0_theme".to_string()),
+ );
+ collected.references.insert(
+ "__style_2__".to_string(),
+ Reference::Keyframes("fade".to_string()),
+ );
+ collected.references.insert(
+ "__font_0__".to_string(),
+ Reference::Font("__devup_font_0_0".to_string()),
+ );
- let class_map: rustc_hash::FxHashMap =
- std::iter::once(("parent".to_string(), "a".to_string())).collect();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
- assert!(code.contains("a:hover"));
+ let keyframes_names = std::iter::once(("fade".to_string(), "k".to_string())).collect();
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &keyframes_names,
+ );
+ assert!(code.contains(r#"const child = css({"animation":"k 1s","selectors":{".f0_parent:hover &":{"color":"blue"}}})"#), "{code}");
+ assert!(
+ code.contains(r#"const parent = css({"background":"white"}) + " f0_parent""#),
+ "{code}"
+ );
+ assert!(
+ code.contains(r#"globalCss({ ".f0_parent > a": {"color":"f0_theme"} })"#),
+ "{code}"
+ );
+ // An unresolved keyframes reference is left as is.
+ let code = super::collected_styles_to_code(&collected, "@devup-ui/react");
+ assert!(code.contains("__style_2__ 1s"), "{code}");
}
#[test]
@@ -2885,8 +2968,11 @@ export const box = style({ padding: 8 })";
);
let class_map = rustc_hash::FxHashMap::default();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("[theme, vars]"));
}
@@ -2914,8 +3000,11 @@ export const box = style({ padding: 8 })";
);
let class_map = rustc_hash::FxHashMap::default();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("const simpleTheme = \"f0_simple\""));
}
@@ -3047,8 +3136,11 @@ export const box = style({ padding: 8 })";
.insert("sizes".to_string(), (variants, true));
let class_map = rustc_hash::FxHashMap::default();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("sizes"));
}
@@ -3069,8 +3161,11 @@ export const box = style({ padding: 8 })";
.insert("CONFIG".to_string(), "{ debug: true }".to_string());
let class_map = rustc_hash::FxHashMap::default();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("export const CONFIG"));
}
@@ -3126,8 +3221,11 @@ export const box = style({ padding: 8 })";
let class_map: rustc_hash::FxHashMap =
std::iter::once(("box".to_string(), "a".to_string())).collect();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("css"));
assert!(code.contains("globalCss"));
@@ -3158,8 +3256,11 @@ export const box = style({ padding: 8 })";
);
let class_map = rustc_hash::FxHashMap::default();
- let code =
- super::collected_styles_to_code_with_classes(&collected, "@devup-ui/react", &class_map);
+ let code = super::collected_styles_to_code_with_keyframes(
+ &collected,
+ "@devup-ui/react",
+ &class_map,
+ );
assert!(code.contains("const simpleTheme = \"f0_simple\""));
}
diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs
index 6da7cceed..8850391ec 100644
--- a/libs/extractor/src/visit.rs
+++ b/libs/extractor/src/visit.rs
@@ -1092,12 +1092,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
&self.filename,
);
// already set style order
- self.styles.extend(styles.into_iter().flat_map(|mut ex| {
- if let ExtractStyleProp::Static(css) = &mut ex {
- css.set_style_order(style_order.unwrap_or(0));
- }
- ex.into_extract()
- }));
+ let style_order = style_order.unwrap_or(0);
+ self.styles.extend(
+ styles
+ .into_iter()
+ .flat_map(ExtractStyleProp::into_extract)
+ .map(|mut style| {
+ style.set_style_order(style_order);
+ style
+ }),
+ );
self.global_css_result(r.is_component())
}
} else if call.arguments.len() == 2
@@ -1139,12 +1143,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
&mut folded,
&self.filename,
);
- self.styles.extend(styles.into_iter().flat_map(|mut ex| {
- if let ExtractStyleProp::Static(css) = &mut ex {
- css.set_style_order(style_order.unwrap_or(0));
- }
- ex.into_extract()
- }));
+ let style_order = style_order.unwrap_or(0);
+ self.styles.extend(
+ styles
+ .into_iter()
+ .flat_map(ExtractStyleProp::into_extract)
+ .map(|mut style| {
+ style.set_style_order(style_order);
+ style
+ }),
+ );
*it = self.global_css_result(util_type.is_component());
} else {
*it = match util_type.as_ref() {
@@ -1633,12 +1641,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
styles,
style_order,
} = extract_global_style_from_expression(&self.ast, expression, &self.filename);
- self.styles.extend(styles.into_iter().flat_map(|mut ex| {
- if let ExtractStyleProp::Static(css) = &mut ex {
- css.set_style_order(style_order.unwrap_or(0));
- }
- ex.into_extract()
- }));
+ let style_order = style_order.unwrap_or(0);
+ self.styles.extend(
+ styles
+ .into_iter()
+ .flat_map(ExtractStyleProp::into_extract)
+ .map(|mut style| {
+ style.set_style_order(style_order);
+ style
+ }),
+ );
}
elem.opening_element.attributes.remove(i);
}
diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs
index 695a0c2e6..a2b824991 100644
--- a/libs/sheet/src/lib.rs
+++ b/libs/sheet/src/lib.rs
@@ -2,12 +2,15 @@ pub mod theme;
use crate::theme::Theme;
use css::{
+ at_rule::{MediaCombination, combine_media_queries, query_order},
atom_hoist::{atom_hoist_threshold, is_atom_hoist},
file_map::canonical,
file_routes::route_count_for_files,
get_custom_shorthand_names, sheet_to_classname,
- style_selector::{AtRuleKind, StyleSelector, get_selector_order, global_selector_order},
- theme_tokens::set_theme_token_levels,
+ style_selector::{
+ AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule,
+ },
+ theme_tokens::{set_theme_token_levels, set_typography_keys},
utils::compile_regex,
write_merge_selector,
};
@@ -31,43 +34,50 @@ macro_rules! push_fmt {
}};
}
-/// Shared 5-field comparator for the decorate-sort-undecorate keyed tuples in
-/// `create_style_with_layers`. Both the `global_keyed` (`bool` first field) and
-/// `keyed` (`u8` first field) buckets share the shape
-/// `(K, order: u8, selector_str: &str, prop: &StyleSheetProperty)` and sort by the
-/// exact same chain: precomputed order key first (`bool`/`u8` both compare via
-/// `Ord`), then selector string, then property, then value. Extracted so the two
-/// call sites cannot drift; produces byte-identical ordering to the former inline
-/// closures.
-fn keyed_prop_cmp(
- a: &(K, u8, &str, &StyleSheetProperty),
- b: &(K, u8, &str, &StyleSheetProperty),
-) -> std::cmp::Ordering {
- a.0.cmp(&b.0)
- .then_with(|| a.1.cmp(&b.1))
- .then_with(|| a.2.cmp(b.2))
- .then_with(|| a.3.property.cmp(&b.3.property))
- .then_with(|| a.3.value.cmp(&b.3.value))
+/// Plain rules first, then pseudo selectors in `SELECTOR_ORDER`.
+fn selector_group(selector: Option<&str>) -> (u8, u8) {
+ selector.map_or((0, 0), |selector| (1, get_selector_order(selector)))
}
-fn global_prop_key(prop: &StyleSheetProperty) -> (bool, u8, &str, &StyleSheetProperty) {
- match &prop.selector {
- Some(StyleSelector::Global(selector, _)) => {
- if let Some(i) = selector.find(':') {
- (
- true,
- global_selector_order(&selector[i..]),
- selector.as_str(),
- prop,
- )
- } else {
- (false, 0u8, selector.as_str(), prop)
- }
+/// Typography preset declarations come first so a declaration written directly
+/// on the same selector wins, and each preset stays contiguous so its rules merge.
+fn prop_cmp(a: &StyleSheetProperty, b: &StyleSheetProperty) -> std::cmp::Ordering {
+ b.typography.cmp(&a.typography).then_with(|| {
+ if a.typography {
+ a.class_name
+ .cmp(&b.class_name)
+ .then_with(|| a.property.cmp(&b.property))
+ .then_with(|| a.value.cmp(&b.value))
+ } else {
+ a.property
+ .cmp(&b.property)
+ .then_with(|| a.value.cmp(&b.value))
+ .then_with(|| a.class_name.cmp(&b.class_name))
}
- _ => (false, 0u8, "", prop),
- }
+ })
}
+/// (selector group, level, selector)
+type RuleOrder<'a> = ((u8, u8), u8, &'a str);
+/// (enclosing at-rules outermost first, selector group, level, selector)
+type AtRuleOrder<'a> = (Vec<(u8, (u8, i64), &'a str)>, (u8, u8), u8, &'a str);
+
+/// The blocks a rule sits in: its breakpoint level and at-rule chain.
+#[derive(Clone, Copy, PartialEq, Eq)]
+struct Wrapper<'a> {
+ level: u8,
+ at_rule: Option<(&'a [AtRule], AtRuleKind, &'a str)>,
+}
+
+/// Global selectors without a pseudo part first, then by `SELECTOR_ORDER`.
+fn global_selector_group(selector: &str) -> (bool, u8) {
+ selector.find(':').map_or((false, 0), |i| {
+ (true, global_selector_order(&selector[i..]))
+ })
+}
+
+type GlobalProp<'a> = (u8, &'a str, &'a StyleSheetProperty);
+
#[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct StyleSheetProperty {
@@ -82,6 +92,9 @@ pub struct StyleSheetProperty {
/// CSS layer name (from vanilla-extract `layer()`)
#[serde(rename = "l", skip_serializing_if = "Option::is_none")]
pub layer: Option,
+ /// Declaration expanded from a conditional `typography` preset
+ #[serde(rename = "t", default, skip_serializing_if = "std::ops::Not::not")]
+ pub typography: bool,
}
#[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)]
@@ -117,13 +130,14 @@ impl Ord for StyleSheetProperty {
}
impl StyleSheetProperty {
- fn write_extract(&self, css: &mut String) {
- write_merge_selector(css, &self.class_name, self.selector.as_ref());
- css.push('{');
+ fn same_rule(&self, other: &Self) -> bool {
+ self.class_name == other.class_name && self.selector == other.selector
+ }
+
+ fn write_declaration(&self, css: &mut String) {
css.push_str(&self.property);
css.push(':');
css.push_str(&convert_theme_variable_value(&self.value));
- css.push('}');
}
}
@@ -291,9 +305,37 @@ impl StyleSheet {
style_order: Option,
filename: Option<&str>,
layer: Option<&str>,
+ ) -> bool {
+ self.insert_property(
+ level,
+ style_order,
+ filename,
+ StyleSheetProperty {
+ class_name: class_name.to_string(),
+ property: property.to_string(),
+ value: value.to_string(),
+ selector: selector.cloned(),
+ layer: layer.map(ToString::to_string),
+ typography: false,
+ },
+ )
+ }
+
+ fn insert_property(
+ &mut self,
+ level: u8,
+ style_order: Option,
+ filename: Option<&str>,
+ prop: StyleSheetProperty,
) -> bool {
// register global css file for cache
- if let Some(StyleSelector::Global(_, file)) = selector {
+ if let Some(
+ StyleSelector::Global(_, file)
+ | StyleSelector::At {
+ file: Some(file), ..
+ },
+ ) = &prop.selector
+ {
// Probe with the borrowed `&str` first so the owned `String` is only
// allocated on first registration, not on repeat (HMR/multi-property) calls.
// Matches the borrow-probe-first pattern in add_import/add_font_face/add_css.
@@ -314,13 +356,7 @@ impl StyleSheet {
.or_default()
.entry(level)
.or_default()
- .insert(StyleSheetProperty {
- class_name: class_name.to_string(),
- property: property.to_string(),
- value: value.to_string(),
- selector: selector.cloned(),
- layer: layer.map(ToString::to_string),
- })
+ .insert(prop)
}
pub fn add_import(&mut self, file: &str, import: &str) {
@@ -414,12 +450,11 @@ impl StyleSheet {
let bucket_empty = if let Some(prop_map) = self.properties.get_mut(&property_key) {
for map in prop_map.values_mut() {
for props in map.values_mut() {
- props.retain(|prop| {
- if let Some(StyleSelector::Global(_, f)) = prop.selector.as_ref() {
- f != file
- } else {
- true
- }
+ props.retain(|prop| match prop.selector.as_ref() {
+ Some(
+ StyleSelector::Global(_, f) | StyleSelector::At { file: Some(f), .. },
+ ) => f != file,
+ _ => true,
});
}
// remove empty map
@@ -443,6 +478,7 @@ impl StyleSheet {
theme.get_length_token_levels(),
theme.get_shadow_token_levels(),
);
+ set_typography_keys(theme.typography.keys().cloned().collect());
self.theme = theme;
}
@@ -466,6 +502,34 @@ impl StyleSheet {
let bucket_scope = if single_css { None } else { Some(filename) };
for style in styles {
match style {
+ // A conditional `typography` preset: its class is the atom, and the
+ // preset's declarations are emitted under the atom's selector.
+ ExtractStyleValue::Static(st) if st.property() == "typography" => {
+ let (StyleProperty::ClassName(class_name)
+ | StyleProperty::Variable { class_name, .. }) = st.extract(name_scope);
+ for (level, property, value) in
+ self.theme.typography_declarations(st.value(), st.level())
+ {
+ if self.insert_property(
+ level,
+ st.style_order(),
+ bucket_scope,
+ StyleSheetProperty {
+ class_name: class_name.clone(),
+ property: property.to_string(),
+ value,
+ selector: st.selector().cloned(),
+ layer: st.layer().map(ToString::to_string),
+ typography: true,
+ },
+ ) {
+ collected = true;
+ if st.style_order() == Some(0) {
+ updated_base_style = true;
+ }
+ }
+ }
+ }
ExtractStyleValue::Static(st) => {
let is_first_value =
st.theme_token_resolution() == ThemeTokenResolution::FirstValue;
@@ -744,245 +808,232 @@ impl StyleSheet {
fn create_style_with_layers>(
&self,
map: &BTreeMap>,
- mut layered_styles: Option<&mut LayeredStyles>,
+ layered_styles: Option<&mut LayeredStyles>,
) -> String {
// Estimate ~64 bytes per property for pre-allocation
let prop_count: usize = map.values().map(FxHashSet::len).sum();
let mut current_css = String::with_capacity(prop_count * 64);
+ let mut class_rules: Vec<(RuleOrder<'_>, &StyleSheetProperty)> =
+ Vec::with_capacity(prop_count);
+ let mut at_rules: Vec<(AtRuleOrder<'_>, Wrapper<'_>, &StyleSheetProperty)> = Vec::new();
+ let mut global_props: Vec> = Vec::new();
for (level, props) in map {
- // Single pass bucketing: the two prior `partition` calls always
- // heap-allocated all four output `Vec`s even when the Global/At
- // buckets stay empty (the common case). One scan pushing each
- // `&prop` into one of three pre-declared `Vec`s leaves empty buckets
- // as non-allocating `Vec::new()` and avoids the second full scan.
- let mut global_props: Vec<_> = Vec::new();
- let mut at_rules: Vec<_> = Vec::new();
- // Plain (non-Global, non-At) selectors dominate: presize this bucket
- // to `props.len()` to avoid its 1→4→8→… grow-reallocs. `global_props`
- // / `at_rules` stay `Vec::new()` so empty buckets never allocate.
- let mut sorted_props: Vec<&StyleSheetProperty> = Vec::with_capacity(props.len());
for prop in props {
let prop: &StyleSheetProperty = prop.borrow();
- match prop.selector {
- Some(StyleSelector::Global(_, _)) => global_props.push(prop),
- Some(StyleSelector::At { .. }) => at_rules.push(prop),
- _ => sorted_props.push(prop),
- }
- }
- // Decorate-sort-undecorate for the Global bucket, mirroring the
- // plain-selector `keyed` sort below. `global_props.sort()` compares via
- // `StyleSheetProperty::cmp` → `StyleSelector::cmp`'s Global arm, which for
- // EACH comparison re-runs `global_selector_order` (a `SELECTOR_ORDER` table
- // walk) on BOTH operands — O(n log n) redundant re-scans of the same
- // selector strings. Compute each prop's Global order key ONCE. The key
- // `(has_colon, order, selector_str, property, value)` reproduces the Global
- // arm byte-for-byte: equal selector strings ⇒ `Ordering::Equal` there, so
- // `StyleSheetProperty::cmp` falls through to (property, value); no-colon
- // props (`has_colon=false`) sort before colon props and tie on the selector
- // string; colon props sort by (order, selector string).
- // The common level has no Global selectors, so `global_props` is empty.
- // Skip the three vector materializations (keyed `with_capacity`, `extend`,
- // sorted `collect`) entirely in that case, mirroring the `at_rules.is_empty()`
- // guard below; only decorate-sort-undecorate when there is actually work.
- // An empty sort/collect yields an empty vec anyway, so output is byte-identical.
- let global_props: Vec<&StyleSheetProperty> = if global_props.is_empty() {
- Vec::new()
- } else {
- let mut global_keyed: Vec<(bool, u8, &str, &StyleSheetProperty)> =
- Vec::with_capacity(global_props.len());
- global_keyed.extend(global_props.into_iter().map(global_prop_key));
- global_keyed.sort_by(keyed_prop_cmp);
- global_keyed
- .into_iter()
- .map(|(_, _, _, prop)| prop)
- .collect()
- };
- // Decorate-sort-undecorate for the plain-selector bucket: sorting via
- // `StyleSheetProperty::cmp` re-runs `get_selector_order` (a byte scan +
- // linear `SELECTOR_ORDER` probe) on BOTH operands of every comparison,
- // i.e. O(n log n) redundant scans of the same `Selector` strings. Here
- // `sorted_props` holds only `None`/`Selector(_)` variants (Global/At were
- // filtered out above), so compute each prop's order key ONCE into a keyed
- // vec and sort that. The key `(is_some, order, selector_str, property,
- // value)` reproduces `StyleSheetProperty::cmp` byte-for-byte: `None` props
- // (is_some=0, order=0, selector_str="") sort by (property, value); `Selector`
- // props (is_some=1) sort by (order, selector_str, property, value) — matching
- // `StyleSelector::cmp`'s `get_selector_order` then string tie-break.
- // The common level often has ONLY global/at props, leaving `sorted_props`
- // empty. Skip the `with_capacity`/`extend`/`sort_by` triple entirely in that
- // case, mirroring the `global_props`/`at_rules` guards above and below; an
- // empty build+sort yields an empty vec anyway, so output is byte-identical.
- let keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> = if sorted_props.is_empty() {
- Vec::new()
- } else {
- let mut keyed: Vec<(u8, u8, &str, &StyleSheetProperty)> =
- Vec::with_capacity(sorted_props.len());
- keyed.extend(sorted_props.into_iter().map(|prop| match &prop.selector {
- Some(StyleSelector::Selector(s)) => {
- (1u8, get_selector_order(s), s.as_str(), prop)
+ match &prop.selector {
+ Some(StyleSelector::Global(selector, _)) => {
+ global_props.push((*level, selector.as_str(), prop));
}
- _ => (0u8, 0u8, "", prop),
- }));
- keyed.sort_by(keyed_prop_cmp);
- keyed
- };
- // The common level has no `@`-rule atoms, so `at_rules` is empty.
- // Skip the `sort()` no-op and the per-level `BTreeMap` construction
- // entirely in that case; only regroup when there is actually work.
- let at_rules: BTreeMap<(AtRuleKind, &String), Vec<_>> = if at_rules.is_empty() {
- BTreeMap::new()
- } else {
- at_rules.sort();
- let mut map: BTreeMap<(AtRuleKind, &String), Vec<_>> = BTreeMap::new();
- for prop in at_rules {
- if let Some(StyleSelector::At { kind, query, .. }) = &prop.selector {
- map.entry((*kind, query)).or_default().push(prop);
+ Some(StyleSelector::At {
+ kind,
+ query,
+ selector,
+ outer,
+ ..
+ }) => {
+ let chain = outer
+ .iter()
+ .map(|rule| (rule.kind, rule.query.as_str()))
+ .chain(std::iter::once((*kind, query.as_str())))
+ .map(|(kind, query)| (kind as u8, query_order(kind, query), query))
+ .collect();
+ let selector = selector.as_deref();
+ at_rules.push((
+ (
+ chain,
+ selector_group(selector),
+ *level,
+ selector.unwrap_or(""),
+ ),
+ Wrapper {
+ level: *level,
+ at_rule: Some((outer.as_slice(), *kind, query.as_str())),
+ },
+ prop,
+ ));
}
+ Some(StyleSelector::Selector(selector)) => class_rules.push((
+ (selector_group(Some(selector)), *level, selector.as_str()),
+ prop,
+ )),
+ None => class_rules.push(((selector_group(None), *level, ""), prop)),
}
- map
- };
+ }
+ }
+ if !global_props.is_empty() {
+ self.write_global_props(&mut current_css, global_props, layered_styles);
+ }
- let break_point = if *level == 0 {
- None
- } else {
- Some(
- self.theme
- .breakpoints
- .get(*level as usize)
- .copied()
- .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0)),
+ // Selector group (plain, then `SELECTOR_ORDER`) sorts before the breakpoint
+ // level, so `:active` still follows a `:hover` set at a wider breakpoint while
+ // one selector's responsive values keep ascending. At-rules follow every plain
+ // rule so a condition beats the breakpoint values it overrides; among
+ // themselves they order by condition, then selector group, then level.
+ class_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.1, b.1)));
+ at_rules.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| prop_cmp(a.2, b.2)));
+
+ let mut open: Option<(Wrapper<'_>, Option)> = None;
+ let mut open_rule: Option<&StyleSheetProperty> = None;
+ for (wrapper, prop) in class_rules
+ .iter()
+ .map(|((_, level, _), prop)| {
+ (
+ Wrapper {
+ level: *level,
+ at_rule: None,
+ },
+ *prop,
)
- };
-
- if !global_props.is_empty() {
- // Separate layered and non-layered global props. Only pay the
- // partition + clone-into-map cost when the caller actually
- // consumes the layered output (base/global path); callers that
- // discard layers pass `None` and keep every global prop inline.
- let non_layered_props = if let Some(layered_styles) = layered_styles.as_deref_mut()
- {
- let (layered_props, non_layered_props): (Vec<_>, Vec<_>) = global_props
- .into_iter()
- .partition(|prop| prop.layer.is_some());
-
- // Collect layered props for later processing
- for prop in layered_props {
- if let Some(layer) = &prop.layer
- && let Some(StyleSelector::Global(selector, _)) = &prop.selector
- {
- // Borrow-probe existing entry by layer.as_str() before cloning
- let bucket = match layered_styles.get_mut(layer.as_str()) {
- Some(bucket) => bucket,
- None => layered_styles.entry(layer.clone()).or_default(),
- };
- bucket.push((
- selector.clone(),
- prop.property.clone(),
- prop.value.clone(),
- ));
- }
- }
- non_layered_props
+ })
+ .chain(at_rules.iter().map(|(_, wrapper, prop)| (*wrapper, *prop)))
+ {
+ if open.as_ref().is_none_or(|(current, _)| *current != wrapper) {
+ if open_rule.take().is_some() {
+ current_css.push('}');
+ }
+ if let Some((_, Some(depth))) = open {
+ current_css.extend(std::iter::repeat_n('}', depth));
+ }
+ let depth = self.open_wrapper(&mut current_css, wrapper);
+ open = Some((wrapper, depth));
+ }
+ if let Some((_, Some(_))) = open {
+ if open_rule.is_some_and(|rule| rule.same_rule(prop)) {
+ current_css.push(';');
} else {
- global_props
- };
-
- // Process non-layered global props as before
- if !non_layered_props.is_empty() {
- let mut selector_map: BTreeMap<_, Vec<_>> = BTreeMap::new();
- for prop in non_layered_props {
- if let Some(StyleSelector::Global(selector, _)) = &prop.selector {
- selector_map
- .entry(selector)
- .or_insert_with(|| Vec::with_capacity(1))
- .push(prop);
- }
- }
- if let Some(break_point) = break_point {
- push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{");
- }
- for (selector, props) in selector_map {
- current_css.push_str(selector);
- current_css.push('{');
- let mut first = true;
- for prop in props {
- if !first {
- current_css.push(';');
- }
- first = false;
- current_css.push_str(&prop.property);
- current_css.push(':');
- current_css.push_str(&prop.value);
- }
- current_css.push('}');
- }
- if break_point.is_some() {
+ if open_rule.is_some() {
current_css.push('}');
}
+ write_merge_selector(
+ &mut current_css,
+ &prop.class_name,
+ prop.selector.as_ref(),
+ );
+ current_css.push('{');
+ open_rule = Some(prop);
+ }
+ prop.write_declaration(&mut current_css);
+ }
+ }
+ if open_rule.is_some() {
+ current_css.push('}');
+ }
+ if let Some((_, Some(depth))) = open {
+ current_css.extend(std::iter::repeat_n('}', depth));
+ }
+ current_css
+ }
+
+ fn break_point(&self, level: u8) -> u16 {
+ self.theme
+ .breakpoints
+ .get(level as usize)
+ .copied()
+ .unwrap_or_else(|| self.theme.breakpoints.last().copied().unwrap_or(0))
+ }
+
+ /// Open the blocks `wrapper` needs and return how many to close, or `None`
+ /// when the breakpoint and the media query can never hold together.
+ fn open_wrapper(&self, css: &mut String, wrapper: Wrapper<'_>) -> Option {
+ let mut rules: Vec<(AtRuleKind, Cow<'_, str>)> =
+ wrapper
+ .at_rule
+ .map_or_else(Vec::new, |(outer, kind, query)| {
+ outer
+ .iter()
+ .map(|rule| (rule.kind, Cow::Borrowed(rule.query.as_str())))
+ .chain(std::iter::once((kind, Cow::Borrowed(query))))
+ .collect()
+ });
+ if wrapper.level > 0 {
+ let bp_query = format!("(min-width:{}px)", self.break_point(wrapper.level));
+ // The breakpoint folds into the outermost `@media` when one query can
+ // express both; otherwise it wraps the whole chain.
+ let combined = match rules.first() {
+ Some((AtRuleKind::Media, query)) => combine_media_queries(&bp_query, query),
+ _ => MediaCombination::Nest,
+ };
+ match combined {
+ MediaCombination::Merged(merged) => rules[0].1 = Cow::Owned(merged),
+ MediaCombination::Never => return None,
+ MediaCombination::Nest => {
+ rules.insert(0, (AtRuleKind::Media, Cow::Owned(bp_query)));
}
}
+ }
+ for (kind, query) in &rules {
+ let _ = write_at_rule(css, *kind, query);
+ css.push('{');
+ }
+ Some(rules.len())
+ }
- if !keyed.is_empty() {
- if let Some(break_point) = break_point {
- push_fmt!(&mut current_css, "@media(min-width:{break_point}px){{");
+ fn write_global_props(
+ &self,
+ css: &mut String,
+ mut global_props: Vec>,
+ mut layered_styles: Option<&mut LayeredStyles>,
+ ) {
+ // Same order as class rules: selector group before breakpoint level, so a
+ // `:hover` set at a wider breakpoint still precedes `:active`.
+ global_props.sort_by(|a, b| {
+ global_selector_group(a.1)
+ .cmp(&global_selector_group(b.1))
+ .then_with(|| a.0.cmp(&b.0))
+ .then_with(|| a.1.cmp(b.1))
+ .then_with(|| a.2.property.cmp(&b.2.property))
+ .then_with(|| a.2.value.cmp(&b.2.value))
+ });
+
+ let mut open_level: Option = None;
+ let mut open_selector: Option<&str> = None;
+ for (level, selector, prop) in global_props {
+ if let (Some(layered_styles), Some(layer)) =
+ (layered_styles.as_deref_mut(), prop.layer.as_ref())
+ {
+ let bucket = match layered_styles.get_mut(layer.as_str()) {
+ Some(bucket) => bucket,
+ None => layered_styles.entry(layer.clone()).or_default(),
+ };
+ bucket.push((
+ selector.to_string(),
+ prop.property.clone(),
+ prop.value.clone(),
+ ));
+ continue;
+ }
+ if open_level != Some(level) {
+ if open_selector.take().is_some() {
+ css.push('}');
}
- for (_, _, _, prop) in &keyed {
- prop.write_extract(&mut current_css);
+ if open_level.is_some_and(|open| open > 0) {
+ css.push('}');
}
- if break_point.is_some() {
- current_css.push('}');
+ if level > 0 {
+ push_fmt!(css, "@media(min-width:{}px){{", self.break_point(level));
}
+ open_level = Some(level);
}
- for ((kind, query), props) in at_rules {
- if let Some(break_point) = break_point {
- match kind {
- AtRuleKind::Media => {
- push_fmt!(
- &mut current_css,
- "@media(min-width:{break_point}px)and {query}{{"
- );
- }
- AtRuleKind::Supports => {
- push_fmt!(
- &mut current_css,
- "@media(min-width:{break_point}px){{@supports{query}{{"
- );
- }
- AtRuleKind::Container => {
- push_fmt!(
- &mut current_css,
- "@media(min-width:{break_point}px){{@container{query}{{"
- );
- }
- AtRuleKind::Layer => {
- push_fmt!(
- &mut current_css,
- "@media(min-width:{break_point}px){{@layer {query}{{"
- );
- }
- }
- for prop in props {
- prop.write_extract(&mut current_css);
- }
- match kind {
- AtRuleKind::Media => current_css.push('}'),
- _ => current_css.push_str("}}"),
- }
- } else {
- push_fmt!(&mut current_css, "@{kind}");
- if query.starts_with('(') {
- push_fmt!(&mut current_css, "{query}{{");
- } else {
- push_fmt!(&mut current_css, " {query}{{");
- }
- for prop in props {
- prop.write_extract(&mut current_css);
- }
- current_css.push('}');
+ if open_selector == Some(selector) {
+ css.push(';');
+ } else {
+ if open_selector.is_some() {
+ css.push('}');
}
+ css.push_str(selector);
+ css.push('{');
+ open_selector = Some(selector);
}
+ css.push_str(&prop.property);
+ css.push(':');
+ css.push_str(&prop.value);
+ }
+ if open_selector.is_some() {
+ css.push('}');
+ }
+ if open_level.is_some_and(|open| open > 0) {
+ css.push('}');
}
- current_css
}
#[inline]
@@ -1155,30 +1206,22 @@ impl StyleSheet {
// Generate styles wrapped in @layer blocks
for (layer_name, styles) in layered_styles {
- // Group by selector
- let mut selector_map: BTreeMap> = BTreeMap::new();
- for (selector, property, value) in styles {
- selector_map
- .entry(selector)
- .or_default()
- .push((property, value));
- }
-
push_fmt!(&mut css, "@layer {layer_name}{{");
- for (selector, props) in selector_map {
- css.push_str(&selector);
- css.push('{');
- let mut first = true;
- for (p, v) in props {
- if !first {
- css.push(';');
+ let mut open_selector: Option<&str> = None;
+ for (selector, p, v) in &styles {
+ if open_selector == Some(selector.as_str()) {
+ css.push(';');
+ } else {
+ if open_selector.is_some() {
+ css.push('}');
}
- first = false;
- push_fmt!(&mut css, "{p}:{v}");
+ css.push_str(selector);
+ css.push('{');
+ open_selector = Some(selector);
}
- css.push('}');
+ push_fmt!(&mut css, "{p}:{v}");
}
- css.push('}');
+ css.push_str("}}");
}
}
// Atom hoisting: emit shared (hoisted) order!=0 atoms into the global
@@ -1412,6 +1455,8 @@ mod tests {
kind: AtRuleKind::Media,
query: "(hover:hover)".to_string(),
selector: None,
+ outer: vec![],
+ file: None,
};
sheet.add_property(
"atr",
@@ -2198,14 +2243,14 @@ mod tests {
}
#[test]
- fn test_speech_selector() {
+ fn test_motion_reduce_selector() {
let mut sheet = StyleSheet::default();
sheet.add_property(
"test",
"display",
0,
"none",
- Some(&"speech".into()),
+ Some(&"motion-reduce".into()),
None,
None,
);
@@ -2241,6 +2286,8 @@ mod tests {
kind: AtRuleKind::Media,
query: "(min-width: 1024px)".to_string(),
selector: Some("&:hover".to_string()),
+ outer: vec![],
+ file: None,
}),
None,
None,
@@ -2254,6 +2301,8 @@ mod tests {
kind: AtRuleKind::Media,
query: "(min-width: 1024px)".to_string(),
selector: Some("&:hover".to_string()),
+ outer: vec![],
+ file: None,
}),
None,
None,
@@ -2274,6 +2323,8 @@ mod tests {
kind: AtRuleKind::Supports,
query: "(display: grid)".to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}),
None,
None,
@@ -2294,6 +2345,8 @@ mod tests {
kind: AtRuleKind::Container,
query: "(min-width: 768px)".to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}),
None,
None,
@@ -2868,6 +2921,31 @@ mod tests {
assert_debug_snapshot!(css.split("*/").nth(1).unwrap());
}
+ #[test]
+ fn test_custom_layer_keeps_selector_order() {
+ let mut sheet = StyleSheet::default();
+ for (selector, value) in [("a:active", "blue"), ("a:hover", "red"), ("a", "black")] {
+ sheet.add_property_with_layer(
+ "a",
+ "color",
+ 0,
+ value,
+ Some(&StyleSelector::Global(
+ selector.to_string(),
+ "links.css.ts".to_string(),
+ )),
+ Some(0),
+ None,
+ Some("ui"),
+ );
+ }
+ let css = sheet.create_css(None, false);
+ assert!(
+ css.contains("@layer ui{a{color:black}a:hover{color:red}a:active{color:blue}}"),
+ "{css}"
+ );
+ }
+
#[test]
fn test_at_rules_with_breakpoints() {
let mut sheet = StyleSheet::default();
@@ -2881,6 +2959,8 @@ mod tests {
kind: AtRuleKind::Supports,
query: "(display: grid)".to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}),
Some(0),
None,
@@ -2904,6 +2984,8 @@ mod tests {
kind: AtRuleKind::Container,
query: "(min-width: 400px)".to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}),
Some(0),
None,
@@ -2945,6 +3027,8 @@ mod tests {
kind: AtRuleKind::Layer,
query: "components".to_string(),
selector: None,
+ outer: vec![],
+ file: None,
}),
Some(0),
None,
@@ -2975,6 +3059,7 @@ mod tests {
value: value.to_string(),
selector: None,
layer: None,
+ typography: false,
};
assert_eq!(make("color", "red").cmp(&make("color", "red")), Equal);
assert!(make("color", "red") < make("color", "white"));
@@ -2991,6 +3076,7 @@ mod tests {
value: value.to_string(),
selector,
layer: None,
+ typography: false,
};
let hover = || Some(StyleSelector::Selector("&:hover".to_string()));
@@ -3008,29 +3094,11 @@ mod tests {
}
#[test]
- fn test_global_prop_key_variants() {
- let make = |selector: Option| StyleSheetProperty {
- class_name: "a".to_string(),
- property: "color".to_string(),
- value: "red".to_string(),
- selector,
- layer: None,
- };
- let pseudo = make(Some(StyleSelector::Global(
- "a:hover".to_string(),
- "test.tsx".to_string(),
- )));
- let plain = make(Some(StyleSelector::Global(
- "body".to_string(),
- "test.tsx".to_string(),
- )));
- let local = make(Some(StyleSelector::Selector("&:hover".to_string())));
-
- assert!(global_prop_key(&pseudo).0);
- assert_eq!(global_prop_key(&pseudo).2, "a:hover");
- assert!(!global_prop_key(&plain).0);
- assert_eq!(global_prop_key(&plain).2, "body");
- assert_eq!(global_prop_key(&local).2, "");
+ fn test_global_selector_group() {
+ assert_eq!(global_selector_group("body"), (false, 0));
+ assert_eq!(global_selector_group("a:hover"), (true, 0));
+ assert_eq!(global_selector_group("a:active"), (true, 3));
+ assert_eq!(global_selector_group(":root"), (true, 0));
}
#[test]
@@ -3196,6 +3264,269 @@ mod tests {
);
}
+ fn pipeline_css(theme: Theme, source: &str) -> String {
+ css::debug::set_debug(false);
+ css::set_prefix(None);
+ css::atom_hoist::set_atom_hoist(None);
+ reset_class_map();
+ reset_file_map();
+ let mut sheet = StyleSheet::default();
+ sheet.set_theme(theme);
+ let output = extract(
+ "test.tsx",
+ &format!("import {{Box,css,styled,globalCss}} from '@devup-ui/core';\n{source}"),
+ ExtractOption {
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
+ single_css: true,
+ import_main_css: false,
+ import_aliases: std::collections::HashMap::new(),
+ },
+ )
+ .unwrap();
+ sheet.update_styles(&output.styles, "test.tsx", true);
+ // Class names come from a process-wide counter; number them by first
+ // appearance so the expected CSS only pins down structure and order.
+ let mut names: Vec = vec![];
+ compile_regex(r"\.([A-Za-z_][\w-]*)")
+ .replace_all(
+ sheet.create_css(None, false).split("*/").nth(1).unwrap(),
+ |caps: ®ex_lite::Captures| {
+ let index = names.iter().position(|n| *n == caps[1]).unwrap_or_else(|| {
+ names.push(caps[1].to_string());
+ names.len() - 1
+ });
+ format!(".c{index}")
+ },
+ )
+ .into_owned()
+ }
+
+ #[test]
+ #[serial]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn test_at_rule_pipeline() {
+ for (source, expected) in [
+ // Conditions (at-rules) win over breakpoint values; pseudo states keep
+ // `SELECTOR_ORDER` across breakpoints.
+ (
+ "",
+ ".c0{background:red}@media(min-width:768px){.c1{background:blue}}.c2:hover{background:green}",
+ ),
+ (
+ "",
+ ".c0:hover{background:red}@media(min-width:768px){.c1:hover{background:blue}}.c2:active{background:green}",
+ ),
+ (
+ "",
+ ".c0{transition:opacity .2s}@media(min-width:768px){.c1{transition:all .3s}}@media(prefers-reduced-motion:reduce){.c2{transition:none}}",
+ ),
+ (
+ "",
+ ".c0{background:red}@media(min-width:768px){.c1{background:blue}}@supports(display:grid){.c2{background:green}}",
+ ),
+ (
+ "",
+ "@media(min-width:500px){.c0{padding:4px}}@media(min-width:1000px){.c1{padding:8px}}@media(max-width:900px){.c2{padding:16px}}@media(max-width:300px){.c3{padding:12px}}",
+ ),
+ (
+ "",
+ "@media(prefers-reduced-motion:reduce){.c0:hover{background:b}.c1:active{background:a}}",
+ ),
+ // Nesting in either direction keeps both the condition and the selector.
+ (
+ "",
+ "@media print{.c0:hover{background:red}.c1:focus{color:red}}",
+ ),
+ (
+ "",
+ "@media(prefers-reduced-motion:no-preference){.c0:hover{transform:scale(1.05)}:is([role=group],[data-group]):hover .c1{color:blue}:root[data-theme=dark] .c2{color:red}}",
+ ),
+ (
+ "",
+ "@media(prefers-reduced-motion:reduce){.c0:hover{background:red}.c1::before{content:\"\"}}",
+ ),
+ (
+ "",
+ ":root[data-theme=dark] .c0:hover{background:red}",
+ ),
+ // Nested at-rules fold into one query or nest; exclusive ones are dropped.
+ (
+ "",
+ "@media print and (prefers-reduced-motion:reduce){.c0{background:red}}",
+ ),
+ (
+ "",
+ "@media(prefers-reduced-motion:reduce){@supports(display:grid){.c0{background:red}}}",
+ ),
+ // Breakpoints combine with the query: media type first, lists distribute,
+ // `not print` becomes `screen`, anything else nests.
+ (
+ "",
+ "@media print{.c0{background:red}}@media print and (min-width:768px){.c1{background:blue}}",
+ ),
+ (
+ "",
+ "@media screen and (min-width:768px){.c0{color:red}}@media(min-width:768px)and (orientation:portrait),(min-width:768px)and (hover:none){.c1{background:blue}}",
+ ),
+ (
+ "",
+ "@media(min-width:768px){@media not print and (color){.c0{background:blue}}}",
+ ),
+ (
+ "",
+ "@media(min-width:768px){@supports not (display:grid){.c0{background:blue}}}",
+ ),
+ // Emotion-style object keys and template literals.
+ (
+ "",
+ ".c0:focus{color:red}@media print{.c1{color:blue}.c2:hover{color:green}}",
+ ),
+ (
+ "",
+ ".c0{transition:all .3s}.c1:hover span{color:blue}@media print{.c2:hover{color:red}}@media(prefers-reduced-motion:reduce){.c3{transition:none}}",
+ ),
+ (
+ "",
+ "@media print and (prefers-reduced-motion:reduce){.c0{display:none}}",
+ ),
+ (
+ "globalCss({ '@media (prefers-reduced-motion: reduce)': { '*': { transition: 'none' } }, _print: { body: { bg: 'white', _hover: { color: 'red' } } }, _media: { '(min-width: 768px)': { body: { m: 2 } } }, _screen: { _print: { body: { color: 'red' } } } })",
+ "@layer b;@layer b{@media(min-width:768px){body{margin:8px}}@media print{body{background:white}body:hover{color:red}}@media(prefers-reduced-motion:reduce){*{transition:none}}}",
+ ),
+ (
+ "",
+ ".c0:hover{margin:4px}.c1:focus{margin:4px}@media print{.c2{background:red}}@media(hover:none){.c3{color:red}}",
+ ),
+ // Global pseudo rules keep `SELECTOR_ORDER` across breakpoints.
+ (
+ "globalCss({ a: { _hover: { color: 'red' }, _active: { color: 'blue' }, _focus: { color: 'green' } } })",
+ "@layer b;@layer b{a:hover{color:red}a:focus{color:green}a:active{color:blue}}",
+ ),
+ (
+ "globalCss({ a: { color: 'black', _hover: { color: [null, null, 'red'] }, _active: { color: 'blue' } } })",
+ "@layer b;@layer b{a{color:black}@media(min-width:768px){a:hover{color:red}}a:active{color:blue}}",
+ ),
+ (
+ "globalCss({ a: { color: ['red', null, 'blue'] } })",
+ "@layer b;@layer b{a{color:red}@media(min-width:768px){a{color:blue}}}",
+ ),
+ ] {
+ assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}");
+ }
+ }
+
+ #[test]
+ #[serial]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn test_conditional_typography_pipeline() {
+ for (source, expected) in [
+ (
+ "",
+ "@layer t;.c2:hover{font-size:12px;line-height:1.2}",
+ ),
+ (
+ "",
+ "@layer t;@media(prefers-reduced-motion:reduce){.c2{font-family:var(--heading);font-size:20px;font-weight:700}}@media(min-width:768px)and (prefers-reduced-motion:reduce){.c2{font-size:32px}}",
+ ),
+ (
+ "",
+ "@layer t;@media(min-width:768px){.c2:hover{font-family:var(--heading);font-size:32px;font-weight:700}}",
+ ),
+ (
+ "",
+ "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}",
+ ),
+ (
+ "",
+ "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-family:var(--heading);font-size:20px;font-weight:700}@media(min-width:768px){.c3:hover{font-size:32px}}",
+ ),
+ ("", "@layer t;"),
+ // A declaration written next to the preset wins over the preset's.
+ (
+ "",
+ "@layer t;.c2:hover{font-size:12px;line-height:1.2}.c3:hover{font-size:11px}",
+ ),
+ (
+ "globalCss({ body: { typography: 'small' } })",
+ "@layer b,t;@layer b{body{font-size:12px;line-height:1.2}}",
+ ),
+ ] {
+ let mut theme = Theme::default();
+ theme.add_typography(
+ "small",
+ vec![Some(Typography::new(
+ None,
+ Some("12px".to_string()),
+ None,
+ Some("1.2".to_string()),
+ None,
+ ))],
+ );
+ theme.add_typography(
+ "title",
+ vec![
+ Some(Typography::new(
+ Some("$heading".to_string()),
+ Some("20px".to_string()),
+ Some("700".to_string()),
+ None,
+ Some(" ".to_string()),
+ )),
+ None,
+ Some(Typography::new(
+ None,
+ Some("32px".to_string()),
+ None,
+ None,
+ None,
+ )),
+ ],
+ );
+ let css = pipeline_css(theme, &format!("const size = 'small';\n{source}"));
+ // Drop the theme's own `.typo-*` layer; only the conditional atoms matter here.
+ let start = css.find("@layer t{").unwrap();
+ let mut depth = 0;
+ let end = css[start..]
+ .char_indices()
+ .find_map(|(index, c)| {
+ match c {
+ '{' => depth += 1,
+ '}' => depth -= 1,
+ _ => {}
+ }
+ (c == '}' && depth == 0).then_some(start + index + 1)
+ })
+ .unwrap();
+ assert_eq!(format!("{}{}", &css[..start], &css[end..]), expected);
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_rm_global_css_drops_global_at_rules() {
+ let mut sheet = StyleSheet::default();
+ let output = extract(
+ "global.tsx",
+ "import {globalCss} from '@devup-ui/core';globalCss({ body: { color: 'red', _motionReduce: { transition: 'none' } } })",
+ ExtractOption {
+ package: "@devup-ui/core".to_string(),
+ css_dir: "@devup-ui/core".to_string(),
+ single_css: true,
+ import_main_css: false,
+ import_aliases: std::collections::HashMap::new(),
+ },
+ )
+ .unwrap();
+ sheet.update_styles(&output.styles, "global.tsx", true);
+ assert!(sheet.create_css(None, false).contains("transition:none"));
+
+ assert!(sheet.rm_global_css("global.tsx", true));
+ let css = sheet.create_css(None, false);
+ assert!(!css.contains("transition:none"), "{css}");
+ assert!(!css.contains("color:red"), "{css}");
+ }
+
#[test]
#[serial]
fn test_dynamic_style_important_full_pipeline() {
diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap
index c57eb2315..851fa55b1 100644
--- a/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__create_css-5.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-":is([role=group],[data-group]):focus-visible .test{background:blue}:is([role=group],[data-group]):focus-visible .test{background:red}"
+":is([role=group],[data-group]):focus-visible .test{background:blue;background:red}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap
index 3193a504f..3f9785c49 100644
--- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test-2.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-".test{border:1px solid}.test{border-color:red}"
+".test{border:1px solid;border-color:red}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap
index d9ef15491..6062adb5f 100644
--- a/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__create_css_sort_test.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media(min-width:480px){.test{background:some}.test{background-color:red}}"
+"@media(min-width:480px){.test{background:some;background-color:red}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap
index d9ef15491..6062adb5f 100644
--- a/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__create_css_with_selector_sort_test-3.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media(min-width:480px){.test{background:some}.test{background-color:red}}"
+"@media(min-width:480px){.test{background:some;background-color:red}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap
index 77513dadb..252f09dc8 100644
--- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap
@@ -13,6 +13,7 @@ StyleSheet {
value: "40px",
selector: None,
layer: None,
+ typography: false,
},
},
},
diff --git a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap
similarity index 64%
rename from libs/sheet/src/snapshots/sheet__tests__speech_selector.snap
rename to libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap
index a180ad3b7..4ac3f1f88 100644
--- a/libs/sheet/src/snapshots/sheet__tests__speech_selector.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__motion_reduce_selector.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media speech{.test{display:none}}"
+"@media(prefers-reduced-motion:reduce){.test{display:none}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap
index ca6c61257..88e3e2b12 100644
--- a/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__print_selector-2.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-".test{margin-bottom:40px}.test{margin-top:40px}@media print{.test{margin-left:40px}.test{margin-right:40px}}"
+".test{margin-bottom:40px;margin-top:40px}@media print{.test{margin-left:40px;margin-right:40px}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap
index 053d618c4..0cce6708d 100644
--- a/libs/sheet/src/snapshots/sheet__tests__print_selector.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__print_selector.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}@media(min-width:480px)and print{.test{margin-bottom:40px}.test{margin-left:40px}.test{margin-right:40px}.test{margin-top:40px}}"
+"@media print{.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}@media print and (min-width:480px){.test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap
index 57a93b52f..b19be1fae 100644
--- a/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_prefix.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px}:is([role=group],[data-group]):hover .test{margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px}:is([role=group],[data-group]):hover .test{margin-right:50px}}"
+"@media(min-width:480px){:is([role=group],[data-group]):hover .test{margin-left:40px;margin-right:40px}}@media(min-width:768px){:is([role=group],[data-group]):hover .test{margin-left:50px;margin-right:50px}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap
index b693d37d7..824a6032e 100644
--- a/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__selector_with_query.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media(min-width: 1024px){.test:hover{margin-bottom:40px}.test:hover{margin-top:40px}}"
+"@media(min-width: 1024px){.test:hover{margin-bottom:40px;margin-top:40px}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap
index 2c1121290..c59953840 100644
--- a/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__style_order_create_css.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@layer o1,o2;@layer o1{.test{margin-left:40px}.test{margin-right:40px}@media(min-width:480px){.test{margin-left:40px}.test{margin-left:44px}.test{margin-right:40px}.test{margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px}.test{margin-right:50px}}}.test{margin-left:70px}.test{margin-right:70px}@media(min-width:480px){.test{margin-left:60px}.test{margin-right:60px}}"
+"@layer o1,o2;@layer o1{.test{margin-left:40px;margin-right:40px}@media(min-width:480px){.test{margin-left:40px;margin-left:44px;margin-right:40px;margin-right:44px}}}@layer o2{@media(min-width:480px){.test{margin-left:50px;margin-right:50px}}}.test{margin-left:70px;margin-right:70px}@media(min-width:480px){.test{margin-left:60px;margin-right:60px}}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap
index 1946f076e..f2d00072b 100644
--- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-2.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-".test{margin-left:41px}.test{margin-left:42px}.test{margin-right:41px}.test{margin-right:42px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-left:51px}:root[data-theme=light] .test{margin-right:50px}:root[data-theme=light] .test{margin-right:51px}"
+".test{margin-left:41px;margin-left:42px;margin-right:41px;margin-right:42px}:root[data-theme=light] .test{margin-left:50px;margin-left:51px;margin-right:50px;margin-right:51px}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap
index 771bbc05b..71c4134fb 100644
--- a/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector-3.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-":root[data-theme=light] .test:hover{margin-left:50px}:root[data-theme=light] .test:hover{margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px}:root[data-theme=light] .test:active{margin-right:50px}"
+":root[data-theme=light] .test:hover{margin-left:50px;margin-right:50px}:root[data-theme=light] .test:active{margin-left:50px;margin-right:50px}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap
index db1c61873..b576c552b 100644
--- a/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__theme_selector.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-":root[data-theme=dark] .test{margin-bottom:40px}:root[data-theme=dark] .test{margin-left:40px}:root[data-theme=dark] .test{margin-right:40px}:root[data-theme=dark] .test{margin-top:40px}:root[data-theme=light] .test{margin-left:50px}:root[data-theme=light] .test{margin-right:50px}"
+":root[data-theme=dark] .test{margin-bottom:40px;margin-left:40px;margin-right:40px;margin-top:40px}:root[data-theme=light] .test{margin-left:50px;margin-right:50px}"
diff --git a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap
index a227ce36d..c051ab081 100644
--- a/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap
+++ b/libs/sheet/src/snapshots/sheet__tests__wrong_breakpoint.snap
@@ -2,4 +2,4 @@
source: libs/sheet/src/lib.rs
expression: "sheet.create_css(None, false).split(\"*/\").nth(1).unwrap()"
---
-"@media(min-width:1600px){.test{margin-left:40px}.test{margin-right:40px}}"
+"@media(min-width:1600px){.test{margin-left:40px;margin-right:40px}}"
diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs
index 9d7b11c62..29ff73962 100644
--- a/libs/sheet/src/theme.rs
+++ b/libs/sheet/src/theme.rs
@@ -606,6 +606,51 @@ impl Theme {
default_variant_key(&self.colors).map(str::to_string)
}
+ /// Declarations of typography preset `name` applied from breakpoint `level`
+ /// up: preset entries at or below `level` merge into `level`, wider ones
+ /// keep their own breakpoint. Values resolve like the `.typo-*` classes.
+ #[must_use]
+ pub fn typography_declarations(
+ &self,
+ name: &str,
+ level: u8,
+ ) -> Vec<(u8, &'static str, String)> {
+ let mut declarations: Vec<(u8, &'static str, String)> = vec![];
+ let Some(typography) = self.typography.get(name) else {
+ return declarations;
+ };
+ for (index, entry) in typography.0.iter().enumerate() {
+ let Some(entry) = entry else {
+ continue;
+ };
+ let target = level.max(index as u8);
+ for (property, value) in [
+ ("font-family", &entry.font_family),
+ ("font-size", &entry.font_size),
+ ("font-weight", &entry.font_weight),
+ ("line-height", &entry.line_height),
+ ("letter-spacing", &entry.letter_spacing),
+ ] {
+ let Some(value) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else {
+ continue;
+ };
+ let resolved = value.strip_prefix('$').map_or_else(
+ || optimize_value(value).into_owned(),
+ |token| format!("var(--{token})"),
+ );
+ if let Some(existing) = declarations
+ .iter_mut()
+ .find(|(l, p, _)| *l == target && *p == property)
+ {
+ existing.2 = resolved;
+ } else {
+ declarations.push((target, property, resolved));
+ }
+ }
+ }
+ declarations
+ }
+
#[must_use]
pub fn get_length_token_levels(&self) -> BTreeMap> {
token_levels(&self.length)
diff --git a/packages/bun-plugin/__regression__/css-emit.bun.ts b/packages/bun-plugin/__regression__/css-emit.bun.ts
index 836570bde..dc0e1fa8f 100644
--- a/packages/bun-plugin/__regression__/css-emit.bun.ts
+++ b/packages/bun-plugin/__regression__/css-emit.bun.ts
@@ -68,3 +68,39 @@ await import(cssPath)
}
},
)
+
+it('emits vanilla-extract styles through the full WASM engine', () => {
+ const cwd = mkdtempSync(join(tmpdir(), 'devup-css-emit-'))
+ try {
+ writeFileSync(join(cwd, 'bunfig.toml'), '')
+ writeFileSync(
+ join(cwd, 'styles.css.ts'),
+ `import { style } from '@devup-ui/react'
+export const box = style({ width: '109px' })
+`,
+ )
+ writeFileSync(
+ join(cwd, 'check.ts'),
+ `import { readFileSync } from 'node:fs'
+import { expect } from 'bun:test'
+
+await import(${JSON.stringify(pluginEntry.replaceAll('\\', '/'))})
+const { box } = await import('./styles.css.ts')
+expect(box).toBeTruthy()
+expect(readFileSync('./df/devup-ui/devup-ui.css', 'utf-8')).toContain('width:109px')
+`,
+ )
+ const result = Bun.spawnSync([process.execPath, 'run', 'check.ts'], {
+ cwd,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' },
+ })
+ expect(
+ result.exitCode,
+ result.stdout.toString() + result.stderr.toString(),
+ ).toBe(0)
+ } finally {
+ rmSync(cwd, { recursive: true, force: true })
+ }
+})
diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap
index 98996cba1..2771fb317 100644
--- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap
+++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap
@@ -39,6 +39,23 @@ exports[`recommended export recommended config 1`] = `
},
"name": "no-duplicate-value",
},
+ "no-typography-token-prefix": {
+ "create": [Function: create],
+ "defaultOptions": [],
+ "meta": {
+ "docs": {
+ "description": "Disallow the \`$\` token prefix on typography keys.",
+ "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/no-typography-token-prefix",
+ },
+ "fixable": "code",
+ "messages": {
+ "noTypographyTokenPrefix": "Typography keys take no \`$\` prefix. Use "{{name}}".",
+ },
+ "schema": [],
+ "type": "problem",
+ },
+ "name": "no-typography-token-prefix",
+ },
"no-useless-responsive": {
"create": [Function: create],
"defaultOptions": [],
@@ -73,6 +90,23 @@ exports[`recommended export recommended config 1`] = `
},
"name": "no-useless-tailing-nulls",
},
+ "prefer-media-shorthand": {
+ "create": [Function: create],
+ "defaultOptions": [],
+ "meta": {
+ "docs": {
+ "description": "Prefer media shorthand props over their media queries.",
+ "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/prefer-media-shorthand",
+ },
+ "fixable": "code",
+ "messages": {
+ "preferMediaShorthand": "Use the \`{{shorthand}}\` shorthand for this query.",
+ },
+ "schema": [],
+ "type": "suggestion",
+ },
+ "name": "prefer-media-shorthand",
+ },
"style-order-range": {
"create": [Function: create],
"defaultOptions": [],
@@ -96,8 +130,10 @@ exports[`recommended export recommended config 1`] = `
"rules": {
"@devup-ui/css-utils-literal-only": "error",
"@devup-ui/no-duplicate-value": "error",
+ "@devup-ui/no-typography-token-prefix": "error",
"@devup-ui/no-useless-responsive": "error",
"@devup-ui/no-useless-tailing-nulls": "error",
+ "@devup-ui/prefer-media-shorthand": "warn",
"@devup-ui/style-order-range": "error",
},
},
diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts
index 3827b73a7..b36e20df4 100644
--- a/packages/eslint-plugin/src/configs/recommended.ts
+++ b/packages/eslint-plugin/src/configs/recommended.ts
@@ -1,8 +1,10 @@
import {
cssUtilsLiteralOnly,
noDuplicateValue,
+ noTypographyTokenPrefix,
noUselessResponsive,
noUselessTailingNulls,
+ preferMediaShorthand,
styleOrderRange,
} from '../rules'
@@ -16,6 +18,8 @@ export default [
'no-duplicate-value': noDuplicateValue,
'no-useless-responsive': noUselessResponsive,
'style-order-range': styleOrderRange,
+ 'no-typography-token-prefix': noTypographyTokenPrefix,
+ 'prefer-media-shorthand': preferMediaShorthand,
},
},
},
@@ -25,6 +29,8 @@ export default [
'@devup-ui/no-duplicate-value': 'error',
'@devup-ui/no-useless-responsive': 'error',
'@devup-ui/style-order-range': 'error',
+ '@devup-ui/no-typography-token-prefix': 'error',
+ '@devup-ui/prefer-media-shorthand': 'warn',
},
},
]
diff --git a/packages/eslint-plugin/src/rules/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/__tests__/index.test.ts
index 16aa96261..624966813 100644
--- a/packages/eslint-plugin/src/rules/__tests__/index.test.ts
+++ b/packages/eslint-plugin/src/rules/__tests__/index.test.ts
@@ -10,6 +10,8 @@ describe('export index', () => {
noDuplicateValue: expect.any(Object),
noUselessResponsive: expect.any(Object),
styleOrderRange: expect.any(Object),
+ noTypographyTokenPrefix: expect.any(Object),
+ preferMediaShorthand: expect.any(Object),
})
})
})
diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts
index 671847783..4f12cc9f4 100644
--- a/packages/eslint-plugin/src/rules/index.ts
+++ b/packages/eslint-plugin/src/rules/index.ts
@@ -1,5 +1,7 @@
export * from './css-utils-literal-only'
export * from './no-duplicate-value'
+export * from './no-typography-token-prefix'
export * from './no-useless-responsive'
export * from './no-useless-tailing-nulls'
+export * from './prefer-media-shorthand'
export * from './style-order-range'
diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md
new file mode 100644
index 000000000..a8d91d3a9
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md
@@ -0,0 +1,39 @@
+# no-typography-token-prefix
+
+Disallow the `$` token prefix on `typography` values.
+
+## Rule Details
+
+`typography` takes the bare key of a `theme.typography` entry. Unlike color,
+length, and shadow tokens it has no `$` prefix; `typography="$heading"` names a
+preset that does not exist, so no style is applied.
+
+The rule checks string values of `typography` on Devup UI components and
+utilities, including values inside responsive arrays, conditionals, and
+selector objects.
+
+### Examples
+
+#### ❌ Incorrect
+
+```tsx
+import { Box, css } from '@devup-ui/react'
+
+;
+;
+css({ _hover: { typography: '$title' } })
+```
+
+#### ✅ Correct
+
+```tsx
+import { Box, css } from '@devup-ui/react'
+
+;
+;
+css({ _hover: { typography: 'title' } })
+```
+
+## Auto-fixable
+
+The `$` prefix is removed.
diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts
new file mode 100644
index 000000000..9c0395101
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/__tests__/index.test.ts
@@ -0,0 +1,56 @@
+import { RuleTester } from '@typescript-eslint/rule-tester'
+import { describe } from 'bun:test'
+
+import { noTypographyTokenPrefix } from '../index'
+
+describe('no-typography-token-prefix rule', () => {
+ const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+ })
+ const imports = 'import { Box, css } from "@devup-ui/react";\n'
+ ruleTester.run('no-typography-token-prefix rule', noTypographyTokenPrefix, {
+ valid: [
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}css({ color: '$primary', ['typography']: 1 })` },
+ { code: `${imports}css({ '$heading': 1 })` },
+ { code: `${imports}css({ typography: 1 })` },
+ { code: `import { Box } from "other";\n` },
+ { code: `const a = { typography: '$heading' }` },
+ ],
+ invalid: [
+ {
+ code: `${imports}`,
+ output: `${imports}`,
+ errors: [
+ { messageId: 'noTypographyTokenPrefix', data: { name: 'heading' } },
+ ],
+ },
+ {
+ code: `${imports}`,
+ output: `${imports}`,
+ errors: [
+ { messageId: 'noTypographyTokenPrefix' },
+ { messageId: 'noTypographyTokenPrefix' },
+ ],
+ },
+ {
+ code: `${imports}css({ _hover: { typography: '$title' } })`,
+ output: `${imports}css({ _hover: { typography: 'title' } })`,
+ errors: [{ messageId: 'noTypographyTokenPrefix' }],
+ },
+ {
+ code: `${imports}`,
+ output: `${imports}`,
+ errors: [{ messageId: 'noTypographyTokenPrefix' }],
+ },
+ ],
+ })
+})
diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts
new file mode 100644
index 000000000..31ae5ec2a
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts
@@ -0,0 +1,97 @@
+import {
+ AST_NODE_TYPES,
+ ESLintUtils,
+ type TSESTree,
+} from '@typescript-eslint/utils'
+
+import { ImportStorage } from '../../utils/import-storage'
+import { propertyKeyName } from '../../utils/property-key-name'
+
+const createRule = ESLintUtils.RuleCreator(
+ (name) =>
+ `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`,
+)
+
+/** Whether `literal` is a value of a `typography` prop rather than a condition. */
+function isTypographyValue(
+ child: TSESTree.Node,
+ parent: TSESTree.Node | undefined = child.parent,
+): boolean {
+ switch (parent?.type) {
+ case AST_NODE_TYPES.JSXAttribute:
+ return (
+ parent.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ parent.name.name === 'typography'
+ )
+ case AST_NODE_TYPES.Property:
+ return parent.value === child && propertyKeyName(parent) === 'typography'
+ case AST_NODE_TYPES.ConditionalExpression:
+ return parent.test !== child && isTypographyValue(parent)
+ case AST_NODE_TYPES.JSXExpressionContainer:
+ case AST_NODE_TYPES.ArrayExpression:
+ case AST_NODE_TYPES.LogicalExpression:
+ return isTypographyValue(parent)
+ default:
+ return false
+ }
+}
+
+export const noTypographyTokenPrefix = createRule({
+ name: 'no-typography-token-prefix',
+ defaultOptions: [],
+ meta: {
+ schema: [],
+ messages: {
+ noTypographyTokenPrefix:
+ 'Typography keys take no `$` prefix. Use "{{name}}".',
+ },
+ type: 'problem',
+ fixable: 'code',
+ docs: {
+ description: 'Disallow the `$` token prefix on typography keys.',
+ },
+ },
+ create(context) {
+ const importStorage = new ImportStorage()
+ let devupContext:
+ TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null
+ return {
+ ImportDeclaration(node) {
+ importStorage.addImportByDeclaration(node)
+ },
+ CallExpression(node) {
+ if (!devupContext && importStorage.checkContextType(node) === 'UTIL') {
+ devupContext = node
+ }
+ },
+ 'CallExpression:exit'(node) {
+ if (devupContext === node) devupContext = null
+ },
+ JSXOpeningElement(node) {
+ if (importStorage.checkContextType(node) === 'COMPONENT') {
+ devupContext = node
+ }
+ },
+ 'JSXOpeningElement:exit'(node) {
+ if (devupContext === node) devupContext = null
+ },
+ Literal(node) {
+ if (
+ !devupContext ||
+ typeof node.value !== 'string' ||
+ !node.value.startsWith('$') ||
+ !isTypographyValue(node)
+ )
+ return
+ const name = node.value.slice(1)
+ const quote = node.raw[0]
+ context.report({
+ node,
+ messageId: 'noTypographyTokenPrefix',
+ data: { name },
+ fix: (fixer) => fixer.replaceText(node, `${quote}${name}${quote}`),
+ })
+ },
+ }
+ },
+})
diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md
new file mode 100644
index 000000000..93128c120
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md
@@ -0,0 +1,48 @@
+# prefer-media-shorthand
+
+Prefer the media shorthand props over spelling out the same media query.
+
+## Rule Details
+
+`_media` entries and `'@media …'` keys whose query is exactly one of the
+shorthands are reported. Whitespace and letter case in the query are ignored.
+
+| Query | Shorthand |
+| ---------------------------------------- | ---------------- |
+| `all` | `_all` |
+| `print` | `_print` |
+| `screen` | `_screen` |
+| `(prefers-reduced-motion: reduce)` | `_motionReduce` |
+| `(prefers-reduced-motion: no-preference)` | `_motionSafe` |
+| `(orientation: portrait)` | `_portrait` |
+| `(orientation: landscape)` | `_landscape` |
+| `(prefers-contrast: more)` | `_contrastMore` |
+| `(prefers-contrast: less)` | `_contrastLess` |
+| `(forced-colors: active)` | `_forcedColors` |
+
+### Examples
+
+#### ❌ Incorrect
+
+```tsx
+import { Box, css } from '@devup-ui/react'
+
+;
+css({ '@media print': { color: 'black' } })
+```
+
+#### ✅ Correct
+
+```tsx
+import { Box, css } from '@devup-ui/react'
+
+;
+css({ _print: { color: 'black' } })
+css({ _media: { '(min-width: 500px)': { p: 1 } } })
+```
+
+## Auto-fixable
+
+A `_media` object holding only the matched query, and a static `'@media …'`
+key, are rewritten to the shorthand. A `_media` object with other queries is
+reported without a fix.
diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts
new file mode 100644
index 000000000..df0232e17
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/__tests__/index.test.ts
@@ -0,0 +1,76 @@
+import { RuleTester } from '@typescript-eslint/rule-tester'
+import { describe } from 'bun:test'
+
+import { preferMediaShorthand } from '../index'
+
+describe('prefer-media-shorthand rule', () => {
+ const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 'latest',
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+ })
+ const imports = 'import { Box, css } from "@devup-ui/react";\n'
+ ruleTester.run('prefer-media-shorthand rule', preferMediaShorthand, {
+ valid: [
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}` },
+ { code: `${imports}css({ '@media (hover: none)': { color: 'red' } })` },
+ { code: `${imports}css({ [key]: { color: 'red' }, 1: 2, ...rest })` },
+ {
+ code: `${imports}css({ _media: { [key]: { color: 'red' }, ...rest } })`,
+ },
+ { code: `${imports}css({ '@mediaprint': { color: 'red' } })` },
+ { code: `${imports}css(styles)` },
+ {
+ code: `import { Box } from "other";\n`,
+ },
+ { code: `css({ '@media print': { color: 'red' } })` },
+ ],
+ invalid: [
+ {
+ code: `${imports}`,
+ output: `${imports}`,
+ errors: [
+ {
+ messageId: 'preferMediaShorthand',
+ data: { shorthand: '_motionReduce' },
+ },
+ ],
+ },
+ {
+ code: `${imports}css({ _media: { print: { color: 'red' } } })`,
+ output: `${imports}css({ _print: { color: 'red' } })`,
+ errors: [{ messageId: 'preferMediaShorthand' }],
+ },
+ {
+ code: `${imports}css({ _media: { '(orientation:portrait)': { p: 1 }, '(min-width: 1px)': { p: 2 } } })`,
+ output: null,
+ errors: [
+ {
+ messageId: 'preferMediaShorthand',
+ data: { shorthand: '_portrait' },
+ },
+ ],
+ },
+ {
+ code: `${imports}css({ '@media (forced-colors: active)': { color: 'red' } })`,
+ output: `${imports}css({ _forcedColors: { color: 'red' } })`,
+ errors: [{ messageId: 'preferMediaShorthand' }],
+ },
+ {
+ code: `${imports}css({ ['@media screen']: { color: 'red' } })`,
+ output: null,
+ errors: [{ messageId: 'preferMediaShorthand' }],
+ },
+ {
+ code: `${imports}`,
+ output: `${imports}`,
+ errors: [{ messageId: 'preferMediaShorthand' }],
+ },
+ ],
+ })
+})
diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts
new file mode 100644
index 000000000..c82d25d4a
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts
@@ -0,0 +1,141 @@
+import {
+ AST_NODE_TYPES,
+ ESLintUtils,
+ type TSESTree,
+} from '@typescript-eslint/utils'
+
+import { ImportStorage } from '../../utils/import-storage'
+import { propertyKeyName } from '../../utils/property-key-name'
+
+const createRule = ESLintUtils.RuleCreator(
+ (name) =>
+ `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`,
+)
+
+const MEDIA_SHORTHANDS = new Map([
+ ['all', '_all'],
+ ['print', '_print'],
+ ['screen', '_screen'],
+ ['(prefers-reduced-motion:reduce)', '_motionReduce'],
+ ['(prefers-reduced-motion:no-preference)', '_motionSafe'],
+ ['(orientation:portrait)', '_portrait'],
+ ['(orientation:landscape)', '_landscape'],
+ ['(prefers-contrast:more)', '_contrastMore'],
+ ['(prefers-contrast:less)', '_contrastLess'],
+ ['(forced-colors:active)', '_forcedColors'],
+])
+
+const AT_MEDIA = /^@media(?=[\s(])/
+
+function shorthandFor(query: string): string | undefined {
+ return MEDIA_SHORTHANDS.get(query.replace(/\s+/g, '').toLowerCase())
+}
+
+export const preferMediaShorthand = createRule({
+ name: 'prefer-media-shorthand',
+ defaultOptions: [],
+ meta: {
+ schema: [],
+ messages: {
+ preferMediaShorthand: 'Use the `{{shorthand}}` shorthand for this query.',
+ },
+ type: 'suggestion',
+ fixable: 'code',
+ docs: {
+ description: 'Prefer media shorthand props over their media queries.',
+ },
+ },
+ create(context) {
+ const importStorage = new ImportStorage()
+ let devupContext:
+ TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null
+
+ function checkMediaRecord(
+ owner: TSESTree.Property | TSESTree.JSXAttribute,
+ record: TSESTree.Node,
+ ) {
+ if (record.type !== AST_NODE_TYPES.ObjectExpression) return
+ for (const prop of record.properties) {
+ if (prop.type !== AST_NODE_TYPES.Property) continue
+ const query = propertyKeyName(prop)
+ const shorthand = query === undefined ? undefined : shorthandFor(query)
+ if (!shorthand) continue
+ const value = context.sourceCode.getText(prop.value)
+ context.report({
+ node: prop.key,
+ messageId: 'preferMediaShorthand',
+ data: { shorthand },
+ fix:
+ record.properties.length === 1
+ ? (fixer) =>
+ fixer.replaceText(
+ owner,
+ owner.type === AST_NODE_TYPES.JSXAttribute
+ ? `${shorthand}={${value}}`
+ : `${shorthand}: ${value}`,
+ )
+ : null,
+ })
+ }
+ }
+
+ return {
+ ImportDeclaration(node) {
+ importStorage.addImportByDeclaration(node)
+ },
+ CallExpression(node) {
+ if (
+ !devupContext &&
+ importStorage.checkContextType(node) === 'UTIL' &&
+ node.arguments.length === 1 &&
+ node.arguments[0].type === AST_NODE_TYPES.ObjectExpression
+ ) {
+ devupContext = node
+ }
+ },
+ 'CallExpression:exit'(node) {
+ if (devupContext === node) devupContext = null
+ },
+ JSXOpeningElement(node) {
+ if (importStorage.checkContextType(node) === 'COMPONENT') {
+ devupContext = node
+ }
+ },
+ 'JSXOpeningElement:exit'(node) {
+ if (devupContext === node) devupContext = null
+ },
+ JSXAttribute(node) {
+ if (
+ devupContext &&
+ node.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ node.name.name === '_media' &&
+ node.value?.type === AST_NODE_TYPES.JSXExpressionContainer
+ ) {
+ checkMediaRecord(node, node.value.expression)
+ }
+ },
+ Property(node) {
+ if (!devupContext) return
+ const name = propertyKeyName(node)
+ if (name === undefined) return
+ if (name === '_media') {
+ checkMediaRecord(node, node.value)
+ return
+ }
+ const shorthand = AT_MEDIA.test(name)
+ ? shorthandFor(name.slice('@media'.length))
+ : undefined
+ if (shorthand) {
+ context.report({
+ node: node.key,
+ messageId: 'preferMediaShorthand',
+ data: { shorthand },
+ fix: node.computed
+ ? null
+ : (fixer) => fixer.replaceText(node.key, shorthand),
+ })
+ }
+ },
+ }
+ },
+})
diff --git a/packages/eslint-plugin/src/utils/property-key-name.ts b/packages/eslint-plugin/src/utils/property-key-name.ts
new file mode 100644
index 000000000..41f20250c
--- /dev/null
+++ b/packages/eslint-plugin/src/utils/property-key-name.ts
@@ -0,0 +1,11 @@
+import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'
+
+export function propertyKeyName(node: TSESTree.Property): string | undefined {
+ if (node.key.type === AST_NODE_TYPES.Identifier && !node.computed)
+ return node.key.name
+ if (
+ node.key.type === AST_NODE_TYPES.Literal &&
+ typeof node.key.value === 'string'
+ )
+ return node.key.value
+}
diff --git a/packages/react/AGENTS.md b/packages/react/AGENTS.md
index b9011b401..2807aa5f5 100644
--- a/packages/react/AGENTS.md
+++ b/packages/react/AGENTS.md
@@ -68,7 +68,7 @@ src/
### Theme Tokens
```tsx
// Color token
- // Typography preset
+ // Typography preset
```
## ANTI-PATTERNS
diff --git a/packages/react/src/types/props/__tests__/index.test-d.ts b/packages/react/src/types/props/__tests__/index.test-d.ts
index 309493e4b..32bc57f14 100644
--- a/packages/react/src/types/props/__tests__/index.test-d.ts
+++ b/packages/react/src/types/props/__tests__/index.test-d.ts
@@ -128,6 +128,30 @@ describe('index', () => {
`,
})
})
+ it('media shorthands and at-rule keys', () => {
+ assertType>({
+ _motionReduce: { transition: 'none' },
+ _motionSafe: { _hover: { transform: 'scale(1.05)' } },
+ _portrait: { flexDir: 'column' },
+ _landscape: [{ flexDir: 'row' }, null, { flexDir: 'column' }],
+ _contrastMore: { borderColor: 'black' },
+ _contrastLess: { borderColor: 'gray' },
+ _forcedColors: { borderColor: 'CanvasText' },
+ _print: { display: 'none' },
+ _screen: { display: 'block' },
+ _all: { color: 'black' },
+ '@media (prefers-reduced-motion: reduce)': { transition: 'none' },
+ '@supports (display: grid)': { display: 'grid' },
+ '@container sidebar (min-width: 400px)': { p: 2 },
+ _hover: {
+ _motionReduce: { transform: 'none' },
+ '@media print': { color: 'black' },
+ },
+ })
+
+ expectTypeOf>().not.toHaveProperty('_speech')
+ })
+
it('DevupComponentAdditionalProps', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function Foo({ children: _ }: { children?: string; c: string }) {
diff --git a/packages/react/src/types/props/selector/index.ts b/packages/react/src/types/props/selector/index.ts
index 891a13b1f..81d19ad0c 100644
--- a/packages/react/src/types/props/selector/index.ts
+++ b/packages/react/src/types/props/selector/index.ts
@@ -71,13 +71,30 @@ export type AtRuleRecord = Partial<
Record>
>
+/** Props that wrap styles in a fixed `@media` query, e.g. `_motionReduce`. */
+export type MediaShorthand =
+ | '_print'
+ | '_screen'
+ | '_all'
+ | '_portrait'
+ | '_landscape'
+ | '_motionReduce'
+ | '_motionSafe'
+ | '_contrastMore'
+ | '_contrastLess'
+ | '_forcedColors'
+
+export type MediaShorthandProps = {
+ [K in MediaShorthand]?: SelectorProps
+}
+
+/** At-rule keys carrying their condition, e.g. `'@media print'`. */
+export type AtRuleKey =
+ `@media ${string}` | `@supports ${string}` | `@container ${string}`
+
export interface DevupSelectorProps
- extends SimpleSelectorProps, AdvancedSelectorProps {
- // media query
- _print?: SelectorProps
- _screen?: SelectorProps
- _speech?: SelectorProps
- _all?: SelectorProps
+ extends SimpleSelectorProps, AdvancedSelectorProps, MediaShorthandProps {
+ [key: AtRuleKey]: SelectorProps | undefined
// at-rules (underscore prefix)
_container?: AtRuleRecord
diff --git a/packages/react/src/utils/__tests__/global-css.test-d.ts b/packages/react/src/utils/__tests__/global-css.test-d.ts
index 0be126b4e..5175a9987 100644
--- a/packages/react/src/utils/__tests__/global-css.test-d.ts
+++ b/packages/react/src/utils/__tests__/global-css.test-d.ts
@@ -12,4 +12,17 @@ describe('globalCss', () => {
},
})
})
+
+ it('top-level at-rules', () => {
+ assertType({
+ '@media (prefers-reduced-motion: reduce)': {
+ '*, *::before, *::after': { transition: 'none' },
+ },
+ _motionReduce: { html: { scrollBehavior: 'auto' } },
+ _print: { body: { bg: 'white' } },
+ _media: { '(min-width: 768px)': { body: { m: 2 } } },
+ '@supports': { '(display: grid)': { main: { display: 'grid' } } },
+ body: { _motionReduce: { transition: 'none' } },
+ })
+ })
})
diff --git a/packages/react/src/utils/global-css.ts b/packages/react/src/utils/global-css.ts
index c35d0ad3e..dd86476c8 100644
--- a/packages/react/src/utils/global-css.ts
+++ b/packages/react/src/utils/global-css.ts
@@ -1,10 +1,12 @@
import type { DevupCommonProps } from '../types/props'
import type {
AdvancedSelector,
+ AtRuleKey,
CamelCase,
DevupSelectorProps,
DevupThemeSelectorProps,
ExtractSelector,
+ MediaShorthand,
SimpleSelector,
} from '../types/props/selector'
@@ -36,6 +38,19 @@ export type GlobalCssProps = {
[
K in `${keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap | '.' | '*' | '#' | ':' | '['}${string}`
]?: DevupCommonProps & DevupSelectorProps & DevupThemeSelectorProps
+} & {
+ // Top-level at-rules wrap whole selector maps: `'@media print': { body: … }`.
+ [K in AtRuleKey | MediaShorthand]?: GlobalCssProps
+} & {
+ [
+ K in
+ | '_media'
+ | '_supports'
+ | '_container'
+ | '@media'
+ | '@supports'
+ | '@container'
+ ]?: Record
}
interface FontFaceProps {