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/bun.lock b/bun.lock
index a6f7b10d1..906ce66d6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -489,11 +489,11 @@
},
"bindings/devup-ui-wasm": {
"name": "@devup-ui/wasm",
- "version": "1.0.82",
+ "version": "1.0.83",
},
"packages/bun-plugin": {
"name": "@devup-ui/bun-plugin",
- "version": "1.0.21",
+ "version": "1.0.22",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -508,7 +508,7 @@
},
"packages/components": {
"name": "@devup-ui/components",
- "version": "0.1.59",
+ "version": "0.1.60",
"dependencies": {
"@devup-ui/react": "workspace:^",
"clsx": "^2.1",
@@ -535,7 +535,7 @@
},
"packages/eslint-plugin": {
"name": "@devup-ui/eslint-plugin",
- "version": "1.0.21",
+ "version": "1.0.22",
"dependencies": {
"@typescript-eslint/utils": "^8.70",
"typescript-eslint": "^8.70",
@@ -552,7 +552,7 @@
},
"packages/next-plugin": {
"name": "@devup-ui/next-plugin",
- "version": "1.0.89",
+ "version": "1.0.90",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -570,7 +570,7 @@
},
"packages/plugin-utils": {
"name": "@devup-ui/plugin-utils",
- "version": "1.0.16",
+ "version": "1.0.17",
"devDependencies": {
"typescript": "^7.0.2",
},
@@ -611,7 +611,7 @@
},
"packages/rsbuild-plugin": {
"name": "@devup-ui/rsbuild-plugin",
- "version": "1.0.66",
+ "version": "1.0.67",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -627,7 +627,7 @@
},
"packages/vite-plugin": {
"name": "@devup-ui/vite-plugin",
- "version": "1.0.72",
+ "version": "1.0.73",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
@@ -642,7 +642,7 @@
},
"packages/webpack-plugin": {
"name": "@devup-ui/webpack-plugin",
- "version": "1.0.70",
+ "version": "1.0.71",
"dependencies": {
"@devup-ui/plugin-utils": "workspace:^",
"@devup-ui/wasm": "workspace:^",
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index 96c6ca430..a5853d5b5 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