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. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorMedia 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 {