From 835bcf7b26138d2a6cd59bb02688673eda3abbf3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 23:49:41 +0900 Subject: [PATCH 1/2] feat(extractor): compile containers, masks, 3D transforms, functional @utility and class helpers Refs #687 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_tailwind_v4_complete.json | 9 + .../(detail)/docs/migration/tailwind/page.mdx | 21 +- libs/css/src/tailwind_definitions.rs | 89 ++- libs/extractor/src/lib.rs | 95 ++- libs/extractor/src/prop_modify_utils.rs | 124 +++- libs/extractor/src/style_values.rs | 47 ++ libs/extractor/src/tailwind.rs | 318 ++++++++- libs/extractor/src/tailwind_custom.rs | 320 +++++++++ libs/extractor/src/tailwind_effects.rs | 198 +++++- libs/extractor/src/tailwind_misc.rs | 642 +++++++++++++++++- libs/extractor/src/tailwind_theme.rs | 84 ++- libs/extractor/src/visit.rs | 81 ++- .../src/__tests__/tailwind-watch.test.ts | 65 ++ packages/bun-plugin/src/plugin.ts | 11 +- packages/bun-plugin/src/tailwind-watch.ts | 28 + .../src/__tests__/checkout-isolation.test.ts | 1 + .../src/__tests__/plugin.test.ts | 51 ++ packages/rsbuild-plugin/src/plugin.ts | 53 +- 18 files changed, 2142 insertions(+), 95 deletions(-) create mode 100644 .changepacks/changepack_log_tailwind_v4_complete.json create mode 100644 libs/extractor/src/tailwind_custom.rs create mode 100644 packages/bun-plugin/src/__tests__/tailwind-watch.test.ts create mode 100644 packages/bun-plugin/src/tailwind-watch.ts 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 4e764871b..9cdd3f7fb 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -17,6 +17,7 @@ mod stylex; mod tailwind; mod tailwind_children; mod tailwind_color; +mod tailwind_custom; mod tailwind_effects; mod tailwind_misc; mod tailwind_motion; @@ -14645,6 +14646,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( "", @@ -14671,8 +14711,59 @@ 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( + "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 77dc8f569..bcea089b4 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) { @@ -123,3 +143,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..27491cf48 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>, /// The declarations, in the order Tailwind writes them pub declarations: Vec, + /// Declarations that go in an at-rule of their own inside the rule, with + /// the conditions of each + pub nested: Vec<(Vec>, Vec)>, } 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()) + let groups = std::iter::once((&self.conditions, &self.declarations)).chain( + self.nested + .iter() + .map(|(conditions, declarations)| (conditions, declarations)), + ); + groups.flat_map(move |(conditions, declarations)| { + conditions.iter().flat_map(move |condition| { + declarations.iter().map(move |(property, value)| { + ExtractStaticStyle::new(property, value, self.level, condition.clone()) + }) }) }) } + /// Every declaration the class has + fn all_declarations(&self) -> impl Iterator { + self.declarations.iter().chain( + self.nested + .iter() + .flat_map(|(_, declarations)| declarations), + ) + } + /// The global rules the declarations need: the @property rules of the /// custom properties they set or read, and the @keyframes they name #[must_use] @@ -319,17 +352,70 @@ impl TailwindClass { let mut rules: Vec> = PROPERTIES .iter() .filter(|(name, _)| { - self.declarations - .iter() + self.all_declarations() .any(|(property, value)| property == name || reads(value, name)) }) .map(|&(_, rule)| Cow::Borrowed(rule)) .collect(); - rules.extend(keyframes_of(&self.declarations)); + let all: Vec = self.all_declarations().cloned().collect(); + rules.extend(keyframes_of(&all)); rules } } +/// The widths of the containers of the default theme +fn container_width(key: &str) -> Option<&'static str> { + Some(match key { + "3xs" => "16rem", + "2xs" => "18rem", + "xs" => "20rem", + "sm" => "24rem", + "md" => "28rem", + "lg" => "32rem", + "xl" => "36rem", + "2xl" => "42rem", + "3xl" => "48rem", + "4xl" => "56rem", + "5xl" => "64rem", + "6xl" => "72rem", + "7xl" => "80rem", + _ => return None, + }) +} + +/// `@md:`, `@max-md:`, `@min-[400px]:` and `@md/main:`, which style an element +/// by the width of its container, or of the container named `main` +fn container_variant(name: &str) -> Option { + let rest = name.strip_prefix('@')?; + let parts = split_top_level(rest, '/'); + let (query, container) = match parts.as_slice() { + [query] => (*query, None), + [query, container] if !container.is_empty() => (*query, Some(*container)), + _ => return None, + }; + let (comparison, key) = if let Some(key) = query.strip_prefix("max-") { + ("<", key) + } else { + (">=", query.strip_prefix("min-").unwrap_or(query)) + }; + let width = if let Some(value) = bracketed(key) { + decode_arbitrary_value(value) + } else { + themed("container", key, container_width)?.into_owned() + }; + if width.is_empty() || width.contains("var(") { + return None; + } + let condition = format!("(width {comparison} {width})"); + Some(Variant::at_rule( + AtRuleKind::Container, + container.map_or_else( + || condition.clone(), + |container| format!("{container} {condition}"), + ), + )) +} + /// The variant a prefix such as `hover` or `md` stands for fn variant(name: &str) -> Option { if let Some(custom) = custom_variant(name) { @@ -341,6 +427,9 @@ fn variant(name: &str) -> Option { ..Variant::default() }); } + if let Some(variant) = container_variant(name) { + return Some(variant); + } if let Some(&level) = RESPONSIVE_PREFIX_MAP.get(name) { return Some(Variant { level, @@ -693,6 +782,55 @@ pub(crate) fn declarations_of(class: &str) -> Option> { }) } +/// What a compiled class sets: each property under each condition, and +/// whether it is important +fn class_keys(class: &TailwindClass) -> BTreeSet { + let groups = std::iter::once((&class.conditions, &class.declarations)).chain( + class + .nested + .iter() + .map(|(conditions, declarations)| (conditions, declarations)), + ); + let mut keys = BTreeSet::new(); + for (conditions, declarations) in groups { + for condition in conditions { + for (property, value) in declarations { + keys.insert(format!( + "{condition:?}|{}|{property}|{}", + class.level, + value.ends_with("!important") + )); + } + } + } + keys +} + +/// `classes` without the Tailwind classes a later one sets all of again, which +/// is what tailwind-merge does: the later class wins +#[must_use] +pub fn merge_classes(classes: &str) -> String { + let tokens: Vec<&str> = classes.split_ascii_whitespace().collect(); + let keys: Vec>> = tokens + .iter() + .map(|token| parse_class(token).map(|class| class_keys(&class))) + .collect(); + tokens + .iter() + .enumerate() + .filter(|(index, _)| { + keys[*index].as_ref().is_none_or(|earlier| { + !keys[index + 1..] + .iter() + .flatten() + .any(|later| earlier.is_subset(later)) + }) + }) + .map(|(_, token)| *token) + .collect::>() + .join(" ") +} + /// 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] @@ -702,9 +840,21 @@ pub fn parse_class(class: &str) -> Option { let Utility { mut declarations, selector: utility_selector, + mut nested, } = utility(name)?; + if declarations + .iter() + .chain(nested.iter().flat_map(|nested| nested.declarations.iter())) + .any(|(_, value)| value.contains("theme(")) + { + return None; + } if important { - for (_, value) in &mut declarations { + for (_, value) in declarations.iter_mut().chain( + nested + .iter_mut() + .flat_map(|nested| nested.declarations.iter_mut()), + ) { *value = Cow::Owned(format!("{value} !important")); } } @@ -746,25 +896,44 @@ pub fn parse_class(class: &str) -> Option { (Cow::Borrowed("content"), Cow::Borrowed("var(--tw-content)")), ); } - let conditions = selectors + let conditions_under = |extra: Option<(AtRuleKind, &str)>| { + selectors + .iter() + .map(|selector| { + let mut condition = + (selector != "&").then(|| StyleSelector::Selector(selector.clone())); + let nested = extra.map(|(kind, query)| (kind, Cow::Borrowed(query))); + for (kind, query) in at_rules + .iter() + .map(|(kind, query)| (*kind, query.clone())) + .chain(nested) + { + // `None` when the media queries can never all match + condition = Some(StyleSelector::nest_at_rule( + condition.as_ref(), + kind, + &query, + )?); + } + Some(condition) + }) + .collect::>>() + }; + let conditions = conditions_under(None)?; + let nested = nested .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) + .map(|nested| { + Some(( + conditions_under(Some((nested.kind, nested.query)))?, + nested.declarations, + )) }) .collect::>>()?; Some(TailwindClass { level, conditions, declarations, + nested, }) } @@ -806,6 +975,38 @@ struct Utility { declarations: Vec, /// Where the declarations apply, `&` being the element selector: &'static str, + /// Declarations in an at-rule inside the rule + nested: Vec, +} + +/// Declarations that go in an at-rule of their own inside the rule of a utility +pub(crate) struct Nested { + pub kind: AtRuleKind, + pub query: &'static str, + pub declarations: Vec, +} + +/// The declarations of the utility `token` (`px-4`, `py-2!`) names when it +/// has no variant and applies to the element itself, which `@apply` writes out +pub(crate) fn apply_utility(token: &str) -> Option> { + if split_top_level(token, ':').len() > 1 { + return None; + } + let (name, important) = important(token); + let Utility { + mut declarations, + selector, + nested, + } = utility(name)?; + if selector != "&" || !nested.is_empty() { + return None; + } + if important { + for (_, value) in &mut declarations { + *value = Cow::Owned(format!("{value} !important")); + } + } + Some(declarations) } /// The declarations of a utility and the selector they apply under @@ -814,11 +1015,20 @@ fn utility(name: &str) -> Option { return Some(Utility { declarations, selector: CHILDREN, + nested: Vec::new(), + }); + } + if let Some((declarations, nested)) = nested_utility(name) { + return Some(Utility { + declarations, + selector: "&", + nested, }); } Some(Utility { declarations: element_utility(name)?, selector: "&", + nested: Vec::new(), }) } @@ -836,13 +1046,11 @@ fn element_utility(name: &str) -> Option> { }; } if !negative { - if let Some(declarations) = custom_utility(name) { - return Some( - declarations - .into_iter() - .map(|(property, value)| decl(property, value)) - .collect(), - ); + if let Some(declarations) = custom_declarations(name) { + return Some(declarations); + } + if let Some(declarations) = functional_declarations(name) { + return Some(declarations); } } if reads_unsupported_theme(name) { @@ -1089,7 +1297,7 @@ 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,)"; +pub(crate) 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)] @@ -1511,6 +1719,53 @@ fn decode_underscores(value: &str) -> String { /// spaces except in `url()` and in the name `var()` reads, and the operators /// in math functions get the spaces CSS requires around them pub(crate) fn decode_arbitrary_value(value: &str) -> String { + resolve_theme_calls(&decode_syntax(value)) +} + +/// The value of a theme variable `--name`, as `theme(--name)` reads it +fn theme_function_value(name: &str) -> Option { + let name = name.strip_prefix("--")?; + if name == "spacing" { + return Some(theme_variable("spacing").unwrap_or_else(|| String::from("0.25rem"))); + } + if let Some(key) = name.strip_prefix("color-") { + return color_value(key).map(Cow::into_owned); + } + if let Some(key) = name.strip_prefix("spacing-") { + return spacing_scale(key).map(Cow::into_owned); + } + theme_variable(name) +} + +/// `value` with each `theme(--name)` it calls replaced by the value of the +/// theme variable; a call it cannot resolve is left, and the class with it +/// stays as written +fn resolve_theme_calls(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find("theme(") { + let open = start + "theme(".len(); + let length = closing_paren(&rest[open..]); + let named = rest[..start] + .chars() + .last() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + let resolved = (!named && open + length < rest.len()) + .then(|| theme_function_value(rest[open..open + length].trim())) + .flatten(); + if let Some(resolved) = resolved { + out.push_str(&rest[..start]); + out.push_str(&resolved); + rest = &rest[open + length + 1..]; + } else { + out.push_str(&rest[..open]); + rest = &rest[open..]; + } + } + out.push_str(rest); + out +} +fn decode_syntax(value: &str) -> String { if !value.contains('(') { return decode_underscores(value); } @@ -1705,7 +1960,7 @@ pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> { } /// What `[…]` holds -fn bracketed(value: &str) -> Option<&str> { +pub(crate) fn bracketed(value: &str) -> Option<&str> { let inner = value.strip_prefix('[')?.strip_suffix(']')?; (!inner.is_empty() && is_balanced(inner)).then_some(inner) } @@ -4486,7 +4741,6 @@ mod tests { fn test_rotate_unknown_value_returns_none() { assert!(parse_single_class("rotate-unknown").is_none()); // 3D rotation is not compiled - assert!(parse_single_class("rotate-x-45").is_none()); } #[test] @@ -4924,7 +5178,6 @@ mod tests { #[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")] @@ -5014,7 +5267,6 @@ mod tests { #[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}"); } diff --git a/libs/extractor/src/tailwind_custom.rs b/libs/extractor/src/tailwind_custom.rs new file mode 100644 index 000000000..9bc22d2c4 --- /dev/null +++ b/libs/extractor/src/tailwind_custom.rs @@ -0,0 +1,320 @@ +//! The utilities the project's Tailwind CSS defines with `@utility`: plain +//! ones (declarations and `@apply`) and functional ones (`@utility tab-*` +//! that read `--value()` and `--modifier()`) + +use std::cell::Cell; + +use css::tailwind_definitions::{UtilityItem, custom_utility, functional_utility, theme_value}; + +use crate::tailwind::{ + Declaration, ValueType, apply_utility, bracketed, decl, decode_arbitrary_value, + is_positive_integer, spacing_scale, split_top_level, value_type, +}; +use crate::tailwind_color::color_value; + +/// How deep `@apply` goes through utilities that apply utilities +const MAX_APPLY_DEPTH: u8 = 8; + +thread_local! { + static APPLY_DEPTH: Cell = const { Cell::new(0) }; +} + +/// The declarations of the `@utility` named `name`, with the utilities it +/// applies written out; `None` when one of them is not a utility this build +/// compiles in full +pub fn custom_declarations(name: &str) -> Option> { + let items = custom_utility(name)?; + let depth = APPLY_DEPTH.with(|depth| { + depth.set(depth.get() + 1); + depth.get() + }); + let declarations = (depth <= MAX_APPLY_DEPTH).then(|| expand(&items)).flatten(); + APPLY_DEPTH.with(|depth| depth.set(depth.get() - 1)); + declarations +} + +fn expand(items: &[UtilityItem]) -> Option> { + let mut declarations = Vec::new(); + for item in items { + match item { + UtilityItem::Declaration(property, value) => { + declarations.push(decl(property.clone(), value.clone())); + } + UtilityItem::Apply(tokens) => { + for token in tokens { + declarations.extend(apply_utility(token)?); + } + } + } + } + Some(declarations) +} + +/// What a candidate holds: the value after the root and its modifier +struct Candidate<'a> { + value: &'a str, + modifier: Option<&'a str>, +} + +/// The type of an argument of `--value()` or `--modifier()`, applied to +/// `value` +fn resolve_argument(argument: &str, value: &str) -> Option { + let argument = argument.trim(); + if let Some(literal) = argument + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .or_else(|| { + argument + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + }) + { + return (literal == value).then(|| literal.to_string()); + } + if let Some(namespace) = argument + .strip_prefix("--") + .and_then(|rest| rest.strip_suffix("-*")) + { + return themed_value(namespace, value); + } + if let Some(kind) = bracketed(argument) { + let inner = bracketed(value)?; + return arbitrary_of_type(kind, &decode_arbitrary_value(inner)); + } + let bare = match argument { + "integer" => is_positive_integer(value), + "number" => value + .parse::() + .is_ok_and(|number| number >= 0.0 && number.to_string() == value), + "percentage" => value.strip_suffix('%').is_some_and(is_positive_integer), + _ => false, + }; + bare.then(|| value.to_string()) +} + +/// The value of `---` that the project defines, or that the +/// defaults of the namespace give +fn themed_value(namespace: &str, key: &str) -> Option { + match namespace { + "color" => color_value(key).map(std::borrow::Cow::into_owned), + "spacing" => spacing_scale(key).map(std::borrow::Cow::into_owned), + _ => theme_value(namespace, key), + } +} + +/// `value` when it is an arbitrary value of the data type `kind` +fn arbitrary_of_type(kind: &str, value: &str) -> Option { + let matches = match kind { + "length" => value_type(None, value) == Some(ValueType::Length), + "number" | "integer" => value_type(None, value) == Some(ValueType::Number), + "percentage" => value + .strip_suffix('%') + .is_some_and(|n| n.parse::().is_ok()), + "color" => value_type(None, value) == Some(ValueType::Color), + "url" | "image" => value_type(None, value) == Some(ValueType::Image), + _ => false, + }; + matches.then(|| value.to_string()) +} + +/// The first argument of `function(...)` that `value` fits +fn resolve_function(arguments: &str, value: Option<&str>) -> Option { + let value = value?; + split_top_level(arguments, ',') + .into_iter() + .find_map(|argument| resolve_argument(argument, value)) +} + +/// `declaration` with its `--value()` and `--modifier()` calls replaced: +/// `Err(())` when `--value()` has no match, `Ok(None)` when the declaration +/// drops out for want of a modifier +fn substitute(text: &str, candidate: &Candidate<'_>) -> Result, ()> { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("--") { + let after = &rest[start..]; + let (name, is_value) = if after.starts_with("--value(") { + ("--value(", true) + } else if after.starts_with("--modifier(") { + ("--modifier(", false) + } else { + out.push_str(&rest[..start + 2]); + rest = &rest[start + 2..]; + continue; + }; + out.push_str(&rest[..start]); + let open = start + name.len(); + let close = matching_paren(&rest[open..]).ok_or(())? + open; + let arguments = &rest[open..close]; + let resolved = if is_value { + resolve_function(arguments, Some(candidate.value)) + } else { + resolve_function(arguments, candidate.modifier) + }; + match (resolved, is_value) { + (Some(resolved), _) => out.push_str(&resolved), + (None, true) => return Err(()), + (None, false) => return Ok(None), + } + rest = &rest[close + 1..]; + } + out.push_str(rest); + Ok(Some(out)) +} + +/// The index of the `)` that closes a `(` just before `text` +fn matching_paren(text: &str) -> Option { + let mut depth = 1usize; + for (index, c) in text.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(index); + } + } + _ => {} + } + } + None +} + +/// Whether `text` still calls a function of `@utility` that this build does +/// not run (`--spacing()`, `--alpha()` ...) +fn calls_unknown_function(text: &str) -> bool { + text.match_indices("--").any(|(index, _)| { + let name = &text[index + 2..]; + let end = name + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) + .unwrap_or(name.len()); + name[end..].starts_with('(') && end > 0 + }) +} + +/// The declarations of the functional `@utility` that `name` is a candidate +/// of (`tab-4` for `tab-*`) +pub fn functional_declarations(name: &str) -> Option> { + name.match_indices('-').find_map(|(index, _)| { + let declarations = functional_utility(&name[..index])?; + let rest = &name[index + 1..]; + let parts = split_top_level(rest, '/'); + let candidate = match parts.as_slice() { + [value] => Candidate { + value, + modifier: None, + }, + [value, modifier] => Candidate { + value, + modifier: Some(modifier), + }, + _ => return None, + }; + let mut resolved = Vec::new(); + for (property, value) in declarations { + let Ok(value) = substitute(&value, &candidate) else { + return None; + }; + let Some(value) = value else { + continue; + }; + if calls_unknown_function(&value) { + return None; + } + resolved.push(decl(property, value)); + } + (!resolved.is_empty()).then_some(resolved) + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use css::tailwind_definitions::set_tailwind_css; + use serial_test::serial; + + use crate::tailwind::declarations_of; + + fn written(class: &str) -> Vec<(String, String)> { + declarations_of(class).unwrap_or_else(|| panic!("{class} stays as written")) + } + + fn pairs(declarations: &[(&str, &str)]) -> Vec<(String, String)> { + declarations + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect() + } + + #[test] + #[serial] + fn apply_writes_out_the_utilities_it_applies() { + set_tailwind_css( + "@utility btn { @apply px-4 py-2!; border-radius: 4px; } @utility btn-lg { @apply btn text-lg; } @utility loop-a { @apply loop-b; } @utility loop-b { @apply loop-a; } @utility hovered { @apply hover:p-4; } @utility unknown { @apply nothing-here; } @utility children { @apply space-x-4; }", + ); + assert_eq!( + written("btn"), + pairs(&[ + ("padding-inline", "1rem"), + ("padding-block", "0.5rem !important"), + ("border-radius", "4px"), + ]) + ); + let lg = written("btn-lg"); + assert_eq!(lg[0], ("padding-inline".to_string(), "1rem".to_string())); + assert_eq!(lg[3].0, "font-size"); + for class in ["loop-a", "hovered", "unknown", "children"] { + assert_eq!(declarations_of(class), None, "{class}"); + } + set_tailwind_css(""); + } + + #[test] + #[serial] + fn functional_utilities_read_their_value_and_modifier() { + set_tailwind_css( + "@theme { --tab-size-github: 8; --color-brand: #0af; --spacing-gutter: 3rem; } @utility tab-* { tab-size: --value(--tab-size-*, integer, [integer]); } @utility pct-* { width: --value(percentage); } @utility lit-* { display: --value('inline-flex', 'grid'); } @utility num-* { line-height: --value(number); } @utility bg-glow-* { background: --value(--color-*, [color]); box-shadow: 0 0 8px --modifier([percentage], number); } @utility gap-* { gap: --value(--spacing-*); } @utility len-* { width: --value([length]); } @utility pc-* { height: --value([percentage]); } @utility many-* { a: --value([url], [number], [unknown]); } @utility q-* { content: \"--value(x\"; } @utility space-* { gap: --spacing(4); } @utility bad-* { width: --value(unknown-type); } @utility mod-* { width: --value(integer); color: --modifier('red'); }", + ); + assert_eq!(written("tab-4"), pairs(&[("tab-size", "4")])); + assert_eq!(written("tab-github"), pairs(&[("tab-size", "8")])); + assert_eq!(written("tab-[12]"), pairs(&[("tab-size", "12")])); + assert_eq!(declarations_of("tab-1.5"), None); + assert_eq!(declarations_of("tab-nothing"), None); + assert_eq!(declarations_of("tab-"), None); + assert_eq!(declarations_of("tab-4/2/3"), None); + assert_eq!(written("pct-50%"), pairs(&[("width", "50%")])); + assert_eq!(declarations_of("pct-50"), None); + assert_eq!(written("lit-grid"), pairs(&[("display", "grid")])); + assert_eq!( + written("lit-inline-flex"), + pairs(&[("display", "inline-flex")]) + ); + assert_eq!(declarations_of("lit-block"), None); + assert_eq!(written("num-1.5"), pairs(&[("line-height", "1.5")])); + assert_eq!(written("bg-glow-brand"), pairs(&[("background", "#0af")])); + assert_eq!( + written("bg-glow-red-500/50"), + pairs(&[ + ("background", "oklch(63.7% 0.237 25.331)"), + ("box-shadow", "0 0 8px 50"), + ]) + ); + assert_eq!(written("bg-glow-[#f00]"), pairs(&[("background", "#f00")])); + assert_eq!(written("gap-gutter"), pairs(&[("gap", "3rem")])); + assert_eq!(written("gap-4"), pairs(&[("gap", "1rem")])); + assert_eq!(written("len-[3px]"), pairs(&[("width", "3px")])); + assert_eq!(declarations_of("len-3px"), None); + assert_eq!(written("many-[1]"), pairs(&[("a", "1")])); + assert_eq!(declarations_of("many-[x]"), None); + for class in ["space-4", "bad-4", "q-1"] { + assert_eq!(declarations_of(class), None, "{class}"); + } + assert_eq!(written("mod-4"), pairs(&[("width", "4")])); + assert_eq!( + written("mod-4/red"), + pairs(&[("width", "4"), ("color", "red")]) + ); + assert_eq!(written("pc-[50%]"), pairs(&[("height", "50%")])); + set_tailwind_css(""); + } +} diff --git a/libs/extractor/src/tailwind_effects.rs b/libs/extractor/src/tailwind_effects.rs index 8b55e4290..971b477c0 100644 --- a/libs/extractor/src/tailwind_effects.rs +++ b/libs/extractor/src/tailwind_effects.rs @@ -268,35 +268,119 @@ fn recolored(shadow: &str, variable: &str) -> String { .collect::>() .join(", ") } -/// The declarations of a `shadow` utility: a size, `none`, a theme shadow or -/// a color +/// The insets of the default theme +fn inset_shadow_size(name: &str) -> Option<&'static str> { + Some(match name { + "2xs" => "inset 0 1px rgb(0 0 0 / 0.05)", + "xs" => "inset 0 1px 1px rgb(0 0 0 / 0.05)", + "sm" => "inset 0 2px 4px rgb(0 0 0 / 0.05)", + _ => return None, + }) +} + +/// The text shadows of the default theme +fn text_shadow_size(name: &str) -> Option<&'static str> { + Some(match name { + "2xs" => "0px 1px 0px rgb(0 0 0 / 0.15)", + "xs" => "0px 1px 1px rgb(0 0 0 / 0.2)", + "sm" => { + "0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075)" + } + "md" => { + "0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1)" + } + "lg" => { + "0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1)" + } + _ => return None, + }) +} + +/// A kind of shadow utility: how its value and color are stored +struct ShadowFamily { + /// The class root, which is also the theme namespace + root: &'static str, + color: &'static str, + alpha: &'static str, + /// The custom property the value is composed in; none when the value is + /// the property itself + composed: Option<&'static str>, + sizes: fn(&str) -> Option<&'static str>, +} + +static SHADOW_FAMILIES: [ShadowFamily; 3] = [ + ShadowFamily { + root: "shadow", + color: "--tw-shadow-color", + alpha: "--tw-shadow-alpha", + composed: Some("--tw-shadow"), + sizes: shadow_size, + }, + ShadowFamily { + root: "inset-shadow", + color: "--tw-inset-shadow-color", + alpha: "--tw-inset-shadow-alpha", + composed: Some("--tw-inset-shadow"), + sizes: inset_shadow_size, + }, + ShadowFamily { + root: "text-shadow", + color: "--tw-text-shadow-color", + alpha: "--tw-text-shadow-alpha", + composed: None, + sizes: text_shadow_size, + }, +]; + +/// The declarations of a `shadow`, `inset-shadow` or `text-shadow` utility: a +/// size, `none`, a color, and for `shadow` a theme shadow of `devup.json` fn shadow_utility(name: &str) -> Option> { - let argument = if name == "shadow" { + SHADOW_FAMILIES + .iter() + .find_map(|family| family_utility(family, name)) +} + +fn family_utility(family: &ShadowFamily, name: &str) -> Option> { + let argument = if name == family.root { "" } else { - name.strip_prefix("shadow-") + name.strip_prefix(family.root)? + .strip_prefix('-') .filter(|argument| !argument.is_empty())? }; - let composed = - |shadow: String| vec![decl("--tw-shadow", shadow), decl("box-shadow", BOX_SHADOW)]; - if let Some(shadow) = themed("shadow", argument, shadow_size) { - return Some(composed(recolored(&shadow, "--tw-shadow-color"))); + let composed = |shadow: String| match family.composed { + Some(variable) => vec![decl(variable, shadow), decl("box-shadow", BOX_SHADOW)], + None => vec![decl("text-shadow", shadow)], + }; + if argument == "initial" { + return Some(vec![decl(family.color, "initial")]); + } + if let Some(shadow) = themed(family.root, argument, family.sizes) { + return Some(composed(recolored(&shadow, family.color))); } match argument { - "none" => return Some(composed(String::from("0 0 #0000"))), - "inherit" => return Some(vec![decl("--tw-shadow-color", "inherit")]), + "none" => { + return Some(composed(String::from(if family.composed.is_some() { + "0 0 #0000" + } else { + "none" + }))); + } + "inherit" => return Some(vec![decl(family.color, "inherit")]), _ => {} } - if is_shadow_token(argument) { + if family.root == "shadow" && 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)"), + family.color, + format!( + "color-mix(in oklab, {color} var({}), transparent)", + family.alpha + ), )]) } - /// A color written in an argument enum Argument { /// The argument is no arbitrary value @@ -306,7 +390,6 @@ enum Argument { /// 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) { @@ -1425,4 +1508,89 @@ mod tests { assert_eq!(declarations_of(class), None, "{class}"); } } + + #[rstest] + #[case( + "text-shadow-2xs", + "0px 1px 0px var(--tw-text-shadow-color, rgb(0 0 0 / 0.15))" + )] + #[case( + "text-shadow-xs", + "0px 1px 1px var(--tw-text-shadow-color, rgb(0 0 0 / 0.2))" + )] + #[case( + "text-shadow-sm", + "0px 1px 0px var(--tw-text-shadow-color, rgb(0 0 0 / 0.075)), 0px 1px 1px var(--tw-text-shadow-color, rgb(0 0 0 / 0.075)), 0px 2px 2px var(--tw-text-shadow-color, rgb(0 0 0 / 0.075))" + )] + #[case( + "text-shadow-md", + "0px 1px 1px var(--tw-text-shadow-color, rgb(0 0 0 / 0.1)), 0px 1px 2px var(--tw-text-shadow-color, rgb(0 0 0 / 0.1)), 0px 2px 4px var(--tw-text-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "text-shadow-lg", + "0px 1px 2px var(--tw-text-shadow-color, rgb(0 0 0 / 0.1)), 0px 3px 2px var(--tw-text-shadow-color, rgb(0 0 0 / 0.1)), 0px 4px 8px var(--tw-text-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case("text-shadow-none", "none")] + fn text_shadows_are_the_property_itself(#[case] class: &str, #[case] shadow: &str) { + expect(class, &[("text-shadow", shadow)]); + } + + #[test] + fn text_shadow_colors() { + expect( + "text-shadow-red-500", + &[( + "--tw-text-shadow-color", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--tw-text-shadow-alpha), transparent)", + )], + ); + expect( + "text-shadow-inherit", + &[("--tw-text-shadow-color", "inherit")], + ); + expect( + "text-shadow-initial", + &[("--tw-text-shadow-color", "initial")], + ); + assert_eq!(declarations_of("text-shadow"), None); + assert_eq!(declarations_of("text-shadow-huge"), None); + } + + #[rstest] + #[case( + "inset-shadow-2xs", + "inset 0 1px var(--tw-inset-shadow-color, rgb(0 0 0 / 0.05))" + )] + #[case( + "inset-shadow-xs", + "inset 0 1px 1px var(--tw-inset-shadow-color, rgb(0 0 0 / 0.05))" + )] + #[case( + "inset-shadow-sm", + "inset 0 2px 4px var(--tw-inset-shadow-color, rgb(0 0 0 / 0.05))" + )] + #[case("inset-shadow-none", "0 0 #0000")] + fn inset_shadows_compose_with_the_other_shadows(#[case] class: &str, #[case] shadow: &str) { + expect( + class, + &[("--tw-inset-shadow", shadow), ("box-shadow", BOX_SHADOW)], + ); + } + + #[test] + fn inset_shadow_colors_and_initial() { + expect( + "inset-shadow-blue-500", + &[( + "--tw-inset-shadow-color", + "color-mix(in oklab, oklch(62.3% 0.214 259.815) var(--tw-inset-shadow-alpha), transparent)", + )], + ); + expect( + "inset-shadow-initial", + &[("--tw-inset-shadow-color", "initial")], + ); + expect("shadow-initial", &[("--tw-shadow-color", "initial")]); + assert_eq!(declarations_of("inset-shadow"), None); + } } diff --git a/libs/extractor/src/tailwind_misc.rs b/libs/extractor/src/tailwind_misc.rs index 2b05db026..0ba4cbdc0 100644 --- a/libs/extractor/src/tailwind_misc.rs +++ b/libs/extractor/src/tailwind_misc.rs @@ -2,12 +2,17 @@ //! value on a few properties: text decoration and clamping, border widths and //! styles, spacing on logical sides, table and transform keywords +use std::borrow::Cow; + +use css::style_selector::AtRuleKind; + use crate::tailwind::{ - Declaration, ValueType, arbitrary_or_variable, decl, is_positive_integer, spacing_scale, - value_type, + Declaration, Nested, TRANSFORM, ValueType, arbitrary_or_variable, bracketed, decl, + decode_arbitrary_value, is_positive_integer, spacing_scale, split_top_level, value_type, }; use crate::tailwind_children::spacing; use crate::tailwind_color::color_value; +use crate::tailwind_theme::themed; /// The utilities that are one fixed list of declarations fn fixed(name: &str) -> Option<&'static [(&'static str, &'static str)]> { @@ -51,6 +56,73 @@ fn fixed(name: &str) -> Option<&'static [(&'static str, &'static str)]> { "decoration-from-font" => &[("text-decoration-thickness", "from-font")], "underline-offset-auto" => &[("text-underline-offset", "auto")], "list-image-none" => &[("list-style-image", "none")], + "normal-nums" => &[("font-variant-numeric", "normal")], + "contain-none" => &[("contain", "none")], + "contain-content" => &[("contain", "content")], + "contain-strict" => &[("contain", "strict")], + "scale-3d" => &[( + "scale", + "var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)", + )], + "translate-3d" => &[( + "translate", + "var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)", + )], + "transform-cpu" => &[("transform", TRANSFORM)], + "transform-gpu" => &[( + "transform", + "translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)", + )], + "mask-none" => &[("mask-image", "none")], + "mask-add" => &[("mask-composite", "add")], + "mask-subtract" => &[("mask-composite", "subtract")], + "mask-intersect" => &[("mask-composite", "intersect")], + "mask-exclude" => &[("mask-composite", "exclude")], + "mask-alpha" => &[("mask-mode", "alpha")], + "mask-luminance" => &[("mask-mode", "luminance")], + "mask-match" => &[("mask-mode", "match-source")], + "mask-type-alpha" => &[("mask-type", "alpha")], + "mask-type-luminance" => &[("mask-type", "luminance")], + "mask-auto" => &[("mask-size", "auto")], + "mask-cover" => &[("mask-size", "cover")], + "mask-contain" => &[("mask-size", "contain")], + "mask-top" => &[("mask-position", "top")], + "mask-top-left" => &[("mask-position", "left top")], + "mask-top-right" => &[("mask-position", "right top")], + "mask-bottom" => &[("mask-position", "bottom")], + "mask-bottom-left" => &[("mask-position", "left bottom")], + "mask-bottom-right" => &[("mask-position", "right bottom")], + "mask-left" => &[("mask-position", "left")], + "mask-right" => &[("mask-position", "right")], + "mask-center" => &[("mask-position", "center")], + "mask-repeat" => &[("mask-repeat", "repeat")], + "mask-no-repeat" => &[("mask-repeat", "no-repeat")], + "mask-repeat-x" => &[("mask-repeat", "repeat-x")], + "mask-repeat-y" => &[("mask-repeat", "repeat-y")], + "mask-repeat-round" => &[("mask-repeat", "round")], + "mask-repeat-space" => &[("mask-repeat", "space")], + "mask-clip-border" => &[("mask-clip", "border-box")], + "mask-clip-padding" => &[("mask-clip", "padding-box")], + "mask-clip-content" => &[("mask-clip", "content-box")], + "mask-clip-fill" => &[("mask-clip", "fill-box")], + "mask-clip-stroke" => &[("mask-clip", "stroke-box")], + "mask-clip-view" => &[("mask-clip", "view-box")], + "mask-no-clip" => &[("mask-clip", "no-clip")], + "mask-origin-border" => &[("mask-origin", "border-box")], + "mask-origin-padding" => &[("mask-origin", "padding-box")], + "mask-origin-content" => &[("mask-origin", "content-box")], + "mask-origin-fill" => &[("mask-origin", "fill-box")], + "mask-origin-stroke" => &[("mask-origin", "stroke-box")], + "mask-origin-view" => &[("mask-origin", "view-box")], + "perspective-origin-center" => &[("perspective-origin", "center")], + "perspective-origin-top" => &[("perspective-origin", "top")], + "perspective-origin-top-right" => &[("perspective-origin", "100% 0")], + "perspective-origin-right" => &[("perspective-origin", "100%")], + "perspective-origin-bottom-right" => &[("perspective-origin", "100% 100%")], + "perspective-origin-bottom" => &[("perspective-origin", "bottom")], + "perspective-origin-bottom-left" => &[("perspective-origin", "0 100%")], + "perspective-origin-left" => &[("perspective-origin", "0")], + "perspective-origin-top-left" => &[("perspective-origin", "0 0")], "line-clamp-none" => &[ ("overflow", "visible"), ("display", "block"), @@ -71,12 +143,6 @@ fn fixed(name: &str) -> Option<&'static [(&'static str, &'static str)]> { ("outline-style", "double"), ], "outline-none" => &[("--tw-outline-style", "none"), ("outline-style", "none")], - "perspective-none" => &[("perspective", "none")], - "perspective-dramatic" => &[("perspective", "100px")], - "perspective-near" => &[("perspective", "300px")], - "perspective-normal" => &[("perspective", "500px")], - "perspective-midrange" => &[("perspective", "800px")], - "perspective-distant" => &[("perspective", "1200px")], "font-stretch-normal" => &[("font-stretch", "normal")], "font-stretch-ultra-condensed" => &[("font-stretch", "ultra-condensed")], "font-stretch-extra-condensed" => &[("font-stretch", "extra-condensed")], @@ -225,11 +291,228 @@ fn negated_pixels(argument: &str) -> Option { Some(format!("calc({} * -1)", pixels_or_value(argument)?)) } +/// What `font-variant-numeric` holds: every figure, spacing and fraction the +/// numeric utilities set +const NUMERIC: &str = "var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)"; + +/// What `contain` holds when `contain-size` and its like compose +const CONTAIN: &str = "var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)"; + +/// The composed `font-variant-numeric` and `contain` utilities: the variable +/// each sets, and its value +fn composed(name: &str) -> Option> { + let (variable, value, property, shared) = match name { + "ordinal" => ("--tw-ordinal", "ordinal", "font-variant-numeric", NUMERIC), + "slashed-zero" => ( + "--tw-slashed-zero", + "slashed-zero", + "font-variant-numeric", + NUMERIC, + ), + "lining-nums" => ( + "--tw-numeric-figure", + "lining-nums", + "font-variant-numeric", + NUMERIC, + ), + "oldstyle-nums" => ( + "--tw-numeric-figure", + "oldstyle-nums", + "font-variant-numeric", + NUMERIC, + ), + "proportional-nums" => ( + "--tw-numeric-spacing", + "proportional-nums", + "font-variant-numeric", + NUMERIC, + ), + "tabular-nums" => ( + "--tw-numeric-spacing", + "tabular-nums", + "font-variant-numeric", + NUMERIC, + ), + "diagonal-fractions" => ( + "--tw-numeric-fraction", + "diagonal-fractions", + "font-variant-numeric", + NUMERIC, + ), + "stacked-fractions" => ( + "--tw-numeric-fraction", + "stacked-fractions", + "font-variant-numeric", + NUMERIC, + ), + "contain-size" => ("--tw-contain-size", "size", "contain", CONTAIN), + "contain-inline-size" => ("--tw-contain-size", "inline-size", "contain", CONTAIN), + "contain-layout" => ("--tw-contain-layout", "layout", "contain", CONTAIN), + "contain-paint" => ("--tw-contain-paint", "paint", "contain", CONTAIN), + "contain-style" => ("--tw-contain-style", "style", "contain", CONTAIN), + _ => return None, + }; + Some(vec![decl(variable, value), decl(property, shared)]) +} + +/// `rotate-x-45`, `translate-z-4`, `scale-z-50`: the 3D steps of the transform +/// utilities, set in their own variable and composed with the others +fn three_d(name: &str, negative: bool) -> Option> { + if let Some(argument) = name.strip_prefix("rotate-") { + let (axis, argument) = argument.split_once('-')?; + if !matches!(axis, "x" | "y" | "z") { + return None; + } + let value = if is_positive_integer(argument) { + format!("{argument}deg") + } else { + arbitrary_or_variable(argument)? + }; + let value = if negative { + format!("calc({value} * -1)") + } else { + value + }; + return Some(vec![ + decl( + format!("--tw-rotate-{axis}"), + format!("rotate{}({value})", axis.to_ascii_uppercase()), + ), + decl("transform", TRANSFORM), + ]); + } + if let Some(argument) = name.strip_prefix("translate-z-") { + return Some(vec![ + decl("--tw-translate-z", spacing(argument, negative)?), + decl( + "translate", + "var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)", + ), + ]); + } + let argument = name.strip_prefix("scale-z-")?; + let value = if is_positive_integer(argument) { + format!("{argument}%") + } else { + arbitrary_or_variable(argument)? + }; + let value = if negative { + format!("calc({value} * -1)") + } else { + value + }; + Some(vec![ + decl("--tw-scale-z", value), + decl( + "scale", + "var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)", + ), + ]) +} + +/// `perspective-near`, `perspective-[300px]` and `perspective-origin-[10%_20%]` +fn perspective(name: &str) -> Option> { + if let Some(argument) = name.strip_prefix("perspective-origin-") { + return Some(vec![decl( + "perspective-origin", + arbitrary_or_variable(argument)?, + )]); + } + let argument = name.strip_prefix("perspective-")?; + if argument == "none" { + return Some(vec![decl("perspective", "none")]); + } + let value = themed("perspective", argument, |key| { + Some(match key { + "dramatic" => "100px", + "near" => "300px", + "normal" => "500px", + "midrange" => "800px", + "distant" => "1200px", + _ => return None, + }) + }) + .map(Cow::into_owned) + .or_else(|| arbitrary_or_variable(argument))?; + Some(vec![decl("perspective", value)]) +} + +/// `mask-[...]`, `mask-size-[...]` and `mask-position-[...]` +fn mask(name: &str) -> Option> { + if let Some(argument) = name.strip_prefix("mask-size-") { + return Some(vec![decl("mask-size", arbitrary_or_variable(argument)?)]); + } + if let Some(argument) = name.strip_prefix("mask-position-") { + return Some(vec![decl( + "mask-position", + arbitrary_or_variable(argument)?, + )]); + } + let value = arbitrary_or_variable(name.strip_prefix("mask-")?)?; + let percentage = value + .strip_suffix('%') + .is_some_and(|number| number.parse::().is_ok()); + let property = if percentage { + "mask-position" + } else if value_type(None, &value) == Some(ValueType::Length) { + "mask-size" + } else { + "mask-image" + }; + Some(vec![decl(property, value)]) +} + +/// The utilities that also have declarations in an at-rule: `outline-hidden` +pub fn nested_utility(name: &str) -> Option<(Vec, Vec)> { + (name == "outline-hidden").then(|| { + ( + vec![ + decl("--tw-outline-style", "none"), + decl("outline-style", "none"), + ], + vec![Nested { + kind: AtRuleKind::Media, + query: "(forced-colors: active)", + declarations: vec![ + decl("outline", "2px solid transparent"), + decl("outline-offset", "2px"), + ], + }], + ) + }) +} + +/// `@container`, `@container-normal` and `@container/main`: an element whose +/// size the container queries of its descendants read +fn container_utility(name: &str) -> Option> { + let rest = name.strip_prefix("@container")?; + let parts = split_top_level(rest, '/'); + let (kind, container) = match parts.as_slice() { + [kind] => (*kind, None), + [kind, container] if !container.is_empty() => (*kind, Some(*container)), + _ => return None, + }; + let value = match kind { + "" => String::from("inline-size"), + "-normal" => String::from("normal"), + "-size" => String::from("size"), + _ => bracketed(kind.strip_prefix('-')?).map(decode_arbitrary_value)?, + }; + let mut declarations = vec![decl("container-type", value)]; + if let Some(container) = container { + declarations.push(decl("container-name", container.to_string())); + } + Some(declarations) +} + /// The declarations of the utilities of this module, `None` for any other pub fn misc_utility(name: &str, negative: bool) -> Option> { if negative { return negated(name); } + if let Some(declarations) = container_utility(name) { + return Some(declarations); + } if let Some(declarations) = fixed(name) { return Some( declarations @@ -238,13 +521,19 @@ pub fn misc_utility(name: &str, negative: bool) -> Option> { .collect(), ); } + if let Some(declarations) = composed(name) + .or_else(|| perspective(name)) + .or_else(|| mask(name)) + { + return Some(declarations); + } if let Some(declarations) = border_utility(name) .or_else(|| border_spacing(name)) .or_else(|| outline_width(name)) { return Some(declarations); } - plain(name) + three_d(name, false).or_else(|| plain(name)) } /// The properties of a spaced utility such as `scroll-mt-2` and its argument @@ -256,6 +545,9 @@ fn split_spaced(name: &str) -> Option<(&'static [&'static str], &str)> { /// The utilities a leading `-` negates fn negated(name: &str) -> Option> { + if let Some(declarations) = three_d(name, true) { + return Some(declarations); + } if let Some(argument) = name.strip_prefix("underline-offset-") { return Some(vec![decl( "text-underline-offset", @@ -331,6 +623,19 @@ mod tests { use crate::tailwind::declarations_of; + fn conditions(class: &str) -> Vec { + crate::tailwind::parse_class(class) + .unwrap() + .conditions + .iter() + .map(|condition| { + condition + .as_ref() + .map_or_else(String::new, ToString::to_string) + }) + .collect() + } + fn expect(class: &str, expected: &[(&str, &str)]) { let expected: Vec<(String, String)> = expected .iter() @@ -577,4 +882,323 @@ mod tests { fn unknown_utilities_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } + + #[rstest] + #[case("@sm:p-4", "@container(width>=24rem)")] + #[case("@md:p-4", "@container(width>=28rem)")] + #[case("@3xs:p-4", "@container(width>=16rem)")] + #[case("@7xl:p-4", "@container(width>=80rem)")] + #[case("@max-md:p-4", "@container(width<28rem)")] + #[case("@min-lg:p-4", "@container(width>=32rem)")] + #[case("@min-[400px]:p-4", "@container(width>=400px)")] + #[case("@max-[30rem]:p-4", "@container(width<30rem)")] + #[case("@[30rem]:p-4", "@container(width>=30rem)")] + #[case("@sm/main:p-4", "@container main (width>=24rem)")] + #[case("@max-md/sidebar:p-4", "@container sidebar (width<28rem)")] + fn container_queries_style_by_the_width_of_the_container( + #[case] class: &str, + #[case] condition: &str, + ) { + assert_eq!(conditions(class), vec![condition]); + } + + #[rstest] + #[case("@sm/:p-4")] + #[case("@huge:p-4")] + #[case("@sm/a/b:p-4")] + #[case("@:p-4")] + #[case("@max-:p-4")] + #[case("@[]:p-4")] + #[case("@[var(--w)]:p-4")] + #[case("@max-[calc(var(--w))]:p-4")] + fn unknown_container_queries_stay_as_written(#[case] class: &str) { + assert_eq!(crate::tailwind::parse_class(class), None, "{class}"); + } + + #[test] + fn container_queries_nest_with_other_variants() { + assert_eq!( + conditions("hover:@md:p-4"), + vec!["@media(hover:hover) @container(width>=28rem) &:hover"] + ); + assert_eq!( + conditions("@md:hover:p-4"), + vec!["@container(width>=28rem) @media(hover:hover) &:hover"] + ); + } + + #[rstest] + #[case("@container", &[("container-type", "inline-size")])] + #[case("@container-normal", &[("container-type", "normal")])] + #[case("@container-size", &[("container-type", "size")])] + #[case("@container-[inline-size_layout]", &[("container-type", "inline-size layout")])] + #[case("@container/main", &[("container-type", "inline-size"), ("container-name", "main")])] + #[case("@container-normal/main", &[("container-type", "normal"), ("container-name", "main")])] + fn container_utilities_make_the_element_a_container( + #[case] class: &str, + #[case] expected: &[(&str, &str)], + ) { + expect(class, expected); + } + + #[rstest] + #[case("@container-")] + #[case("@container-huge")] + #[case("@container/")] + #[case("@container/a/b")] + #[case("@containers")] + fn unknown_containers_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn outline_hidden_has_a_forced_colors_fallback() { + let class = crate::tailwind::parse_class("outline-hidden").unwrap(); + assert_eq!( + class.declarations, + vec![ + ("--tw-outline-style".into(), "none".into()), + ("outline-style".into(), "none".into()), + ] + ); + assert_eq!(class.nested.len(), 1); + assert_eq!( + class.nested[0].0[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("@media(forced-colors:active)") + ); + assert_eq!( + class.nested[0].1, + vec![ + ("outline".into(), "2px solid transparent".into()), + ("outline-offset".into(), "2px".into()), + ] + ); + let styles: Vec<_> = class + .styles() + .map(|style| style.property().to_string()) + .collect(); + assert_eq!( + styles, + vec![ + "--tw-outline-style", + "outline-style", + "outline", + "outline-offset" + ] + ); + let important = crate::tailwind::parse_class("outline-hidden!").unwrap(); + assert_eq!(important.nested[0].1[1].1, "2px !important"); + let hovered = crate::tailwind::parse_class("hover:outline-hidden").unwrap(); + assert_eq!( + hovered.nested[0].0[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("@media(hover:hover)and (forced-colors:active) &:hover") + ); + } + + #[rstest] + #[case("ordinal", "--tw-ordinal", "ordinal")] + #[case("slashed-zero", "--tw-slashed-zero", "slashed-zero")] + #[case("lining-nums", "--tw-numeric-figure", "lining-nums")] + #[case("oldstyle-nums", "--tw-numeric-figure", "oldstyle-nums")] + #[case("proportional-nums", "--tw-numeric-spacing", "proportional-nums")] + #[case("tabular-nums", "--tw-numeric-spacing", "tabular-nums")] + #[case("diagonal-fractions", "--tw-numeric-fraction", "diagonal-fractions")] + #[case("stacked-fractions", "--tw-numeric-fraction", "stacked-fractions")] + fn numeric_figures_compose_through_their_variables( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + expect( + class, + &[ + (variable, value), + ( + "font-variant-numeric", + "var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)", + ), + ], + ); + expect("normal-nums", &[("font-variant-numeric", "normal")]); + } + + #[rstest] + #[case("contain-size", "--tw-contain-size", "size")] + #[case("contain-inline-size", "--tw-contain-size", "inline-size")] + #[case("contain-layout", "--tw-contain-layout", "layout")] + #[case("contain-paint", "--tw-contain-paint", "paint")] + #[case("contain-style", "--tw-contain-style", "style")] + fn contain_composes_through_its_variables( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + expect( + class, + &[ + (variable, value), + ( + "contain", + "var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)", + ), + ], + ); + } + + #[test] + fn contain_keywords() { + expect("contain-none", &[("contain", "none")]); + expect("contain-content", &[("contain", "content")]); + expect("contain-strict", &[("contain", "strict")]); + expect( + "transform-cpu", + &[( + "transform", + "var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)", + )], + ); + expect( + "transform-gpu", + &[( + "transform", + "translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)", + )], + ); + } + + #[test] + fn three_d_transforms_compose_with_the_others() { + let transform = "var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)"; + expect( + "rotate-x-45", + &[ + ("--tw-rotate-x", "rotateX(45deg)"), + ("transform", transform), + ], + ); + expect( + "rotate-y-12", + &[ + ("--tw-rotate-y", "rotateY(12deg)"), + ("transform", transform), + ], + ); + expect( + "rotate-z-0", + &[("--tw-rotate-z", "rotateZ(0deg)"), ("transform", transform)], + ); + expect( + "-rotate-x-45", + &[ + ("--tw-rotate-x", "rotateX(calc(45deg * -1))"), + ("transform", transform), + ], + ); + expect( + "rotate-y-[1.5rad]", + &[ + ("--tw-rotate-y", "rotateY(1.5rad)"), + ("transform", transform), + ], + ); + let translate = "var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)"; + expect( + "translate-z-4", + &[("--tw-translate-z", "1rem"), ("translate", translate)], + ); + expect( + "-translate-z-4", + &[("--tw-translate-z", "-1rem"), ("translate", translate)], + ); + expect( + "translate-z-[3px]", + &[("--tw-translate-z", "3px"), ("translate", translate)], + ); + expect("translate-3d", &[("translate", translate)]); + let scale = "var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)"; + expect("scale-z-50", &[("--tw-scale-z", "50%"), ("scale", scale)]); + expect( + "-scale-z-50", + &[("--tw-scale-z", "calc(50% * -1)"), ("scale", scale)], + ); + expect( + "scale-z-[1.5]", + &[("--tw-scale-z", "1.5"), ("scale", scale)], + ); + expect("scale-3d", &[("scale", scale)]); + } + + #[test] + fn perspective_and_its_origin() { + expect("perspective-dramatic", &[("perspective", "100px")]); + expect("perspective-near", &[("perspective", "300px")]); + expect("perspective-normal", &[("perspective", "500px")]); + expect("perspective-midrange", &[("perspective", "800px")]); + expect("perspective-distant", &[("perspective", "1200px")]); + expect("perspective-none", &[("perspective", "none")]); + expect("perspective-[400px]", &[("perspective", "400px")]); + expect("perspective-(--p)", &[("perspective", "var(--p)")]); + expect( + "perspective-origin-[10%_20%]", + &[("perspective-origin", "10% 20%")], + ); + for (name, value) in [ + ("center", "center"), + ("top", "top"), + ("top-right", "100% 0"), + ("right", "100%"), + ("bottom-right", "100% 100%"), + ("bottom", "bottom"), + ("bottom-left", "0 100%"), + ("left", "0"), + ("top-left", "0 0"), + ] { + expect( + &format!("perspective-origin-{name}"), + &[("perspective-origin", value)], + ); + } + } + + #[test] + fn masks() { + expect("mask-none", &[("mask-image", "none")]); + expect("mask-add", &[("mask-composite", "add")]); + expect("mask-intersect", &[("mask-composite", "intersect")]); + expect("mask-luminance", &[("mask-mode", "luminance")]); + expect("mask-match", &[("mask-mode", "match-source")]); + expect("mask-type-alpha", &[("mask-type", "alpha")]); + expect("mask-cover", &[("mask-size", "cover")]); + expect("mask-top-left", &[("mask-position", "left top")]); + expect("mask-center", &[("mask-position", "center")]); + expect("mask-repeat-round", &[("mask-repeat", "round")]); + expect("mask-clip-padding", &[("mask-clip", "padding-box")]); + expect("mask-no-clip", &[("mask-clip", "no-clip")]); + expect("mask-origin-view", &[("mask-origin", "view-box")]); + expect("mask-[url(/m.svg)]", &[("mask-image", "url(/m.svg)")]); + expect( + "mask-[linear-gradient(black,transparent)]", + &[("mask-image", "linear-gradient(black,transparent)")], + ); + expect("mask-[50%]", &[("mask-position", "50%")]); + expect("mask-[10px]", &[("mask-size", "10px")]); + expect("mask-size-[10px_20px]", &[("mask-size", "10px 20px")]); + expect("mask-position-(--p)", &[("mask-position", "var(--p)")]); + for class in [ + "mask-linear-50", + "mask-t-from-50%", + "mask-radial-from-50%", + "mask-conic-from-50", + "mask-[]", + "mask-size-", + ] { + assert_eq!(declarations_of(class), None, "{class}"); + } + } } diff --git a/libs/extractor/src/tailwind_theme.rs b/libs/extractor/src/tailwind_theme.rs index 9946ded00..928609351 100644 --- a/libs/extractor/src/tailwind_theme.rs +++ b/libs/extractor/src/tailwind_theme.rs @@ -38,7 +38,6 @@ static UNSUPPORTED: &[(&str, &str)] = &[ ("min-w-", "container"), ("w-", "container"), ("inset-shadow-", "inset-shadow"), - ("perspective-", "perspective"), ("aspect-", "aspect"), ]; @@ -350,7 +349,6 @@ mod tests { ]) ); assert_eq!(crate::tailwind::declarations_of("-content-auto"), None); - assert_eq!(crate::tailwind::declarations_of("tab-4"), None); set_tailwind_css(""); assert_eq!(crate::tailwind::declarations_of("content-auto"), None); } @@ -366,4 +364,86 @@ mod tests { assert!(crate::tailwind::declarations_of("font-thin").is_some()); set_tailwind_css(""); } + + #[test] + #[serial] + fn theme_calls_in_arbitrary_values_read_the_theme() { + set_tailwind_css( + "@theme { --color-brand: #0af; --spacing: 0.3rem; --spacing-gutter: 3rem; --radius-pill: 40px; --font-display: Inter; }", + ); + assert_eq!( + written("bg-[theme(--color-brand)]"), + pairs(&[("background-color", "#0af")]) + ); + assert_eq!( + written("bg-[theme(--color-red-500)]"), + pairs(&[("background-color", "oklch(63.7% 0.237 25.331)")]) + ); + assert_eq!( + written("w-[theme(--spacing-gutter)]"), + pairs(&[("width", "3rem")]) + ); + assert_eq!( + written("w-[theme(--spacing-4)]"), + pairs(&[("width", "1.2rem")]) + ); + assert_eq!( + written("w-[calc(theme(--spacing)*2)]"), + pairs(&[("width", "calc(0.3rem * 2)")]) + ); + assert_eq!( + written("rounded-[theme(--radius-pill)]"), + pairs(&[("border-radius", "40px")]) + ); + assert_eq!( + written("p-[theme(--spacing)_theme(--spacing-gutter)]"), + pairs(&[("padding", "0.3rem 3rem")]) + ); + for class in [ + "bg-[theme(--color-nothing)]", + "bg-[theme(colors.red.500)]", + "w-[theme(--nothing)]", + "w-[theme(--spacing-gutter]", + "w-[mytheme(--spacing-gutter)]", + ] { + assert_eq!(crate::tailwind::declarations_of(class), None, "{class}"); + } + set_tailwind_css(""); + assert_eq!( + written("w-[theme(--spacing)]"), + pairs(&[("width", "0.25rem")]) + ); + } + + #[test] + #[serial] + fn container_widths_come_from_the_theme() { + set_tailwind_css("@theme { --container-card: 22rem; }"); + let class = crate::tailwind::parse_class("@card:p-4").unwrap(); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("@container(width>=22rem)") + ); + assert!(crate::tailwind::parse_class("@sm:p-4").is_some()); + set_tailwind_css("@theme { --container-*: initial; }"); + assert_eq!(crate::tailwind::parse_class("@sm:p-4"), None); + set_tailwind_css(""); + } + + #[test] + fn merge_drops_what_a_later_class_sets_again() { + use crate::tailwind::merge_classes; + assert_eq!(merge_classes("p-4 m-2 p-2"), "m-2 p-2"); + assert_eq!(merge_classes("p-4 card p-2"), "card p-2"); + assert_eq!(merge_classes("px-4 p-2"), "px-4 p-2"); + assert_eq!(merge_classes("p-2 p-4!"), "p-2 p-4!"); + assert_eq!(merge_classes("hover:p-2 p-4 hover:p-1"), "p-4 hover:p-1"); + assert_eq!(merge_classes("md:p-2 p-4"), "md:p-2 p-4"); + assert_eq!(merge_classes("text-sm text-lg"), "text-lg"); + assert_eq!(merge_classes("card card"), "card card"); + assert_eq!(merge_classes(" "), ""); + } } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..834a846d0 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -23,7 +23,7 @@ use crate::extractor::{ extract_style_from_styled::{extract_style_from_styled, take_styled_modifiers}, }; use crate::gen_class_name::{gen_class_names, merge_expression_for_class_name}; -use crate::prop_modify_utils::{convert_class_name, modify_prop_object, modify_props}; +use crate::prop_modify_utils::{compile_cva, convert_class_name, modify_prop_object, modify_props}; use crate::stylex::{ StylexDynamicInfo, StylexFunction, StylexNamespaceValue, create_theme_class, css_variable_block, css_variable_rules, define_vars_variable, variable_values, @@ -207,6 +207,28 @@ fn is_alias(declarator: &VariableDeclarator<'_>, compiled: &FxHashSet) - /// The reads of `names` a program keeps outside the types it erases; with /// `scoping`, only those no binding of the program declares +/// Whether the file has a `className` that is more than a string +#[derive(Default)] +struct DynamicClassNames { + found: bool, +} + +impl<'a> oxc_ast_visit::Visit<'a> for DynamicClassNames { + fn visit_jsx_attribute(&mut self, it: &oxc_ast::ast::JSXAttribute<'a>) { + if let oxc_ast::ast::JSXAttributeName::Identifier(name) = &it.name + && name.name == "className" + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &it.value + && container + .expression + .as_expression() + .is_some_and(|expression| !matches!(expression, Expression::StringLiteral(_))) + { + self.found = true; + } + oxc_ast_visit::walk::walk_jsx_attribute(self, it); + } +} + struct CompiledReads<'s> { names: &'s FxHashSet, scoping: Option<&'s oxc_semantic::Scoping>, @@ -451,17 +473,20 @@ impl<'a> DevupVisitor<'a> { /// Whether `program` imports a style function whose result it may bind: /// `css()` gives a class, `keyframes()` a name fn binds_style_results(&self, program: &Program<'a>) -> bool { - program.body.iter().any(|statement| { - matches!(statement, Statement::ImportDeclaration(import) - if (import.source.value == self.package - || import.source.value == self.compat_package.as_str()) - && import.specifiers.iter().flatten().any(|specifier| match specifier { - ImportSpecifier(specifier) => { - matches!(specifier.imported.name().as_str(), "css" | "keyframes") - } - _ => true, - })) - }) + let mut class_names = DynamicClassNames::default(); + oxc_ast_visit::Visit::visit_program(&mut class_names, program); + class_names.found + || program.body.iter().any(|statement| { + matches!(statement, Statement::ImportDeclaration(import) + if (import.source.value == self.package + || import.source.value == self.compat_package.as_str()) + && import.specifiers.iter().flatten().any(|specifier| match specifier { + ImportSpecifier(specifier) => { + matches!(specifier.imported.name().as_str(), "css" | "keyframes") + } + _ => true, + })) + }) } /// Put the spreads of `props` that may change when read again, and the @@ -1919,6 +1944,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_call_expression(&mut self, it: &mut CallExpression<'a>) { + if matches!(&it.callee, Expression::Identifier(callee) if callee.name == "cva") { + let styles = compile_cva(&self.ast, it, self.split_filename.as_deref()); + self.styles.extend(styles); + } let jsx = if let Expression::Identifier(ident) = &it.callee { self.jsx_imports.get(ident.name.as_str()).cloned() } else if let Some(name) = &self.jsx_object @@ -2163,8 +2192,25 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .filter(|util| matches!(util.as_ref(), UtilType::Css | UtilType::Keyframes)) .and_then(|util| Some((util, self.style_values.constant(&it.id)?))); + let class_text = match &it.init { + Some(Expression::StringLiteral(text)) => Some(text.value.to_string()), + Some(Expression::TemplateLiteral(template)) if template.expressions.is_empty() => { + template + .quasis + .first() + .and_then(|quasi| quasi.value.cooked.as_ref()) + .map(ToString::to_string) + } + _ => None, + } + .zip(self.style_values.constant(&it.id)); + walk_variable_declarator(self, it); + if let Some((text, symbol)) = class_text { + self.style_values.insert_text(symbol, text); + } + if let Some((util, symbol)) = style_result && let Some(Expression::StringLiteral(value)) = &it.init { @@ -2588,6 +2634,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } + for attr in attrs.iter_mut() { + if let Attribute(attr) = attr + && let Identifier(name) = &attr.name + && name.name == "className" + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &mut attr.value + && let Some(expression) = container.expression.as_expression_mut() + { + self.style_values.read_in_class(&self.ast, expression); + } + } + if let ParsedStyleOrder::Conditional { condition, consequent, diff --git a/packages/bun-plugin/src/__tests__/tailwind-watch.test.ts b/packages/bun-plugin/src/__tests__/tailwind-watch.test.ts new file mode 100644 index 000000000..e85bc69d4 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/tailwind-watch.test.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' + +import { createTailwindWatcher } from '../tailwind-watch' + +let directory: string + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'devup-bun-tailwind-')) +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +async function waitFor(check: () => boolean) { + for (let attempt = 0; attempt < 100 && !check(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +describe('createTailwindWatcher', () => { + it('reloads when a Tailwind CSS file changes and watches what it gives', async () => { + const first = join(directory, 'first.css') + const second = join(directory, 'second.css') + writeFileSync(first, 'a') + writeFileSync(second, 'b') + const reload = mock(async () => [first, second]) + const watchFiles = createTailwindWatcher(reload) + + watchFiles([first]) + watchFiles([first]) + writeFileSync(first, 'changed') + await waitFor(() => reload.mock.calls.length > 0) + expect(reload).toHaveBeenCalled() + + const calls = reload.mock.calls.length + writeFileSync(second, 'changed') + await waitFor(() => reload.mock.calls.length > calls) + expect(reload.mock.calls.length).toBeGreaterThan(calls) + }) + + it('stops watching a file whose watcher fails and watches it again later', () => { + const watchers: EventEmitter[] = [] + const close = mock() + const watchFile = mock(() => { + const watcher = Object.assign(new EventEmitter(), { close }) + watchers.push(watcher) + return watcher + }) as unknown as typeof import('node:fs').watch + const watchFiles = createTailwindWatcher(async () => [], watchFile) + + watchFiles(['a.css']) + watchFiles(['a.css']) + expect(watchFile).toHaveBeenCalledTimes(1) + watchers[0].emit('error', new Error('EPERM')) + expect(close).toHaveBeenCalledTimes(1) + watchFiles(['a.css']) + expect(watchFile).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 1de92e728..acf6e7243 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -9,6 +9,7 @@ import { type CustomShorthands, loadDevupConfig, mergeImportAliases, + tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' import { @@ -24,6 +25,7 @@ import { import { plugin } from 'bun' import { cssDirName, cssNamespace, resolveCssId } from './css-id' +import { createTailwindWatcher } from './tailwind-watch' const libPackage = '@devup-ui/react' const devupFile = 'devup.json' @@ -36,11 +38,13 @@ export interface DevupUIBunPluginOptions { shorthands?: CustomShorthands } -async function writeDataFiles() { +async function writeDataFiles(): Promise { let theme = {} + let tailwindFiles: string[] = [] try { const config = await loadDevupConfig(devupFile) theme = withTailwindCss(config.theme ?? {}, config) + tailwindFiles = tailwindCssFiles(config) } catch { // Error reading devup.json, use empty theme } @@ -57,8 +61,11 @@ async function writeDataFiles() { await mkdir(cssDir, { recursive: true }) } await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') + return tailwindFiles } +const watchTailwindCss = createTailwindWatcher(writeDataFiles) + async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { registerShorthands(shorthands ?? {}) setModuleResolver(createModuleResolver()) @@ -69,7 +76,7 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { createCompatTypes(importAliases), 'utf-8', ) - await writeDataFiles() + watchTailwindCss(await writeDataFiles()) } // Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a diff --git a/packages/bun-plugin/src/tailwind-watch.ts b/packages/bun-plugin/src/tailwind-watch.ts new file mode 100644 index 000000000..ce05da080 --- /dev/null +++ b/packages/bun-plugin/src/tailwind-watch.ts @@ -0,0 +1,28 @@ +import { watch } from 'node:fs' + +/** + * Watches the files of the project's Tailwind CSS, and calls `reload` when one + * changes. `reload` gives the files to watch from then on; each file is + * watched once. + */ +export function createTailwindWatcher( + reload: () => Promise, + watchFile: typeof watch = watch, +) { + const watched = new Set() + function watchFiles(files: string[]) { + for (const file of files) { + if (watched.has(file)) continue + watched.add(file) + const watcher = watchFile(file, { persistent: false }, () => { + void reload().then(watchFiles) + }) + // A file that is removed or cannot be watched no longer rebuilds + watcher.on('error', () => { + watcher.close() + watched.delete(file) + }) + } + } + return watchFiles +} diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts index b57d9ef2a..05349c288 100644 --- a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -69,6 +69,7 @@ async function extractedCssDirIn(checkout: string) { await plugin.setup({ transform, modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), } as unknown as RsbuildSetupContext) codeExtractSpy.mockClear() diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index b7f2c97f4..d57d2c7af 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -42,6 +42,7 @@ function createSetupContext( return { transform: mock(), modifyRsbuildConfig: mock(), + modifyRspackConfig: mock(), renderChunk: mock(), generateBundle: mock(), closeBundle: mock(), @@ -390,6 +391,56 @@ const App = () => `, map: undefined, }) }) + it('reads the Tailwind CSS again when it changes and compiles every module again', async () => { + const readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue( + '@theme { --color-brand: #0af; }', + ) + const statSpy = spyOn(fsPromises, 'stat') + readFileSpy.mockResolvedValue( + JSON.stringify({ tailwind: { css: 'tailwind.css' } }), + ) + existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') + const modifyRspackConfig = mock() + const transform = mock() + await DevupUI({}).setup( + createSetupContext({ modifyRspackConfig, transform }), + ) + + const config: { plugins?: { apply: (compiler: unknown) => void }[] } = {} + modifyRspackConfig.mock.calls[0][0](config) + const tapPromise = mock() + config.plugins?.[0].apply({ hooks: { watchRun: { tapPromise } } }) + const watchRun = tapPromise.mock.calls[0][1] as () => Promise + registerThemeSpy.mockClear() + + statSpy.mockResolvedValueOnce({ mtimeMs: 1 } as fs.Stats) + await watchRun() + expect(registerThemeSpy).not.toHaveBeenCalled() + statSpy.mockResolvedValueOnce({ mtimeMs: 1 } as fs.Stats) + await watchRun() + expect(registerThemeSpy).not.toHaveBeenCalled() + statSpy.mockResolvedValueOnce({ mtimeMs: 2 } as fs.Stats) + await watchRun() + expect(registerThemeSpy).toHaveBeenCalledWith({ + tailwindCss: '@theme { --color-brand: #0af; }', + }) + statSpy.mockRejectedValueOnce(new Error('gone')) + registerThemeSpy.mockClear() + await watchRun() + expect(registerThemeSpy).toHaveBeenCalled() + + codeExtractSpy.mockReturnValue(createCodeExtractResult()) + const addDependency = mock() + await transform.mock.calls[1][1]({ + code: "import { Box } from '@devup-ui/react'", + resourcePath: 'src/App.tsx', + addDependency, + }) + expect(addDependency).toHaveBeenCalledWith(resolve('tailwind.css')) + statSpy.mockRestore() + readFileSyncSpy.mockRestore() + readFileSpy.mockResolvedValue('{}') + }) it('registers the text of the Tailwind CSS with the theme', async () => { const readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue( '@theme { --color-brand: #0af; }', diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index db21d5cf3..edf41bcba 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, stat, writeFile } from 'node:fs/promises' import { basename, dirname, join, relative, resolve } from 'node:path' import { @@ -15,6 +15,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' import { @@ -73,12 +74,14 @@ async function writeDataFiles( DevupUIRsbuildPluginOptions, 'extractCss' | 'debug' | 'include' >, -) { +): Promise { + let tailwindFiles: string[] = [] try { const config = await loadDevupConfig(options.devupFile) const theme = config.theme ?? {} registerTheme(withTailwindCss(theme, config)) + tailwindFiles = tailwindCssFiles(config) const interfaceCode = getThemeInterface( ...createThemeInterfaceArgs(options.package), ) @@ -102,6 +105,7 @@ async function writeDataFiles( ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) : Promise.resolve(), ]) + return tailwindFiles } export const DevupUI = ({ @@ -137,7 +141,7 @@ export const DevupUI = ({ 'utf-8', ) - await writeDataFiles({ + let tailwindFiles = await writeDataFiles({ package: libPackage, cssDir, devupFile, @@ -146,6 +150,48 @@ export const DevupUI = ({ }) if (!extractCss) return + // The project's Tailwind CSS defines what a Tailwind class compiles to: + // read it again when it changes, and compile every module again + api.modifyRspackConfig((config) => { + config.plugins ??= [] + config.plugins.push({ + apply(compiler: { + hooks: { + watchRun: { + tapPromise: (name: string, fn: () => Promise) => void + } + } + }) { + let stamp: string | undefined + compiler.hooks.watchRun.tapPromise( + 'DevupUIRsbuildPlugin', + async () => { + const current = ( + await Promise.all( + tailwindFiles.map((file) => + stat(file).then( + (stats) => stats.mtimeMs, + () => 0, + ), + ), + ) + ).join() + if (stamp !== undefined && stamp !== current) { + tailwindFiles = await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + } + stamp = current + }, + ) + }, + }) + }) + // Atom-level hoisting (opt-in via `atomHoist`). Configured BEFORE any // transform so atoms receive global (shared) class names. Composes with // single-importer collapse (both keyed by the canonical bucket). rsbuild @@ -260,6 +306,7 @@ export const DevupUI = ({ async ({ code, resourcePath, addDependency }) => { if (createNodeModulesExcludeRegex(include).test(resourcePath)) return code + for (const file of tailwindFiles) addDependency(file) // The stylesheet import is emitted relative to the importing file, as // in the next/webpack/vite loaders. An absolute cssDir would bake this // checkout's path into the emitted module, so byte-identical sources From 8104fdec92c6bb73155f3d4d5f179a3d40eda3fe Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 4 Oct 2026 03:18:43 +0900 Subject: [PATCH 2/2] test: cover parenthesized cn arguments, nested --value parentheses and unknown rotate axes Refs #687 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 1 + libs/extractor/src/tailwind_custom.rs | 4 +++- libs/extractor/src/tailwind_misc.rs | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 9cdd3f7fb..e1164dfa4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14744,6 +14744,7 @@ const on = false; "", r#"
"# )] + #[case("", r#"
"#)] #[case( "", r#"-
"# diff --git a/libs/extractor/src/tailwind_custom.rs b/libs/extractor/src/tailwind_custom.rs index 9bc22d2c4..378574d68 100644 --- a/libs/extractor/src/tailwind_custom.rs +++ b/libs/extractor/src/tailwind_custom.rs @@ -273,7 +273,7 @@ mod tests { #[serial] fn functional_utilities_read_their_value_and_modifier() { set_tailwind_css( - "@theme { --tab-size-github: 8; --color-brand: #0af; --spacing-gutter: 3rem; } @utility tab-* { tab-size: --value(--tab-size-*, integer, [integer]); } @utility pct-* { width: --value(percentage); } @utility lit-* { display: --value('inline-flex', 'grid'); } @utility num-* { line-height: --value(number); } @utility bg-glow-* { background: --value(--color-*, [color]); box-shadow: 0 0 8px --modifier([percentage], number); } @utility gap-* { gap: --value(--spacing-*); } @utility len-* { width: --value([length]); } @utility pc-* { height: --value([percentage]); } @utility many-* { a: --value([url], [number], [unknown]); } @utility q-* { content: \"--value(x\"; } @utility space-* { gap: --spacing(4); } @utility bad-* { width: --value(unknown-type); } @utility mod-* { width: --value(integer); color: --modifier('red'); }", + "@theme { --tab-size-github: 8; --color-brand: #0af; --spacing-gutter: 3rem; } @utility tab-* { tab-size: --value(--tab-size-*, integer, [integer]); } @utility pct-* { width: --value(percentage); } @utility lit-* { display: --value('inline-flex', 'grid'); } @utility num-* { line-height: --value(number); } @utility bg-glow-* { background: --value(--color-*, [color]); box-shadow: 0 0 8px --modifier([percentage], number); } @utility gap-* { gap: --value(--spacing-*); } @utility len-* { width: --value([length]); } @utility pc-* { height: --value([percentage]); } @utility many-* { a: --value([url], [number], [unknown]); } @utility nest-* { a: --value(fn(x), integer); } @utility nest-* { a: --value(fn(x), integer); } @utility q-* { content: \"--value(x\"; } @utility space-* { gap: --spacing(4); } @utility bad-* { width: --value(unknown-type); } @utility mod-* { width: --value(integer); color: --modifier('red'); }", ); assert_eq!(written("tab-4"), pairs(&[("tab-size", "4")])); assert_eq!(written("tab-github"), pairs(&[("tab-size", "8")])); @@ -306,6 +306,8 @@ mod tests { assert_eq!(declarations_of("len-3px"), None); assert_eq!(written("many-[1]"), pairs(&[("a", "1")])); assert_eq!(declarations_of("many-[x]"), None); + assert_eq!(written("nest-4"), pairs(&[("a", "4")])); + assert_eq!(written("nest-4"), pairs(&[("a", "4")])); for class in ["space-4", "bad-4", "q-1"] { assert_eq!(declarations_of(class), None, "{class}"); } diff --git a/libs/extractor/src/tailwind_misc.rs b/libs/extractor/src/tailwind_misc.rs index 0ba4cbdc0..ff76871ce 100644 --- a/libs/extractor/src/tailwind_misc.rs +++ b/libs/extractor/src/tailwind_misc.rs @@ -879,6 +879,10 @@ mod tests { #[case("-indent")] #[case("-table-auto")] #[case("antialiased-")] + #[case("rotate-w-45")] + #[case("rotate-x")] + #[case("rotate-w-45")] + #[case("rotate-x")] fn unknown_utilities_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); }