Skip to main content

stylex_transform/shared/utils/css/
common.rs

1use crate::shared::utils::common::round_to_decimal_places;
2use crate::shared::utils::css::normalizers::base::base_normalizer;
3use crate::shared::utils::css::validators::unprefixed_custom_properties::unprefixed_custom_properties_validator;
4use crate::shared::{structures::state_manager::StateManager, utils::common::dashify};
5use stylex_constants::constants::common::{
6  COLOR_FUNCTION_LISTED_NORMALIZED_PROPERTY_VALUES,
7  COLOR_RELATIVE_VALUES_LISTED_NORMALIZED_PROPERTY_VALUES, CSS_CONTENT_FUNCTIONS,
8  CSS_CONTENT_KEYWORDS,
9};
10use stylex_constants::constants::long_hand_logical::LONG_HAND_LOGICAL;
11use stylex_constants::constants::long_hand_physical::LONG_HAND_PHYSICAL;
12use stylex_constants::constants::messages::LINT_UNCLOSED_FUNCTION;
13use stylex_constants::constants::number_properties::NUMBER_PROPERTY_SUFFIXIES;
14use stylex_constants::constants::priorities::{
15  AT_RULE_PRIORITIES, CAMEL_CASE_PRIORITIES, PSEUDO_CLASS_PRIORITIES, PSEUDO_ELEMENT_PRIORITY,
16};
17use stylex_constants::constants::shorthands_of_longhands::SHORTHANDS_OF_LONGHANDS;
18use stylex_constants::constants::shorthands_of_shorthands::SHORTHANDS_OF_SHORTHANDS;
19use stylex_constants::constants::unitless_number_properties::UNITLESS_NUMBER_PROPERTIES;
20use stylex_css::css::generate_ltr::generate_ltr;
21use stylex_css::css::generate_rtl::generate_rtl;
22use stylex_css::css::normalizers::whitespace_normalizer::whitespace_normalizer;
23use stylex_regex::regex::{
24  ANCESTOR_SELECTOR, ANY_SIBLING_SELECTOR, CLEAN_CSS_VAR, DESCENDANT_SELECTOR, MANY_SPACES,
25  PSEUDO_PART_REGEX, SIBLING_AFTER_SELECTOR, SIBLING_BEFORE_SELECTOR,
26};
27use stylex_structures::pair::Pair;
28use stylex_structures::stylex_state_options::StyleXStateOptions;
29use stylex_types::structures::injectable_style::InjectableStyle;
30
31use stylex_macros::stylex_panic;
32use swc_core::{
33  common::{BytePos, input::StringInput, source_map::SmallPos},
34  css::{
35    ast::{Ident, Stylesheet},
36    codegen::{
37      CodeGenerator, CodegenConfig, Emit,
38      writer::basic::{BasicCssWriter, BasicCssWriterConfig},
39    },
40    parser::{error::Error, parse_string_input, parser::ParserConfig},
41  },
42};
43
44#[allow(unused_imports)]
45pub(crate) use stylex_css::values::common::split_value_required;
46
47#[allow(unused_imports)]
48pub(crate) use stylex_css::values::common::split_value;
49
50const THUMB_VARIANTS: [&str; 3] = [
51  "::-webkit-slider-thumb",
52  "::-moz-range-thumb",
53  "::-ms-thumb",
54];
55
56pub(crate) fn build_nested_css_rule(
57  class_name: &str,
58  decls: String,
59  pseudos: &mut [String],
60  at_rules: &mut [String],
61  const_rules: &mut [String],
62) -> String {
63  let pseudo = pseudos
64    .iter()
65    .filter(|&p| p != "::thumb")
66    .collect::<Vec<&String>>();
67  let pseudo_strs: Vec<&str> = pseudo.iter().map(|s| s.as_str()).collect();
68  let pseudo = pseudo_strs.join("");
69
70  let mut combined_at_rules = Vec::with_capacity(at_rules.len() + const_rules.len());
71
72  combined_at_rules.extend_from_slice(at_rules);
73  combined_at_rules.extend_from_slice(const_rules);
74
75  // Bump specificity of stylex.when selectors
76  let has_where = pseudo.contains(":where(");
77  let extra_class_for_where = if has_where {
78    format!(".{}", class_name)
79  } else {
80    String::default()
81  };
82
83  let mut selector_for_at_rules = format!(
84    ".{class_name}{extra_class_for_where}{}{pseudo}",
85    combined_at_rules
86      .iter()
87      .map(|_| format!(".{}", class_name))
88      .collect::<Vec<String>>()
89      .join(""),
90  );
91
92  if pseudos.contains(&"::thumb".to_string()) {
93    selector_for_at_rules = THUMB_VARIANTS
94      .iter()
95      .map(|suffix| format!("{}{}", selector_for_at_rules, suffix))
96      .collect::<Vec<String>>()
97      .join(", ");
98  }
99
100  combined_at_rules.iter().fold(
101    format!("{}{{{}}}", selector_for_at_rules, decls),
102    |acc, at_rule| format!("{}{{{}}}", at_rule, acc),
103  )
104}
105
106pub(crate) fn generate_css_rule(
107  class_name: &str,
108  key: &str,
109  values: &[String],
110  pseudos: &mut [String],
111  at_rules: &mut [String],
112  const_rules: &mut [String],
113  options: &StyleXStateOptions,
114) -> InjectableStyle {
115  let mut pairs: Vec<Pair> = vec![];
116
117  for value in values {
118    pairs.push(Pair::new(key.to_string(), value.clone()));
119  }
120
121  let ltr_pairs: Vec<Pair> = pairs
122    .iter()
123    .map(|pair| generate_ltr(pair, options))
124    .collect::<Vec<Pair>>();
125
126  let rtl_pairs: Vec<Pair> = pairs
127    .iter()
128    .filter_map(|pair| generate_rtl(pair, options))
129    .collect::<Vec<Pair>>();
130
131  let ltr_decls = ltr_pairs
132    .iter()
133    .map(|pair| format!("{}:{}", pair.key, pair.value))
134    .collect::<Vec<String>>()
135    .join(";");
136
137  let rtl_decls = rtl_pairs
138    .iter()
139    .map(|pair| format!("{}:{}", pair.key, pair.value))
140    .collect::<Vec<String>>()
141    .join(";");
142
143  let ltr_rule = build_nested_css_rule(class_name, ltr_decls, pseudos, at_rules, const_rules);
144  let rtl_rule = if rtl_decls.is_empty() {
145    None
146  } else {
147    Some(build_nested_css_rule(
148      class_name,
149      rtl_decls,
150      pseudos,
151      at_rules,
152      const_rules,
153    ))
154  };
155
156  let priority = get_priority(key)
157    + pseudos.iter().map(|p| get_priority(p)).sum::<f64>()
158    + at_rules.iter().map(|a| get_priority(a)).sum::<f64>()
159    + const_rules.iter().map(|c| get_priority(c)).sum::<f64>();
160
161  InjectableStyle {
162    priority: Some(priority),
163    rtl: rtl_rule,
164    ltr: ltr_rule,
165  }
166}
167
168// Helper to calculate compound pseudo priority (e.g. :hover::after => sum of parts)
169fn get_compound_pseudo_priority(key: &str) -> Option<f64> {
170  let mut parts: Vec<String> = vec![];
171
172  for mat in PSEUDO_PART_REGEX.find_iter(key).flatten() {
173    parts.push(mat.as_str().to_string());
174  }
175
176  // Only handle chains of simple pseudo-classes and pseudo-elements.
177  // Opt out if there's zero/one part or any functional pseudo-class.
178  if parts.len() <= 1 || parts.iter().any(|p| p.contains('(')) {
179    return None;
180  }
181
182  let mut total: f64 = 0.0;
183
184  for part in parts.iter() {
185    total += if part.starts_with("::") {
186      PSEUDO_ELEMENT_PRIORITY
187    } else {
188      **PSEUDO_CLASS_PRIORITIES.get(part.as_str()).unwrap_or(&&40.0)
189    };
190  }
191
192  Some(total)
193}
194
195fn get_at_rule_priority(key: &str) -> Option<f64> {
196  if key.starts_with("--") {
197    return Some(1.0);
198  }
199
200  if key.starts_with("@supports") {
201    return AT_RULE_PRIORITIES.get("@supports").map(|v| **v);
202  }
203
204  if key.starts_with("@media") {
205    return AT_RULE_PRIORITIES.get("@media").map(|v| **v);
206  }
207
208  if key.starts_with("@container") {
209    return AT_RULE_PRIORITIES.get("@container").map(|v| **v);
210  }
211
212  None
213}
214
215fn get_pseudo_element_priority(key: &str) -> Option<f64> {
216  if key.starts_with("::") {
217    return Some(PSEUDO_ELEMENT_PRIORITY);
218  }
219
220  None
221}
222
223fn get_pseudo_class_priority(key: &str) -> Option<f64> {
224  let pseudo_base = |p: &str| -> f64 { **PSEUDO_CLASS_PRIORITIES.get(p).unwrap_or(&&40.0) / 100.0 };
225
226  // Check ancestor selector
227  if let Ok(Some(captures)) = ANCESTOR_SELECTOR.captures(key)
228    && let Some(pseudo) = captures.get(1)
229  {
230    return Some(10.0 + pseudo_base(pseudo.as_str()));
231  }
232
233  // Check descendant selector
234  if let Ok(Some(captures)) = DESCENDANT_SELECTOR.captures(key)
235    && let Some(pseudo) = captures.get(1)
236  {
237    return Some(15.0 + pseudo_base(pseudo.as_str()));
238  }
239
240  // Check any sibling selector (must come before individual sibling selectors)
241  if let Ok(Some(captures)) = ANY_SIBLING_SELECTOR.captures(key)
242    && let (Some(pseudo1), Some(pseudo2)) = (captures.get(1), captures.get(2))
243  {
244    return Some(20.0 + pseudo_base(pseudo1.as_str()).max(pseudo_base(pseudo2.as_str())));
245  }
246
247  // Check sibling before selector
248  if let Ok(Some(captures)) = SIBLING_BEFORE_SELECTOR.captures(key)
249    && let Some(pseudo) = captures.get(1)
250  {
251    return Some(30.0 + pseudo_base(pseudo.as_str()));
252  }
253
254  // Check sibling after selector
255  if let Ok(Some(captures)) = SIBLING_AFTER_SELECTOR.captures(key)
256    && let Some(pseudo) = captures.get(1)
257  {
258    return Some(40.0 + pseudo_base(pseudo.as_str()));
259  }
260
261  if key.starts_with(':') {
262    let prop: &str = key.split('(').next().unwrap_or(key);
263
264    return Some(**PSEUDO_CLASS_PRIORITIES.get(prop).unwrap_or(&&40.0));
265  }
266
267  None
268}
269
270fn get_default_priority(key: &str) -> Option<f64> {
271  if SHORTHANDS_OF_SHORTHANDS.contains(key) {
272    return Some(1000.0);
273  }
274
275  if SHORTHANDS_OF_LONGHANDS.contains(key) {
276    return Some(2000.0);
277  }
278
279  if LONG_HAND_LOGICAL.contains(key) {
280    return Some(3000.0);
281  }
282
283  if LONG_HAND_PHYSICAL.contains(key) {
284    return Some(4000.0);
285  }
286
287  None
288}
289
290pub(crate) fn get_priority(key: &str) -> f64 {
291  if let Some(at_rule_priority) = get_at_rule_priority(key) {
292    return at_rule_priority;
293  }
294
295  if let Some(compound_priority) = get_compound_pseudo_priority(key) {
296    return compound_priority;
297  }
298
299  if let Some(pseudo_element_priority) = get_pseudo_element_priority(key) {
300    return pseudo_element_priority;
301  }
302
303  if let Some(pseudo_class_priority) = get_pseudo_class_priority(key) {
304    return pseudo_class_priority;
305  }
306
307  if let Some(default_priority) = get_default_priority(key) {
308    return default_priority;
309  }
310
311  3000.0
312}
313
314pub(crate) fn transform_value(key: &str, value: &str, state: &StateManager) -> String {
315  let css_property_value = value.trim();
316
317  let value = match &css_property_value.parse::<f64>() {
318    Ok(value) => format!(
319      "{0}{1}",
320      round_to_decimal_places(*value, 4),
321      get_number_suffix(key)
322    ),
323    Err(_) => css_property_value.to_string(),
324  };
325
326  if key == "content" || key == "hyphenateCharacter" || key == "hyphenate-character" {
327    let is_css_function = CSS_CONTENT_FUNCTIONS
328      .iter()
329      .any(|func| value.contains(func));
330
331    let is_keyword = CSS_CONTENT_KEYWORDS.contains(&value.as_str());
332
333    let double_quote_count = value.matches('"').count();
334    let single_quote_count = value.matches('\'').count();
335
336    let has_matching_quotes = double_quote_count >= 2 || single_quote_count >= 2;
337
338    if is_css_function || is_keyword || has_matching_quotes {
339      return value.to_string();
340    }
341
342    return format!("\"{}\"", value);
343  }
344
345  normalize_css_property_value(key, value.as_ref(), &state.options)
346}
347
348pub(crate) fn transform_value_cached(key: &str, value: &str, state: &mut StateManager) -> String {
349  let cache_key: String = format!("{}:{}", key, value);
350
351  let cache = state.css_property_seen.get(&cache_key);
352
353  if let Some(result) = cache {
354    return result.to_string();
355  }
356
357  let result = transform_value(key, value, state);
358
359  state.css_property_seen.insert(cache_key, result.clone());
360
361  result
362}
363
364pub fn swc_parse_css(source: &str) -> (Result<Stylesheet, Error>, Vec<Error>) {
365  let config = ParserConfig {
366    allow_wrong_line_comments: false,
367    css_modules: false,
368    legacy_nesting: false,
369    legacy_ie: false,
370  };
371
372  let input = StringInput::new(
373    source,
374    BytePos::from_usize(0),
375    BytePos::from_usize(source.len()),
376  );
377  let mut errors: Vec<Error> = vec![];
378
379  (parse_string_input(input, None, config, &mut errors), errors)
380}
381
382pub(crate) fn normalize_css_property_value(
383  css_property: &str,
384  css_property_value: &str,
385  options: &StyleXStateOptions,
386) -> String {
387  if COLOR_FUNCTION_LISTED_NORMALIZED_PROPERTY_VALUES
388    .iter()
389    .chain(COLOR_RELATIVE_VALUES_LISTED_NORMALIZED_PROPERTY_VALUES.iter())
390    .any(|css_fnc| {
391      css_property_value.contains(format!("{}(", css_fnc).as_str())
392        || css_property_value.to_lowercase().contains(css_fnc)
393    })
394  {
395    return MANY_SPACES.replace_all(css_property_value, " ").to_string();
396  }
397
398  let is_css_variable = css_property.starts_with("--");
399
400  let css_property_for_parsing = if is_css_variable {
401    "color"
402  } else {
403    css_property
404  };
405
406  let css_rule = if css_property_for_parsing.starts_with(':') {
407    format!("{0} {1}", css_property_for_parsing, css_property_value)
408  } else {
409    format!(
410      "* {{ {0}: {1} }}",
411      css_property_for_parsing, css_property_value
412    )
413  };
414
415  let (parsed_css, errors) = swc_parse_css(css_rule.as_str());
416
417  if !errors.is_empty() {
418    let mut error_message = match errors.first() {
419      Some(e) => e.message().to_string(),
420      None => stylex_panic!("CSS parsing failed but no error details were available."),
421    };
422
423    if error_message.ends_with("expected ')'") || error_message.ends_with("expected '('") {
424      error_message = LINT_UNCLOSED_FUNCTION.to_string();
425    }
426
427    stylex_panic!("{}, css rule: {}", error_message, css_rule)
428  }
429
430  match parsed_css {
431    Ok(parsed_css_property_value) => {
432      // let validators: Vec<Validator> = vec![
433      //   unprefixed_custom_properties_validator,
434      //   // Add other validator functions here...
435      // ];
436
437      // let normalizers: Vec<Normalizer> = vec![
438      //   base_normalizer,
439      //   // Add other normalizer functions here...
440      // ];
441
442      // for validator in validators {
443      //   validator(ast.clone());
444      // }
445
446      unprefixed_custom_properties_validator(&parsed_css_property_value);
447
448      let parsed_ast = base_normalizer(
449        parsed_css_property_value,
450        options.enable_font_size_px_to_rem,
451        Some(css_property),
452      );
453
454      // for normalizer in normalizers {
455      //   parsed_ast = normalizer(parsed_ast, options.enable_font_size_px_to_rem);
456      // }
457
458      let result = whitespace_normalizer(stringify(&parsed_ast));
459
460      convert_css_function_to_camel_case(result.as_str())
461    },
462    Err(err) => {
463      stylex_panic!("{}", err.message())
464    },
465  }
466}
467
468// type Normalizer = fn(Stylesheet, bool) -> Stylesheet;
469// type Validator = fn(Stylesheet);
470
471pub(crate) fn get_number_suffix(key: &str) -> String {
472  if UNITLESS_NUMBER_PROPERTIES.contains(key) || key.starts_with("--") {
473    return String::default();
474  }
475
476  let result = match NUMBER_PROPERTY_SUFFIXIES.get(key) {
477    Some(suffix) => suffix,
478    None => "px",
479  };
480
481  result.to_string()
482}
483
484pub(crate) fn get_value_from_ident(ident: &Ident) -> String {
485  ident.value.to_string()
486}
487
488fn convert_css_function_to_camel_case(function: &str) -> String {
489  let Some(items) = function.find('(') else {
490    return function.to_string();
491  };
492
493  let (name, args) = function.split_at(items);
494
495  let Some(camel_case_name) = CAMEL_CASE_PRIORITIES.get(name) else {
496    return function.to_string();
497  };
498
499  format!("{}{}", camel_case_name, args)
500}
501
502pub fn stringify(node: &Stylesheet) -> String {
503  let mut buf = String::new();
504  let writer = BasicCssWriter::new(&mut buf, None, BasicCssWriterConfig::default());
505  let mut codegen = CodeGenerator::new(writer, CodegenConfig { minify: true });
506
507  match Emit::emit(&mut codegen, node) {
508    Ok(_) => {},
509    Err(e) => stylex_panic!("CSS codegen emit failed: {}", e),
510  };
511
512  let mut result = buf.replace('\'', "");
513
514  if result.contains("--\\") {
515    /*
516     * In CSS, identifiers (including element names, classes, and IDs in selectors)
517     * can contain only the characters [a-zA-Z0-9] and ISO 10646 characters U+00A0 and higher,
518     * plus the hyphen (-) and the underscore (_);
519     * they cannot start with a digit, two hyphens, or a hyphen followed by a digit.
520     *
521     * https://stackoverflow.com/a/27882887/6717252
522     *
523     * HACK: Replace `--\3{number}` with `--{number}` to simulate original behavior of StyleX
524     */
525
526    result = CLEAN_CSS_VAR
527      .replace_all(buf.as_str(), |caps: &fancy_regex::Captures| {
528        caps
529          .get(1)
530          .map_or(String::default(), |m| m.as_str().to_string())
531      })
532      .to_string();
533  }
534
535  result
536}
537
538/// Converts a camelCase CSS property name to its hyphenated form.
539///
540/// Custom properties (`--*`) are returned as-is. Vendor-prefixed properties
541/// (e.g. `MsTransition`, `WebkitTapHighlightColor`) are converted to their
542/// standard hyphenated forms (`-ms-transition`, `-webkit-tap-highlight-color`).
543pub(crate) fn normalize_css_property_name(prop: &str) -> String {
544  if prop.starts_with("--") {
545    return prop.to_string();
546  }
547  dashify(prop)
548}
549
550/// Serializes a list of key-value pairs into an inline CSS style string.
551///
552/// Each pair is formatted as `property:value` and joined with `;`.
553pub(crate) fn inline_style_to_css_string(pairs: &[Pair]) -> String {
554  pairs
555    .iter()
556    .map(|p| format!("{}:{}", normalize_css_property_name(&p.key), p.value))
557    .collect::<Vec<_>>()
558    .join(";")
559}