From c4a62b68edcc3c6ac111a102ed99857c3183c7a6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 18:23:06 +0900 Subject: [PATCH 1/2] fix(extractor): compile Tailwind classes one by one with v4 variants and declarations Classes Tailwind does not define stay in the className as written, a class with a variant the compiler does not understand is left unchanged instead of applying unconditionally, and utilities emit every declaration Tailwind v4 writes: multi-declaration utilities in full, translate/rotate/scale/skew through their own properties so they compose, and arbitrary values read with Tailwind's grammar. Template classNames keep text that runs into an interpolation attached to it. Refs #687 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_tailwind_classes.json | 7 + libs/extractor/src/lib.rs | 86 + libs/extractor/src/prop_modify_utils.rs | 680 ++- ...sts__parse_tailwind_to_styles_complex.snap | 12 +- ...ts__parse_tailwind_to_styles_variants.snap | 12 +- ...sts__tailwind_classname_with_variants.snap | 12 +- ...tor__tests__tailwind_full_integration.snap | 60 +- ..._tailwind_mixed_with_devup_responsive.snap | 12 +- ...ctor__tests__tailwind_negative_values.snap | 22 +- ...ests__tailwind_with_all_variant_types.snap | 78 +- ...ind_with_conditional_template_literal.snap | 20 +- libs/extractor/src/tailwind.rs | 4225 +++++++++-------- libs/sheet/src/lib.rs | 55 + 13 files changed, 2931 insertions(+), 2350 deletions(-) create mode 100644 .changepacks/changepack_log_tailwind_classes.json diff --git a/.changepacks/changepack_log_tailwind_classes.json b/.changepacks/changepack_log_tailwind_classes.json new file mode 100644 index 000000000..3e2f64256 --- /dev/null +++ b/.changepacks/changepack_log_tailwind_classes.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Tailwind classes in a className: each class compiles on its own and every class Tailwind does not define (custom, plugin and analytics classes) stays as written, where any recognized class used to rebuild the whole className without them and could rewrite part of another class; a class touching a template interpolation (icon-${name}) is left as written, and template classNames no longer get spaces inserted around interpolations. A class whose variant is not understood stays as written instead of applying unconditionally; variants follow Tailwind v4: stacked variants compose (hover:focus: matches the element itself), hover is guarded by @media (hover: hover), group-*/peer-* use the group/peer marker classes (and group-hover/name), and data-*, aria-*, has-*, nth-*, *, **, supports-[…] and [&…]/[@media …] variants compile. Utilities emit all their declarations (size-*, rounded-t-*, truncate, sr-only, not-sr-only, text-sm with its line height, text-sm/6, leading-*), translate/rotate/scale/skew compose through Tailwind's individual properties and registered variables (@property) with negative values kept negative, and arbitrary values follow Tailwind's grammar: spaces for underscores except in url() and var() names, \\_ for an underscore, spaces around math operators, type hints, [property:value] and (--variable) shorthands. Words that only share a utility's prefix (select-wrapper, order-summary, cursor-foo) are no longer compiled", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..44d8863ea 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14553,6 +14553,92 @@ const Button = styled.button({ bg: 'red' }) )); } + // Each Tailwind class becomes the classes of its styles; every other class, + // and every class that runs into an interpolation, stays as written + #[rstest] + #[case( + r#""#, + r#"
"# + )] + #[case(r#""#, r#"
"#)] + #[case( + r#""#, + r#"
"# + )] + #[case( + "", + "
" + )] + #[case( + r"", + r"
" + )] + #[case( + "", + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case( + r#""#, + r#"
"# + )] + #[case(r#""#, r#"
"#)] + #[case( + "", + "-
" + )] + #[case( + r#""#, + r#"-
"# + )] + #[case( + r#""#, + r#"-
"# + )] + #[case( + r#""#, + r#"-
"# + )] + #[case( + "", + "-
" + )] + #[case("", "-
")] + #[serial] + fn test_tailwind_keeps_other_classes(#[case] jsx: &str, #[case] expected: &str) { + reset_class_map(); + reset_file_map(); + let output = extract( + "test.tsx", + &format!("import {{Box}} from '@devup-ui/core'\n{jsx}\n"), + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap(); + // A leading `-` marks code that needs no stylesheet + let expected = expected.strip_prefix('-').map_or_else( + || format!("import \"@devup-ui/core/devup-ui.css\";\n{expected};\n"), + |code| format!("{code};\n"), + ); + assert_eq!(output.code, expected); + } + #[test] #[serial] fn test_tailwind_classname_extraction() { diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 7076a2e68..b8b1ce7da 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -1,10 +1,9 @@ use crate::extract_style::ExtractStyleProperty; +use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{ - TailwindClass, has_tailwind_classes, parse_single_class, parse_tailwind_to_styles, -}; +use crate::tailwind::{PROPERTY_RULES, PROPERTY_RULES_FILE, parse_class}; use crate::utils::{get_str_by_property_key, merge_object_expressions}; use crate::{ExtractStyleProp, ExtractStyleValue}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -16,8 +15,6 @@ use oxc_ast::ast::{ }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; -use rustc_hash::FxHashMap; -use std::borrow::Cow; /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. @@ -313,26 +310,23 @@ pub fn get_class_name_expression<'a>( spread_props: &[Expression<'a>], filename: Option<&str>, ) -> (Option>, Vec) { - // Extract Tailwind styles from static className strings and generate class names - let (tailwind_styles, tailwind_class_expr) = - extract_tailwind_from_class_name(ast_builder, class_name_prop, style_order, filename); - - // Determine the className expression to use: - // - If we extracted Tailwind styles, use generated class names (replace original) - // - Otherwise, preserve the original className - let class_name_to_use = if let Some(tailwind_class_expr) = tailwind_class_expr { - // Tailwind className → replaced with generated class names. A rebuilt - // `cond && "a"` still evaluates to `false`, which React would render as - // `class="false"`, so it needs the same falsy guard as a passthrough. - Some(convert_class_name(ast_builder, &tailwind_class_expr)) - } else { - // Non-Tailwind className → keep original - class_name_prop - .as_ref() - .map(|class_name| convert_class_name(ast_builder, class_name)) + let mut tailwind = TailwindClassName { + style_order, + filename, + styles: Vec::new(), + property_rules: false, }; + let compiled = class_name_prop + .as_ref() + .and_then(|class_name| tailwind.compile_expression(ast_builder, class_name)); + // A rebuilt `cond && "a"` still evaluates to `false`, which React would + // render as `class="false"`, so it needs the same falsy guard as a passthrough. + let class_name_to_use = compiled + .as_ref() + .or(class_name_prop.as_ref()) + .map(|class_name| convert_class_name(ast_builder, class_name)); - // Merge class names: [tailwind/original class names] + [devup-ui component styles] + // Merge class names: [className] + [devup-ui component styles] let mut class_expressions = Vec::with_capacity(2 + spread_props.len()); if let Some(class_name) = class_name_to_use { class_expressions.push(class_name); @@ -361,294 +355,206 @@ pub fn get_class_name_expression<'a>( } let expression = merge_string_expressions(ast_builder, &class_expressions); - (expression, tailwind_styles) + (expression, tailwind.into_styles()) } -/// Apply `style_order` to all `ExtractStyleValue` items -fn apply_style_order_to_styles(styles: &mut [ExtractStyleValue], style_order: Option) { - if let Some(order) = style_order { - for style in styles.iter_mut() { - style.set_style_order(order); - } - } +/// Compiles the Tailwind classes of a className: each becomes the classes of +/// its styles, and every other class stays as written +struct TailwindClassName<'f> { + style_order: Option, + filename: Option<&'f str>, + styles: Vec, + /// Whether a compiled class uses the custom properties Tailwind registers + property_rules: bool, } -/// Extract Tailwind CSS styles from a static className string and generate devup-ui class names -/// Returns (extracted styles for CSS generation, generated class names expression) -fn extract_tailwind_from_class_name<'a>( - ast_builder: &AstBuilder<'a>, - class_name_prop: &Option>, - style_order: Option, - filename: Option<&str>, -) -> (Vec, Option>) { - // Extract from static string literals - if let Some(Expression::StringLiteral(literal)) = class_name_prop { - let class_str = literal.value.as_str(); - if has_tailwind_classes(class_str) { - let mut tailwind_styles = parse_tailwind_to_styles(class_str); - if !tailwind_styles.is_empty() { - // Apply style_order to all extracted Tailwind styles - apply_style_order_to_styles(&mut tailwind_styles, style_order); - - // Move ExtractStyleValue into ExtractStyleProp::Static for gen_class_names, - // then recover the same values afterward without deep-cloning each style. - let mut tailwind_style_props: Vec = tailwind_styles - .into_iter() - .map(ExtractStyleProp::Static) - .collect(); - - // Generate devup-ui class names for the Tailwind styles - let class_names_expr = gen_class_names( - ast_builder, - &mut tailwind_style_props, - style_order, - filename, - ); - - // Tailwind className styles are always `Static`, for which - // `into_extract` yields exactly `vec![style]`. Flattening through it - // keeps that hot path allocation-equivalent while staying total, so - // there is no unreachable arm to carve out of coverage. - let tailwind_styles = tailwind_style_props - .into_iter() - .flat_map(ExtractStyleProp::into_extract) - .collect(); - - return (tailwind_styles, class_names_expr); - } +impl TailwindClassName<'_> { + fn into_styles(self) -> Vec { + let mut styles = self.styles; + if self.property_rules { + styles.push(ExtractStyleValue::Css(ExtractCss { + css: PROPERTY_RULES.to_string(), + file: PROPERTY_RULES_FILE.to_string(), + })); } - } - - // Extract from any expression that can still carry static class strings: - // `` `${cond ? 'text-red' : 'text-blue'} p-4` ``, `cond ? 'p-4' : 'p-8'`, `cond && 'p-4'`. - if let Some(expression) = class_name_prop { - let mut all_classes = String::new(); - extract_classes_from_expression(expression, &mut all_classes); - if has_tailwind_classes(&all_classes) { - // Single pass over every class: parse ONCE, then build both the - // `Tailwind class → generated class name` mapping and the styles vec for - // CSS generation together. The previous code called - // `build_tailwind_class_mapping` and then `parse_tailwind_to_styles`, which - // re-parsed (and re-allocated a `TailwindClass` + `ExtractStaticStyle` for) - // every class a second time. Merging them keeps the mapping, the collected - // styles, and the ordered `extract()` side effects byte-identical while - // halving the per-class parse/allocate work. - let mut class_mapping: FxHashMap = FxHashMap::default(); - // Upper bound: at most one style per whitespace-separated class (the same - // presize `parse_tailwind_to_styles` used), so the vec never grow-reallocs. - 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(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); + styles + } + + /// `text` with its Tailwind classes compiled, or `None` when it has none. A + /// class running into an end that continues in an interpolation is not + /// whole, so it stays as written. + fn compile_text(&mut self, text: &str, open_start: bool, open_end: bool) -> Option { + let mut compiled = String::with_capacity(text.len()); + let mut written = 0; + let mut start = 0; + // Class names are separated by ASCII whitespace, one byte each + for class in text.split(|c: char| c.is_ascii_whitespace()) { + let end = start + class.len(); + let whole = (!open_start || start > 0) && (!open_end || end < text.len()); + if let Some(tailwind) = whole.then(|| parse_class(class)).flatten() { + compiled.push_str(&text[written..start]); + self.property_rules |= tailwind.uses_properties(); + let mut separator = ""; + for mut style in tailwind.styles() { + if let Some(order) = self.style_order { + style.style_order = Some(order); } - // `ExtractStaticStyle::extract` always yields a `ClassName`, so this - // records the exact mapping entry the two-pass version produced. - if let StyleProperty::ClassName(generated) = static_style.extract(filename) { - class_mapping.insert(class.to_string(), generated); - } - tailwind_styles.push(ExtractStyleValue::Static(static_style)); + let (StyleProperty::ClassName(name) + | StyleProperty::Variable { + class_name: name, .. + }) = style.extract(self.filename); + compiled.push_str(separator); + compiled.push_str(&name); + separator = " "; + self.styles.push(ExtractStyleValue::Static(style)); } + written = end; } - - if !class_mapping.is_empty() { - // Build the same expression back with replaced class names - let new_expression = rebuild_expression_with_mapping_unsorted( + start = end + 1; + } + (written > 0).then(|| { + compiled.push_str(&text[written..]); + compiled + }) + } + + /// `expression` with the Tailwind classes of its strings compiled, or + /// `None` when they have none + fn compile_expression<'a>( + &mut self, + ast_builder: &AstBuilder<'a>, + expression: &Expression<'a>, + ) -> Option> { + match expression { + Expression::StringLiteral(literal) => { + let compiled = self.compile_text(&literal.value, false, false)?; + Some(Expression::new_string_literal( + SPAN, + Str::from_in(&compiled, ast_builder.allocator()), + None, ast_builder, - expression, - &class_mapping, - ); - - return (tailwind_styles, Some(new_expression)); + )) + } + Expression::TemplateLiteral(template) => self.compile_template(ast_builder, template), + Expression::ConditionalExpression(conditional) => { + let consequent = self.compile_expression(ast_builder, &conditional.consequent); + let alternate = self.compile_expression(ast_builder, &conditional.alternate); + if consequent.is_none() && alternate.is_none() { + return None; + } + Some(Expression::new_conditional_expression( + conditional.span, + conditional.test.clone_in(ast_builder.allocator()), + compiled_or(ast_builder, consequent, &conditional.consequent), + compiled_or(ast_builder, alternate, &conditional.alternate), + ast_builder, + )) } + Expression::LogicalExpression(logical) => { + let left = self.compile_expression(ast_builder, &logical.left); + let right = self.compile_expression(ast_builder, &logical.right); + if left.is_none() && right.is_none() { + return None; + } + Some(Expression::new_logical_expression( + logical.span, + compiled_or(ast_builder, left, &logical.left), + logical.operator, + compiled_or(ast_builder, right, &logical.right), + ast_builder, + )) + } + Expression::ParenthesizedExpression(parenthesized) => { + let inner = self.compile_expression(ast_builder, &parenthesized.expression)?; + Some(Expression::new_parenthesized_expression( + parenthesized.span, + inner, + ast_builder, + )) + } + // Variables, calls and the like only hold their classes at runtime + _ => None, } } - (Vec::new(), None) -} - -/// Rebuild a template literal, replacing Tailwind classes with generated class names -fn rebuild_expression_with_mapping_unsorted<'a>( - ast_builder: &AstBuilder<'a>, - expression: &Expression<'a>, - class_mapping: &FxHashMap, -) -> Expression<'a> { - // Sort the mapping ONCE by key length descending (avoids partial replacements, - // e.g. "text-3xl" before "text-3") and reuse the sorted slice for every quasi - // and nested expression instead of re-sorting per call. - let mut sorted_classes: Vec<(&String, &String)> = class_mapping.iter().collect(); - sorted_classes.sort_by_key(|(k, _)| std::cmp::Reverse(k.len())); - rebuild_expression_with_mapping(ast_builder, expression, &sorted_classes) -} - -/// Rebuild a template literal using a pre-sorted class mapping slice. -fn rebuild_template_literal_with_sorted<'a>( - ast_builder: &AstBuilder<'a>, - template: &oxc_ast::ast::TemplateLiteral<'a>, - sorted_classes: &[(&String, &String)], -) -> Expression<'a> { - // Rebuild quasis with replaced class names - let new_quasis = template.quasis.iter().map(|quasi| { - let raw = quasi.value.raw.as_str(); - let replaced = replace_classes_in_string(raw, sorted_classes); - let cooked = quasi.value.cooked.as_ref().map(|c| { - let replaced_cooked = replace_classes_in_string(c.as_str(), sorted_classes); - Str::from_in(&replaced_cooked, ast_builder.allocator()) - }); - TemplateElement::new( - quasi.span, - TemplateElementValue { - raw: Str::from_in(&replaced, ast_builder.allocator()), - cooked, - }, - quasi.tail, - ast_builder, - ) - }); - - // Rebuild expressions with replaced class names - let new_expressions = template - .expressions - .iter() - .map(|expr| rebuild_expression_with_mapping(ast_builder, expr, sorted_classes)); - - Expression::new_template_literal( - template.span, - oxc_allocator::Vec::from_iter_in(new_quasis, ast_builder), - oxc_allocator::Vec::from_iter_in(new_expressions, ast_builder), - ast_builder, - ) -} - -/// Replace Tailwind class names in a string with generated class names. -/// -/// `sorted_classes` MUST already be sorted by key length descending so longer -/// class names are replaced before their prefixes (e.g. "text-3xl" before "text-3"). -fn replace_classes_in_string(s: &str, sorted_classes: &[(&String, &String)]) -> String { - let mut result = Cow::Borrowed(s); - for (tailwind_class, generated_class) in sorted_classes { - // `str::replace` already scans the whole string internally and allocates a - // fresh `String` only on a match, so the previous standalone `contains` - // pre-scan was a redundant second scan of the same content. Compute the - // replacement once and only re-own the `Cow` when it actually changed - // (length differs, or same-length differing contents). Byte-identical. - let replaced = result.replace(tailwind_class.as_str(), generated_class); - if replaced.len() != result.len() || replaced != *result { - result = Cow::Owned(replaced); + fn compile_template<'a>( + &mut self, + ast_builder: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + ) -> Option> { + let last = template.quasis.len() - 1; + let quasis: Vec> = template + .quasis + .iter() + .enumerate() + .map(|(index, quasi)| { + // The text on each side of an interpolation runs into it + self.compile_text(quasi.value.cooked.as_ref()?, index > 0, index < last) + }) + .collect(); + let expressions: Vec>> = template + .expressions + .iter() + .map(|expression| self.compile_expression(ast_builder, expression)) + .collect(); + if quasis.iter().all(Option::is_none) && expressions.iter().all(Option::is_none) { + return None; } + let quasis = template + .quasis + .iter() + .zip(quasis) + .map(|(quasi, compiled)| match compiled { + Some(cooked) => TemplateElement::new( + quasi.span, + TemplateElementValue { + raw: Str::from_in(&template_raw(&cooked), ast_builder.allocator()), + cooked: Some(Str::from_in(&cooked, ast_builder.allocator())), + }, + quasi.tail, + ast_builder, + ), + None => quasi.clone_in(ast_builder.allocator()), + }); + let expressions = template + .expressions + .iter() + .zip(expressions) + .map(|(expression, compiled)| compiled_or(ast_builder, compiled, expression)); + Some(Expression::new_template_literal( + template.span, + oxc_allocator::Vec::from_iter_in(quasis, ast_builder), + oxc_allocator::Vec::from_iter_in(expressions, ast_builder), + ast_builder, + )) } - result.into_owned() } -/// Rebuild an expression, replacing Tailwind classes in string literals -fn rebuild_expression_with_mapping<'a>( +/// `compiled`, or a copy of `original` when nothing in it compiled +fn compiled_or<'a>( ast_builder: &AstBuilder<'a>, - expr: &Expression<'a>, - sorted_classes: &[(&String, &String)], + compiled: Option>, + original: &Expression<'a>, ) -> Expression<'a> { - match expr { - Expression::StringLiteral(lit) => { - let replaced = replace_classes_in_string(lit.value.as_str(), sorted_classes); - Expression::new_string_literal( - SPAN, - Str::from_in(&replaced, ast_builder.allocator()), - None, - ast_builder, - ) - } - Expression::ConditionalExpression(cond) => { - let consequent = - rebuild_expression_with_mapping(ast_builder, &cond.consequent, sorted_classes); - let alternate = - rebuild_expression_with_mapping(ast_builder, &cond.alternate, sorted_classes); - Expression::new_conditional_expression( - cond.span, - cond.test.clone_in(ast_builder.allocator()), - consequent, - alternate, - ast_builder, - ) - } - Expression::LogicalExpression(logic) => { - let left = rebuild_expression_with_mapping(ast_builder, &logic.left, sorted_classes); - let right = rebuild_expression_with_mapping(ast_builder, &logic.right, sorted_classes); - Expression::new_logical_expression(logic.span, left, logic.operator, right, ast_builder) - } - Expression::ParenthesizedExpression(paren) => { - let inner = - rebuild_expression_with_mapping(ast_builder, &paren.expression, sorted_classes); - Expression::new_parenthesized_expression(paren.span, inner, ast_builder) - } - Expression::TemplateLiteral(inner_template) => { - rebuild_template_literal_with_sorted(ast_builder, inner_template, sorted_classes) - } - // For other expressions (variables, etc.), keep as-is - _ => expr.clone_in(ast_builder.allocator()), - } + compiled.unwrap_or_else(|| original.clone_in(ast_builder.allocator())) } -/// Extract all class name strings from a template literal, including from conditional expressions -fn extract_all_classes_from_template_literal(template: &oxc_ast::ast::TemplateLiteral) -> String { - let mut classes = String::new(); - - // Extract from quasis (static parts of template literal) - for quasi in &template.quasis { - let raw = quasi.value.raw.as_str(); - push_class_segment(&mut classes, raw.trim()); - } - - // Extract from expressions (dynamic parts) - for expr in &template.expressions { - extract_classes_from_expression(expr, &mut classes); - } - - classes -} - -fn push_class_segment(classes: &mut String, value: &str) { - if value.is_empty() { - return; - } - if !classes.is_empty() { - classes.push(' '); - } - classes.push_str(value); -} - -/// Recursively extract class name strings from an expression -fn extract_classes_from_expression(expr: &Expression, classes: &mut String) { - match expr { - // Direct string literal: 'text-red-500' - Expression::StringLiteral(lit) => { - let value = lit.value.as_str().trim(); - push_class_segment(classes, value); - } - // Ternary/conditional: cond ? 'text-red' : 'text-blue' - Expression::ConditionalExpression(cond) => { - extract_classes_from_expression(&cond.consequent, classes); - extract_classes_from_expression(&cond.alternate, classes); - } - // Logical OR: value || 'fallback' - Expression::LogicalExpression(logic) => { - extract_classes_from_expression(&logic.left, classes); - extract_classes_from_expression(&logic.right, classes); - } - // Parenthesized expression: (expr) - Expression::ParenthesizedExpression(paren) => { - extract_classes_from_expression(&paren.expression, classes); - } - // Template literal inside expression - Expression::TemplateLiteral(inner_template) => { - let inner_classes = extract_all_classes_from_template_literal(inner_template); - push_class_segment(classes, &inner_classes); +/// The raw text of a template literal part whose value is `cooked` +fn template_raw(cooked: &str) -> String { + let mut raw = String::with_capacity(cooked.len()); + let mut chars = cooked.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' | '`' => { + raw.push('\\'); + raw.push(c); + } + '$' if chars.peek() == Some(&'{') => raw.push_str("\\$"), + '\r' => raw.push_str("\\r"), + c => raw.push(c), } - // Other expressions (variables, function calls, etc.) - skip, can't extract statically - _ => {} } + raw } /// Whether the object `spread` gives may hold `key`: an object literal holds @@ -719,107 +625,76 @@ fn merge_string_expressions<'a>( return Some(expression.clone_in(ast_builder.allocator())); } - let mut string_literals: std::vec::Vec = vec![]; - let mut other_expressions = vec![]; - let mut prev_str = String::new(); - for ex in expressions { - if let Expression::StringLiteral(literal) = ex { - // Reuse the `prev_str` buffer instead of allocating a fresh String via - // `format!` each iteration. `prev_str` is only ever built here from trimmed - // pieces, so it never carries leading whitespace; trim only its trailing end. - let trimmed_len = prev_str.trim_end().len(); - prev_str.truncate(trimmed_len); - let target = literal.value.trim(); - if !prev_str.is_empty() { - prev_str.push(' '); + let mut list = ClassList { + texts: vec![String::new()], + interpolations: Vec::new(), + }; + for expression in expressions { + match expression { + Expression::StringLiteral(literal) => { + let classes = literal.value.trim(); + if !classes.is_empty() { + list.separate(); + list.text().push_str(classes); + } } - prev_str.push_str(target); - } else if let Expression::TemplateLiteral(template) = ex { - for (idx, q) in template.quasis.iter().enumerate() { - let target_prev = prev_str.trim(); - let target = q.value.raw.trim(); - if idx < template.quasis.len() - 1 { - string_literals.push(format!( - "{}{}{}{}{}", - if !other_expressions.is_empty() || idx > 0 { - " " - } else { - "" - }, - target_prev, - if target_prev.is_empty() { "" } else { " " }, - target, - if !target.is_empty() && !target.ends_with("typo-") { - " " - } else { - "" + Expression::TemplateLiteral(template) => { + let last = template.quasis.len() - 1; + for (index, quasi) in template.quasis.iter().enumerate() { + let text = quasi + .value + .cooked + .as_ref() + .map_or(quasi.value.raw.as_str(), |cooked| cooked.as_str()); + let classes = text.trim(); + if index == 0 { + if !classes.is_empty() || last > 0 { + list.separate(); } - )); - } else { - // Reuse the existing heap buffer instead of dropping it and - // allocating a fresh String: one fewer allocation per template - // quasi. Output stays byte-identical (same trimmed contents). - prev_str.clear(); - prev_str.push_str(q.value.raw.trim()); + } else if text.starts_with(|c: char| c.is_ascii_whitespace()) + && (!classes.is_empty() || index < last) + { + list.text().push(' '); + } + list.text().push_str(classes); + if index < last { + // Text running into an interpolation stays attached to + // it, as in `icon-${name}` + if !classes.is_empty() && text.ends_with(|c: char| c.is_ascii_whitespace()) + { + list.text().push(' '); + } + list.interpolate( + template.expressions[index].clone_in(ast_builder.allocator()), + ); + } } } - other_expressions.extend(template.expressions.clone_in(ast_builder.allocator())); - } else { - let target_prev = prev_str.trim(); - string_literals.push(format!( - "{}{}{}", - if other_expressions.is_empty() { - "" - } else { - " " - }, - target_prev, - if target_prev.is_empty() { "" } else { " " } - )); - other_expressions.push(ex.clone_in(ast_builder.allocator())); - // Reuse the backing capacity instead of allocating a fresh empty - // String; `clear()` keeps the buffer, byte-identical behavior. - prev_str.clear(); - } - } - { - let tail = prev_str.trim(); - if tail.is_empty() { - string_literals.push(String::new()); - } else { - let mut buf = String::with_capacity(tail.len() + 1); - buf.push(' '); - buf.push_str(tail); - string_literals.push(buf); + _ => { + list.separate(); + list.interpolate(expression.clone_in(ast_builder.allocator())); + } } } - if other_expressions.is_empty() { - // Concatenate the already-owned fragments into one presized buffer instead - // of `join("")`, which allocates a fresh Vec-backed buffer internally after - // summing lengths. Same summed-capacity, same byte order, then `.trim()` — - // byte-identical to `string_literals.join("")`. - let mut merged = String::with_capacity(string_literals.iter().map(String::len).sum()); - for frag in &string_literals { - merged.push_str(frag); - } + + if list.interpolations.is_empty() { return Some(Expression::new_string_literal( SPAN, - Str::from_in(merged.trim(), ast_builder.allocator()), + Str::from_in(list.texts[0].trim(), ast_builder.allocator()), None, ast_builder, )); } - - let q = oxc_allocator::Vec::from_iter_in( - string_literals.iter().enumerate().map(|(idx, s)| { - let tail = idx == string_literals.len() - 1; + let last = list.texts.len() - 1; + let quasis = oxc_allocator::Vec::from_iter_in( + list.texts.iter().enumerate().map(|(index, text)| { TemplateElement::new( SPAN, TemplateElementValue { - raw: Str::from_in(s, ast_builder.allocator()), - cooked: None, + raw: Str::from_in(&template_raw(text), ast_builder.allocator()), + cooked: Some(Str::from_in(text, ast_builder.allocator())), }, - tail, + index == last, ast_builder, ) }), @@ -827,17 +702,40 @@ fn merge_string_expressions<'a>( ); Some(Expression::new_template_literal( SPAN, - q, - oxc_allocator::Vec::from_iter_in( - other_expressions - .into_iter() - .map(|ex| ex.clone_in(ast_builder.allocator())), - ast_builder, - ), + quasis, + oxc_allocator::Vec::from_iter_in(list.interpolations, ast_builder), ast_builder, )) } +/// A className merged from lists of classes, each separated from the next by +/// a space +struct ClassList<'a> { + /// The text before each interpolation, and after the last + texts: Vec, + interpolations: Vec>, +} + +impl<'a> ClassList<'a> { + fn text(&mut self) -> &mut String { + let last = self.texts.len() - 1; + &mut self.texts[last] + } + + /// A space before the next list, when anything precedes it + fn separate(&mut self) { + let empty = self.interpolations.is_empty() && self.texts[0].is_empty(); + if !empty && !self.text().ends_with(' ') { + self.text().push(' '); + } + } + + fn interpolate(&mut self, expression: Expression<'a>) { + self.interpolations.push(expression); + self.texts.push(String::new()); + } +} + pub fn convert_class_name<'a>( ast_builder: &AstBuilder<'a>, class_name: &Expression<'a>, @@ -955,31 +853,9 @@ pub fn convert_style_vars<'a>( #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; - use crate::extract_style::{ - extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, - }; use crate::utils::expression_to_code; use oxc_allocator::Allocator; - #[test] - fn test_apply_style_order_to_all_styles() { - let mut styles = [ - ExtractStyleValue::Static(ExtractStaticStyle::new("color", "red", 0, None)), - ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("padding", 0, "size", None)), - ]; - - apply_style_order_to_styles(&mut styles, Some(7)); - - let ExtractStyleValue::Static(static_style) = &styles[0] else { - panic!("expected static style"); - }; - let ExtractStyleValue::Dynamic(dynamic_style) = &styles[1] else { - panic!("expected dynamic style"); - }; - assert_eq!(static_style.style_order(), Some(7)); - assert_eq!(dynamic_style.style_order(), Some(7)); - } - #[test] fn test_merge_string_expressions_builds_template() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap index 1d47576b2..833afb951 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap @@ -39,9 +39,15 @@ expression: sort_styles(styles) value: "0 10px 15px -3px rgb(0 0 0 / .1),0 4px 6px -4px rgb(0 0 0 / .1)", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap index 4c4efe70d..1513c6081 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap @@ -9,9 +9,15 @@ expression: sort_styles(styles) value: "#3B82F6", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap index e55ce0f5f..494738fe9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap @@ -10,9 +10,15 @@ ToBTreeSet { value: "#3B82F6", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap index 0c7d9440d..5e6e6069a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap @@ -4,6 +4,16 @@ expression: "ToBTreeSet::from(extract(\"components/Card.tsx\",\nr#\"import {Box, --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-leading", + value: "1.625", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -108,9 +118,15 @@ ToBTreeSet { value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, @@ -350,6 +366,36 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(1.25 / .875))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(1.75 / 1.125))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(1.75 / 1.25))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "margin-top", @@ -410,6 +456,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap index ed64853b3..757687d59 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap @@ -50,9 +50,15 @@ ToBTreeSet { value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap index 6fbafc753..b318297c2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap @@ -4,6 +4,16 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-translate-x", + value: "-50%", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "margin", @@ -16,14 +26,20 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "transform", - value: "translateX(-50%)", + property: "translate", + value: "var(--tw-translate-x) var(--tw-translate-y)", level: 0, selector: None, style_order: None, layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap index b116c3f90..2c1b7dcdc 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap @@ -4,6 +4,48 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-scale-x", + value: "95%", + level: 0, + selector: Some( + Selector( + "&:active", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-scale-y", + value: "95%", + level: 0, + selector: Some( + Selector( + "&:active", + ), + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-scale-z", + value: "95%", + level: 0, + selector: Some( + Selector( + "&:active", + ), + ), + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "background-color", @@ -24,9 +66,15 @@ ToBTreeSet { value: "#1F2937", level: 0, selector: Some( - Selector( - ":root[data-theme=dark]:hover &", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + ":root[data-theme=dark] &:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, @@ -38,9 +86,15 @@ ToBTreeSet { value: "#3B82F6", level: 0, selector: Some( - Selector( - "&:hover", - ), + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, ), style_order: None, layer: None, @@ -136,8 +190,8 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "transform", - value: "scale(.95)", + property: "scale", + value: "var(--tw-scale-x) var(--tw-scale-y)", level: 0, selector: Some( Selector( @@ -148,6 +202,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap index ff2b5b952..0a19f6804 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\n\n hello\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Box} from '@devup-ui/core'\n\n hello\n\n\",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -34,6 +34,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "line-height", + value: "var(--tw-leading,calc(2.25 / 1.875))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "padding-right", @@ -44,6 +54,12 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n hello\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
\n hello\n
;\n", } diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..ee2beb194 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -1,7 +1,9 @@ -//! Tailwind CSS class parser for devup-ui extraction +//! Tailwind CSS classes in a `className`, compiled to devup-ui styles //! -//! This module parses Tailwind CSS class strings and converts them to -//! `ExtractStyleValue` objects for integration with the devup-ui extraction system. +//! A class compiles only when every part of it is understood: its variants +//! (`md:`, `hover:`, `data-[state=open]:`, `[&>*]:` …) and its utility, which +//! becomes the declarations Tailwind CSS v4 writes for it. Any other class is +//! left as written, for whatever else defines it. // The nested if-let pattern is intentional for readability in parsing code. // Using if-let chains would make the code harder to read and modify. @@ -9,22 +11,19 @@ use std::borrow::Cow; -use css::style_selector::StyleSelector; +use css::{ + at_rule::split_at_rule_key, + style_selector::{AtRuleKind, StyleSelector}, +}; use phf::{phf_map, phf_set}; -use crate::extract_style::{ - extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, -}; +use crate::extract_style::extract_static_style::ExtractStaticStyle; + +/// A declaration: property and value +pub type Declaration = (Cow<'static, str>, Cow<'static, str>); -/// Responsive breakpoint levels matching devup-ui convention -/// 0 = base (no prefix) -/// 1 = sm (640px) -/// 2 = md (768px) -/// 3 = lg (1024px) -/// 4 = xl (1280px) -/// 5 = 2xl (1536px) -/// -/// Map of responsive prefix to level +/// Breakpoint level of each responsive variant; the breakpoints themselves +/// come from the devup theme static RESPONSIVE_PREFIX_MAP: phf::Map<&'static str, u8> = phf_map! { "sm" => 1, "md" => 2, @@ -33,363 +32,371 @@ static RESPONSIVE_PREFIX_MAP: phf::Map<&'static str, u8> = phf_map! { "2xl" => 5, }; -/// Variant prefixes that map to CSS pseudo-classes/selectors -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TailwindVariant { - Hover, - Focus, - FocusVisible, - FocusWithin, - Active, - Visited, - Disabled, - Enabled, - Checked, - Indeterminate, - Default, - Required, - Valid, - Invalid, - InRange, - OutOfRange, - PlaceholderShown, - Autofill, - ReadOnly, - FirstChild, - LastChild, - OnlyChild, - OddChild, - EvenChild, - FirstOfType, - LastOfType, - OnlyOfType, - Empty, - Target, - Open, - Dark, - Placeholder, - Before, - After, - Selection, - Marker, - FirstLetter, - FirstLine, - Backdrop, - File, - GroupHover, - GroupFocus, - GroupActive, - GroupDisabled, - PeerHover, - PeerFocus, - PeerActive, - PeerDisabled, - PeerChecked, - PeerInvalid, - Print, - Screen, - Portrait, - Landscape, - MotionReduce, - MotionSafe, - ContrastMore, - ContrastLess, - ForcedColors, - Rtl, - Ltr, +/// Media query Tailwind guards hover styles with, so that a tap on a touch +/// screen does not leave them applied +const HOVER_QUERY: &str = "(hover: hover)"; + +/// Pseudo-class variants, which `group-*`, `peer-*` and `has-*` also take +static STATE_VARIANTS: phf::Map<&'static str, &'static str> = phf_map! { + "first" => "&:first-child", + "last" => "&:last-child", + "only" => "&:only-child", + "odd" => "&:nth-child(odd)", + "even" => "&:nth-child(even)", + "first-of-type" => "&:first-of-type", + "last-of-type" => "&:last-of-type", + "only-of-type" => "&:only-of-type", + "visited" => "&:visited", + "target" => "&:target", + "open" => "&:is([open], :popover-open, :open)", + "default" => "&:default", + "checked" => "&:checked", + "indeterminate" => "&:indeterminate", + "placeholder-shown" => "&:placeholder-shown", + "autofill" => "&:autofill", + "optional" => "&:optional", + "required" => "&:required", + "valid" => "&:valid", + "invalid" => "&:invalid", + "user-valid" => "&:user-valid", + "user-invalid" => "&:user-invalid", + "in-range" => "&:in-range", + "out-of-range" => "&:out-of-range", + "read-only" => "&:read-only", + "empty" => "&:empty", + "focus-within" => "&:focus-within", + "hover" => "&:hover", + "focus" => "&:focus", + "focus-visible" => "&:focus-visible", + "active" => "&:active", + "enabled" => "&:enabled", + "disabled" => "&:disabled", + "inert" => "&:is([inert], [inert] *)", +}; + +/// Pseudo-element variants. A selector list holding a pseudo-element some +/// browser does not know is dropped whole there, so each gets a rule of its own. +static PSEUDO_ELEMENT_VARIANTS: phf::Map<&'static str, &'static [&'static str]> = phf_map! { + "before" => &["&::before"], + "after" => &["&::after"], + "placeholder" => &["&::placeholder"], + "file" => &["&::file-selector-button"], + "backdrop" => &["&::backdrop"], + "first-letter" => &["&::first-letter"], + "first-line" => &["&::first-line"], + "details-content" => &["&::details-content"], + "selection" => &["& *::selection", "&::selection"], + "marker" => &[ + "& *::marker", + "&::marker", + "& *::-webkit-details-marker", + "&::-webkit-details-marker", + ], +}; + +/// Other selector variants +static SELECTOR_VARIANTS: phf::Map<&'static str, &'static str> = phf_map! { + // The devup theme picks the color scheme: `prefers-color-scheme` until a + // theme is set on the root + "dark" => ":root[data-theme=dark] &", + "rtl" => "&:where(:dir(rtl), [dir=\"rtl\"], [dir=\"rtl\"] *)", + "ltr" => "&:where(:dir(ltr), [dir=\"ltr\"], [dir=\"ltr\"] *)", + "*" => ":is(& > *)", + "**" => ":is(& *)", +}; + +/// Media query variants +static MEDIA_VARIANTS: phf::Map<&'static str, &'static str> = phf_map! { + "motion-safe" => "(prefers-reduced-motion: no-preference)", + "motion-reduce" => "(prefers-reduced-motion: reduce)", + "contrast-more" => "(prefers-contrast: more)", + "contrast-less" => "(prefers-contrast: less)", + "portrait" => "(orientation: portrait)", + "landscape" => "(orientation: landscape)", + "print" => "print", + "forced-colors" => "(forced-colors: active)", + "inverted-colors" => "(inverted-colors: inverted)", + "noscript" => "(scripting: none)", + "pointer-fine" => "(pointer: fine)", + "pointer-coarse" => "(pointer: coarse)", + "pointer-none" => "(pointer: none)", + "any-pointer-fine" => "(any-pointer: fine)", + "any-pointer-coarse" => "(any-pointer: coarse)", + "any-pointer-none" => "(any-pointer: none)", +}; + +/// The boolean ARIA attributes `aria-*` variants check for `true` +static ARIA_VARIANTS: phf::Set<&'static str> = phf_set! { + "busy", + "checked", + "disabled", + "expanded", + "hidden", + "pressed", + "readonly", + "required", + "selected", +}; + +/// The custom properties Tailwind registers, so that each element starts from +/// their initial value instead of inheriting its parent's +pub const PROPERTY_RULES: &str = concat!( + "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", + "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", + "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", + "@property --tw-rotate-x{syntax:\"*\";inherits:false}", + "@property --tw-rotate-y{syntax:\"*\";inherits:false}", + "@property --tw-rotate-z{syntax:\"*\";inherits:false}", + "@property --tw-skew-x{syntax:\"*\";inherits:false}", + "@property --tw-skew-y{syntax:\"*\";inherits:false}", + "@property --tw-leading{syntax:\"*\";inherits:false}", + "@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}", + "@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", +); + +/// The stylesheet keeps [`PROPERTY_RULES`] under this name, which no source +/// file has, so that no file's update removes them +pub const PROPERTY_RULES_FILE: &str = "@devup-ui/tailwind"; + +/// What one variant adds to the condition a class applies under +#[derive(Default)] +struct Variant { + /// Breakpoint level + level: u8, + /// Selectors the element matches, any one of them; `&` is the element + selectors: Vec>, + /// At-rule the rule goes in + at_rule: Option<(AtRuleKind, Cow<'static, str>)>, + /// `::before`/`::after`, which only render with `content` + content: bool, } -impl TailwindVariant { - /// Convert variant to `StyleSelector` - pub fn to_selector(self) -> StyleSelector { - match self { - TailwindVariant::Hover => StyleSelector::Selector("&:hover".to_string()), - TailwindVariant::Focus => StyleSelector::Selector("&:focus".to_string()), - TailwindVariant::FocusVisible => StyleSelector::Selector("&:focus-visible".to_string()), - TailwindVariant::FocusWithin => StyleSelector::Selector("&:focus-within".to_string()), - TailwindVariant::Active => StyleSelector::Selector("&:active".to_string()), - TailwindVariant::Visited => StyleSelector::Selector("&:visited".to_string()), - TailwindVariant::Disabled => StyleSelector::Selector("&:disabled".to_string()), - TailwindVariant::Enabled => StyleSelector::Selector("&:enabled".to_string()), - TailwindVariant::Checked => StyleSelector::Selector("&:checked".to_string()), - TailwindVariant::Indeterminate => { - StyleSelector::Selector("&:indeterminate".to_string()) - } - TailwindVariant::Default => StyleSelector::Selector("&:default".to_string()), - TailwindVariant::Required => StyleSelector::Selector("&:required".to_string()), - TailwindVariant::Valid => StyleSelector::Selector("&:valid".to_string()), - TailwindVariant::Invalid => StyleSelector::Selector("&:invalid".to_string()), - TailwindVariant::InRange => StyleSelector::Selector("&:in-range".to_string()), - TailwindVariant::OutOfRange => StyleSelector::Selector("&:out-of-range".to_string()), - TailwindVariant::PlaceholderShown => { - StyleSelector::Selector("&:placeholder-shown".to_string()) - } - TailwindVariant::Autofill => StyleSelector::Selector("&:autofill".to_string()), - TailwindVariant::ReadOnly => StyleSelector::Selector("&:read-only".to_string()), - TailwindVariant::FirstChild => StyleSelector::Selector("&:first-child".to_string()), - TailwindVariant::LastChild => StyleSelector::Selector("&:last-child".to_string()), - TailwindVariant::OnlyChild => StyleSelector::Selector("&:only-child".to_string()), - TailwindVariant::OddChild => StyleSelector::Selector("&:nth-child(odd)".to_string()), - TailwindVariant::EvenChild => StyleSelector::Selector("&:nth-child(even)".to_string()), - TailwindVariant::FirstOfType => StyleSelector::Selector("&:first-of-type".to_string()), - TailwindVariant::LastOfType => StyleSelector::Selector("&:last-of-type".to_string()), - TailwindVariant::OnlyOfType => StyleSelector::Selector("&:only-of-type".to_string()), - TailwindVariant::Empty => StyleSelector::Selector("&:empty".to_string()), - TailwindVariant::Target => StyleSelector::Selector("&:target".to_string()), - TailwindVariant::Open => StyleSelector::Selector("&[open]".to_string()), - TailwindVariant::Dark => { - StyleSelector::Selector(":root[data-theme=dark] &".to_string()) - } - TailwindVariant::Placeholder => StyleSelector::Selector("&::placeholder".to_string()), - TailwindVariant::Before => StyleSelector::Selector("&::before".to_string()), - TailwindVariant::After => StyleSelector::Selector("&::after".to_string()), - TailwindVariant::Selection => StyleSelector::Selector("&::selection".to_string()), - TailwindVariant::Marker => StyleSelector::Selector("&::marker".to_string()), - TailwindVariant::FirstLetter => StyleSelector::Selector("&::first-letter".to_string()), - TailwindVariant::FirstLine => StyleSelector::Selector("&::first-line".to_string()), - TailwindVariant::Backdrop => StyleSelector::Selector("&::backdrop".to_string()), - TailwindVariant::File => StyleSelector::Selector("&::file-selector-button".to_string()), - // Group variants emit a single `:is()` selector matching both the - // legacy `role="group"` attribute (deprecated, removal planned for v2) - // and the new `data-group` attribute. `:is()` adopts the highest- - // specificity argument, so specificity equals the prior `*[role=group]` - // form. Keep these in sync with `StyleSelector::from("group-*")` in - // `libs/css/src/style_selector.rs`. - TailwindVariant::GroupHover => { - StyleSelector::Selector(":is([role=group],[data-group]):hover &".to_string()) - } - TailwindVariant::GroupFocus => { - StyleSelector::Selector(":is([role=group],[data-group]):focus &".to_string()) - } - TailwindVariant::GroupActive => { - StyleSelector::Selector(":is([role=group],[data-group]):active &".to_string()) - } - TailwindVariant::GroupDisabled => { - StyleSelector::Selector(":is([role=group],[data-group]):disabled &".to_string()) - } - TailwindVariant::PeerHover => StyleSelector::Selector(".peer:hover ~ &".to_string()), - TailwindVariant::PeerFocus => StyleSelector::Selector(".peer:focus ~ &".to_string()), - TailwindVariant::PeerActive => StyleSelector::Selector(".peer:active ~ &".to_string()), - TailwindVariant::PeerDisabled => { - StyleSelector::Selector(".peer:disabled ~ &".to_string()) - } - TailwindVariant::PeerChecked => { - StyleSelector::Selector(".peer:checked ~ &".to_string()) - } - TailwindVariant::PeerInvalid => { - StyleSelector::Selector(".peer:invalid ~ &".to_string()) - } - // 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()), +impl Variant { + fn selector(selector: impl Into>) -> Self { + Self { + selectors: vec![selector.into()], + ..Self::default() + } + } + + fn at_rule(kind: AtRuleKind, query: impl Into>) -> Self { + Self { + at_rule: Some((kind, query.into())), + ..Self::default() } } - /// Parse variant from string prefix - pub fn from_prefix(prefix: &str) -> Option { - match prefix { - "hover" => Some(TailwindVariant::Hover), - "focus" => Some(TailwindVariant::Focus), - "focus-visible" => Some(TailwindVariant::FocusVisible), - "focus-within" => Some(TailwindVariant::FocusWithin), - "active" => Some(TailwindVariant::Active), - "visited" => Some(TailwindVariant::Visited), - "disabled" => Some(TailwindVariant::Disabled), - "enabled" => Some(TailwindVariant::Enabled), - "checked" => Some(TailwindVariant::Checked), - "indeterminate" => Some(TailwindVariant::Indeterminate), - "default" => Some(TailwindVariant::Default), - "required" => Some(TailwindVariant::Required), - "valid" => Some(TailwindVariant::Valid), - "invalid" => Some(TailwindVariant::Invalid), - "in-range" => Some(TailwindVariant::InRange), - "out-of-range" => Some(TailwindVariant::OutOfRange), - "placeholder-shown" => Some(TailwindVariant::PlaceholderShown), - "autofill" => Some(TailwindVariant::Autofill), - "read-only" => Some(TailwindVariant::ReadOnly), - "first" => Some(TailwindVariant::FirstChild), - "last" => Some(TailwindVariant::LastChild), - "only" => Some(TailwindVariant::OnlyChild), - "odd" => Some(TailwindVariant::OddChild), - "even" => Some(TailwindVariant::EvenChild), - "first-of-type" => Some(TailwindVariant::FirstOfType), - "last-of-type" => Some(TailwindVariant::LastOfType), - "only-of-type" => Some(TailwindVariant::OnlyOfType), - "empty" => Some(TailwindVariant::Empty), - "target" => Some(TailwindVariant::Target), - "open" => Some(TailwindVariant::Open), - "dark" => Some(TailwindVariant::Dark), - "placeholder" => Some(TailwindVariant::Placeholder), - "before" => Some(TailwindVariant::Before), - "after" => Some(TailwindVariant::After), - "selection" => Some(TailwindVariant::Selection), - "marker" => Some(TailwindVariant::Marker), - "first-letter" => Some(TailwindVariant::FirstLetter), - "first-line" => Some(TailwindVariant::FirstLine), - "backdrop" => Some(TailwindVariant::Backdrop), - "file" => Some(TailwindVariant::File), - "group-hover" => Some(TailwindVariant::GroupHover), - "group-focus" => Some(TailwindVariant::GroupFocus), - "group-active" => Some(TailwindVariant::GroupActive), - "group-disabled" => Some(TailwindVariant::GroupDisabled), - "peer-hover" => Some(TailwindVariant::PeerHover), - "peer-focus" => Some(TailwindVariant::PeerFocus), - "peer-active" => Some(TailwindVariant::PeerActive), - "peer-disabled" => Some(TailwindVariant::PeerDisabled), - "peer-checked" => Some(TailwindVariant::PeerChecked), - "peer-invalid" => Some(TailwindVariant::PeerInvalid), - "print" => Some(TailwindVariant::Print), - "screen" => Some(TailwindVariant::Screen), - "portrait" => Some(TailwindVariant::Portrait), - "landscape" => Some(TailwindVariant::Landscape), - "motion-reduce" => Some(TailwindVariant::MotionReduce), - "motion-safe" => Some(TailwindVariant::MotionSafe), - "contrast-more" => Some(TailwindVariant::ContrastMore), - "contrast-less" => Some(TailwindVariant::ContrastLess), - "forced-colors" => Some(TailwindVariant::ForcedColors), - "rtl" => Some(TailwindVariant::Rtl), - "ltr" => Some(TailwindVariant::Ltr), - _ => None, + /// A state the element is in, hovering only on devices that can hover + fn state(selector: Cow<'static, str>, hover: bool) -> Self { + Self { + selectors: vec![selector], + at_rule: hover.then_some((AtRuleKind::Media, Cow::Borrowed(HOVER_QUERY))), + ..Self::default() } } } -/// Parsed Tailwind class with all components +/// A Tailwind class, compiled #[derive(Debug, Clone, PartialEq, Eq)] pub struct TailwindClass { - /// Responsive level (0=base, 1=sm, 2=md, 3=lg, 4=xl, 5=2xl) - pub responsive: u8, - /// Variants/modifiers applied - pub variants: Vec, - /// CSS property name - pub property: &'static str, - /// CSS value - pub value: Cow<'static, str>, - /// Whether this is a negative value - pub negative: bool, + /// Breakpoint level + pub level: u8, + /// The conditions the declarations apply under, one rule each + pub conditions: Vec>, + /// The declarations, in the order Tailwind writes them + pub declarations: Vec, } -/// Remove every non-overlapping occurrence of `needle` from `haystack` in place. -/// -/// Behaves exactly like `*haystack = haystack.replace(needle, "")` (left-to-right, -/// non-overlapping) but mutates the existing buffer instead of allocating a new -/// `String`. `needle` must be non-empty. -fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); - let mut search_from = 0; - while let Some(rel) = haystack[search_from..].find(needle) { - let at = search_from + rel; - haystack.replace_range(at..at + needle.len(), ""); - // Continue scanning from where the removed text was (non-overlapping, - // matching `str::replace`'s left-to-right semantics). - search_from = at; +impl TailwindClass { + /// Every declaration under every condition + pub fn styles(&self) -> impl Iterator + '_ { + self.conditions.iter().flat_map(move |condition| { + self.declarations.iter().map(move |(property, value)| { + ExtractStaticStyle::new(property, value, self.level, condition.clone()) + }) + }) + } + + /// Whether the declarations use the custom properties of [`PROPERTY_RULES`] + #[must_use] + pub fn uses_properties(&self) -> bool { + self.declarations + .iter() + .any(|(property, value)| property.starts_with("--tw-") || value.contains("var(--tw-")) } } -impl TailwindClass { - /// 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 - // path borrows `self.value` directly, dropping one allocation per class. - let value: Cow = if self.negative && self.property != "transform" { - let mut v = String::with_capacity(self.value.len() + 1); - v.push('-'); - v.push_str(&self.value); - Cow::Owned(v) - } else { - Cow::Borrowed(self.value.as_ref()) - }; +/// The variant a prefix such as `hover` or `md` stands for +fn variant(name: &str) -> Option { + if let Some(&level) = RESPONSIVE_PREFIX_MAP.get(name) { + return Some(Variant { + level, + ..Variant::default() + }); + } + if let Some(&query) = MEDIA_VARIANTS.get(name) { + return Some(Variant::at_rule(AtRuleKind::Media, query)); + } + if let Some(&selectors) = PSEUDO_ELEMENT_VARIANTS.get(name) { + return Some(Variant { + selectors: selectors.iter().copied().map(Cow::Borrowed).collect(), + content: matches!(name, "before" | "after"), + ..Variant::default() + }); + } + if let Some(&selector) = SELECTOR_VARIANTS.get(name) { + return Some(Variant::selector(selector)); + } + if let Some(selector) = state_selector(name) { + return Some(Variant::state(selector, name == "hover")); + } + if let Some(state) = name.strip_prefix("group-") { + return relation_variant(state, "group", " *"); + } + if let Some(state) = name.strip_prefix("peer-") { + return relation_variant(state, "peer", " ~ *"); + } + if let Some(condition) = name.strip_prefix("supports-") { + return supports_query(bracketed(condition)?) + .map(|query| Variant::at_rule(AtRuleKind::Supports, query)); + } + arbitrary_variant(bracketed(name)?) +} - let selector = if self.variants.is_empty() { - None - } else { - // Combine multiple variants into a single selector - Some(self.combine_selectors()?) +/// The selector of a state the element is in, which `group-*`, `peer-*` and +/// `has-*` look for on another element +fn state_selector(name: &str) -> Option> { + if let Some(&selector) = STATE_VARIANTS.get(name) { + return Some(Cow::Borrowed(selector)); + } + if let Some(attribute) = name.strip_prefix("aria-") { + if ARIA_VARIANTS.contains(attribute) { + return Some(Cow::Owned(format!("&[aria-{attribute}=\"true\"]"))); + } + return attribute_selector("aria-", bracketed(attribute)?); + } + if let Some(attribute) = name.strip_prefix("data-") { + return match bracketed(attribute) { + Some(inner) => attribute_selector("data-", inner), + None => is_name(attribute).then(|| Cow::Owned(format!("&[data-{attribute}]"))), }; - - Some(ExtractStaticStyle::new( - self.property, - value.as_ref(), - self.responsive, - selector, - )) } + if let Some(position) = name.strip_prefix("nth-") { + return nth_selector(position); + } + if let Some(inner) = name.strip_prefix("has-") { + let inner = match bracketed(inner) { + Some(selector) if !selector.contains('&') => { + format!(":is({})", decode_underscores(selector)) + } + Some(_) => return None, + // Hovering a descendant is not guarded like hovering the element + None if inner == "hover" || inner.starts_with("has-") => return None, + None => state_selector(inner)?.replacen('&', "", 1), + }; + return Some(Cow::Owned(format!("&:has({inner})"))); + } + None +} - /// Combine multiple variant selectors; `None` when they can never match together. - fn combine_selectors(&self) -> Option { - if self.variants.len() == 1 { - return Some(self.variants[0].to_selector()); - } +/// `group-*`/`peer-*`: the state of an ancestor marked `group`, or of an earlier +/// sibling marked `peer`, the marker named in `group-hover/name` +fn relation_variant(state: &str, marker: &str, relation: &str) -> Option { + let (state, name) = match state.rsplit_once('/') { + Some((state, name)) if is_name(name) => (state, Some(name)), + Some(_) => return None, + None => (state, None), + }; + let selector = state_selector(state)?; + let mut element = format!(":where(.{marker}"); + if let Some(name) = name { + element.push_str("\\/"); + element.push_str(name); + } + element.push(')'); + Some(Variant::state( + Cow::Owned(format!( + "&:is({}{relation})", + selector.replacen('&', &element, 1) + )), + state == "hover", + )) +} - // For multiple variants, combine them - // e.g., dark:hover: becomes :root[data-theme=dark] &:hover - let mut selector_str = String::new(); - let mut at_rules = vec![]; - - for variant in &self.variants { - let sel = variant.to_selector(); - match sel { - StyleSelector::Selector(s) => { - if selector_str.is_empty() { - selector_str = s; - } else { - // Combine selectors in place, byte-identical to the previous - // `format!("{}{}", selector_str.replace(" &", ""), s.replace('&', ""))` - // form but without the two throwaway `String`s and the `format!` - // buffer: drop all " &" from the accumulator in place, then append - // `s` with every '&' skipped. - remove_all_substr(&mut selector_str, " &"); - for part in s.split('&') { - selector_str.push_str(part); - } - // `remove_all_substr` above stripped every " &" and each - // appended `part` comes from `s.split('&')`, so NO '&' char - // remains in the accumulator here — hence `contains(" &")` - // is provably always false and the only live check is the - // trailing-space one. Output stays byte-identical (locked by - // `test_combine_selectors_byte_identical_to_prior_impl`). - if !selector_str.ends_with(" &") { - selector_str.push_str(" &"); - } - } - } - 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. - StyleSelector::Global(_, _) => { - unreachable!("TailwindVariant should not produce Global selector") - } +/// `[name=value]` of an `aria-[…]`/`data-[…]` variant, the value quoted like +/// Tailwind quotes it +fn attribute_selector(prefix: &str, inner: &str) -> Option> { + let inner = decode_underscores(inner); + let selector = match inner.split_once('=') { + Some((name, value)) if is_name(name) && !value.is_empty() => { + if is_quoted(value) { + format!("&[{prefix}{name}={value}]") + } else if value.contains(['"', '\'', '\\', ']']) { + return None; + } else { + format!("&[{prefix}{name}=\"{value}\"]") } } + None if is_name(&inner) => format!("&[{prefix}{inner}]"), + _ => return None, + }; + Some(Cow::Owned(selector)) +} - // 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 +/// `nth-3`, `nth-last-3`, `nth-of-type-3`, `nth-last-of-type-3` and `nth-[…]` +fn nth_selector(position: &str) -> Option> { + let (pseudo, position) = if let Some(position) = position.strip_prefix("last-of-type-") { + ("nth-last-of-type", position) + } else if let Some(position) = position.strip_prefix("of-type-") { + ("nth-of-type", position) + } else if let Some(position) = position.strip_prefix("last-") { + ("nth-last-child", position) + } else { + ("nth-child", position) + }; + let position = match bracketed(position) { + Some(inner) => decode_underscores(inner), + None if position.parse::().is_ok_and(|n| n > 0) => position.to_string(), + None => return None, + }; + Some(Cow::Owned(format!("&:{pseudo}({position})"))) +} + +/// The condition of `supports-[…]`: a declaration such as `display:grid`, or a +/// parenthesized condition +fn supports_query(inner: &str) -> Option { + let condition = decode_underscores(inner); + if condition.starts_with('(') { + Some(condition) + } else if condition.contains(':') { + Some(format!("({condition})")) + } else { + None } } +/// A `[…]` variant: selectors with `&` for the element, or an at-rule +fn arbitrary_variant(inner: &str) -> Option { + let text = decode_underscores(inner); + if text.starts_with('@') { + let (kind, query) = split_at_rule_key(&text)?; + return Some(Variant::at_rule(kind, query.to_string())); + } + let selectors: Vec> = split_top_level(&text, ',') + .into_iter() + .map(|selector| Cow::Owned(selector.trim().to_string())) + .collect(); + selectors + .iter() + .all(|selector| selector.contains('&')) + .then_some(Variant { + selectors, + ..Variant::default() + }) +} + /// Tailwind color values static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { // Inherit/Current/Transparent @@ -739,16 +746,16 @@ static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "fit" => "fit-content", }; -/// Font size scale +/// Font size scale: size and the line height relative to it static FONT_SIZE_SCALE: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! { - "xs" => ("0.75rem", "1rem"), - "sm" => ("0.875rem", "1.25rem"), - "base" => ("1rem", "1.5rem"), - "lg" => ("1.125rem", "1.75rem"), - "xl" => ("1.25rem", "1.75rem"), - "2xl" => ("1.5rem", "2rem"), - "3xl" => ("1.875rem", "2.25rem"), - "4xl" => ("2.25rem", "2.5rem"), + "xs" => ("0.75rem", "calc(1 / 0.75)"), + "sm" => ("0.875rem", "calc(1.25 / 0.875)"), + "base" => ("1rem", "calc(1.5 / 1)"), + "lg" => ("1.125rem", "calc(1.75 / 1.125)"), + "xl" => ("1.25rem", "calc(1.75 / 1.25)"), + "2xl" => ("1.5rem", "calc(2 / 1.5)"), + "3xl" => ("1.875rem", "calc(2.25 / 1.875)"), + "4xl" => ("2.25rem", "calc(2.5 / 2.25)"), "5xl" => ("3rem", "1"), "6xl" => ("3.75rem", "1"), "7xl" => ("4.5rem", "1"), @@ -860,454 +867,1064 @@ static EASE_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", }; -/// Check if a string contains Tailwind classes -pub fn has_tailwind_classes(class_str: &str) -> bool { - // Simple heuristic: if it looks like a Tailwind class pattern - for part in class_str.split_whitespace() { - if is_likely_tailwind_class(part) { - return true; +/// The class compiled, or `None` when it is not a Tailwind class understood in +/// full, which must then stay in the className as written +#[must_use] +pub fn parse_class(class: &str) -> Option { + let mut parts = split_top_level(class, ':'); + let mut declarations = utility(parts.pop()?)?; + let mut level = 0; + let mut selectors = vec![String::from("&")]; + let mut at_rules = Vec::new(); + let mut content = false; + // Variants nest left to right, the first one outermost + for name in parts { + let variant = variant(name)?; + level = level.max(variant.level); + if !variant.selectors.is_empty() { + selectors = selectors + .iter() + .flat_map(|outer| { + variant + .selectors + .iter() + .map(move |selector| selector.replace('&', outer)) + }) + .collect(); } + at_rules.extend(variant.at_rule); + content |= variant.content; + } + if content + && !declarations + .iter() + .any(|(property, _)| property == "content") + { + declarations.insert( + 0, + (Cow::Borrowed("content"), Cow::Borrowed("var(--tw-content)")), + ); } - false + let conditions = selectors + .into_iter() + .map(|selector| { + let mut condition = (selector != "&").then_some(StyleSelector::Selector(selector)); + for (kind, query) in &at_rules { + // `None` when the media queries can never all match + condition = Some(StyleSelector::nest_at_rule( + condition.as_ref(), + *kind, + query, + )?); + } + Some(condition) + }) + .collect::>>()?; + Some(TailwindClass { + level, + conditions, + declarations, + }) } -/// Common Tailwind prefixes -static TAILWIND_PREFIXES: &[&str] = &[ - "bg-", - "text-", - "font-", - "p-", - "px-", - "py-", - "pt-", - "pr-", - "pb-", - "pl-", - "m-", - "mx-", - "my-", - "mt-", - "mr-", - "mb-", - "ml-", - "w-", - "h-", - "min-w-", - "max-w-", - "min-h-", - "max-h-", - "flex", - "grid", - "block", - "inline", - "hidden", - "absolute", - "relative", - "fixed", - "sticky", - "top-", - "right-", - "bottom-", - "left-", - "inset-", - "z-", - "opacity-", - "rounded", - "border", - "shadow", - "gap-", - "space-", - "items-", - "justify-", - "content-", - "self-", - "order-", - "col-", - "row-", - "overflow-", - "object-", - "aspect-", - "transition", - "duration-", - "ease-", - "delay-", - "animate-", - "cursor-", - "select-", - "resize", - "appearance-", - "outline", - "ring", - "fill-", - "stroke-", - "sr-", - "not-sr-", - "container", - "columns-", - "break-", - "decoration-", - "underline", - "overline", - "line-through", - "no-underline", - "uppercase", - "lowercase", - "capitalize", - "normal-case", - "truncate", - "leading-", - "tracking-", - "list-", - "align-", - "whitespace-", - "hyphens-", - "blur", - "brightness-", - "contrast-", - "grayscale", - "invert", - "saturate-", - "sepia", - "drop-shadow", - "backdrop-", - "scale-", - "rotate-", - "translate-", - "skew-", - "origin-", - "accent-", - "caret-", - "scroll-", - "snap-", - "touch-", - "will-change-", - "table", - "clear-", - "float-", - "isolate", - "isolation-", - "mix-blend-", - "bg-blend-", - "divide-", - "place-", - "grow", - "shrink", - "basis-", -]; +/// Properties a leading `-` negates +static NEGATABLE_PROPERTIES: phf::Set<&'static str> = phf_set! { + "margin", + "margin-inline", + "margin-block", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "margin-inline-start", + "margin-inline-end", + "inset", + "inset-inline", + "inset-block", + "top", + "right", + "bottom", + "left", + "z-index", + "order", + "letter-spacing", + "scroll-margin", +}; -/// Exact matches for utility classes without values -static EXACT_TAILWIND_CLASSES: phf::Set<&'static str> = phf_set! { - "flex", - "inline-flex", - "grid", - "inline-grid", - "block", - "inline-block", - "inline", - "contents", - "flow-root", - "hidden", - "invisible", - "visible", - "collapse", - "absolute", - "relative", - "fixed", - "sticky", - "static", - "isolate", - "isolation-auto", - "container", - "truncate", - "uppercase", - "lowercase", - "capitalize", - "normal-case", - "italic", - "not-italic", - "underline", - "overline", - "line-through", - "no-underline", - "antialiased", - "subpixel-antialiased", - "ordinal", - "slashed-zero", - "lining-nums", - "oldstyle-nums", - "proportional-nums", - "tabular-nums", - "diagonal-fractions", - "stacked-fractions", - "sr-only", - "not-sr-only", - "resize", - "resize-none", - "resize-y", - "resize-x", - "transition", - "transition-none", - "transition-all", - "transition-colors", - "transition-opacity", - "transition-shadow", - "transition-transform", - "animate-none", - "animate-spin", - "animate-ping", - "animate-pulse", - "animate-bounce", - "grayscale", - "grayscale-0", - "invert", - "invert-0", - "sepia", - "sepia-0", - "backdrop-blur", - "backdrop-blur-none", - "backdrop-grayscale", - "backdrop-grayscale-0", - "backdrop-invert", - "backdrop-invert-0", - "backdrop-sepia", - "backdrop-sepia-0", - "table", - "table-caption", - "table-cell", - "table-column", - "table-column-group", - "table-footer-group", - "table-header-group", - "table-row-group", - "table-row", - "border-collapse", - "border-separate", - "grow", - "grow-0", - "shrink", - "shrink-0", - "rounded", - "rounded-none", - "rounded-sm", - "rounded-md", - "rounded-lg", - "rounded-xl", - "rounded-2xl", - "rounded-3xl", - "rounded-full", - "border", - "border-0", - "border-2", - "border-4", - "border-8", - "shadow", - "shadow-sm", - "shadow-md", - "shadow-lg", - "shadow-xl", - "shadow-2xl", - "shadow-inner", - "shadow-none", - "outline", - "outline-none", - "outline-dashed", - "outline-dotted", - "outline-double", - "ring", - "ring-0", - "ring-1", - "ring-2", - "ring-4", - "ring-8", - "ring-inset", - "blur", - "blur-none", - "blur-sm", - "blur-md", - "blur-lg", - "blur-xl", - "blur-2xl", - "blur-3xl", +/// The declarations of a utility, which a leading `-` negates +fn utility(name: &str) -> Option> { + let (negative, name) = name + .strip_prefix('-') + .map_or((false, name), |name| (true, name)); + if name.starts_with('[') { + return if negative { + None + } else { + arbitrary_property(name) + }; + } + if let Some((root, value, variable)) = split_arbitrary(name) { + return arbitrary_utility(root, value, variable, negative); + } + if let Some(declarations) = compound_utility(name, negative) { + return Some(declarations); + } + let (property, value) = parse_utility(name)?; + let value = if negative { + negate(property, &value)? + } else { + value + }; + Some(vec![(Cow::Borrowed(property), value)]) +} + +/// `value` negated, for the properties Tailwind lets a `-` negate +fn negate(property: &str, value: &str) -> Option> { + (NEGATABLE_PROPERTIES.contains(property) + && value.starts_with(|c: char| c.is_ascii_digit() || c == '.')) + .then(|| Cow::Owned(format!("-{value}"))) +} + +/// `[property:value]` +fn arbitrary_property(name: &str) -> Option> { + let (property, value) = bracketed(name)?.split_once(':')?; + if !is_property_name(property) { + return None; + } + let value = decode_arbitrary_value(value); + (!value.trim().is_empty()).then(|| vec![(Cow::Owned(property.to_string()), Cow::Owned(value))]) +} + +/// `root-[value]`, or `root-(--variable)` for `var(--variable)` +fn split_arbitrary(name: &str) -> Option<(&str, &str, bool)> { + let (open, variable) = match name.as_bytes().last()? { + b']' => ('[', false), + b')' => ('(', true), + _ => return None, + }; + let start = name.find(open)?; + let root = name[..start].strip_suffix('-')?; + let value = &name[start + 1..name.len() - 1]; + (!root.is_empty() && !value.is_empty() && is_balanced(value)).then_some((root, value, variable)) +} + +/// Roots a leading `-` negates with an arbitrary value +static NEGATABLE_ROOTS: phf::Set<&'static str> = phf_set! { + "m", "mx", "my", "mt", "mr", "mb", "ml", "ms", "me", + "inset", "inset-x", "inset-y", "start", "end", "top", "right", "bottom", "left", + "z", "order", "tracking", "indent", + "scroll-m", "scroll-mx", "scroll-my", "scroll-mt", "scroll-mr", "scroll-mb", "scroll-ml", + "scroll-ms", "scroll-me", + "translate", "translate-x", "translate-y", "rotate", "scale", "scale-x", "scale-y", + "skew", "skew-x", "skew-y", }; -/// Check if a single class looks like a Tailwind utility -fn is_likely_tailwind_class(class: &str) -> bool { - // Strip any responsive/variant prefixes - let class = class - .split(':') - .next_back() - .unwrap_or(class) - .trim_start_matches('-'); +/// Data types a `[type:value]`/`(type:--variable)` hint names +static DATA_TYPES: phf::Set<&'static str> = phf_set! { + "color", "length", "percentage", "number", "integer", "url", "image", "position", + "bg-size", "line-width", "family-name", "generic-name", "absolute-size", "relative-size", + "angle", "vector", +}; - if EXACT_TAILWIND_CLASSES.contains(class) { - return true; +/// The declarations of `root-[value]`/`root-(--variable)` +fn arbitrary_utility( + root: &str, + value: &str, + variable: bool, + negative: bool, +) -> Option> { + let (hint, value) = match value.split_once(':') { + Some((hint, value)) if DATA_TYPES.contains(hint) => (Some(hint), value), + // A type Tailwind does not know + Some((hint, _)) + if hint + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'-') => + { + return None; + } + _ => (None, value), + }; + let mut value = if variable { + if !value.starts_with("--") { + return None; + } + format!("var({value})") + } else { + decode_arbitrary_value(value) + }; + if value.trim().is_empty() { + return None; } + if negative { + if !NEGATABLE_ROOTS.contains(root) { + return None; + } + value = format!("calc({value} * -1)"); + } + arbitrary_declarations(root, hint, value) +} - for prefix in TAILWIND_PREFIXES { - if let Some(value_part) = class.strip_prefix(prefix) { - // For prefixes that end with '-', validate the value part - if prefix.ends_with('-') { - if is_valid_tailwind_value(value_part) { - return true; - } +/// Roots whose arbitrary value goes to one property +static ARBITRARY_PROPERTIES: phf::Map<&'static str, &'static str> = phf_map! { + "w" => "width", + "h" => "height", + "min-w" => "min-width", + "max-w" => "max-width", + "min-h" => "min-height", + "max-h" => "max-height", + "p" => "padding", + "px" => "padding-inline", + "py" => "padding-block", + "pt" => "padding-top", + "pr" => "padding-right", + "pb" => "padding-bottom", + "pl" => "padding-left", + "ps" => "padding-inline-start", + "pe" => "padding-inline-end", + "m" => "margin", + "mx" => "margin-inline", + "my" => "margin-block", + "mt" => "margin-top", + "mr" => "margin-right", + "mb" => "margin-bottom", + "ml" => "margin-left", + "ms" => "margin-inline-start", + "me" => "margin-inline-end", + "inset" => "inset", + "inset-x" => "inset-inline", + "inset-y" => "inset-block", + "start" => "inset-inline-start", + "end" => "inset-inline-end", + "top" => "top", + "right" => "right", + "bottom" => "bottom", + "left" => "left", + "gap" => "gap", + "gap-x" => "column-gap", + "gap-y" => "row-gap", + "z" => "z-index", + "order" => "order", + "opacity" => "opacity", + "aspect" => "aspect-ratio", + "columns" => "columns", + "basis" => "flex-basis", + "flex" => "flex", + "grid-cols" => "grid-template-columns", + "grid-rows" => "grid-template-rows", + "col" => "grid-column", + "row" => "grid-row", + "col-start" => "grid-column-start", + "col-end" => "grid-column-end", + "row-start" => "grid-row-start", + "row-end" => "grid-row-end", + "auto-cols" => "grid-auto-columns", + "auto-rows" => "grid-auto-rows", + "tracking" => "letter-spacing", + "indent" => "text-indent", + "underline-offset" => "text-underline-offset", + "duration" => "transition-duration", + "delay" => "transition-delay", + "ease" => "transition-timing-function", + "origin" => "transform-origin", + "rotate" => "rotate", + "scale" => "scale", + "cursor" => "cursor", + "list" => "list-style-type", + "will-change" => "will-change", + "object" => "object-position", + "scroll-m" => "scroll-margin", + "scroll-mx" => "scroll-margin-inline", + "scroll-my" => "scroll-margin-block", + "scroll-mt" => "scroll-margin-top", + "scroll-mr" => "scroll-margin-right", + "scroll-mb" => "scroll-margin-bottom", + "scroll-ml" => "scroll-margin-left", + "scroll-ms" => "scroll-margin-inline-start", + "scroll-me" => "scroll-margin-inline-end", + "scroll-p" => "scroll-padding", + "scroll-px" => "scroll-padding-inline", + "scroll-py" => "scroll-padding-block", + "scroll-pt" => "scroll-padding-top", + "scroll-pr" => "scroll-padding-right", + "scroll-pb" => "scroll-padding-bottom", + "scroll-pl" => "scroll-padding-left", + "scroll-ps" => "scroll-padding-inline-start", + "scroll-pe" => "scroll-padding-inline-end", + "fill" => "fill", + "accent" => "accent-color", + "caret" => "caret-color", +}; + +/// Corners each `rounded-*` side rounds +static RADIUS_SIDES: phf::Map<&'static str, &'static [&'static str]> = phf_map! { + "" => &["border-radius"], + "t" => &["border-top-left-radius", "border-top-right-radius"], + "r" => &["border-top-right-radius", "border-bottom-right-radius"], + "b" => &["border-bottom-right-radius", "border-bottom-left-radius"], + "l" => &["border-top-left-radius", "border-bottom-left-radius"], + "s" => &["border-start-start-radius", "border-end-start-radius"], + "e" => &["border-start-end-radius", "border-end-end-radius"], + "tl" => &["border-top-left-radius"], + "tr" => &["border-top-right-radius"], + "br" => &["border-bottom-right-radius"], + "bl" => &["border-bottom-left-radius"], + "ss" => &["border-start-start-radius"], + "se" => &["border-start-end-radius"], + "ee" => &["border-end-end-radius"], + "es" => &["border-end-start-radius"], +}; + +/// Width and color property of each `border-*` side +static BORDER_SIDES: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! { + "" => ("border-width", "border-color"), + "x" => ("border-inline-width", "border-inline-color"), + "y" => ("border-block-width", "border-block-color"), + "t" => ("border-top-width", "border-top-color"), + "r" => ("border-right-width", "border-right-color"), + "b" => ("border-bottom-width", "border-bottom-color"), + "l" => ("border-left-width", "border-left-color"), + "s" => ("border-inline-start-width", "border-inline-start-color"), + "e" => ("border-inline-end-width", "border-inline-end-color"), +}; + +/// `translate` reading the translation of each axis +const TRANSLATE: &str = "var(--tw-translate-x) var(--tw-translate-y)"; +/// `scale` reading the scale of each axis +const SCALE: &str = "var(--tw-scale-x) var(--tw-scale-y)"; +/// `transform` composing the rotations and skews no other property takes +const TRANSFORM: &str = "var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)"; + +/// What an arbitrary value is, for the roots that take several kinds +#[derive(Clone, Copy, PartialEq, Eq)] +enum ValueType { + Length, + Number, + Image, + Color, + Position, + Size, + Family, +} + +/// The type a hint names, or that the value has +fn value_type(hint: Option<&str>, value: &str) -> Option { + Some(match hint { + Some("length" | "percentage" | "line-width" | "absolute-size" | "relative-size") => { + ValueType::Length + } + Some("number" | "integer") => ValueType::Number, + Some("image" | "url") => ValueType::Image, + Some("color") => ValueType::Color, + Some("position") => ValueType::Position, + Some("bg-size") => ValueType::Size, + Some("family-name" | "generic-name") => ValueType::Family, + Some(_) => return None, + None if is_number(value) => ValueType::Number, + None if is_length(value) => ValueType::Length, + None if is_image(value) => ValueType::Image, + // Tailwind reads anything else, such as `var(--x)`, as a color + None => ValueType::Color, + }) +} + +/// The declarations of an arbitrary value under `root` +fn arbitrary_declarations( + root: &str, + hint: Option<&str>, + value: String, +) -> Option> { + if let Some(&property) = ARBITRARY_PROPERTIES.get(root) { + return Some(vec![(Cow::Borrowed(property), Cow::Owned(value))]); + } + if let Some(side) = root.strip_prefix("rounded") { + let side = if side.is_empty() { + side + } else { + side.strip_prefix('-')? + }; + return Some(each(RADIUS_SIDES.get(side)?, &value)); + } + match root { + "size" => return Some(each(&["width", "height"], &value)), + "col-span" | "row-span" => { + let property = if root == "col-span" { + "grid-column" } else { - // Prefix without dash (like "flex", "grid") - exact prefix match is enough - return true; - } + "grid-row" + }; + return Some(vec![( + Cow::Borrowed(property), + Cow::Owned(format!("span {value} / span {value}")), + )]); + } + "content" => { + return Some(vec![ + (Cow::Borrowed("--tw-content"), Cow::Owned(value)), + (Cow::Borrowed("content"), Cow::Borrowed("var(--tw-content)")), + ]); } + "leading" => return Some(each(&["--tw-leading", "line-height"], &value)), + "translate" | "translate-x" | "translate-y" => { + return Some(axes(root, "translate", &value, TRANSLATE)); + } + "scale-x" | "scale-y" => return Some(axes(root, "scale", &value, SCALE)), + "skew" | "skew-x" | "skew-y" => return Some(skew(root, &value)), + "blur" | "brightness" | "contrast" | "saturate" => { + return Some(vec![function("filter", root, &value)]); + } + "backdrop-blur" => return Some(vec![function("backdrop-filter", "blur", &value)]), + _ => {} } + let value_type = value_type(hint, &value)?; + let property = if let Some(side) = root.strip_prefix("border") { + let side = if side.is_empty() { + side + } else { + side.strip_prefix('-')? + }; + let &(width, color) = BORDER_SIDES.get(side)?; + let line_widths = hint.is_none() + && value + .split_ascii_whitespace() + .all(|part| is_length(part) || matches!(part, "thin" | "medium" | "thick")); + if line_widths || value_type == ValueType::Length { + width + } else if value_type == ValueType::Color { + color + } else { + return None; + } + } else { + match (root, value_type) { + ("text", ValueType::Length) => "font-size", + ("text", ValueType::Color) + if hint.is_none() && FONT_SIZE_KEYWORDS.contains(value.as_str()) => + { + "font-size" + } + ("text", ValueType::Color) => "color", + ("bg", ValueType::Image) => "background-image", + ("bg", ValueType::Color) => "background-color", + ("bg", ValueType::Position) => "background-position", + // `bg-[length:…]` sizes the background; a bare length is ambiguous + ("bg", ValueType::Size | ValueType::Length) if hint.is_some() => "background-size", + ("outline", ValueType::Length) => "outline-width", + ("outline", ValueType::Color) => "outline-color", + ("stroke", ValueType::Length | ValueType::Number) => "stroke-width", + ("stroke", ValueType::Color) => "stroke", + ("decoration", ValueType::Length) => "text-decoration-thickness", + ("decoration", ValueType::Color) => "text-decoration-color", + ("font", ValueType::Number) => "font-weight", + ("font", ValueType::Color | ValueType::Family) => "font-family", + _ => return None, + } + }; + Some(vec![(Cow::Borrowed(property), Cow::Owned(value))]) +} - // Check for arbitrary value syntax - if class.contains('[') && class.contains(']') { - return true; - } +/// `value` for every property in `properties` +fn each(properties: &[&'static str], value: &str) -> Vec { + properties + .iter() + .map(|&property| (Cow::Borrowed(property), Cow::Owned(value.to_string()))) + .collect() +} - false +/// `property: name(value)` +fn function(property: &'static str, name: &str, value: &str) -> Declaration { + ( + Cow::Borrowed(property), + Cow::Owned(format!("{name}({value})")), + ) } -/// Exact-match value keywords for Tailwind utilities -static TAILWIND_VALUE_KEYWORDS: phf::Set<&'static str> = phf_set! { - "auto", - "full", - "screen", - "min", - "max", - "fit", - "px", - "none", - "inherit", - "current", - "transparent", - "black", - "white", -}; +/// `value` on the axes `root` names, each in its `--tw--` +/// variable, which `property` reads as `composite` +fn axes(root: &str, name: &'static str, value: &str, composite: &'static str) -> Vec { + let axes: &[&str] = match root.strip_prefix(name) { + Some("-x") => &["x"], + Some("-y") => &["y"], + _ => &["x", "y"], + }; + let mut declarations: Vec = axes + .iter() + .map(|axis| { + ( + Cow::Owned(format!("--tw-{name}-{axis}")), + Cow::Owned(value.to_string()), + ) + }) + .collect(); + declarations.push((Cow::Borrowed(name), Cow::Borrowed(composite))); + declarations +} + +/// The skew `root` names by `angle`, composed into `transform` +fn skew(root: &str, angle: &str) -> Vec { + let axes: &[(&str, &str)] = match root { + "skew-x" => &[("x", "X")], + "skew-y" => &[("y", "Y")], + _ => &[("x", "X"), ("y", "Y")], + }; + let mut declarations: Vec = axes + .iter() + .map(|(axis, function)| { + ( + Cow::Owned(format!("--tw-skew-{axis}")), + Cow::Owned(format!("skew{function}({angle})")), + ) + }) + .collect(); + declarations.push((Cow::Borrowed("transform"), Cow::Borrowed(TRANSFORM))); + declarations +} -/// Exact-match size suffixes for Tailwind utilities -static TAILWIND_SIZE_KEYWORDS: phf::Set<&'static str> = phf_set! { - "xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", +/// Line height of each `leading-*` name +static LEADING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { + "none" => "1", + "tight" => "1.25", + "snug" => "1.375", + "normal" => "1.5", + "relaxed" => "1.625", + "loose" => "2", }; -/// Tailwind color-family names. Loop-invariant lookup set: promoted from a -/// per-call `[&str; 22]` stack array + linear `strip_prefix` scan to a -/// module-level `phf::Set` for an O(1) first-segment `contains`. -static TAILWIND_COLOR_NAMES: phf::Set<&'static str> = phf_set! { - "slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime", - "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", - "pink", "rose", +/// Font-size keywords `text-[…]` takes for a size +static FONT_SIZE_KEYWORDS: phf::Set<&'static str> = phf_set! { + "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large", + "larger", "smaller", }; -/// Check if a value part looks like a valid Tailwind value -fn is_valid_tailwind_value(value: &str) -> bool { - if value.is_empty() { - return false; +/// Utilities Tailwind writes with several declarations, and those a leading +/// `-` negates in a way of their own +fn compound_utility(name: &str, negative: bool) -> Option> { + if let Some(declarations) = transform_utility(name, negative) { + return Some(declarations); + } + if negative { + return None; } + let fixed: &[(&'static str, &'static str)] = match name { + "truncate" => &[ + ("overflow", "hidden"), + ("text-overflow", "ellipsis"), + ("white-space", "nowrap"), + ], + "sr-only" => &[ + ("position", "absolute"), + ("width", "1px"), + ("height", "1px"), + ("padding", "0"), + ("margin", "-1px"), + ("overflow", "hidden"), + ("clip-path", "inset(50%)"), + ("white-space", "nowrap"), + ("border-width", "0"), + ], + "not-sr-only" => &[ + ("position", "static"), + ("width", "auto"), + ("height", "auto"), + ("padding", "0"), + ("margin", "0"), + ("overflow", "visible"), + ("clip-path", "none"), + ("white-space", "normal"), + ], + _ => &[], + }; + if !fixed.is_empty() { + return Some( + fixed + .iter() + .map(|&(property, value)| (Cow::Borrowed(property), Cow::Borrowed(value))) + .collect(), + ); + } + if let Some(size) = name.strip_prefix("size-") { + return Some(each(&["width", "height"], sizing_value(size)?)); + } + if let Some(declarations) = radius_utility(name) { + return Some(declarations); + } + if let Some(size) = name.strip_prefix("text-") { + if let Some(declarations) = font_size_utility(size) { + return Some(declarations); + } + } + if let Some(leading) = name.strip_prefix("leading-") { + let value = line_height_value(leading)?; + return Some(vec![ + (Cow::Borrowed("--tw-leading"), value.clone()), + (Cow::Borrowed("line-height"), value), + ]); + } + None +} - // Ordered cheapest-and-most-common first so the dominant numeric/color - // utilities short-circuit before the two phf keyword probes. Every branch - // is a side-effect-free predicate returning `true`, so reordering cannot - // change the boolean result for any input. +/// A spacing value `size-*` and `translate-*` take +fn sizing_value(size: &str) -> Option<&'static str> { + if matches!(size, "screen" | "svw" | "lvw" | "dvw") { + return None; + } + SPACING_SCALE.get(size).copied() +} - // Arbitrary value syntax [...] - if value.starts_with('[') && value.ends_with(']') { - return true; +/// `rounded`, `rounded-lg`, `rounded-t`, `rounded-t-lg`, … +fn radius_utility(name: &str) -> Option> { + let rest = name.strip_prefix("rounded")?; + let (side, size) = if rest.is_empty() { + ("", "") + } else { + let rest = rest.strip_prefix('-')?; + match rest.split_once('-') { + Some((side, size)) if RADIUS_SIDES.contains_key(side) => (side, size), + _ if RADIUS_SIDES.contains_key(rest) => (rest, ""), + _ => ("", rest), + } + }; + let value = BORDER_RADIUS_SCALE.get(size)?; + Some(each(RADIUS_SIDES.get(side)?, value)) +} + +/// `text-sm`, with the line height of the size unless `text-sm/6` sets one +fn font_size_utility(name: &str) -> Option> { + let (size, line_height) = match name.split_once('/') { + Some((size, line_height)) => (size, Some(line_height_value(line_height)?)), + None => (name, None), + }; + let &(font_size, default_line_height) = FONT_SIZE_SCALE.get(size)?; + // `leading-*` sets `--tw-leading`, which wins over the size's line height + let line_height = line_height + .unwrap_or_else(|| Cow::Owned(format!("var(--tw-leading, {default_line_height})"))); + Some(vec![ + (Cow::Borrowed("font-size"), Cow::Borrowed(font_size)), + (Cow::Borrowed("line-height"), line_height), + ]) +} + +/// The line height of `leading-*` and of the `/…` after a font size +fn line_height_value(name: &str) -> Option> { + if let Some(&value) = LEADING_SCALE.get(name) { + return Some(Cow::Borrowed(value)); } + if let Some(inner) = bracketed(name) { + let value = decode_arbitrary_value(inner); + return (!value.trim().is_empty()).then_some(Cow::Owned(value)); + } + if let Some(variable) = name + .strip_prefix('(') + .and_then(|name| name.strip_suffix(')')) + { + return variable + .starts_with("--") + .then(|| Cow::Owned(format!("var({variable})"))); + } + if is_number(name) { + return SPACING_SCALE.get(name).map(|&value| Cow::Borrowed(value)); + } + None +} - // Numeric values (including decimals like 0.5, 1.5) — the dominant case - // (`p-4`→`4`, `z-10`→`10`). - // Safe: `value.is_empty()` returns early above, so `value` has at least one char. - if value.starts_with(|c: char| c.is_ascii_digit()) { - return true; +/// Translate, rotate, scale and skew, which Tailwind writes to properties of +/// their own so that they compose +fn transform_utility(name: &str, negative: bool) -> Option> { + let sign = if negative { "-" } else { "" }; + if let Some(rest) = name.strip_prefix("translate-") { + let (root, size) = axis_root("translate", rest); + if matches!(size, "auto" | "min" | "max" | "fit") { + return None; + } + let value = format!("{sign}{}", sizing_value(size)?); + return Some(axes(root, "translate", &value, TRANSLATE)); + } + if let Some(angle) = name.strip_prefix("rotate-") { + return is_number(angle).then(|| { + vec![( + Cow::Borrowed("rotate"), + Cow::Owned(format!("{sign}{angle}deg")), + )] + }); + } + if let Some(rest) = name.strip_prefix("scale-") { + let (root, amount) = axis_root("scale", rest); + if !is_integer(amount) { + return None; + } + let value = format!("{sign}{amount}%"); + if root == "scale" { + let mut declarations = each(&["--tw-scale-x", "--tw-scale-y", "--tw-scale-z"], &value); + declarations.push((Cow::Borrowed("scale"), Cow::Borrowed(SCALE))); + return Some(declarations); + } + return Some(axes(root, "scale", &value, SCALE)); } + if let Some(rest) = name.strip_prefix("skew-") { + let (root, angle) = axis_root("skew", rest); + return is_number(angle).then(|| skew(root, &format!("{sign}{angle}deg"))); + } + None +} - // Color names with shade (e.g., red-500, blue-100). Every Tailwind color - // token is `` or `-`, so an O(1) first-segment lookup - // reproduces the old `` / `-…` prefix scan byte-for-byte. - let base = value.split('-').next().unwrap_or(value); - if TAILWIND_COLOR_NAMES.contains(base) { - return true; +/// The root `name-x`/`name-y`/`name` that `rest` starts with, and its value +fn axis_root<'a>(name: &str, rest: &'a str) -> (&'static str, &'a str) { + match name { + "translate" => { + if let Some(value) = rest.strip_prefix("x-") { + ("translate-x", value) + } else if let Some(value) = rest.strip_prefix("y-") { + ("translate-y", value) + } else { + ("translate", rest) + } + } + "scale" => { + if let Some(value) = rest.strip_prefix("x-") { + ("scale-x", value) + } else if let Some(value) = rest.strip_prefix("y-") { + ("scale-y", value) + } else { + ("scale", rest) + } + } + _ => { + if let Some(value) = rest.strip_prefix("x-") { + ("skew-x", value) + } else if let Some(value) = rest.strip_prefix("y-") { + ("skew-y", value) + } else { + ("skew", rest) + } + } } +} - // Common keywords - if TAILWIND_VALUE_KEYWORDS.contains(value) { - return true; +/// `_` read as a space and `\_` as `_`, as Tailwind reads arbitrary values +fn decode_underscores(value: &str) -> String { + let mut decoded = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' if chars.peek() == Some(&'_') => { + chars.next(); + decoded.push('_'); + } + '_' => decoded.push(' '), + c => decoded.push(c), + } } + decoded +} - // Size suffixes (xs, sm, md, lg, xl, 2xl, etc.) - if TAILWIND_SIZE_KEYWORDS.contains(value) { - return true; +/// An arbitrary value as CSS, the way Tailwind decodes it: underscores are +/// spaces except in `url()` and in the name `var()` reads, and the operators +/// in math functions get the spaces CSS requires around them +fn decode_arbitrary_value(value: &str) -> String { + if !value.contains('(') { + return decode_underscores(value); + } + let mut decoded = String::with_capacity(value.len()); + let mut index = 0; + while index < value.len() { + index += decode_arguments(&value[index..], &mut decoded); + if index < value.len() { + // A `)` closing nothing + decoded.push(')'); + index += 1; + } } + add_whitespace_around_math_operators(&decoded) +} - // Fraction values (1/2, 1/3, 2/3, etc.) - if let Some((numerator, denominator)) = value.split_once('/') { - if !denominator.contains('/') - && numerator.chars().all(|c| c.is_ascii_digit()) - && denominator.chars().all(|c| c.is_ascii_digit()) - { - return true; +/// Decodes `value` into `decoded` up to the `)` closing the function it is in, +/// returning the bytes read +fn decode_arguments(value: &str, decoded: &mut String) -> usize { + let mut index = 0; + while let Some(c) = value[index..].chars().next() { + match c { + ')' => return index, + '(' => { + let name = function_name(&value[..index]); + decoded.push('('); + index += 1; + if name == "url" || name.ends_with("_url") { + let end = closing_paren(&value[index..]); + decoded.push_str(&value[index..index + end]); + index += end; + } else { + if matches!(name, "var" | "theme") + || name.ends_with("_var") + || name.ends_with("_theme") + { + // The first argument names a custom property + let end = value[index..] + .find([',', ')']) + .unwrap_or(value.len() - index); + let argument = &value[index..index + end]; + if !argument.contains('(') { + decoded.push_str(&argument.replace("\\_", "_")); + index += end; + } + } + index += decode_arguments(&value[index..], decoded); + } + if value[index..].starts_with(')') { + decoded.push(')'); + index += 1; + } + } + '\\' if value[index + 1..].starts_with('_') => { + decoded.push('_'); + index += 2; + } + '_' => { + decoded.push(' '); + index += 1; + } + c => { + decoded.push(c); + index += c.len_utf8(); + } } } + index +} - false +/// The name of the function whose `(` follows `before` +fn function_name(before: &str) -> &str { + let start = before + .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .map_or(0, |index| index + 1); + &before[start..] } -/// Parse a className string into a list of `ExtractStyleValue` -pub fn parse_tailwind_to_styles(class_str: &str) -> Vec { - // Upper bound: at most one style per whitespace-separated class, so - // presizing removes the grow-realloc chain. Use a cheap single byte scan - // (whitespace-byte count + 1) instead of the heavier `split_whitespace` - // state machine just to size the buffer. - let mut styles = - Vec::with_capacity(class_str.bytes().filter(u8::is_ascii_whitespace).count() + 1); - - for class in class_str.split_whitespace() { - if let Some(static_style) = parse_single_class(class) - .as_ref() - .and_then(TailwindClass::to_static_style) - { - styles.push(ExtractStyleValue::Static(static_style)); +/// The index of the `)` closing the parentheses `value` is in, or its length +fn closing_paren(value: &str) -> usize { + let mut depth = 0usize; + for (index, byte) in value.bytes().enumerate() { + match byte { + b'(' => depth += 1, + b')' if depth == 0 => return index, + b')' => depth -= 1, + _ => {} + } + } + value.len() +} + +/// Math functions whose operators need spaces around them +const MATH_FUNCTIONS: [&str; 19] = [ + "calc", "min", "max", "clamp", "mod", "rem", "sin", "cos", "tan", "asin", "acos", "atan", + "atan2", "pow", "sqrt", "hypot", "log", "exp", "round", +]; + +/// `calc(100%-2rem)` as `calc(100% - 2rem)`, ported from Tailwind +fn add_whitespace_around_math_operators(input: &str) -> String { + if !MATH_FUNCTIONS + .iter() + .any(|function| input.contains(function)) + { + return input.to_string(); + } + let chars: Vec = input.chars().collect(); + let mut result = String::with_capacity(input.len() + 8); + // Whether each open parenthesis is in a math function, innermost last + let mut formattable: Vec = Vec::new(); + let mut value_end = None; + let mut last_value_end = None; + for (index, &c) in chars.iter().enumerate() { + // A number, and then its unit + if c.is_ascii_digit() || (value_end.is_some() && (c == '%' || c.is_ascii_alphabetic())) { + value_end = Some(index); + } else { + last_value_end = value_end; + value_end = None; + } + let in_math = formattable.last().copied().unwrap_or(false); + match c { + '(' => { + result.push(c); + let start = chars[..index] + .iter() + .rposition(|c| !(c.is_ascii_digit() || c.is_ascii_lowercase())) + .map_or(0, |position| position + 1); + let name: String = chars[start..index].iter().collect(); + formattable + .push(MATH_FUNCTIONS.contains(&name.as_str()) || (in_math && name.is_empty())); + } + ')' => { + result.push(c); + formattable.pop(); + } + ',' if in_math => result.push_str(", "), + ' ' if in_math && result.ends_with(' ') => {} + '+' | '*' | '/' | '-' if in_math => { + let mut before = result.trim_end().chars().rev(); + let previous = before.next(); + let before_previous = before.next(); + let next = chars.get(index + 1).copied(); + if matches!(previous, Some('e' | 'E')) + && before_previous.is_some_and(|c| c.is_ascii_digit()) + { + // A scientific notation exponent, such as `1e-3` + result.push(c); + } else if matches!(previous, Some('+' | '*' | '/' | '-' | '(' | ',')) { + // The sign of an operand + result.push(c); + } else if chars[index - 1] == ' ' { + result.push(c); + result.push(' '); + } else if previous.is_some_and(|c| c.is_ascii_digit() || c == ')') + || next.is_some_and(|c| { + c.is_ascii_digit() || matches!(c, '(' | '+' | '*' | '/' | '-') + }) + || last_value_end.is_some_and(|end| end + 1 == index) + { + result.push(' '); + result.push(c); + result.push(' '); + } else { + result.push(c); + } + } + c => result.push(c), } } + result +} - styles +/// `value` split at each top-level `separator`, outside brackets and parentheses +fn split_top_level(value: &str, separator: char) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (index, c) in value.char_indices() { + match c { + '[' | '(' => depth += 1, + ']' | ')' => depth = depth.saturating_sub(1), + c if c == separator && depth == 0 => { + parts.push(&value[start..index]); + start = index + c.len_utf8(); + } + _ => {} + } + } + parts.push(&value[start..]); + parts } -/// Parse a single Tailwind class string -pub fn parse_single_class(class: &str) -> Option { - let mut remaining = class; - let mut responsive_level: u8 = 0; - let mut variants: Vec = Vec::new(); +/// What `[…]` holds +fn bracketed(value: &str) -> Option<&str> { + let inner = value.strip_prefix('[')?.strip_suffix(']')?; + (!inner.is_empty() && is_balanced(inner)).then_some(inner) +} - // Handle negative prefix at the start - let negative = remaining.starts_with('-'); - if negative { - remaining = &remaining[1..]; +/// Whether the brackets and parentheses of `value` all close, in order +fn is_balanced(value: &str) -> bool { + let mut open = Vec::new(); + for c in value.chars() { + match c { + '[' | '(' => open.push(c), + ']' if open.pop() != Some('[') => return false, + ')' if open.pop() != Some('(') => return false, + _ => {} + } } + open.is_empty() +} - // Parse prefixes (responsive and variants) - while let Some(colon_pos) = remaining.find(':') { - let prefix = &remaining[..colon_pos]; +/// A name such as `active` in `data-active` or `item` in `group/item` +fn is_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} - // Check if it's a responsive prefix - if let Some(&level) = RESPONSIVE_PREFIX_MAP.get(prefix) { - responsive_level = level; - } else if let Some(variant) = TailwindVariant::from_prefix(prefix) { - variants.push(variant); - } else { - // Unknown prefix, might be an arbitrary variant - // For now, skip unknown prefixes +/// A quoted string +fn is_quoted(value: &str) -> bool { + value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) +} + +/// A CSS property name, custom or not +fn is_property_name(value: &str) -> bool { + match value.strip_prefix("--") { + Some(name) => is_name(name), + None => { + value + .trim_start_matches('-') + .starts_with(|c: char| c.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'-') } + } +} - remaining = &remaining[colon_pos + 1..]; +/// A plain number such as `600` or `1.5` +fn is_number(value: &str) -> bool { + value.parse::().is_ok() + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') +} + +/// A plain integer such as `110` +fn is_integer(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// Units a length takes +static LENGTH_UNITS: phf::Set<&'static str> = phf_set! { + "%", "px", "rem", "em", "ex", "ch", "cap", "ic", "lh", "rlh", "rex", "rch", "rcap", + "ric", "vw", "vh", "vi", "vb", "vmin", "vmax", "svw", "svh", "svi", "svb", "svmin", "svmax", + "lvw", "lvh", "lvi", "lvb", "lvmin", "lvmax", "dvw", "dvh", "dvi", "dvb", "dvmin", "dvmax", + "cqw", "cqh", "cqi", "cqb", "cqmin", "cqmax", "cm", "mm", "q", "in", "pt", "pc", +}; + +/// One or more lengths, such as `2rem`, `0`, `calc(…)` or `1px 2px` +fn is_length(value: &str) -> bool { + if MATH_FUNCTIONS.iter().any(|function| { + value + .strip_prefix(function) + .is_some_and(|rest| rest.starts_with('(')) + }) { + return true; } + let mut parts = value.split_ascii_whitespace().peekable(); + parts.peek().is_some() + && parts.all(|part| { + let number = part.trim_start_matches(['-', '+']); + let unit_start = number + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(number.len()); + part == "0" + || (unit_start > 0 + && number[..unit_start].parse::().is_ok() + && LENGTH_UNITS.contains(number[unit_start..].to_ascii_lowercase().as_str())) + }) +} - // Now parse the utility class - parse_utility(remaining, negative).map(|(property, value)| TailwindClass { - responsive: responsive_level, - variants, - property, - value, - negative, - }) +/// An image such as `url(…)` or a gradient +fn is_image(value: &str) -> bool { + [ + "url(", + "image(", + "image-set(", + "cross-fade(", + "element(", + "linear-gradient(", + "radial-gradient(", + "conic-gradient(", + "repeating-linear-gradient(", + "repeating-radial-gradient(", + "repeating-conic-gradient(", + ] + .iter() + .any(|function| value.starts_with(function)) } type ParsedUtility = (&'static str, Cow<'static, str>); @@ -1316,13 +1933,8 @@ fn tw(property: &'static str, value: impl Into>) -> ParsedUtil (property, value.into()) } -/// Parse a utility class (without prefixes) into property and value -fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'static, str>)> { - // Handle arbitrary values first - if let Some(result) = parse_arbitrary_value(class) { - return Some(result); - } - +/// The one declaration of a utility from the tables below +fn parse_utility(class: &str) -> Option { // Layout utilities if let Some(result) = parse_layout_utility(class) { return Some(result); @@ -1334,7 +1946,7 @@ fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'s } // Spacing (padding, margin) - if let Some(result) = parse_spacing_utility(class, is_negative) { + if let Some(result) = parse_spacing_utility(class) { return Some(result); } @@ -1373,8 +1985,8 @@ fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'s return Some(result); } - // Transforms - if let Some(result) = parse_transform_utility(class, is_negative) { + // Transform origin + if let Some(result) = parse_transform_utility(class) { return Some(result); } @@ -1384,105 +1996,7 @@ fn parse_utility(class: &str, is_negative: bool) -> Option<(&'static str, Cow<'s } // SVG - if let Some(result) = parse_svg_utility(class) { - return Some(result); - } - - // Accessibility - if let Some(result) = parse_accessibility_utility(class) { - return Some(result); - } - - None -} - -/// Parse arbitrary value syntax: class-[value] -fn parse_arbitrary_value(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - if !class.contains('[') { - return None; - } - - let bracket_start = class.find('[')?; - let bracket_end = class.rfind(']')?; - - if bracket_end <= bracket_start { - return None; - } - - let prefix = &class[..bracket_start]; - let value = &class[bracket_start + 1..bracket_end]; - - // Replace underscores with spaces in arbitrary values - let value = value.replace('_', " "); - - match prefix { - "w-" => Some(tw("width", value)), - "h-" => Some(tw("height", value)), - "min-w-" => Some(tw("min-width", value)), - "max-w-" => Some(tw("max-width", value)), - "min-h-" => Some(tw("min-height", value)), - "max-h-" => Some(tw("max-height", value)), - "p-" => Some(tw("padding", value)), - "px-" => Some(tw("padding-inline", value)), - "py-" => Some(tw("padding-block", value)), - "pt-" => Some(tw("padding-top", value)), - "pr-" => Some(tw("padding-right", value)), - "pb-" => Some(tw("padding-bottom", value)), - "pl-" => Some(tw("padding-left", value)), - "m-" => Some(tw("margin", value)), - "mx-" => Some(tw("margin-inline", value)), - "my-" => Some(tw("margin-block", value)), - "mt-" => Some(tw("margin-top", value)), - "mr-" => Some(tw("margin-right", value)), - "mb-" => Some(tw("margin-bottom", value)), - "ml-" => Some(tw("margin-left", value)), - "top-" => Some(tw("top", value)), - "right-" => Some(tw("right", value)), - "bottom-" => Some(tw("bottom", value)), - "left-" => Some(tw("left", value)), - "inset-" => Some(tw("inset", value)), - "inset-x-" => Some(tw("inset-inline", value)), - "inset-y-" => Some(tw("inset-block", value)), - "gap-" => Some(tw("gap", value)), - "gap-x-" => Some(tw("column-gap", value)), - "gap-y-" => Some(tw("row-gap", value)), - "text-" => Some(tw("color", value)), - "bg-" => Some(tw("background-color", value)), - "border-" => Some(tw("border-color", value)), - "rounded-" => Some(tw("border-radius", value)), - "opacity-" => Some(tw("opacity", value)), - "z-" => Some(tw("z-index", value)), - "font-" => Some(tw("font-family", value)), - "tracking-" => Some(tw("letter-spacing", value)), - "leading-" => Some(tw("line-height", value)), - "duration-" => Some(tw("transition-duration", value)), - "delay-" => Some(tw("transition-delay", value)), - "scale-" => Some(tw("transform", format!("scale({value})"))), - "rotate-" => Some(tw("transform", format!("rotate({value})"))), - "translate-x-" => Some(tw("transform", format!("translateX({value})"))), - "translate-y-" => Some(tw("transform", format!("translateY({value})"))), - "skew-x-" => Some(tw("transform", format!("skewX({value})"))), - "skew-y-" => Some(tw("transform", format!("skewY({value})"))), - "aspect-" => Some(tw("aspect-ratio", value)), - "columns-" => Some(tw("columns", value)), - "grid-cols-" => Some(tw( - "grid-template-columns", - format!("repeat({value}, minmax(0, 1fr))"), - )), - "grid-rows-" => Some(tw( - "grid-template-rows", - format!("repeat({value}, minmax(0, 1fr))"), - )), - "col-span-" => Some(tw("grid-column", format!("span {value} / span {value}"))), - "row-span-" => Some(tw("grid-row", format!("span {value} / span {value}"))), - "basis-" => Some(tw("flex-basis", value)), - "blur-" => Some(tw("filter", format!("blur({value})"))), - "brightness-" => Some(tw("filter", format!("brightness({value})"))), - "contrast-" => Some(tw("filter", format!("contrast({value})"))), - "saturate-" => Some(tw("filter", format!("saturate({value})"))), - "backdrop-blur-" => Some(tw("backdrop-filter", format!("blur({value})"))), - _ => None, - } + parse_svg_utility(class) } /// Parse layout utilities (display, position, visibility, etc.) @@ -1599,7 +2113,13 @@ fn parse_layout_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "auto" => Cow::Borrowed("auto"), "square" => Cow::Borrowed("1 / 1"), "video" => Cow::Borrowed("16 / 9"), - v => Cow::Owned(v.replace('-', " / ")), + v => { + let (width, height) = v.split_once('/')?; + if !is_integer(width) || !is_integer(height) { + return None; + } + Cow::Owned(v.to_string()) + } }; return Some(tw("aspect-ratio", value)); } @@ -1621,7 +2141,8 @@ fn parse_layout_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "5xl" => Cow::Borrowed("64rem"), "6xl" => Cow::Borrowed("72rem"), "7xl" => Cow::Borrowed("80rem"), - v => Cow::Owned(v.to_string()), + v if is_integer(v) => Cow::Owned(v.to_string()), + _ => return None, }; return Some(tw("columns", value)); } @@ -1869,7 +2390,9 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Order with number if let Some(rest) = class.strip_prefix("order-") { - return Some(tw("order", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("order", Cow::Owned(rest.to_string()))); + } } // Grid cols @@ -1901,12 +2424,16 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Col start if let Some(rest) = class.strip_prefix("col-start-") { - return Some(tw("grid-column-start", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-column-start", Cow::Owned(rest.to_string()))); + } } // Col end if let Some(rest) = class.strip_prefix("col-end-") { - return Some(tw("grid-column-end", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-column-end", Cow::Owned(rest.to_string()))); + } } // Row span @@ -1918,12 +2445,16 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Row start if let Some(rest) = class.strip_prefix("row-start-") { - return Some(tw("grid-row-start", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-row-start", Cow::Owned(rest.to_string()))); + } } // Row end if let Some(rest) = class.strip_prefix("row-end-") { - return Some(tw("grid-row-end", Cow::Owned(rest.to_string()))); + if is_integer(rest) { + return Some(tw("grid-row-end", Cow::Owned(rest.to_string()))); + } } // Gap @@ -1949,13 +2480,7 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st } /// Parse spacing utilities (padding, margin, space) -fn parse_spacing_utility( - class: &str, - _is_negative: bool, -) -> Option<(&'static str, Cow<'static, str>)> { - // Note: is_negative is handled at a higher level in TailwindClass::to_static_style() - // We don't apply the negative sign here to avoid double-negation - +fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Padding if let Some(rest) = class.strip_prefix("px-") { if let Some(&value) = SPACING_SCALE.get(rest) { @@ -2200,15 +2725,6 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) return Some(tw("max-height", value)); } - // Size (width and height) - if let Some(rest) = class.strip_prefix("size-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - // This should set both width and height - // For simplicity, we'll use the width shorthand and handle height separately - return Some(tw("width", Cow::Borrowed(value))); - } - } - None } @@ -2241,17 +2757,11 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s _ => {} } - // Font size + // Text color (font sizes are `compound_utility`'s) if let Some(rest) = class.strip_prefix("text-") { - // First check if it's a color if let Some(&color) = TAILWIND_COLORS.get(rest) { return Some(tw("color", Cow::Borrowed(color))); } - // Then check if it's a font size - if let Some(&(size, _line_height)) = FONT_SIZE_SCALE.get(rest) { - // Return font-size (line-height would need separate handling) - return Some(tw("font-size", Cow::Borrowed(size))); - } // Text alignment match rest { "left" => return Some(tw("text-align", Cow::Borrowed("left"))), @@ -2300,9 +2810,6 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Text overflow match class { - "truncate" => { - return Some(tw("text-overflow", Cow::Borrowed("ellipsis"))); - } "text-ellipsis" => return Some(tw("text-overflow", Cow::Borrowed("ellipsis"))), "text-clip" => return Some(tw("text-overflow", Cow::Borrowed("clip"))), _ => {} @@ -2319,7 +2826,11 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Whitespace if let Some(rest) = class.strip_prefix("whitespace-") { - return Some(tw("white-space", Cow::Owned(rest.to_string()))); + return matches!( + rest, + "normal" | "nowrap" | "pre" | "pre-line" | "pre-wrap" | "break-spaces" + ) + .then(|| tw("white-space", Cow::Owned(rest.to_string()))); } // Word break @@ -2333,43 +2844,22 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Hyphens if let Some(rest) = class.strip_prefix("hyphens-") { - return Some(tw("hyphens", Cow::Owned(rest.to_string()))); + return matches!(rest, "none" | "manual" | "auto") + .then(|| tw("hyphens", Cow::Owned(rest.to_string()))); } // Letter spacing if let Some(rest) = class.strip_prefix("tracking-") { let value = match rest { - "tighter" => Cow::Borrowed("-0.05em"), - "tight" => Cow::Borrowed("-0.025em"), - "normal" => Cow::Borrowed("0em"), - "wide" => Cow::Borrowed("0.025em"), - "wider" => Cow::Borrowed("0.05em"), - "widest" => Cow::Borrowed("0.1em"), - _ => Cow::Owned(rest.to_string()), - }; - return Some(tw("letter-spacing", value)); - } - - // Line height - if let Some(rest) = class.strip_prefix("leading-") { - let value = match rest { - "none" => Cow::Borrowed("1"), - "tight" => Cow::Borrowed("1.25"), - "snug" => Cow::Borrowed("1.375"), - "normal" => Cow::Borrowed("1.5"), - "relaxed" => Cow::Borrowed("1.625"), - "loose" => Cow::Borrowed("2"), - "3" => Cow::Borrowed(".75rem"), - "4" => Cow::Borrowed("1rem"), - "5" => Cow::Borrowed("1.25rem"), - "6" => Cow::Borrowed("1.5rem"), - "7" => Cow::Borrowed("1.75rem"), - "8" => Cow::Borrowed("2rem"), - "9" => Cow::Borrowed("2.25rem"), - "10" => Cow::Borrowed("2.5rem"), - _ => Cow::Owned(rest.to_string()), + "tighter" => "-0.05em", + "tight" => "-0.025em", + "normal" => "0em", + "wide" => "0.025em", + "wider" => "0.05em", + "widest" => "0.1em", + _ => return None, }; - return Some(tw("line-height", value)); + return Some(tw("letter-spacing", Cow::Borrowed(value))); } // List style type @@ -2386,7 +2876,11 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Vertical align if let Some(rest) = class.strip_prefix("align-") { - return Some(tw("vertical-align", Cow::Owned(rest.to_string()))); + return matches!( + rest, + "baseline" | "top" | "middle" | "bottom" | "text-top" | "text-bottom" | "sub" | "super" + ) + .then(|| tw("vertical-align", Cow::Owned(rest.to_string()))); } // Content @@ -2507,59 +3001,7 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s /// Parse border utilities fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Border radius - if let Some(rest) = class.strip_prefix("rounded-") { - // Specific corners - if let Some(corner) = rest.strip_prefix("t-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-left-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("r-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("b-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("l-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-left-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("tl-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-left-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("tr-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-top-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("br-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-right-radius", Cow::Borrowed(value))); - } - } - if let Some(corner) = rest.strip_prefix("bl-") { - if let Some(&value) = BORDER_RADIUS_SCALE.get(corner) { - return Some(tw("border-bottom-left-radius", Cow::Borrowed(value))); - } - } - if let Some(&value) = BORDER_RADIUS_SCALE.get(rest) { - return Some(tw("border-radius", Cow::Borrowed(value))); - } - } - - // Standalone "rounded" (without suffix) - note: "rounded-*" variants are handled - // via BORDER_RADIUS_SCALE lookup above in the strip_prefix("rounded-") branch - if class == "rounded" { - return Some(tw("border-radius", Cow::Borrowed("0.25rem"))); - } + // Border radius is compound_utility's // Border width if let Some(rest) = class.strip_prefix("border-") { @@ -2759,17 +3201,27 @@ fn parse_effects_utility(class: &str) -> Option<(&'static str, Cow<'static, str> // Mix blend mode if let Some(rest) = class.strip_prefix("mix-blend-") { - return Some(tw("mix-blend-mode", Cow::Owned(rest.to_string()))); + return (BLEND_MODES.contains(rest) || matches!(rest, "plus-darker" | "plus-lighter")) + .then(|| tw("mix-blend-mode", Cow::Owned(rest.to_string()))); } // Background blend mode if let Some(rest) = class.strip_prefix("bg-blend-") { - return Some(tw("background-blend-mode", Cow::Owned(rest.to_string()))); + return BLEND_MODES + .contains(rest) + .then(|| tw("background-blend-mode", Cow::Owned(rest.to_string()))); } None } +/// Blend modes `mix-blend-*` and `bg-blend-*` take +static BLEND_MODES: phf::Set<&'static str> = phf_set! { + "normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", + "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", + "luminosity", +}; + /// Parse filter utilities (blur, brightness, contrast, etc.) fn parse_filter_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Blur @@ -3073,86 +3525,9 @@ fn parse_transition_utility(class: &str) -> Option<(&'static str, Cow<'static, s None } -/// Parse transform utilities (scale, rotate, translate, skew) -fn parse_transform_utility( - class: &str, - is_negative: bool, -) -> Option<(&'static str, Cow<'static, str>)> { - // Scale - if let Some(rest) = class.strip_prefix("scale-x-") { - let value = parse_scale_value(rest)?; - return Some(tw("transform", format!("scaleX({value})"))); - } - if let Some(rest) = class.strip_prefix("scale-y-") { - let value = parse_scale_value(rest)?; - return Some(tw("transform", format!("scaleY({value})"))); - } - if let Some(rest) = class.strip_prefix("scale-") { - let value = parse_scale_value(rest)?; - return Some(tw("transform", format!("scale({value})"))); - } - - // Rotate - if let Some(rest) = class.strip_prefix("rotate-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "1" => Cow::Borrowed("1deg"), - "2" => Cow::Borrowed("2deg"), - "3" => Cow::Borrowed("3deg"), - "6" => Cow::Borrowed("6deg"), - "12" => Cow::Borrowed("12deg"), - "45" => Cow::Borrowed("45deg"), - "90" => Cow::Borrowed("90deg"), - "180" => Cow::Borrowed("180deg"), - _ => return None, - }; - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("rotate({neg_prefix}{value})"))); - } - - // Translate - if let Some(rest) = class.strip_prefix("translate-x-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("translateX({neg_prefix}{value})"))); - } - } - if let Some(rest) = class.strip_prefix("translate-y-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("translateY({neg_prefix}{value})"))); - } - } - - // Skew - if let Some(rest) = class.strip_prefix("skew-x-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "1" => Cow::Borrowed("1deg"), - "2" => Cow::Borrowed("2deg"), - "3" => Cow::Borrowed("3deg"), - "6" => Cow::Borrowed("6deg"), - "12" => Cow::Borrowed("12deg"), - _ => return None, - }; - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("skewX({neg_prefix}{value})"))); - } - if let Some(rest) = class.strip_prefix("skew-y-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "1" => Cow::Borrowed("1deg"), - "2" => Cow::Borrowed("2deg"), - "3" => Cow::Borrowed("3deg"), - "6" => Cow::Borrowed("6deg"), - "12" => Cow::Borrowed("12deg"), - _ => return None, - }; - let neg_prefix = if is_negative { "-" } else { "" }; - return Some(tw("transform", format!("skewY({neg_prefix}{value})"))); - } - - // Transform origin +/// Parse transform origin utilities (translate, rotate, scale and skew are +/// `transform_utility`'s) +fn parse_transform_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { if let Some(rest) = class.strip_prefix("origin-") { let value = match rest { "center" => Cow::Borrowed("center"), @@ -3172,12 +3547,6 @@ fn parse_transform_utility( None } -/// Parse scale value (50 -> 0.5, 100 -> 1, 150 -> 1.5) -fn parse_scale_value(s: &str) -> Option { - let n: u32 = s.parse().ok()?; - Some(f64::from(n) / 100.0) -} - /// Parse interactivity utilities (cursor, pointer-events, resize, etc.) fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Accent color @@ -3199,7 +3568,9 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Cursor if let Some(rest) = class.strip_prefix("cursor-") { - return Some(tw("cursor", Cow::Owned(rest.to_string()))); + return CURSORS + .contains(rest) + .then(|| tw("cursor", Cow::Owned(rest.to_string()))); } // Caret color @@ -3211,7 +3582,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Pointer events if let Some(rest) = class.strip_prefix("pointer-events-") { - return Some(tw("pointer-events", Cow::Owned(rest.to_string()))); + return matches!(rest, "none" | "auto") + .then(|| tw("pointer-events", Cow::Owned(rest.to_string()))); } // Resize @@ -3290,12 +3662,26 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Touch action if let Some(rest) = class.strip_prefix("touch-") { - return Some(tw("touch-action", Cow::Owned(rest.to_string()))); + return matches!( + rest, + "auto" + | "none" + | "pan-x" + | "pan-left" + | "pan-right" + | "pan-y" + | "pan-up" + | "pan-down" + | "pinch-zoom" + | "manipulation" + ) + .then(|| tw("touch-action", Cow::Owned(rest.to_string()))); } // User select if let Some(rest) = class.strip_prefix("select-") { - return Some(tw("user-select", Cow::Owned(rest.to_string()))); + return matches!(rest, "none" | "text" | "all" | "auto") + .then(|| tw("user-select", Cow::Owned(rest.to_string()))); } // Will change @@ -3346,23 +3732,20 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { None } -/// Parse accessibility utilities (screen reader only) -fn parse_accessibility_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - match class { - "sr-only" => { - // This utility requires multiple CSS properties - // We'll return the most important one - Some(tw("position", Cow::Borrowed("absolute"))) - } - "not-sr-only" => Some(tw("position", Cow::Borrowed("static"))), - _ => None, - } -} +/// Cursors `cursor-*` takes +static CURSORS: phf::Set<&'static str> = phf_set! { + "auto", "default", "pointer", "wait", "text", "move", "help", "not-allowed", "none", + "context-menu", "progress", "cell", "crosshair", "vertical-text", "alias", "copy", "no-drop", + "grab", "grabbing", "all-scroll", "col-resize", "row-resize", "n-resize", "e-resize", + "s-resize", "w-resize", "ne-resize", "nw-resize", "se-resize", "sw-resize", "ew-resize", + "ns-resize", "nesw-resize", "nwse-resize", "zoom-in", "zoom-out", +}; #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; + use crate::extract_style::extract_style_value::ExtractStyleValue; use css::class_map::reset_class_map; use css::file_map::reset_file_map; use insta::assert_debug_snapshot; @@ -3375,27 +3758,67 @@ mod tests { styles.into_iter().collect() } - #[test] - fn test_has_tailwind_classes_basic() { - assert!(has_tailwind_classes("bg-red-500 text-white")); - assert!(has_tailwind_classes("p-4 m-2")); - assert!(has_tailwind_classes("flex items-center")); - assert!(!has_tailwind_classes("my-custom-class")); - assert!(!has_tailwind_classes("")); - } - - #[test] - fn test_has_tailwind_classes_with_responsive() { - assert!(has_tailwind_classes("sm:bg-blue-500")); - assert!(has_tailwind_classes("md:flex lg:hidden")); - assert!(has_tailwind_classes("hover:bg-red-500")); - } - - #[test] - fn test_has_tailwind_classes_with_arbitrary() { - assert!(has_tailwind_classes("w-[100px]")); - assert!(has_tailwind_classes("text-[#ff0000]")); - assert!(has_tailwind_classes("p-[calc(100%-20px)]")); + /// The styles of every class in `classes` that compiles + fn parse_tailwind_to_styles(classes: &str) -> Vec { + classes + .split_whitespace() + .filter_map(parse_class) + .flat_map(|class| { + class + .styles() + .map(ExtractStyleValue::Static) + .collect::>() + }) + .collect() + } + + /// The declarations `class` compiles to, `None` when it stays as written + fn declarations(class: &str) -> Option> { + parse_class(class).map(|class| { + class + .declarations + .into_iter() + .map(|(property, value)| (property.into_owned(), value.into_owned())) + .collect() + }) + } + + /// What `class` applies under: its level and each condition, written out + fn conditions(class: &str) -> Option<(u8, Vec)> { + parse_class(class).map(|class| { + ( + class.level, + class + .conditions + .iter() + .map(|condition| { + condition + .as_ref() + .map_or_else(String::new, ToString::to_string) + }) + .collect(), + ) + }) + } + + struct Single { + property: String, + value: String, + } + + /// The one declaration of a single-declaration utility + fn parse_single_class(class: &str) -> Option { + let mut declarations = declarations(class)?; + assert_eq!(declarations.len(), 1, "{class}: {declarations:?}"); + let (property, value) = declarations.remove(0); + Some(Single { property, value }) + } + + fn owned(declarations: &[(&str, &str)]) -> Vec<(String, String)> { + declarations + .iter() + .map(|(property, value)| ((*property).to_string(), (*value).to_string())) + .collect() } #[rstest] @@ -3521,8 +3944,6 @@ mod tests { )] #[case("font-bold", "font-weight", "700")] #[case("font-normal", "font-weight", "400")] - #[case("text-sm", "font-size", "0.875rem")] - #[case("text-xl", "font-size", "1.25rem")] #[case("text-center", "text-align", "center")] #[case("italic", "font-style", "italic")] #[case("underline", "text-decoration-line", "underline")] @@ -3595,68 +4016,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[test] - fn test_parse_responsive_prefix() { - let parsed = parse_single_class("sm:bg-red-500").expect("Should parse"); - assert_eq!(parsed.responsive, 1); - assert_eq!(parsed.property, "background-color"); - assert_eq!(parsed.value, "#ef4444"); - - let parsed = parse_single_class("md:p-4").expect("Should parse"); - assert_eq!(parsed.responsive, 2); - assert_eq!(parsed.property, "padding"); - assert_eq!(parsed.value, "1rem"); - - let parsed = parse_single_class("lg:flex").expect("Should parse"); - assert_eq!(parsed.responsive, 3); - assert_eq!(parsed.property, "display"); - assert_eq!(parsed.value, "flex"); - - let parsed = parse_single_class("xl:hidden").expect("Should parse"); - assert_eq!(parsed.responsive, 4); - assert_eq!(parsed.property, "display"); - assert_eq!(parsed.value, "none"); - - let parsed = parse_single_class("2xl:w-full").expect("Should parse"); - assert_eq!(parsed.responsive, 5); - assert_eq!(parsed.property, "width"); - assert_eq!(parsed.value, "100%"); - } - - #[test] - fn test_parse_variant_hover() { - let parsed = parse_single_class("hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Hover); - assert_eq!(parsed.property, "background-color"); - assert_eq!(parsed.value, "#3b82f6"); - } - - #[test] - fn test_parse_variant_focus() { - let parsed = parse_single_class("focus:outline-none").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Focus); - } - - #[test] - fn test_parse_variant_dark() { - let parsed = parse_single_class("dark:bg-gray-800").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Dark); - assert_eq!(parsed.property, "background-color"); - assert_eq!(parsed.value, "#1f2937"); - } - - #[test] - fn test_parse_combined_responsive_variant() { - let parsed = parse_single_class("sm:hover:bg-red-500").expect("Should parse"); - assert_eq!(parsed.responsive, 1); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Hover); - assert_eq!(parsed.property, "background-color"); - } - #[test] fn test_parse_arbitrary_width() { let parsed = parse_single_class("w-[100px]").expect("Should parse"); @@ -3675,78 +4034,7 @@ mod tests { fn test_parse_arbitrary_calc() { let parsed = parse_single_class("w-[calc(100%-20px)]").expect("Should parse"); assert_eq!(parsed.property, "width"); - assert_eq!(parsed.value, "calc(100%-20px)"); - } - - #[test] - fn test_parse_negative_margin() { - let parsed = parse_single_class("-m-4").expect("Should parse"); - assert_eq!(parsed.property, "margin"); - assert_eq!(parsed.value, "1rem"); - assert!(parsed.negative); - - let static_style = parsed.to_static_style().expect("style"); - assert_eq!(static_style.value(), "-1rem"); - } - - #[test] - fn test_parse_negative_translate() { - let parsed = parse_single_class("-translate-x-4").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "translateX(-1rem)"); - } - - #[test] - fn test_variant_to_selector() { - assert_eq!( - TailwindVariant::Hover.to_selector(), - StyleSelector::Selector("&:hover".to_string()) - ); - assert_eq!( - TailwindVariant::Focus.to_selector(), - StyleSelector::Selector("&:focus".to_string()) - ); - assert_eq!( - TailwindVariant::Dark.to_selector(), - StyleSelector::Selector(":root[data-theme=dark] &".to_string()) - ); - assert_eq!( - TailwindVariant::GroupHover.to_selector(), - StyleSelector::Selector(":is([role=group],[data-group]):hover &".to_string()) - ); - } - - #[test] - fn test_to_static_style() { - let parsed = parse_single_class("bg-red-500").expect("Should parse"); - 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 - assert_eq!(static_style.value(), "#EF4444"); - assert_eq!(static_style.level(), 0); - assert!(static_style.selector().is_none()); - } - - #[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().expect("style"); - - assert_eq!(static_style.property(), "padding"); - assert_eq!(static_style.value(), "1rem"); - assert_eq!(static_style.level(), 2); - } - - #[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().expect("style"); - - assert_eq!(static_style.property(), "background-color"); - assert!(static_style.selector().is_some()); - let selector = static_style.selector().unwrap(); - assert_eq!(selector.to_string(), "&:hover"); + assert_eq!(parsed.value, "calc(100% - 20px)"); } #[test] @@ -3826,21 +4114,6 @@ mod tests { assert_eq!(parsed.property, "transition-timing-function"); } - #[test] - fn test_parse_transform_utilities() { - let parsed = parse_single_class("scale-50").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "scale(0.5)"); - - let parsed = parse_single_class("rotate-45").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "rotate(45deg)"); - - let parsed = parse_single_class("translate-x-4").expect("Should parse"); - assert_eq!(parsed.property, "transform"); - assert_eq!(parsed.value, "translateX(1rem)"); - } - #[test] fn test_parse_filter_utilities() { let parsed = parse_single_class("blur").expect("Should parse"); @@ -3851,417 +4124,57 @@ mod tests { assert_eq!(parsed.property, "filter"); assert_eq!(parsed.value, "blur(16px)"); - let parsed = parse_single_class("grayscale").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "grayscale(100%)"); - } - - #[test] - fn test_parse_interactivity_utilities() { - let parsed = parse_single_class("cursor-pointer").expect("Should parse"); - assert_eq!(parsed.property, "cursor"); - assert_eq!(parsed.value, "pointer"); - - let parsed = parse_single_class("select-none").expect("Should parse"); - assert_eq!(parsed.property, "user-select"); - assert_eq!(parsed.value, "none"); - - let parsed = parse_single_class("pointer-events-none").expect("Should parse"); - assert_eq!(parsed.property, "pointer-events"); - assert_eq!(parsed.value, "none"); - } - - #[test] - fn test_parse_svg_utilities() { - let parsed = parse_single_class("fill-red-500").expect("Should parse"); - assert_eq!(parsed.property, "fill"); - assert_eq!(parsed.value, "#ef4444"); - - let parsed = parse_single_class("stroke-black").expect("Should parse"); - assert_eq!(parsed.property, "stroke"); - assert_eq!(parsed.value, "#000"); - - let parsed = parse_single_class("stroke-2").expect("Should parse"); - assert_eq!(parsed.property, "stroke-width"); - assert_eq!(parsed.value, "2"); - } - - #[test] - fn test_peer_variants() { - let parsed = parse_single_class("peer-hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::PeerHover); - - let selector = parsed.variants[0].to_selector(); - assert_eq!(selector.to_string(), ".peer:hover ~ &"); - } - - #[test] - fn test_group_variants() { - let parsed = parse_single_class("group-hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::GroupHover); - - let selector = parsed.variants[0].to_selector(); - assert_eq!( - selector.to_string(), - ":is([role=group],[data-group]):hover &" - ); - } - - #[test] - fn test_media_variants() { - let parsed = parse_single_class("print:hidden").expect("Should parse"); - assert_eq!(parsed.variants.len(), 1); - assert_eq!(parsed.variants[0], TailwindVariant::Print); - - let selector = parsed.variants[0].to_selector(); - if let StyleSelector::At { kind, query, .. } = selector { - assert_eq!(kind, css::style_selector::AtRuleKind::Media); - assert_eq!(query, "print"); - } else { - panic!("Expected At selector"); - } - } - - #[test] - fn test_accessibility_utilities() { - let parsed = parse_single_class("sr-only").expect("Should parse"); - assert_eq!(parsed.property, "position"); - assert_eq!(parsed.value, "absolute"); - } - - #[test] - fn test_empty_string() { - let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); - } - - #[test] - fn test_unknown_class() { - let result = parse_single_class("unknown-class"); - assert!(result.is_none()); - } - - #[test] - fn test_is_likely_tailwind_class_exact_matches() { - assert!(is_likely_tailwind_class("flex")); - assert!(is_likely_tailwind_class("grid")); - assert!(is_likely_tailwind_class("hidden")); - assert!(is_likely_tailwind_class("absolute")); - assert!(is_likely_tailwind_class("truncate")); - assert!(is_likely_tailwind_class("sr-only")); - } - - // ==================== WAVE 1: TailwindVariant Tests ==================== - - // Wave 1.1: Pseudo-class variant selectors (lines 104-131) - #[rstest] - #[case(TailwindVariant::FocusVisible, "&:focus-visible")] - #[case(TailwindVariant::FocusWithin, "&:focus-within")] - #[case(TailwindVariant::Visited, "&:visited")] - #[case(TailwindVariant::Enabled, "&:enabled")] - #[case(TailwindVariant::Checked, "&:checked")] - #[case(TailwindVariant::Indeterminate, "&:indeterminate")] - #[case(TailwindVariant::Default, "&:default")] - #[case(TailwindVariant::Required, "&:required")] - #[case(TailwindVariant::Valid, "&:valid")] - #[case(TailwindVariant::Invalid, "&:invalid")] - #[case(TailwindVariant::InRange, "&:in-range")] - #[case(TailwindVariant::OutOfRange, "&:out-of-range")] - #[case(TailwindVariant::PlaceholderShown, "&:placeholder-shown")] - #[case(TailwindVariant::Autofill, "&:autofill")] - #[case(TailwindVariant::ReadOnly, "&:read-only")] - #[case(TailwindVariant::FirstChild, "&:first-child")] - #[case(TailwindVariant::LastChild, "&:last-child")] - #[case(TailwindVariant::OnlyChild, "&:only-child")] - #[case(TailwindVariant::OddChild, "&:nth-child(odd)")] - #[case(TailwindVariant::EvenChild, "&:nth-child(even)")] - #[case(TailwindVariant::FirstOfType, "&:first-of-type")] - #[case(TailwindVariant::LastOfType, "&:last-of-type")] - #[case(TailwindVariant::OnlyOfType, "&:only-of-type")] - #[case(TailwindVariant::Empty, "&:empty")] - #[case(TailwindVariant::Target, "&:target")] - #[case(TailwindVariant::Open, "&[open]")] - fn test_variant_to_selector_pseudo_classes( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.2: Pseudo-element variant selectors (lines 133-141) - #[rstest] - #[case(TailwindVariant::Placeholder, "&::placeholder")] - #[case(TailwindVariant::Before, "&::before")] - #[case(TailwindVariant::After, "&::after")] - #[case(TailwindVariant::Selection, "&::selection")] - #[case(TailwindVariant::Marker, "&::marker")] - #[case(TailwindVariant::FirstLetter, "&::first-letter")] - #[case(TailwindVariant::FirstLine, "&::first-line")] - #[case(TailwindVariant::Backdrop, "&::backdrop")] - #[case(TailwindVariant::File, "&::file-selector-button")] - fn test_variant_to_selector_pseudo_elements( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.3: Group/Peer variant selectors (lines 143-151) - #[rstest] - #[case(TailwindVariant::GroupFocus, ":is([role=group],[data-group]):focus &")] - #[case( - TailwindVariant::GroupActive, - ":is([role=group],[data-group]):active &" - )] - #[case( - TailwindVariant::GroupDisabled, - ":is([role=group],[data-group]):disabled &" - )] - #[case(TailwindVariant::PeerFocus, ".peer:focus ~ &")] - #[case(TailwindVariant::PeerActive, ".peer:active ~ &")] - #[case(TailwindVariant::PeerDisabled, ".peer:disabled ~ &")] - #[case(TailwindVariant::PeerChecked, ".peer:checked ~ &")] - #[case(TailwindVariant::PeerInvalid, ".peer:invalid ~ &")] - fn test_variant_to_selector_group_peer( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // 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)")] - fn test_variant_to_selector_media_queries( - #[case] variant: TailwindVariant, - #[case] expected_query: &str, - ) { - if let StyleSelector::At { kind, query, .. } = variant.to_selector() { - assert_eq!(kind, css::style_selector::AtRuleKind::Media); - assert_eq!(query, expected_query); - } else { - panic!("Expected At selector for {variant:?}"); - } - } - - // Wave 1.4 continued: Direction variants (lines 161-162) - #[rstest] - #[case(TailwindVariant::Rtl, "[dir=rtl] &")] - #[case(TailwindVariant::Ltr, "[dir=ltr] &")] - fn test_variant_to_selector_direction( - #[case] variant: TailwindVariant, - #[case] expected: &str, - ) { - assert_eq!( - variant.to_selector(), - StyleSelector::Selector(expected.to_string()) - ); - } - - // Wave 1.4: from_prefix tests for untested variants (lines 220-230) - #[rstest] - #[case("screen", TailwindVariant::Screen)] - #[case("portrait", TailwindVariant::Portrait)] - #[case("landscape", TailwindVariant::Landscape)] - #[case("motion-reduce", TailwindVariant::MotionReduce)] - #[case("motion-safe", TailwindVariant::MotionSafe)] - #[case("contrast-more", TailwindVariant::ContrastMore)] - #[case("contrast-less", TailwindVariant::ContrastLess)] - #[case("forced-colors", TailwindVariant::ForcedColors)] - #[case("rtl", TailwindVariant::Rtl)] - #[case("ltr", TailwindVariant::Ltr)] - fn test_from_prefix_media_variants(#[case] prefix: &str, #[case] expected: TailwindVariant) { - assert_eq!(TailwindVariant::from_prefix(prefix), Some(expected)); - } - - // ==================== WAVE 2: Edge Cases & Arbitrary Values ==================== - - // Wave 2.1: combine_selectors with At-rule (lines 292-295) - #[test] - fn test_combine_selectors_at_rule_with_hover() { - let parsed = parse_single_class("print:hover:bg-blue-500").expect("Should parse"); - assert_eq!(parsed.variants.len(), 2); - assert_eq!(parsed.variants[0], TailwindVariant::Print); - assert_eq!(parsed.variants[1], TailwindVariant::Hover); - - 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); - assert_eq!(query, "print"); - assert!(nested.is_some()); - } else { - panic!("Expected At selector"); - } - } - - #[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 [ - (":root[data-theme=dark] &", " &"), - (".peer:hover ~ & &", " &"), - (" & & &", " &"), - ("no-match-here", " &"), - ("", " &"), - (" &", " &"), - (":is([role=group],[data-group]):hover &", " &"), - ] { - let mut buf = haystack.to_string(); - remove_all_substr(&mut buf, needle); - assert_eq!(buf, haystack.replace(needle, ""), "haystack={haystack:?}"); - } + let parsed = parse_single_class("grayscale").expect("Should parse"); + assert_eq!(parsed.property, "filter"); + assert_eq!(parsed.value, "grayscale(100%)"); } #[test] - fn test_combine_selectors_byte_identical_to_prior_impl() { - // Oracle: the exact previous implementation of the multi-variant join loop. - fn old_combine(variants: &[TailwindVariant]) -> String { - let mut selector_str = String::new(); - for variant in variants { - if let StyleSelector::Selector(s) = variant.to_selector() { - if selector_str.is_empty() { - selector_str = s; - } else { - selector_str = - format!("{}{}", selector_str.replace(" &", ""), s.replace('&', "")); - if !selector_str.contains(" &") && !selector_str.ends_with(" &") { - selector_str.push_str(" &"); - } - } - } - } - selector_str - } + fn test_parse_interactivity_utilities() { + let parsed = parse_single_class("cursor-pointer").expect("Should parse"); + assert_eq!(parsed.property, "cursor"); + assert_eq!(parsed.value, "pointer"); - // A representative spread of &-prefixed, &-suffixed and multi-segment selectors - // in several orderings — exercises every branch of the in-place rewrite. - let combos: &[&[TailwindVariant]] = &[ - &[TailwindVariant::Dark, TailwindVariant::Hover], - &[TailwindVariant::Hover, TailwindVariant::Dark], - &[TailwindVariant::GroupHover, TailwindVariant::Focus], - &[TailwindVariant::PeerHover, TailwindVariant::Active], - &[ - TailwindVariant::Hover, - TailwindVariant::Focus, - TailwindVariant::Dark, - ], - &[ - TailwindVariant::Dark, - TailwindVariant::GroupHover, - TailwindVariant::Before, - ], - &[TailwindVariant::Before, TailwindVariant::After], - &[ - TailwindVariant::PeerHover, - TailwindVariant::GroupFocus, - TailwindVariant::Hover, - ], - ]; - - for variants in combos { - let cls = TailwindClass { - responsive: 0, - variants: variants.to_vec(), - property: "color", - value: Cow::Borrowed("red"), - negative: false, - }; - let expected = old_combine(variants); - match cls.combine_selectors().expect("selector") { - StyleSelector::Selector(actual) => { - assert_eq!(actual, expected, "variants={variants:?}"); - } - other => panic!("expected Selector, got {other:?} for {variants:?}"), - } - } - } + let parsed = parse_single_class("select-none").expect("Should parse"); + assert_eq!(parsed.property, "user-select"); + assert_eq!(parsed.value, "none"); - // Wave 2.2: is_valid_tailwind_value edge cases (lines 816, 825, 859, 864-866) - #[test] - fn test_has_tailwind_classes_arbitrary_syntax() { - // Line 816: arbitrary value syntax detection - assert!(has_tailwind_classes("w-[100px]")); - assert!(has_tailwind_classes("bg-[#ff0000]")); - assert!(has_tailwind_classes("p-[calc(100%-20px)]")); + let parsed = parse_single_class("pointer-events-none").expect("Should parse"); + assert_eq!(parsed.property, "pointer-events"); + assert_eq!(parsed.value, "none"); } #[test] - fn test_is_valid_tailwind_value_empty() { - // Line 825: empty value should return false - assert!(!is_valid_tailwind_value("")); + fn test_parse_svg_utilities() { + let parsed = parse_single_class("fill-red-500").expect("Should parse"); + assert_eq!(parsed.property, "fill"); + assert_eq!(parsed.value, "#ef4444"); + + let parsed = parse_single_class("stroke-black").expect("Should parse"); + assert_eq!(parsed.property, "stroke"); + assert_eq!(parsed.value, "#000"); + + let parsed = parse_single_class("stroke-2").expect("Should parse"); + assert_eq!(parsed.property, "stroke-width"); + assert_eq!(parsed.value, "2"); } #[test] - fn test_is_valid_tailwind_value_size_keywords() { - // Line 859: size keywords - assert!(is_valid_tailwind_value("xs")); - assert!(is_valid_tailwind_value("sm")); - assert!(is_valid_tailwind_value("md")); - assert!(is_valid_tailwind_value("lg")); - assert!(is_valid_tailwind_value("xl")); - assert!(is_valid_tailwind_value("2xl")); - assert!(is_valid_tailwind_value("3xl")); + fn test_empty_string() { + let styles = parse_tailwind_to_styles(""); + assert!(styles.is_empty()); } #[test] - fn test_is_valid_tailwind_value_fractions() { - // Lines 864-866: fraction values - assert!(is_valid_tailwind_value("1/2")); - assert!(is_valid_tailwind_value("1/3")); - assert!(is_valid_tailwind_value("2/3")); - assert!(is_valid_tailwind_value("1/4")); - assert!(is_valid_tailwind_value("3/4")); + fn test_unknown_class() { + let result = parse_single_class("unknown-class"); + assert!(result.is_none()); } + // ==================== WAVE 1: TailwindVariant Tests ==================== + + // ==================== WAVE 2: Edge Cases & Arbitrary Values ==================== + // Wave 2.3: parse_arbitrary_value extended tests (lines 1057-1084) #[rstest] #[case("border-[#ff0000]", "border-color", "#ff0000")] @@ -4269,7 +4182,6 @@ mod tests { #[case("z-[999]", "z-index", "999")] #[case("font-[Arial]", "font-family", "Arial")] #[case("tracking-[0.2em]", "letter-spacing", "0.2em")] - #[case("leading-[2]", "line-height", "2")] #[case("duration-[500ms]", "transition-duration", "500ms")] #[case("delay-[200ms]", "transition-delay", "200ms")] #[case("aspect-[16/9]", "aspect-ratio", "16/9")] @@ -4286,33 +4198,37 @@ mod tests { } #[rstest] - #[case("scale-[1.5]", "transform", "scale(1.5)")] - #[case("rotate-[30deg]", "transform", "rotate(30deg)")] - #[case("translate-x-[50px]", "transform", "translateX(50px)")] - #[case("translate-y-[50px]", "transform", "translateY(50px)")] - #[case("skew-x-[10deg]", "transform", "skewX(10deg)")] - #[case("skew-y-[10deg]", "transform", "skewY(10deg)")] + #[case("scale-[1.5]", &[("scale", "1.5")])] + #[case("rotate-[30deg]", &[("rotate", "30deg")])] + #[case("translate-x-[50px]", &[("--tw-translate-x", "50px"), ("translate", TRANSLATE)])] + #[case("translate-y-[50px]", &[("--tw-translate-y", "50px"), ("translate", TRANSLATE)])] + #[case( + "translate-[50px]", + &[("--tw-translate-x", "50px"), ("--tw-translate-y", "50px"), ("translate", TRANSLATE)] + )] + #[case("scale-x-[2]", &[("--tw-scale-x", "2"), ("scale", SCALE)])] + #[case("skew-x-[10deg]", &[("--tw-skew-x", "skewX(10deg)"), ("transform", TRANSFORM)])] + #[case("skew-y-[10deg]", &[("--tw-skew-y", "skewY(10deg)"), ("transform", TRANSFORM)])] + #[case( + "skew-[10deg]", + &[ + ("--tw-skew-x", "skewX(10deg)"), + ("--tw-skew-y", "skewY(10deg)"), + ("transform", TRANSFORM), + ] + )] + #[case("-translate-x-[13px]", &[("--tw-translate-x", "calc(13px * -1)"), ("translate", TRANSLATE)])] + #[case("-skew-y-[6deg]", &[("--tw-skew-y", "skewY(calc(6deg * -1))"), ("transform", TRANSFORM)])] fn test_parse_arbitrary_transform_values( #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, + #[case] expected: &[(&str, &str)], ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!(declarations(class), Some(owned(expected))); } #[rstest] - #[case( - "grid-cols-[200px_1fr]", - "grid-template-columns", - "repeat(200px 1fr, minmax(0, 1fr))" - )] - #[case( - "grid-rows-[auto_1fr]", - "grid-template-rows", - "repeat(auto 1fr, minmax(0, 1fr))" - )] + #[case("grid-cols-[200px_1fr]", "grid-template-columns", "200px 1fr")] + #[case("grid-rows-[auto_1fr]", "grid-template-rows", "auto 1fr")] #[case("col-span-[2]", "grid-column", "span 2 / span 2")] #[case("row-span-[3]", "grid-row", "span 3 / span 3")] fn test_parse_arbitrary_grid_values( @@ -4576,29 +4492,32 @@ mod tests { assert_eq!(parsed.value, expected_value); } + // `leading-*` also sets `--tw-leading`, which wins over a font size's line height #[rstest] - #[case("leading-none", "line-height", "1")] - #[case("leading-tight", "line-height", "1.25")] - #[case("leading-snug", "line-height", "1.375")] - #[case("leading-normal", "line-height", "1.5")] - #[case("leading-relaxed", "line-height", "1.625")] - #[case("leading-loose", "line-height", "2")] - #[case("leading-3", "line-height", ".75rem")] - #[case("leading-4", "line-height", "1rem")] - #[case("leading-5", "line-height", "1.25rem")] - #[case("leading-6", "line-height", "1.5rem")] - #[case("leading-7", "line-height", "1.75rem")] - #[case("leading-8", "line-height", "2rem")] - #[case("leading-9", "line-height", "2.25rem")] - #[case("leading-10", "line-height", "2.5rem")] - fn test_parse_leading_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("leading-none", "1")] + #[case("leading-tight", "1.25")] + #[case("leading-snug", "1.375")] + #[case("leading-normal", "1.5")] + #[case("leading-relaxed", "1.625")] + #[case("leading-loose", "2")] + #[case("leading-3", "0.75rem")] + #[case("leading-4", "1rem")] + #[case("leading-5", "1.25rem")] + #[case("leading-6", "1.5rem")] + #[case("leading-7", "1.75rem")] + #[case("leading-8", "2rem")] + #[case("leading-9", "2.25rem")] + #[case("leading-10", "2.5rem")] + #[case("leading-[2]", "2")] + #[case("leading-(--l)", "var(--l)")] + fn test_parse_leading_utilities(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("--tw-leading", expected_value), + ("line-height", expected_value) + ])) + ); } // Wave 4.4: List styles & alignment (lines 1947-1965) @@ -4757,24 +4676,27 @@ mod tests { assert_eq!(parsed.value, expected_value); } - // Wave 5.3: Border corners/sides (lines 2081-2189) + // Sides round both of their corners #[rstest] - #[case("rounded-t-lg", "border-top-left-radius", "0.5rem")] - #[case("rounded-r-lg", "border-top-right-radius", "0.5rem")] - #[case("rounded-b-lg", "border-bottom-right-radius", "0.5rem")] - #[case("rounded-l-lg", "border-bottom-left-radius", "0.5rem")] - #[case("rounded-tl-lg", "border-top-left-radius", "0.5rem")] - #[case("rounded-tr-lg", "border-top-right-radius", "0.5rem")] - #[case("rounded-br-lg", "border-bottom-right-radius", "0.5rem")] - #[case("rounded-bl-lg", "border-bottom-left-radius", "0.5rem")] - fn test_parse_border_radius_corners( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("rounded-t-lg", &[("border-top-left-radius", "0.5rem"), ("border-top-right-radius", "0.5rem")])] + #[case("rounded-r-lg", &[("border-top-right-radius", "0.5rem"), ("border-bottom-right-radius", "0.5rem")])] + #[case("rounded-b-lg", &[("border-bottom-right-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])] + #[case("rounded-l-lg", &[("border-top-left-radius", "0.5rem"), ("border-bottom-left-radius", "0.5rem")])] + #[case("rounded-s-lg", &[("border-start-start-radius", "0.5rem"), ("border-end-start-radius", "0.5rem")])] + #[case("rounded-e-lg", &[("border-start-end-radius", "0.5rem"), ("border-end-end-radius", "0.5rem")])] + #[case("rounded-t", &[("border-top-left-radius", "0.25rem"), ("border-top-right-radius", "0.25rem")])] + #[case("rounded-tl-lg", &[("border-top-left-radius", "0.5rem")])] + #[case("rounded-tr-lg", &[("border-top-right-radius", "0.5rem")])] + #[case("rounded-br-lg", &[("border-bottom-right-radius", "0.5rem")])] + #[case("rounded-bl-lg", &[("border-bottom-left-radius", "0.5rem")])] + #[case("rounded-ss-lg", &[("border-start-start-radius", "0.5rem")])] + #[case("rounded-se-lg", &[("border-start-end-radius", "0.5rem")])] + #[case("rounded-ee-lg", &[("border-end-end-radius", "0.5rem")])] + #[case("rounded-es-lg", &[("border-end-start-radius", "0.5rem")])] + #[case("rounded-t-[3px]", &[("border-top-left-radius", "3px"), ("border-top-right-radius", "3px")])] + #[case("rounded-[3px]", &[("border-radius", "3px")])] + fn test_parse_border_radius_corners(#[case] class: &str, #[case] expected: &[(&str, &str)]) { + assert_eq!(declarations(class), Some(owned(expected))); } #[rstest] @@ -5109,81 +5031,132 @@ mod tests { assert_eq!(parsed.value, expected_value); } - // Wave 5.8: Transforms (lines 2630-2716) #[rstest] - #[case("scale-x-0", "transform", "scaleX(0)")] - #[case("scale-x-50", "transform", "scaleX(0.5)")] - #[case("scale-x-100", "transform", "scaleX(1)")] - #[case("scale-x-150", "transform", "scaleX(1.5)")] - #[case("scale-y-0", "transform", "scaleY(0)")] - #[case("scale-y-50", "transform", "scaleY(0.5)")] - #[case("scale-y-100", "transform", "scaleY(1)")] - #[case("scale-y-150", "transform", "scaleY(1.5)")] + #[case("scale-x-0", "--tw-scale-x", "0%")] + #[case("scale-x-50", "--tw-scale-x", "50%")] + #[case("scale-x-100", "--tw-scale-x", "100%")] + #[case("scale-x-150", "--tw-scale-x", "150%")] + #[case("scale-y-0", "--tw-scale-y", "0%")] + #[case("scale-y-50", "--tw-scale-y", "50%")] + #[case("scale-y-100", "--tw-scale-y", "100%")] + #[case("scale-y-150", "--tw-scale-y", "150%")] + #[case("-scale-x-50", "--tw-scale-x", "-50%")] fn test_parse_scale_axis_utilities( #[case] class: &str, #[case] expected_prop: &str, #[case] expected_value: &str, ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!( + declarations(class), + Some(owned(&[(expected_prop, expected_value), ("scale", SCALE)])) + ); } #[rstest] - #[case("rotate-0", "transform", "rotate(0deg)")] - #[case("rotate-1", "transform", "rotate(1deg)")] - #[case("rotate-2", "transform", "rotate(2deg)")] - #[case("rotate-3", "transform", "rotate(3deg)")] - #[case("rotate-6", "transform", "rotate(6deg)")] - #[case("rotate-12", "transform", "rotate(12deg)")] - #[case("rotate-90", "transform", "rotate(90deg)")] - #[case("rotate-180", "transform", "rotate(180deg)")] - fn test_parse_rotate_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("scale-110", "110%")] + #[case("-scale-110", "-110%")] + fn test_parse_scale_utilities(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("--tw-scale-x", expected_value), + ("--tw-scale-y", expected_value), + ("--tw-scale-z", expected_value), + ("scale", SCALE), + ])) + ); + } + + #[rstest] + #[case("rotate-0", "0deg")] + #[case("rotate-1", "1deg")] + #[case("rotate-2", "2deg")] + #[case("rotate-3", "3deg")] + #[case("rotate-6", "6deg")] + #[case("rotate-12", "12deg")] + #[case("rotate-90", "90deg")] + #[case("rotate-180", "180deg")] + #[case("rotate-15", "15deg")] + #[case("-rotate-45", "-45deg")] + fn test_parse_rotate_utilities(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[("rotate", expected_value)])) + ); } #[rstest] - #[case("translate-y-4", "transform", "translateY(1rem)")] - #[case("translate-y-px", "transform", "translateY(1px)")] - #[case("translate-y-full", "transform", "translateY(100%)")] - #[case("translate-y-1/2", "transform", "translateY(50%)")] + #[case("translate-y-4", "--tw-translate-y", "1rem")] + #[case("translate-y-px", "--tw-translate-y", "1px")] + #[case("translate-y-full", "--tw-translate-y", "100%")] + #[case("translate-y-1/2", "--tw-translate-y", "50%")] + #[case("translate-x-4", "--tw-translate-x", "1rem")] + #[case("-translate-x-4", "--tw-translate-x", "-1rem")] + #[case("-translate-x-1/2", "--tw-translate-x", "-50%")] fn test_parse_translate_y_utilities( #[case] class: &str, #[case] expected_prop: &str, #[case] expected_value: &str, ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!( + declarations(class), + Some(owned(&[ + (expected_prop, expected_value), + ("translate", TRANSLATE) + ])) + ); + } + + #[test] + fn test_parse_translate_both_axes() { + assert_eq!( + declarations("translate-2"), + Some(owned(&[ + ("--tw-translate-x", "0.5rem"), + ("--tw-translate-y", "0.5rem"), + ("translate", TRANSLATE), + ])) + ); } #[rstest] - #[case("skew-x-0", "transform", "skewX(0deg)")] - #[case("skew-x-1", "transform", "skewX(1deg)")] - #[case("skew-x-2", "transform", "skewX(2deg)")] - #[case("skew-x-3", "transform", "skewX(3deg)")] - #[case("skew-x-6", "transform", "skewX(6deg)")] - #[case("skew-x-12", "transform", "skewX(12deg)")] - #[case("skew-y-0", "transform", "skewY(0deg)")] - #[case("skew-y-1", "transform", "skewY(1deg)")] - #[case("skew-y-2", "transform", "skewY(2deg)")] - #[case("skew-y-3", "transform", "skewY(3deg)")] - #[case("skew-y-6", "transform", "skewY(6deg)")] - #[case("skew-y-12", "transform", "skewY(12deg)")] + #[case("skew-x-0", "--tw-skew-x", "skewX(0deg)")] + #[case("skew-x-1", "--tw-skew-x", "skewX(1deg)")] + #[case("skew-x-2", "--tw-skew-x", "skewX(2deg)")] + #[case("skew-x-3", "--tw-skew-x", "skewX(3deg)")] + #[case("skew-x-6", "--tw-skew-x", "skewX(6deg)")] + #[case("skew-x-12", "--tw-skew-x", "skewX(12deg)")] + #[case("skew-y-0", "--tw-skew-y", "skewY(0deg)")] + #[case("skew-y-1", "--tw-skew-y", "skewY(1deg)")] + #[case("skew-y-2", "--tw-skew-y", "skewY(2deg)")] + #[case("skew-y-3", "--tw-skew-y", "skewY(3deg)")] + #[case("skew-y-6", "--tw-skew-y", "skewY(6deg)")] + #[case("skew-y-12", "--tw-skew-y", "skewY(12deg)")] + #[case("-skew-y-6", "--tw-skew-y", "skewY(-6deg)")] fn test_parse_skew_utilities( #[case] class: &str, #[case] expected_prop: &str, #[case] expected_value: &str, ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + assert_eq!( + declarations(class), + Some(owned(&[ + (expected_prop, expected_value), + ("transform", TRANSFORM) + ])) + ); + } + + #[test] + fn test_parse_skew_both_axes() { + assert_eq!( + declarations("skew-3"), + Some(owned(&[ + ("--tw-skew-x", "skewX(3deg)"), + ("--tw-skew-y", "skewY(3deg)"), + ("transform", TRANSFORM), + ])) + ); } #[rstest] @@ -5316,46 +5289,11 @@ mod tests { // WAVE 6: Coverage Gap Tests - Remaining Uncovered Lines // ============================================================================ - // Wave 6.1: TailwindVariant::from_prefix() returning None (line 230) - #[test] - fn test_tailwind_variant_unknown_prefix_returns_none() { - assert_eq!(TailwindVariant::from_prefix("unknown-prefix"), None); - assert_eq!(TailwindVariant::from_prefix("nonexistent"), None); - assert_eq!(TailwindVariant::from_prefix("foo-bar"), None); - assert_eq!(TailwindVariant::from_prefix("xyz"), None); - assert_eq!(TailwindVariant::from_prefix(""), None); - } - - // Wave 6.2: parse_arbitrary_value edge cases (lines 1015, 1084) - #[test] - fn test_parse_arbitrary_value_malformed_brackets() { - // Test bracket_end <= bracket_start case (line 1015) - let result = parse_arbitrary_value("]["); - assert!(result.is_none()); - - let result = parse_arbitrary_value("w-]["); - assert!(result.is_none()); - } - - #[test] - fn test_parse_arbitrary_value_unknown_prefix() { - // Test unknown prefix fallback (line 1084) - let result = parse_arbitrary_value("unknown-[value]"); - assert!(result.is_none()); - - let result = parse_arbitrary_value("foo-[bar]"); - assert!(result.is_none()); - - let result = parse_arbitrary_value("xyz-[123px]"); - assert!(result.is_none()); - } - - // Wave 6.3: Custom aspect-ratio (line 1202) #[rstest] - #[case("aspect-4-3", "aspect-ratio", "4 / 3")] - #[case("aspect-16-10", "aspect-ratio", "16 / 10")] - #[case("aspect-21-9", "aspect-ratio", "21 / 9")] - #[case("aspect-3-2", "aspect-ratio", "3 / 2")] + #[case("aspect-4/3", "aspect-ratio", "4/3")] + #[case("aspect-16/10", "aspect-ratio", "16/10")] + #[case("aspect-21/9", "aspect-ratio", "21/9")] + #[case("aspect-3/2", "aspect-ratio", "3/2")] fn test_parse_custom_aspect_ratio( #[case] class: &str, #[case] expected_prop: &str, @@ -5455,16 +5393,16 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("truncate", "text-overflow", "ellipsis")] - fn test_parse_truncate( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse truncate"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[test] + fn test_parse_truncate() { + assert_eq!( + declarations("truncate"), + Some(owned(&[ + ("overflow", "hidden"), + ("text-overflow", "ellipsis"), + ("white-space", "nowrap"), + ])) + ); } #[rstest] @@ -5484,31 +5422,23 @@ mod tests { assert_eq!(parsed.value, expected_value); } - // Wave 6.8: Tracking/leading fallbacks (lines 1918, 1940) + // Only Tailwind's names compile; any other word is someone else's class #[rstest] - #[case("tracking-custom", "letter-spacing", "custom")] - #[case("tracking-0.5em", "letter-spacing", "0.5em")] - fn test_parse_tracking_fallback( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse tracking fallback"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("tracking-custom")] + #[case("tracking-0.5em")] + fn test_parse_tracking_fallback(#[case] class: &str) { + assert_eq!(declarations(class), None); } - #[rstest] - #[case("leading-custom", "line-height", "custom")] - #[case("leading-1.5", "line-height", "1.5")] - fn test_parse_leading_fallback( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse leading fallback"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[test] + fn test_parse_leading_fallback() { + assert_eq!( + declarations("leading-1.5"), + Some(owned(&[ + ("--tw-leading", "0.375rem"), + ("line-height", "0.375rem") + ])) + ); } // Wave 6.9: List style fallback (line 1953) @@ -5739,29 +5669,23 @@ mod tests { assert!(result.is_none()); } - // Wave 6.18: Transform fallbacks (lines 2654-2714) #[test] fn test_rotate_unknown_value_returns_none() { - let result = parse_single_class("rotate-unknown"); - assert!(result.is_none()); - let result = parse_single_class("rotate-999"); - assert!(result.is_none()); + assert!(parse_single_class("rotate-unknown").is_none()); + // 3D rotation is not compiled + assert!(parse_single_class("rotate-x-45").is_none()); } #[test] fn test_skew_x_unknown_value_returns_none() { - let result = parse_single_class("skew-x-unknown"); - assert!(result.is_none()); - let result = parse_single_class("skew-x-99"); - assert!(result.is_none()); + assert!(parse_single_class("skew-x-unknown").is_none()); + assert!(parse_single_class("skew-x-1px").is_none()); } #[test] fn test_skew_y_unknown_value_returns_none() { - let result = parse_single_class("skew-y-unknown"); - assert!(result.is_none()); - let result = parse_single_class("skew-y-99"); - assert!(result.is_none()); + assert!(parse_single_class("skew-y-unknown").is_none()); + assert!(parse_single_class("skew-y-1px").is_none()); } #[test] @@ -5811,34 +5735,6 @@ mod tests { // WAVE 7: Additional Coverage Gap Tests // ============================================================================ - // Wave 7.1: is_likely_tailwind_class arbitrary value syntax (line 816) - #[rstest] - #[case("w-[100px]")] - #[case("h-[50vh]")] - #[case("bg-[#ff0000]")] - #[case("text-[1.5rem]")] - #[case("grid-cols-[1fr_2fr]")] - #[case("p-[calc(100%-20px)]")] - fn test_is_likely_tailwind_class_arbitrary_syntax(#[case] class: &str) { - assert!(is_likely_tailwind_class(class)); - } - - // Wave 7.2: is_valid_tailwind_value fraction values (lines 864-866) - #[rstest] - #[case("1/2", true)] - #[case("2/3", true)] - #[case("3/4", true)] - #[case("5/6", true)] - #[case("11/12", true)] - #[case("a/b", false)] - #[case("foo/bar", false)] - fn test_is_valid_tailwind_value_fractions_extended( - #[case] value: &str, - #[case] expected: bool, - ) { - assert_eq!(is_valid_tailwind_value(value), expected); - } - // Wave 7.3: min-w/max-w/min-h/max-h/size with SPACING_SCALE values (lines 1682, 1719, 1760, 1783, 1797) #[rstest] #[case("min-w-4", "min-width", "1rem")] @@ -5902,19 +5798,21 @@ mod tests { } #[rstest] - #[case("size-4", "width", "1rem")] - #[case("size-8", "width", "2rem")] - #[case("size-12", "width", "3rem")] - #[case("size-px", "width", "1px")] - #[case("size-0.5", "width", "0.125rem")] - fn test_parse_size_spacing_scale( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse size spacing"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); + #[case("size-4", "1rem")] + #[case("size-8", "2rem")] + #[case("size-12", "3rem")] + #[case("size-px", "1px")] + #[case("size-0.5", "0.125rem")] + #[case("size-full", "100%")] + #[case("size-[3px]", "3px")] + fn test_parse_size_spacing_scale(#[case] class: &str, #[case] expected_value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("width", expected_value), + ("height", expected_value) + ])) + ); } // Wave 7.4: text- prefix fallback when not font-size or text-align (line 1833) @@ -6066,30 +5964,6 @@ mod tests { // WAVE 8: Coverage Gap Tests for Lines 816, 866, 295 // ============================================================================ - // Wave 8.1: has_tailwind_classes with arbitrary CSS syntax (line 816) - // Classes with [...] that don't match any prefix trigger the arbitrary check - // Note: Classes with ':' get split (variant prefix removal), so avoid them - #[rstest] - #[case("custom-[value]")] - #[case("xyz-[test]")] - #[case("my-class-[10px]")] - #[case("foo-[bar]")] - fn test_has_tailwind_classes_arbitrary_css_syntax(#[case] class: &str) { - assert!(has_tailwind_classes(class)); - } - - // Wave 8.2: is_likely_tailwind_class with pure arbitrary syntax (line 816) - // These classes have brackets but don't match any standard prefix - // The check at line 816 triggers when a class has [...] but doesn't match prefixes - #[rstest] - #[case("custom-[value]")] - #[case("xyz-[100px]")] - #[case("my-[test]")] - #[case("unknown-[arbitrary]")] - fn test_is_likely_tailwind_class_pure_arbitrary_syntax(#[case] class: &str) { - assert!(is_likely_tailwind_class(class)); - } - // Wave 8.3: parse_tailwind_to_styles integration for rounded variants (via BORDER_RADIUS_SCALE) #[test] #[serial] @@ -6114,16 +5988,531 @@ mod tests { assert_eq!(styles.len(), 5); } - // Wave 8.5: is_valid_tailwind_value fraction edge case (line 866) - // Edge case where value starts with '/' - empty first part is vacuously all-digits + // ==================== Variants (Tailwind v4) ==================== + + #[rstest] + #[case("first:p-4", "&:first-child")] + #[case("last:p-4", "&:last-child")] + #[case("only:p-4", "&:only-child")] + #[case("odd:p-4", "&:nth-child(odd)")] + #[case("even:p-4", "&:nth-child(even)")] + #[case("first-of-type:p-4", "&:first-of-type")] + #[case("last-of-type:p-4", "&:last-of-type")] + #[case("only-of-type:p-4", "&:only-of-type")] + #[case("visited:p-4", "&:visited")] + #[case("target:p-4", "&:target")] + #[case("open:p-4", "&:is([open], :popover-open, :open)")] + #[case("default:p-4", "&:default")] + #[case("checked:p-4", "&:checked")] + #[case("indeterminate:p-4", "&:indeterminate")] + #[case("placeholder-shown:p-4", "&:placeholder-shown")] + #[case("autofill:p-4", "&:autofill")] + #[case("optional:p-4", "&:optional")] + #[case("required:p-4", "&:required")] + #[case("valid:p-4", "&:valid")] + #[case("invalid:p-4", "&:invalid")] + #[case("user-valid:p-4", "&:user-valid")] + #[case("user-invalid:p-4", "&:user-invalid")] + #[case("in-range:p-4", "&:in-range")] + #[case("out-of-range:p-4", "&:out-of-range")] + #[case("read-only:p-4", "&:read-only")] + #[case("empty:p-4", "&:empty")] + #[case("focus-within:p-4", "&:focus-within")] + #[case("focus:p-4", "&:focus")] + #[case("focus-visible:p-4", "&:focus-visible")] + #[case("active:p-4", "&:active")] + #[case("enabled:p-4", "&:enabled")] + #[case("disabled:p-4", "&:disabled")] + #[case("inert:p-4", "&:is([inert], [inert] *)")] + #[case("placeholder:p-4", "&::placeholder")] + #[case("file:p-4", "&::file-selector-button")] + #[case("backdrop:p-4", "&::backdrop")] + #[case("first-letter:p-4", "&::first-letter")] + #[case("first-line:p-4", "&::first-line")] + #[case("details-content:p-4", "&::details-content")] + #[case("dark:p-4", ":root[data-theme=dark] &")] + #[case("rtl:p-4", "&:where(:dir(rtl), [dir=\"rtl\"], [dir=\"rtl\"] *)")] + #[case("ltr:p-4", "&:where(:dir(ltr), [dir=\"ltr\"], [dir=\"ltr\"] *)")] + #[case("*:p-4", ":is(& > *)")] + #[case("**:p-4", ":is(& *)")] + #[case("aria-checked:p-4", "&[aria-checked=\"true\"]")] + #[case("aria-[sort=ascending]:p-4", "&[aria-sort=\"ascending\"]")] + #[case("aria-[a=b_c]:p-4", "&[aria-a=\"b c\"]")] + #[case("aria-[label]:p-4", "&[aria-label]")] + #[case("data-active:p-4", "&[data-active]")] + #[case("data-[state=open]:p-4", "&[data-state=\"open\"]")] + #[case("data-[state='open']:p-4", "&[data-state='open']")] + #[case("nth-3:p-4", "&:nth-child(3)")] + #[case("nth-last-3:p-4", "&:nth-last-child(3)")] + #[case("nth-of-type-3:p-4", "&:nth-of-type(3)")] + #[case("nth-last-of-type-3:p-4", "&:nth-last-of-type(3)")] + #[case("nth-[2n+1_of_.x]:p-4", "&:nth-child(2n+1 of .x)")] + #[case("has-[img]:p-4", "&:has(:is(img))")] + #[case("has-[.x_y]:p-4", "&:has(:is(.x y))")] + #[case("has-checked:p-4", "&:has(:checked)")] + #[case("has-aria-checked:p-4", "&:has([aria-checked=\"true\"])")] + #[case("group-focus:p-4", "&:is(:where(.group):focus *)")] + #[case("group-first:p-4", "&:is(:where(.group):first-child *)")] + #[case("group-data-active:p-4", "&:is(:where(.group)[data-active] *)")] + #[case("group-has-checked:p-4", "&:is(:where(.group):has(:checked) *)")] + #[case("group-focus/item:p-4", "&:is(:where(.group\\/item):focus *)")] + #[case("peer-checked:p-4", "&:is(:where(.peer):checked ~ *)")] + #[case( + "peer-aria-checked:p-4", + "&:is(:where(.peer)[aria-checked=\"true\"] ~ *)" + )] + #[case( + "peer-placeholder-shown:p-4", + "&:is(:where(.peer):placeholder-shown ~ *)" + )] + #[case("[&>*]:p-4", "&>*")] + #[case("[&_p]:p-4", "& p")] + #[case("[.dark_&]:p-4", ".dark &")] + #[case("[&:nth-child(3)]:p-4", "&:nth-child(3)")] + #[case("hover:focus:p-4", "@media(hover:hover) &:hover:focus")] + #[case("*:hover:p-4", "@media(hover:hover) :is(& > *):hover")] + #[case("hover:[&>*]:p-4", "@media(hover:hover) &:hover>*")] + #[case("[&>*]:hover:p-4", "@media(hover:hover) &>*:hover")] + #[case( + "group-hover:focus:p-4", + "@media(hover:hover) &:is(:where(.group):hover *):focus" + )] + #[case("dark:hover:p-4", "@media(hover:hover) :root[data-theme=dark] &:hover")] + #[case("hover:dark:p-4", "@media(hover:hover) :root[data-theme=dark] &:hover")] + #[case("before:hover:p-4", "@media(hover:hover) &::before:hover")] + fn test_selector_variants(#[case] class: &str, #[case] expected: &str) { + assert_eq!(conditions(class), Some((0, vec![expected.to_string()]))); + } + + #[rstest] + #[case("hover:p-4", 0, &["@media(hover:hover) &:hover"])] + #[case("group-hover:p-4", 0, &["@media(hover:hover) &:is(:where(.group):hover *)"])] + #[case("group-hover/item:p-4", 0, &["@media(hover:hover) &:is(:where(.group\\/item):hover *)"])] + #[case("peer-hover:p-4", 0, &["@media(hover:hover) &:is(:where(.peer):hover ~ *)"])] + #[case("motion-safe:p-4", 0, &["@media(prefers-reduced-motion:no-preference)"])] + #[case("motion-reduce:p-4", 0, &["@media(prefers-reduced-motion:reduce)"])] + #[case("contrast-more:p-4", 0, &["@media(prefers-contrast:more)"])] + #[case("contrast-less:p-4", 0, &["@media(prefers-contrast:less)"])] + #[case("portrait:p-4", 0, &["@media(orientation:portrait)"])] + #[case("landscape:p-4", 0, &["@media(orientation:landscape)"])] + #[case("print:p-4", 0, &["@media print"])] + #[case("forced-colors:p-4", 0, &["@media(forced-colors:active)"])] + #[case("inverted-colors:p-4", 0, &["@media(inverted-colors:inverted)"])] + #[case("noscript:p-4", 0, &["@media(scripting:none)"])] + #[case("pointer-fine:p-4", 0, &["@media(pointer:fine)"])] + #[case("pointer-coarse:p-4", 0, &["@media(pointer:coarse)"])] + #[case("pointer-none:p-4", 0, &["@media(pointer:none)"])] + #[case("any-pointer-fine:p-4", 0, &["@media(any-pointer:fine)"])] + #[case("any-pointer-coarse:p-4", 0, &["@media(any-pointer:coarse)"])] + #[case("any-pointer-none:p-4", 0, &["@media(any-pointer:none)"])] + #[case("supports-[display:grid]:p-4", 0, &["@supports(display:grid)"])] + #[case("supports-[(display:grid)]:p-4", 0, &["@supports(display:grid)"])] + #[case("[@media(width>=10px)]:p-4", 0, &["@media(width>=10px)"])] + #[case("[@supports(display:grid)]:p-4", 0, &["@supports(display:grid)"])] + #[case("sm:p-4", 1, &[""])] + #[case("md:p-4", 2, &[""])] + #[case("lg:p-4", 3, &[""])] + #[case("xl:p-4", 4, &[""])] + #[case("2xl:p-4", 5, &[""])] + #[case("sm:md:p-4", 2, &[""])] + #[case("md:hover:p-4", 2, &["@media(hover:hover) &:hover"])] + #[case("hover:md:p-4", 2, &["@media(hover:hover) &:hover"])] + #[case("selection:p-4", 0, &["& *::selection", "&::selection"])] + #[case( + "marker:p-4", + 0, + &["& *::marker", "&::marker", "& *::-webkit-details-marker", "&::-webkit-details-marker"] + )] + #[case("md:selection:p-4", 2, &["& *::selection", "&::selection"])] + #[case("[&_p,&_li]:p-4", 0, &["& p", "& li"])] + #[case("[&_p,&_li]:hover:p-4", 0, &["@media(hover:hover) & p:hover", "@media(hover:hover) & li:hover"])] + fn test_variant_conditions(#[case] class: &str, #[case] level: u8, #[case] expected: &[&str]) { + assert_eq!( + conditions(class), + Some((level, expected.iter().map(ToString::to_string).collect())) + ); + } + + #[test] + fn test_stacked_media_variants() { + let (_, conditions) = conditions("print:hover:p-4").unwrap(); + assert_eq!(conditions.len(), 1); + assert!(conditions[0].starts_with("@media print"), "{conditions:?}"); + assert!(conditions[0].contains("(hover:hover)"), "{conditions:?}"); + assert!(conditions[0].ends_with("&:hover"), "{conditions:?}"); + } + + // `::before`/`::after` only render with `content`, which Tailwind adds + #[test] + fn test_pseudo_element_content() { + assert_eq!( + declarations("before:block"), + Some(owned(&[ + ("content", "var(--tw-content)"), + ("display", "block") + ])) + ); + assert_eq!( + declarations("after:content-['x']"), + Some(owned(&[ + ("--tw-content", "'x'"), + ("content", "var(--tw-content)") + ])) + ); + assert_eq!( + declarations("placeholder:block"), + Some(owned(&[("display", "block")])) + ); + } + + // A class whose variants or utility are not understood in full stays as written #[rstest] - #[case("/2", true)] - #[case("/12", true)] - #[case("/123", true)] - fn test_is_valid_tailwind_value_slash_prefix_fraction( - #[case] value: &str, - #[case] expected: bool, + #[case("custom")] + #[case("prose")] + #[case("my-p-4-class")] + #[case("analytics-hook")] + #[case("container")] + #[case("group")] + #[case("peer")] + #[case("group/item")] + #[case("")] + #[case(":p-4")] + #[case("hover:")] + #[case("hover::p-4")] + #[case("not-hover:p-4")] + #[case("in-focus:p-4")] + #[case("max-md:p-4")] + #[case("min-[700px]:p-4")] + #[case("@sm:p-4")] + #[case("@container:p-4")] + #[case("starting:p-4")] + #[case("screen:p-4")] + #[case("unknown:p-4")] + #[case("[.x]:p-4")] + #[case("[@foo_x]:p-4")] + #[case("[&]]:p-4")] + #[case("supports-grid:p-4")] + #[case("supports-[grid]:p-4")] + #[case("group-[.x]:p-4")] + #[case("group-unknown:p-4")] + #[case("group-hover/a.b:p-4")] + #[case("peer-hover/:p-4")] + #[case("has-hover:p-4")] + #[case("has-has-[x]:p-4")] + #[case("has-[&>p]:p-4")] + #[case("has-unknown:p-4")] + #[case("aria-foo:p-4")] + #[case("aria-[a~=b]:p-4")] + #[case("data-[]:p-4")] + #[case("data-[a=]:p-4")] + #[case("data-[a=b\"c]:p-4")] + #[case("data-[a_b]:p-4")] + #[case("data-a.b:p-4")] + #[case("nth-0:p-4")] + #[case("nth-x:p-4")] + #[case("print:[@media_screen]:p-4")] + #[case("p-4!")] + #[case("!p-4")] + #[case("bg-red-500/50")] + #[case("text-red-500/6")] + #[case("text-sm/x")] + #[case("text-sm/[_]")] + #[case("text-sm/(x)")] + #[case("select-wrapper")] + #[case("order-summary")] + #[case("cursor-foo")] + #[case("align-left")] + #[case("whitespace-x")] + #[case("hyphens-x")] + #[case("pointer-events-x")] + #[case("touch-x")] + #[case("mix-blend-x")] + #[case("bg-blend-plus-lighter")] + #[case("col-start-x")] + #[case("col-end-x")] + #[case("row-start-x")] + #[case("row-end-x")] + #[case("columns-x")] + #[case("aspect-x")] + #[case("aspect-4-3")] + #[case("aspect-a/3")] + #[case("leading-custom")] + #[case("leading-(x)")] + #[case("-p-4")] + #[case("-m-auto")] + #[case("-order-first")] + #[case("-size-4")] + #[case("-truncate")] + #[case("-[color:red]")] + #[case("-p-[4px]")] + #[case("--[1px]")] + #[case("[Color:red]")] + #[case("[--x.y:1]")] + #[case("[color:_]")] + #[case("[colorred]")] + #[case("[color:red")] + #[case("w-[]")] + #[case("w-]")] + #[case("w[1px]")] + #[case("foo-[1px]")] + #[case("w-(x)")] + #[case("w-[_]")] + #[case("bg-[size:cover]")] + #[case("text-[2]")] + #[case("text-[angle:3deg]")] + #[case("bg-[10px]")] + #[case("border-[3]")] + #[case("border-[url(x)]")] + #[case("borderx-[3px]")] + #[case("border-z-[3px]")] + #[case("roundedx-[3px]")] + #[case("rounded-z-[3px]")] + #[case("rounded-huge")] + #[case("roundedx")] + #[case("font-[2px]")] + #[case("outline-[url(x)]")] + #[case("size-screen")] + #[case("translate-x-auto")] + #[case("translate-x-screen")] + #[case("translate-x")] + #[case("scale-1.5")] + #[case("skew-x")] + #[case("rotate-x-45")] + fn test_preserved_classes(#[case] class: &str) { + assert_eq!(parse_class(class), None, "{class}"); + } + + // ==================== Utilities with several declarations ==================== + + #[rstest] + #[case("text-xs", "0.75rem", "var(--tw-leading, calc(1 / 0.75))")] + #[case("text-sm", "0.875rem", "var(--tw-leading, calc(1.25 / 0.875))")] + #[case("text-base", "1rem", "var(--tw-leading, calc(1.5 / 1))")] + #[case("text-xl", "1.25rem", "var(--tw-leading, calc(1.75 / 1.25))")] + #[case("text-9xl", "8rem", "var(--tw-leading, 1)")] + #[case("text-sm/6", "0.875rem", "1.5rem")] + #[case("text-sm/[1.7]", "0.875rem", "1.7")] + #[case("text-sm/tight", "0.875rem", "1.25")] + #[case("text-sm/(--lh)", "0.875rem", "var(--lh)")] + fn test_font_size_utilities( + #[case] class: &str, + #[case] font_size: &str, + #[case] line_height: &str, ) { - assert_eq!(is_valid_tailwind_value(value), expected); + assert_eq!( + declarations(class), + Some(owned(&[ + ("font-size", font_size), + ("line-height", line_height) + ])) + ); + } + + #[test] + fn test_screen_reader_utilities() { + assert_eq!( + declarations("sr-only"), + Some(owned(&[ + ("position", "absolute"), + ("width", "1px"), + ("height", "1px"), + ("padding", "0"), + ("margin", "-1px"), + ("overflow", "hidden"), + ("clip-path", "inset(50%)"), + ("white-space", "nowrap"), + ("border-width", "0"), + ])) + ); + assert_eq!( + declarations("not-sr-only"), + Some(owned(&[ + ("position", "static"), + ("width", "auto"), + ("height", "auto"), + ("padding", "0"), + ("margin", "0"), + ("overflow", "visible"), + ("clip-path", "none"), + ("white-space", "normal"), + ])) + ); + } + + #[rstest] + #[case("-m-4", "margin", "-1rem")] + #[case("-mt-4", "margin-top", "-1rem")] + #[case("-inset-x-4", "inset-inline", "-1rem")] + #[case("-top-2", "top", "-0.5rem")] + #[case("-z-10", "z-index", "-10")] + #[case("-order-1", "order", "-1")] + #[case("-tracking-wide", "letter-spacing", "-0.025em")] + #[case("-scroll-m-4", "scroll-margin", "-1rem")] + #[case("-m-[4px]", "margin", "calc(4px * -1)")] + #[case("-top-[3px]", "top", "calc(3px * -1)")] + #[case("-z-[3]", "z-index", "calc(3 * -1)")] + #[case("-rotate-[30deg]", "rotate", "calc(30deg * -1)")] + #[case("mt-[-3px]", "margin-top", "-3px")] + fn test_negative_utilities(#[case] class: &str, #[case] property: &str, #[case] value: &str) { + assert_eq!(declarations(class), Some(owned(&[(property, value)]))); + } + + // ==================== Arbitrary values ==================== + + #[rstest] + #[case("[mask-type:luminance]", "mask-type", "luminance")] + #[case("[--my_var:1px_2px]", "--my_var", "1px 2px")] + #[case("[margin:calc(1px+2px)]", "margin", "calc(1px + 2px)")] + #[case("[-webkit-line-clamp:3]", "-webkit-line-clamp", "3")] + #[case("bg-(--my-color)", "background-color", "var(--my-color)")] + #[case("bg-(image:--x)", "background-image", "var(--x)")] + #[case("text-(length:--my-size)", "font-size", "var(--my-size)")] + #[case("text-(--x)", "color", "var(--x)")] + #[case("w-(--x,10px)", "width", "var(--x,10px)")] + #[case("p-(length:--x)", "padding", "var(--x)")] + #[case("text-[2rem]", "font-size", "2rem")] + #[case("text-[1.5em]", "font-size", "1.5em")] + #[case("text-[large]", "font-size", "large")] + #[case("text-[calc(1rem+2px)]", "font-size", "calc(1rem + 2px)")] + #[case("text-[length:var(--x)]", "font-size", "var(--x)")] + #[case("text-[percentage:50%]", "font-size", "50%")] + #[case("text-[absolute-size:large]", "font-size", "large")] + #[case("text-[#fff]", "color", "#fff")] + #[case("text-[var(--x)]", "color", "var(--x)")] + #[case("text-[color:var(--x)]", "color", "var(--x)")] + #[case("bg-[url(/a_b.png)]", "background-image", "url(/a_b.png)")] + #[case("bg-[url('/a_b.png')]", "background-image", "url('/a_b.png')")] + #[case( + "bg-[linear-gradient(red,blue)]", + "background-image", + "linear-gradient(red,blue)" + )] + #[case("bg-[image:var(--x)]", "background-image", "var(--x)")] + #[case("bg-[url:var(--x)]", "background-image", "var(--x)")] + #[case("bg-[#123456]", "background-color", "#123456")] + #[case("bg-[var(--x)]", "background-color", "var(--x)")] + #[case("bg-[length:200px_100px]", "background-size", "200px 100px")] + #[case("bg-[bg-size:cover]", "background-size", "cover")] + #[case("bg-[position:top]", "background-position", "top")] + #[case("border-[3px]", "border-width", "3px")] + #[case("border-[2px_4px]", "border-width", "2px 4px")] + #[case("border-[thin]", "border-width", "thin")] + #[case("border-[0]", "border-width", "0")] + #[case("border-[length:var(--x)]", "border-width", "var(--x)")] + #[case("border-[line-width:var(--x)]", "border-width", "var(--x)")] + #[case("border-[#fff]", "border-color", "#fff")] + #[case("border-[var(--x)]", "border-color", "var(--x)")] + #[case("border-t-[3px]", "border-top-width", "3px")] + #[case("border-t-[red]", "border-top-color", "red")] + #[case("border-x-[1px]", "border-inline-width", "1px")] + #[case("border-s-[red]", "border-inline-start-color", "red")] + #[case("outline-[3px]", "outline-width", "3px")] + #[case("outline-[red]", "outline-color", "red")] + #[case("stroke-[3px]", "stroke-width", "3px")] + #[case("stroke-[2]", "stroke-width", "2")] + #[case("stroke-[red]", "stroke", "red")] + #[case("decoration-[3px]", "text-decoration-thickness", "3px")] + #[case("decoration-[red]", "text-decoration-color", "red")] + #[case("font-[600]", "font-weight", "600")] + #[case("font-[number:var(--w)]", "font-weight", "var(--w)")] + #[case("font-[Inter_Var]", "font-family", "Inter Var")] + #[case("font-[family-name:var(--f)]", "font-family", "var(--f)")] + #[case("blur-[2px]", "filter", "blur(2px)")] + #[case("backdrop-blur-[2px]", "backdrop-filter", "blur(2px)")] + #[case("w-[calc(100%_-_2rem)]", "width", "calc(100% - 2rem)")] + #[case("m-[var(--x_y)]", "margin", "var(--x_y)")] + #[case( + "grid-cols-[repeat(2,minmax(0,1fr))]", + "grid-template-columns", + "repeat(2,minmax(0,1fr))" + )] + #[case("col-[1/3]", "grid-column", "1/3")] + #[case("row-[span_2]", "grid-row", "span 2")] + #[case("origin-[33%_75%]", "transform-origin", "33% 75%")] + #[case("object-[25%_75%]", "object-position", "25% 75%")] + fn test_arbitrary_utilities(#[case] class: &str, #[case] property: &str, #[case] value: &str) { + assert_eq!(declarations(class), Some(owned(&[(property, value)]))); + } + + #[rstest] + #[case("content-['a_b']", "'a b'")] + #[case("content-['hello\\_world']", "'hello_world'")] + #[case("content-[attr(data-a_b)]", "attr(data-a b)")] + fn test_arbitrary_content(#[case] class: &str, #[case] value: &str) { + assert_eq!( + declarations(class), + Some(owned(&[ + ("--tw-content", value), + ("content", "var(--tw-content)") + ])) + ); + } + + // Values decoded like Tailwind does, checked against Tailwind CSS 4.3.3 + #[rstest] + #[case("calc(100%-2rem)", "calc(100% - 2rem)")] + #[case("calc(1rem+2px)", "calc(1rem + 2px)")] + #[case("calc(2*3px)", "calc(2 * 3px)")] + #[case("calc(10px/2)", "calc(10px / 2)")] + #[case("min(10px,2rem)", "min(10px, 2rem)")] + #[case("max(10px,calc(1rem-2px))", "max(10px, calc(1rem - 2px))")] + #[case("clamp(1rem,2vw+1px,3rem)", "clamp(1rem, 2vw + 1px, 3rem)")] + #[case("calc(100%-var(--x))", "calc(100% - var(--x))")] + #[case("calc(var(--a)*-1)", "calc(var(--a) * -1)")] + #[case("calc(-1*2px)", "calc(-1 * 2px)")] + #[case("calc(1e-3px+1px)", "calc(1e-3px + 1px)")] + #[case("calc(100%_-_2rem)", "calc(100% - 2rem)")] + #[case("calc(1px_+2px)", "calc(1px + 2px)")] + #[case("calc((1px+2px)*3)", "calc((1px + 2px) * 3)")] + #[case("calc(1px--2px)", "calc(1px - -2px)")] + #[case("calc(a-b)", "calc(a-b)")] + #[case("var(--a_b,calc(1px+2px))", "var(--a_b,calc(1px + 2px))")] + #[case("var(calc(1px+2px))", "var(calc(1px + 2px))")] + #[case("theme(spacing_4)", "theme(spacing_4)")] + #[case("x_var(--a_b)", "x var(--a_b)")] + #[case("x_theme(--a_b)", "x theme(--a_b)")] + #[case("x_url(a_b)", "x url(a_b)")] + #[case("repeat(2,minmax(0,1fr))", "repeat(2,minmax(0,1fr))")] + #[case("url(/a_b.png)", "url(/a_b.png)")] + #[case("url(a_b", "url(a_b")] + #[case("attr(data-a_b)", "attr(data-a b)")] + #[case("a_(b)_c", "a (b) c")] + #[case("a)b_c", "a)b c")] + #[case("a)b_(c)", "a)b (c)")] + #[case("attr(a\\_b)", "attr(a_b)")] + #[case("url(a(b)_c)", "url(a(b)_c)")] + #[case("'a_b'", "'a b'")] + #[case("'hello\\_world'", "'hello_world'")] + #[case("한_글", "한 글")] + fn test_decode_arbitrary_value(#[case] value: &str, #[case] expected: &str) { + assert_eq!(decode_arbitrary_value(value), expected); + } + + #[test] + fn test_balanced_brackets() { + assert!(is_balanced("[a](b)")); + assert!(!is_balanced("(]")); + assert!(!is_balanced("[)")); + assert!(!is_balanced("(")); + } + + #[test] + fn test_styles_and_properties() { + let class = parse_class("md:hover:p-4").unwrap(); + let styles: Vec = class.styles().collect(); + assert_eq!(styles.len(), 1); + assert_eq!(styles[0].property(), "padding"); + assert_eq!(styles[0].value(), "1rem"); + assert_eq!(styles[0].level(), 2); + assert_eq!( + styles[0].selector().map(ToString::to_string).as_deref(), + Some("@media(hover:hover) &:hover") + ); + assert!(!class.uses_properties()); + + let class = parse_class("selection:translate-x-4").unwrap(); + assert_eq!(class.styles().count(), 4); + assert!(class.uses_properties()); + assert!(parse_class("before:block").unwrap().uses_properties()); } } diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8f..3f3ea2732 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3680,6 +3680,61 @@ mod tests { assert!(!css.contains("color:red"), "{css}"); } + #[test] + #[serial] + fn test_tailwind_classes_css() { + reset_class_map(); + reset_file_map(); + let mut sheet = StyleSheet::default(); + for (file, code) in [ + ( + "a.tsx", + "import {Box} from '@devup-ui/core'\n", + ), + ( + "b.tsx", + "import {Box} from '@devup-ui/core'\n", + ), + ] { + let output = extract( + file, + code, + 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, file, true); + } + let css = sheet.create_css(None, false); + // The registrations are written once however many files use them + assert_eq!( + css.matches("@property --tw-translate-x{").count(), + 1, + "{css}" + ); + assert!( + css.contains("@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}"), + "{css}" + ); + for rule in [ + ".a{--tw-translate-x:1rem}", + ".d{--tw-translate-y:.5rem}", + ".b{translate:var(--tw-translate-x) var(--tw-translate-y)}", + ".g{font-size:.875rem}", + ".h{line-height:var(--tw-leading,calc(1.25 / .875))}", + ".e::before{content:var(--tw-content)}", + ".f::before{display:inline-block}", + "@media(hover:hover){.c:hover:focus{margin-top:1rem}}", + ] { + assert!(css.contains(rule), "{rule} in {css}"); + } + } + #[test] #[serial] fn test_dynamic_base_style_updates_base_sheet() { From b4790adfaad4d4c48ad6c4f9882cc0a46d37116d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 16:28:43 +0900 Subject: [PATCH 2/2] feat(extractor): follow Tailwind v4 palette, theme tokens, modifiers and composed utilities Refs #687 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_tailwind_v4_theme.json | 7 + bindings/devup-ui-wasm/src/lib.rs | 8 +- libs/css/src/theme_tokens.rs | 44 +- libs/extractor/src/lib.rs | 12 +- libs/extractor/src/prop_modify_utils.rs | 21 +- ...tests__parse_tailwind_to_styles_basic.snap | 2 +- ...sts__parse_tailwind_to_styles_complex.snap | 80 +- ...__parse_tailwind_to_styles_responsive.snap | 2 +- ...ts__parse_tailwind_to_styles_variants.snap | 2 +- ..._tests__tailwind_classname_extraction.snap | 2 +- ...__tailwind_classname_with_devup_props.snap | 2 +- ...sts__tailwind_classname_with_variants.snap | 2 +- ...ests__tailwind_conditional_class_name.snap | 2 +- ...tor__tests__tailwind_full_integration.snap | 168 +- ..._tailwind_mixed_with_devup_responsive.snap | 84 +- ...ctor__tests__tailwind_negative_values.snap | 8 +- ...literal_with_function_call_expression.snap | 2 +- ...plate_literal_with_logical_expression.snap | 4 +- ...literal_with_parenthesized_expression.snap | 4 +- ...ind_template_literal_with_style_order.snap | 4 +- ...late_literal_with_variable_expression.snap | 2 +- ...ests__tailwind_with_all_variant_types.snap | 26 +- ...tractor__tests__tailwind_with_as_prop.snap | 2 +- ...ind_with_conditional_template_literal.snap | 6 +- libs/extractor/src/tailwind.rs | 1536 +++-------------- libs/extractor/src/tailwind_children.rs | 326 ++++ libs/extractor/src/tailwind_color.rs | 545 ++++++ libs/extractor/src/tailwind_effects.rs | 1049 +++++++++++ libs/extractor/src/tailwind_motion.rs | 338 ++++ libs/sheet/src/lib.rs | 3 +- libs/sheet/src/theme.rs | 24 +- 31 files changed, 2932 insertions(+), 1385 deletions(-) create mode 100644 .changepacks/changepack_log_tailwind_v4_theme.json create mode 100644 libs/extractor/src/tailwind_children.rs create mode 100644 libs/extractor/src/tailwind_color.rs create mode 100644 libs/extractor/src/tailwind_effects.rs create mode 100644 libs/extractor/src/tailwind_motion.rs diff --git a/.changepacks/changepack_log_tailwind_v4_theme.json b/.changepacks/changepack_log_tailwind_v4_theme.json new file mode 100644 index 000000000..b9ac3422e --- /dev/null +++ b/.changepacks/changepack_log_tailwind_v4_theme.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Tailwind classes in a className follow Tailwind CSS v4: colors are the v4 default palette (oklch) and the colors and shadows of devup.json theme are usable as Tailwind theme values (bg-primary, text-text, shadow-card, as var(--token)); the opacity modifier (bg-red-500/50, /[71.37%], /(--alpha)) mixes with color-mix as v4 does; a trailing or leading ! makes every declaration important; blur, brightness, contrast, grayscale, hue-rotate, invert, saturate, sepia and their backdrop-* forms compose through Tailwind's custom properties instead of overwriting filter and backdrop-filter; gradients use bg-linear-*, bg-conic-* and bg-radial-* with from-*, via-* and to-* composing through the gradient variables (bg-gradient-to-* is not defined by v4 and stays as written); animate-* brings the @keyframes it names and transition, duration, delay and ease follow v4; space-* and divide-* apply to the children like v4 (:where(& > :not(:last-child))); @property rules are emitted only for the custom properties a class uses. Anything the build cannot reproduce exactly (drop-shadow-*) stays as written in the className. No new build errors", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..9902a1c96 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{LazyLock, RwLock}; #[derive(Default, Debug)] @@ -6,6 +6,7 @@ struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, typography: Vec, + colors: BTreeSet, } static TOKEN_REGISTRY: LazyLock> = @@ -27,6 +28,28 @@ pub fn set_typography_keys(keys: Vec) { } } +/// Registers the names of the theme's colors, which Tailwind classes such as +/// `bg-primary` can use +pub fn set_color_tokens(names: BTreeSet) { + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + registry.colors = names; + } +} + +/// Whether the theme defines the color `name`, a CSS variable of that name +pub fn is_color_token(name: &str) -> bool { + TOKEN_REGISTRY + .read() + .is_ok_and(|registry| registry.colors.contains(name)) +} + +/// Whether the theme defines the shadow `name`, a CSS variable of that name +pub fn is_shadow_token(name: &str) -> bool { + TOKEN_REGISTRY + .read() + .is_ok_and(|registry| registry.shadow.contains_key(name)) +} + /// 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 { @@ -119,7 +142,24 @@ mod tests { 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()); + assert_eq!(get_typography_keys(), Vec::::new()); + } + + #[test] + #[serial] + fn test_color_and_shadow_tokens() { + set_color_tokens(BTreeSet::from(["primary".to_string()])); + let mut shadow = BTreeMap::new(); + shadow.insert("card".to_string(), vec![0]); + set_theme_token_levels(BTreeMap::new(), shadow); + + assert!(is_color_token("primary")); + assert!(!is_color_token("card")); + assert!(is_shadow_token("card")); + assert!(!is_shadow_token("primary")); + set_color_tokens(BTreeSet::new()); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + assert!(!is_color_token("primary")); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 44d8863ea..4749b28f4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15,6 +15,10 @@ mod source_map; mod style_values; mod stylex; mod tailwind; +mod tailwind_children; +mod tailwind_color; +mod tailwind_effects; +mod tailwind_motion; mod util_type; mod utils; mod vanilla_extract; @@ -836,8 +840,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13602,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18864,7 +18868,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index b8b1ce7da..08a56e2dc 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -3,7 +3,7 @@ use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{PROPERTY_RULES, PROPERTY_RULES_FILE, parse_class}; +use crate::tailwind::{PROPERTY_RULES_FILE, parse_class}; use crate::utils::{get_str_by_property_key, merge_object_expressions}; use crate::{ExtractStyleProp, ExtractStyleValue}; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -15,6 +15,7 @@ use oxc_ast::ast::{ }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; +use std::collections::BTreeSet; /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. @@ -314,7 +315,7 @@ pub fn get_class_name_expression<'a>( style_order, filename, styles: Vec::new(), - property_rules: false, + rules: BTreeSet::new(), }; let compiled = class_name_prop .as_ref() @@ -364,19 +365,19 @@ struct TailwindClassName<'f> { style_order: Option, filename: Option<&'f str>, styles: Vec, - /// Whether a compiled class uses the custom properties Tailwind registers - property_rules: bool, + /// The global rules the compiled classes need, @property and @keyframes + rules: BTreeSet<&'static str>, } impl TailwindClassName<'_> { fn into_styles(self) -> Vec { let mut styles = self.styles; - if self.property_rules { - styles.push(ExtractStyleValue::Css(ExtractCss { - css: PROPERTY_RULES.to_string(), + styles.extend(self.rules.into_iter().map(|rule| { + ExtractStyleValue::Css(ExtractCss { + css: rule.to_string(), file: PROPERTY_RULES_FILE.to_string(), - })); - } + }) + })); styles } @@ -393,7 +394,7 @@ impl TailwindClassName<'_> { let whole = (!open_start || start > 0) && (!open_end || end < text.len()); if let Some(tailwind) = whole.then(|| parse_class(class)).flatten() { compiled.push_str(&text[written..start]); - self.property_rules |= tailwind.uses_properties(); + self.rules.extend(tailwind.rules()); let mut separator = ""; for mut style in tailwind.styles() { if let Some(order) = self.style_order { diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap index 8baa4082f..e860275c9 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap index 833afb951..1304e0565 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap @@ -3,6 +3,46 @@ source: libs/extractor/src/tailwind.rs expression: sort_styles(styles) --- { + Static( + ExtractStaticStyle { + property: "--tw-duration", + value: "200ms", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 10px 15px -3px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 4px 6px -4px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -36,7 +76,17 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "box-shadow", - value: "0 10px 15px -3px rgb(0 0 0 / .1),0 4px 6px -4px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -55,8 +105,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", + property: "display", + value: "flex", level: 0, selector: None, style_order: None, @@ -65,8 +115,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "display", - value: "flex", + property: "justify-content", + value: "space-between", level: 0, selector: None, style_order: None, @@ -75,8 +125,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "justify-content", - value: "space-between", + property: "padding", + value: "1rem", level: 0, selector: None, style_order: None, @@ -85,8 +135,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "padding", - value: "1rem", + property: "transition-duration", + value: "200ms", level: 0, selector: None, style_order: None, @@ -96,7 +146,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "transition-duration", - value: "200ms", + value: "var(--tw-duration,150ms)", level: 0, selector: None, style_order: None, @@ -113,4 +163,14 @@ expression: sort_styles(styles) layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-timing-function", + value: "var(--tw-ease,cubic-bezier(.4,0,.2,1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), } diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap index 429e176b5..a3b0ca15a 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 1, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap index 1513c6081..abef745f6 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap index 405c547e6..eecec6300 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap index 419ad0a7d..6c84131ee 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap index 494738fe9..f0b2b97df 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap index 23f909148..0822cf9a9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap index 5e6e6069a..5069aa833 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap @@ -14,6 +14,36 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 20px 25px -5px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 8px 10px -6px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -37,13 +67,9 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#1F2937", + value: "#FFF", level: 0, - selector: Some( - Selector( - ":root[data-theme=dark] &", - ), - ), + selector: None, style_order: None, layer: None, }, @@ -51,9 +77,13 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(27.8% .033 256.848)", level: 0, - selector: None, + selector: Some( + Selector( + ":root[data-theme=dark] &", + ), + ), style_order: None, layer: None, }, @@ -61,7 +91,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#FFF", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -71,7 +101,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-color", - value: "#374151", + value: "oklch(37.3% .034 259.733)", level: 0, selector: Some( Selector( @@ -85,7 +115,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-color", - value: "#E5E7EB", + value: "oklch(92.8% .006 264.531)", level: 0, selector: None, style_order: None, @@ -115,7 +145,17 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -134,8 +174,8 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", + property: "color", + value: "#FFF", level: 0, selector: None, style_order: None, @@ -145,9 +185,13 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#111827", + value: "#FFF", level: 0, - selector: None, + selector: Some( + Selector( + ":root[data-theme=dark] &", + ), + ), style_order: None, layer: None, }, @@ -155,7 +199,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#374151", + value: "oklch(21% .034 264.665)", level: 0, selector: None, style_order: None, @@ -165,7 +209,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#6B7280", + value: "oklch(37.3% .034 259.733)", level: 0, selector: None, style_order: None, @@ -175,13 +219,9 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#9CA3AF", + value: "oklch(55.1% .027 264.364)", level: 0, - selector: Some( - Selector( - ":root[data-theme=dark] &", - ), - ), + selector: None, style_order: None, layer: None, }, @@ -189,7 +229,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#D1D5DB", + value: "oklch(70.7% .022 261.325)", level: 0, selector: Some( Selector( @@ -203,17 +243,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#FFF", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - Static( - ExtractStaticStyle { - property: "color", - value: "#FFF", + value: "oklch(87.2% .01 258.338)", level: 0, selector: Some( Selector( @@ -436,6 +466,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-duration", + value: "var(--tw-duration,150ms)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "transition-property", @@ -446,6 +486,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-timing-function", + value: "var(--tw-ease,cubic-bezier(.4,0,.2,1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "width", @@ -458,10 +508,52 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-duration{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ease{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-leading{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", file: "@devup-ui/tailwind", }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap index 757687d59..c40210ab3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap @@ -4,6 +4,36 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 20px 25px -5px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 8px 10px -6px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "background", @@ -47,7 +77,17 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -64,16 +104,6 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), Static( ExtractStaticStyle { property: "display", @@ -124,6 +154,36 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap index b318297c2..9606d3cd4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap @@ -36,7 +36,13 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap index 20f358653..f6a66523a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap index 3d4bcb1db..3d1f6db09 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap index 65171e2bd..5078bf11f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap index 96adc8f43..fb37f986e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#22C55E", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: Some( @@ -19,7 +19,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(72.3% .219 149.579)", level: 0, selector: None, style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap index 96f1eff06..f503be46e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap index 2c1b7dcdc..dccb572cf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap @@ -49,7 +49,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#111827", + value: "oklch(21% .034 264.665)", level: 0, selector: Some( Selector( @@ -63,7 +63,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#1F2937", + value: "oklch(27.8% .033 256.848)", level: 0, selector: Some( At { @@ -83,7 +83,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { @@ -204,7 +204,25 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-ring-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap index 20c321150..925089cf7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#111827", + value: "oklch(21% .034 264.665)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap index 0a19f6804..51bbbbde7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#22C55E", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(72.3% .219 149.579)", level: 0, selector: None, style_order: None, @@ -56,7 +56,7 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-leading{syntax:\"*\";inherits:false}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index ee2beb194..c06d5fb6e 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -18,6 +18,10 @@ use css::{ use phf::{phf_map, phf_set}; use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::tailwind_children::{CHILDREN, children_utility}; +use crate::tailwind_color::color_value; +use crate::tailwind_effects::effect_utility; +use crate::tailwind_motion::{keyframes_of, motion_utility}; /// A declaration: property and value pub type Declaration = (Cow<'static, str>, Cow<'static, str>); @@ -138,25 +142,104 @@ static ARIA_VARIANTS: phf::Set<&'static str> = phf_set! { "selected", }; +/// `@property` rule of a custom property: with an initial value, or without +macro_rules! property { + ($name:literal, $syntax:literal, $initial:literal) => { + ( + $name, + concat!( + "@property ", + $name, + "{syntax:\"", + $syntax, + "\";inherits:false;initial-value:", + $initial, + "}" + ), + ) + }; + ($name:literal) => { + ( + $name, + concat!("@property ", $name, "{syntax:\"*\";inherits:false}"), + ) + }; +} + /// The custom properties Tailwind registers, so that each element starts from /// their initial value instead of inheriting its parent's -pub const PROPERTY_RULES: &str = concat!( - "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", - "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", - "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-rotate-x{syntax:\"*\";inherits:false}", - "@property --tw-rotate-y{syntax:\"*\";inherits:false}", - "@property --tw-rotate-z{syntax:\"*\";inherits:false}", - "@property --tw-skew-x{syntax:\"*\";inherits:false}", - "@property --tw-skew-y{syntax:\"*\";inherits:false}", - "@property --tw-leading{syntax:\"*\";inherits:false}", - "@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}", - "@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", -); - -/// The stylesheet keeps [`PROPERTY_RULES`] under this name, which no source +static PROPERTIES: &[(&str, &str)] = &[ + property!("--tw-translate-x", "*", "0"), + property!("--tw-translate-y", "*", "0"), + property!("--tw-scale-x", "*", "1"), + property!("--tw-scale-y", "*", "1"), + property!("--tw-scale-z", "*", "1"), + property!("--tw-rotate-x"), + property!("--tw-rotate-y"), + property!("--tw-rotate-z"), + property!("--tw-skew-x"), + property!("--tw-skew-y"), + property!("--tw-leading"), + property!("--tw-content", "*", "\"\""), + property!("--tw-scroll-snap-strictness", "*", "proximity"), + property!("--tw-blur"), + property!("--tw-brightness"), + property!("--tw-contrast"), + property!("--tw-grayscale"), + property!("--tw-hue-rotate"), + property!("--tw-invert"), + property!("--tw-saturate"), + property!("--tw-sepia"), + property!("--tw-drop-shadow"), + property!("--tw-backdrop-blur"), + property!("--tw-backdrop-brightness"), + property!("--tw-backdrop-contrast"), + property!("--tw-backdrop-grayscale"), + property!("--tw-backdrop-hue-rotate"), + property!("--tw-backdrop-invert"), + property!("--tw-backdrop-opacity"), + property!("--tw-backdrop-saturate"), + property!("--tw-backdrop-sepia"), + property!("--tw-gradient-position"), + property!("--tw-gradient-from", "", "#0000"), + property!("--tw-gradient-via", "", "#0000"), + property!("--tw-gradient-to", "", "#0000"), + property!("--tw-gradient-stops"), + property!("--tw-gradient-via-stops"), + property!("--tw-gradient-from-position", "", "0%"), + property!("--tw-gradient-via-position", "", "50%"), + property!("--tw-gradient-to-position", "", "100%"), + property!("--tw-space-x-reverse", "*", "0"), + property!("--tw-space-y-reverse", "*", "0"), + property!("--tw-divide-x-reverse", "*", "0"), + property!("--tw-divide-y-reverse", "*", "0"), + property!("--tw-border-style", "*", "solid"), + property!("--tw-duration"), + property!("--tw-ease"), + property!("--tw-shadow", "*", "0 0 #0000"), + property!("--tw-shadow-color"), + property!("--tw-shadow-alpha", "", "100%"), + property!("--tw-drop-shadow-color"), + property!("--tw-drop-shadow-alpha", "", "100%"), + property!("--tw-drop-shadow-size"), + property!("--tw-inset-shadow", "*", "0 0 #0000"), + property!("--tw-inset-shadow-color"), + property!("--tw-inset-shadow-alpha", "", "100%"), + property!("--tw-ring-color"), + property!("--tw-ring-shadow", "*", "0 0 #0000"), + property!("--tw-inset-ring-color"), + property!("--tw-inset-ring-shadow", "*", "0 0 #0000"), +]; + +/// Whether `value` reads the custom property `name`, whole and not as the +/// start of a longer name +fn reads(value: &str, name: &str) -> bool { + value.match_indices(name).any(|(start, _)| { + !value[start + name.len()..] + .starts_with(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} +/// The stylesheet keeps the rules of [`TailwindClass::rules`] under this name, which no source /// file has, so that no file's update removes them pub const PROPERTY_RULES_FILE: &str = "@devup-ui/tailwind"; @@ -219,12 +302,21 @@ impl TailwindClass { }) } - /// Whether the declarations use the custom properties of [`PROPERTY_RULES`] + /// The global rules the declarations need: the @property rules of the + /// custom properties they set or read, and the @keyframes they name #[must_use] - pub fn uses_properties(&self) -> bool { - self.declarations + pub fn rules(&self) -> Vec<&'static str> { + let mut rules: Vec<&'static str> = PROPERTIES .iter() - .any(|(property, value)| property.starts_with("--tw-") || value.contains("var(--tw-")) + .filter(|(name, _)| { + self.declarations + .iter() + .any(|(property, value)| property == name || reads(value, name)) + }) + .map(|&(_, rule)| rule) + .collect(); + rules.extend(keyframes_of(&self.declarations)); + rules } } @@ -397,283 +489,8 @@ fn arbitrary_variant(inner: &str) -> Option { }) } -/// Tailwind color values -static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { - // Inherit/Current/Transparent - "inherit" => "inherit", - "current" => "currentColor", - "transparent" => "transparent", - // Black and White - "black" => "#000", - "white" => "#fff", - // Slate - "slate-50" => "#f8fafc", - "slate-100" => "#f1f5f9", - "slate-200" => "#e2e8f0", - "slate-300" => "#cbd5e1", - "slate-400" => "#94a3b8", - "slate-500" => "#64748b", - "slate-600" => "#475569", - "slate-700" => "#334155", - "slate-800" => "#1e293b", - "slate-900" => "#0f172a", - "slate-950" => "#020617", - // Gray - "gray-50" => "#f9fafb", - "gray-100" => "#f3f4f6", - "gray-200" => "#e5e7eb", - "gray-300" => "#d1d5db", - "gray-400" => "#9ca3af", - "gray-500" => "#6b7280", - "gray-600" => "#4b5563", - "gray-700" => "#374151", - "gray-800" => "#1f2937", - "gray-900" => "#111827", - "gray-950" => "#030712", - // Zinc - "zinc-50" => "#fafafa", - "zinc-100" => "#f4f4f5", - "zinc-200" => "#e4e4e7", - "zinc-300" => "#d4d4d8", - "zinc-400" => "#a1a1aa", - "zinc-500" => "#71717a", - "zinc-600" => "#52525b", - "zinc-700" => "#3f3f46", - "zinc-800" => "#27272a", - "zinc-900" => "#18181b", - "zinc-950" => "#09090b", - // Neutral - "neutral-50" => "#fafafa", - "neutral-100" => "#f5f5f5", - "neutral-200" => "#e5e5e5", - "neutral-300" => "#d4d4d4", - "neutral-400" => "#a3a3a3", - "neutral-500" => "#737373", - "neutral-600" => "#525252", - "neutral-700" => "#404040", - "neutral-800" => "#262626", - "neutral-900" => "#171717", - "neutral-950" => "#0a0a0a", - // Stone - "stone-50" => "#fafaf9", - "stone-100" => "#f5f5f4", - "stone-200" => "#e7e5e4", - "stone-300" => "#d6d3d1", - "stone-400" => "#a8a29e", - "stone-500" => "#78716c", - "stone-600" => "#57534e", - "stone-700" => "#44403c", - "stone-800" => "#292524", - "stone-900" => "#1c1917", - "stone-950" => "#0c0a09", - // Red - "red-50" => "#fef2f2", - "red-100" => "#fee2e2", - "red-200" => "#fecaca", - "red-300" => "#fca5a5", - "red-400" => "#f87171", - "red-500" => "#ef4444", - "red-600" => "#dc2626", - "red-700" => "#b91c1c", - "red-800" => "#991b1b", - "red-900" => "#7f1d1d", - "red-950" => "#450a0a", - // Orange - "orange-50" => "#fff7ed", - "orange-100" => "#ffedd5", - "orange-200" => "#fed7aa", - "orange-300" => "#fdba74", - "orange-400" => "#fb923c", - "orange-500" => "#f97316", - "orange-600" => "#ea580c", - "orange-700" => "#c2410c", - "orange-800" => "#9a3412", - "orange-900" => "#7c2d12", - "orange-950" => "#431407", - // Amber - "amber-50" => "#fffbeb", - "amber-100" => "#fef3c7", - "amber-200" => "#fde68a", - "amber-300" => "#fcd34d", - "amber-400" => "#fbbf24", - "amber-500" => "#f59e0b", - "amber-600" => "#d97706", - "amber-700" => "#b45309", - "amber-800" => "#92400e", - "amber-900" => "#78350f", - "amber-950" => "#451a03", - // Yellow - "yellow-50" => "#fefce8", - "yellow-100" => "#fef9c3", - "yellow-200" => "#fef08a", - "yellow-300" => "#fde047", - "yellow-400" => "#facc15", - "yellow-500" => "#eab308", - "yellow-600" => "#ca8a04", - "yellow-700" => "#a16207", - "yellow-800" => "#854d0e", - "yellow-900" => "#713f12", - "yellow-950" => "#422006", - // Lime - "lime-50" => "#f7fee7", - "lime-100" => "#ecfccb", - "lime-200" => "#d9f99d", - "lime-300" => "#bef264", - "lime-400" => "#a3e635", - "lime-500" => "#84cc16", - "lime-600" => "#65a30d", - "lime-700" => "#4d7c0f", - "lime-800" => "#3f6212", - "lime-900" => "#365314", - "lime-950" => "#1a2e05", - // Green - "green-50" => "#f0fdf4", - "green-100" => "#dcfce7", - "green-200" => "#bbf7d0", - "green-300" => "#86efac", - "green-400" => "#4ade80", - "green-500" => "#22c55e", - "green-600" => "#16a34a", - "green-700" => "#15803d", - "green-800" => "#166534", - "green-900" => "#14532d", - "green-950" => "#052e16", - // Emerald - "emerald-50" => "#ecfdf5", - "emerald-100" => "#d1fae5", - "emerald-200" => "#a7f3d0", - "emerald-300" => "#6ee7b7", - "emerald-400" => "#34d399", - "emerald-500" => "#10b981", - "emerald-600" => "#059669", - "emerald-700" => "#047857", - "emerald-800" => "#065f46", - "emerald-900" => "#064e3b", - "emerald-950" => "#022c22", - // Teal - "teal-50" => "#f0fdfa", - "teal-100" => "#ccfbf1", - "teal-200" => "#99f6e4", - "teal-300" => "#5eead4", - "teal-400" => "#2dd4bf", - "teal-500" => "#14b8a6", - "teal-600" => "#0d9488", - "teal-700" => "#0f766e", - "teal-800" => "#115e59", - "teal-900" => "#134e4a", - "teal-950" => "#042f2e", - // Cyan - "cyan-50" => "#ecfeff", - "cyan-100" => "#cffafe", - "cyan-200" => "#a5f3fc", - "cyan-300" => "#67e8f9", - "cyan-400" => "#22d3ee", - "cyan-500" => "#06b6d4", - "cyan-600" => "#0891b2", - "cyan-700" => "#0e7490", - "cyan-800" => "#155e75", - "cyan-900" => "#164e63", - "cyan-950" => "#083344", - // Sky - "sky-50" => "#f0f9ff", - "sky-100" => "#e0f2fe", - "sky-200" => "#bae6fd", - "sky-300" => "#7dd3fc", - "sky-400" => "#38bdf8", - "sky-500" => "#0ea5e9", - "sky-600" => "#0284c7", - "sky-700" => "#0369a1", - "sky-800" => "#075985", - "sky-900" => "#0c4a6e", - "sky-950" => "#082f49", - // Blue - "blue-50" => "#eff6ff", - "blue-100" => "#dbeafe", - "blue-200" => "#bfdbfe", - "blue-300" => "#93c5fd", - "blue-400" => "#60a5fa", - "blue-500" => "#3b82f6", - "blue-600" => "#2563eb", - "blue-700" => "#1d4ed8", - "blue-800" => "#1e40af", - "blue-900" => "#1e3a8a", - "blue-950" => "#172554", - // Indigo - "indigo-50" => "#eef2ff", - "indigo-100" => "#e0e7ff", - "indigo-200" => "#c7d2fe", - "indigo-300" => "#a5b4fc", - "indigo-400" => "#818cf8", - "indigo-500" => "#6366f1", - "indigo-600" => "#4f46e5", - "indigo-700" => "#4338ca", - "indigo-800" => "#3730a3", - "indigo-900" => "#312e81", - "indigo-950" => "#1e1b4b", - // Violet - "violet-50" => "#f5f3ff", - "violet-100" => "#ede9fe", - "violet-200" => "#ddd6fe", - "violet-300" => "#c4b5fd", - "violet-400" => "#a78bfa", - "violet-500" => "#8b5cf6", - "violet-600" => "#7c3aed", - "violet-700" => "#6d28d9", - "violet-800" => "#5b21b6", - "violet-900" => "#4c1d95", - "violet-950" => "#2e1065", - // Purple - "purple-50" => "#faf5ff", - "purple-100" => "#f3e8ff", - "purple-200" => "#e9d5ff", - "purple-300" => "#d8b4fe", - "purple-400" => "#c084fc", - "purple-500" => "#a855f7", - "purple-600" => "#9333ea", - "purple-700" => "#7e22ce", - "purple-800" => "#6b21a8", - "purple-900" => "#581c87", - "purple-950" => "#3b0764", - // Fuchsia - "fuchsia-50" => "#fdf4ff", - "fuchsia-100" => "#fae8ff", - "fuchsia-200" => "#f5d0fe", - "fuchsia-300" => "#f0abfc", - "fuchsia-400" => "#e879f9", - "fuchsia-500" => "#d946ef", - "fuchsia-600" => "#c026d3", - "fuchsia-700" => "#a21caf", - "fuchsia-800" => "#86198f", - "fuchsia-900" => "#701a75", - "fuchsia-950" => "#4a044e", - // Pink - "pink-50" => "#fdf2f8", - "pink-100" => "#fce7f3", - "pink-200" => "#fbcfe8", - "pink-300" => "#f9a8d4", - "pink-400" => "#f472b6", - "pink-500" => "#ec4899", - "pink-600" => "#db2777", - "pink-700" => "#be185d", - "pink-800" => "#9d174d", - "pink-900" => "#831843", - "pink-950" => "#500724", - // Rose - "rose-50" => "#fff1f2", - "rose-100" => "#ffe4e6", - "rose-200" => "#fecdd3", - "rose-300" => "#fda4af", - "rose-400" => "#fb7185", - "rose-500" => "#f43f5e", - "rose-600" => "#e11d48", - "rose-700" => "#be123c", - "rose-800" => "#9f1239", - "rose-900" => "#881337", - "rose-950" => "#4c0519", -}; - /// Spacing scale (Tailwind default: 1 unit = 0.25rem = 4px) -static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { +pub(crate) static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "0" => "0px", "px" => "1px", "0.5" => "0.125rem", @@ -825,18 +642,6 @@ static Z_INDEX_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "auto" => "auto", }; -/// Box shadow scale -static BOX_SHADOW_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "sm" => "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "" => "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "md" => "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - "lg" => "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", - "xl" => "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", - "2xl" => "0 25px 50px -12px rgb(0 0 0 / 0.25)", - "inner" => "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)", - "none" => "0 0 #0000", -}; - /// Border width scale static BORDER_WIDTH_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "0" => "0px", @@ -846,33 +651,33 @@ static BORDER_WIDTH_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "8" => "8px", }; -/// Transition duration scale -static DURATION_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "0" => "0s", - "75" => "75ms", - "100" => "100ms", - "150" => "150ms", - "200" => "200ms", - "300" => "300ms", - "500" => "500ms", - "700" => "700ms", - "1000" => "1000ms", -}; - -/// Ease timing functions -static EASE_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "linear" => "linear", - "in" => "cubic-bezier(0.4, 0, 1, 1)", - "out" => "cubic-bezier(0, 0, 0.2, 1)", - "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", -}; +/// The declarations `class` compiles to, `None` when it stays as written +#[cfg(test)] +pub(crate) fn declarations_of(class: &str) -> Option> { + parse_class(class).map(|class| { + class + .declarations + .into_iter() + .map(|(property, value)| (property.into_owned(), value.into_owned())) + .collect() + }) +} /// The class compiled, or `None` when it is not a Tailwind class understood in /// full, which must then stay in the className as written #[must_use] pub fn parse_class(class: &str) -> Option { let mut parts = split_top_level(class, ':'); - let mut declarations = utility(parts.pop()?)?; + let (name, important) = important(parts.pop()?); + let Utility { + mut declarations, + selector: utility_selector, + } = utility(name)?; + if important { + for (_, value) in &mut declarations { + *value = Cow::Owned(format!("{value} !important")); + } + } let mut level = 0; let mut selectors = vec![String::from("&")]; let mut at_rules = Vec::new(); @@ -895,6 +700,12 @@ pub fn parse_class(class: &str) -> Option { at_rules.extend(variant.at_rule); content |= variant.content; } + if utility_selector != "&" { + selectors = selectors + .iter() + .map(|selector| utility_selector.replace('&', selector)) + .collect(); + } if content && !declarations .iter() @@ -951,8 +762,39 @@ static NEGATABLE_PROPERTIES: phf::Set<&'static str> = phf_set! { "scroll-margin", }; -/// The declarations of a utility, which a leading `-` negates -fn utility(name: &str) -> Option> { +/// `name` without the `!` that makes its declarations important, trailing as +/// v4 writes it or leading as v3 did +fn important(name: &str) -> (&str, bool) { + name.strip_suffix('!') + .or_else(|| name.strip_prefix('!')) + .map_or((name, false), |name| (name, true)) +} + +/// What a utility declares, and on which element: itself, or the children of +/// the element it is on +struct Utility { + declarations: Vec, + /// Where the declarations apply, `&` being the element + selector: &'static str, +} + +/// The declarations of a utility and the selector they apply under +fn utility(name: &str) -> Option { + if let Some(declarations) = children_utility(name) { + return Some(Utility { + declarations, + selector: CHILDREN, + }); + } + Some(Utility { + declarations: element_utility(name)?, + selector: "&", + }) +} + +/// The declarations of a utility on the element itself, which a leading `-` +/// negates +fn element_utility(name: &str) -> Option> { let (negative, name) = name .strip_prefix('-') .map_or((false, name), |name| (true, name)); @@ -963,6 +805,14 @@ fn utility(name: &str) -> Option> { arbitrary_property(name) }; } + if let Some(declarations) = effect_utility(name, negative) { + return Some(declarations); + } + if !negative { + if let Some(declarations) = motion_utility(name) { + return Some(declarations); + } + } if let Some((root, value, variable)) = split_arbitrary(name) { return arbitrary_utility(root, value, variable, negative); } @@ -1197,7 +1047,7 @@ const TRANSFORM: &str = "var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate /// What an arbitrary value is, for the roots that take several kinds #[derive(Clone, Copy, PartialEq, Eq)] -enum ValueType { +pub(crate) enum ValueType { Length, Number, Image, @@ -1208,7 +1058,7 @@ enum ValueType { } /// The type a hint names, or that the value has -fn value_type(hint: Option<&str>, value: &str) -> Option { +pub(crate) fn value_type(hint: Option<&str>, value: &str) -> Option { Some(match hint { Some("length" | "percentage" | "line-width" | "absolute-size" | "relative-size") => { ValueType::Length @@ -1270,10 +1120,6 @@ fn arbitrary_declarations( } "scale-x" | "scale-y" => return Some(axes(root, "scale", &value, SCALE)), "skew" | "skew-x" | "skew-y" => return Some(skew(root, &value)), - "blur" | "brightness" | "contrast" | "saturate" => { - return Some(vec![function("filter", root, &value)]); - } - "backdrop-blur" => return Some(vec![function("backdrop-filter", "blur", &value)]), _ => {} } let value_type = value_type(hint, &value)?; @@ -1331,14 +1177,6 @@ fn each(properties: &[&'static str], value: &str) -> Vec { .collect() } -/// `property: name(value)` -fn function(property: &'static str, name: &str, value: &str) -> Declaration { - ( - Cow::Borrowed(property), - Cow::Owned(format!("{name}({value})")), - ) -} - /// `value` on the axes `root` names, each in its `--tw--` /// variable, which `property` reads as `composite` fn axes(root: &str, name: &'static str, value: &str, composite: &'static str) -> Vec { @@ -1620,7 +1458,7 @@ fn decode_underscores(value: &str) -> String { /// An arbitrary value as CSS, the way Tailwind decodes it: underscores are /// spaces except in `url()` and in the name `var()` reads, and the operators /// in math functions get the spaces CSS requires around them -fn decode_arbitrary_value(value: &str) -> String { +pub(crate) fn decode_arbitrary_value(value: &str) -> String { if !value.contains('(') { return decode_underscores(value); } @@ -1795,7 +1633,7 @@ fn add_whitespace_around_math_operators(input: &str) -> String { } /// `value` split at each top-level `separator`, outside brackets and parentheses -fn split_top_level(value: &str, separator: char) -> Vec<&str> { +pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> { let mut parts = Vec::new(); let mut depth = 0usize; let mut start = 0; @@ -1933,6 +1771,35 @@ fn tw(property: &'static str, value: impl Into>) -> ParsedUtil (property, value.into()) } +/// A declaration +pub(crate) fn decl( + property: impl Into>, + value: impl Into>, +) -> Declaration { + (property.into(), value.into()) +} + +/// Whether alue is a whole number written without a sign or leading zeros, +/// which is what v4 takes for the bare values of most utilities +pub(crate) fn is_positive_integer(value: &str) -> bool { + value == "0" + || (!value.starts_with('0') + && value.len() < 16 + && value.bytes().all(|byte| byte.is_ascii_digit()) + && !value.is_empty()) +} + +/// [value] as the CSS it stands for, or (--variable) as ar(--variable) +pub(crate) fn arbitrary_or_variable(argument: &str) -> Option { + if let Some(value) = bracketed(argument) { + return (!value.is_empty() && is_balanced(value)).then(|| decode_arbitrary_value(value)); + } + let variable = argument + .strip_prefix('(') + .and_then(|argument| argument.strip_suffix(')'))?; + (variable.starts_with("--") && is_balanced(variable)).then(|| format!("var({variable})")) +} + /// The one declaration of a utility from the tables below fn parse_utility(class: &str) -> Option { // Layout utilities @@ -1975,16 +1842,6 @@ fn parse_utility(class: &str) -> Option { return Some(result); } - // Filters - if let Some(result) = parse_filter_utility(class) { - return Some(result); - } - - // Transitions & Animation - if let Some(result) = parse_transition_utility(class) { - return Some(result); - } - // Transform origin if let Some(result) = parse_transform_utility(class) { return Some(result); @@ -2575,24 +2432,6 @@ fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str> } } - // Space between - if let Some(rest) = class.strip_prefix("space-x-") { - if rest == "reverse" { - return Some(tw("--tw-space-x-reverse", Cow::Borrowed("1"))); - } - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("column-gap", Cow::Borrowed(value))); - } - } - if let Some(rest) = class.strip_prefix("space-y-") { - if rest == "reverse" { - return Some(tw("--tw-space-y-reverse", Cow::Borrowed("1"))); - } - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("row-gap", Cow::Borrowed(value))); - } - } - None } @@ -2759,8 +2598,8 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Text color (font sizes are `compound_utility`'s) if let Some(rest) = class.strip_prefix("text-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("color", color)); } // Text alignment match rest { @@ -2898,8 +2737,8 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Background color if let Some(rest) = class.strip_prefix("bg-") { // Check if it's a color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("background-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("background-color", color)); } // Background attachment match rest { @@ -2958,42 +2797,6 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s "none" => return Some(tw("background-image", Cow::Borrowed("none"))), _ => {} } - - // Gradient directions - if let Some(dir) = rest.strip_prefix("gradient-to-") { - let direction = match dir { - "t" => Cow::Borrowed("to top"), - "tr" => Cow::Borrowed("to top right"), - "r" => Cow::Borrowed("to right"), - "br" => Cow::Borrowed("to bottom right"), - "b" => Cow::Borrowed("to bottom"), - "bl" => Cow::Borrowed("to bottom left"), - "l" => Cow::Borrowed("to left"), - "tl" => Cow::Borrowed("to top left"), - _ => return None, - }; - return Some(tw( - "background-image", - format!("linear-gradient({direction}, var(--tw-gradient-stops))"), - )); - } - } - - // Gradient color stops - if let Some(rest) = class.strip_prefix("from-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-from", Cow::Borrowed(color))); - } - } - if let Some(rest) = class.strip_prefix("via-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-via", Cow::Borrowed(color))); - } - } - if let Some(rest) = class.strip_prefix("to-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-to", Cow::Borrowed(color))); - } } None @@ -3006,8 +2809,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) // Border width if let Some(rest) = class.strip_prefix("border-") { // Border color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("border-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("border-color", color)); } // Border width per side @@ -3088,8 +2891,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "dotted" => return Some(tw("outline-style", Cow::Borrowed("dotted"))), "double" => return Some(tw("outline-style", Cow::Borrowed("double"))), _ => { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("outline-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("outline-color", color)); } } } @@ -3130,8 +2933,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) } "inset" => return Some(tw("--tw-ring-inset", Cow::Borrowed("inset"))), _ => { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-ring-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("--tw-ring-color", color)); } } } @@ -3143,55 +2946,11 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) )); } - // Divide - if let Some(rest) = class.strip_prefix("divide-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-divide-color", Cow::Borrowed(color))); - } - match rest { - "x" => return Some(tw("--tw-divide-x-reverse", Cow::Borrowed("0"))), - "x-0" => return Some(tw("border-inline-width", Cow::Borrowed("0px"))), - "x-2" => return Some(tw("border-inline-width", Cow::Borrowed("2px"))), - "x-4" => return Some(tw("border-inline-width", Cow::Borrowed("4px"))), - "x-8" => return Some(tw("border-inline-width", Cow::Borrowed("8px"))), - "x-reverse" => return Some(tw("--tw-divide-x-reverse", Cow::Borrowed("1"))), - "y" => return Some(tw("--tw-divide-y-reverse", Cow::Borrowed("0"))), - "y-0" => return Some(tw("border-block-width", Cow::Borrowed("0px"))), - "y-2" => return Some(tw("border-block-width", Cow::Borrowed("2px"))), - "y-4" => return Some(tw("border-block-width", Cow::Borrowed("4px"))), - "y-8" => return Some(tw("border-block-width", Cow::Borrowed("8px"))), - "y-reverse" => return Some(tw("--tw-divide-y-reverse", Cow::Borrowed("1"))), - "solid" => return Some(tw("border-style", Cow::Borrowed("solid"))), - "dashed" => return Some(tw("border-style", Cow::Borrowed("dashed"))), - "dotted" => return Some(tw("border-style", Cow::Borrowed("dotted"))), - "double" => return Some(tw("border-style", Cow::Borrowed("double"))), - "none" => return Some(tw("border-style", Cow::Borrowed("none"))), - _ => {} - } - } - None } /// Parse effects utilities (shadow, opacity, mix-blend, etc.) fn parse_effects_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Box shadow - if let Some(rest) = class.strip_prefix("shadow-") { - if let Some(&value) = BOX_SHADOW_SCALE.get(rest) { - return Some(tw("box-shadow", Cow::Borrowed(value))); - } - // Shadow color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-shadow-color", Cow::Borrowed(color))); - } - } - if class == "shadow" { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)"), - )); - } - // Opacity if let Some(rest) = class.strip_prefix("opacity-") { if let Some(&value) = OPACITY_SCALE.get(rest) { @@ -3222,309 +2981,6 @@ static BLEND_MODES: phf::Set<&'static str> = phf_set! { "luminosity", }; -/// Parse filter utilities (blur, brightness, contrast, etc.) -fn parse_filter_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Blur - if let Some(rest) = class.strip_prefix("blur-") { - let value = match rest { - "none" => Cow::Borrowed("0"), - "sm" => Cow::Borrowed("4px"), - "md" => Cow::Borrowed("12px"), - "lg" => Cow::Borrowed("16px"), - "xl" => Cow::Borrowed("24px"), - "2xl" => Cow::Borrowed("40px"), - "3xl" => Cow::Borrowed("64px"), - _ => return None, - }; - return Some(tw("filter", format!("blur({value})"))); - } - if class == "blur" { - return Some(tw("filter", Cow::Borrowed("blur(8px)"))); - } - - // Brightness - if let Some(rest) = class.strip_prefix("brightness-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "90" => Cow::Borrowed(".9"), - "95" => Cow::Borrowed(".95"), - "100" => Cow::Borrowed("1"), - "105" => Cow::Borrowed("1.05"), - "110" => Cow::Borrowed("1.1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("brightness({value})"))); - } - - // Contrast - if let Some(rest) = class.strip_prefix("contrast-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "100" => Cow::Borrowed("1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("contrast({value})"))); - } - - // Drop shadow - if let Some(rest) = class.strip_prefix("drop-shadow-") { - let value = match rest { - "sm" => Cow::Borrowed("drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))"), - "md" => Cow::Borrowed( - "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))", - ), - "lg" => Cow::Borrowed( - "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))", - ), - "xl" => Cow::Borrowed( - "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))", - ), - "2xl" => Cow::Borrowed("drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))"), - "none" => Cow::Borrowed("drop-shadow(0 0 #0000)"), - _ => return None, - }; - return Some(tw("filter", value)); - } - if class == "drop-shadow" { - return Some(tw( - "filter", - Cow::Borrowed( - "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))", - ), - )); - } - - // Grayscale - if class == "grayscale" { - return Some(tw("filter", Cow::Borrowed("grayscale(100%)"))); - } - if class == "grayscale-0" { - return Some(tw("filter", Cow::Borrowed("grayscale(0)"))); - } - - // Hue rotate - if let Some(rest) = class.strip_prefix("hue-rotate-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "15" => Cow::Borrowed("15deg"), - "30" => Cow::Borrowed("30deg"), - "60" => Cow::Borrowed("60deg"), - "90" => Cow::Borrowed("90deg"), - "180" => Cow::Borrowed("180deg"), - _ => return None, - }; - return Some(tw("filter", format!("hue-rotate({value})"))); - } - - // Invert - if class == "invert" { - return Some(tw("filter", Cow::Borrowed("invert(100%)"))); - } - if class == "invert-0" { - return Some(tw("filter", Cow::Borrowed("invert(0)"))); - } - - // Saturate - if let Some(rest) = class.strip_prefix("saturate-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "100" => Cow::Borrowed("1"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("saturate({value})"))); - } - - // Sepia - if class == "sepia" { - return Some(tw("filter", Cow::Borrowed("sepia(100%)"))); - } - if class == "sepia-0" { - return Some(tw("filter", Cow::Borrowed("sepia(0)"))); - } - - // Backdrop filters - if let Some(rest) = class.strip_prefix("backdrop-blur-") { - let value = match rest { - "none" => Cow::Borrowed("0"), - "sm" => Cow::Borrowed("4px"), - "md" => Cow::Borrowed("12px"), - "lg" => Cow::Borrowed("16px"), - "xl" => Cow::Borrowed("24px"), - "2xl" => Cow::Borrowed("40px"), - "3xl" => Cow::Borrowed("64px"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("blur({value})"))); - } - if class == "backdrop-blur" { - return Some(tw("backdrop-filter", Cow::Borrowed("blur(8px)"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-brightness-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "90" => Cow::Borrowed(".9"), - "95" => Cow::Borrowed(".95"), - "100" => Cow::Borrowed("1"), - "105" => Cow::Borrowed("1.05"), - "110" => Cow::Borrowed("1.1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("brightness({value})"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-contrast-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "100" => Cow::Borrowed("1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("contrast({value})"))); - } - - if class == "backdrop-grayscale" { - return Some(tw("backdrop-filter", Cow::Borrowed("grayscale(100%)"))); - } - if class == "backdrop-grayscale-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("grayscale(0)"))); - } - - if class == "backdrop-invert" { - return Some(tw("backdrop-filter", Cow::Borrowed("invert(100%)"))); - } - if class == "backdrop-invert-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("invert(0)"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-opacity-") { - if let Some(&value) = OPACITY_SCALE.get(rest) { - return Some(tw("backdrop-filter", format!("opacity({value})"))); - } - } - - if let Some(rest) = class.strip_prefix("backdrop-saturate-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "100" => Cow::Borrowed("1"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("saturate({value})"))); - } - - if class == "backdrop-sepia" { - return Some(tw("backdrop-filter", Cow::Borrowed("sepia(100%)"))); - } - if class == "backdrop-sepia-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("sepia(0)"))); - } - - None -} - -/// Parse transition and animation utilities -fn parse_transition_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Transition - match class { - "transition-none" => return Some(tw("transition-property", Cow::Borrowed("none"))), - "transition-all" => return Some(tw("transition-property", Cow::Borrowed("all"))), - "transition" => { - return Some(tw( - "transition-property", - Cow::Borrowed( - "color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter", - ), - )); - } - "transition-colors" => { - return Some(tw( - "transition-property", - Cow::Borrowed( - "color, background-color, border-color, text-decoration-color, fill, stroke", - ), - )); - } - "transition-opacity" => return Some(tw("transition-property", Cow::Borrowed("opacity"))), - "transition-shadow" => return Some(tw("transition-property", Cow::Borrowed("box-shadow"))), - "transition-transform" => { - return Some(tw("transition-property", Cow::Borrowed("transform"))); - } - _ => {} - } - - // Duration - if let Some(rest) = class.strip_prefix("duration-") { - if let Some(&value) = DURATION_SCALE.get(rest) { - return Some(tw("transition-duration", Cow::Borrowed(value))); - } - } - - // Ease (timing function) - if let Some(rest) = class.strip_prefix("ease-") { - if let Some(&value) = EASE_SCALE.get(rest) { - return Some(tw("transition-timing-function", Cow::Borrowed(value))); - } - } - - // Delay - if let Some(rest) = class.strip_prefix("delay-") { - if let Some(&value) = DURATION_SCALE.get(rest) { - return Some(tw("transition-delay", Cow::Borrowed(value))); - } - } - - // Animation - match class { - "animate-none" => return Some(tw("animation", Cow::Borrowed("none"))), - "animate-spin" => { - return Some(tw("animation", Cow::Borrowed("spin 1s linear infinite"))); - } - "animate-ping" => { - return Some(tw( - "animation", - Cow::Borrowed("ping 1s cubic-bezier(0, 0, 0.2, 1) infinite"), - )); - } - "animate-pulse" => { - return Some(tw( - "animation", - Cow::Borrowed("pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite"), - )); - } - "animate-bounce" => { - return Some(tw("animation", Cow::Borrowed("bounce 1s infinite"))); - } - _ => {} - } - - None -} - /// Parse transform origin utilities (translate, rotate, scale and skew are /// `transform_utility`'s) fn parse_transform_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { @@ -3554,8 +3010,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static if rest == "auto" { return Some(tw("accent-color", Cow::Borrowed("auto"))); } - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("accent-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("accent-color", color)); } } @@ -3575,8 +3031,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Caret color if let Some(rest) = class.strip_prefix("caret-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("caret-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("caret-color", color)); } } @@ -3706,8 +3162,8 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { if rest == "none" { return Some(tw("fill", Cow::Borrowed("none"))); } - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("fill", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("fill", color)); } } @@ -3724,8 +3180,8 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { _ => {} } // Stroke color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("stroke", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("stroke", color)); } } @@ -3822,12 +3278,12 @@ mod tests { } #[rstest] - #[case("bg-red-500", "background-color", "#ef4444")] - #[case("bg-blue-500", "background-color", "#3b82f6")] + #[case("bg-red-500", "background-color", "oklch(63.7% 0.237 25.331)")] + #[case("bg-blue-500", "background-color", "oklch(62.3% 0.214 259.815)")] #[case("bg-black", "background-color", "#000")] #[case("bg-white", "background-color", "#fff")] #[case("bg-transparent", "background-color", "transparent")] - #[case("text-red-500", "color", "#ef4444")] + #[case("text-red-500", "color", "oklch(63.7% 0.237 25.331)")] #[case("text-white", "color", "#fff")] fn test_parse_color_utilities( #[case] class: &str, @@ -3958,18 +3414,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case( - "transition-colors", - "color, background-color, border-color, text-decoration-color, fill, stroke" - )] - #[case("transition-transform", "transform")] - fn test_parse_transition_property_utilities(#[case] class: &str, #[case] expected_value: &str) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, "transition-property"); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("rounded", "border-radius", "0.25rem")] #[case("rounded-none", "border-radius", "0px")] @@ -3977,7 +3421,7 @@ mod tests { #[case("rounded-lg", "border-radius", "0.5rem")] #[case("border", "border-width", "1px")] #[case("border-2", "border-width", "2px")] - #[case("border-red-500", "border-color", "#ef4444")] + #[case("border-red-500", "border-color", "oklch(63.7% 0.237 25.331)")] fn test_parse_border_utilities( #[case] class: &str, #[case] expected_prop: &str, @@ -4101,34 +3545,6 @@ mod tests { assert_eq!(parsed.value, "span 3 / span 3"); } - #[test] - fn test_parse_transition_utilities() { - let parsed = parse_single_class("transition").expect("Should parse"); - assert_eq!(parsed.property, "transition-property"); - - let parsed = parse_single_class("duration-300").expect("Should parse"); - assert_eq!(parsed.property, "transition-duration"); - assert_eq!(parsed.value, "300ms"); - - let parsed = parse_single_class("ease-in-out").expect("Should parse"); - assert_eq!(parsed.property, "transition-timing-function"); - } - - #[test] - fn test_parse_filter_utilities() { - let parsed = parse_single_class("blur").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "blur(8px)"); - - let parsed = parse_single_class("blur-lg").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "blur(16px)"); - - let parsed = parse_single_class("grayscale").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "grayscale(100%)"); - } - #[test] fn test_parse_interactivity_utilities() { let parsed = parse_single_class("cursor-pointer").expect("Should parse"); @@ -4148,7 +3564,7 @@ mod tests { fn test_parse_svg_utilities() { let parsed = parse_single_class("fill-red-500").expect("Should parse"); assert_eq!(parsed.property, "fill"); - assert_eq!(parsed.value, "#ef4444"); + assert_eq!(parsed.value, "oklch(63.7% 0.237 25.331)"); let parsed = parse_single_class("stroke-black").expect("Should parse"); assert_eq!(parsed.property, "stroke"); @@ -4162,7 +3578,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] @@ -4182,7 +3598,6 @@ mod tests { #[case("z-[999]", "z-index", "999")] #[case("font-[Arial]", "font-family", "Arial")] #[case("tracking-[0.2em]", "letter-spacing", "0.2em")] - #[case("duration-[500ms]", "transition-duration", "500ms")] #[case("delay-[200ms]", "transition-delay", "200ms")] #[case("aspect-[16/9]", "aspect-ratio", "16/9")] #[case("columns-[3]", "columns", "3")] @@ -4241,22 +3656,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("blur-[10px]", "filter", "blur(10px)")] - #[case("brightness-[1.2]", "filter", "brightness(1.2)")] - #[case("contrast-[1.5]", "filter", "contrast(1.5)")] - #[case("saturate-[2]", "filter", "saturate(2)")] - #[case("backdrop-blur-[5px]", "backdrop-filter", "blur(5px)")] - fn test_parse_arbitrary_filter_values( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // ==================== WAVE 3: Layout & Flex/Grid Utilities ==================== // Wave 3.1: aspect-ratio utilities (lines 1198-1204) @@ -4399,8 +3798,6 @@ mod tests { #[case("mr-4", "margin-right", "1rem")] #[case("mb-4", "margin-bottom", "1rem")] #[case("ml-4", "margin-left", "1rem")] - #[case("space-x-4", "column-gap", "1rem")] - #[case("space-y-4", "row-gap", "1rem")] #[case("space-x-reverse", "--tw-space-x-reverse", "1")] #[case("space-y-reverse", "--tw-space-y-reverse", "1")] fn test_parse_logical_spacing_utilities( @@ -4607,75 +4004,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case( - "bg-gradient-to-t", - "background-image", - "linear-gradient(to top, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-tr", - "background-image", - "linear-gradient(to top right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-r", - "background-image", - "linear-gradient(to right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-br", - "background-image", - "linear-gradient(to bottom right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-b", - "background-image", - "linear-gradient(to bottom, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-bl", - "background-image", - "linear-gradient(to bottom left, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-l", - "background-image", - "linear-gradient(to left, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-tl", - "background-image", - "linear-gradient(to top left, var(--tw-gradient-stops))" - )] - fn test_parse_background_gradient( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - // Wave 5.2: Gradient stops (lines 2057-2068) - #[rstest] - #[case("from-red-500", "--tw-gradient-from", "#ef4444")] - #[case("from-blue-500", "--tw-gradient-from", "#3b82f6")] - #[case("via-red-500", "--tw-gradient-via", "#ef4444")] - #[case("via-blue-500", "--tw-gradient-via", "#3b82f6")] - #[case("to-red-500", "--tw-gradient-to", "#ef4444")] - #[case("to-blue-500", "--tw-gradient-to", "#3b82f6")] - fn test_parse_gradient_stops( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Sides round both of their corners #[rstest] #[case("rounded-t-lg", &[("border-top-left-radius", "0.5rem"), ("border-top-right-radius", "0.5rem")])] @@ -4771,37 +4099,7 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("divide-x", "--tw-divide-x-reverse", "0")] - #[case("divide-y", "--tw-divide-y-reverse", "0")] - #[case("divide-x-2", "border-inline-width", "2px")] - #[case("divide-y-2", "border-block-width", "2px")] - #[case("divide-x-reverse", "--tw-divide-x-reverse", "1")] - #[case("divide-y-reverse", "--tw-divide-y-reverse", "1")] - fn test_parse_divide_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.5: Effects (lines 2328-2350) - #[rstest] - #[case("shadow-red-500", "--tw-shadow-color", "#ef4444")] - #[case("shadow-blue-500", "--tw-shadow-color", "#3b82f6")] - fn test_parse_shadow_color_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("bg-blend-normal", "background-blend-mode", "normal")] #[case("bg-blend-multiply", "background-blend-mode", "multiply")] @@ -4818,195 +4116,7 @@ mod tests { } // Wave 5.6: Filters & backdrop filters (lines 2365-2555) - #[rstest] - #[case("blur-none", "filter", "blur(0)")] - #[case("blur-sm", "filter", "blur(4px)")] - #[case("blur-md", "filter", "blur(12px)")] - #[case("blur-xl", "filter", "blur(24px)")] - #[case("blur-2xl", "filter", "blur(40px)")] - #[case("blur-3xl", "filter", "blur(64px)")] - fn test_parse_blur_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("brightness-0", "filter", "brightness(0)")] - #[case("brightness-50", "filter", "brightness(.5)")] - #[case("brightness-100", "filter", "brightness(1)")] - #[case("brightness-150", "filter", "brightness(1.5)")] - #[case("brightness-200", "filter", "brightness(2)")] - fn test_parse_brightness_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("contrast-0", "filter", "contrast(0)")] - #[case("contrast-50", "filter", "contrast(.5)")] - #[case("contrast-100", "filter", "contrast(1)")] - #[case("contrast-150", "filter", "contrast(1.5)")] - #[case("contrast-200", "filter", "contrast(2)")] - fn test_parse_contrast_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case( - "drop-shadow", - "filter", - "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))" - )] - #[case("drop-shadow-sm", "filter", "drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))")] - #[case( - "drop-shadow-md", - "filter", - "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))" - )] - #[case( - "drop-shadow-lg", - "filter", - "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))" - )] - #[case( - "drop-shadow-xl", - "filter", - "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))" - )] - #[case( - "drop-shadow-2xl", - "filter", - "drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))" - )] - #[case("drop-shadow-none", "filter", "drop-shadow(0 0 #0000)")] - fn test_parse_drop_shadow_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("hue-rotate-0", "filter", "hue-rotate(0deg)")] - #[case("hue-rotate-15", "filter", "hue-rotate(15deg)")] - #[case("hue-rotate-30", "filter", "hue-rotate(30deg)")] - #[case("hue-rotate-60", "filter", "hue-rotate(60deg)")] - #[case("hue-rotate-90", "filter", "hue-rotate(90deg)")] - #[case("hue-rotate-180", "filter", "hue-rotate(180deg)")] - fn test_parse_hue_rotate_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("invert-0", "filter", "invert(0)")] - #[case("invert", "filter", "invert(100%)")] - #[case("saturate-0", "filter", "saturate(0)")] - #[case("saturate-50", "filter", "saturate(.5)")] - #[case("saturate-100", "filter", "saturate(1)")] - #[case("saturate-150", "filter", "saturate(1.5)")] - #[case("saturate-200", "filter", "saturate(2)")] - #[case("sepia-0", "filter", "sepia(0)")] - #[case("sepia", "filter", "sepia(100%)")] - fn test_parse_filter_effects_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-blur", "backdrop-filter", "blur(8px)")] - #[case("backdrop-blur-sm", "backdrop-filter", "blur(4px)")] - #[case("backdrop-blur-md", "backdrop-filter", "blur(12px)")] - #[case("backdrop-blur-lg", "backdrop-filter", "blur(16px)")] - #[case("backdrop-blur-xl", "backdrop-filter", "blur(24px)")] - #[case("backdrop-blur-2xl", "backdrop-filter", "blur(40px)")] - #[case("backdrop-blur-3xl", "backdrop-filter", "blur(64px)")] - #[case("backdrop-blur-none", "backdrop-filter", "blur(0)")] - fn test_parse_backdrop_blur_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-brightness-0", "backdrop-filter", "brightness(0)")] - #[case("backdrop-brightness-100", "backdrop-filter", "brightness(1)")] - #[case("backdrop-contrast-0", "backdrop-filter", "contrast(0)")] - #[case("backdrop-contrast-100", "backdrop-filter", "contrast(1)")] - #[case("backdrop-grayscale-0", "backdrop-filter", "grayscale(0)")] - #[case("backdrop-grayscale", "backdrop-filter", "grayscale(100%)")] - #[case("backdrop-invert-0", "backdrop-filter", "invert(0)")] - #[case("backdrop-invert", "backdrop-filter", "invert(100%)")] - #[case("backdrop-opacity-0", "backdrop-filter", "opacity(0)")] - #[case("backdrop-opacity-100", "backdrop-filter", "opacity(1)")] - #[case("backdrop-saturate-0", "backdrop-filter", "saturate(0)")] - #[case("backdrop-saturate-100", "backdrop-filter", "saturate(1)")] - #[case("backdrop-sepia-0", "backdrop-filter", "sepia(0)")] - #[case("backdrop-sepia", "backdrop-filter", "sepia(100%)")] - fn test_parse_backdrop_filter_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.7: Transitions & animations (lines 2600-2618) - #[rstest] - #[case("delay-0", "transition-delay", "0s")] - #[case("delay-75", "transition-delay", "75ms")] - #[case("delay-100", "transition-delay", "100ms")] - #[case("delay-150", "transition-delay", "150ms")] - #[case("delay-200", "transition-delay", "200ms")] - #[case("delay-300", "transition-delay", "300ms")] - #[case("delay-500", "transition-delay", "500ms")] - #[case("delay-700", "transition-delay", "700ms")] - #[case("delay-1000", "transition-delay", "1000ms")] - fn test_parse_delay_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("animate-none", "animation", "none")] #[case("animate-spin", "animation", "spin 1s linear infinite")] @@ -5182,10 +4292,10 @@ mod tests { // Wave 5.9: Interactivity (lines 2732-2842) #[rstest] #[case("accent-auto", "accent-color", "auto")] - #[case("accent-red-500", "accent-color", "#ef4444")] + #[case("accent-red-500", "accent-color", "oklch(63.7% 0.237 25.331)")] #[case("appearance-none", "appearance", "none")] #[case("appearance-auto", "appearance", "auto")] - #[case("caret-red-500", "caret-color", "#ef4444")] + #[case("caret-red-500", "caret-color", "oklch(63.7% 0.237 25.331)")] fn test_parse_interactivity_accent_caret( #[case] class: &str, #[case] expected_prop: &str, @@ -5510,8 +4620,8 @@ mod tests { #[rstest] #[case("outline-black", "outline-color", "#000")] #[case("outline-white", "outline-color", "#fff")] - #[case("outline-red-500", "outline-color", "#ef4444")] - #[case("outline-blue-500", "outline-color", "#3b82f6")] + #[case("outline-red-500", "outline-color", "oklch(63.7% 0.237 25.331)")] + #[case("outline-blue-500", "outline-color", "oklch(62.3% 0.214 259.815)")] fn test_parse_outline_color( #[case] class: &str, #[case] expected_prop: &str, @@ -5539,8 +4649,8 @@ mod tests { #[rstest] #[case("ring-black", "--tw-ring-color", "#000")] #[case("ring-white", "--tw-ring-color", "#fff")] - #[case("ring-red-500", "--tw-ring-color", "#ef4444")] - #[case("ring-blue-500", "--tw-ring-color", "#3b82f6")] + #[case("ring-red-500", "--tw-ring-color", "oklch(63.7% 0.237 25.331)")] + #[case("ring-blue-500", "--tw-ring-color", "oklch(62.3% 0.214 259.815)")] fn test_parse_ring_color( #[case] class: &str, #[case] expected_prop: &str, @@ -5552,53 +4662,7 @@ mod tests { } // Wave 6.15: Divide utilities (lines 2293-2313) - #[rstest] - #[case("divide-black", "--tw-divide-color", "#000")] - #[case("divide-white", "--tw-divide-color", "#fff")] - #[case("divide-red-500", "--tw-divide-color", "#ef4444")] - fn test_parse_divide_color( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse divide color"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("divide-solid", "border-style", "solid")] - #[case("divide-dashed", "border-style", "dashed")] - #[case("divide-dotted", "border-style", "dotted")] - #[case("divide-double", "border-style", "double")] - #[case("divide-none", "border-style", "none")] - fn test_parse_divide_style( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse divide style"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 6.16: Filter fallbacks (lines 2390, 2405, 2419) - #[test] - fn test_brightness_unknown_value_returns_none() { - let result = parse_single_class("brightness-unknown"); - assert!(result.is_none()); - let result = parse_single_class("brightness-999"); - assert!(result.is_none()); - } - - #[test] - fn test_contrast_unknown_value_returns_none() { - let result = parse_single_class("contrast-unknown"); - assert!(result.is_none()); - let result = parse_single_class("contrast-999"); - assert!(result.is_none()); - } - #[test] fn test_drop_shadow_unknown_value_returns_none() { let result = parse_single_class("drop-shadow-unknown"); @@ -5608,49 +4672,6 @@ mod tests { } // Wave 6.17: Backdrop filter extended values (lines 2504-2555) - #[rstest] - #[case("backdrop-brightness-105", "backdrop-filter", "brightness(1.05)")] - #[case("backdrop-brightness-110", "backdrop-filter", "brightness(1.1)")] - #[case("backdrop-brightness-125", "backdrop-filter", "brightness(1.25)")] - #[case("backdrop-brightness-150", "backdrop-filter", "brightness(1.5)")] - #[case("backdrop-brightness-200", "backdrop-filter", "brightness(2)")] - fn test_parse_backdrop_brightness_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-brightness"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-contrast-125", "backdrop-filter", "contrast(1.25)")] - #[case("backdrop-contrast-150", "backdrop-filter", "contrast(1.5)")] - #[case("backdrop-contrast-200", "backdrop-filter", "contrast(2)")] - fn test_parse_backdrop_contrast_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-contrast"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-saturate-150", "backdrop-filter", "saturate(1.5)")] - #[case("backdrop-saturate-200", "backdrop-filter", "saturate(2)")] - fn test_parse_backdrop_saturate_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-saturate"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[test] fn test_backdrop_brightness_unknown_returns_none() { let result = parse_single_class("backdrop-brightness-unknown"); @@ -5873,22 +4894,6 @@ mod tests { } // Wave 7.8: shadow without suffix (line 2333) - #[rstest] - #[case( - "shadow", - "box-shadow", - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)" - )] - fn test_shadow_without_suffix( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse shadow"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.9: mix-blend- prefix (line 2345) #[rstest] #[case("mix-blend-normal", "mix-blend-mode", "normal")] @@ -5928,38 +4933,8 @@ mod tests { } // Wave 7.11: grayscale-0 (line 2432) - #[rstest] - #[case("grayscale-0", "filter", "grayscale(0)")] - fn test_grayscale_zero( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse grayscale"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.12: hue-rotate- unknown value (line 2444) - #[rstest] - #[case("hue-rotate-unknown")] - #[case("hue-rotate-999")] - #[case("hue-rotate-45")] - fn test_hue_rotate_unknown_returns_none(#[case] class: &str) { - let result = parse_single_class(class); - assert!(result.is_none()); - } - // Wave 7.13: saturate- unknown value (line 2465) - #[rstest] - #[case("saturate-unknown")] - #[case("saturate-999")] - #[case("saturate-75")] - fn test_saturate_unknown_returns_none(#[case] class: &str) { - let result = parse_single_class(class); - assert!(result.is_none()); - } - // ============================================================================ // WAVE 8: Coverage Gap Tests for Lines 816, 866, 295 // ============================================================================ @@ -6211,10 +5186,6 @@ mod tests { #[case("nth-0:p-4")] #[case("nth-x:p-4")] #[case("print:[@media_screen]:p-4")] - #[case("p-4!")] - #[case("!p-4")] - #[case("bg-red-500/50")] - #[case("text-red-500/6")] #[case("text-sm/x")] #[case("text-sm/[_]")] #[case("text-sm/(x)")] @@ -6417,8 +5388,6 @@ mod tests { #[case("font-[number:var(--w)]", "font-weight", "var(--w)")] #[case("font-[Inter_Var]", "font-family", "Inter Var")] #[case("font-[family-name:var(--f)]", "font-family", "var(--f)")] - #[case("blur-[2px]", "filter", "blur(2px)")] - #[case("backdrop-blur-[2px]", "backdrop-filter", "blur(2px)")] #[case("w-[calc(100%_-_2rem)]", "width", "calc(100% - 2rem)")] #[case("m-[var(--x_y)]", "margin", "var(--x_y)")] #[case( @@ -6508,11 +5477,20 @@ mod tests { styles[0].selector().map(ToString::to_string).as_deref(), Some("@media(hover:hover) &:hover") ); - assert!(!class.uses_properties()); + assert_eq!(class.rules(), Vec::<&str>::new()); let class = parse_class("selection:translate-x-4").unwrap(); assert_eq!(class.styles().count(), 4); - assert!(class.uses_properties()); - assert!(parse_class("before:block").unwrap().uses_properties()); + assert_eq!( + class.rules(), + vec![ + "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", + ] + ); + assert_eq!( + parse_class("before:block").unwrap().rules(), + vec!["@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}"] + ); } } diff --git a/libs/extractor/src/tailwind_children.rs b/libs/extractor/src/tailwind_children.rs new file mode 100644 index 000000000..b1195d9eb --- /dev/null +++ b/libs/extractor/src/tailwind_children.rs @@ -0,0 +1,326 @@ +//! `space-*` and `divide-*`: utilities that style the children of the element, +//! all but the last, through the selector Tailwind CSS v4 writes for them + +use crate::tailwind::{ + Declaration, SPACING_SCALE, arbitrary_or_variable, decl, is_positive_integer, +}; +use crate::tailwind_color::color_value; + +/// The children `space-*` and `divide-*` style, which `:where` leaves to any +/// utility of the child itself +pub const CHILDREN: &str = ":where(& > :not(:last-child))"; + +/// The value of the spacing `argument`: a step of the scale, or an arbitrary +/// value, negated by a leading `-` +fn spacing(argument: &str, negative: bool) -> Option { + if let Some(&value) = SPACING_SCALE.get(argument) { + return Some(if negative && value != "0px" { + format!("-{value}") + } else { + value.to_string() + }); + } + let value = arbitrary_or_variable(argument)?; + Some(if negative { + format!("calc({value} * -1)") + } else { + value + }) +} +/// The margins of `space-x-*` and `space-y-*` +fn space_utility(axis: char, argument: &str, negative: bool) -> Option> { + let reverse = format!("--tw-space-{axis}-reverse"); + if argument == "reverse" { + return (!negative).then(|| vec![decl(reverse, "1")]); + } + let value = spacing(argument, negative)?; + let (start, end) = if axis == 'x' { + ("margin-inline-start", "margin-inline-end") + } else { + ("margin-block-start", "margin-block-end") + }; + if matches!(value.as_str(), "0px" | "0") { + return Some(vec![decl(start, "0"), decl(end, "0")]); + } + Some(vec![ + decl(start, format!("calc({value} * var({reverse}))")), + decl(end, format!("calc({value} * calc(1 - var({reverse})))")), + ]) +} + +/// The border width of `divide-x-*` and `divide-y-*`: a number of pixels, +/// or an arbitrary value +fn divide_width(argument: &str) -> Option { + if is_positive_integer(argument) { + return Some(format!("{argument}px")); + } + arbitrary_or_variable(argument) +} + +/// The borders of `divide-x-*` and `divide-y-*` +fn divide_width_utility(axis: char, argument: &str) -> Option> { + let reverse = format!("--tw-divide-{axis}-reverse"); + if argument == "-reverse" { + return Some(vec![decl(reverse, "1")]); + } + let width = if argument.is_empty() { + String::from("1px") + } else { + divide_width(argument.strip_prefix('-')?)? + }; + let start = format!("calc({width} * var({reverse}))"); + let end = format!("calc({width} * calc(1 - var({reverse})))"); + Some(if axis == 'x' { + vec![ + decl("border-inline-style", "var(--tw-border-style)"), + decl("border-inline-start-width", start), + decl("border-inline-end-width", end), + ] + } else { + vec![ + decl("border-bottom-style", "var(--tw-border-style)"), + decl("border-top-style", "var(--tw-border-style)"), + decl("border-top-width", start), + decl("border-bottom-width", end), + ] + }) +} +/// The declarations of `space-*` and `divide-*`, which style the children +pub fn children_utility(name: &str) -> Option> { + let (negative, name) = name + .strip_prefix('-') + .map_or((false, name), |name| (true, name)); + if let Some(argument) = name.strip_prefix("space-x-") { + return space_utility('x', argument, negative); + } + if let Some(argument) = name.strip_prefix("space-y-") { + return space_utility('y', argument, negative); + } + if negative { + return None; + } + let argument = name.strip_prefix("divide-")?; + if let Some(declarations) = argument + .strip_prefix('x') + .and_then(|argument| divide_width_utility('x', argument)) + .or_else(|| { + argument + .strip_prefix('y') + .and_then(|argument| divide_width_utility('y', argument)) + }) + { + return Some(declarations); + } + if matches!(argument, "solid" | "dashed" | "dotted" | "double" | "none") { + return Some(vec![ + decl("--tw-border-style", argument.to_string()), + decl("border-style", argument.to_string()), + ]); + } + color_value(argument).map(|color| vec![decl("border-color", color)]) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::tailwind::{declarations_of, parse_class}; + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + #[rstest] + #[case("space-x-0", &[("margin-inline-start", "0"), ("margin-inline-end", "0")])] + #[case("space-x-px", &[ + ("margin-inline-start", "calc(1px * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(1px * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("space-x-4", &[ + ("margin-inline-start", "calc(1rem * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(1rem * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-4", &[ + ("margin-inline-start", "calc(-1rem * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(-1rem * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-px", &[ + ("margin-inline-start", "calc(-1px * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(-1px * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-y-0", &[("margin-block-start", "0"), ("margin-block-end", "0")])] + #[case("space-y-2.5", &[ + ("margin-block-start", "calc(0.625rem * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(0.625rem * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-y-[3px]", &[ + ("margin-block-start", "calc(3px * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(3px * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-x-(--gap)", &[ + ("margin-inline-start", "calc(var(--gap) * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(var(--gap) * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-[3px]", &[ + ("margin-inline-start", "calc(calc(3px * -1) * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(calc(3px * -1) * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-y-(--gap)", &[ + ("margin-block-start", "calc(calc(var(--gap) * -1) * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(calc(var(--gap) * -1) * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-x-reverse", &[("--tw-space-x-reverse", "1")])] + #[case("space-y-reverse", &[("--tw-space-y-reverse", "1")])] + fn space_adds_margins_between_the_children( + #[case] class: &str, + #[case] expected: &[(&str, &str)], + ) { + expect(class, expected); + } + + #[rstest] + #[case("divide-x", "x", "1px")] + #[case("divide-x-0", "x", "0px")] + #[case("divide-x-2", "x", "2px")] + #[case("divide-x-8", "x", "8px")] + #[case("divide-x-[3px]", "x", "3px")] + #[case("divide-x-(--w)", "x", "var(--w)")] + #[case("divide-y", "y", "1px")] + #[case("divide-y-4", "y", "4px")] + fn divide_borders_the_children(#[case] class: &str, #[case] axis: &str, #[case] width: &str) { + if axis == "x" { + expect( + class, + &[ + ("border-inline-style", "var(--tw-border-style)"), + ( + "border-inline-start-width", + &format!("calc({width} * var(--tw-divide-x-reverse))"), + ), + ( + "border-inline-end-width", + &format!("calc({width} * calc(1 - var(--tw-divide-x-reverse)))"), + ), + ], + ); + } else { + expect( + class, + &[ + ("border-bottom-style", "var(--tw-border-style)"), + ("border-top-style", "var(--tw-border-style)"), + ( + "border-top-width", + &format!("calc({width} * var(--tw-divide-y-reverse))"), + ), + ( + "border-bottom-width", + &format!("calc({width} * calc(1 - var(--tw-divide-y-reverse)))"), + ), + ], + ); + } + } + + #[test] + fn divide_reverse_style_and_color() { + expect("divide-x-reverse", &[("--tw-divide-x-reverse", "1")]); + expect("divide-y-reverse", &[("--tw-divide-y-reverse", "1")]); + for style in ["solid", "dashed", "dotted", "double", "none"] { + expect( + &format!("divide-{style}"), + &[("--tw-border-style", style), ("border-style", style)], + ); + } + expect( + "divide-red-500", + &[("border-color", "oklch(63.7% 0.237 25.331)")], + ); + expect( + "divide-yellow-500/50", + &[( + "border-color", + "color-mix(in oklab, oklch(79.5% 0.184 86.047) 50%, transparent)", + )], + ); + } + + #[rstest] + #[case("divide-xyz")] + #[case("divide-x-")] + #[case("divide-x-1.5")] + #[case("divide-x-05")] + #[case("divide-x-reverse-2")] + #[case("divide-y-abc")] + #[case("divide-purple")] + #[case("divide-")] + #[case("-divide-x-2")] + #[case("-divide-red-500")] + #[case("space-x-")] + #[case("space-x-reverse-1")] + #[case("space-x-1.3")] + #[case("space-x-huge")] + #[case("-space-x-reverse")] + #[case("space-z-4")] + fn unknown_children_utilities_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn children_utilities_apply_under_variants_to_the_children() { + let class = parse_class("space-x-4").unwrap(); + assert_eq!(class.conditions.len(), 1); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(CHILDREN) + ); + let class = parse_class("md:space-x-4").unwrap(); + assert_eq!(class.level, 2); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(CHILDREN) + ); + let class = parse_class("focus:divide-x-2").unwrap(); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(":where(&:focus > :not(:last-child))") + ); + let class = parse_class("space-x-4!").unwrap(); + assert!( + class + .declarations + .iter() + .all(|(_, value)| value.ends_with(" !important")) + ); + } + + #[test] + fn children_rules_register_their_variables() { + assert_eq!( + parse_class("space-x-4").unwrap().rules(), + vec!["@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}"] + ); + assert_eq!( + parse_class("divide-x-2").unwrap().rules(), + vec![ + "@property --tw-divide-x-reverse{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}", + ] + ); + } +} diff --git a/libs/extractor/src/tailwind_color.rs b/libs/extractor/src/tailwind_color.rs new file mode 100644 index 000000000..db1aaf193 --- /dev/null +++ b/libs/extractor/src/tailwind_color.rs @@ -0,0 +1,545 @@ +//! The colors a Tailwind class names: the devup theme's, then the default +//! palette of Tailwind CSS v4, with the opacity modifier (`/50`, `/[0.5]`, +//! `/(--alpha)`) applied the way v4 writes it + +use std::borrow::Cow; + +use css::theme_tokens::is_color_token; +use phf::phf_map; + +use crate::tailwind::{decode_arbitrary_value, split_top_level}; + +/// The colors of the Tailwind CSS v4 default theme (`--color-*`) +static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { + "inherit" => "inherit", + "current" => "currentcolor", + "transparent" => "transparent", + "red-50" => "oklch(97.1% 0.013 17.38)", + "red-100" => "oklch(93.6% 0.032 17.717)", + "red-200" => "oklch(88.5% 0.062 18.334)", + "red-300" => "oklch(80.8% 0.114 19.571)", + "red-400" => "oklch(70.4% 0.191 22.216)", + "red-500" => "oklch(63.7% 0.237 25.331)", + "red-600" => "oklch(57.7% 0.245 27.325)", + "red-700" => "oklch(50.5% 0.213 27.518)", + "red-800" => "oklch(44.4% 0.177 26.899)", + "red-900" => "oklch(39.6% 0.141 25.723)", + "red-950" => "oklch(25.8% 0.092 26.042)", + "orange-50" => "oklch(98% 0.016 73.684)", + "orange-100" => "oklch(95.4% 0.038 75.164)", + "orange-200" => "oklch(90.1% 0.076 70.697)", + "orange-300" => "oklch(83.7% 0.128 66.29)", + "orange-400" => "oklch(75% 0.183 55.934)", + "orange-500" => "oklch(70.5% 0.213 47.604)", + "orange-600" => "oklch(64.6% 0.222 41.116)", + "orange-700" => "oklch(55.3% 0.195 38.402)", + "orange-800" => "oklch(47% 0.157 37.304)", + "orange-900" => "oklch(40.8% 0.123 38.172)", + "orange-950" => "oklch(26.6% 0.079 36.259)", + "amber-50" => "oklch(98.7% 0.022 95.277)", + "amber-100" => "oklch(96.2% 0.059 95.617)", + "amber-200" => "oklch(92.4% 0.12 95.746)", + "amber-300" => "oklch(87.9% 0.169 91.605)", + "amber-400" => "oklch(82.8% 0.189 84.429)", + "amber-500" => "oklch(76.9% 0.188 70.08)", + "amber-600" => "oklch(66.6% 0.179 58.318)", + "amber-700" => "oklch(55.5% 0.163 48.998)", + "amber-800" => "oklch(47.3% 0.137 46.201)", + "amber-900" => "oklch(41.4% 0.112 45.904)", + "amber-950" => "oklch(27.9% 0.077 45.635)", + "yellow-50" => "oklch(98.7% 0.026 102.212)", + "yellow-100" => "oklch(97.3% 0.071 103.193)", + "yellow-200" => "oklch(94.5% 0.129 101.54)", + "yellow-300" => "oklch(90.5% 0.182 98.111)", + "yellow-400" => "oklch(85.2% 0.199 91.936)", + "yellow-500" => "oklch(79.5% 0.184 86.047)", + "yellow-600" => "oklch(68.1% 0.162 75.834)", + "yellow-700" => "oklch(55.4% 0.135 66.442)", + "yellow-800" => "oklch(47.6% 0.114 61.907)", + "yellow-900" => "oklch(42.1% 0.095 57.708)", + "yellow-950" => "oklch(28.6% 0.066 53.813)", + "lime-50" => "oklch(98.6% 0.031 120.757)", + "lime-100" => "oklch(96.7% 0.067 122.328)", + "lime-200" => "oklch(93.8% 0.127 124.321)", + "lime-300" => "oklch(89.7% 0.196 126.665)", + "lime-400" => "oklch(84.1% 0.238 128.85)", + "lime-500" => "oklch(76.8% 0.233 130.85)", + "lime-600" => "oklch(64.8% 0.2 131.684)", + "lime-700" => "oklch(64.8% 0.2 131.684)", + "lime-800" => "oklch(45.3% 0.124 130.933)", + "lime-900" => "oklch(40.5% 0.101 131.063)", + "lime-950" => "oklch(27.4% 0.072 132.109)", + "green-50" => "oklch(98.2% 0.018 155.826)", + "green-100" => "oklch(96.2% 0.044 156.743)", + "green-200" => "oklch(92.5% 0.084 155.995)", + "green-300" => "oklch(87.1% 0.15 154.449)", + "green-400" => "oklch(79.2% 0.209 151.711)", + "green-500" => "oklch(72.3% 0.219 149.579)", + "green-600" => "oklch(62.7% 0.194 149.214)", + "green-700" => "oklch(52.7% 0.154 150.069)", + "green-800" => "oklch(44.8% 0.119 151.328)", + "green-900" => "oklch(39.3% 0.095 152.535)", + "green-950" => "oklch(26.6% 0.065 152.934)", + "emerald-50" => "oklch(97.9% 0.021 166.113)", + "emerald-100" => "oklch(95% 0.052 163.051)", + "emerald-200" => "oklch(90.5% 0.093 164.15)", + "emerald-300" => "oklch(84.5% 0.143 164.978)", + "emerald-400" => "oklch(76.5% 0.177 163.223)", + "emerald-500" => "oklch(69.6% 0.17 162.48)", + "emerald-600" => "oklch(59.6% 0.145 163.225)", + "emerald-700" => "oklch(50.8% 0.118 165.612)", + "emerald-800" => "oklch(43.2% 0.095 166.913)", + "emerald-900" => "oklch(37.8% 0.077 168.94)", + "emerald-950" => "oklch(26.2% 0.051 172.552)", + "teal-50" => "oklch(98.4% 0.014 180.72)", + "teal-100" => "oklch(95.3% 0.051 180.801)", + "teal-200" => "oklch(91% 0.096 180.426)", + "teal-300" => "oklch(85.5% 0.138 181.071)", + "teal-400" => "oklch(77.7% 0.152 181.912)", + "teal-500" => "oklch(70.4% 0.14 182.503)", + "teal-600" => "oklch(60% 0.118 184.704)", + "teal-700" => "oklch(51.1% 0.096 186.391)", + "teal-800" => "oklch(43.7% 0.078 188.216)", + "teal-900" => "oklch(38.6% 0.063 188.416)", + "teal-950" => "oklch(27.7% 0.046 192.524)", + "cyan-50" => "oklch(98.4% 0.019 200.873)", + "cyan-100" => "oklch(95.6% 0.045 203.388)", + "cyan-200" => "oklch(91.7% 0.08 205.041)", + "cyan-300" => "oklch(86.5% 0.127 207.078)", + "cyan-400" => "oklch(78.9% 0.154 211.53)", + "cyan-500" => "oklch(71.5% 0.143 215.221)", + "cyan-600" => "oklch(60.9% 0.126 221.723)", + "cyan-700" => "oklch(52% 0.105 223.128)", + "cyan-800" => "oklch(45% 0.085 224.283)", + "cyan-900" => "oklch(39.8% 0.07 227.392)", + "cyan-950" => "oklch(30.2% 0.056 229.695)", + "sky-50" => "oklch(97.7% 0.013 236.62)", + "sky-100" => "oklch(95.1% 0.026 236.824)", + "sky-200" => "oklch(90.1% 0.058 230.902)", + "sky-300" => "oklch(82.8% 0.111 230.318)", + "sky-400" => "oklch(74.6% 0.16 232.661)", + "sky-500" => "oklch(68.5% 0.169 237.323)", + "sky-600" => "oklch(58.8% 0.158 241.966)", + "sky-700" => "oklch(50% 0.134 242.749)", + "sky-800" => "oklch(44.3% 0.11 240.79)", + "sky-900" => "oklch(39.1% 0.09 240.876)", + "sky-950" => "oklch(29.3% 0.066 243.157)", + "blue-50" => "oklch(97% 0.014 254.604)", + "blue-100" => "oklch(93.2% 0.032 255.585)", + "blue-200" => "oklch(88.2% 0.059 254.128)", + "blue-300" => "oklch(80.9% 0.105 251.813)", + "blue-400" => "oklch(70.7% 0.165 254.624)", + "blue-500" => "oklch(62.3% 0.214 259.815)", + "blue-600" => "oklch(54.6% 0.245 262.881)", + "blue-700" => "oklch(48.8% 0.243 264.376)", + "blue-800" => "oklch(42.4% 0.199 265.638)", + "blue-900" => "oklch(37.9% 0.146 265.522)", + "blue-950" => "oklch(28.2% 0.091 267.935)", + "indigo-50" => "oklch(96.2% 0.018 272.314)", + "indigo-100" => "oklch(93% 0.034 272.788)", + "indigo-200" => "oklch(87% 0.065 274.039)", + "indigo-300" => "oklch(78.5% 0.115 274.713)", + "indigo-400" => "oklch(67.3% 0.182 276.935)", + "indigo-500" => "oklch(58.5% 0.233 277.117)", + "indigo-600" => "oklch(51.1% 0.262 276.966)", + "indigo-700" => "oklch(45.7% 0.24 277.023)", + "indigo-800" => "oklch(39.8% 0.195 277.366)", + "indigo-900" => "oklch(35.9% 0.144 278.697)", + "indigo-950" => "oklch(25.7% 0.09 281.288)", + "violet-50" => "oklch(96.9% 0.016 293.756)", + "violet-100" => "oklch(94.3% 0.029 294.588)", + "violet-200" => "oklch(89.4% 0.057 293.283)", + "violet-300" => "oklch(81.1% 0.111 293.571)", + "violet-400" => "oklch(70.2% 0.183 293.541)", + "violet-500" => "oklch(60.6% 0.25 292.717)", + "violet-600" => "oklch(54.1% 0.281 293.009)", + "violet-700" => "oklch(49.1% 0.27 292.581)", + "violet-800" => "oklch(43.2% 0.232 292.759)", + "violet-900" => "oklch(38% 0.189 293.745)", + "violet-950" => "oklch(28.3% 0.141 291.089)", + "purple-50" => "oklch(97.7% 0.014 308.299)", + "purple-100" => "oklch(94.6% 0.033 307.174)", + "purple-200" => "oklch(90.2% 0.063 306.703)", + "purple-300" => "oklch(82.7% 0.119 306.383)", + "purple-400" => "oklch(71.4% 0.203 305.504)", + "purple-500" => "oklch(62.7% 0.265 303.9)", + "purple-600" => "oklch(55.8% 0.288 302.321)", + "purple-700" => "oklch(49.6% 0.265 301.924)", + "purple-800" => "oklch(43.8% 0.218 303.724)", + "purple-900" => "oklch(38.1% 0.176 304.987)", + "purple-950" => "oklch(29.1% 0.149 302.717)", + "fuchsia-50" => "oklch(97.7% 0.017 320.058)", + "fuchsia-100" => "oklch(95.2% 0.037 318.852)", + "fuchsia-200" => "oklch(90.3% 0.076 319.62)", + "fuchsia-300" => "oklch(83.3% 0.145 321.434)", + "fuchsia-400" => "oklch(74% 0.238 322.16)", + "fuchsia-500" => "oklch(66.7% 0.295 322.15)", + "fuchsia-600" => "oklch(59.1% 0.293 322.896)", + "fuchsia-700" => "oklch(51.8% 0.253 323.949)", + "fuchsia-800" => "oklch(45.2% 0.211 324.591)", + "fuchsia-900" => "oklch(40.1% 0.17 325.612)", + "fuchsia-950" => "oklch(29.3% 0.136 325.661)", + "pink-50" => "oklch(97.1% 0.014 343.198)", + "pink-100" => "oklch(94.8% 0.028 342.258)", + "pink-200" => "oklch(89.9% 0.061 343.231)", + "pink-300" => "oklch(82.3% 0.12 346.018)", + "pink-400" => "oklch(71.8% 0.202 349.761)", + "pink-500" => "oklch(65.6% 0.241 354.308)", + "pink-600" => "oklch(59.2% 0.249 0.584)", + "pink-700" => "oklch(52.5% 0.223 3.958)", + "pink-800" => "oklch(45.9% 0.187 3.815)", + "pink-900" => "oklch(40.8% 0.153 2.432)", + "pink-950" => "oklch(28.4% 0.109 3.907)", + "rose-50" => "oklch(96.9% 0.015 12.422)", + "rose-100" => "oklch(94.1% 0.03 12.58)", + "rose-200" => "oklch(89.2% 0.058 10.001)", + "rose-300" => "oklch(81% 0.117 11.638)", + "rose-400" => "oklch(71.2% 0.194 13.428)", + "rose-500" => "oklch(64.5% 0.246 16.439)", + "rose-600" => "oklch(58.6% 0.253 17.585)", + "rose-700" => "oklch(51.4% 0.222 16.935)", + "rose-800" => "oklch(45.5% 0.188 13.697)", + "rose-900" => "oklch(41% 0.159 10.272)", + "rose-950" => "oklch(27.1% 0.105 12.094)", + "slate-50" => "oklch(98.4% 0.003 247.858)", + "slate-100" => "oklch(96.8% 0.007 247.896)", + "slate-200" => "oklch(92.9% 0.013 255.508)", + "slate-300" => "oklch(86.9% 0.022 252.894)", + "slate-400" => "oklch(70.4% 0.04 256.788)", + "slate-500" => "oklch(55.4% 0.046 257.417)", + "slate-600" => "oklch(44.6% 0.043 257.281)", + "slate-700" => "oklch(37.2% 0.044 257.287)", + "slate-800" => "oklch(27.9% 0.041 260.031)", + "slate-900" => "oklch(20.8% 0.042 265.755)", + "slate-950" => "oklch(12.9% 0.042 264.695)", + "gray-50" => "oklch(98.5% 0.002 247.839)", + "gray-100" => "oklch(96.7% 0.003 264.542)", + "gray-200" => "oklch(92.8% 0.006 264.531)", + "gray-300" => "oklch(87.2% 0.01 258.338)", + "gray-400" => "oklch(70.7% 0.022 261.325)", + "gray-500" => "oklch(55.1% 0.027 264.364)", + "gray-600" => "oklch(44.6% 0.03 256.802)", + "gray-700" => "oklch(37.3% 0.034 259.733)", + "gray-800" => "oklch(27.8% 0.033 256.848)", + "gray-900" => "oklch(21% 0.034 264.665)", + "gray-950" => "oklch(13% 0.028 261.692)", + "zinc-50" => "oklch(98.5% 0 none)", + "zinc-100" => "oklch(96.7% 0.001 286.375)", + "zinc-200" => "oklch(92% 0.004 286.32)", + "zinc-300" => "oklch(87.1% 0.006 286.286)", + "zinc-400" => "oklch(70.5% 0.015 286.067)", + "zinc-500" => "oklch(55.2% 0.016 285.938)", + "zinc-600" => "oklch(44.2% 0.017 285.786)", + "zinc-700" => "oklch(37% 0.013 285.805)", + "zinc-800" => "oklch(27.4% 0.006 286.033)", + "zinc-900" => "oklch(21% 0.006 285.885)", + "zinc-950" => "oklch(14.1% 0.005 285.823)", + "neutral-50" => "oklch(98.5% 0 none)", + "neutral-100" => "oklch(97% 0 none)", + "neutral-200" => "oklch(92.2% 0 none)", + "neutral-300" => "oklch(87% 0 none)", + "neutral-400" => "oklch(70.8% 0 none)", + "neutral-500" => "oklch(55.6% 0 none)", + "neutral-600" => "oklch(43.9% 0 none)", + "neutral-700" => "oklch(37.1% 0 none)", + "neutral-800" => "oklch(26.9% 0 none)", + "neutral-900" => "oklch(20.5% 0 none)", + "neutral-950" => "oklch(14.5% 0 none)", + "stone-50" => "oklch(98.5% 0.001 106.423)", + "stone-100" => "oklch(97% 0.001 106.424)", + "stone-200" => "oklch(92.3% 0.003 48.717)", + "stone-300" => "oklch(86.9% 0.005 56.366)", + "stone-400" => "oklch(70.9% 0.01 56.259)", + "stone-500" => "oklch(55.3% 0.013 58.071)", + "stone-600" => "oklch(44.4% 0.011 73.639)", + "stone-700" => "oklch(37.4% 0.01 67.558)", + "stone-800" => "oklch(26.8% 0.007 34.298)", + "stone-900" => "oklch(21.6% 0.006 56.043)", + "stone-950" => "oklch(14.7% 0.004 49.25)", + "mauve-50" => "oklch(98.5% 0 none)", + "mauve-100" => "oklch(96% 0.003 325.6)", + "mauve-200" => "oklch(92.2% 0.005 325.62)", + "mauve-300" => "oklch(86.5% 0.012 325.68)", + "mauve-400" => "oklch(71.1% 0.019 323.02)", + "mauve-500" => "oklch(54.2% 0.034 322.5)", + "mauve-600" => "oklch(43.5% 0.029 321.78)", + "mauve-700" => "oklch(36.4% 0.029 323.89)", + "mauve-800" => "oklch(26.3% 0.024 320.12)", + "mauve-900" => "oklch(21.2% 0.019 322.12)", + "mauve-950" => "oklch(14.5% 0.008 326)", + "olive-50" => "oklch(98.8% 0.003 106.5)", + "olive-100" => "oklch(96.6% 0.005 106.5)", + "olive-200" => "oklch(93% 0.007 106.5)", + "olive-300" => "oklch(88% 0.011 106.6)", + "olive-400" => "oklch(73.7% 0.021 106.9)", + "olive-500" => "oklch(58% 0.031 107.3)", + "olive-600" => "oklch(46.6% 0.025 107.3)", + "olive-700" => "oklch(39.4% 0.023 107.4)", + "olive-800" => "oklch(28.6% 0.016 107.4)", + "olive-900" => "oklch(22.8% 0.013 107.4)", + "olive-950" => "oklch(15.3% 0.006 107.1)", + "mist-50" => "oklch(98.7% 0.002 197.1)", + "mist-100" => "oklch(96.3% 0.002 197.1)", + "mist-200" => "oklch(92.5% 0.005 214.3)", + "mist-300" => "oklch(87.2% 0.007 219.6)", + "mist-400" => "oklch(72.3% 0.014 214.4)", + "mist-500" => "oklch(56% 0.021 213.5)", + "mist-600" => "oklch(45% 0.017 213.2)", + "mist-700" => "oklch(37.8% 0.015 216)", + "mist-800" => "oklch(27.5% 0.011 216.9)", + "mist-900" => "oklch(21.8% 0.008 223.9)", + "mist-950" => "oklch(14.8% 0.004 228.8)", + "taupe-50" => "oklch(98.6% 0.002 67.8)", + "taupe-100" => "oklch(96% 0.002 17.2)", + "taupe-200" => "oklch(92.2% 0.005 34.3)", + "taupe-300" => "oklch(86.8% 0.007 39.5)", + "taupe-400" => "oklch(71.4% 0.014 41.2)", + "taupe-500" => "oklch(54.7% 0.021 43.1)", + "taupe-600" => "oklch(43.8% 0.017 39.3)", + "taupe-700" => "oklch(36.7% 0.016 35.7)", + "taupe-800" => "oklch(26.8% 0.011 36.5)", + "taupe-900" => "oklch(21.4% 0.009 43.1)", + "taupe-950" => "oklch(14.7% 0.004 49.3)", + "black" => "#000", + "white" => "#fff", +}; + +/// Whether `value` is a multiple of 0.25 written without needless zeros, which +/// is what v4 takes for an opacity +fn is_opacity(value: &str) -> bool { + value + .parse::() + .is_ok_and(|number| number >= 0.0 && number % 0.25 == 0.0 && number.to_string() == value) +} + +/// The alpha of an opacity modifier, as `color-mix` takes it +fn alpha(modifier: &str) -> Option { + if let Some(inner) = modifier + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + { + return Some(percentage(&decode_arbitrary_value(inner))); + } + if let Some(inner) = modifier + .strip_prefix('(') + .and_then(|inner| inner.strip_suffix(')')) + { + return inner.starts_with("--").then(|| format!("var({inner})")); + } + is_opacity(modifier).then(|| format!("{modifier}%")) +} + +/// `0.5` as the `50%` v4 turns a number into, anything else as written +fn percentage(value: &str) -> String { + value + .parse::() + .ok() + .filter(|number| { + number.is_finite() && value.starts_with(|c: char| c.is_ascii_digit() || c == '.') + }) + .map_or_else( + || value.to_string(), + |number| format!("{}%", number * 100.0), + ) +} + +/// `color` at `alpha`, which is `color` itself at a hundred percent +fn with_alpha(color: &str, alpha: &str) -> String { + if alpha == "100%" { + color.to_string() + } else { + format!("color-mix(in oklab, {color} {alpha}, transparent)") + } +} + +/// The color of the theme or the palette named `name` +fn named_color(name: &str) -> Option> { + if is_color_token(name) { + return Some(Cow::Owned(format!("var(--{name})"))); + } + TAILWIND_COLORS.get(name).map(|&color| Cow::Borrowed(color)) +} + +/// The color a class names (`red-500`, `primary`, `black/50`), `None` when it +/// names none +pub fn color_value(name: &str) -> Option> { + let mut parts = split_top_level(name, '/'); + let modifier = if parts.len() == 2 { parts.pop() } else { None }; + let [name] = parts[..] else { + return None; + }; + let color = named_color(name)?; + match modifier { + None => Some(color), + Some(_) => with_modifier(&color, modifier), + } +} + +/// `color` with the opacity `modifier`, if there is one. `inherit` has no +/// color to mix. +pub fn with_modifier(color: &str, modifier: Option<&str>) -> Option> { + let Some(modifier) = modifier else { + return Some(Cow::Owned(color.to_string())); + }; + if color == "inherit" { + return None; + } + Some(Cow::Owned(with_alpha(color, &alpha(modifier)?))) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeSet; + + use css::theme_tokens::set_color_tokens; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::tailwind::{decl, declarations_of}; + + fn color(class: &str) -> Option { + let mut declarations = declarations_of(class)?; + assert_eq!(declarations.len(), 1, "{class}: {declarations:?}"); + Some(declarations.remove(0).1) + } + + #[rstest] + #[case("bg-red-500", "oklch(63.7% 0.237 25.331)")] + #[case("bg-blue-500", "oklch(62.3% 0.214 259.815)")] + #[case("bg-slate-50", "oklch(98.4% 0.003 247.858)")] + #[case("bg-neutral-50", "oklch(98.5% 0 none)")] + #[case("bg-mauve-950", "oklch(14.5% 0.008 326)")] + #[case("bg-black", "#000")] + #[case("bg-white", "#fff")] + #[case("bg-transparent", "transparent")] + #[case("bg-current", "currentcolor")] + #[case("bg-inherit", "inherit")] + #[case("text-emerald-400", "oklch(76.5% 0.177 163.223)")] + #[case("border-rose-950", "oklch(27.1% 0.105 12.094)")] + #[case("outline-sky-300", "oklch(82.8% 0.111 230.318)")] + #[case("accent-lime-600", "oklch(64.8% 0.2 131.684)")] + #[case("caret-fuchsia-100", "oklch(95.2% 0.037 318.852)")] + #[case("fill-zinc-900", "oklch(21% 0.006 285.885)")] + #[case("stroke-stone-200", "oklch(92.3% 0.003 48.717)")] + fn palette_is_the_default_theme_of_v4(#[case] class: &str, #[case] expected: &str) { + assert_eq!(color(class).as_deref(), Some(expected)); + } + + #[rstest] + #[case( + "bg-red-500/50", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)" + )] + #[case( + "bg-red-500/0", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 0%, transparent)" + )] + #[case( + "bg-red-500/12.5", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 12.5%, transparent)" + )] + #[case("bg-red-500/100", "oklch(63.7% 0.237 25.331)")] + #[case( + "bg-red-500/[71.37%]", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 71.37%, transparent)" + )] + #[case( + "bg-red-500/[0.5]", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)" + )] + #[case( + "bg-red-500/(--alpha)", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--alpha), transparent)" + )] + #[case("bg-current/50", "color-mix(in oklab, currentcolor 50%, transparent)")] + #[case( + "bg-transparent/50", + "color-mix(in oklab, transparent 50%, transparent)" + )] + #[case("bg-black/10", "color-mix(in oklab, #000 10%, transparent)")] + #[case("text-white/75", "color-mix(in oklab, #fff 75%, transparent)")] + fn opacity_modifier_mixes_with_transparent(#[case] class: &str, #[case] expected: &str) { + assert_eq!(color(class).as_deref(), Some(expected)); + } + + #[rstest] + #[case("bg-inherit/50")] + #[case("bg-red-500/")] + #[case("bg-red-500/abc")] + #[case("bg-red-500/-5")] + #[case("bg-red-500/12.3")] + #[case("bg-red-500/012")] + #[case("bg-red-500/(alpha)")] + #[case("bg-red-500/50/50")] + #[case("bg-red-501")] + #[case("bg-unknown")] + fn invalid_color_stays_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn number_alphas_are_percentages() { + assert_eq!(percentage("0.5"), "50%"); + assert_eq!(percentage(".25"), "25%"); + assert_eq!(percentage("71.37%"), "71.37%"); + assert_eq!(percentage("var(--a)"), "var(--a)"); + assert_eq!(percentage("+1"), "+1"); + assert_eq!(percentage("inf"), "inf"); + assert!(is_opacity("0.25")); + assert!(!is_opacity("0.30")); + assert!(!is_opacity("-1")); + } + + #[test] + #[serial] + fn theme_colors_are_the_css_variables_of_the_theme() { + set_color_tokens(BTreeSet::from([ + String::from("primary"), + String::from("text"), + String::from("red-500"), + ])); + assert_eq!(color("bg-primary").as_deref(), Some("var(--primary)")); + assert_eq!(color("text-text").as_deref(), Some("var(--text)")); + assert_eq!(color("border-primary").as_deref(), Some("var(--primary)")); + assert_eq!( + color("bg-primary/50").as_deref(), + Some("color-mix(in oklab, var(--primary) 50%, transparent)") + ); + assert_eq!(color("bg-red-500").as_deref(), Some("var(--red-500)")); + assert_eq!( + color("bg-blue-500").as_deref(), + Some("oklch(62.3% 0.214 259.815)") + ); + assert_eq!(declarations_of("bg-secondary"), None); + set_color_tokens(BTreeSet::new()); + assert_eq!(declarations_of("bg-primary"), None); + } + + #[rstest] + #[case("p-4!", &[("padding", "1rem !important")])] + #[case("!p-4", &[("padding", "1rem !important")])] + #[case("-m-4!", &[("margin", "-1rem !important")])] + #[case("[color:red]!", &[("color", "red !important")])] + #[case("bg-red-500/50!", &[("background-color", "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent) !important")])] + #[case("blur-sm!", &[ + ("--tw-blur", "blur(8px) !important"), + ("filter", "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,) !important"), + ])] + fn important_marks_every_declaration(#[case] class: &str, #[case] expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected)); + } + + #[test] + fn important_applies_under_variants() { + let class = crate::tailwind::parse_class("hover:p-4!").unwrap(); + assert_eq!(class.declarations, vec![decl("padding", "1rem !important")]); + assert_eq!(crate::tailwind::parse_class("p-4!!"), None); + assert_eq!(crate::tailwind::parse_class("!"), None); + } +} diff --git a/libs/extractor/src/tailwind_effects.rs b/libs/extractor/src/tailwind_effects.rs new file mode 100644 index 000000000..c4ce46500 --- /dev/null +++ b/libs/extractor/src/tailwind_effects.rs @@ -0,0 +1,1049 @@ +//! Filters, backdrop filters, shadows and gradients: the Tailwind CSS v4 +//! utilities that compose through custom properties, so that `blur-sm` and +//! `grayscale` on one element add up instead of overwriting each other + +use std::borrow::Cow; + +use css::theme_tokens::is_shadow_token; + +use crate::tailwind::{ + Declaration, ValueType, arbitrary_or_variable, decl, decode_arbitrary_value, + is_positive_integer, split_top_level, value_type, +}; +use crate::tailwind_color::{color_value, with_modifier}; + +/// What `filter` holds: every function the filter utilities set +const FILTER: &str = "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)"; + +/// What `backdrop-filter` holds +const BACKDROP_FILTER: &str = "var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)"; + +/// How a bare number after a filter utility reads +#[derive(Clone, Copy)] +enum Bare { + Percent, + Degrees, + Opacity, + Blur, +} + +/// A function the filter utilities set +struct FilterFunction { + root: &'static str, + bare: Bare, + /// The value of the utility without one + default: Option<&'static str>, + /// Whether a leading `-` negates it + negative: bool, +} + +static FILTER_FUNCTIONS: [FilterFunction; 9] = [ + FilterFunction { + root: "blur", + bare: Bare::Blur, + default: Some("8px"), + negative: false, + }, + FilterFunction { + root: "brightness", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "contrast", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "grayscale", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, + FilterFunction { + root: "hue-rotate", + bare: Bare::Degrees, + default: None, + negative: true, + }, + FilterFunction { + root: "invert", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, + FilterFunction { + root: "opacity", + bare: Bare::Opacity, + default: None, + negative: false, + }, + FilterFunction { + root: "saturate", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "sepia", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, +]; + +/// The sizes of `blur-*` +fn blur_size(name: &str) -> Option<&'static str> { + Some(match name { + "xs" => "4px", + "sm" => "8px", + "md" => "12px", + "lg" => "16px", + "xl" => "24px", + "2xl" => "40px", + "3xl" => "64px", + _ => return None, + }) +} + +/// The value of a bare number such as the `50` of `brightness-50` +fn bare_value(bare: Bare, value: &str) -> Option { + match bare { + Bare::Percent if is_positive_integer(value) => Some(format!("{value}%")), + Bare::Degrees if is_positive_integer(value) => Some(format!("{value}deg")), + Bare::Opacity if is_opacity_number(value) => Some(format!("{value}%")), + Bare::Blur => blur_size(value).map(str::to_string), + _ => None, + } +} + +/// A multiple of 0.25, written without needless zeros +fn is_opacity_number(value: &str) -> bool { + value + .parse::() + .is_ok_and(|number| number >= 0.0 && number % 0.25 == 0.0 && number.to_string() == value) +} + +/// What a filter utility puts in the function: its default, a size, an +/// arbitrary value or a bare number +fn function_value( + function: &FilterFunction, + argument: Option<&str>, + negative: bool, +) -> Option { + let value = match argument { + None => function.default?.to_string(), + Some(argument) => { + arbitrary_or_variable(argument).or_else(|| bare_value(function.bare, argument))? + } + }; + match (negative, function.negative) { + (false, _) => Some(value), + (true, true) => Some(format!("calc({value} * -1)")), + (true, false) => None, + } +} + +/// The declarations of a filter or backdrop filter utility such as `blur-sm`, +/// `backdrop-opacity-50` or `filter-none` +fn filter_utility(name: &str, negative: bool) -> Option> { + let (backdrop, name) = name + .strip_prefix("backdrop-") + .map_or((false, name), |name| (true, name)); + let composed = if backdrop { BACKDROP_FILTER } else { FILTER }; + let property = if backdrop { + "backdrop-filter" + } else { + "filter" + }; + let apply = |value: Cow<'static, str>| { + let mut declarations = Vec::with_capacity(3); + if backdrop { + declarations.push(decl("-webkit-backdrop-filter", value.clone())); + } + declarations.push(decl(property, value)); + declarations + }; + if !negative { + if name == "filter" { + return Some(apply(Cow::Borrowed(composed))); + } + if let Some(argument) = name.strip_prefix("filter-") { + let value = if argument == "none" { + Cow::Borrowed("none") + } else { + Cow::Owned(arbitrary_or_variable(argument)?) + }; + return Some(apply(value)); + } + } + let function = FILTER_FUNCTIONS + .iter() + .filter(|function| backdrop || function.root != "opacity") + .find(|function| { + name == function.root + || name + .strip_prefix(function.root) + .is_some_and(|rest| rest.starts_with('-')) + })?; + let argument = name + .strip_prefix(function.root) + .and_then(|rest| rest.strip_prefix('-')); + let prefix = if backdrop { "--tw-backdrop-" } else { "--tw-" }; + // `blur-none` empties the function, which `var(--tw-blur,)` then skips + let value = if argument == Some("none") && function.root == "blur" && !negative { + String::from(" ") + } else { + format!( + "{}({})", + function.root, + function_value(function, argument, negative)? + ) + }; + let mut declarations = vec![decl(format!("{prefix}{}", function.root), value)]; + declarations.extend(apply(Cow::Borrowed(composed))); + Some(declarations) +} + +/// What `box-shadow` holds: the shadows `shadow-*` and `ring-*` set, each +/// nothing until one does +const BOX_SHADOW: &str = "var(--tw-inset-shadow,0 0 #0000), var(--tw-inset-ring-shadow,0 0 #0000), var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)"; + +/// The shadows of the default theme, each color to be replaced by `--tw-shadow-color` +fn shadow_size(name: &str) -> Option<&'static str> { + Some(match name { + "2xs" => "0 1px rgb(0 0 0 / 0.05)", + "xs" => "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "sm" => "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "md" => "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "lg" => "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", + "xl" => "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + "2xl" => "0 25px 50px -12px rgb(0 0 0 / 0.25)", + _ => return None, + }) +} + +/// `shadow` with the color of each of its layers taken from `--tw-shadow-color` +/// when a `shadow-` utility sets one +fn recolored(shadow: &str) -> String { + split_top_level(shadow, ',') + .into_iter() + .map(|layer| { + let layer = layer.trim(); + layer.find("rgb(").map_or_else( + || layer.to_string(), + |color| { + format!( + "{}var(--tw-shadow-color, {})", + &layer[..color], + &layer[color..] + ) + }, + ) + }) + .collect::>() + .join(", ") +} + +/// The declarations of a `shadow` utility: a size, `none`, a theme shadow or +/// a color +fn shadow_utility(name: &str) -> Option> { + let argument = if name == "shadow" { + "sm" + } else { + name.strip_prefix("shadow-") + .filter(|argument| !argument.is_empty())? + }; + let composed = + |shadow: String| vec![decl("--tw-shadow", shadow), decl("box-shadow", BOX_SHADOW)]; + if let Some(shadow) = shadow_size(argument) { + return Some(composed(recolored(shadow))); + } + match argument { + "none" => return Some(composed(String::from("0 0 #0000"))), + "inherit" => return Some(vec![decl("--tw-shadow-color", "inherit")]), + _ => {} + } + if is_shadow_token(argument) { + return Some(composed(format!("var(--{argument})"))); + } + let color = shadow_color(argument)?; + Some(vec![decl( + "--tw-shadow-color", + format!("color-mix(in oklab, {color} var(--tw-shadow-alpha), transparent)"), + )]) +} + +/// A color written in an argument +enum Argument { + /// The argument is no arbitrary value + Named, + /// An arbitrary value that is a color + Color(Cow<'static, str>), + /// An arbitrary value that is not, or a modifier that cannot be applied + Invalid, +} + +/// The color of `shadow-`, a named one or an arbitrary one +fn shadow_color(argument: &str) -> Option> { + match arbitrary_color(argument) { + Argument::Named => color_value(argument), + Argument::Color(color) => Some(color), + Argument::Invalid => None, + } +} + +/// `[value]` or `[value]/50` read as a color +fn arbitrary_color(argument: &str) -> Argument { + let parts = split_top_level(argument, '/'); + let Some(value) = parts[0] + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + else { + return Argument::Named; + }; + let value = decode_arbitrary_value(value); + let single = !value.is_empty() && split_top_level(&value, ' ').len() == 1; + if !single || value_type(None, &value) != Some(ValueType::Color) { + return Argument::Invalid; + } + let modifier = match parts[1..] { + [] => None, + [modifier] => Some(modifier), + _ => return Argument::Invalid, + }; + with_modifier(&value, modifier).map_or(Argument::Invalid, Argument::Color) +} +/// The directions of `bg-linear-*` +fn linear_direction(name: &str) -> Option<&'static str> { + Some(match name { + "to-t" => "to top", + "to-tr" => "to top right", + "to-r" => "to right", + "to-br" => "to bottom right", + "to-b" => "to bottom", + "to-bl" => "to bottom left", + "to-l" => "to left", + "to-tl" => "to top left", + _ => return None, + }) +} + +/// The color space the gradient `modifier` (`/oklch`, `/longer`) interpolates in +fn interpolation(modifier: Option<&str>) -> Option { + let Some(modifier) = modifier else { + return Some(String::from("in oklab")); + }; + if let Some(value) = modifier + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + return Some(decode_arbitrary_value(value)); + } + Some(match modifier { + "longer" | "shorter" | "increasing" | "decreasing" => { + format!("in oklch {modifier} hue") + } + "oklab" | "oklch" | "srgb" | "hsl" => format!("in {modifier}"), + _ => return None, + }) +} + +/// A gradient: its position and the image drawn from the stops +fn gradient(position: String, function: &str, fallback: Option<&str>) -> Vec { + let stops = fallback.map_or_else( + || String::from("var(--tw-gradient-stops)"), + |fallback| format!("var(--tw-gradient-stops,{fallback})"), + ); + vec![ + decl("--tw-gradient-position", position), + decl("background-image", format!("{function}-gradient({stops})")), + ] +} + +/// `bg-linear-*`, `bg-conic-*` and `bg-radial-*` +fn gradient_utility(name: &str, negative: bool) -> Option> { + let rest = name.strip_prefix("bg-")?; + let parts = split_top_level(rest, '/'); + let (&root, modifier) = (parts.first()?, parts.get(1).copied()); + if parts.len() > 2 { + return None; + } + let (kind, argument) = match root.split_once('-') { + Some((kind, argument)) => (kind, Some(argument)), + None => (root, None), + }; + if !matches!(kind, "linear" | "conic" | "radial") { + return None; + } + if let Some(value) = argument.and_then(arbitrary_or_variable) { + if modifier.is_some() || negative { + return None; + } + return Some(gradient(value.clone(), kind, Some(&value))); + } + let method = interpolation(modifier)?; + let angle = |degrees: &str| { + is_positive_integer(degrees).then(|| { + if negative { + format!("calc({degrees}deg * -1)") + } else { + format!("{degrees}deg") + } + }) + }; + let position = match (kind, argument) { + ("linear", Some(argument)) => match linear_direction(argument) { + Some(direction) if !negative => format!("{direction} {method}"), + Some(_) => return None, + None => format!("{} {method}", angle(argument)?), + }, + ("conic", Some(argument)) => format!("from {} {method}", angle(argument)?), + ("conic" | "radial", None) if !negative => method, + _ => return None, + }; + Some(gradient(position, kind, None)) +} + +/// The stops a color stop utility sets +const STOPS: &str = "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))"; + +/// The stops once `via` is set +const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; + +/// A color or a position of a gradient stop: `from-red-500`, `via-10%`, +/// `to-[#fff]` +fn stop_utility(name: &str) -> Option> { + if name == "via-none" { + return Some(vec![decl("--tw-gradient-via-stops", "initial")]); + } + let (stop, rest) = name.split_once('-')?; + if !matches!(stop, "from" | "via" | "to") { + return None; + } + let position = |value: String| { + let property = match stop { + "from" => "--tw-gradient-from-position", + "via" => "--tw-gradient-via-position", + _ => "--tw-gradient-to-position", + }; + Some(vec![decl(property, value)]) + }; + if let Some(percent) = rest.strip_suffix('%') { + return if is_positive_integer(percent) { + position(rest.to_string()) + } else { + None + }; + } + if let Some(value) = rest + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + let value = decode_arbitrary_value(value); + if value_type(None, &value) == Some(ValueType::Length) { + return position(value); + } + } + let color = match arbitrary_color(rest) { + Argument::Named => color_value(rest)?, + Argument::Color(color) => color, + Argument::Invalid => return None, + }; + Some(match stop { + "from" => vec![ + decl("--tw-gradient-from", color), + decl("--tw-gradient-stops", STOPS), + ], + "via" => vec![ + decl("--tw-gradient-via", color), + decl("--tw-gradient-via-stops", VIA_STOPS), + decl("--tw-gradient-stops", "var(--tw-gradient-via-stops)"), + ], + _ => vec![ + decl("--tw-gradient-to", color), + decl("--tw-gradient-stops", STOPS), + ], + }) +} + +/// The declarations of the utilities of this module, `None` for any other +pub fn effect_utility(name: &str, negative: bool) -> Option> { + if let Some(declarations) = filter_utility(name, negative) { + return Some(declarations); + } + if let Some(declarations) = gradient_utility(name, negative) { + return Some(declarations); + } + if negative { + return None; + } + shadow_utility(name).or_else(|| stop_utility(name)) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use css::theme_tokens::set_theme_token_levels; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::tailwind::{TailwindClass, declarations_of, parse_class}; + + fn written(class: &str) -> Vec<(String, String)> { + declarations_of(class).unwrap_or_else(|| panic!("{class} stays as written")) + } + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(written(class), expected, "{class}"); + } + + fn filter(variable: &str, value: &str) -> Vec<(String, String)> { + vec![ + (variable.to_string(), value.to_string()), + ("filter".to_string(), FILTER.to_string()), + ] + } + + fn backdrop(variable: &str, value: &str) -> Vec<(String, String)> { + vec![ + (variable.to_string(), value.to_string()), + ( + "-webkit-backdrop-filter".to_string(), + BACKDROP_FILTER.to_string(), + ), + ("backdrop-filter".to_string(), BACKDROP_FILTER.to_string()), + ] + } + + #[rstest] + #[case("blur", "blur(8px)")] + #[case("blur-xs", "blur(4px)")] + #[case("blur-sm", "blur(8px)")] + #[case("blur-md", "blur(12px)")] + #[case("blur-lg", "blur(16px)")] + #[case("blur-xl", "blur(24px)")] + #[case("blur-2xl", "blur(40px)")] + #[case("blur-3xl", "blur(64px)")] + #[case("blur-none", " ")] + #[case("blur-[2px]", "blur(2px)")] + #[case("blur-(--radius)", "blur(var(--radius))")] + fn blur_composes_through_its_variable(#[case] class: &str, #[case] value: &str) { + assert_eq!(written(class), filter("--tw-blur", value)); + } + + #[rstest] + #[case("brightness-50", "--tw-brightness", "brightness(50%)")] + #[case("brightness-125", "--tw-brightness", "brightness(125%)")] + #[case("brightness-[1.75]", "--tw-brightness", "brightness(1.75)")] + #[case("contrast-200", "--tw-contrast", "contrast(200%)")] + #[case("grayscale", "--tw-grayscale", "grayscale(100%)")] + #[case("grayscale-0", "--tw-grayscale", "grayscale(0%)")] + #[case("grayscale-25", "--tw-grayscale", "grayscale(25%)")] + #[case("hue-rotate-90", "--tw-hue-rotate", "hue-rotate(90deg)")] + #[case("-hue-rotate-90", "--tw-hue-rotate", "hue-rotate(calc(90deg * -1))")] + #[case( + "-hue-rotate-[15deg]", + "--tw-hue-rotate", + "hue-rotate(calc(15deg * -1))" + )] + #[case("invert", "--tw-invert", "invert(100%)")] + #[case("invert-0", "--tw-invert", "invert(0%)")] + #[case("saturate-150", "--tw-saturate", "saturate(150%)")] + #[case("sepia", "--tw-sepia", "sepia(100%)")] + #[case("sepia-50", "--tw-sepia", "sepia(50%)")] + fn filter_functions_set_their_own_variable( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + assert_eq!(written(class), filter(variable, value)); + } + + #[rstest] + #[case("backdrop-blur", "--tw-backdrop-blur", "blur(8px)")] + #[case("backdrop-blur-xl", "--tw-backdrop-blur", "blur(24px)")] + #[case("backdrop-blur-none", "--tw-backdrop-blur", " ")] + #[case("backdrop-blur-[2px]", "--tw-backdrop-blur", "blur(2px)")] + #[case( + "backdrop-brightness-75", + "--tw-backdrop-brightness", + "brightness(75%)" + )] + #[case("backdrop-contrast-50", "--tw-backdrop-contrast", "contrast(50%)")] + #[case("backdrop-grayscale", "--tw-backdrop-grayscale", "grayscale(100%)")] + #[case( + "backdrop-hue-rotate-15", + "--tw-backdrop-hue-rotate", + "hue-rotate(15deg)" + )] + #[case( + "-backdrop-hue-rotate-15", + "--tw-backdrop-hue-rotate", + "hue-rotate(calc(15deg * -1))" + )] + #[case("backdrop-invert-25", "--tw-backdrop-invert", "invert(25%)")] + #[case("backdrop-opacity-50", "--tw-backdrop-opacity", "opacity(50%)")] + #[case("backdrop-opacity-12.5", "--tw-backdrop-opacity", "opacity(12.5%)")] + #[case("backdrop-saturate-200", "--tw-backdrop-saturate", "saturate(200%)")] + #[case("backdrop-sepia", "--tw-backdrop-sepia", "sepia(100%)")] + fn backdrop_filter_functions_compose_too( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + assert_eq!(written(class), backdrop(variable, value)); + } + + #[test] + fn filter_and_backdrop_filter_read_every_function() { + expect("filter", &[("filter", FILTER)]); + expect("filter-none", &[("filter", "none")]); + expect("filter-[url(#a)]", &[("filter", "url(#a)")]); + expect("filter-(--f)", &[("filter", "var(--f)")]); + expect( + "backdrop-filter", + &[ + ("-webkit-backdrop-filter", BACKDROP_FILTER), + ("backdrop-filter", BACKDROP_FILTER), + ], + ); + expect( + "backdrop-filter-none", + &[ + ("-webkit-backdrop-filter", "none"), + ("backdrop-filter", "none"), + ], + ); + expect( + "backdrop-filter-[blur(2px)]", + &[ + ("-webkit-backdrop-filter", "blur(2px)"), + ("backdrop-filter", "blur(2px)"), + ], + ); + assert_eq!(FILTER.matches("var(--tw-").count(), 9); + assert_eq!(BACKDROP_FILTER.matches("var(--tw-backdrop-").count(), 9); + } + + #[test] + fn two_filters_add_up_instead_of_overwriting() { + let blur = written("blur-sm"); + let grayscale = written("grayscale"); + let shared = blur.last().unwrap(); + assert_eq!(shared, grayscale.last().unwrap()); + assert_ne!(blur[0].0, grayscale[0].0); + let rules = [ + parse_class("blur-sm").unwrap(), + parse_class("grayscale").unwrap(), + ] + .iter() + .flat_map(TailwindClass::rules) + .collect::>(); + assert!(rules.contains(&"@property --tw-blur{syntax:\"*\";inherits:false}")); + assert!(rules.contains(&"@property --tw-grayscale{syntax:\"*\";inherits:false}")); + } + + #[rstest] + #[case("blur-huge")] + #[case("blur-")] + #[case("blur-[]")] + #[case("blur-(radius)")] + #[case("-blur-sm")] + #[case("brightness")] + #[case("brightness-")] + #[case("brightness-05")] + #[case("brightness-1.5")] + #[case("brightness-sm")] + #[case("-brightness-50")] + #[case("hue-rotate")] + #[case("opacity-blur")] + #[case("backdrop-opacity")] + #[case("backdrop-opacity-13.3")] + #[case("backdrop-blur-none/50")] + #[case("filter-blur")] + #[case("filter-[]")] + #[case("-filter")] + #[case("-filter-none")] + #[case("backdrop-")] + #[case("blurry")] + #[case("drop-shadow-md")] + fn unknown_filters_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("shadow-2xs", "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05))")] + #[case("shadow-xs", "0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05))")] + #[case( + "shadow", + "0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-sm", + "0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-md", + "0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-lg", + "0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-xl", + "0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-2xl", + "0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25))" + )] + #[case("shadow-none", "0 0 #0000")] + fn shadow_sizes_follow_the_default_theme(#[case] class: &str, #[case] shadow: &str) { + expect( + class, + &[("--tw-shadow", shadow), ("box-shadow", BOX_SHADOW)], + ); + } + + #[test] + #[serial] + fn shadow_tokens_are_the_css_variables_of_the_theme() { + set_theme_token_levels( + BTreeMap::new(), + BTreeMap::from([(String::from("card"), vec![0, 3])]), + ); + expect( + "shadow-card", + &[("--tw-shadow", "var(--card)"), ("box-shadow", BOX_SHADOW)], + ); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + assert_eq!(declarations_of("shadow-card"), None); + } + + #[rstest] + #[case( + "shadow-red-500", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-red-500/50", + "color-mix(in oklab, color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-[#fff]", + "color-mix(in oklab, #fff var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-[#fff]/50", + "color-mix(in oklab, color-mix(in oklab, #fff 50%, transparent) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-current", + "color-mix(in oklab, currentcolor var(--tw-shadow-alpha), transparent)" + )] + fn shadow_colors_set_the_color_the_shadows_read(#[case] class: &str, #[case] color: &str) { + expect(class, &[("--tw-shadow-color", color)]); + } + + #[test] + fn shadow_inherit_and_unknown_shadows() { + expect("shadow-inherit", &[("--tw-shadow-color", "inherit")]); + for class in [ + "shadow-", + "shadow-huge", + "shadow-red-500/13.3", + "shadow-[1px_1px_red]/50", + "shadow-[#fff]/1/2", + ] { + assert_eq!(declarations_of(class), None, "{class}"); + } + } + + #[test] + fn shadow_recoloring_keeps_what_has_no_color() { + assert_eq!(recolored("0 0 #0000"), "0 0 #0000"); + assert_eq!( + recolored("0 1px rgb(0 0 0 / 0.05), 0 0 #0000"), + "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05)), 0 0 #0000" + ); + } + + #[rstest] + #[case("bg-linear-to-t", "to top in oklab")] + #[case("bg-linear-to-tr", "to top right in oklab")] + #[case("bg-linear-to-r", "to right in oklab")] + #[case("bg-linear-to-br", "to bottom right in oklab")] + #[case("bg-linear-to-b", "to bottom in oklab")] + #[case("bg-linear-to-bl", "to bottom left in oklab")] + #[case("bg-linear-to-l", "to left in oklab")] + #[case("bg-linear-to-tl", "to top left in oklab")] + #[case("bg-linear-45", "45deg in oklab")] + #[case("-bg-linear-45", "calc(45deg * -1) in oklab")] + #[case("bg-linear-0", "0deg in oklab")] + #[case("bg-linear-to-r/srgb", "to right in srgb")] + #[case("bg-linear-to-r/hsl", "to right in hsl")] + #[case("bg-linear-to-r/oklch", "to right in oklch")] + #[case("bg-linear-to-r/oklab", "to right in oklab")] + #[case("bg-linear-to-r/longer", "to right in oklch longer hue")] + #[case("bg-linear-to-r/shorter", "to right in oklch shorter hue")] + #[case("bg-linear-to-r/increasing", "to right in oklch increasing hue")] + #[case("bg-linear-to-r/decreasing", "to right in oklch decreasing hue")] + #[case("bg-linear-90/[in_hsl_longer_hue]", "90deg in hsl longer hue")] + fn linear_gradients_use_the_syntax_of_v4(#[case] class: &str, #[case] position: &str) { + expect( + class, + &[ + ("--tw-gradient-position", position), + ( + "background-image", + "linear-gradient(var(--tw-gradient-stops))", + ), + ], + ); + } + + #[test] + fn conic_and_radial_gradients() { + for (class, position, image) in [ + ( + "bg-conic", + "in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic/srgb", + "in srgb", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic-180", + "from 180deg in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "-bg-conic-180", + "from calc(180deg * -1) in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic-90/longer", + "from 90deg in oklch longer hue", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-radial", + "in oklab", + "radial-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-radial/oklch", + "in oklch", + "radial-gradient(var(--tw-gradient-stops))", + ), + ] { + expect( + class, + &[ + ("--tw-gradient-position", position), + ("background-image", image), + ], + ); + } + } + + #[test] + fn arbitrary_gradients_carry_their_own_stops() { + expect( + "bg-linear-[25deg,red_5%,yellow_60%,lime_90%,teal]", + &[ + ( + "--tw-gradient-position", + "25deg,red 5%,yellow 60%,lime 90%,teal", + ), + ( + "background-image", + "linear-gradient(var(--tw-gradient-stops,25deg,red 5%,yellow 60%,lime 90%,teal))", + ), + ], + ); + expect( + "bg-radial-[at_50%_75%,#fff,#000]", + &[ + ("--tw-gradient-position", "at 50% 75%,#fff,#000"), + ( + "background-image", + "radial-gradient(var(--tw-gradient-stops,at 50% 75%,#fff,#000))", + ), + ], + ); + expect( + "bg-conic-(--stops)", + &[ + ("--tw-gradient-position", "var(--stops)"), + ( + "background-image", + "conic-gradient(var(--tw-gradient-stops,var(--stops)))", + ), + ], + ); + } + + #[rstest] + #[case("bg-gradient-to-r")] + #[case("bg-gradient-to-br")] + #[case("bg-linear")] + #[case("bg-linear-")] + #[case("bg-linear-sideways")] + #[case("bg-linear-4.5")] + #[case("bg-linear-045")] + #[case("-bg-linear-to-r")] + #[case("-bg-linear")] + #[case("-bg-radial")] + #[case("-bg-conic")] + #[case("-bg-linear-[10deg,red,blue]")] + #[case("bg-linear-[10deg,red,blue]/oklch")] + #[case("bg-linear-to-r/neon")] + #[case("bg-linear-to-r/oklch/srgb")] + #[case("bg-radial-45")] + #[case("bg-conic-x")] + #[case("bg-sweep-45")] + fn gradients_v4_does_not_define_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + const STOPS: &str = "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))"; + const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; + + #[test] + fn gradient_stops_compose_through_variables() { + expect( + "from-red-500", + &[ + ("--tw-gradient-from", "oklch(63.7% 0.237 25.331)"), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "via-white", + &[ + ("--tw-gradient-via", "#fff"), + ("--tw-gradient-via-stops", VIA_STOPS), + ("--tw-gradient-stops", "var(--tw-gradient-via-stops)"), + ], + ); + expect( + "to-blue-500/50", + &[ + ( + "--tw-gradient-to", + "color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent)", + ), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "from-[#f00]", + &[ + ("--tw-gradient-from", "#f00"), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "from-[#f00]/50", + &[ + ( + "--tw-gradient-from", + "color-mix(in oklab, #f00 50%, transparent)", + ), + ("--tw-gradient-stops", STOPS), + ], + ); + expect("via-none", &[("--tw-gradient-via-stops", "initial")]); + } + + #[test] + fn gradient_stop_positions() { + expect("from-10%", &[("--tw-gradient-from-position", "10%")]); + expect("via-30%", &[("--tw-gradient-via-position", "30%")]); + expect("to-90%", &[("--tw-gradient-to-position", "90%")]); + expect("from-[12px]", &[("--tw-gradient-from-position", "12px")]); + expect("to-[40%]", &[("--tw-gradient-to-position", "40%")]); + } + + #[rstest] + #[case("from-")] + #[case("from-1.5%")] + #[case("from-05%")] + #[case("from-%")] + #[case("from-[12px]/50")] + #[case("from-[url(a.png)]")] + #[case("from-[]")] + #[case("from-nothing")] + #[case("from-red-500/")] + #[case("-from-red-500")] + #[case("via-none/50")] + #[case("up-red-500")] + fn unknown_gradient_stops_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn gradient_rules_register_the_stop_variables() { + let rules = parse_class("from-red-500").unwrap().rules(); + for name in [ + "--tw-gradient-position", + "--tw-gradient-from", + "--tw-gradient-to", + "--tw-gradient-stops", + "--tw-gradient-via-stops", + "--tw-gradient-from-position", + "--tw-gradient-to-position", + ] { + assert!( + rules + .iter() + .any(|rule| rule.starts_with(&format!("@property {name}{{"))), + "{name}" + ); + } + let rules = parse_class("via-30%").unwrap().rules(); + assert_eq!( + rules, + vec![ + "@property --tw-gradient-via-position{syntax:\"\";inherits:false;initial-value:50%}" + ] + ); + } + + #[test] + fn modifiers_and_variants_apply_to_composed_utilities() { + let class = parse_class("hover:blur-sm").unwrap(); + assert_eq!(class.declarations, written_pairs("blur-sm")); + assert_eq!(class.conditions.len(), 1); + assert_eq!(effect_utility("shadow-md", true), None); + assert_eq!(effect_utility("nothing", false), None); + } + + fn written_pairs( + class: &str, + ) -> Vec<( + std::borrow::Cow<'static, str>, + std::borrow::Cow<'static, str>, + )> { + written(class) + .into_iter() + .map(|(property, value)| (property.into(), value.into())) + .collect() + } +} diff --git a/libs/extractor/src/tailwind_motion.rs b/libs/extractor/src/tailwind_motion.rs new file mode 100644 index 000000000..11106c185 --- /dev/null +++ b/libs/extractor/src/tailwind_motion.rs @@ -0,0 +1,338 @@ +//! Transitions and animations the way Tailwind CSS v4 writes them: the +//! duration and easing a `transition` uses are the custom properties +//! `duration-*` and `ease-*` set, and `animate-*` brings the `@keyframes` it +//! names + +use crate::tailwind::{Declaration, arbitrary_or_variable, decl, is_positive_integer}; + +/// The easing a transition has when no `ease-*` sets one +const TIMING: &str = "var(--tw-ease, cubic-bezier(0.4, 0, 0.2, 1))"; + +/// The duration a transition has when no `duration-*` sets one +const DURATION: &str = "var(--tw-duration, 150ms)"; + +/// What `transition` makes transition +const TRANSITION: &str = "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events"; + +/// What `transition-colors` makes transition +const TRANSITION_COLORS: &str = "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to"; + +/// The `@keyframes` of the animations of the default theme +pub static KEYFRAMES: [(&str, &str); 4] = [ + ("spin", "@keyframes spin{to{transform:rotate(360deg)}}"), + ( + "ping", + "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}", + ), + ("pulse", "@keyframes pulse{50%{opacity:0.5}}"), + ( + "bounce", + "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}", + ), +]; + +/// The value of `animate-` for the animations of the default theme +fn animation(name: &str) -> Option<&'static str> { + Some(match name { + "spin" => "spin 1s linear infinite", + "ping" => "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", + "pulse" => "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "bounce" => "bounce 1s infinite", + "none" => "none", + _ => return None, + }) +} + +/// A transition of `property`, with the default easing and duration +fn transition(property: &str) -> Vec { + vec![ + decl("transition-property", property.to_string()), + decl("transition-timing-function", TIMING), + decl("transition-duration", DURATION), + ] +} + +/// The theme easings of `ease-` +fn easing(name: &str) -> Option<&'static str> { + Some(match name { + "in" => "cubic-bezier(0.4, 0, 1, 1)", + "out" => "cubic-bezier(0, 0, 0.2, 1)", + "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", + "linear" => "linear", + _ => return None, + }) +} + +/// The declarations of a `transition`, `duration`, `delay`, `ease` or +/// `animate` utility, `None` for any other +pub fn motion_utility(name: &str) -> Option> { + if name == "transition" { + return Some(transition(TRANSITION)); + } + if let Some(argument) = name.strip_prefix("transition-") { + return Some(match argument { + "none" => vec![decl("transition-property", "none")], + "all" => transition("all"), + "colors" => transition(TRANSITION_COLORS), + "opacity" => transition("opacity"), + "shadow" => transition("box-shadow"), + "transform" => transition("transform, translate, scale, rotate"), + "discrete" => vec![decl("transition-behavior", "allow-discrete")], + "normal" => vec![decl("transition-behavior", "normal")], + _ => transition(&arbitrary_or_variable(argument)?), + }); + } + if let Some(argument) = name.strip_prefix("delay-") { + let value = arbitrary_or_variable(argument).or_else(|| milliseconds(argument))?; + return Some(vec![decl("transition-delay", value)]); + } + if let Some(argument) = name.strip_prefix("duration-") { + if argument == "initial" { + return Some(vec![decl("--tw-duration", "initial")]); + } + let value = arbitrary_or_variable(argument).or_else(|| milliseconds(argument))?; + return Some(vec![ + decl("--tw-duration", value.clone()), + decl("transition-duration", value), + ]); + } + if let Some(argument) = name.strip_prefix("ease-") { + if argument == "initial" { + return Some(vec![decl("--tw-ease", "initial")]); + } + let value = easing(argument) + .map(str::to_string) + .or_else(|| arbitrary_or_variable(argument))?; + return Some(vec![ + decl("--tw-ease", value.clone()), + decl("transition-timing-function", value), + ]); + } + let argument = name.strip_prefix("animate-")?; + let value = animation(argument) + .map(str::to_string) + .or_else(|| arbitrary_or_variable(argument))?; + Some(vec![decl("animation", value)]) +} + +/// `150` as `150ms` +fn milliseconds(value: &str) -> Option { + is_positive_integer(value).then(|| format!("{value}ms")) +} + +/// The `@keyframes` rules the declarations name in an `animation` +pub fn keyframes_of(declarations: &[Declaration]) -> impl Iterator + '_ { + declarations + .iter() + .filter(|(property, _)| property == "animation") + .flat_map(|(_, value)| value.split_whitespace()) + .filter_map(|word| { + KEYFRAMES + .iter() + .find(|(name, _)| *name == word) + .map(|&(_, rule)| rule) + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::tailwind::{declarations_of, parse_class}; + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + fn transition(property: &str) -> Vec<(&str, &str)> { + vec![ + ("transition-property", property), + ("transition-timing-function", TIMING), + ("transition-duration", DURATION), + ] + } + + #[test] + fn transitions_read_the_easing_and_duration_utilities_set() { + expect("transition", &transition(TRANSITION)); + expect("transition-all", &transition("all")); + expect("transition-colors", &transition(TRANSITION_COLORS)); + expect("transition-opacity", &transition("opacity")); + expect("transition-shadow", &transition("box-shadow")); + expect( + "transition-transform", + &transition("transform, translate, scale, rotate"), + ); + expect("transition-none", &[("transition-property", "none")]); + expect("transition-[height]", &transition("height")); + expect("transition-[height,opacity]", &transition("height,opacity")); + expect("transition-(--props)", &transition("var(--props)")); + expect( + "transition-discrete", + &[("transition-behavior", "allow-discrete")], + ); + expect("transition-normal", &[("transition-behavior", "normal")]); + assert_eq!(TIMING, "var(--tw-ease, cubic-bezier(0.4, 0, 0.2, 1))"); + assert_eq!(DURATION, "var(--tw-duration, 150ms)"); + } + + #[test] + fn transitions_cover_what_v4_transitions() { + for property in [ + "outline-color", + "--tw-gradient-from", + "translate", + "scale", + "rotate", + "-webkit-backdrop-filter", + "display", + "content-visibility", + "overlay", + "pointer-events", + ] { + assert!(TRANSITION.contains(property), "{property}"); + } + assert!(TRANSITION_COLORS.contains("outline-color")); + } + + #[rstest] + #[case("duration-0", "0ms")] + #[case("duration-75", "75ms")] + #[case("duration-150", "150ms")] + #[case("duration-250", "250ms")] + #[case("duration-1000", "1000ms")] + #[case("duration-[2s]", "2s")] + #[case("duration-(--speed)", "var(--speed)")] + fn duration_sets_the_variable_a_transition_reads(#[case] class: &str, #[case] value: &str) { + expect( + class, + &[("--tw-duration", value), ("transition-duration", value)], + ); + } + + #[rstest] + #[case("ease-in", "cubic-bezier(0.4, 0, 1, 1)")] + #[case("ease-out", "cubic-bezier(0, 0, 0.2, 1)")] + #[case("ease-in-out", "cubic-bezier(0.4, 0, 0.2, 1)")] + #[case("ease-linear", "linear")] + #[case("ease-[cubic-bezier(0.3,0,0,1)]", "cubic-bezier(0.3,0,0,1)")] + #[case("ease-(--curve)", "var(--curve)")] + fn ease_sets_the_variable_a_transition_reads(#[case] class: &str, #[case] value: &str) { + expect( + class, + &[("--tw-ease", value), ("transition-timing-function", value)], + ); + } + + #[test] + fn initial_and_delay() { + expect("duration-initial", &[("--tw-duration", "initial")]); + expect("ease-initial", &[("--tw-ease", "initial")]); + expect("delay-0", &[("transition-delay", "0ms")]); + expect("delay-150", &[("transition-delay", "150ms")]); + expect("delay-[3s]", &[("transition-delay", "3s")]); + expect("delay-(--wait)", &[("transition-delay", "var(--wait)")]); + } + + #[rstest] + #[case("transition-sideways")] + #[case("transition-[]")] + #[case("transition-")] + #[case("-transition")] + #[case("duration")] + #[case("duration-")] + #[case("duration-1.5")] + #[case("duration-050")] + #[case("duration-fast")] + #[case("-duration-150")] + #[case("delay-1s")] + #[case("delay-")] + #[case("ease")] + #[case("ease-bouncy")] + #[case("animate")] + #[case("animate-wobble")] + #[case("animate-")] + #[case("-animate-spin")] + fn unknown_motion_stays_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("animate-spin", "spin 1s linear infinite", "spin")] + #[case("animate-ping", "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", "ping")] + #[case( + "animate-pulse", + "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "pulse" + )] + #[case("animate-bounce", "bounce 1s infinite", "bounce")] + fn animations_bring_their_keyframes( + #[case] class: &str, + #[case] animation: &str, + #[case] keyframes: &str, + ) { + expect(class, &[("animation", animation)]); + let rules = parse_class(class).unwrap().rules(); + assert_eq!(rules.len(), 1); + assert!( + rules[0].starts_with(&format!("@keyframes {keyframes}{{")), + "{rules:?}" + ); + } + + #[test] + fn keyframes_match_the_default_theme_of_v4() { + let rule = |name: &str| { + KEYFRAMES + .iter() + .find(|(keyframes, _)| *keyframes == name) + .unwrap() + .1 + }; + assert_eq!( + rule("spin"), + "@keyframes spin{to{transform:rotate(360deg)}}" + ); + assert_eq!( + rule("ping"), + "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}" + ); + assert_eq!(rule("pulse"), "@keyframes pulse{50%{opacity:0.5}}"); + assert_eq!( + rule("bounce"), + "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}" + ); + } + + #[test] + fn arbitrary_animations_bring_the_keyframes_they_name() { + expect("animate-none", &[("animation", "none")]); + expect( + "animate-[wiggle_1s_ease-in-out_infinite]", + &[("animation", "wiggle 1s ease-in-out infinite")], + ); + expect("animate-(--spin)", &[("animation", "var(--spin)")]); + assert_eq!( + parse_class("animate-[wiggle_1s_ease-in-out_infinite]") + .unwrap() + .rules(), + Vec::<&str>::new() + ); + assert_eq!( + parse_class("animate-[spin_3s_linear_infinite]") + .unwrap() + .rules(), + vec!["@keyframes spin{to{transform:rotate(360deg)}}"] + ); + assert_eq!( + parse_class("animate-none").unwrap().rules(), + Vec::<&str>::new() + ); + } +} diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 3f3ea2732..4e37fb36b 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -10,7 +10,7 @@ use css::{ style_selector::{ AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, }, - theme_tokens::{set_theme_token_levels, set_typography_keys}, + theme_tokens::{set_color_tokens, set_theme_token_levels, set_typography_keys}, utils::compile_regex, write_merge_selector, }; @@ -481,6 +481,7 @@ impl StyleSheet { theme.get_shadow_token_levels(), ); set_typography_keys(theme.typography.keys().cloned().collect()); + set_color_tokens(theme.get_color_token_names()); self.theme = theme; } diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 2aeb60873..1b620e497 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -2,7 +2,7 @@ use css::optimize_value::optimize_value; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write as _; /// `ColorEntry` stores both the original key (for TypeScript interface) and CSS key (for CSS variables) @@ -818,6 +818,15 @@ impl Theme { declarations } + /// The CSS names of the colors every theme defines + #[must_use] + pub fn get_color_token_names(&self) -> BTreeSet { + self.colors + .values() + .flat_map(|theme| theme.css_entries().map(|(name, _)| name.clone())) + .collect() + } + #[must_use] pub fn get_length_token_levels(&self) -> BTreeMap> { token_levels(&self.length) @@ -1394,6 +1403,19 @@ mod tests { assert!(!color_theme.contains_key("primary.100")); } + #[test] + fn test_color_token_names_cover_every_theme() { + let theme: Theme = serde_json::from_str( + r##"{"colors": {"light": {"primary": "#100"}, "dark": {"primary": "#200", "text.muted": "#300"}}}"##, + ) + .unwrap(); + + assert_eq!( + theme.get_color_token_names(), + BTreeSet::from([String::from("primary"), String::from("text-muted")]) + ); + } + #[test] fn test_deep_nested_color_should_succeed() { // Deep nesting should be flattened with dashes