diff --git a/.changepacks/changepack_log_tailwind_v4_complete.json b/.changepacks/changepack_log_tailwind_v4_complete.json
new file mode 100644
index 000000000..31da09f54
--- /dev/null
+++ b/.changepacks/changepack_log_tailwind_v4_complete.json
@@ -0,0 +1,9 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/rsbuild-plugin/package.json": "Patch",
+ "packages/bun-plugin/package.json": "Patch"
+ },
+ "note": "More of Tailwind CSS v4 compiles: container queries (@sm:, @max-md:, @min-[400px]:, @sm/main:) and @container utilities, mask-* (all but the gradient masks), text-shadow-*, inset-shadow-*, font-variant-numeric utilities (tabular-nums and the like), 3D transforms (rotate-x/y/z, translate-z, scale-z, translate-3d, scale-3d, transform-3d, backface-*, perspective-*, perspective-origin-*), contain-* and outline-hidden (with its forced-colors fallback). The project's Tailwind CSS is read further: functional @utility with --value() and --modifier(), @apply in @utility, and theme(--name) in arbitrary values; classes of @plugin entries stay as written. A className now compiles the classes of cn()/twMerge() calls whose arguments are all static (merged as tailwind-merge does, the later class wins), of consts bound to class strings, and of cva() calls (base, variants, compound variants). The Rsbuild plugin reads the Tailwind CSS again and compiles every module again when it changes, and the Bun plugin reads it again. No new build errors",
+ "date": "2026-10-01T00:00:00.000Z"
+}
\ No newline at end of file
diff --git a/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx b/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx
index 64314212c..aca2ed1bf 100644
--- a/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx
@@ -31,9 +31,12 @@ A class is never rewritten into something different from what Tailwind CSS would
Classes compile in the `className` of Devup UI components, and the build only reads what it can know:
- string literals, template literals, `cond ? 'a' : 'b'`, `a && 'b'`, `a || 'b'` and parentheses,
-- the arguments of `clsx(...)`, `classnames(...)` and `classNames(...)` (strings, arrays, and the keys of objects), and `['a', cond && 'b'].join(' ')`.
+- a `const` of the same file bound to a string (`const base = 'p-4 card'`, read as the classes it holds wherever the `className` reads it; a `let`, a parameter or a template with `${}` is not),
+- the arguments of `clsx(...)`, `classnames(...)` and `classNames(...)` (strings, arrays, and the keys of objects), and `['a', cond && 'b'].join(' ')`, each class compiled where it is written,
+- `cn(...)` and `twMerge(...)` when **every** argument is a string, a template without `${}`, or an array of them: the build merges them as tailwind-merge does, so a Tailwind class that a later one sets again is dropped (`cn('p-4 m-2', 'p-2')` keeps `m-2` and `p-2`; a variant (`hover:p-2`), a breakpoint and `!` each count apart, and `px-4` and `p-2` both stay) and compiles what is left. `cn` is read as `twMerge(clsx(...))`: if your `cn` does something else, give it another name. A `cn()` with a condition or any other argument is left alone, since its result is only known at runtime and the strings in it must reach `twMerge` as written,
+- the strings of `cva(base, { variants, compoundVariants })` in a file that imports Devup UI: the base, every variant option and every compound variant `class`/`className` compile where they are written (`cva` only joins them, so each class stays a class). A file without a Devup UI import is not compiled at all, so put `cva` calls in a file that has one.
-Other helpers (`cn`, `twMerge`, `cva`) may merge or drop classes by rules the build does not run, so their strings are not compiled. A class that is only known at runtime (a variable, a prop, a computed string) cannot be compiled either. In every case the `className` keeps working as before, as plain text.
+A class that is only known at runtime (a prop, a computed string, an imported constant, a `cn()` with a condition) cannot be compiled. In every case the `className` keeps working as before, as plain text.
A plain DOM element (`
`) or a component that is not a Devup UI component is **not** touched: its `className` is passed through as written, so Tailwind (its own build) can style it. Use `` when you want the class compiled.
@@ -72,7 +75,7 @@ The CSS file of your project that imports `tailwindcss` can define more, as it d
@custom-variant theme-midnight (&:where([data-theme='midnight'] *));
```
-Every plugin reads the file when the build starts. The Vite, webpack and Next.js plugins also read it again when it, or a CSS file it imports with a relative path, changes (the Rsbuild and Bun plugins do not watch it, so restart them after a change). So `bg-brand`, `p-gutter`, `3xl:flex`, `animate-wiggle`, `content-auto` and `theme-midnight:bg-black` compile.
+Every plugin reads the file when the build starts, and again when it, or a CSS file it imports with a relative path, changes: Vite, webpack, Next.js and Rsbuild compile every module again, and the Bun plugin (a runtime plugin, with no rebuild of its own) reads the definitions again, so a module compiles with them the next time Bun loads it. So `bg-brand`, `p-gutter`, `3xl:flex`, `animate-wiggle`, `content-auto` and `theme-midnight:bg-black` compile.
The file is found, in this order: the `tailwind.css` option of `devup.json`, else the first of `src/app/globals.css`, `app/globals.css`, `src/globals.css`, `src/index.css`, `src/styles/globals.css`, `src/styles.css`, `src/style.css`, `src/app.css`, `styles/globals.css`, `app/app.css` that imports `tailwindcss`.
@@ -84,9 +87,15 @@ The file is found, in this order: the `tailwind.css` option of `devup.json`, els
Use `"css": false` to read none.
-What is read: `@theme` variables of the color, spacing (`--spacing`, `--spacing-*`), shadow, blur, radius, ease, animate, `default-transition-*` and breakpoint namespaces (and `@keyframes` inside `@theme`), resets such as `--color-*: initial` and `--*: initial`, `@utility` with plain declarations, and `@custom-variant` as a selector list, or a block holding `@slot;` in a selector, `@media` or `@supports`. A `@custom-variant` with the name of a built-in variant (`dark`) replaces it.
+What is read:
-What is not read keeps its classes as written: functional `@utility` (`tab-*` with `--value()`), nested rules and `@apply` in `@utility`, `@plugin`, `@config`, values using `theme()`, and a file whose braces do not balance. If the project changes the values of a namespace the build does not read (`--text-*`, `--font-*`, `--leading-*`, `--tracking-*`, `--container-*`, `--perspective-*`, `--aspect-*`), the classes that read it (`text-lg`, `font-bold`, `max-w-sm` ...) also stay as written, instead of compiling to a default the project replaced.
+- `@theme` variables of the color, spacing (`--spacing`, `--spacing-*`), shadow, inset-shadow, text-shadow, drop-shadow, blur, radius, ease, animate, perspective, container (`@sm:`), `default-transition-*` and breakpoint namespaces (and `@keyframes` inside `@theme`), and resets such as `--color-*: initial` and `--*: initial`.
+- `@utility name { ... }` with declarations and `@apply` lines. `@apply` writes out the utilities it names (`@apply px-4 py-2!`), also through other `@utility` that apply utilities; a utility with a variant (`hover:p-4`), one that styles children (`space-x-4`), an unknown one, or a loop leaves the whole `@utility` as written.
+- Functional `@utility name-* { ... }` whose declarations read `--value(...)` and `--modifier(...)`. An argument is `integer`, `number`, `percentage`, a literal (`'inherit'`), a theme namespace (`--tab-size-*`, `--color-*`, `--spacing-*`) or an arbitrary type (`[length]`, `[color]`, `[number]`, `[url]`); the first argument the value fits is used. A declaration that reads a `--modifier()` that is not given is dropped; a `--value()` no argument fits makes the class stay as written; `--spacing()`, `--alpha()` and the other functions of a utility are not run, so a utility using them stays as written.
+- `@custom-variant` as a selector list, or a block holding `@slot;` in a selector, `@media` or `@supports`. A `@custom-variant` with the name of a built-in variant (`dark`) replaces it.
+- `theme(--name)` in arbitrary values (`p-[theme(--spacing-gutter)]`, `bg-[theme(--color-brand)]`, `w-[calc(theme(--spacing)*2)]`): a theme variable of the project, a color, or a spacing step. A call for a variable the build cannot read (including the v3 form `theme(colors.red.500)`) makes the class stay as written.
+
+What is not read keeps its classes as written: `@plugin` and `@config` (they run JavaScript, whose output the build does not know: the classes a plugin such as `@tailwindcss/typography` makes, like `prose`, stay in the `className` for Tailwind's own build), nested rules in `@utility`, `theme()` in CSS (outside arbitrary values), `--theme()`, and a file whose braces do not balance. If the project changes the values of a namespace the build does not read (`--text-*`, `--font-*`, `--leading-*`, `--tracking-*`, `--container-*`, `--perspective-*`, `--aspect-*`), the classes that read it (`text-lg`, `font-bold`, `max-w-sm` ...) also stay as written, instead of compiling to a default the project replaced.
## Classes and style props on one element
@@ -98,4 +107,4 @@ Tailwind classes and Devup UI props both become one atomic class per property in
## What stays as written
-Utilities this build does not compile stay in the `className`: container (`container`, `@container` queries), the masks (`mask-*`), `text-shadow-*`, `inset-shadow-*`, composed font-variant-numeric (`tabular-nums`), 3D transform steps (`rotate-x-*`, `translate-z-*`, `scale-z-*`), `contain-*`, `outline-hidden`, and the variants and values that are not listed in Tailwind CSS v4.
+Utilities this build does not compile stay in the `className`: `container` (it sets a width per breakpoint, which one class cannot hold), the gradient masks (`mask-linear-*`, `mask-radial-*`, `mask-conic-*`, `mask-t|r|b|l|x|y-from|to-*`), and the variants and values that are not listed in Tailwind CSS v4. Container queries (`@sm:`, `@max-md:`, `@min-[400px]:`, `@sm/main:`) and the `@container` utilities, the other masks (`mask-none`, `mask-[...]`, sizes, positions, repeats, clips, origins, composites, modes), `text-shadow-*`, `inset-shadow-*`, `font-variant-numeric` (`tabular-nums` and the like, composing), 3D transforms (`rotate-x|y|z-*`, `translate-z-*`, `scale-z-*`, `translate-3d`, `scale-3d`, `transform-3d`, `backface-*`, `perspective-*`), `contain-*` and `outline-hidden` compile as Tailwind CSS v4 writes them.
diff --git a/libs/css/src/tailwind_definitions.rs b/libs/css/src/tailwind_definitions.rs
index d0d366833..b52e26726 100644
--- a/libs/css/src/tailwind_definitions.rs
+++ b/libs/css/src/tailwind_definitions.rs
@@ -17,6 +17,15 @@ pub struct CustomVariant {
pub at_rule: Option<(AtRuleKind, String)>,
}
+/// One line of the body of an `@utility`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum UtilityItem {
+ /// `property: value`
+ Declaration(String, String),
+ /// `@apply a b c`: the utilities that are applied
+ Apply(Vec),
+}
+
#[derive(Debug, Default)]
struct Definitions {
theme: BTreeMap,
@@ -24,7 +33,10 @@ struct Definitions {
resets: BTreeSet,
/// `--*: initial` empties every namespace
reset_all: bool,
- utilities: BTreeMap>,
+ utilities: BTreeMap>,
+ /// `@utility name-*`, by `name`: declarations that read `--value()` and
+ /// `--modifier()`
+ functional: BTreeMap>,
variants: BTreeMap,
keyframes: BTreeMap,
}
@@ -137,6 +149,25 @@ fn compact(value: &str) -> String {
out
}
+/// The declarations and `@apply` lines of `body`, `None` when it holds anything
+/// else
+fn utility_items(body: &str) -> Option> {
+ parse_items(body)?
+ .into_iter()
+ .map(|item| match item {
+ Item::Statement(statement) => match statement.strip_prefix("@apply") {
+ Some(tokens) if tokens.starts_with(char::is_whitespace) => Some(
+ UtilityItem::Apply(tokens.split_whitespace().map(str::to_string).collect()),
+ ),
+ Some(_) => None,
+ None => declaration(&statement)
+ .map(|(property, value)| UtilityItem::Declaration(property, value)),
+ },
+ Item::Block(..) => None,
+ })
+ .collect()
+}
+
/// `name: value` as the pair it declares
fn declaration(statement: &str) -> Option<(String, String)> {
let (name, value) = statement.split_once(':')?;
@@ -201,14 +232,24 @@ impl Definitions {
}
fn read_utility(&mut self, name: &str, body: &str) {
- if name.is_empty() || name.contains('*') || name.contains(char::is_whitespace) {
+ if name.is_empty() || name.contains(char::is_whitespace) {
return;
}
- if let Some(declarations) = declarations(body) {
- self.utilities.insert(name.to_string(), declarations);
+ if let Some(root) = name.strip_suffix("-*") {
+ if let Some(declarations) =
+ declarations(body).filter(|_| !root.is_empty() && !root.contains('*'))
+ {
+ self.functional.insert(root.to_string(), declarations);
+ }
+ return;
+ }
+ if name.contains('*') {
+ return;
+ }
+ if let Some(items) = utility_items(body) {
+ self.utilities.insert(name.to_string(), items);
}
}
-
fn read_custom_variant(&mut self, rest: &str, body: Option<&str>) {
let rest = rest.trim();
let (name, definition) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
@@ -388,12 +429,18 @@ pub fn theme_keys(namespace: &str) -> Vec {
.unwrap_or_default()
}
-/// The declarations of the `@utility` named `name`
+/// The body of the `@utility` named `name`
#[must_use]
-pub fn custom_utility(name: &str) -> Option> {
+pub fn custom_utility(name: &str) -> Option> {
with(|definitions| definitions.utilities.get(name).cloned())
}
+/// The declarations of the functional `@utility` named `-*`
+#[must_use]
+pub fn functional_utility(root: &str) -> Option> {
+ with(|definitions| definitions.functional.get(root).cloned())
+}
+
/// The `@custom-variant` named `name`
#[must_use]
pub fn custom_variant(name: &str) -> Option {
@@ -475,19 +522,41 @@ mod tests {
#[serial]
fn utilities_are_static_declarations() {
set_tailwind_css(
- "@layer base { a { color: red } } @utility content-auto { content-visibility: auto; contain-intrinsic-size: 0 500px } @utility tab-* { tab-size: --value(integer); } @utility nested { &:hover { color: red } } @utility empty { } @utility two words { a: b }",
+ "@layer base { a { color: red } } @utility content-auto { content-visibility: auto; contain-intrinsic-size: 0 500px } @utility tab-* { tab-size: --value(integer); } @utility nested { &:hover { color: red } } @utility empty { } @utility two words { a: b } @utility btn { @apply px-4 py-2; color: red; } @utility bad-apply { @applyx a; } @utility any-* x { a: b } @utility -* { a: b } @utility a*b { a: b } @utility fn-* { a: b; &:hover { c: d } }",
);
assert_eq!(
custom_utility("content-auto"),
Some(vec![
- ("content-visibility".to_string(), "auto".to_string()),
- ("contain-intrinsic-size".to_string(), "0 500px".to_string()),
+ UtilityItem::Declaration("content-visibility".to_string(), "auto".to_string()),
+ UtilityItem::Declaration(
+ "contain-intrinsic-size".to_string(),
+ "0 500px".to_string()
+ ),
])
);
assert_eq!(custom_utility("tab-*"), None);
+ assert_eq!(
+ functional_utility("tab"),
+ Some(vec![(
+ "tab-size".to_string(),
+ "--value(integer)".to_string()
+ )])
+ );
assert_eq!(custom_utility("nested"), None);
assert_eq!(custom_utility("empty"), Some(vec![]));
assert_eq!(custom_utility("two"), None);
+ assert_eq!(
+ custom_utility("btn"),
+ Some(vec![
+ UtilityItem::Apply(vec!["px-4".to_string(), "py-2".to_string()]),
+ UtilityItem::Declaration("color".to_string(), "red".to_string()),
+ ])
+ );
+ assert_eq!(custom_utility("bad-apply"), None);
+ assert_eq!(functional_utility("any"), None);
+ assert_eq!(functional_utility("fn"), None);
+ assert_eq!(functional_utility(""), None);
+ assert_eq!(custom_utility("a*b"), None);
set_tailwind_css("");
}
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index a5d5afa82..c148f75bb 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -21,6 +21,7 @@ mod stylex;
mod tailwind;
mod tailwind_children;
mod tailwind_color;
+mod tailwind_custom;
mod tailwind_effects;
mod tailwind_misc;
mod tailwind_motion;
@@ -14799,6 +14800,45 @@ const Button = styled.button({ bg: 'red' })
assert_eq!(output.code, expected);
}
+ #[rstest]
+ #[case(
+ "const button = cva('p-4 card', { variants: { size: { sm: 'text-sm', lg: ['text-lg', 'p-8'], none: null } }, compoundVariants: [{ size: 'sm', class: 'm-2' }, { size: 'lg', className: ['m-4'] }], defaultVariants: { size: 'sm' } });\n",
+ "import \"@devup-ui/core/devup-ui.css\";\nconst button = cva(\"a card\", {\n\tvariants: { size: {\n\t\tsm: \"b c\",\n\t\tlg: [\"d e\", \"f\"],\n\t\tnone: null\n\t} },\n\tcompoundVariants: [{\n\t\tsize: \"sm\",\n\t\tclass: \"g\"\n\t}, {\n\t\tsize: \"lg\",\n\t\tclassName: [\"h\"]\n\t}],\n\tdefaultVariants: { size: \"sm\" }\n});\n;\n"
+ )]
+ #[case(
+ "const button = cva();\n",
+ "const button = cva();\n;\n"
+ )]
+ #[case(
+ "const button = cva(base, { variants: 1, compoundVariants: 2 });\n",
+ "const button = cva(base, {\n\tvariants: 1,\n\tcompoundVariants: 2\n});\n;\n"
+ )]
+ #[case(
+ "const button = cva(['p-4'], { variants: { size: 1, tone: { a: 'm-2' } }, compoundVariants: [1, { tone: 'a', other: 'm-1' }, { ...x }] });\n",
+ "import \"@devup-ui/core/devup-ui.css\";\nconst button = cva([\"a\"], {\n\tvariants: {\n\t\tsize: 1,\n\t\ttone: { a: \"b\" }\n\t},\n\tcompoundVariants: [\n\t\t1,\n\t\t{\n\t\t\ttone: \"a\",\n\t\t\tother: \"m-1\"\n\t\t},\n\t\t{ ...x }\n\t]\n});\n;\n"
+ )]
+ #[case(
+ "const button = cva(base, { ...shared, variants: { ...more, tone: { ...options, a: 'm-2' }, size: { sm: 'p-1' }, odd: 1 }, compoundVariants: [...list, { class: 'm-1' }] });\n",
+ "import \"@devup-ui/core/devup-ui.css\";\nconst button = cva(base, {\n\t...shared,\n\tvariants: {\n\t\t...more,\n\t\ttone: {\n\t\t\t...options,\n\t\t\ta: \"a\"\n\t\t},\n\t\tsize: { sm: \"b\" },\n\t\todd: 1\n\t},\n\tcompoundVariants: [...list, { class: \"c\" }]\n});\n;\n"
+ )]
+ #[serial]
+ fn test_tailwind_cva(#[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();
+ assert_eq!(output.code, expected);
+ }
#[rstest]
#[case(
"",
@@ -14825,8 +14865,60 @@ const Button = styled.button({ bg: 'red' })
""
)]
#[case(
- "",
- r#"-"#
+ "const base = 'p-4 card';\n",
+ r#"const base = "p-4 card";
+"#
+ )]
+ #[case(
+ "const base = `p-4`;\nconst on = false;\n",
+ r#"const base = `p-4`;
+const on = false;
+"#
+ )]
+ #[case(
+ "let base = 'p-4';\n",
+ r#"-let base = "p-4";
+"#
+ )]
+ #[case(
+ "const base = 'p-4'\nconst f = (base) => ",
+ "-const base = \"p-4\";\nconst f = (base) => "
+ )]
+ #[case(
+ "const base = `p-4 ${x}`;\n",
+ r#"-const base = `p-4 ${x}`;
+"#
+ )]
+ #[case("", r#""#)]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case("", r#""#)]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "",
+ r#""#
+ )]
+ #[case(
+ "",
+ r#"-"#
+ )]
+ #[case(
+ "const base = 'p-4';\n",
+ r#"const base = "p-4";
+"#
)]
#[case(
"",
diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs
index c43e1e818..4b2a8c588 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_FILE, parse_class};
+use crate::tailwind::{PROPERTY_RULES_FILE, merge_classes, parse_class};
use crate::utils::{get_str_by_property_key, merge_object_expressions};
use crate::{ExtractStyleProp, ExtractStyleValue};
use oxc_allocator::{CloneIn, FromIn, GetAllocator};
@@ -361,6 +361,87 @@ pub fn get_class_name_expression<'a>(
(expression, tailwind.into_styles())
}
+/// Compiles the Tailwind classes of a `cva(base, { variants, compoundVariants })`
+/// call, which joins the classes it is given as `clsx` does: the base, the
+/// classes of each variant option and of each compound variant. Returns the
+/// styles of the classes that compiled.
+pub fn compile_cva<'a>(
+ ast_builder: &AstBuilder<'a>,
+ call: &mut CallExpression<'a>,
+ filename: Option<&str>,
+) -> Vec {
+ let mut tailwind = TailwindClassName {
+ style_order: None,
+ filename,
+ styles: Vec::new(),
+ rules: BTreeSet::new(),
+ };
+ let mut compile = |expression: &mut Expression<'a>| {
+ if let Some(compiled) = tailwind.compile_expression(ast_builder, expression) {
+ *expression = compiled;
+ }
+ };
+ if let Some(base) = call
+ .arguments
+ .get_mut(0)
+ .and_then(Argument::as_expression_mut)
+ {
+ compile(base);
+ }
+ if let Some(Expression::ObjectExpression(config)) = call
+ .arguments
+ .get_mut(1)
+ .and_then(Argument::as_expression_mut)
+ {
+ for property in &mut config.properties {
+ let ObjectPropertyKind::ObjectProperty(property) = property else {
+ continue;
+ };
+ match (
+ get_str_by_property_key(&property.key).as_deref(),
+ &mut property.value,
+ ) {
+ (Some("variants"), Expression::ObjectExpression(groups)) => {
+ for group in &mut groups.properties {
+ let ObjectPropertyKind::ObjectProperty(group) = group else {
+ continue;
+ };
+ let Expression::ObjectExpression(options) = &mut group.value else {
+ continue;
+ };
+ for option in &mut options.properties {
+ if let ObjectPropertyKind::ObjectProperty(option) = option {
+ compile(&mut option.value);
+ }
+ }
+ }
+ }
+ (Some("compoundVariants"), Expression::ArrayExpression(variants)) => {
+ for variant in &mut variants.elements {
+ let Some(Expression::ObjectExpression(variant)) =
+ variant.as_expression_mut()
+ else {
+ continue;
+ };
+ for property in &mut variant.properties {
+ if let ObjectPropertyKind::ObjectProperty(property) = property
+ && matches!(
+ get_str_by_property_key(&property.key).as_deref(),
+ Some("class" | "className")
+ )
+ {
+ compile(&mut property.value);
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ tailwind.into_styles()
+}
+
/// Compiles the Tailwind classes of a className: each becomes the classes of
/// its styles, and every other class stays as written
struct TailwindClassName<'f> {
@@ -531,6 +612,20 @@ impl TailwindClassName<'_> {
let mut compiled = call.clone_in(ast_builder.allocator());
let mut changed = false;
match &call.callee {
+ Expression::Identifier(callee) if matches!(callee.name.as_str(), "cn" | "twMerge") => {
+ let mut classes = String::new();
+ for argument in &call.arguments {
+ classes.push_str(&static_classes(argument.as_expression()?)?);
+ classes.push(' ');
+ }
+ let compiled = self.compile_text(&merge_classes(&classes), false, false)?;
+ return Some(Expression::new_string_literal(
+ SPAN,
+ Str::from_in(&compiled, ast_builder.allocator()),
+ None,
+ ast_builder,
+ ));
+ }
Expression::Identifier(callee)
if matches!(callee.name.as_str(), "clsx" | "classnames" | "classNames") =>
{
@@ -613,6 +708,33 @@ impl TailwindClassName<'_> {
}
}
+/// The classes a static argument of `cn()` holds: strings, templates without
+/// interpolation and arrays of them
+fn static_classes(expression: &Expression<'_>) -> Option {
+ match expression {
+ Expression::StringLiteral(literal) => Some(literal.value.to_string()),
+ Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
+ .quasis
+ .first()?
+ .value
+ .cooked
+ .as_ref()
+ .map(ToString::to_string),
+ Expression::ParenthesizedExpression(parenthesized) => {
+ static_classes(&parenthesized.expression)
+ }
+ Expression::ArrayExpression(array) => {
+ let mut classes = String::new();
+ for element in &array.elements {
+ classes.push_str(&static_classes(element.as_expression()?)?);
+ classes.push(' ');
+ }
+ Some(classes)
+ }
+ _ => None,
+ }
+}
+
/// `compiled`, or a copy of `original` when nothing in it compiled
fn compiled_or<'a>(
ast_builder: &AstBuilder<'a>,
diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs
index 95156559d..eebc48a63 100644
--- a/libs/extractor/src/style_values.rs
+++ b/libs/extractor/src/style_values.rs
@@ -24,6 +24,9 @@ pub enum StyleValue {
pub struct StyleValues {
scoping: Option,
values: FxHashMap,
+ /// The `const`s the file binds to a string, which a `className` reads as
+ /// the classes they hold
+ texts: FxHashMap,
}
impl StyleValues {
@@ -31,6 +34,7 @@ impl StyleValues {
Self {
scoping: Some(scoping),
values: FxHashMap::default(),
+ texts: FxHashMap::default(),
}
}
@@ -48,6 +52,22 @@ impl StyleValues {
self.values.insert(symbol, value);
}
+ pub fn insert_text(&mut self, symbol: SymbolId, text: String) {
+ self.texts.insert(symbol, text);
+ }
+
+ /// `expression`, a `className`, reading the strings the constants hold
+ pub fn read_in_class<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) {
+ if let Some(scoping) = self.scoping.as_ref().filter(|_| !self.texts.is_empty()) {
+ ClassReads {
+ ast,
+ scoping,
+ texts: &self.texts,
+ }
+ .visit_expression(expression);
+ }
+ }
+
/// `expression` reading what the bindings recorded hold
pub fn read_in<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) {
if let Some(mut reads) = self.reads(ast, false) {
@@ -182,3 +202,30 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> {
(self.in_rules, self.in_text) = outer;
}
}
+
+struct ClassReads<'s, 'a> {
+ ast: &'s AstBuilder<'a>,
+ scoping: &'s Scoping,
+ texts: &'s FxHashMap,
+}
+
+impl<'a> VisitMut<'a> for ClassReads<'_, 'a> {
+ fn visit_expression(&mut self, it: &mut Expression<'a>) {
+ if let Expression::Identifier(identifier) = it
+ && let Some(text) = identifier
+ .reference_id
+ .get()
+ .and_then(|reference| self.scoping.get_reference(reference).symbol_id())
+ .and_then(|symbol| self.texts.get(&symbol))
+ {
+ *it = Expression::new_string_literal(
+ SPAN,
+ Str::from_in(text.as_str(), self.ast.allocator()),
+ None,
+ self.ast,
+ );
+ return;
+ }
+ walk_mut::walk_expression(self, it);
+ }
+}
diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs
index a32e13dde..2a6722fd7 100644
--- a/libs/extractor/src/tailwind.rs
+++ b/libs/extractor/src/tailwind.rs
@@ -10,8 +10,9 @@
#![allow(clippy::collapsible_if)]
use std::borrow::Cow;
+use std::collections::BTreeSet;
-use css::tailwind_definitions::{custom_utility, custom_variant, theme_value, theme_variable};
+use css::tailwind_definitions::{custom_variant, theme_value, theme_variable};
use css::{
at_rule::split_at_rule_key,
style_selector::{AtRuleKind, StyleSelector},
@@ -21,8 +22,9 @@ 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_custom::{custom_declarations, functional_declarations};
use crate::tailwind_effects::effect_utility;
-use crate::tailwind_misc::misc_utility;
+use crate::tailwind_misc::{misc_utility, nested_utility};
use crate::tailwind_motion::{keyframes_of, motion_utility};
use crate::tailwind_theme::{reads_unsupported_theme, scaled, themed};
@@ -174,6 +176,16 @@ macro_rules! property {
static PROPERTIES: &[(&str, &str)] = &[
property!("--tw-translate-x", "*", "0"),
property!("--tw-translate-y", "*", "0"),
+ property!("--tw-translate-z", "*", "0"),
+ property!("--tw-ordinal"),
+ property!("--tw-slashed-zero"),
+ property!("--tw-numeric-figure"),
+ property!("--tw-numeric-spacing"),
+ property!("--tw-numeric-fraction"),
+ property!("--tw-contain-size"),
+ property!("--tw-contain-layout"),
+ property!("--tw-contain-paint"),
+ property!("--tw-contain-style"),
property!("--tw-scale-x", "*", "1"),
property!("--tw-scale-y", "*", "1"),
property!("--tw-scale-z", "*", "1"),
@@ -225,6 +237,8 @@ static PROPERTIES: &[(&str, &str)] = &[
property!("--tw-shadow", "*", "0 0 #0000"),
property!("--tw-shadow-color"),
property!("--tw-shadow-alpha", "", "100%"),
+ property!("--tw-text-shadow-color"),
+ property!("--tw-text-shadow-alpha", "", "100%"),
property!("--tw-drop-shadow-color"),
property!("--tw-drop-shadow-alpha", "", "100%"),
property!("--tw-drop-shadow-size"),
@@ -300,18 +314,37 @@ pub struct TailwindClass {
pub conditions: Vec