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/.changepacks/changepack_log_tailwind_v4_theme.json b/.changepacks/changepack_log_tailwind_v4_theme.json new file mode 100644 index 000000000..b9ac3422e --- /dev/null +++ b/.changepacks/changepack_log_tailwind_v4_theme.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Tailwind classes in a className follow Tailwind CSS v4: colors are the v4 default palette (oklch) and the colors and shadows of devup.json theme are usable as Tailwind theme values (bg-primary, text-text, shadow-card, as var(--token)); the opacity modifier (bg-red-500/50, /[71.37%], /(--alpha)) mixes with color-mix as v4 does; a trailing or leading ! makes every declaration important; blur, brightness, contrast, grayscale, hue-rotate, invert, saturate, sepia and their backdrop-* forms compose through Tailwind's custom properties instead of overwriting filter and backdrop-filter; gradients use bg-linear-*, bg-conic-* and bg-radial-* with from-*, via-* and to-* composing through the gradient variables (bg-gradient-to-* is not defined by v4 and stays as written); animate-* brings the @keyframes it names and transition, duration, delay and ease follow v4; space-* and divide-* apply to the children like v4 (:where(& > :not(:last-child))); @property rules are emitted only for the custom properties a class uses. Anything the build cannot reproduce exactly (drop-shadow-*) stays as written in the className. No new build errors", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_tailwind_v4_utilities.json b/.changepacks/changepack_log_tailwind_v4_utilities.json new file mode 100644 index 000000000..f43315b48 --- /dev/null +++ b/.changepacks/changepack_log_tailwind_v4_utilities.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Tailwind classes in a className read the Tailwind CSS of the project: the file that imports tailwindcss (the tailwind.css option of devup.json, else a conventional path) gives @theme variables (colors, spacing, shadows, blurs, radii, easings, animations with their @keyframes, default transitions, breakpoints), @utility utilities and @custom-variant variants, and the Vite, webpack and Next.js plugins read it again when it or a CSS file it imports changes. Anything that cannot be read exactly, and the classes of namespaces the build does not read, stay as written. More of Tailwind CSS v4 compiles: ring-*, inset-ring-*, ring-offset-* and drop-shadow-* compose with shadows and filters, borders and outlines draw in --tw-border-style and --tw-outline-style (a border now shows its style), line-clamp, text decoration, border-spacing, logical and scroll spacing, tables, perspective, font-stretch and more; the arguments of clsx(), classnames() and classNames() and [...].join(' ') compile in place. Radii follow v4 (rounded-sm is 0.25rem, rounded-full is calc(infinity * 1px)). 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/LeftMenu.tsx b/apps/landing/src/app/(detail)/docs/LeftMenu.tsx index 85250e4ff..0eeebcf36 100644 --- a/apps/landing/src/app/(detail)/docs/LeftMenu.tsx +++ b/apps/landing/src/app/(detail)/docs/LeftMenu.tsx @@ -54,6 +54,10 @@ export function LeftMenu() { to: '/docs/migration/stylex', children: 'StyleX', }, + { + to: '/docs/migration/tailwind', + children: 'Tailwind CSS', + }, ]} > Migration diff --git a/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx b/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx new file mode 100644 index 000000000..aca2ed1bf --- /dev/null +++ b/apps/landing/src/app/(detail)/docs/migration/tailwind/page.mdx @@ -0,0 +1,110 @@ +export const metadata = { + title: 'Migration', + alternates: { + canonical: '/docs/migration/tailwind', + }, +} + +# Tailwind CSS + +Tailwind CSS classes written in the `className` of a Devup UI component (`Box`, `Flex`, `Text` ...) are compiled at build time to Devup UI classes, the same way style props are. Nothing runs in the browser, and no Tailwind build is needed for those classes. The classes follow Tailwind CSS **v4**. + +```tsx + +``` + +## When a class compiles + +A class compiles only when the build understands **all** of it: every variant (`md:`, `hover:`, `data-[state=open]:`, `[&>*]:` ...), the utility, its value and its modifiers (`/50`, `!`). Then it becomes the declarations Tailwind CSS v4 writes for it. + +Every other class **stays in the `className` exactly as written**, so whatever else defines it keeps working. This covers: + +- your own classes (`card`) and plugin classes (`prose`), +- a class Tailwind CSS v4 does not define (`bg-gradient-to-r`, which v3 had), +- a class the build cannot reproduce exactly (see [what stays as written](#what-stays-as-written)), +- a class whose theme value the project's Tailwind CSS defines in a way the build cannot read. + +A class is never rewritten into something different from what Tailwind CSS would generate: it compiles to the declarations of v4 or it is left alone. A class with a Tailwind name compiles with the Tailwind meaning, so do not give a class of your own the name of a Tailwind utility. + +## Where classes are read + +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, +- 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. + +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. + +## Theme values + +Colors from Tailwind CSS v4's default palette (`bg-red-500`, in `oklch`) and the values of `devup.json` work together: + +- `devup.json` colors are Tailwind colors named by their token: `bg-primary`, `text-text`, `border-primary/50` become `var(--primary)`, `var(--text)` and so on. When a name is both a `devup.json` color and a palette color, the `devup.json` color is used. +- `devup.json` shadow tokens work as `shadow-` (`shadow-card`). +- Breakpoints are the ones of Devup UI (`theme.breakpoints` in `devup.json`), not Tailwind's: `sm:`, `md:`, `lg:`, `xl:` and `2xl:` are the first five breakpoints after the base. Change them in `devup.json`. +- `dark:` follows the Devup UI theme (`prefers-color-scheme` until a theme is set with `data-theme`). + +## Tailwind CSS definitions + +The CSS file of your project that imports `tailwindcss` can define more, as it does for Tailwind itself: + +```css +@import 'tailwindcss'; + +@theme { + --color-brand: oklch(60% 0.2 250); + --spacing-gutter: 3rem; + --breakpoint-3xl: 120rem; + --animate-wiggle: wiggle 1s ease-in-out infinite; + @keyframes wiggle { + 50% { + transform: rotate(3deg); + } + } +} + +@utility content-auto { + content-visibility: auto; +} + +@custom-variant theme-midnight (&:where([data-theme='midnight'] *)); +``` + +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`. + +```json +{ + "tailwind": { "css": "src/styles/tailwind.css" } +} +``` + +Use `"css": false` to read none. + +What is read: + +- `@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 + +Tailwind classes and Devup UI props both become one atomic class per property in the same stylesheet, so they follow the same rules as two props: + +- The Tailwind classes of an element compile first, its Devup props after them. For the **same property** (`p-4` and `p={12}`), the prop comes later in the stylesheet and wins, whichever comes first in the JSX. +- Properties are ordered with shorthands before longhands. `bg="green"` is `background`, while `bg-red-500` is `background-color`, which comes after it, so the Tailwind class wins there. Use one of the two for a property when both would set it. +- Responsive and `hover:` styles come after the base styles at the same property, as with props. + +## What stays as written + +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/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap index d47163a56..ee141312e 100644 --- a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap +++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap @@ -61,4 +61,5 @@ Theme { }, length: {}, shadows: {}, + tailwind_css: "", } diff --git a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap index daca7d9dd..d1d972a7a 100644 --- a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap +++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap @@ -892,4 +892,5 @@ Theme { }, length: {}, shadows: {}, + tailwind_css: "", } diff --git a/e2e/exported-routes.ts b/e2e/exported-routes.ts index 5c8edd2bc..bc0b228e6 100644 --- a/e2e/exported-routes.ts +++ b/e2e/exported-routes.ts @@ -83,6 +83,7 @@ export const EXPECTED_EXPORTED_ROUTES = [ '/docs/migration/overview', '/docs/migration/styled-components', '/docs/migration/stylex', + '/docs/migration/tailwind', '/docs/migration/vanilla-extract', '/docs/overview', '/docs/quick-start', diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..090497b79 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -12,6 +12,7 @@ pub mod optimize_value; pub mod rm_css_comment; mod selector_separator; pub mod style_selector; +pub mod tailwind_definitions; pub mod theme_tokens; pub mod utils; diff --git a/libs/css/src/tailwind_definitions.rs b/libs/css/src/tailwind_definitions.rs new file mode 100644 index 000000000..b52e26726 --- /dev/null +++ b/libs/css/src/tailwind_definitions.rs @@ -0,0 +1,643 @@ +//! The definitions a project's Tailwind CSS file makes: `@theme` variables, +//! `@utility` utilities and `@custom-variant` variants, read at build time +//! +//! A definition that cannot be read exactly is left out, so the classes that +//! use it stay as written; a file that cannot be read at all defines nothing. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{LazyLock, RwLock}; + +use crate::style_selector::AtRuleKind; + +/// A `@custom-variant`: the selectors the element matches, any one of them +/// (`&` being the element), and the at-rule the styles go in +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CustomVariant { + pub selectors: Vec, + 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, + /// Namespaces `--ns-*: initial` empties, such as `--color-` + resets: BTreeSet, + /// `--*: initial` empties every namespace + reset_all: bool, + utilities: BTreeMap>, + /// `@utility name-*`, by `name`: declarations that read `--value()` and + /// `--modifier()` + functional: BTreeMap>, + variants: BTreeMap, + keyframes: BTreeMap, +} + +static DEFINITIONS: LazyLock> = + LazyLock::new(|| RwLock::new(Definitions::default())); + +/// One statement of a stylesheet +#[derive(Debug, PartialEq, Eq)] +enum Item { + /// `prelude;`, which a declaration is as well + Statement(String), + /// `prelude { body }` + Block(String, String), +} + +/// `css` without its comments +fn strip_comments(css: &str) -> String { + let mut out = String::with_capacity(css.len()); + let mut rest = css; + while let Some(start) = rest.find("/*") { + out.push_str(&rest[..start]); + rest = rest[start + 2..] + .find("*/") + .map_or("", |end| &rest[start + 2 + end + 2..]); + } + out.push_str(rest); + out +} + +/// The statements and blocks of `css`, `None` when its brackets or strings do +/// not balance +fn parse_items(css: &str) -> Option> { + let mut items = Vec::new(); + let mut prelude = String::new(); + let mut body = String::new(); + let mut depth = 0usize; + let mut parens = 0usize; + let mut quote: Option = None; + for c in css.chars() { + let target = if depth == 0 { &mut prelude } else { &mut body }; + if let Some(open) = quote { + target.push(c); + if c == open { + quote = None; + } + continue; + } + match c { + '"' | '\'' => { + quote = Some(c); + target.push(c); + } + '(' => { + parens += 1; + target.push(c); + } + ')' => { + parens = parens.checked_sub(1)?; + target.push(c); + } + '{' if parens == 0 => { + depth += 1; + if depth > 1 { + body.push(c); + } + } + '}' if parens == 0 => { + depth = depth.checked_sub(1)?; + if depth == 0 { + items.push(Item::Block( + prelude.trim().to_string(), + std::mem::take(&mut body), + )); + prelude.clear(); + } else { + body.push(c); + } + } + ';' if parens == 0 && depth == 0 => { + items.push(Item::Statement(prelude.trim().to_string())); + prelude.clear(); + } + _ => target.push(c), + } + } + let balanced = depth == 0 && parens == 0 && quote.is_none(); + if balanced && !prelude.trim().is_empty() { + items.push(Item::Statement(prelude.trim().to_string())); + } + balanced.then_some(items) +} + +/// `value` without the whitespace CSS does not need +fn compact(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut pending_space = false; + for c in value.trim().chars() { + if c.is_whitespace() { + pending_space = true; + continue; + } + let tight = matches!(c, '{' | '}' | ':' | ';' | ','); + if pending_space && !tight && !out.ends_with(['{', '}', ':', ';', ',']) { + out.push(' '); + } + pending_space = false; + out.push(c); + } + 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(':')?; + let name = name.trim(); + let value = value.trim(); + let valid = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'*')); + (valid && !value.is_empty()).then(|| (name.to_string(), value.to_string())) +} + +/// The declarations of `body`, `None` when it holds anything else +fn declarations(body: &str) -> Option> { + parse_items(body)? + .into_iter() + .map(|item| match item { + Item::Statement(statement) => declaration(&statement), + Item::Block(..) => None, + }) + .collect() +} + +impl Definitions { + fn read_theme(&mut self, body: &str) -> Option<()> { + for item in parse_items(body)? { + match item { + Item::Statement(statement) => { + let Some((name, value)) = declaration(&statement) else { + continue; + }; + self.theme_variable(name, value); + } + Item::Block(prelude, block) => { + if let Some(name) = prelude.strip_prefix("@keyframes") { + let name = name.trim().to_string(); + let rule = compact(&format!("@keyframes {name}{{{block}}}")); + self.keyframes.insert(name, rule); + } + } + } + } + Some(()) + } + + fn theme_variable(&mut self, name: String, value: String) { + if !name.starts_with("--") { + return; + } + if value == "initial" { + if name == "--*" { + self.reset_all = true; + } else if let Some(namespace) = name.strip_suffix('*') { + self.resets.insert(namespace.to_string()); + } + return; + } + if name.contains('*') || value.contains("theme(") { + return; + } + self.theme.insert(name, value); + } + + fn read_utility(&mut self, name: &str, body: &str) { + if name.is_empty() || name.contains(char::is_whitespace) { + return; + } + 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, "")); + let definition = definition.trim(); + let variant = match body { + None => definition + .strip_prefix('(') + .and_then(|inner| inner.strip_suffix(')')) + .map(selector_variant), + Some(body) if definition.is_empty() => block_variant(body), + Some(_) => None, + }; + if let Some(variant) = variant.filter(|_| !name.is_empty()) { + self.variants.insert(name.to_string(), variant); + } + } +} + +/// The selectors of `(…)`, which must hold the element as `&` +fn selector_variant(selectors: &str) -> CustomVariant { + CustomVariant { + selectors: split_selectors(selectors), + at_rule: None, + } +} + +/// `selectors` split at the commas outside any bracket +fn split_selectors(selectors: &str) -> Vec { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (index, c) in selectors.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(selectors[start..index].trim().to_string()); + start = index + 1; + } + _ => {} + } + } + parts.push(selectors[start..].trim().to_string()); + parts +} + +/// The variant a `@custom-variant name { … }` block holds: `@slot;` alone, +/// or one selector or at-rule around it +fn block_variant(body: &str) -> Option { + match parse_items(body)?.as_slice() { + [Item::Statement(statement)] if statement == "@slot" => Some(selector_variant("&")), + [Item::Block(prelude, inner)] if compact(inner).trim_end_matches(';') == "@slot" => { + around_slot(prelude) + } + _ => None, + } +} + +/// The variant `prelude { @slot; }` is: an at-rule or a selector +fn around_slot(prelude: &str) -> Option { + for (name, kind) in [ + ("@media", AtRuleKind::Media), + ("@supports", AtRuleKind::Supports), + ] { + if let Some(query) = prelude.strip_prefix(name) { + return Some(CustomVariant { + selectors: Vec::new(), + at_rule: Some((kind, compact(query))), + }); + } + } + (!prelude.starts_with('@')).then(|| selector_variant(prelude)) +} +/// `var(--name)` written whole in `value` +fn sole_variable(value: &str) -> Option<&str> { + value + .strip_prefix("var(") + .and_then(|inner| inner.strip_suffix(')')) + .map(str::trim) + .filter(|name| name.starts_with("--") && !name.contains([',', ' ', '('])) +} + +fn read(css: &str) -> Option { + let css = strip_comments(css); + let mut definitions = Definitions::default(); + for item in parse_items(&css)? { + match item { + Item::Block(prelude, body) => { + let (at, rest) = prelude + .split_once(char::is_whitespace) + .unwrap_or((prelude.as_str(), "")); + match at { + "@theme" => definitions.read_theme(&body)?, + "@utility" => definitions.read_utility(rest.trim(), &body), + "@custom-variant" => definitions.read_custom_variant(rest, Some(&body)), + _ => {} + } + } + Item::Statement(statement) => { + if let Some(rest) = statement.strip_prefix("@custom-variant") { + definitions.read_custom_variant(rest, None); + } + } + } + } + definitions.resolve_references(); + Some(definitions) +} + +impl Definitions { + /// A theme variable that is another one (`--color-brand: var(--color-mint)`) + /// is that one's value, as the theme variables are not written out + fn resolve_references(&mut self) { + for _ in 0..8 { + let snapshot = self.theme.clone(); + for value in self.theme.values_mut() { + if let Some(target) = sole_variable(value).and_then(|name| snapshot.get(name)) { + value.clone_from(target); + } + } + } + } +} + +/// Reads the definitions of the Tailwind CSS in `css`, which replace the ones +/// read before; `""` clears them +pub fn set_tailwind_css(css: &str) { + let definitions = read(css).unwrap_or_default(); + if let Ok(mut registry) = DEFINITIONS.write() { + *registry = definitions; + } +} + +fn with(read: impl FnOnce(&Definitions) -> Option) -> Option { + DEFINITIONS.read().ok().and_then(|registry| read(®istry)) +} + +/// The value of the theme variable `---` +#[must_use] +pub fn theme_value(namespace: &str, key: &str) -> Option { + with(|definitions| { + definitions + .theme + .get(&format!("--{namespace}-{key}")) + .cloned() + }) +} + +/// The value of the theme variable `--` +#[must_use] +pub fn theme_variable(name: &str) -> Option { + with(|definitions| definitions.theme.get(&format!("--{name}")).cloned()) +} + +/// Whether the theme empties the default values of `---*` +#[must_use] +pub fn namespace_is_reset(namespace: &str) -> bool { + with(|definitions| { + Some(definitions.reset_all || definitions.resets.contains(&format!("--{namespace}-"))) + }) + .unwrap_or(false) +} + +/// The keys of the theme variables `---` +#[must_use] +pub fn theme_keys(namespace: &str) -> Vec { + let prefix = format!("--{namespace}-"); + with(|definitions| { + Some( + definitions + .theme + .keys() + .filter_map(|name| name.strip_prefix(&prefix).map(str::to_string)) + .collect(), + ) + }) + .unwrap_or_default() +} + +/// The body of the `@utility` named `name` +#[must_use] +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 { + with(|definitions| definitions.variants.get(name).cloned()) +} + +/// The `@keyframes` rule the theme defines for `name` +#[must_use] +pub fn theme_keyframes(name: &str) -> Option { + with(|definitions| definitions.keyframes.get(name).cloned()) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn theme_variables_are_read_by_namespace() { + set_tailwind_css( + "@import \"tailwindcss\";\n/* c */ @theme { --color-brand: #f00; --spacing-gutter: 3rem; --breakpoint-3xl: 120rem; --font-display: 'Inter', sans-serif; }", + ); + assert_eq!(theme_value("color", "brand").as_deref(), Some("#f00")); + assert_eq!(theme_value("spacing", "gutter").as_deref(), Some("3rem")); + assert_eq!(theme_value("breakpoint", "3xl").as_deref(), Some("120rem")); + assert_eq!( + theme_value("font", "display").as_deref(), + Some("'Inter', sans-serif") + ); + assert_eq!(theme_value("color", "nothing"), None); + assert_eq!(theme_variable("color-brand").as_deref(), Some("#f00")); + assert_eq!(theme_keys("color"), vec!["brand"]); + set_tailwind_css(""); + assert_eq!(theme_value("color", "brand"), None); + assert_eq!(theme_keys("color"), Vec::::new()); + } + + #[test] + #[serial] + fn theme_options_and_resets() { + set_tailwind_css( + "@theme inline { --color-*: initial; --color-a: red; --color-b: var(--color-a); --color-c: var(--color-b); --x-*: theme(foo); --color-d: theme(--color-a); }", + ); + assert!(namespace_is_reset("color")); + assert!(!namespace_is_reset("shadow")); + assert_eq!(theme_value("color", "b").as_deref(), Some("red")); + assert_eq!(theme_value("color", "c").as_deref(), Some("red")); + assert_eq!(theme_value("color", "d"), None); + set_tailwind_css("@theme { --*: initial; --color-a: red }"); + assert!(namespace_is_reset("shadow")); + set_tailwind_css("@theme { --color-a: var(--other, red); color: red; --: 1; --a b: 2 }"); + assert_eq!( + theme_value("color", "a").as_deref(), + Some("var(--other, red)") + ); + assert!(!namespace_is_reset("color")); + set_tailwind_css(""); + } + + #[test] + #[serial] + fn theme_keyframes_are_kept_compact() { + set_tailwind_css( + "@theme { --animate-wiggle: wiggle 1s ease-in-out infinite;\n @keyframes wiggle { 0%, 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } } @media print {} }", + ); + assert_eq!( + theme_keyframes("wiggle").as_deref(), + Some( + "@keyframes wiggle{0%,100%{transform:rotate(-3deg);}50%{transform:rotate(3deg);}}" + ) + ); + assert_eq!(theme_keyframes("other"), None); + set_tailwind_css(""); + } + + #[test] + #[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 } @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![ + 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(""); + } + + #[test] + #[serial] + fn custom_variants() { + set_tailwind_css( + "@custom-variant theme-midnight (&:where([data-theme=\"midnight\"] *));\n@custom-variant dark { &:where(.dark, .dark *) { @slot; } }\n@custom-variant pointer-hover { @media (hover: hover) { @slot; } }\n@custom-variant wide { @supports (display: grid) { @slot; } }\n@custom-variant any (&:hover, &:focus);\n@custom-variant plain { @slot; }\n@custom-variant bad { &:hover { color: red; } }\n@custom-variant worse { &:hover { &:focus { @slot; } } @slot; }\n@custom-variant bare;\n@custom-variant (&:hover);\n@custom-variant named-block extra { &:hover { @slot; } }\n@custom-variant at { @layer x { @slot; } }", + ); + assert_eq!( + custom_variant("theme-midnight").unwrap().selectors, + vec!["&:where([data-theme=\"midnight\"] *)"] + ); + assert_eq!( + custom_variant("dark").unwrap().selectors, + vec!["&:where(.dark, .dark *)"] + ); + assert_eq!( + custom_variant("pointer-hover").unwrap(), + CustomVariant { + selectors: vec![], + at_rule: Some((AtRuleKind::Media, "(hover:hover)".to_string())), + } + ); + assert_eq!( + custom_variant("wide").unwrap().at_rule, + Some((AtRuleKind::Supports, "(display:grid)".to_string())) + ); + assert_eq!( + custom_variant("any").unwrap().selectors, + vec!["&:hover", "&:focus"] + ); + assert_eq!(custom_variant("plain").unwrap().selectors, vec!["&"]); + for name in ["bad", "worse", "bare", "named-block", "at", ""] { + assert_eq!(custom_variant(name), None, "{name}"); + } + set_tailwind_css(""); + } + + #[test] + #[serial] + fn a_file_that_cannot_be_read_defines_nothing() { + for css in [ + "@theme { --color-a: red;", + "@theme { --color-a: red; }}", + "@theme { --color-a: url(\"a }", + "@utility a { b: c) }", + "@theme { --color-a: (red }", + ] { + set_tailwind_css("@theme { --color-keep: red }"); + set_tailwind_css(css); + assert_eq!(theme_value("color", "a"), None, "{css}"); + assert_eq!(theme_value("color", "keep"), None, "{css}"); + } + set_tailwind_css(""); + } + + #[test] + fn items_and_text_helpers() { + assert_eq!(strip_comments("a/* x */b/* open"), "ab"); + assert_eq!( + parse_items("@import \"a;b\"; .a { b: c }").unwrap(), + vec![ + Item::Statement("@import \"a;b\"".to_string()), + Item::Block(".a".to_string(), " b: c ".to_string()), + ] + ); + assert_eq!( + parse_items("a: b").unwrap(), + vec![Item::Statement("a: b".to_string())] + ); + assert_eq!(parse_items(" ").unwrap(), vec![]); + assert_eq!(compact(" a { b : c ; } "), "a{b:c;}"); + assert_eq!(compact("a b"), "a b"); + assert_eq!(split_selectors("&:a(b, c), &d"), vec!["&:a(b, c)", "&d"]); + assert_eq!(sole_variable("var(--a)"), Some("--a")); + assert_eq!(sole_variable("var(--a, red)"), None); + assert_eq!(sole_variable("red"), None); + assert_eq!(declaration("a"), None); + assert_eq!(declaration("a:"), None); + assert_eq!(declaration("a b: c"), None); + assert_eq!(declarations("a: b; c { d: e }"), None); + } +} diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 3b2346aed..9902a1c96 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{LazyLock, RwLock}; #[derive(Default, Debug)] @@ -6,6 +6,7 @@ struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, typography: Vec, + colors: BTreeSet, } static TOKEN_REGISTRY: LazyLock> = @@ -27,6 +28,28 @@ pub fn set_typography_keys(keys: Vec) { } } +/// Registers the names of the theme's colors, which Tailwind classes such as +/// `bg-primary` can use +pub fn set_color_tokens(names: BTreeSet) { + if let Ok(mut registry) = TOKEN_REGISTRY.write() { + registry.colors = names; + } +} + +/// Whether the theme defines the color `name`, a CSS variable of that name +pub fn is_color_token(name: &str) -> bool { + TOKEN_REGISTRY + .read() + .is_ok_and(|registry| registry.colors.contains(name)) +} + +/// Whether the theme defines the shadow `name`, a CSS variable of that name +pub fn is_shadow_token(name: &str) -> bool { + TOKEN_REGISTRY + .read() + .is_ok_and(|registry| registry.shadow.contains_key(name)) +} + /// Typography names defined by the registered theme, so a dynamic /// `typography` value under a selector can resolve to one class per name. pub fn get_typography_keys() -> Vec { @@ -122,6 +145,23 @@ mod tests { assert_eq!(get_typography_keys(), Vec::::new()); } + #[test] + #[serial] + fn test_color_and_shadow_tokens() { + set_color_tokens(BTreeSet::from(["primary".to_string()])); + let mut shadow = BTreeMap::new(); + shadow.insert("card".to_string(), vec![0]); + set_theme_token_levels(BTreeMap::new(), shadow); + + assert!(is_color_token("primary")); + assert!(!is_color_token("card")); + assert!(is_shadow_token("card")); + assert!(!is_shadow_token("primary")); + set_color_tokens(BTreeSet::new()); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + assert!(!is_color_token("primary")); + } + #[test] fn test_is_responsive_theme_token_without_prefix() { let value = std::hint::black_box("noprefix"); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..e1164dfa4 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15,6 +15,13 @@ mod source_map; mod style_values; mod stylex; mod tailwind; +mod tailwind_children; +mod tailwind_color; +mod tailwind_custom; +mod tailwind_effects; +mod tailwind_misc; +mod tailwind_motion; +mod tailwind_theme; mod util_type; mod utils; mod vanilla_extract; @@ -14639,6 +14646,172 @@ 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( + "", + r#"
"# + )] + #[case( + "", + r#"
"# + )] + #[case( + "", + r#"
"# + )] + #[case( + "", + r#"
"# + )] + #[case( + "", + r#"
"# + )] + #[case( + "", + "
" + )] + #[case( + "const base = 'p-4 card';\n", + r#"const base = "p-4 card"; +
"# + )] + #[case( + "const base = `p-4`;\nconst on = false;\n", + r#"const base = `p-4`; +const on = false; +
"# + )] + #[case( + "let base = 'p-4';\n", + r#"-let base = "p-4"; +
"# + )] + #[case( + "const base = 'p-4'\nconst f = (base) => ", + "-const base = \"p-4\";\nconst f = (base) =>
" + )] + #[case( + "const base = `p-4 ${x}`;\n", + r#"-const base = `p-4 ${x}`; +
"# + )] + #[case("", r#"
"#)] + #[case( + "", + r#"
"# + )] + #[case( + "", + r#"
"# + )] + #[case("", r#"
"#)] + #[case( + "", + r#"-
"# + )] + #[case( + "", + r#"-
"# + )] + #[case( + "", + r#"
"# + )] + #[case( + "", + r#"-
"# + )] + #[case( + "const base = 'p-4';\n", + r#"const base = "p-4"; +
"# + )] + #[case( + "", + r#"-
"# + )] + #[case( + "", + r#"-
"# + )] + #[case( + "", + r#"-
"# + )] + #[case( + "", + "-
" + )] + #[case( + "", + "-
" + )] + #[case( + "", + "-
" + )] + #[serial] + fn test_tailwind_helper_calls(#[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(); + let expected = expected.strip_prefix('-').map_or_else( + || format!("import \"@devup-ui/core/devup-ui.css\";\n{expected};\n"), + |code| format!("{code};\n"), + ); + assert_eq!(output.code, expected); + } #[test] #[serial] fn test_tailwind_classname_extraction() { diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index b8b1ce7da..4b2a8c588 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -3,18 +3,21 @@ use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::style_property::StyleProperty; use crate::gen_class_name::gen_class_names; use crate::gen_style::gen_styles; -use crate::tailwind::{PROPERTY_RULES, PROPERTY_RULES_FILE, parse_class}; +use crate::tailwind::{PROPERTY_RULES_FILE, 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}; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ - Expression, IdentifierName, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, - LogicalOperator, ObjectPropertyKind, PropertyKey, PropertyKind, StaticMemberExpression, Str, - StringLiteral, TemplateElement, TemplateElementValue, TemplateLiteral, + Argument, ArrayExpressionElement, CallExpression, Expression, IdentifierName, JSXAttributeItem, + JSXAttributeName, JSXAttributeValue, LogicalOperator, ObjectPropertyKind, PropertyKey, + PropertyKind, StaticMemberExpression, Str, StringLiteral, TemplateElement, + TemplateElementValue, TemplateLiteral, }; use oxc_ast::builder::AstBuilder; use oxc_span::SPAN; +use std::borrow::Cow; +use std::collections::BTreeSet; /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. @@ -314,7 +317,7 @@ pub fn get_class_name_expression<'a>( style_order, filename, styles: Vec::new(), - property_rules: false, + rules: BTreeSet::new(), }; let compiled = class_name_prop .as_ref() @@ -358,25 +361,106 @@ 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> { style_order: Option, filename: Option<&'f str>, styles: Vec, - /// Whether a compiled class uses the custom properties Tailwind registers - property_rules: bool, + /// The global rules the compiled classes need, @property and @keyframes + rules: BTreeSet>, } impl TailwindClassName<'_> { fn into_styles(self) -> Vec { let mut styles = self.styles; - if self.property_rules { - styles.push(ExtractStyleValue::Css(ExtractCss { - css: PROPERTY_RULES.to_string(), + styles.extend(self.rules.into_iter().map(|rule| { + ExtractStyleValue::Css(ExtractCss { + css: rule.to_string(), file: PROPERTY_RULES_FILE.to_string(), - })); - } + }) + })); styles } @@ -393,7 +477,7 @@ impl TailwindClassName<'_> { let whole = (!open_start || start > 0) && (!open_end || end < text.len()); if let Some(tailwind) = whole.then(|| parse_class(class)).flatten() { compiled.push_str(&text[written..start]); - self.property_rules |= tailwind.uses_properties(); + self.rules.extend(tailwind.rules()); let mut separator = ""; for mut style in tailwind.styles() { if let Some(order) = self.style_order { @@ -472,11 +556,105 @@ impl TailwindClassName<'_> { ast_builder, )) } - // Variables, calls and the like only hold their classes at runtime + Expression::CallExpression(call) => self.compile_call(ast_builder, call), + Expression::ArrayExpression(array) => { + let mut compiled = array.clone_in(ast_builder.allocator()); + let mut changed = false; + for element in &mut compiled.elements { + if let Some(expression) = element + .as_expression() + .and_then(|expression| self.compile_expression(ast_builder, expression)) + { + *element = ArrayExpressionElement::from(expression); + changed = true; + } + } + changed.then_some(Expression::ArrayExpression(compiled)) + } + Expression::ObjectExpression(object) => { + let mut compiled = object.clone_in(ast_builder.allocator()); + let mut changed = false; + for property in &mut compiled.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + let PropertyKey::StringLiteral(key) = &property.key else { + continue; + }; + if property.computed { + continue; + } + if let Some(text) = self.compile_text(&key.value, false, false) { + property.key = PropertyKey::from(Expression::new_string_literal( + SPAN, + Str::from_in(&text, ast_builder.allocator()), + None, + ast_builder, + )); + changed = true; + } + } + changed.then_some(Expression::ObjectExpression(compiled)) + } + // Variables and the like only hold their classes at runtime _ => None, } } + /// `clsx(...)`, `classnames(...)` and `[...].join(" ")`, which join the + /// classes they are given as they are, so each class compiles where it is + /// written + fn compile_call<'a>( + &mut self, + ast_builder: &AstBuilder<'a>, + call: &CallExpression<'a>, + ) -> Option> { + 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") => + { + for argument in &mut compiled.arguments { + if let Some(expression) = argument + .as_expression() + .and_then(|expression| self.compile_expression(ast_builder, expression)) + { + *argument = Argument::from(expression); + changed = true; + } + } + } + Expression::StaticMemberExpression(member) + if member.property.name == "join" + && matches!(call.arguments.as_slice(), [Argument::StringLiteral(separator)] if separator.value == " ") => + { + let array = self.compile_expression(ast_builder, &member.object)?; + let mut callee = member.clone_in(ast_builder.allocator()); + callee.object = array; + compiled.callee = Expression::StaticMemberExpression(callee); + changed = true; + } + _ => {} + } + changed + .then(|| Expression::CallExpression(oxc_allocator::Box::new_in(compiled, ast_builder))) + } + fn compile_template<'a>( &mut self, ast_builder: &AstBuilder<'a>, @@ -530,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/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap index 8baa4082f..e860275c9 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_basic.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap index 833afb951..1304e0565 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_complex.snap @@ -3,6 +3,46 @@ source: libs/extractor/src/tailwind.rs expression: sort_styles(styles) --- { + Static( + ExtractStaticStyle { + property: "--tw-duration", + value: "200ms", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 10px 15px -3px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 4px 6px -4px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -36,7 +76,17 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "box-shadow", - value: "0 10px 15px -3px rgb(0 0 0 / .1),0 4px 6px -4px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -55,8 +105,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", + property: "display", + value: "flex", level: 0, selector: None, style_order: None, @@ -65,8 +115,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "display", - value: "flex", + property: "justify-content", + value: "space-between", level: 0, selector: None, style_order: None, @@ -75,8 +125,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "justify-content", - value: "space-between", + property: "padding", + value: "1rem", level: 0, selector: None, style_order: None, @@ -85,8 +135,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "padding", - value: "1rem", + property: "transition-duration", + value: "200ms", level: 0, selector: None, style_order: None, @@ -96,7 +146,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "transition-duration", - value: "200ms", + value: "var(--tw-duration,150ms)", level: 0, selector: None, style_order: None, @@ -113,4 +163,14 @@ expression: sort_styles(styles) layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-timing-function", + value: "var(--tw-ease,cubic-bezier(.4,0,.2,1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), } diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap index 429e176b5..a3b0ca15a 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_responsive.snap @@ -6,7 +6,7 @@ expression: sort_styles(styles) Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 1, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap index 1513c6081..854d09b33 100644 --- a/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tailwind__tests__parse_tailwind_to_styles_variants.snap @@ -3,10 +3,24 @@ source: libs/extractor/src/tailwind.rs expression: sort_styles(styles) --- { + Static( + ExtractStaticStyle { + property: "--tw-outline-style", + value: "none", + level: 0, + selector: Some( + Selector( + "&:focus", + ), + ), + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { @@ -39,8 +53,8 @@ expression: sort_styles(styles) ), Static( ExtractStaticStyle { - property: "outline", - value: "2px solid transparent", + property: "outline-style", + value: "none", level: 0, selector: Some( Selector( diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap index 405c547e6..eecec6300 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_extraction.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap index 419ad0a7d..6c84131ee 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_devup_props.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap index 494738fe9..f0b2b97df 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_classname_with_variants.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap index 23f909148..0822cf9a9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_conditional_class_name.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap index 5e6e6069a..9044aaced 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_full_integration.snap @@ -14,6 +14,36 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 20px 25px -5px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 8px 10px -6px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "align-items", @@ -37,13 +67,9 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#1F2937", + value: "#FFF", level: 0, - selector: Some( - Selector( - ":root[data-theme=dark] &", - ), - ), + selector: None, style_order: None, layer: None, }, @@ -51,9 +77,13 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(27.8% .033 256.848)", level: 0, - selector: None, + selector: Some( + Selector( + ":root[data-theme=dark] &", + ), + ), style_order: None, layer: None, }, @@ -61,7 +91,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#FFF", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -71,7 +101,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-color", - value: "#374151", + value: "oklch(37.3% .034 259.733)", level: 0, selector: Some( Selector( @@ -85,7 +115,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-color", - value: "#E5E7EB", + value: "oklch(92.8% .006 264.531)", level: 0, selector: None, style_order: None, @@ -105,7 +135,37 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "border-radius", - value: "9999px", + value: "calc(infinity * 1px)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "border-top-style", + value: "var(--tw-border-style)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "border-top-width", + value: "1px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: None, style_order: None, @@ -115,7 +175,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -134,8 +194,8 @@ ToBTreeSet { ), Static( ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", + property: "color", + value: "#FFF", level: 0, selector: None, style_order: None, @@ -145,9 +205,13 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#111827", + value: "#FFF", level: 0, - selector: None, + selector: Some( + Selector( + ":root[data-theme=dark] &", + ), + ), style_order: None, layer: None, }, @@ -155,7 +219,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#374151", + value: "oklch(21% .034 264.665)", level: 0, selector: None, style_order: None, @@ -165,7 +229,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#6B7280", + value: "oklch(37.3% .034 259.733)", level: 0, selector: None, style_order: None, @@ -175,13 +239,9 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#9CA3AF", + value: "oklch(55.1% .027 264.364)", level: 0, - selector: Some( - Selector( - ":root[data-theme=dark] &", - ), - ), + selector: None, style_order: None, layer: None, }, @@ -189,7 +249,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#D1D5DB", + value: "oklch(70.7% .022 261.325)", level: 0, selector: Some( Selector( @@ -203,17 +263,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#FFF", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - Static( - ExtractStaticStyle { - property: "color", - value: "#FFF", + value: "oklch(87.2% .01 258.338)", level: 0, selector: Some( Selector( @@ -436,6 +486,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-duration", + value: "var(--tw-duration,150ms)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "transition-property", @@ -446,6 +506,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "transition-timing-function", + value: "var(--tw-ease,cubic-bezier(.4,0,.2,1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "width", @@ -458,10 +528,64 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-duration{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ease{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-leading{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", file: "@devup-ui/tailwind", }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n// Card component with comprehensive Tailwind usage\nexport const Card = () =>
\n
\n
\n A\n
\n
\n Title\n Subtitle\n
\n
\n
\n \n Content goes here with multiple Tailwind utilities combined.\n \n
\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap index 757687d59..4838b53c4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_mixed_with_devup_responsive.snap @@ -4,6 +4,36 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 20px 25px -5px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 8px 10px -6px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: Some( + At { + kind: Media, + query: "(hover:hover)", + selector: Some( + "&:hover", + ), + outer: [], + file: None, + }, + ), + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "--tw-shadow", + value: "0 4px 6px -1px var(--tw-shadow-color,rgb(0 0 0 / .1)),0 2px 4px -2px var(--tw-shadow-color,rgb(0 0 0 / .1))", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "background", @@ -47,7 +77,17 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 20px 25px -5px rgb(0 0 0 / .1),0 8px 10px -6px rgb(0 0 0 / .1)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "box-shadow", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( At { @@ -64,16 +104,6 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "box-shadow", - value: "0 4px 6px -1px rgb(0 0 0 / .1),0 2px 4px -2px rgb(0 0 0 / .1)", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), Static( ExtractStaticStyle { property: "display", @@ -124,6 +154,42 @@ ToBTreeSet { layer: None, }, ), + Css( + ExtractCss { + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap index b318297c2..9606d3cd4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_negative_values.snap @@ -36,7 +36,13 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap index 20f358653..f6a66523a 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_function_call_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap index 3d4bcb1db..3d1f6db09 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_logical_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap index 65171e2bd..5078bf11f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_parenthesized_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap index 96adc8f43..fb37f986e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_style_order.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#22C55E", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: Some( @@ -19,7 +19,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(72.3% .219 149.579)", level: 0, selector: None, style_order: Some( diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap index 96f1eff06..f503be46e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_template_literal_with_variable_expression.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#EF4444", + value: "oklch(63.7% .237 25.331)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap index 2c1b7dcdc..146ffd849 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_all_variant_types.snap @@ -4,6 +4,20 @@ expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@dev --- ToBTreeSet { styles: { + Static( + ExtractStaticStyle { + property: "--tw-ring-shadow", + value: "var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor)", + level: 0, + selector: Some( + Selector( + "&:focus", + ), + ), + style_order: None, + layer: None, + }, + ), Static( ExtractStaticStyle { property: "--tw-scale-x", @@ -49,7 +63,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#111827", + value: "oklch(21% .034 264.665)", level: 0, selector: Some( Selector( @@ -63,7 +77,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#1F2937", + value: "oklch(27.8% .033 256.848)", level: 0, selector: Some( At { @@ -83,7 +97,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "background-color", - value: "#3B82F6", + value: "oklch(62.3% .214 259.815)", level: 0, selector: Some( At { @@ -103,7 +117,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "box-shadow", - value: "0 0 0 2px var(--tw-ring-color)", + value: "var(--tw-inset-shadow,0 0 #0000),var(--tw-inset-ring-shadow,0 0 #0000),var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)", level: 0, selector: Some( Selector( @@ -204,10 +218,70 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-color{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-inset{syntax:\"*\";inherits:false}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-offset-width{syntax:\"\";inherits:false;initial-value:0px}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", + file: "@devup-ui/tailwind", + }, + ), + Css( + ExtractCss { + css: "@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}", file: "@devup-ui/tailwind", }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap index 20c321150..925089cf7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_as_prop.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#111827", + value: "oklch(21% .034 264.665)", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap index 0a19f6804..51bbbbde7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap +++ b/libs/extractor/src/snapshots/extractor__tests__tailwind_with_conditional_template_literal.snap @@ -7,7 +7,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#22C55E", + value: "oklch(62.3% .214 259.815)", level: 0, selector: None, style_order: None, @@ -17,7 +17,7 @@ ToBTreeSet { Static( ExtractStaticStyle { property: "color", - value: "#3B82F6", + value: "oklch(72.3% .219 149.579)", level: 0, selector: None, style_order: None, @@ -56,7 +56,7 @@ ToBTreeSet { ), Css( ExtractCss { - css: "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", + css: "@property --tw-leading{syntax:\"*\";inherits:false}", file: "@devup-ui/tailwind", }, ), diff --git a/libs/extractor/src/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 d683b5b7c..27491cf48 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -10,7 +10,9 @@ #![allow(clippy::collapsible_if)] use std::borrow::Cow; +use std::collections::BTreeSet; +use css::tailwind_definitions::{custom_variant, theme_value, theme_variable}; use css::{ at_rule::split_at_rule_key, style_selector::{AtRuleKind, StyleSelector}, @@ -18,6 +20,13 @@ use css::{ use phf::{phf_map, phf_set}; use crate::extract_style::extract_static_style::ExtractStaticStyle; +use crate::tailwind_children::{CHILDREN, children_utility}; +use crate::tailwind_color::color_value; +use crate::tailwind_custom::{custom_declarations, functional_declarations}; +use crate::tailwind_effects::effect_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}; /// A declaration: property and value pub type Declaration = (Cow<'static, str>, Cow<'static, str>); @@ -138,25 +147,123 @@ static ARIA_VARIANTS: phf::Set<&'static str> = phf_set! { "selected", }; +/// `@property` rule of a custom property: with an initial value, or without +macro_rules! property { + ($name:literal, $syntax:literal, $initial:literal) => { + ( + $name, + concat!( + "@property ", + $name, + "{syntax:\"", + $syntax, + "\";inherits:false;initial-value:", + $initial, + "}" + ), + ) + }; + ($name:literal) => { + ( + $name, + concat!("@property ", $name, "{syntax:\"*\";inherits:false}"), + ) + }; +} + /// The custom properties Tailwind registers, so that each element starts from /// their initial value instead of inheriting its parent's -pub const PROPERTY_RULES: &str = concat!( - "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", - "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", - "@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}", - "@property --tw-rotate-x{syntax:\"*\";inherits:false}", - "@property --tw-rotate-y{syntax:\"*\";inherits:false}", - "@property --tw-rotate-z{syntax:\"*\";inherits:false}", - "@property --tw-skew-x{syntax:\"*\";inherits:false}", - "@property --tw-skew-y{syntax:\"*\";inherits:false}", - "@property --tw-leading{syntax:\"*\";inherits:false}", - "@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}", - "@property --tw-scroll-snap-strictness{syntax:\"*\";inherits:false;initial-value:proximity}", -); - -/// The stylesheet keeps [`PROPERTY_RULES`] under this name, which no source +static PROPERTIES: &[(&str, &str)] = &[ + property!("--tw-translate-x", "*", "0"), + property!("--tw-translate-y", "*", "0"), + property!("--tw-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"), + property!("--tw-rotate-x"), + property!("--tw-rotate-y"), + property!("--tw-rotate-z"), + property!("--tw-skew-x"), + property!("--tw-skew-y"), + property!("--tw-leading"), + property!("--tw-content", "*", "\"\""), + property!("--tw-scroll-snap-strictness", "*", "proximity"), + property!("--tw-blur"), + property!("--tw-brightness"), + property!("--tw-contrast"), + property!("--tw-grayscale"), + property!("--tw-hue-rotate"), + property!("--tw-invert"), + property!("--tw-saturate"), + property!("--tw-sepia"), + property!("--tw-drop-shadow"), + property!("--tw-backdrop-blur"), + property!("--tw-backdrop-brightness"), + property!("--tw-backdrop-contrast"), + property!("--tw-backdrop-grayscale"), + property!("--tw-backdrop-hue-rotate"), + property!("--tw-backdrop-invert"), + property!("--tw-backdrop-opacity"), + property!("--tw-backdrop-saturate"), + property!("--tw-backdrop-sepia"), + property!("--tw-gradient-position"), + property!("--tw-gradient-from", "", "#0000"), + property!("--tw-gradient-via", "", "#0000"), + property!("--tw-gradient-to", "", "#0000"), + property!("--tw-gradient-stops"), + property!("--tw-gradient-via-stops"), + property!("--tw-gradient-from-position", "", "0%"), + property!("--tw-gradient-via-position", "", "50%"), + property!("--tw-gradient-to-position", "", "100%"), + property!("--tw-space-x-reverse", "*", "0"), + property!("--tw-space-y-reverse", "*", "0"), + property!("--tw-divide-x-reverse", "*", "0"), + property!("--tw-divide-y-reverse", "*", "0"), + property!("--tw-border-style", "*", "solid"), + property!("--tw-outline-style", "*", "solid"), + property!("--tw-border-spacing-x", "", "0"), + property!("--tw-border-spacing-y", "", "0"), + property!("--tw-duration"), + property!("--tw-ease"), + 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"), + property!("--tw-inset-shadow", "*", "0 0 #0000"), + property!("--tw-inset-shadow-color"), + property!("--tw-inset-shadow-alpha", "", "100%"), + property!("--tw-ring-color"), + property!("--tw-ring-inset"), + property!("--tw-ring-offset-width", "", "0px"), + property!("--tw-ring-offset-color", "*", "#fff"), + property!("--tw-ring-offset-shadow", "*", "0 0 #0000"), + property!("--tw-ring-shadow", "*", "0 0 #0000"), + property!("--tw-inset-ring-color"), + property!("--tw-inset-ring-shadow", "*", "0 0 #0000"), +]; + +/// Whether `value` reads the custom property `name`, whole and not as the +/// start of a longer name +fn reads(value: &str, name: &str) -> bool { + value.match_indices(name).any(|(start, _)| { + !value[start + name.len()..] + .starts_with(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} +/// The stylesheet keeps the rules of [`TailwindClass::rules`] under this name, which no source /// file has, so that no file's update removes them pub const PROPERTY_RULES_FILE: &str = "@devup-ui/tailwind"; @@ -207,35 +314,134 @@ 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()) + }) }) }) } - /// Whether the declarations use the custom properties of [`PROPERTY_RULES`] + /// 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] - pub fn uses_properties(&self) -> bool { - self.declarations + pub fn rules(&self) -> Vec> { + let mut rules: Vec> = PROPERTIES .iter() - .any(|(property, value)| property.starts_with("--tw-") || value.contains("var(--tw-")) + .filter(|(name, _)| { + self.all_declarations() + .any(|(property, value)| property == name || reads(value, name)) + }) + .map(|&(_, rule)| Cow::Borrowed(rule)) + .collect(); + 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) { + return Some(Variant { + selectors: custom.selectors.into_iter().map(Cow::Owned).collect(), + at_rule: custom + .at_rule + .map(|(kind, query)| (kind, Cow::Owned(query))), + ..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, ..Variant::default() }); } + if let Some(width) = theme_value("breakpoint", name) { + return Some(Variant::at_rule( + AtRuleKind::Media, + format!("(min-width:{width})"), + )); + } if let Some(&query) = MEDIA_VARIANTS.get(name) { return Some(Variant::at_rule(AtRuleKind::Media, query)); } @@ -397,283 +603,8 @@ fn arbitrary_variant(inner: &str) -> Option { }) } -/// Tailwind color values -static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { - // Inherit/Current/Transparent - "inherit" => "inherit", - "current" => "currentColor", - "transparent" => "transparent", - // Black and White - "black" => "#000", - "white" => "#fff", - // Slate - "slate-50" => "#f8fafc", - "slate-100" => "#f1f5f9", - "slate-200" => "#e2e8f0", - "slate-300" => "#cbd5e1", - "slate-400" => "#94a3b8", - "slate-500" => "#64748b", - "slate-600" => "#475569", - "slate-700" => "#334155", - "slate-800" => "#1e293b", - "slate-900" => "#0f172a", - "slate-950" => "#020617", - // Gray - "gray-50" => "#f9fafb", - "gray-100" => "#f3f4f6", - "gray-200" => "#e5e7eb", - "gray-300" => "#d1d5db", - "gray-400" => "#9ca3af", - "gray-500" => "#6b7280", - "gray-600" => "#4b5563", - "gray-700" => "#374151", - "gray-800" => "#1f2937", - "gray-900" => "#111827", - "gray-950" => "#030712", - // Zinc - "zinc-50" => "#fafafa", - "zinc-100" => "#f4f4f5", - "zinc-200" => "#e4e4e7", - "zinc-300" => "#d4d4d8", - "zinc-400" => "#a1a1aa", - "zinc-500" => "#71717a", - "zinc-600" => "#52525b", - "zinc-700" => "#3f3f46", - "zinc-800" => "#27272a", - "zinc-900" => "#18181b", - "zinc-950" => "#09090b", - // Neutral - "neutral-50" => "#fafafa", - "neutral-100" => "#f5f5f5", - "neutral-200" => "#e5e5e5", - "neutral-300" => "#d4d4d4", - "neutral-400" => "#a3a3a3", - "neutral-500" => "#737373", - "neutral-600" => "#525252", - "neutral-700" => "#404040", - "neutral-800" => "#262626", - "neutral-900" => "#171717", - "neutral-950" => "#0a0a0a", - // Stone - "stone-50" => "#fafaf9", - "stone-100" => "#f5f5f4", - "stone-200" => "#e7e5e4", - "stone-300" => "#d6d3d1", - "stone-400" => "#a8a29e", - "stone-500" => "#78716c", - "stone-600" => "#57534e", - "stone-700" => "#44403c", - "stone-800" => "#292524", - "stone-900" => "#1c1917", - "stone-950" => "#0c0a09", - // Red - "red-50" => "#fef2f2", - "red-100" => "#fee2e2", - "red-200" => "#fecaca", - "red-300" => "#fca5a5", - "red-400" => "#f87171", - "red-500" => "#ef4444", - "red-600" => "#dc2626", - "red-700" => "#b91c1c", - "red-800" => "#991b1b", - "red-900" => "#7f1d1d", - "red-950" => "#450a0a", - // Orange - "orange-50" => "#fff7ed", - "orange-100" => "#ffedd5", - "orange-200" => "#fed7aa", - "orange-300" => "#fdba74", - "orange-400" => "#fb923c", - "orange-500" => "#f97316", - "orange-600" => "#ea580c", - "orange-700" => "#c2410c", - "orange-800" => "#9a3412", - "orange-900" => "#7c2d12", - "orange-950" => "#431407", - // Amber - "amber-50" => "#fffbeb", - "amber-100" => "#fef3c7", - "amber-200" => "#fde68a", - "amber-300" => "#fcd34d", - "amber-400" => "#fbbf24", - "amber-500" => "#f59e0b", - "amber-600" => "#d97706", - "amber-700" => "#b45309", - "amber-800" => "#92400e", - "amber-900" => "#78350f", - "amber-950" => "#451a03", - // Yellow - "yellow-50" => "#fefce8", - "yellow-100" => "#fef9c3", - "yellow-200" => "#fef08a", - "yellow-300" => "#fde047", - "yellow-400" => "#facc15", - "yellow-500" => "#eab308", - "yellow-600" => "#ca8a04", - "yellow-700" => "#a16207", - "yellow-800" => "#854d0e", - "yellow-900" => "#713f12", - "yellow-950" => "#422006", - // Lime - "lime-50" => "#f7fee7", - "lime-100" => "#ecfccb", - "lime-200" => "#d9f99d", - "lime-300" => "#bef264", - "lime-400" => "#a3e635", - "lime-500" => "#84cc16", - "lime-600" => "#65a30d", - "lime-700" => "#4d7c0f", - "lime-800" => "#3f6212", - "lime-900" => "#365314", - "lime-950" => "#1a2e05", - // Green - "green-50" => "#f0fdf4", - "green-100" => "#dcfce7", - "green-200" => "#bbf7d0", - "green-300" => "#86efac", - "green-400" => "#4ade80", - "green-500" => "#22c55e", - "green-600" => "#16a34a", - "green-700" => "#15803d", - "green-800" => "#166534", - "green-900" => "#14532d", - "green-950" => "#052e16", - // Emerald - "emerald-50" => "#ecfdf5", - "emerald-100" => "#d1fae5", - "emerald-200" => "#a7f3d0", - "emerald-300" => "#6ee7b7", - "emerald-400" => "#34d399", - "emerald-500" => "#10b981", - "emerald-600" => "#059669", - "emerald-700" => "#047857", - "emerald-800" => "#065f46", - "emerald-900" => "#064e3b", - "emerald-950" => "#022c22", - // Teal - "teal-50" => "#f0fdfa", - "teal-100" => "#ccfbf1", - "teal-200" => "#99f6e4", - "teal-300" => "#5eead4", - "teal-400" => "#2dd4bf", - "teal-500" => "#14b8a6", - "teal-600" => "#0d9488", - "teal-700" => "#0f766e", - "teal-800" => "#115e59", - "teal-900" => "#134e4a", - "teal-950" => "#042f2e", - // Cyan - "cyan-50" => "#ecfeff", - "cyan-100" => "#cffafe", - "cyan-200" => "#a5f3fc", - "cyan-300" => "#67e8f9", - "cyan-400" => "#22d3ee", - "cyan-500" => "#06b6d4", - "cyan-600" => "#0891b2", - "cyan-700" => "#0e7490", - "cyan-800" => "#155e75", - "cyan-900" => "#164e63", - "cyan-950" => "#083344", - // Sky - "sky-50" => "#f0f9ff", - "sky-100" => "#e0f2fe", - "sky-200" => "#bae6fd", - "sky-300" => "#7dd3fc", - "sky-400" => "#38bdf8", - "sky-500" => "#0ea5e9", - "sky-600" => "#0284c7", - "sky-700" => "#0369a1", - "sky-800" => "#075985", - "sky-900" => "#0c4a6e", - "sky-950" => "#082f49", - // Blue - "blue-50" => "#eff6ff", - "blue-100" => "#dbeafe", - "blue-200" => "#bfdbfe", - "blue-300" => "#93c5fd", - "blue-400" => "#60a5fa", - "blue-500" => "#3b82f6", - "blue-600" => "#2563eb", - "blue-700" => "#1d4ed8", - "blue-800" => "#1e40af", - "blue-900" => "#1e3a8a", - "blue-950" => "#172554", - // Indigo - "indigo-50" => "#eef2ff", - "indigo-100" => "#e0e7ff", - "indigo-200" => "#c7d2fe", - "indigo-300" => "#a5b4fc", - "indigo-400" => "#818cf8", - "indigo-500" => "#6366f1", - "indigo-600" => "#4f46e5", - "indigo-700" => "#4338ca", - "indigo-800" => "#3730a3", - "indigo-900" => "#312e81", - "indigo-950" => "#1e1b4b", - // Violet - "violet-50" => "#f5f3ff", - "violet-100" => "#ede9fe", - "violet-200" => "#ddd6fe", - "violet-300" => "#c4b5fd", - "violet-400" => "#a78bfa", - "violet-500" => "#8b5cf6", - "violet-600" => "#7c3aed", - "violet-700" => "#6d28d9", - "violet-800" => "#5b21b6", - "violet-900" => "#4c1d95", - "violet-950" => "#2e1065", - // Purple - "purple-50" => "#faf5ff", - "purple-100" => "#f3e8ff", - "purple-200" => "#e9d5ff", - "purple-300" => "#d8b4fe", - "purple-400" => "#c084fc", - "purple-500" => "#a855f7", - "purple-600" => "#9333ea", - "purple-700" => "#7e22ce", - "purple-800" => "#6b21a8", - "purple-900" => "#581c87", - "purple-950" => "#3b0764", - // Fuchsia - "fuchsia-50" => "#fdf4ff", - "fuchsia-100" => "#fae8ff", - "fuchsia-200" => "#f5d0fe", - "fuchsia-300" => "#f0abfc", - "fuchsia-400" => "#e879f9", - "fuchsia-500" => "#d946ef", - "fuchsia-600" => "#c026d3", - "fuchsia-700" => "#a21caf", - "fuchsia-800" => "#86198f", - "fuchsia-900" => "#701a75", - "fuchsia-950" => "#4a044e", - // Pink - "pink-50" => "#fdf2f8", - "pink-100" => "#fce7f3", - "pink-200" => "#fbcfe8", - "pink-300" => "#f9a8d4", - "pink-400" => "#f472b6", - "pink-500" => "#ec4899", - "pink-600" => "#db2777", - "pink-700" => "#be185d", - "pink-800" => "#9d174d", - "pink-900" => "#831843", - "pink-950" => "#500724", - // Rose - "rose-50" => "#fff1f2", - "rose-100" => "#ffe4e6", - "rose-200" => "#fecdd3", - "rose-300" => "#fda4af", - "rose-400" => "#fb7185", - "rose-500" => "#f43f5e", - "rose-600" => "#e11d48", - "rose-700" => "#be123c", - "rose-800" => "#9f1239", - "rose-900" => "#881337", - "rose-950" => "#4c0519", -}; - /// Spacing scale (Tailwind default: 1 unit = 0.25rem = 4px) -static SPACING_SCALE: phf::Map<&'static str, &'static str> = phf_map! { +static DEFAULT_SPACING: phf::Map<&'static str, &'static str> = phf_map! { "0" => "0px", "px" => "1px", "0.5" => "0.125rem", @@ -776,17 +707,31 @@ static FONT_WEIGHT_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "black" => "900", }; +/// The spacing of a step: the project's @theme spacing, else Tailwind's +pub(crate) fn spacing_scale(key: &str) -> Option> { + if let Some(value) = theme_value("spacing", key) { + return Some(Cow::Owned(value)); + } + if let Some(scaled) = theme_variable("spacing").and_then(|base| scaled(key, &base)) { + return Some(Cow::Owned(scaled)); + } + DEFAULT_SPACING + .get(key) + .map(|&value| Cow::Borrowed(value)) + .or_else(|| scaled(key, "0.25rem").map(Cow::Owned)) +} + /// Border radius scale static BORDER_RADIUS_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "none" => "0px", - "sm" => "0.125rem", + "xs" => "0.125rem", + "sm" => "0.25rem", "" => "0.25rem", "md" => "0.375rem", "lg" => "0.5rem", "xl" => "0.75rem", "2xl" => "1rem", "3xl" => "1.5rem", - "full" => "9999px", + "4xl" => "2rem", }; /// Opacity scale @@ -825,54 +770,94 @@ static Z_INDEX_SCALE: phf::Map<&'static str, &'static str> = phf_map! { "auto" => "auto", }; -/// Box shadow scale -static BOX_SHADOW_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "sm" => "0 1px 2px 0 rgb(0 0 0 / 0.05)", - "" => "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", - "md" => "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", - "lg" => "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", - "xl" => "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", - "2xl" => "0 25px 50px -12px rgb(0 0 0 / 0.25)", - "inner" => "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)", - "none" => "0 0 #0000", -}; - -/// Border width scale -static BORDER_WIDTH_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "0" => "0px", - "" => "1px", - "2" => "2px", - "4" => "4px", - "8" => "8px", -}; +/// The declarations `class` compiles to, `None` when it stays as written +#[cfg(test)] +pub(crate) fn declarations_of(class: &str) -> Option> { + parse_class(class).map(|class| { + class + .declarations + .into_iter() + .map(|(property, value)| (property.into_owned(), value.into_owned())) + .collect() + }) +} -/// Transition duration scale -static DURATION_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "0" => "0s", - "75" => "75ms", - "100" => "100ms", - "150" => "150ms", - "200" => "200ms", - "300" => "300ms", - "500" => "500ms", - "700" => "700ms", - "1000" => "1000ms", -}; +/// 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 +} -/// Ease timing functions -static EASE_SCALE: phf::Map<&'static str, &'static str> = phf_map! { - "linear" => "linear", - "in" => "cubic-bezier(0.4, 0, 1, 1)", - "out" => "cubic-bezier(0, 0, 0.2, 1)", - "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", -}; +/// `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] pub fn parse_class(class: &str) -> Option { let mut parts = split_top_level(class, ':'); - let mut declarations = utility(parts.pop()?)?; + let (name, important) = important(parts.pop()?); + let Utility { + mut declarations, + selector: utility_selector, + 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 declarations.iter_mut().chain( + nested + .iter_mut() + .flat_map(|nested| nested.declarations.iter_mut()), + ) { + *value = Cow::Owned(format!("{value} !important")); + } + } let mut level = 0; let mut selectors = vec![String::from("&")]; let mut at_rules = Vec::new(); @@ -895,6 +880,12 @@ pub fn parse_class(class: &str) -> Option { at_rules.extend(variant.at_rule); content |= variant.content; } + if utility_selector != "&" { + selectors = selectors + .iter() + .map(|selector| utility_selector.replace('&', selector)) + .collect(); + } if content && !declarations .iter() @@ -905,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, }) } @@ -951,8 +961,80 @@ static NEGATABLE_PROPERTIES: phf::Set<&'static str> = phf_set! { "scroll-margin", }; -/// The declarations of a utility, which a leading `-` negates -fn utility(name: &str) -> Option> { +/// `name` without the `!` that makes its declarations important, trailing as +/// v4 writes it or leading as v3 did +fn important(name: &str) -> (&str, bool) { + name.strip_suffix('!') + .or_else(|| name.strip_prefix('!')) + .map_or((name, false), |name| (name, true)) +} + +/// What a utility declares, and on which element: itself, or the children of +/// the element it is on +struct Utility { + declarations: Vec, + /// Where the declarations apply, `&` being the element + selector: &'static str, + /// 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 +fn utility(name: &str) -> Option { + if let Some(declarations) = children_utility(name) { + 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(), + }) +} + +/// The declarations of a utility on the element itself, which a leading `-` +/// negates +fn element_utility(name: &str) -> Option> { let (negative, name) = name .strip_prefix('-') .map_or((false, name), |name| (true, name)); @@ -963,6 +1045,28 @@ fn utility(name: &str) -> Option> { arbitrary_property(name) }; } + if !negative { + 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) { + return None; + } + if let Some(declarations) = effect_utility(name, negative) { + return Some(declarations); + } + if let Some(declarations) = misc_utility(name, negative) { + return Some(declarations); + } + if !negative { + if let Some(declarations) = motion_utility(name) { + return Some(declarations); + } + } if let Some((root, value, variable)) = split_arbitrary(name) { return arbitrary_utility(root, value, variable, negative); } @@ -1193,11 +1297,11 @@ 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)] -enum ValueType { +pub(crate) enum ValueType { Length, Number, Image, @@ -1208,7 +1312,7 @@ enum ValueType { } /// The type a hint names, or that the value has -fn value_type(hint: Option<&str>, value: &str) -> Option { +pub(crate) fn value_type(hint: Option<&str>, value: &str) -> Option { Some(match hint { Some("length" | "percentage" | "line-width" | "absolute-size" | "relative-size") => { ValueType::Length @@ -1270,10 +1374,6 @@ fn arbitrary_declarations( } "scale-x" | "scale-y" => return Some(axes(root, "scale", &value, SCALE)), "skew" | "skew-x" | "skew-y" => return Some(skew(root, &value)), - "blur" | "brightness" | "contrast" | "saturate" => { - return Some(vec![function("filter", root, &value)]); - } - "backdrop-blur" => return Some(vec![function("backdrop-filter", "blur", &value)]), _ => {} } let value_type = value_type(hint, &value)?; @@ -1331,14 +1431,6 @@ fn each(properties: &[&'static str], value: &str) -> Vec { .collect() } -/// `property: name(value)` -fn function(property: &'static str, name: &str, value: &str) -> Declaration { - ( - Cow::Borrowed(property), - Cow::Owned(format!("{name}({value})")), - ) -} - /// `value` on the axes `root` names, each in its `--tw--` /// variable, which `property` reads as `composite` fn axes(root: &str, name: &'static str, value: &str, composite: &'static str) -> Vec { @@ -1443,7 +1535,7 @@ fn compound_utility(name: &str, negative: bool) -> Option> { ); } if let Some(size) = name.strip_prefix("size-") { - return Some(each(&["width", "height"], sizing_value(size)?)); + return Some(each(&["width", "height"], &sizing_value(size)?)); } if let Some(declarations) = radius_utility(name) { return Some(declarations); @@ -1464,11 +1556,11 @@ fn compound_utility(name: &str, negative: bool) -> Option> { } /// A spacing value `size-*` and `translate-*` take -fn sizing_value(size: &str) -> Option<&'static str> { +fn sizing_value(size: &str) -> Option> { if matches!(size, "screen" | "svw" | "lvw" | "dvw") { return None; } - SPACING_SCALE.get(size).copied() + spacing_scale(size) } /// `rounded`, `rounded-lg`, `rounded-t`, `rounded-t-lg`, … @@ -1484,8 +1576,14 @@ fn radius_utility(name: &str) -> Option> { _ => ("", rest), } }; - let value = BORDER_RADIUS_SCALE.get(size)?; - Some(each(RADIUS_SIDES.get(side)?, value)) + let value = match size { + "none" => Cow::Borrowed("0"), + "full" => Cow::Borrowed("calc(infinity * 1px)"), + _ => themed("radius", size, |size| { + BORDER_RADIUS_SCALE.get(size).copied() + })?, + }; + Some(each(RADIUS_SIDES.get(side)?, &value)) } /// `text-sm`, with the line height of the size unless `text-sm/6` sets one @@ -1522,7 +1620,7 @@ fn line_height_value(name: &str) -> Option> { .then(|| Cow::Owned(format!("var({variable})"))); } if is_number(name) { - return SPACING_SCALE.get(name).map(|&value| Cow::Borrowed(value)); + return spacing_scale(name); } None } @@ -1620,7 +1718,54 @@ fn decode_underscores(value: &str) -> String { /// An arbitrary value as CSS, the way Tailwind decodes it: underscores are /// spaces except in `url()` and in the name `var()` reads, and the operators /// in math functions get the spaces CSS requires around them -fn decode_arbitrary_value(value: &str) -> String { +pub(crate) fn decode_arbitrary_value(value: &str) -> String { + 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); } @@ -1795,7 +1940,7 @@ fn add_whitespace_around_math_operators(input: &str) -> String { } /// `value` split at each top-level `separator`, outside brackets and parentheses -fn split_top_level(value: &str, separator: char) -> Vec<&str> { +pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> { let mut parts = Vec::new(); let mut depth = 0usize; let mut start = 0; @@ -1815,7 +1960,7 @@ 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) } @@ -1933,6 +2078,35 @@ fn tw(property: &'static str, value: impl Into>) -> ParsedUtil (property, value.into()) } +/// A declaration +pub(crate) fn decl( + property: impl Into>, + value: impl Into>, +) -> Declaration { + (property.into(), value.into()) +} + +/// Whether alue is a whole number written without a sign or leading zeros, +/// which is what v4 takes for the bare values of most utilities +pub(crate) fn is_positive_integer(value: &str) -> bool { + value == "0" + || (!value.starts_with('0') + && value.len() < 16 + && value.bytes().all(|byte| byte.is_ascii_digit()) + && !value.is_empty()) +} + +/// [value] as the CSS it stands for, or (--variable) as ar(--variable) +pub(crate) fn arbitrary_or_variable(argument: &str) -> Option { + if let Some(value) = bracketed(argument) { + return (!value.is_empty() && is_balanced(value)).then(|| decode_arbitrary_value(value)); + } + let variable = argument + .strip_prefix('(') + .and_then(|argument| argument.strip_suffix(')'))?; + (variable.starts_with("--") && is_balanced(variable)).then(|| format!("var({variable})")) +} + /// The one declaration of a utility from the tables below fn parse_utility(class: &str) -> Option { // Layout utilities @@ -1975,16 +2149,6 @@ fn parse_utility(class: &str) -> Option { return Some(result); } - // Filters - if let Some(result) = parse_filter_utility(class) { - return Some(result); - } - - // Transitions & Animation - if let Some(result) = parse_transition_utility(class) { - return Some(result); - } - // Transform origin if let Some(result) = parse_transform_utility(class) { return Some(result); @@ -2193,38 +2357,38 @@ fn parse_layout_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) // Top/Right/Bottom/Left/Inset if let Some(rest) = class.strip_prefix("top-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("top", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("top", value)); } } if let Some(rest) = class.strip_prefix("right-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("right", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("right", value)); } } if let Some(rest) = class.strip_prefix("bottom-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("bottom", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("bottom", value)); } } if let Some(rest) = class.strip_prefix("left-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("left", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("left", value)); } } if let Some(rest) = class.strip_prefix("inset-x-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("inset-inline", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("inset-inline", value)); } } if let Some(rest) = class.strip_prefix("inset-y-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("inset-block", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("inset-block", value)); } } if let Some(rest) = class.strip_prefix("inset-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("inset", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("inset", value)); } } @@ -2383,8 +2547,8 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st _ => { // Flex basis with spacing scale if let Some(rest) = class.strip_prefix("basis-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("flex-basis", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("flex-basis", value)); } } @@ -2459,18 +2623,18 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st // Gap if let Some(rest) = class.strip_prefix("gap-x-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("column-gap", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("column-gap", value)); } } if let Some(rest) = class.strip_prefix("gap-y-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("row-gap", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("row-gap", value)); } } if let Some(rest) = class.strip_prefix("gap-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("gap", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("gap", value)); } } @@ -2483,113 +2647,95 @@ fn parse_flex_grid_utility(class: &str) -> Option<(&'static str, Cow<'static, st fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Padding if let Some(rest) = class.strip_prefix("px-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-inline", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-inline", value)); } } if let Some(rest) = class.strip_prefix("py-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-block", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-block", value)); } } if let Some(rest) = class.strip_prefix("pt-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-top", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-top", value)); } } if let Some(rest) = class.strip_prefix("pr-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-right", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-right", value)); } } if let Some(rest) = class.strip_prefix("pb-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-bottom", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-bottom", value)); } } if let Some(rest) = class.strip_prefix("pl-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-left", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-left", value)); } } if let Some(rest) = class.strip_prefix("ps-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-inline-start", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-inline-start", value)); } } if let Some(rest) = class.strip_prefix("pe-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding-inline-end", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding-inline-end", value)); } } if let Some(rest) = class.strip_prefix("p-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("padding", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("padding", value)); } } // Margin if let Some(rest) = class.strip_prefix("mx-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-inline", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-inline", value)); } } if let Some(rest) = class.strip_prefix("my-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-block", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-block", value)); } } if let Some(rest) = class.strip_prefix("mt-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-top", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-top", value)); } } if let Some(rest) = class.strip_prefix("mr-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-right", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-right", value)); } } if let Some(rest) = class.strip_prefix("mb-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-bottom", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-bottom", value)); } } if let Some(rest) = class.strip_prefix("ml-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-left", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-left", value)); } } if let Some(rest) = class.strip_prefix("ms-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-inline-start", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-inline-start", value)); } } if let Some(rest) = class.strip_prefix("me-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin-inline-end", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin-inline-end", value)); } } if let Some(rest) = class.strip_prefix("m-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("margin", Cow::Borrowed(value))); - } - } - - // Space between - if let Some(rest) = class.strip_prefix("space-x-") { - if rest == "reverse" { - return Some(tw("--tw-space-x-reverse", Cow::Borrowed("1"))); - } - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("column-gap", Cow::Borrowed(value))); - } - } - if let Some(rest) = class.strip_prefix("space-y-") { - if rest == "reverse" { - return Some(tw("--tw-space-y-reverse", Cow::Borrowed("1"))); - } - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("row-gap", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("margin", value)); } } @@ -2600,8 +2746,8 @@ fn parse_spacing_utility(class: &str) -> Option<(&'static str, Cow<'static, str> fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { // Width if let Some(rest) = class.strip_prefix("w-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("width", Cow::Borrowed(value))); + if let Some(value) = spacing_scale(rest) { + return Some(tw("width", value)); } } @@ -2613,13 +2759,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "min" => Cow::Borrowed("min-content"), "max" => Cow::Borrowed("max-content"), "fit" => Cow::Borrowed("fit-content"), - _ => { - if let Some(&v) = SPACING_SCALE.get(rest) { - Cow::Borrowed(v) - } else { - return None; - } - } + _ => spacing_scale(rest)?, }; return Some(tw("min-width", value)); } @@ -2650,13 +2790,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "screen-lg" => Cow::Borrowed("1024px"), "screen-xl" => Cow::Borrowed("1280px"), "screen-2xl" => Cow::Borrowed("1536px"), - _ => { - if let Some(&v) = SPACING_SCALE.get(rest) { - Cow::Borrowed(v) - } else { - return None; - } - } + _ => spacing_scale(rest)?, }; return Some(tw("max-width", value)); } @@ -2668,13 +2802,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "svh" => Cow::Borrowed("100svh"), "lvh" => Cow::Borrowed("100lvh"), "dvh" => Cow::Borrowed("100dvh"), - _ => { - if let Some(&v) = SPACING_SCALE.get(rest) { - Cow::Borrowed(v) - } else { - return None; - } - } + _ => spacing_scale(rest)?, }; return Some(tw("height", value)); } @@ -2691,13 +2819,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "min" => Cow::Borrowed("min-content"), "max" => Cow::Borrowed("max-content"), "fit" => Cow::Borrowed("fit-content"), - _ => { - if let Some(&v) = SPACING_SCALE.get(rest) { - Cow::Borrowed(v) - } else { - return None; - } - } + _ => spacing_scale(rest)?, }; return Some(tw("min-height", value)); } @@ -2714,13 +2836,7 @@ fn parse_sizing_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) "min" => Cow::Borrowed("min-content"), "max" => Cow::Borrowed("max-content"), "fit" => Cow::Borrowed("fit-content"), - _ => { - if let Some(&v) = SPACING_SCALE.get(rest) { - Cow::Borrowed(v) - } else { - return None; - } - } + _ => spacing_scale(rest)?, }; return Some(tw("max-height", value)); } @@ -2759,8 +2875,8 @@ fn parse_typography_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Text color (font sizes are `compound_utility`'s) if let Some(rest) = class.strip_prefix("text-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("color", color)); } // Text alignment match rest { @@ -2898,8 +3014,8 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s // Background color if let Some(rest) = class.strip_prefix("bg-") { // Check if it's a color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("background-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("background-color", color)); } // Background attachment match rest { @@ -2958,42 +3074,6 @@ fn parse_background_utility(class: &str) -> Option<(&'static str, Cow<'static, s "none" => return Some(tw("background-image", Cow::Borrowed("none"))), _ => {} } - - // Gradient directions - if let Some(dir) = rest.strip_prefix("gradient-to-") { - let direction = match dir { - "t" => Cow::Borrowed("to top"), - "tr" => Cow::Borrowed("to top right"), - "r" => Cow::Borrowed("to right"), - "br" => Cow::Borrowed("to bottom right"), - "b" => Cow::Borrowed("to bottom"), - "bl" => Cow::Borrowed("to bottom left"), - "l" => Cow::Borrowed("to left"), - "tl" => Cow::Borrowed("to top left"), - _ => return None, - }; - return Some(tw( - "background-image", - format!("linear-gradient({direction}, var(--tw-gradient-stops))"), - )); - } - } - - // Gradient color stops - if let Some(rest) = class.strip_prefix("from-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-from", Cow::Borrowed(color))); - } - } - if let Some(rest) = class.strip_prefix("via-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-via", Cow::Borrowed(color))); - } - } - if let Some(rest) = class.strip_prefix("to-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-gradient-to", Cow::Borrowed(color))); - } } None @@ -3006,56 +3086,8 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) // Border width if let Some(rest) = class.strip_prefix("border-") { // Border color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("border-color", Cow::Borrowed(color))); - } - - // Border width per side - if let Some(width) = rest.strip_prefix("t-") { - if let Some(&value) = BORDER_WIDTH_SCALE.get(width) { - return Some(tw("border-top-width", Cow::Borrowed(value))); - } - } - if let Some(width) = rest.strip_prefix("r-") { - if let Some(&value) = BORDER_WIDTH_SCALE.get(width) { - return Some(tw("border-right-width", Cow::Borrowed(value))); - } - } - if let Some(width) = rest.strip_prefix("b-") { - if let Some(&value) = BORDER_WIDTH_SCALE.get(width) { - return Some(tw("border-bottom-width", Cow::Borrowed(value))); - } - } - if let Some(width) = rest.strip_prefix("l-") { - if let Some(&value) = BORDER_WIDTH_SCALE.get(width) { - return Some(tw("border-left-width", Cow::Borrowed(value))); - } - } - if let Some(width) = rest.strip_prefix("x-") { - if let Some(&value) = BORDER_WIDTH_SCALE.get(width) { - return Some(tw("border-inline-width", Cow::Borrowed(value))); - } - } - if let Some(width) = rest.strip_prefix("y-") { - if let Some(&value) = BORDER_WIDTH_SCALE.get(width) { - return Some(tw("border-block-width", Cow::Borrowed(value))); - } - } - - // Border width - if let Some(&value) = BORDER_WIDTH_SCALE.get(rest) { - return Some(tw("border-width", Cow::Borrowed(value))); - } - - // Border style - match rest { - "solid" => return Some(tw("border-style", Cow::Borrowed("solid"))), - "dashed" => return Some(tw("border-style", Cow::Borrowed("dashed"))), - "dotted" => return Some(tw("border-style", Cow::Borrowed("dotted"))), - "double" => return Some(tw("border-style", Cow::Borrowed("double"))), - "hidden" => return Some(tw("border-style", Cow::Borrowed("hidden"))), - "none" => return Some(tw("border-style", Cow::Borrowed("none"))), - _ => {} + if let Some(color) = color_value(rest) { + return Some(tw("border-color", color)); } // Border collapse (for tables) @@ -3067,106 +3099,10 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) } } - // Standalone "border" (without suffix) - note: "border-0/2/4/8" variants are handled - // via BORDER_WIDTH_SCALE lookup above in the strip_prefix("border-") branch - if class == "border" { - return Some(tw("border-width", Cow::Borrowed("1px"))); - } - - // Outline + // Outline color if let Some(rest) = class.strip_prefix("outline-") { - match rest { - "none" => { - return Some(tw("outline", Cow::Borrowed("2px solid transparent"))); - } - "0" => return Some(tw("outline-width", Cow::Borrowed("0px"))), - "1" => return Some(tw("outline-width", Cow::Borrowed("1px"))), - "2" => return Some(tw("outline-width", Cow::Borrowed("2px"))), - "4" => return Some(tw("outline-width", Cow::Borrowed("4px"))), - "8" => return Some(tw("outline-width", Cow::Borrowed("8px"))), - "dashed" => return Some(tw("outline-style", Cow::Borrowed("dashed"))), - "dotted" => return Some(tw("outline-style", Cow::Borrowed("dotted"))), - "double" => return Some(tw("outline-style", Cow::Borrowed("double"))), - _ => { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("outline-color", Cow::Borrowed(color))); - } - } - } - } - if class == "outline" { - return Some(tw("outline-style", Cow::Borrowed("solid"))); - } - - // Ring - if let Some(rest) = class.strip_prefix("ring-") { - match rest { - "0" => { - return Some(tw("--tw-ring-offset-shadow", Cow::Borrowed("0 0 #0000"))); - } - "1" => { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 0 0 1px var(--tw-ring-color)"), - )); - } - "2" => { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 0 0 2px var(--tw-ring-color)"), - )); - } - "4" => { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 0 0 4px var(--tw-ring-color)"), - )); - } - "8" => { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 0 0 8px var(--tw-ring-color)"), - )); - } - "inset" => return Some(tw("--tw-ring-inset", Cow::Borrowed("inset"))), - _ => { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-ring-color", Cow::Borrowed(color))); - } - } - } - } - if class == "ring" { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 0 0 3px var(--tw-ring-color)"), - )); - } - - // Divide - if let Some(rest) = class.strip_prefix("divide-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-divide-color", Cow::Borrowed(color))); - } - match rest { - "x" => return Some(tw("--tw-divide-x-reverse", Cow::Borrowed("0"))), - "x-0" => return Some(tw("border-inline-width", Cow::Borrowed("0px"))), - "x-2" => return Some(tw("border-inline-width", Cow::Borrowed("2px"))), - "x-4" => return Some(tw("border-inline-width", Cow::Borrowed("4px"))), - "x-8" => return Some(tw("border-inline-width", Cow::Borrowed("8px"))), - "x-reverse" => return Some(tw("--tw-divide-x-reverse", Cow::Borrowed("1"))), - "y" => return Some(tw("--tw-divide-y-reverse", Cow::Borrowed("0"))), - "y-0" => return Some(tw("border-block-width", Cow::Borrowed("0px"))), - "y-2" => return Some(tw("border-block-width", Cow::Borrowed("2px"))), - "y-4" => return Some(tw("border-block-width", Cow::Borrowed("4px"))), - "y-8" => return Some(tw("border-block-width", Cow::Borrowed("8px"))), - "y-reverse" => return Some(tw("--tw-divide-y-reverse", Cow::Borrowed("1"))), - "solid" => return Some(tw("border-style", Cow::Borrowed("solid"))), - "dashed" => return Some(tw("border-style", Cow::Borrowed("dashed"))), - "dotted" => return Some(tw("border-style", Cow::Borrowed("dotted"))), - "double" => return Some(tw("border-style", Cow::Borrowed("double"))), - "none" => return Some(tw("border-style", Cow::Borrowed("none"))), - _ => {} + if let Some(color) = color_value(rest) { + return Some(tw("outline-color", color)); } } @@ -3175,23 +3111,6 @@ fn parse_border_utility(class: &str) -> Option<(&'static str, Cow<'static, str>) /// Parse effects utilities (shadow, opacity, mix-blend, etc.) fn parse_effects_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Box shadow - if let Some(rest) = class.strip_prefix("shadow-") { - if let Some(&value) = BOX_SHADOW_SCALE.get(rest) { - return Some(tw("box-shadow", Cow::Borrowed(value))); - } - // Shadow color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("--tw-shadow-color", Cow::Borrowed(color))); - } - } - if class == "shadow" { - return Some(tw( - "box-shadow", - Cow::Borrowed("0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)"), - )); - } - // Opacity if let Some(rest) = class.strip_prefix("opacity-") { if let Some(&value) = OPACITY_SCALE.get(rest) { @@ -3222,313 +3141,10 @@ static BLEND_MODES: phf::Set<&'static str> = phf_set! { "luminosity", }; -/// Parse filter utilities (blur, brightness, contrast, etc.) -fn parse_filter_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Blur - if let Some(rest) = class.strip_prefix("blur-") { - let value = match rest { - "none" => Cow::Borrowed("0"), - "sm" => Cow::Borrowed("4px"), - "md" => Cow::Borrowed("12px"), - "lg" => Cow::Borrowed("16px"), - "xl" => Cow::Borrowed("24px"), - "2xl" => Cow::Borrowed("40px"), - "3xl" => Cow::Borrowed("64px"), - _ => return None, - }; - return Some(tw("filter", format!("blur({value})"))); - } - if class == "blur" { - return Some(tw("filter", Cow::Borrowed("blur(8px)"))); - } - - // Brightness - if let Some(rest) = class.strip_prefix("brightness-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "90" => Cow::Borrowed(".9"), - "95" => Cow::Borrowed(".95"), - "100" => Cow::Borrowed("1"), - "105" => Cow::Borrowed("1.05"), - "110" => Cow::Borrowed("1.1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("brightness({value})"))); - } - - // Contrast - if let Some(rest) = class.strip_prefix("contrast-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "100" => Cow::Borrowed("1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("contrast({value})"))); - } - - // Drop shadow - if let Some(rest) = class.strip_prefix("drop-shadow-") { - let value = match rest { - "sm" => Cow::Borrowed("drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))"), - "md" => Cow::Borrowed( - "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))", - ), - "lg" => Cow::Borrowed( - "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))", - ), - "xl" => Cow::Borrowed( - "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))", - ), - "2xl" => Cow::Borrowed("drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))"), - "none" => Cow::Borrowed("drop-shadow(0 0 #0000)"), - _ => return None, - }; - return Some(tw("filter", value)); - } - if class == "drop-shadow" { - return Some(tw( - "filter", - Cow::Borrowed( - "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))", - ), - )); - } - - // Grayscale - if class == "grayscale" { - return Some(tw("filter", Cow::Borrowed("grayscale(100%)"))); - } - if class == "grayscale-0" { - return Some(tw("filter", Cow::Borrowed("grayscale(0)"))); - } - - // Hue rotate - if let Some(rest) = class.strip_prefix("hue-rotate-") { - let value = match rest { - "0" => Cow::Borrowed("0deg"), - "15" => Cow::Borrowed("15deg"), - "30" => Cow::Borrowed("30deg"), - "60" => Cow::Borrowed("60deg"), - "90" => Cow::Borrowed("90deg"), - "180" => Cow::Borrowed("180deg"), - _ => return None, - }; - return Some(tw("filter", format!("hue-rotate({value})"))); - } - - // Invert - if class == "invert" { - return Some(tw("filter", Cow::Borrowed("invert(100%)"))); - } - if class == "invert-0" { - return Some(tw("filter", Cow::Borrowed("invert(0)"))); - } - - // Saturate - if let Some(rest) = class.strip_prefix("saturate-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "100" => Cow::Borrowed("1"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("filter", format!("saturate({value})"))); - } - - // Sepia - if class == "sepia" { - return Some(tw("filter", Cow::Borrowed("sepia(100%)"))); - } - if class == "sepia-0" { - return Some(tw("filter", Cow::Borrowed("sepia(0)"))); - } - - // Backdrop filters - if let Some(rest) = class.strip_prefix("backdrop-blur-") { - let value = match rest { - "none" => Cow::Borrowed("0"), - "sm" => Cow::Borrowed("4px"), - "md" => Cow::Borrowed("12px"), - "lg" => Cow::Borrowed("16px"), - "xl" => Cow::Borrowed("24px"), - "2xl" => Cow::Borrowed("40px"), - "3xl" => Cow::Borrowed("64px"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("blur({value})"))); - } - if class == "backdrop-blur" { - return Some(tw("backdrop-filter", Cow::Borrowed("blur(8px)"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-brightness-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "90" => Cow::Borrowed(".9"), - "95" => Cow::Borrowed(".95"), - "100" => Cow::Borrowed("1"), - "105" => Cow::Borrowed("1.05"), - "110" => Cow::Borrowed("1.1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("brightness({value})"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-contrast-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "75" => Cow::Borrowed(".75"), - "100" => Cow::Borrowed("1"), - "125" => Cow::Borrowed("1.25"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("contrast({value})"))); - } - - if class == "backdrop-grayscale" { - return Some(tw("backdrop-filter", Cow::Borrowed("grayscale(100%)"))); - } - if class == "backdrop-grayscale-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("grayscale(0)"))); - } - - if class == "backdrop-invert" { - return Some(tw("backdrop-filter", Cow::Borrowed("invert(100%)"))); - } - if class == "backdrop-invert-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("invert(0)"))); - } - - if let Some(rest) = class.strip_prefix("backdrop-opacity-") { - if let Some(&value) = OPACITY_SCALE.get(rest) { - return Some(tw("backdrop-filter", format!("opacity({value})"))); - } - } - - if let Some(rest) = class.strip_prefix("backdrop-saturate-") { - let value = match rest { - "0" => Cow::Borrowed("0"), - "50" => Cow::Borrowed(".5"), - "100" => Cow::Borrowed("1"), - "150" => Cow::Borrowed("1.5"), - "200" => Cow::Borrowed("2"), - _ => return None, - }; - return Some(tw("backdrop-filter", format!("saturate({value})"))); - } - - if class == "backdrop-sepia" { - return Some(tw("backdrop-filter", Cow::Borrowed("sepia(100%)"))); - } - if class == "backdrop-sepia-0" { - return Some(tw("backdrop-filter", Cow::Borrowed("sepia(0)"))); - } - - None -} - -/// Parse transition and animation utilities -fn parse_transition_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - // Transition - match class { - "transition-none" => return Some(tw("transition-property", Cow::Borrowed("none"))), - "transition-all" => return Some(tw("transition-property", Cow::Borrowed("all"))), - "transition" => { - return Some(tw( - "transition-property", - Cow::Borrowed( - "color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter", - ), - )); - } - "transition-colors" => { - return Some(tw( - "transition-property", - Cow::Borrowed( - "color, background-color, border-color, text-decoration-color, fill, stroke", - ), - )); - } - "transition-opacity" => return Some(tw("transition-property", Cow::Borrowed("opacity"))), - "transition-shadow" => return Some(tw("transition-property", Cow::Borrowed("box-shadow"))), - "transition-transform" => { - return Some(tw("transition-property", Cow::Borrowed("transform"))); - } - _ => {} - } - - // Duration - if let Some(rest) = class.strip_prefix("duration-") { - if let Some(&value) = DURATION_SCALE.get(rest) { - return Some(tw("transition-duration", Cow::Borrowed(value))); - } - } - - // Ease (timing function) - if let Some(rest) = class.strip_prefix("ease-") { - if let Some(&value) = EASE_SCALE.get(rest) { - return Some(tw("transition-timing-function", Cow::Borrowed(value))); - } - } - - // Delay - if let Some(rest) = class.strip_prefix("delay-") { - if let Some(&value) = DURATION_SCALE.get(rest) { - return Some(tw("transition-delay", Cow::Borrowed(value))); - } - } - - // Animation - match class { - "animate-none" => return Some(tw("animation", Cow::Borrowed("none"))), - "animate-spin" => { - return Some(tw("animation", Cow::Borrowed("spin 1s linear infinite"))); - } - "animate-ping" => { - return Some(tw( - "animation", - Cow::Borrowed("ping 1s cubic-bezier(0, 0, 0.2, 1) infinite"), - )); - } - "animate-pulse" => { - return Some(tw( - "animation", - Cow::Borrowed("pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite"), - )); - } - "animate-bounce" => { - return Some(tw("animation", Cow::Borrowed("bounce 1s infinite"))); - } - _ => {} - } - - None -} - -/// Parse transform origin utilities (translate, rotate, scale and skew are -/// `transform_utility`'s) -fn parse_transform_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { - if let Some(rest) = class.strip_prefix("origin-") { +/// Parse transform origin utilities (translate, rotate, scale and skew are +/// `transform_utility`'s) +fn parse_transform_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { + if let Some(rest) = class.strip_prefix("origin-") { let value = match rest { "center" => Cow::Borrowed("center"), "top" => Cow::Borrowed("top"), @@ -3554,8 +3170,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static if rest == "auto" { return Some(tw("accent-color", Cow::Borrowed("auto"))); } - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("accent-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("accent-color", color)); } } @@ -3575,8 +3191,8 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static // Caret color if let Some(rest) = class.strip_prefix("caret-") { - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("caret-color", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("caret-color", color)); } } @@ -3604,18 +3220,6 @@ fn parse_interactivity_utility(class: &str) -> Option<(&'static str, Cow<'static } } - // Scroll margin/padding - if let Some(rest) = class.strip_prefix("scroll-m-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("scroll-margin", Cow::Borrowed(value))); - } - } - if let Some(rest) = class.strip_prefix("scroll-p-") { - if let Some(&value) = SPACING_SCALE.get(rest) { - return Some(tw("scroll-padding", Cow::Borrowed(value))); - } - } - // Scroll snap if let Some(rest) = class.strip_prefix("snap-") { match rest { @@ -3706,8 +3310,8 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { if rest == "none" { return Some(tw("fill", Cow::Borrowed("none"))); } - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("fill", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("fill", color)); } } @@ -3724,8 +3328,8 @@ fn parse_svg_utility(class: &str) -> Option<(&'static str, Cow<'static, str>)> { _ => {} } // Stroke color - if let Some(&color) = TAILWIND_COLORS.get(rest) { - return Some(tw("stroke", Cow::Borrowed(color))); + if let Some(color) = color_value(rest) { + return Some(tw("stroke", color)); } } @@ -3822,12 +3426,12 @@ mod tests { } #[rstest] - #[case("bg-red-500", "background-color", "#ef4444")] - #[case("bg-blue-500", "background-color", "#3b82f6")] + #[case("bg-red-500", "background-color", "oklch(63.7% 0.237 25.331)")] + #[case("bg-blue-500", "background-color", "oklch(62.3% 0.214 259.815)")] #[case("bg-black", "background-color", "#000")] #[case("bg-white", "background-color", "#fff")] #[case("bg-transparent", "background-color", "transparent")] - #[case("text-red-500", "color", "#ef4444")] + #[case("text-red-500", "color", "oklch(63.7% 0.237 25.331)")] #[case("text-white", "color", "#fff")] fn test_parse_color_utilities( #[case] class: &str, @@ -3958,26 +3562,12 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case( - "transition-colors", - "color, background-color, border-color, text-decoration-color, fill, stroke" - )] - #[case("transition-transform", "transform")] - fn test_parse_transition_property_utilities(#[case] class: &str, #[case] expected_value: &str) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, "transition-property"); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("rounded", "border-radius", "0.25rem")] - #[case("rounded-none", "border-radius", "0px")] - #[case("rounded-full", "border-radius", "9999px")] + #[case("rounded-none", "border-radius", "0")] + #[case("rounded-full", "border-radius", "calc(infinity * 1px)")] #[case("rounded-lg", "border-radius", "0.5rem")] - #[case("border", "border-width", "1px")] - #[case("border-2", "border-width", "2px")] - #[case("border-red-500", "border-color", "#ef4444")] + #[case("border-red-500", "border-color", "oklch(63.7% 0.237 25.331)")] fn test_parse_border_utilities( #[case] class: &str, #[case] expected_prop: &str, @@ -4101,34 +3691,6 @@ mod tests { assert_eq!(parsed.value, "span 3 / span 3"); } - #[test] - fn test_parse_transition_utilities() { - let parsed = parse_single_class("transition").expect("Should parse"); - assert_eq!(parsed.property, "transition-property"); - - let parsed = parse_single_class("duration-300").expect("Should parse"); - assert_eq!(parsed.property, "transition-duration"); - assert_eq!(parsed.value, "300ms"); - - let parsed = parse_single_class("ease-in-out").expect("Should parse"); - assert_eq!(parsed.property, "transition-timing-function"); - } - - #[test] - fn test_parse_filter_utilities() { - let parsed = parse_single_class("blur").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "blur(8px)"); - - let parsed = parse_single_class("blur-lg").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "blur(16px)"); - - let parsed = parse_single_class("grayscale").expect("Should parse"); - assert_eq!(parsed.property, "filter"); - assert_eq!(parsed.value, "grayscale(100%)"); - } - #[test] fn test_parse_interactivity_utilities() { let parsed = parse_single_class("cursor-pointer").expect("Should parse"); @@ -4148,7 +3710,7 @@ mod tests { fn test_parse_svg_utilities() { let parsed = parse_single_class("fill-red-500").expect("Should parse"); assert_eq!(parsed.property, "fill"); - assert_eq!(parsed.value, "#ef4444"); + assert_eq!(parsed.value, "oklch(63.7% 0.237 25.331)"); let parsed = parse_single_class("stroke-black").expect("Should parse"); assert_eq!(parsed.property, "stroke"); @@ -4182,7 +3744,6 @@ mod tests { #[case("z-[999]", "z-index", "999")] #[case("font-[Arial]", "font-family", "Arial")] #[case("tracking-[0.2em]", "letter-spacing", "0.2em")] - #[case("duration-[500ms]", "transition-duration", "500ms")] #[case("delay-[200ms]", "transition-delay", "200ms")] #[case("aspect-[16/9]", "aspect-ratio", "16/9")] #[case("columns-[3]", "columns", "3")] @@ -4241,22 +3802,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("blur-[10px]", "filter", "blur(10px)")] - #[case("brightness-[1.2]", "filter", "brightness(1.2)")] - #[case("contrast-[1.5]", "filter", "contrast(1.5)")] - #[case("saturate-[2]", "filter", "saturate(2)")] - #[case("backdrop-blur-[5px]", "backdrop-filter", "blur(5px)")] - fn test_parse_arbitrary_filter_values( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // ==================== WAVE 3: Layout & Flex/Grid Utilities ==================== // Wave 3.1: aspect-ratio utilities (lines 1198-1204) @@ -4399,8 +3944,6 @@ mod tests { #[case("mr-4", "margin-right", "1rem")] #[case("mb-4", "margin-bottom", "1rem")] #[case("ml-4", "margin-left", "1rem")] - #[case("space-x-4", "column-gap", "1rem")] - #[case("space-y-4", "row-gap", "1rem")] #[case("space-x-reverse", "--tw-space-x-reverse", "1")] #[case("space-y-reverse", "--tw-space-y-reverse", "1")] fn test_parse_logical_spacing_utilities( @@ -4607,75 +4150,6 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case( - "bg-gradient-to-t", - "background-image", - "linear-gradient(to top, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-tr", - "background-image", - "linear-gradient(to top right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-r", - "background-image", - "linear-gradient(to right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-br", - "background-image", - "linear-gradient(to bottom right, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-b", - "background-image", - "linear-gradient(to bottom, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-bl", - "background-image", - "linear-gradient(to bottom left, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-l", - "background-image", - "linear-gradient(to left, var(--tw-gradient-stops))" - )] - #[case( - "bg-gradient-to-tl", - "background-image", - "linear-gradient(to top left, var(--tw-gradient-stops))" - )] - fn test_parse_background_gradient( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - // Wave 5.2: Gradient stops (lines 2057-2068) - #[rstest] - #[case("from-red-500", "--tw-gradient-from", "#ef4444")] - #[case("from-blue-500", "--tw-gradient-from", "#3b82f6")] - #[case("via-red-500", "--tw-gradient-via", "#ef4444")] - #[case("via-blue-500", "--tw-gradient-via", "#3b82f6")] - #[case("to-red-500", "--tw-gradient-to", "#ef4444")] - #[case("to-blue-500", "--tw-gradient-to", "#3b82f6")] - fn test_parse_gradient_stops( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Sides round both of their corners #[rstest] #[case("rounded-t-lg", &[("border-top-left-radius", "0.5rem"), ("border-top-right-radius", "0.5rem")])] @@ -4699,109 +4173,8 @@ mod tests { assert_eq!(declarations(class), Some(owned(expected))); } - #[rstest] - #[case("border-t-2", "border-top-width", "2px")] - #[case("border-r-2", "border-right-width", "2px")] - #[case("border-b-2", "border-bottom-width", "2px")] - #[case("border-l-2", "border-left-width", "2px")] - #[case("border-x-2", "border-inline-width", "2px")] - #[case("border-y-2", "border-block-width", "2px")] - fn test_parse_border_width_sides( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.4: Border styles, outline, ring, divide (lines 2211-2313) - #[rstest] - #[case("border-solid", "border-style", "solid")] - #[case("border-dashed", "border-style", "dashed")] - #[case("border-dotted", "border-style", "dotted")] - #[case("border-double", "border-style", "double")] - #[case("border-hidden", "border-style", "hidden")] - #[case("border-none", "border-style", "none")] - fn test_parse_border_styles( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("outline-none", "outline", "2px solid transparent")] - #[case("outline", "outline-style", "solid")] - #[case("outline-dashed", "outline-style", "dashed")] - #[case("outline-dotted", "outline-style", "dotted")] - #[case("outline-double", "outline-style", "double")] - #[case("outline-0", "outline-width", "0px")] - #[case("outline-1", "outline-width", "1px")] - #[case("outline-2", "outline-width", "2px")] - #[case("outline-4", "outline-width", "4px")] - #[case("outline-8", "outline-width", "8px")] - fn test_parse_outline_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("ring", "box-shadow", "0 0 0 3px var(--tw-ring-color)")] - #[case("ring-0", "--tw-ring-offset-shadow", "0 0 #0000")] - #[case("ring-1", "box-shadow", "0 0 0 1px var(--tw-ring-color)")] - #[case("ring-2", "box-shadow", "0 0 0 2px var(--tw-ring-color)")] - #[case("ring-inset", "--tw-ring-inset", "inset")] - fn test_parse_ring_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("divide-x", "--tw-divide-x-reverse", "0")] - #[case("divide-y", "--tw-divide-y-reverse", "0")] - #[case("divide-x-2", "border-inline-width", "2px")] - #[case("divide-y-2", "border-block-width", "2px")] - #[case("divide-x-reverse", "--tw-divide-x-reverse", "1")] - #[case("divide-y-reverse", "--tw-divide-y-reverse", "1")] - fn test_parse_divide_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.5: Effects (lines 2328-2350) - #[rstest] - #[case("shadow-red-500", "--tw-shadow-color", "#ef4444")] - #[case("shadow-blue-500", "--tw-shadow-color", "#3b82f6")] - fn test_parse_shadow_color_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("bg-blend-normal", "background-blend-mode", "normal")] #[case("bg-blend-multiply", "background-blend-mode", "multiply")] @@ -4818,195 +4191,7 @@ mod tests { } // Wave 5.6: Filters & backdrop filters (lines 2365-2555) - #[rstest] - #[case("blur-none", "filter", "blur(0)")] - #[case("blur-sm", "filter", "blur(4px)")] - #[case("blur-md", "filter", "blur(12px)")] - #[case("blur-xl", "filter", "blur(24px)")] - #[case("blur-2xl", "filter", "blur(40px)")] - #[case("blur-3xl", "filter", "blur(64px)")] - fn test_parse_blur_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("brightness-0", "filter", "brightness(0)")] - #[case("brightness-50", "filter", "brightness(.5)")] - #[case("brightness-100", "filter", "brightness(1)")] - #[case("brightness-150", "filter", "brightness(1.5)")] - #[case("brightness-200", "filter", "brightness(2)")] - fn test_parse_brightness_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("contrast-0", "filter", "contrast(0)")] - #[case("contrast-50", "filter", "contrast(.5)")] - #[case("contrast-100", "filter", "contrast(1)")] - #[case("contrast-150", "filter", "contrast(1.5)")] - #[case("contrast-200", "filter", "contrast(2)")] - fn test_parse_contrast_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case( - "drop-shadow", - "filter", - "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))" - )] - #[case("drop-shadow-sm", "filter", "drop-shadow(0 1px 1px rgb(0 0 0 / 0.05))")] - #[case( - "drop-shadow-md", - "filter", - "drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06))" - )] - #[case( - "drop-shadow-lg", - "filter", - "drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1))" - )] - #[case( - "drop-shadow-xl", - "filter", - "drop-shadow(0 20px 13px rgb(0 0 0 / 0.03)) drop-shadow(0 8px 5px rgb(0 0 0 / 0.08))" - )] - #[case( - "drop-shadow-2xl", - "filter", - "drop-shadow(0 25px 25px rgb(0 0 0 / 0.15))" - )] - #[case("drop-shadow-none", "filter", "drop-shadow(0 0 #0000)")] - fn test_parse_drop_shadow_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("hue-rotate-0", "filter", "hue-rotate(0deg)")] - #[case("hue-rotate-15", "filter", "hue-rotate(15deg)")] - #[case("hue-rotate-30", "filter", "hue-rotate(30deg)")] - #[case("hue-rotate-60", "filter", "hue-rotate(60deg)")] - #[case("hue-rotate-90", "filter", "hue-rotate(90deg)")] - #[case("hue-rotate-180", "filter", "hue-rotate(180deg)")] - fn test_parse_hue_rotate_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("invert-0", "filter", "invert(0)")] - #[case("invert", "filter", "invert(100%)")] - #[case("saturate-0", "filter", "saturate(0)")] - #[case("saturate-50", "filter", "saturate(.5)")] - #[case("saturate-100", "filter", "saturate(1)")] - #[case("saturate-150", "filter", "saturate(1.5)")] - #[case("saturate-200", "filter", "saturate(2)")] - #[case("sepia-0", "filter", "sepia(0)")] - #[case("sepia", "filter", "sepia(100%)")] - fn test_parse_filter_effects_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-blur", "backdrop-filter", "blur(8px)")] - #[case("backdrop-blur-sm", "backdrop-filter", "blur(4px)")] - #[case("backdrop-blur-md", "backdrop-filter", "blur(12px)")] - #[case("backdrop-blur-lg", "backdrop-filter", "blur(16px)")] - #[case("backdrop-blur-xl", "backdrop-filter", "blur(24px)")] - #[case("backdrop-blur-2xl", "backdrop-filter", "blur(40px)")] - #[case("backdrop-blur-3xl", "backdrop-filter", "blur(64px)")] - #[case("backdrop-blur-none", "backdrop-filter", "blur(0)")] - fn test_parse_backdrop_blur_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-brightness-0", "backdrop-filter", "brightness(0)")] - #[case("backdrop-brightness-100", "backdrop-filter", "brightness(1)")] - #[case("backdrop-contrast-0", "backdrop-filter", "contrast(0)")] - #[case("backdrop-contrast-100", "backdrop-filter", "contrast(1)")] - #[case("backdrop-grayscale-0", "backdrop-filter", "grayscale(0)")] - #[case("backdrop-grayscale", "backdrop-filter", "grayscale(100%)")] - #[case("backdrop-invert-0", "backdrop-filter", "invert(0)")] - #[case("backdrop-invert", "backdrop-filter", "invert(100%)")] - #[case("backdrop-opacity-0", "backdrop-filter", "opacity(0)")] - #[case("backdrop-opacity-100", "backdrop-filter", "opacity(1)")] - #[case("backdrop-saturate-0", "backdrop-filter", "saturate(0)")] - #[case("backdrop-saturate-100", "backdrop-filter", "saturate(1)")] - #[case("backdrop-sepia-0", "backdrop-filter", "sepia(0)")] - #[case("backdrop-sepia", "backdrop-filter", "sepia(100%)")] - fn test_parse_backdrop_filter_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 5.7: Transitions & animations (lines 2600-2618) - #[rstest] - #[case("delay-0", "transition-delay", "0s")] - #[case("delay-75", "transition-delay", "75ms")] - #[case("delay-100", "transition-delay", "100ms")] - #[case("delay-150", "transition-delay", "150ms")] - #[case("delay-200", "transition-delay", "200ms")] - #[case("delay-300", "transition-delay", "300ms")] - #[case("delay-500", "transition-delay", "500ms")] - #[case("delay-700", "transition-delay", "700ms")] - #[case("delay-1000", "transition-delay", "1000ms")] - fn test_parse_delay_utilities( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("animate-none", "animation", "none")] #[case("animate-spin", "animation", "spin 1s linear infinite")] @@ -5182,10 +4367,10 @@ mod tests { // Wave 5.9: Interactivity (lines 2732-2842) #[rstest] #[case("accent-auto", "accent-color", "auto")] - #[case("accent-red-500", "accent-color", "#ef4444")] + #[case("accent-red-500", "accent-color", "oklch(63.7% 0.237 25.331)")] #[case("appearance-none", "appearance", "none")] #[case("appearance-auto", "appearance", "auto")] - #[case("caret-red-500", "caret-color", "#ef4444")] + #[case("caret-red-500", "caret-color", "oklch(63.7% 0.237 25.331)")] fn test_parse_interactivity_accent_caret( #[case] class: &str, #[case] expected_prop: &str, @@ -5459,14 +4644,14 @@ mod tests { // Wave 6.11: Individual rounded variants (via BORDER_RADIUS_SCALE lookup) #[rstest] - #[case("rounded-none", "border-radius", "0px")] - #[case("rounded-sm", "border-radius", "0.125rem")] + #[case("rounded-none", "border-radius", "0")] + #[case("rounded-sm", "border-radius", "0.25rem")] #[case("rounded-md", "border-radius", "0.375rem")] #[case("rounded-lg", "border-radius", "0.5rem")] #[case("rounded-xl", "border-radius", "0.75rem")] #[case("rounded-2xl", "border-radius", "1rem")] #[case("rounded-3xl", "border-radius", "1.5rem")] - #[case("rounded-full", "border-radius", "9999px")] + #[case("rounded-full", "border-radius", "calc(infinity * 1px)")] fn test_parse_individual_rounded_variants( #[case] class: &str, #[case] expected_prop: &str, @@ -5491,27 +4676,12 @@ mod tests { assert_eq!(parsed.value, expected_value); } - #[rstest] - #[case("border-0", "border-width", "0px")] - #[case("border-2", "border-width", "2px")] - #[case("border-4", "border-width", "4px")] - #[case("border-8", "border-width", "8px")] - fn test_parse_border_width_standalone( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse border width"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 6.13: Outline color (lines 2250-2251) #[rstest] #[case("outline-black", "outline-color", "#000")] #[case("outline-white", "outline-color", "#fff")] - #[case("outline-red-500", "outline-color", "#ef4444")] - #[case("outline-blue-500", "outline-color", "#3b82f6")] + #[case("outline-red-500", "outline-color", "oklch(63.7% 0.237 25.331)")] + #[case("outline-blue-500", "outline-color", "oklch(62.3% 0.214 259.815)")] fn test_parse_outline_color( #[case] class: &str, #[case] expected_prop: &str, @@ -5523,24 +4693,11 @@ mod tests { } // Wave 6.14: Ring utilities (lines 2273-2281) - #[rstest] - #[case("ring-4", "box-shadow", "0 0 0 4px var(--tw-ring-color)")] - #[case("ring-8", "box-shadow", "0 0 0 8px var(--tw-ring-color)")] - fn test_parse_ring_width_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse ring width"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[rstest] #[case("ring-black", "--tw-ring-color", "#000")] #[case("ring-white", "--tw-ring-color", "#fff")] - #[case("ring-red-500", "--tw-ring-color", "#ef4444")] - #[case("ring-blue-500", "--tw-ring-color", "#3b82f6")] + #[case("ring-red-500", "--tw-ring-color", "oklch(63.7% 0.237 25.331)")] + #[case("ring-blue-500", "--tw-ring-color", "oklch(62.3% 0.214 259.815)")] fn test_parse_ring_color( #[case] class: &str, #[case] expected_prop: &str, @@ -5552,53 +4709,7 @@ mod tests { } // Wave 6.15: Divide utilities (lines 2293-2313) - #[rstest] - #[case("divide-black", "--tw-divide-color", "#000")] - #[case("divide-white", "--tw-divide-color", "#fff")] - #[case("divide-red-500", "--tw-divide-color", "#ef4444")] - fn test_parse_divide_color( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse divide color"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("divide-solid", "border-style", "solid")] - #[case("divide-dashed", "border-style", "dashed")] - #[case("divide-dotted", "border-style", "dotted")] - #[case("divide-double", "border-style", "double")] - #[case("divide-none", "border-style", "none")] - fn test_parse_divide_style( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse divide style"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 6.16: Filter fallbacks (lines 2390, 2405, 2419) - #[test] - fn test_brightness_unknown_value_returns_none() { - let result = parse_single_class("brightness-unknown"); - assert!(result.is_none()); - let result = parse_single_class("brightness-999"); - assert!(result.is_none()); - } - - #[test] - fn test_contrast_unknown_value_returns_none() { - let result = parse_single_class("contrast-unknown"); - assert!(result.is_none()); - let result = parse_single_class("contrast-999"); - assert!(result.is_none()); - } - #[test] fn test_drop_shadow_unknown_value_returns_none() { let result = parse_single_class("drop-shadow-unknown"); @@ -5608,49 +4719,6 @@ mod tests { } // Wave 6.17: Backdrop filter extended values (lines 2504-2555) - #[rstest] - #[case("backdrop-brightness-105", "backdrop-filter", "brightness(1.05)")] - #[case("backdrop-brightness-110", "backdrop-filter", "brightness(1.1)")] - #[case("backdrop-brightness-125", "backdrop-filter", "brightness(1.25)")] - #[case("backdrop-brightness-150", "backdrop-filter", "brightness(1.5)")] - #[case("backdrop-brightness-200", "backdrop-filter", "brightness(2)")] - fn test_parse_backdrop_brightness_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-brightness"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-contrast-125", "backdrop-filter", "contrast(1.25)")] - #[case("backdrop-contrast-150", "backdrop-filter", "contrast(1.5)")] - #[case("backdrop-contrast-200", "backdrop-filter", "contrast(2)")] - fn test_parse_backdrop_contrast_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-contrast"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - - #[rstest] - #[case("backdrop-saturate-150", "backdrop-filter", "saturate(1.5)")] - #[case("backdrop-saturate-200", "backdrop-filter", "saturate(2)")] - fn test_parse_backdrop_saturate_extended( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse backdrop-saturate"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - #[test] fn test_backdrop_brightness_unknown_returns_none() { let result = parse_single_class("backdrop-brightness-unknown"); @@ -5673,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] @@ -5828,14 +4895,14 @@ mod tests { // Wave 7.5: Individual rounded variants via BORDER_RADIUS_SCALE lookup #[rstest] #[case("rounded", "border-radius", "0.25rem")] - #[case("rounded-none", "border-radius", "0px")] - #[case("rounded-sm", "border-radius", "0.125rem")] + #[case("rounded-none", "border-radius", "0")] + #[case("rounded-sm", "border-radius", "0.25rem")] #[case("rounded-md", "border-radius", "0.375rem")] #[case("rounded-lg", "border-radius", "0.5rem")] #[case("rounded-xl", "border-radius", "0.75rem")] #[case("rounded-2xl", "border-radius", "1rem")] #[case("rounded-3xl", "border-radius", "1.5rem")] - #[case("rounded-full", "border-radius", "9999px")] + #[case("rounded-full", "border-radius", "calc(infinity * 1px)")] fn test_rounded_variants_full_path( #[case] class: &str, #[case] expected_prop: &str, @@ -5847,22 +4914,6 @@ mod tests { } // Wave 7.6: border-0/2/4/8 via BORDER_WIDTH_SCALE lookup - #[rstest] - #[case("border", "border-width", "1px")] - #[case("border-0", "border-width", "0px")] - #[case("border-2", "border-width", "2px")] - #[case("border-4", "border-width", "4px")] - #[case("border-8", "border-width", "8px")] - fn test_border_width_standalone_full_path( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse border"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.7: divide- unknown value fallback (line 2313) #[rstest] #[case("divide-unknown")] @@ -5873,22 +4924,6 @@ mod tests { } // Wave 7.8: shadow without suffix (line 2333) - #[rstest] - #[case( - "shadow", - "box-shadow", - "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)" - )] - fn test_shadow_without_suffix( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse shadow"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.9: mix-blend- prefix (line 2345) #[rstest] #[case("mix-blend-normal", "mix-blend-mode", "normal")] @@ -5928,38 +4963,8 @@ mod tests { } // Wave 7.11: grayscale-0 (line 2432) - #[rstest] - #[case("grayscale-0", "filter", "grayscale(0)")] - fn test_grayscale_zero( - #[case] class: &str, - #[case] expected_prop: &str, - #[case] expected_value: &str, - ) { - let parsed = parse_single_class(class).expect("Should parse grayscale"); - assert_eq!(parsed.property, expected_prop); - assert_eq!(parsed.value, expected_value); - } - // Wave 7.12: hue-rotate- unknown value (line 2444) - #[rstest] - #[case("hue-rotate-unknown")] - #[case("hue-rotate-999")] - #[case("hue-rotate-45")] - fn test_hue_rotate_unknown_returns_none(#[case] class: &str) { - let result = parse_single_class(class); - assert!(result.is_none()); - } - // Wave 7.13: saturate- unknown value (line 2465) - #[rstest] - #[case("saturate-unknown")] - #[case("saturate-999")] - #[case("saturate-75")] - fn test_saturate_unknown_returns_none(#[case] class: &str) { - let result = parse_single_class(class); - assert!(result.is_none()); - } - // ============================================================================ // WAVE 8: Coverage Gap Tests for Lines 816, 866, 295 // ============================================================================ @@ -5978,16 +4983,6 @@ mod tests { } // Wave 8.4: parse_tailwind_to_styles integration for border widths (via BORDER_WIDTH_SCALE) - #[test] - #[serial] - fn test_parse_tailwind_to_styles_border_width_integration() { - reset_class_map(); - reset_file_map(); - - let styles = parse_tailwind_to_styles("border border-0 border-2 border-4 border-8"); - assert_eq!(styles.len(), 5); - } - // ==================== Variants (Tailwind v4) ==================== #[rstest] @@ -6183,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")] @@ -6211,10 +5205,6 @@ mod tests { #[case("nth-0:p-4")] #[case("nth-x:p-4")] #[case("print:[@media_screen]:p-4")] - #[case("p-4!")] - #[case("!p-4")] - #[case("bg-red-500/50")] - #[case("text-red-500/6")] #[case("text-sm/x")] #[case("text-sm/[_]")] #[case("text-sm/(x)")] @@ -6277,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}"); } @@ -6394,19 +5383,13 @@ mod tests { #[case("bg-[length:200px_100px]", "background-size", "200px 100px")] #[case("bg-[bg-size:cover]", "background-size", "cover")] #[case("bg-[position:top]", "background-position", "top")] - #[case("border-[3px]", "border-width", "3px")] - #[case("border-[2px_4px]", "border-width", "2px 4px")] #[case("border-[thin]", "border-width", "thin")] - #[case("border-[0]", "border-width", "0")] #[case("border-[length:var(--x)]", "border-width", "var(--x)")] #[case("border-[line-width:var(--x)]", "border-width", "var(--x)")] #[case("border-[#fff]", "border-color", "#fff")] #[case("border-[var(--x)]", "border-color", "var(--x)")] - #[case("border-t-[3px]", "border-top-width", "3px")] #[case("border-t-[red]", "border-top-color", "red")] - #[case("border-x-[1px]", "border-inline-width", "1px")] #[case("border-s-[red]", "border-inline-start-color", "red")] - #[case("outline-[3px]", "outline-width", "3px")] #[case("outline-[red]", "outline-color", "red")] #[case("stroke-[3px]", "stroke-width", "3px")] #[case("stroke-[2]", "stroke-width", "2")] @@ -6417,8 +5400,6 @@ mod tests { #[case("font-[number:var(--w)]", "font-weight", "var(--w)")] #[case("font-[Inter_Var]", "font-family", "Inter Var")] #[case("font-[family-name:var(--f)]", "font-family", "var(--f)")] - #[case("blur-[2px]", "filter", "blur(2px)")] - #[case("backdrop-blur-[2px]", "backdrop-filter", "blur(2px)")] #[case("w-[calc(100%_-_2rem)]", "width", "calc(100% - 2rem)")] #[case("m-[var(--x_y)]", "margin", "var(--x_y)")] #[case( @@ -6496,6 +5477,21 @@ mod tests { assert!(!is_balanced("(")); } + #[rstest] + #[case("inset-y-4", "inset-block", "1rem")] + #[case("inset-x-2", "inset-inline", "0.5rem")] + #[case("basis-4", "flex-basis", "1rem")] + #[case("basis-13", "flex-basis", "3.25rem")] + #[case("top-13", "top", "3.25rem")] + fn test_parse_spacing_scale_steps( + #[case] class: &str, + #[case] expected_prop: &str, + #[case] expected_value: &str, + ) { + let parsed = parse_single_class(class).expect("Should parse"); + assert_eq!(parsed.property, expected_prop); + assert_eq!(parsed.value, expected_value); + } #[test] fn test_styles_and_properties() { let class = parse_class("md:hover:p-4").unwrap(); @@ -6508,11 +5504,20 @@ mod tests { styles[0].selector().map(ToString::to_string).as_deref(), Some("@media(hover:hover) &:hover") ); - assert!(!class.uses_properties()); + assert_eq!(class.rules(), Vec::<&str>::new()); let class = parse_class("selection:translate-x-4").unwrap(); assert_eq!(class.styles().count(), 4); - assert!(class.uses_properties()); - assert!(parse_class("before:block").unwrap().uses_properties()); + assert_eq!( + class.rules(), + vec![ + "@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}", + ] + ); + assert_eq!( + parse_class("before:block").unwrap().rules(), + vec!["@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}"] + ); } } diff --git a/libs/extractor/src/tailwind_children.rs b/libs/extractor/src/tailwind_children.rs new file mode 100644 index 000000000..78d266b05 --- /dev/null +++ b/libs/extractor/src/tailwind_children.rs @@ -0,0 +1,326 @@ +//! `space-*` and `divide-*`: utilities that style the children of the element, +//! all but the last, through the selector Tailwind CSS v4 writes for them + +use crate::tailwind::{ + Declaration, arbitrary_or_variable, decl, is_positive_integer, spacing_scale, +}; +use crate::tailwind_color::color_value; + +/// The children `space-*` and `divide-*` style, which `:where` leaves to any +/// utility of the child itself +pub const CHILDREN: &str = ":where(& > :not(:last-child))"; + +/// The value of the spacing `argument`: a step of the scale, or an arbitrary +/// value, negated by a leading `-` +pub(crate) fn spacing(argument: &str, negative: bool) -> Option { + if let Some(value) = spacing_scale(argument) { + return Some(if negative && value != "0px" { + format!("-{value}") + } else { + value.to_string() + }); + } + let value = arbitrary_or_variable(argument)?; + Some(if negative { + format!("calc({value} * -1)") + } else { + value + }) +} +/// The margins of `space-x-*` and `space-y-*` +fn space_utility(axis: char, argument: &str, negative: bool) -> Option> { + let reverse = format!("--tw-space-{axis}-reverse"); + if argument == "reverse" { + return (!negative).then(|| vec![decl(reverse, "1")]); + } + let value = spacing(argument, negative)?; + let (start, end) = if axis == 'x' { + ("margin-inline-start", "margin-inline-end") + } else { + ("margin-block-start", "margin-block-end") + }; + if matches!(value.as_str(), "0px" | "0") { + return Some(vec![decl(start, "0"), decl(end, "0")]); + } + Some(vec![ + decl(start, format!("calc({value} * var({reverse}))")), + decl(end, format!("calc({value} * calc(1 - var({reverse})))")), + ]) +} + +/// The border width of `divide-x-*` and `divide-y-*`: a number of pixels, +/// or an arbitrary value +fn divide_width(argument: &str) -> Option { + if is_positive_integer(argument) { + return Some(format!("{argument}px")); + } + arbitrary_or_variable(argument) +} + +/// The borders of `divide-x-*` and `divide-y-*` +fn divide_width_utility(axis: char, argument: &str) -> Option> { + let reverse = format!("--tw-divide-{axis}-reverse"); + if argument == "-reverse" { + return Some(vec![decl(reverse, "1")]); + } + let width = if argument.is_empty() { + String::from("1px") + } else { + divide_width(argument.strip_prefix('-')?)? + }; + let start = format!("calc({width} * var({reverse}))"); + let end = format!("calc({width} * calc(1 - var({reverse})))"); + Some(if axis == 'x' { + vec![ + decl("border-inline-style", "var(--tw-border-style)"), + decl("border-inline-start-width", start), + decl("border-inline-end-width", end), + ] + } else { + vec![ + decl("border-bottom-style", "var(--tw-border-style)"), + decl("border-top-style", "var(--tw-border-style)"), + decl("border-top-width", start), + decl("border-bottom-width", end), + ] + }) +} +/// The declarations of `space-*` and `divide-*`, which style the children +pub fn children_utility(name: &str) -> Option> { + let (negative, name) = name + .strip_prefix('-') + .map_or((false, name), |name| (true, name)); + if let Some(argument) = name.strip_prefix("space-x-") { + return space_utility('x', argument, negative); + } + if let Some(argument) = name.strip_prefix("space-y-") { + return space_utility('y', argument, negative); + } + if negative { + return None; + } + let argument = name.strip_prefix("divide-")?; + if let Some(declarations) = argument + .strip_prefix('x') + .and_then(|argument| divide_width_utility('x', argument)) + .or_else(|| { + argument + .strip_prefix('y') + .and_then(|argument| divide_width_utility('y', argument)) + }) + { + return Some(declarations); + } + if matches!(argument, "solid" | "dashed" | "dotted" | "double" | "none") { + return Some(vec![ + decl("--tw-border-style", argument.to_string()), + decl("border-style", argument.to_string()), + ]); + } + color_value(argument).map(|color| vec![decl("border-color", color)]) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::tailwind::{declarations_of, parse_class}; + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + #[rstest] + #[case("space-x-0", &[("margin-inline-start", "0"), ("margin-inline-end", "0")])] + #[case("space-x-px", &[ + ("margin-inline-start", "calc(1px * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(1px * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("space-x-4", &[ + ("margin-inline-start", "calc(1rem * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(1rem * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-4", &[ + ("margin-inline-start", "calc(-1rem * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(-1rem * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-px", &[ + ("margin-inline-start", "calc(-1px * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(-1px * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-y-0", &[("margin-block-start", "0"), ("margin-block-end", "0")])] + #[case("space-y-2.5", &[ + ("margin-block-start", "calc(0.625rem * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(0.625rem * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-y-[3px]", &[ + ("margin-block-start", "calc(3px * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(3px * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-x-(--gap)", &[ + ("margin-inline-start", "calc(var(--gap) * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(var(--gap) * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-x-[3px]", &[ + ("margin-inline-start", "calc(calc(3px * -1) * var(--tw-space-x-reverse))"), + ("margin-inline-end", "calc(calc(3px * -1) * calc(1 - var(--tw-space-x-reverse)))"), + ])] + #[case("-space-y-(--gap)", &[ + ("margin-block-start", "calc(calc(var(--gap) * -1) * var(--tw-space-y-reverse))"), + ("margin-block-end", "calc(calc(var(--gap) * -1) * calc(1 - var(--tw-space-y-reverse)))"), + ])] + #[case("space-x-reverse", &[("--tw-space-x-reverse", "1")])] + #[case("space-y-reverse", &[("--tw-space-y-reverse", "1")])] + fn space_adds_margins_between_the_children( + #[case] class: &str, + #[case] expected: &[(&str, &str)], + ) { + expect(class, expected); + } + + #[rstest] + #[case("divide-x", "x", "1px")] + #[case("divide-x-0", "x", "0px")] + #[case("divide-x-2", "x", "2px")] + #[case("divide-x-8", "x", "8px")] + #[case("divide-x-[3px]", "x", "3px")] + #[case("divide-x-(--w)", "x", "var(--w)")] + #[case("divide-y", "y", "1px")] + #[case("divide-y-4", "y", "4px")] + fn divide_borders_the_children(#[case] class: &str, #[case] axis: &str, #[case] width: &str) { + if axis == "x" { + expect( + class, + &[ + ("border-inline-style", "var(--tw-border-style)"), + ( + "border-inline-start-width", + &format!("calc({width} * var(--tw-divide-x-reverse))"), + ), + ( + "border-inline-end-width", + &format!("calc({width} * calc(1 - var(--tw-divide-x-reverse)))"), + ), + ], + ); + } else { + expect( + class, + &[ + ("border-bottom-style", "var(--tw-border-style)"), + ("border-top-style", "var(--tw-border-style)"), + ( + "border-top-width", + &format!("calc({width} * var(--tw-divide-y-reverse))"), + ), + ( + "border-bottom-width", + &format!("calc({width} * calc(1 - var(--tw-divide-y-reverse)))"), + ), + ], + ); + } + } + + #[test] + fn divide_reverse_style_and_color() { + expect("divide-x-reverse", &[("--tw-divide-x-reverse", "1")]); + expect("divide-y-reverse", &[("--tw-divide-y-reverse", "1")]); + for style in ["solid", "dashed", "dotted", "double", "none"] { + expect( + &format!("divide-{style}"), + &[("--tw-border-style", style), ("border-style", style)], + ); + } + expect( + "divide-red-500", + &[("border-color", "oklch(63.7% 0.237 25.331)")], + ); + expect( + "divide-yellow-500/50", + &[( + "border-color", + "color-mix(in oklab, oklch(79.5% 0.184 86.047) 50%, transparent)", + )], + ); + } + + #[rstest] + #[case("divide-xyz")] + #[case("divide-x-")] + #[case("divide-x-1.5")] + #[case("divide-x-05")] + #[case("divide-x-reverse-2")] + #[case("divide-y-abc")] + #[case("divide-purple")] + #[case("divide-")] + #[case("-divide-x-2")] + #[case("-divide-red-500")] + #[case("space-x-")] + #[case("space-x-reverse-1")] + #[case("space-x-1.3")] + #[case("space-x-huge")] + #[case("-space-x-reverse")] + #[case("space-z-4")] + fn unknown_children_utilities_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn children_utilities_apply_under_variants_to_the_children() { + let class = parse_class("space-x-4").unwrap(); + assert_eq!(class.conditions.len(), 1); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(CHILDREN) + ); + let class = parse_class("md:space-x-4").unwrap(); + assert_eq!(class.level, 2); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(CHILDREN) + ); + let class = parse_class("focus:divide-x-2").unwrap(); + assert_eq!( + class.conditions[0] + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some(":where(&:focus > :not(:last-child))") + ); + let class = parse_class("space-x-4!").unwrap(); + assert!( + class + .declarations + .iter() + .all(|(_, value)| value.ends_with(" !important")) + ); + } + + #[test] + fn children_rules_register_their_variables() { + assert_eq!( + parse_class("space-x-4").unwrap().rules(), + vec!["@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}"] + ); + assert_eq!( + parse_class("divide-x-2").unwrap().rules(), + vec![ + "@property --tw-divide-x-reverse{syntax:\"*\";inherits:false;initial-value:0}", + "@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}", + ] + ); + } +} diff --git a/libs/extractor/src/tailwind_color.rs b/libs/extractor/src/tailwind_color.rs new file mode 100644 index 000000000..fa48bf6b0 --- /dev/null +++ b/libs/extractor/src/tailwind_color.rs @@ -0,0 +1,559 @@ +//! The colors a Tailwind class names: the devup theme's, then the default +//! palette of Tailwind CSS v4, with the opacity modifier (`/50`, `/[0.5]`, +//! `/(--alpha)`) applied the way v4 writes it + +use std::borrow::Cow; + +use css::tailwind_definitions::{namespace_is_reset, theme_value}; +use css::theme_tokens::is_color_token; +use phf::phf_map; + +use crate::tailwind::{decode_arbitrary_value, split_top_level}; + +/// The colors of the Tailwind CSS v4 default theme (`--color-*`) +static TAILWIND_COLORS: phf::Map<&'static str, &'static str> = phf_map! { + "inherit" => "inherit", + "current" => "currentcolor", + "transparent" => "transparent", + "red-50" => "oklch(97.1% 0.013 17.38)", + "red-100" => "oklch(93.6% 0.032 17.717)", + "red-200" => "oklch(88.5% 0.062 18.334)", + "red-300" => "oklch(80.8% 0.114 19.571)", + "red-400" => "oklch(70.4% 0.191 22.216)", + "red-500" => "oklch(63.7% 0.237 25.331)", + "red-600" => "oklch(57.7% 0.245 27.325)", + "red-700" => "oklch(50.5% 0.213 27.518)", + "red-800" => "oklch(44.4% 0.177 26.899)", + "red-900" => "oklch(39.6% 0.141 25.723)", + "red-950" => "oklch(25.8% 0.092 26.042)", + "orange-50" => "oklch(98% 0.016 73.684)", + "orange-100" => "oklch(95.4% 0.038 75.164)", + "orange-200" => "oklch(90.1% 0.076 70.697)", + "orange-300" => "oklch(83.7% 0.128 66.29)", + "orange-400" => "oklch(75% 0.183 55.934)", + "orange-500" => "oklch(70.5% 0.213 47.604)", + "orange-600" => "oklch(64.6% 0.222 41.116)", + "orange-700" => "oklch(55.3% 0.195 38.402)", + "orange-800" => "oklch(47% 0.157 37.304)", + "orange-900" => "oklch(40.8% 0.123 38.172)", + "orange-950" => "oklch(26.6% 0.079 36.259)", + "amber-50" => "oklch(98.7% 0.022 95.277)", + "amber-100" => "oklch(96.2% 0.059 95.617)", + "amber-200" => "oklch(92.4% 0.12 95.746)", + "amber-300" => "oklch(87.9% 0.169 91.605)", + "amber-400" => "oklch(82.8% 0.189 84.429)", + "amber-500" => "oklch(76.9% 0.188 70.08)", + "amber-600" => "oklch(66.6% 0.179 58.318)", + "amber-700" => "oklch(55.5% 0.163 48.998)", + "amber-800" => "oklch(47.3% 0.137 46.201)", + "amber-900" => "oklch(41.4% 0.112 45.904)", + "amber-950" => "oklch(27.9% 0.077 45.635)", + "yellow-50" => "oklch(98.7% 0.026 102.212)", + "yellow-100" => "oklch(97.3% 0.071 103.193)", + "yellow-200" => "oklch(94.5% 0.129 101.54)", + "yellow-300" => "oklch(90.5% 0.182 98.111)", + "yellow-400" => "oklch(85.2% 0.199 91.936)", + "yellow-500" => "oklch(79.5% 0.184 86.047)", + "yellow-600" => "oklch(68.1% 0.162 75.834)", + "yellow-700" => "oklch(55.4% 0.135 66.442)", + "yellow-800" => "oklch(47.6% 0.114 61.907)", + "yellow-900" => "oklch(42.1% 0.095 57.708)", + "yellow-950" => "oklch(28.6% 0.066 53.813)", + "lime-50" => "oklch(98.6% 0.031 120.757)", + "lime-100" => "oklch(96.7% 0.067 122.328)", + "lime-200" => "oklch(93.8% 0.127 124.321)", + "lime-300" => "oklch(89.7% 0.196 126.665)", + "lime-400" => "oklch(84.1% 0.238 128.85)", + "lime-500" => "oklch(76.8% 0.233 130.85)", + "lime-600" => "oklch(64.8% 0.2 131.684)", + "lime-700" => "oklch(64.8% 0.2 131.684)", + "lime-800" => "oklch(45.3% 0.124 130.933)", + "lime-900" => "oklch(40.5% 0.101 131.063)", + "lime-950" => "oklch(27.4% 0.072 132.109)", + "green-50" => "oklch(98.2% 0.018 155.826)", + "green-100" => "oklch(96.2% 0.044 156.743)", + "green-200" => "oklch(92.5% 0.084 155.995)", + "green-300" => "oklch(87.1% 0.15 154.449)", + "green-400" => "oklch(79.2% 0.209 151.711)", + "green-500" => "oklch(72.3% 0.219 149.579)", + "green-600" => "oklch(62.7% 0.194 149.214)", + "green-700" => "oklch(52.7% 0.154 150.069)", + "green-800" => "oklch(44.8% 0.119 151.328)", + "green-900" => "oklch(39.3% 0.095 152.535)", + "green-950" => "oklch(26.6% 0.065 152.934)", + "emerald-50" => "oklch(97.9% 0.021 166.113)", + "emerald-100" => "oklch(95% 0.052 163.051)", + "emerald-200" => "oklch(90.5% 0.093 164.15)", + "emerald-300" => "oklch(84.5% 0.143 164.978)", + "emerald-400" => "oklch(76.5% 0.177 163.223)", + "emerald-500" => "oklch(69.6% 0.17 162.48)", + "emerald-600" => "oklch(59.6% 0.145 163.225)", + "emerald-700" => "oklch(50.8% 0.118 165.612)", + "emerald-800" => "oklch(43.2% 0.095 166.913)", + "emerald-900" => "oklch(37.8% 0.077 168.94)", + "emerald-950" => "oklch(26.2% 0.051 172.552)", + "teal-50" => "oklch(98.4% 0.014 180.72)", + "teal-100" => "oklch(95.3% 0.051 180.801)", + "teal-200" => "oklch(91% 0.096 180.426)", + "teal-300" => "oklch(85.5% 0.138 181.071)", + "teal-400" => "oklch(77.7% 0.152 181.912)", + "teal-500" => "oklch(70.4% 0.14 182.503)", + "teal-600" => "oklch(60% 0.118 184.704)", + "teal-700" => "oklch(51.1% 0.096 186.391)", + "teal-800" => "oklch(43.7% 0.078 188.216)", + "teal-900" => "oklch(38.6% 0.063 188.416)", + "teal-950" => "oklch(27.7% 0.046 192.524)", + "cyan-50" => "oklch(98.4% 0.019 200.873)", + "cyan-100" => "oklch(95.6% 0.045 203.388)", + "cyan-200" => "oklch(91.7% 0.08 205.041)", + "cyan-300" => "oklch(86.5% 0.127 207.078)", + "cyan-400" => "oklch(78.9% 0.154 211.53)", + "cyan-500" => "oklch(71.5% 0.143 215.221)", + "cyan-600" => "oklch(60.9% 0.126 221.723)", + "cyan-700" => "oklch(52% 0.105 223.128)", + "cyan-800" => "oklch(45% 0.085 224.283)", + "cyan-900" => "oklch(39.8% 0.07 227.392)", + "cyan-950" => "oklch(30.2% 0.056 229.695)", + "sky-50" => "oklch(97.7% 0.013 236.62)", + "sky-100" => "oklch(95.1% 0.026 236.824)", + "sky-200" => "oklch(90.1% 0.058 230.902)", + "sky-300" => "oklch(82.8% 0.111 230.318)", + "sky-400" => "oklch(74.6% 0.16 232.661)", + "sky-500" => "oklch(68.5% 0.169 237.323)", + "sky-600" => "oklch(58.8% 0.158 241.966)", + "sky-700" => "oklch(50% 0.134 242.749)", + "sky-800" => "oklch(44.3% 0.11 240.79)", + "sky-900" => "oklch(39.1% 0.09 240.876)", + "sky-950" => "oklch(29.3% 0.066 243.157)", + "blue-50" => "oklch(97% 0.014 254.604)", + "blue-100" => "oklch(93.2% 0.032 255.585)", + "blue-200" => "oklch(88.2% 0.059 254.128)", + "blue-300" => "oklch(80.9% 0.105 251.813)", + "blue-400" => "oklch(70.7% 0.165 254.624)", + "blue-500" => "oklch(62.3% 0.214 259.815)", + "blue-600" => "oklch(54.6% 0.245 262.881)", + "blue-700" => "oklch(48.8% 0.243 264.376)", + "blue-800" => "oklch(42.4% 0.199 265.638)", + "blue-900" => "oklch(37.9% 0.146 265.522)", + "blue-950" => "oklch(28.2% 0.091 267.935)", + "indigo-50" => "oklch(96.2% 0.018 272.314)", + "indigo-100" => "oklch(93% 0.034 272.788)", + "indigo-200" => "oklch(87% 0.065 274.039)", + "indigo-300" => "oklch(78.5% 0.115 274.713)", + "indigo-400" => "oklch(67.3% 0.182 276.935)", + "indigo-500" => "oklch(58.5% 0.233 277.117)", + "indigo-600" => "oklch(51.1% 0.262 276.966)", + "indigo-700" => "oklch(45.7% 0.24 277.023)", + "indigo-800" => "oklch(39.8% 0.195 277.366)", + "indigo-900" => "oklch(35.9% 0.144 278.697)", + "indigo-950" => "oklch(25.7% 0.09 281.288)", + "violet-50" => "oklch(96.9% 0.016 293.756)", + "violet-100" => "oklch(94.3% 0.029 294.588)", + "violet-200" => "oklch(89.4% 0.057 293.283)", + "violet-300" => "oklch(81.1% 0.111 293.571)", + "violet-400" => "oklch(70.2% 0.183 293.541)", + "violet-500" => "oklch(60.6% 0.25 292.717)", + "violet-600" => "oklch(54.1% 0.281 293.009)", + "violet-700" => "oklch(49.1% 0.27 292.581)", + "violet-800" => "oklch(43.2% 0.232 292.759)", + "violet-900" => "oklch(38% 0.189 293.745)", + "violet-950" => "oklch(28.3% 0.141 291.089)", + "purple-50" => "oklch(97.7% 0.014 308.299)", + "purple-100" => "oklch(94.6% 0.033 307.174)", + "purple-200" => "oklch(90.2% 0.063 306.703)", + "purple-300" => "oklch(82.7% 0.119 306.383)", + "purple-400" => "oklch(71.4% 0.203 305.504)", + "purple-500" => "oklch(62.7% 0.265 303.9)", + "purple-600" => "oklch(55.8% 0.288 302.321)", + "purple-700" => "oklch(49.6% 0.265 301.924)", + "purple-800" => "oklch(43.8% 0.218 303.724)", + "purple-900" => "oklch(38.1% 0.176 304.987)", + "purple-950" => "oklch(29.1% 0.149 302.717)", + "fuchsia-50" => "oklch(97.7% 0.017 320.058)", + "fuchsia-100" => "oklch(95.2% 0.037 318.852)", + "fuchsia-200" => "oklch(90.3% 0.076 319.62)", + "fuchsia-300" => "oklch(83.3% 0.145 321.434)", + "fuchsia-400" => "oklch(74% 0.238 322.16)", + "fuchsia-500" => "oklch(66.7% 0.295 322.15)", + "fuchsia-600" => "oklch(59.1% 0.293 322.896)", + "fuchsia-700" => "oklch(51.8% 0.253 323.949)", + "fuchsia-800" => "oklch(45.2% 0.211 324.591)", + "fuchsia-900" => "oklch(40.1% 0.17 325.612)", + "fuchsia-950" => "oklch(29.3% 0.136 325.661)", + "pink-50" => "oklch(97.1% 0.014 343.198)", + "pink-100" => "oklch(94.8% 0.028 342.258)", + "pink-200" => "oklch(89.9% 0.061 343.231)", + "pink-300" => "oklch(82.3% 0.12 346.018)", + "pink-400" => "oklch(71.8% 0.202 349.761)", + "pink-500" => "oklch(65.6% 0.241 354.308)", + "pink-600" => "oklch(59.2% 0.249 0.584)", + "pink-700" => "oklch(52.5% 0.223 3.958)", + "pink-800" => "oklch(45.9% 0.187 3.815)", + "pink-900" => "oklch(40.8% 0.153 2.432)", + "pink-950" => "oklch(28.4% 0.109 3.907)", + "rose-50" => "oklch(96.9% 0.015 12.422)", + "rose-100" => "oklch(94.1% 0.03 12.58)", + "rose-200" => "oklch(89.2% 0.058 10.001)", + "rose-300" => "oklch(81% 0.117 11.638)", + "rose-400" => "oklch(71.2% 0.194 13.428)", + "rose-500" => "oklch(64.5% 0.246 16.439)", + "rose-600" => "oklch(58.6% 0.253 17.585)", + "rose-700" => "oklch(51.4% 0.222 16.935)", + "rose-800" => "oklch(45.5% 0.188 13.697)", + "rose-900" => "oklch(41% 0.159 10.272)", + "rose-950" => "oklch(27.1% 0.105 12.094)", + "slate-50" => "oklch(98.4% 0.003 247.858)", + "slate-100" => "oklch(96.8% 0.007 247.896)", + "slate-200" => "oklch(92.9% 0.013 255.508)", + "slate-300" => "oklch(86.9% 0.022 252.894)", + "slate-400" => "oklch(70.4% 0.04 256.788)", + "slate-500" => "oklch(55.4% 0.046 257.417)", + "slate-600" => "oklch(44.6% 0.043 257.281)", + "slate-700" => "oklch(37.2% 0.044 257.287)", + "slate-800" => "oklch(27.9% 0.041 260.031)", + "slate-900" => "oklch(20.8% 0.042 265.755)", + "slate-950" => "oklch(12.9% 0.042 264.695)", + "gray-50" => "oklch(98.5% 0.002 247.839)", + "gray-100" => "oklch(96.7% 0.003 264.542)", + "gray-200" => "oklch(92.8% 0.006 264.531)", + "gray-300" => "oklch(87.2% 0.01 258.338)", + "gray-400" => "oklch(70.7% 0.022 261.325)", + "gray-500" => "oklch(55.1% 0.027 264.364)", + "gray-600" => "oklch(44.6% 0.03 256.802)", + "gray-700" => "oklch(37.3% 0.034 259.733)", + "gray-800" => "oklch(27.8% 0.033 256.848)", + "gray-900" => "oklch(21% 0.034 264.665)", + "gray-950" => "oklch(13% 0.028 261.692)", + "zinc-50" => "oklch(98.5% 0 none)", + "zinc-100" => "oklch(96.7% 0.001 286.375)", + "zinc-200" => "oklch(92% 0.004 286.32)", + "zinc-300" => "oklch(87.1% 0.006 286.286)", + "zinc-400" => "oklch(70.5% 0.015 286.067)", + "zinc-500" => "oklch(55.2% 0.016 285.938)", + "zinc-600" => "oklch(44.2% 0.017 285.786)", + "zinc-700" => "oklch(37% 0.013 285.805)", + "zinc-800" => "oklch(27.4% 0.006 286.033)", + "zinc-900" => "oklch(21% 0.006 285.885)", + "zinc-950" => "oklch(14.1% 0.005 285.823)", + "neutral-50" => "oklch(98.5% 0 none)", + "neutral-100" => "oklch(97% 0 none)", + "neutral-200" => "oklch(92.2% 0 none)", + "neutral-300" => "oklch(87% 0 none)", + "neutral-400" => "oklch(70.8% 0 none)", + "neutral-500" => "oklch(55.6% 0 none)", + "neutral-600" => "oklch(43.9% 0 none)", + "neutral-700" => "oklch(37.1% 0 none)", + "neutral-800" => "oklch(26.9% 0 none)", + "neutral-900" => "oklch(20.5% 0 none)", + "neutral-950" => "oklch(14.5% 0 none)", + "stone-50" => "oklch(98.5% 0.001 106.423)", + "stone-100" => "oklch(97% 0.001 106.424)", + "stone-200" => "oklch(92.3% 0.003 48.717)", + "stone-300" => "oklch(86.9% 0.005 56.366)", + "stone-400" => "oklch(70.9% 0.01 56.259)", + "stone-500" => "oklch(55.3% 0.013 58.071)", + "stone-600" => "oklch(44.4% 0.011 73.639)", + "stone-700" => "oklch(37.4% 0.01 67.558)", + "stone-800" => "oklch(26.8% 0.007 34.298)", + "stone-900" => "oklch(21.6% 0.006 56.043)", + "stone-950" => "oklch(14.7% 0.004 49.25)", + "mauve-50" => "oklch(98.5% 0 none)", + "mauve-100" => "oklch(96% 0.003 325.6)", + "mauve-200" => "oklch(92.2% 0.005 325.62)", + "mauve-300" => "oklch(86.5% 0.012 325.68)", + "mauve-400" => "oklch(71.1% 0.019 323.02)", + "mauve-500" => "oklch(54.2% 0.034 322.5)", + "mauve-600" => "oklch(43.5% 0.029 321.78)", + "mauve-700" => "oklch(36.4% 0.029 323.89)", + "mauve-800" => "oklch(26.3% 0.024 320.12)", + "mauve-900" => "oklch(21.2% 0.019 322.12)", + "mauve-950" => "oklch(14.5% 0.008 326)", + "olive-50" => "oklch(98.8% 0.003 106.5)", + "olive-100" => "oklch(96.6% 0.005 106.5)", + "olive-200" => "oklch(93% 0.007 106.5)", + "olive-300" => "oklch(88% 0.011 106.6)", + "olive-400" => "oklch(73.7% 0.021 106.9)", + "olive-500" => "oklch(58% 0.031 107.3)", + "olive-600" => "oklch(46.6% 0.025 107.3)", + "olive-700" => "oklch(39.4% 0.023 107.4)", + "olive-800" => "oklch(28.6% 0.016 107.4)", + "olive-900" => "oklch(22.8% 0.013 107.4)", + "olive-950" => "oklch(15.3% 0.006 107.1)", + "mist-50" => "oklch(98.7% 0.002 197.1)", + "mist-100" => "oklch(96.3% 0.002 197.1)", + "mist-200" => "oklch(92.5% 0.005 214.3)", + "mist-300" => "oklch(87.2% 0.007 219.6)", + "mist-400" => "oklch(72.3% 0.014 214.4)", + "mist-500" => "oklch(56% 0.021 213.5)", + "mist-600" => "oklch(45% 0.017 213.2)", + "mist-700" => "oklch(37.8% 0.015 216)", + "mist-800" => "oklch(27.5% 0.011 216.9)", + "mist-900" => "oklch(21.8% 0.008 223.9)", + "mist-950" => "oklch(14.8% 0.004 228.8)", + "taupe-50" => "oklch(98.6% 0.002 67.8)", + "taupe-100" => "oklch(96% 0.002 17.2)", + "taupe-200" => "oklch(92.2% 0.005 34.3)", + "taupe-300" => "oklch(86.8% 0.007 39.5)", + "taupe-400" => "oklch(71.4% 0.014 41.2)", + "taupe-500" => "oklch(54.7% 0.021 43.1)", + "taupe-600" => "oklch(43.8% 0.017 39.3)", + "taupe-700" => "oklch(36.7% 0.016 35.7)", + "taupe-800" => "oklch(26.8% 0.011 36.5)", + "taupe-900" => "oklch(21.4% 0.009 43.1)", + "taupe-950" => "oklch(14.7% 0.004 49.3)", + "black" => "#000", + "white" => "#fff", +}; + +/// Whether `value` is a multiple of 0.25 written without needless zeros, which +/// is what v4 takes for an opacity +fn is_opacity(value: &str) -> bool { + value + .parse::() + .is_ok_and(|number| number >= 0.0 && number % 0.25 == 0.0 && number.to_string() == value) +} + +/// The alpha of an opacity modifier, as `color-mix` takes it +fn alpha(modifier: &str) -> Option { + if let Some(inner) = modifier + .strip_prefix('[') + .and_then(|inner| inner.strip_suffix(']')) + { + return Some(percentage(&decode_arbitrary_value(inner))); + } + if let Some(inner) = modifier + .strip_prefix('(') + .and_then(|inner| inner.strip_suffix(')')) + { + return inner.starts_with("--").then(|| format!("var({inner})")); + } + is_opacity(modifier).then(|| format!("{modifier}%")) +} + +/// `0.5` as the `50%` v4 turns a number into, anything else as written +fn percentage(value: &str) -> String { + value + .parse::() + .ok() + .filter(|number| { + number.is_finite() && value.starts_with(|c: char| c.is_ascii_digit() || c == '.') + }) + .map_or_else( + || value.to_string(), + |number| format!("{}%", number * 100.0), + ) +} + +/// `color` at `alpha`, which is `color` itself at a hundred percent +fn with_alpha(color: &str, alpha: &str) -> String { + if alpha == "100%" { + color.to_string() + } else { + format!("color-mix(in oklab, {color} {alpha}, transparent)") + } +} + +/// The color of the theme or the palette named `name` +fn named_color(name: &str) -> Option> { + if is_color_token(name) { + return Some(Cow::Owned(format!("var(--{name})"))); + } + if let Some(value) = theme_value("color", name) { + return Some(palette_reference(&value).unwrap_or(Cow::Owned(value))); + } + let universal = matches!(name, "inherit" | "current" | "transparent"); + if namespace_is_reset("color") && !universal { + return None; + } + TAILWIND_COLORS.get(name).map(|&color| Cow::Borrowed(color)) +} + +/// The palette color a theme color that is ar(--color-) stands for +fn palette_reference(value: &str) -> Option> { + let name = value.strip_prefix("var(--color-")?.strip_suffix(')')?; + TAILWIND_COLORS.get(name).map(|&color| Cow::Borrowed(color)) +} + +/// The color a class names (`red-500`, `primary`, `black/50`), `None` when it +/// names none +pub fn color_value(name: &str) -> Option> { + let mut parts = split_top_level(name, '/'); + let modifier = if parts.len() == 2 { parts.pop() } else { None }; + let [name] = parts[..] else { + return None; + }; + let color = named_color(name)?; + match modifier { + None => Some(color), + Some(_) => with_modifier(&color, modifier), + } +} + +/// `color` with the opacity `modifier`, if there is one. `inherit` has no +/// color to mix. +pub fn with_modifier(color: &str, modifier: Option<&str>) -> Option> { + let Some(modifier) = modifier else { + return Some(Cow::Owned(color.to_string())); + }; + if color == "inherit" { + return None; + } + Some(Cow::Owned(with_alpha(color, &alpha(modifier)?))) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeSet; + + use css::theme_tokens::set_color_tokens; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::tailwind::{decl, declarations_of}; + + fn color(class: &str) -> Option { + let mut declarations = declarations_of(class)?; + assert_eq!(declarations.len(), 1, "{class}: {declarations:?}"); + Some(declarations.remove(0).1) + } + + #[rstest] + #[case("bg-red-500", "oklch(63.7% 0.237 25.331)")] + #[case("bg-blue-500", "oklch(62.3% 0.214 259.815)")] + #[case("bg-slate-50", "oklch(98.4% 0.003 247.858)")] + #[case("bg-neutral-50", "oklch(98.5% 0 none)")] + #[case("bg-mauve-950", "oklch(14.5% 0.008 326)")] + #[case("bg-black", "#000")] + #[case("bg-white", "#fff")] + #[case("bg-transparent", "transparent")] + #[case("bg-current", "currentcolor")] + #[case("bg-inherit", "inherit")] + #[case("text-emerald-400", "oklch(76.5% 0.177 163.223)")] + #[case("border-rose-950", "oklch(27.1% 0.105 12.094)")] + #[case("outline-sky-300", "oklch(82.8% 0.111 230.318)")] + #[case("accent-lime-600", "oklch(64.8% 0.2 131.684)")] + #[case("caret-fuchsia-100", "oklch(95.2% 0.037 318.852)")] + #[case("fill-zinc-900", "oklch(21% 0.006 285.885)")] + #[case("stroke-stone-200", "oklch(92.3% 0.003 48.717)")] + fn palette_is_the_default_theme_of_v4(#[case] class: &str, #[case] expected: &str) { + assert_eq!(color(class).as_deref(), Some(expected)); + } + + #[rstest] + #[case( + "bg-red-500/50", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)" + )] + #[case( + "bg-red-500/0", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 0%, transparent)" + )] + #[case( + "bg-red-500/12.5", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 12.5%, transparent)" + )] + #[case("bg-red-500/100", "oklch(63.7% 0.237 25.331)")] + #[case( + "bg-red-500/[71.37%]", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 71.37%, transparent)" + )] + #[case( + "bg-red-500/[0.5]", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)" + )] + #[case( + "bg-red-500/(--alpha)", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--alpha), transparent)" + )] + #[case("bg-current/50", "color-mix(in oklab, currentcolor 50%, transparent)")] + #[case( + "bg-transparent/50", + "color-mix(in oklab, transparent 50%, transparent)" + )] + #[case("bg-black/10", "color-mix(in oklab, #000 10%, transparent)")] + #[case("text-white/75", "color-mix(in oklab, #fff 75%, transparent)")] + fn opacity_modifier_mixes_with_transparent(#[case] class: &str, #[case] expected: &str) { + assert_eq!(color(class).as_deref(), Some(expected)); + } + + #[rstest] + #[case("bg-inherit/50")] + #[case("bg-red-500/")] + #[case("bg-red-500/abc")] + #[case("bg-red-500/-5")] + #[case("bg-red-500/12.3")] + #[case("bg-red-500/012")] + #[case("bg-red-500/(alpha)")] + #[case("bg-red-500/50/50")] + #[case("bg-red-501")] + #[case("bg-unknown")] + fn invalid_color_stays_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn number_alphas_are_percentages() { + assert_eq!(percentage("0.5"), "50%"); + assert_eq!(percentage(".25"), "25%"); + assert_eq!(percentage("71.37%"), "71.37%"); + assert_eq!(percentage("var(--a)"), "var(--a)"); + assert_eq!(percentage("+1"), "+1"); + assert_eq!(percentage("inf"), "inf"); + assert!(is_opacity("0.25")); + assert!(!is_opacity("0.30")); + assert!(!is_opacity("-1")); + } + + #[test] + #[serial] + fn theme_colors_are_the_css_variables_of_the_theme() { + set_color_tokens(BTreeSet::from([ + String::from("primary"), + String::from("text"), + String::from("red-500"), + ])); + assert_eq!(color("bg-primary").as_deref(), Some("var(--primary)")); + assert_eq!(color("text-text").as_deref(), Some("var(--text)")); + assert_eq!(color("border-primary").as_deref(), Some("var(--primary)")); + assert_eq!( + color("bg-primary/50").as_deref(), + Some("color-mix(in oklab, var(--primary) 50%, transparent)") + ); + assert_eq!(color("bg-red-500").as_deref(), Some("var(--red-500)")); + assert_eq!( + color("bg-blue-500").as_deref(), + Some("oklch(62.3% 0.214 259.815)") + ); + assert_eq!(declarations_of("bg-secondary"), None); + set_color_tokens(BTreeSet::new()); + assert_eq!(declarations_of("bg-primary"), None); + } + + #[rstest] + #[case("p-4!", &[("padding", "1rem !important")])] + #[case("!p-4", &[("padding", "1rem !important")])] + #[case("-m-4!", &[("margin", "-1rem !important")])] + #[case("[color:red]!", &[("color", "red !important")])] + #[case("bg-red-500/50!", &[("background-color", "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent) !important")])] + #[case("blur-sm!", &[ + ("--tw-blur", "blur(8px) !important"), + ("filter", "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,) !important"), + ])] + fn important_marks_every_declaration(#[case] class: &str, #[case] expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected)); + } + + #[test] + fn important_applies_under_variants() { + let class = crate::tailwind::parse_class("hover:p-4!").unwrap(); + assert_eq!(class.declarations, vec![decl("padding", "1rem !important")]); + assert_eq!(crate::tailwind::parse_class("p-4!!"), None); + assert_eq!(crate::tailwind::parse_class("!"), None); + } +} diff --git a/libs/extractor/src/tailwind_custom.rs b/libs/extractor/src/tailwind_custom.rs new file mode 100644 index 000000000..378574d68 --- /dev/null +++ b/libs/extractor/src/tailwind_custom.rs @@ -0,0 +1,322 @@ +//! 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 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")])); + 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); + 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}"); + } + 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 new file mode 100644 index 000000000..971b477c0 --- /dev/null +++ b/libs/extractor/src/tailwind_effects.rs @@ -0,0 +1,1596 @@ +//! Filters, backdrop filters, shadows and gradients: the Tailwind CSS v4 +//! utilities that compose through custom properties, so that `blur-sm` and +//! `grayscale` on one element add up instead of overwriting each other + +use std::borrow::Cow; + +use css::theme_tokens::is_shadow_token; + +use crate::tailwind::{ + Declaration, ValueType, arbitrary_or_variable, decl, decode_arbitrary_value, + is_positive_integer, split_top_level, value_type, +}; +use crate::tailwind_color::{color_value, with_modifier}; +use crate::tailwind_theme::themed; + +/// What `filter` holds: every function the filter utilities set +const FILTER: &str = "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)"; + +/// What `backdrop-filter` holds +const BACKDROP_FILTER: &str = "var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)"; + +/// How a bare number after a filter utility reads +#[derive(Clone, Copy)] +enum Bare { + Percent, + Degrees, + Opacity, + Blur, +} + +/// A function the filter utilities set +struct FilterFunction { + root: &'static str, + bare: Bare, + /// The value of the utility without one + default: Option<&'static str>, + /// Whether a leading `-` negates it + negative: bool, +} + +static FILTER_FUNCTIONS: [FilterFunction; 9] = [ + FilterFunction { + root: "blur", + bare: Bare::Blur, + default: Some("8px"), + negative: false, + }, + FilterFunction { + root: "brightness", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "contrast", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "grayscale", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, + FilterFunction { + root: "hue-rotate", + bare: Bare::Degrees, + default: None, + negative: true, + }, + FilterFunction { + root: "invert", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, + FilterFunction { + root: "opacity", + bare: Bare::Opacity, + default: None, + negative: false, + }, + FilterFunction { + root: "saturate", + bare: Bare::Percent, + default: None, + negative: false, + }, + FilterFunction { + root: "sepia", + bare: Bare::Percent, + default: Some("100%"), + negative: false, + }, +]; + +/// The sizes of `blur-*` +fn blur_size(name: &str) -> Option<&'static str> { + Some(match name { + "xs" => "4px", + "sm" => "8px", + "md" => "12px", + "lg" => "16px", + "xl" => "24px", + "2xl" => "40px", + "3xl" => "64px", + _ => return None, + }) +} + +/// The value of a bare number such as the `50` of `brightness-50` +fn bare_value(bare: Bare, value: &str) -> Option { + match bare { + Bare::Percent if is_positive_integer(value) => Some(format!("{value}%")), + Bare::Degrees if is_positive_integer(value) => Some(format!("{value}deg")), + Bare::Opacity if is_opacity_number(value) => Some(format!("{value}%")), + Bare::Blur => themed("blur", value, blur_size).map(Cow::into_owned), + _ => None, + } +} + +/// A multiple of 0.25, written without needless zeros +fn is_opacity_number(value: &str) -> bool { + value + .parse::() + .is_ok_and(|number| number >= 0.0 && number % 0.25 == 0.0 && number.to_string() == value) +} + +/// What a filter utility puts in the function: its default, a size, an +/// arbitrary value or a bare number +fn function_value( + function: &FilterFunction, + argument: Option<&str>, + negative: bool, +) -> Option { + let value = match argument { + None if function.root == "blur" => themed("blur", "", |_| Some("8px"))?.into_owned(), + None => function.default?.to_string(), + Some(argument) => { + arbitrary_or_variable(argument).or_else(|| bare_value(function.bare, argument))? + } + }; + match (negative, function.negative) { + (false, _) => Some(value), + (true, true) => Some(format!("calc({value} * -1)")), + (true, false) => None, + } +} + +/// The declarations of a filter or backdrop filter utility such as `blur-sm`, +/// `backdrop-opacity-50` or `filter-none` +fn filter_utility(name: &str, negative: bool) -> Option> { + let (backdrop, name) = name + .strip_prefix("backdrop-") + .map_or((false, name), |name| (true, name)); + let composed = if backdrop { BACKDROP_FILTER } else { FILTER }; + let property = if backdrop { + "backdrop-filter" + } else { + "filter" + }; + let apply = |value: Cow<'static, str>| { + let mut declarations = Vec::with_capacity(3); + if backdrop { + declarations.push(decl("-webkit-backdrop-filter", value.clone())); + } + declarations.push(decl(property, value)); + declarations + }; + if !negative { + if name == "filter" { + return Some(apply(Cow::Borrowed(composed))); + } + if let Some(argument) = name.strip_prefix("filter-") { + let value = if argument == "none" { + Cow::Borrowed("none") + } else { + Cow::Owned(arbitrary_or_variable(argument)?) + }; + return Some(apply(value)); + } + } + let function = FILTER_FUNCTIONS + .iter() + .filter(|function| backdrop || function.root != "opacity") + .find(|function| { + name == function.root + || name + .strip_prefix(function.root) + .is_some_and(|rest| rest.starts_with('-')) + })?; + let argument = name + .strip_prefix(function.root) + .and_then(|rest| rest.strip_prefix('-')); + let prefix = if backdrop { "--tw-backdrop-" } else { "--tw-" }; + // `blur-none` empties the function, which `var(--tw-blur,)` then skips + let value = if argument == Some("none") && function.root == "blur" && !negative { + String::from(" ") + } else { + format!( + "{}({})", + function.root, + function_value(function, argument, negative)? + ) + }; + let mut declarations = vec![decl(format!("{prefix}{}", function.root), value)]; + declarations.extend(apply(Cow::Borrowed(composed))); + Some(declarations) +} + +/// What `box-shadow` holds: the shadows `shadow-*` and `ring-*` set, each +/// nothing until one does +const BOX_SHADOW: &str = "var(--tw-inset-shadow,0 0 #0000), var(--tw-inset-ring-shadow,0 0 #0000), var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)"; + +/// The shadows of the default theme, each color to be replaced by `--tw-shadow-color` +fn shadow_size(name: &str) -> Option<&'static str> { + Some(match name { + "2xs" => "0 1px rgb(0 0 0 / 0.05)", + "xs" => "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "inner" => "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)", + "" | "sm" => "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "md" => "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + "lg" => "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", + "xl" => "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + "2xl" => "0 25px 50px -12px rgb(0 0 0 / 0.25)", + _ => return None, + }) +} + +/// Whether `token` of a shadow layer is its color +fn is_shadow_color(token: &str) -> bool { + const FUNCTIONS: [&str; 10] = [ + "rgb(", + "rgba(", + "hsl(", + "hsla(", + "hwb(", + "lab(", + "lch(", + "oklab(", + "oklch(", + "color-mix(", + ]; + token.starts_with('#') + || FUNCTIONS.iter().any(|function| token.starts_with(function)) + || (token.bytes().all(|byte| byte.is_ascii_alphabetic()) && token != "inset") +} + +/// `shadow` with the color of each of its layers taken from `--tw-shadow-color` +/// when a `shadow-` utility sets one +fn recolored(shadow: &str, variable: &str) -> String { + split_top_level(shadow, ',') + .into_iter() + .map(|layer| { + split_top_level(layer.trim(), ' ') + .into_iter() + .map(|token| { + if is_shadow_color(token) { + format!("var({variable}, {token})") + } else { + token.to_string() + } + }) + .collect::>() + .join(" ") + }) + .collect::>() + .join(", ") +} +/// 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> { + 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(family.root)? + .strip_prefix('-') + .filter(|argument| !argument.is_empty())? + }; + 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(if family.composed.is_some() { + "0 0 #0000" + } else { + "none" + }))); + } + "inherit" => return Some(vec![decl(family.color, "inherit")]), + _ => {} + } + if family.root == "shadow" && is_shadow_token(argument) { + return Some(composed(format!("var(--{argument})"))); + } + let color = shadow_color(argument)?; + Some(vec![decl( + 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 + Named, + /// An arbitrary value that is a color + Color(Cow<'static, str>), + /// An arbitrary value that is not, or a modifier that cannot be applied + Invalid, +} +/// The color of `shadow-`, a named one or an arbitrary one +fn shadow_color(argument: &str) -> Option> { + match arbitrary_color(argument) { + Argument::Named => color_value(argument), + Argument::Color(color) => Some(color), + Argument::Invalid => None, + } +} + +/// `[value]` or `[value]/50` read as a color +fn arbitrary_color(argument: &str) -> Argument { + let parts = split_top_level(argument, '/'); + let Some(value) = parts[0] + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + else { + return Argument::Named; + }; + let value = decode_arbitrary_value(value); + let single = !value.is_empty() && split_top_level(&value, ' ').len() == 1; + if !single || value_type(None, &value) != Some(ValueType::Color) { + return Argument::Invalid; + } + let modifier = match parts[1..] { + [] => None, + [modifier] => Some(modifier), + _ => return Argument::Invalid, + }; + with_modifier(&value, modifier).map_or(Argument::Invalid, Argument::Color) +} +/// The drop shadows of the default theme +fn drop_shadow_size(name: &str) -> Option<&'static str> { + Some(match name { + "xs" => "0 1px 1px rgb(0 0 0 / 0.05)", + "sm" => "0 1px 2px rgb(0 0 0 / 0.15)", + "md" => "0 3px 3px rgb(0 0 0 / 0.12)", + "lg" => "0 4px 4px rgb(0 0 0 / 0.15)", + "xl" => "0 9px 7px rgb(0 0 0 / 0.1)", + "2xl" => "0 25px 25px rgb(0 0 0 / 0.15)", + "" => "0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06)", + _ => return None, + }) +} + +/// `shadow`, a list of shadows, as the `drop-shadow()` functions of `filter` +fn drop_shadows(shadow: &str) -> String { + split_top_level(shadow, ',') + .into_iter() + .map(|layer| format!("drop-shadow({})", layer.trim())) + .collect::>() + .join(" ") +} + +/// The declarations of a `drop-shadow` utility: composed into `filter` with +/// the other filters, its color apart from its size +fn drop_shadow_utility(name: &str) -> Option> { + let argument = if name == "drop-shadow" { + "" + } else { + name.strip_prefix("drop-shadow-") + .filter(|argument| !argument.is_empty())? + }; + let sized = |size: String, shadow: String| { + vec![ + decl( + "--tw-drop-shadow-size", + drop_shadows(&recolored(&size, "--tw-drop-shadow-color")), + ), + decl("--tw-drop-shadow", shadow), + decl("filter", FILTER), + ] + }; + if argument == "none" { + return Some(vec![decl("--tw-drop-shadow", " "), decl("filter", FILTER)]); + } + if argument == "inherit" { + return Some(vec![ + decl("--tw-drop-shadow-color", "inherit"), + decl("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), + ]); + } + if let Some(size) = themed("drop-shadow", argument, drop_shadow_size) { + return Some(sized(size.to_string(), drop_shadows(&size))); + } + if let Some(value) = arbitrary_or_variable(argument) + .filter(|_| argument.starts_with('[')) + .filter(|value| { + value_type(None, value) != Some(ValueType::Color) + || split_top_level(value, ' ').len() > 1 + }) + { + return Some(sized(value, String::from("var(--tw-drop-shadow-size)"))); + } + let color = shadow_color(argument)?; + Some(vec![ + decl( + "--tw-drop-shadow-color", + format!("color-mix(in oklab, {color} var(--tw-drop-shadow-alpha), transparent)"), + ), + decl("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), + ]) +} + +/// The color of a ring when no `ring-` sets one +const RING_COLOR: &str = "currentcolor"; + +/// The declarations of `ring`, `inset-ring` and `ring-offset` utilities: a +/// width, which draws the ring, or a color, which the ring reads +fn ring_utility(name: &str) -> Option> { + if name == "ring-inset" { + return Some(vec![decl("--tw-ring-inset", "inset")]); + } + let (root, argument) = if let Some(argument) = name.strip_prefix("ring-offset") { + ("ring-offset", argument) + } else if let Some(argument) = name.strip_prefix("inset-ring") { + ("inset-ring", argument) + } else { + ("ring", name.strip_prefix("ring")?) + }; + let argument = if argument.is_empty() { + None + } else { + Some( + argument + .strip_prefix('-') + .filter(|argument| !argument.is_empty())?, + ) + }; + let width = argument.and_then(|argument| { + if is_positive_integer(argument) { + Some(format!("{argument}px")) + } else { + arbitrary_or_variable(argument) + .filter(|_| argument.starts_with('[')) + .filter(|value| value_type(None, value) == Some(ValueType::Length)) + } + }); + let default_width = argument.is_none().then(|| String::from("1px")); + match (width.or(default_width), root) { + (Some(width), "ring") => Some(vec![ + decl( + "--tw-ring-shadow", + format!( + "var(--tw-ring-inset, ) 0 0 0 calc({width} + var(--tw-ring-offset-width)) var(--tw-ring-color, {RING_COLOR})" + ), + ), + decl("box-shadow", BOX_SHADOW), + ]), + (Some(width), "inset-ring") => Some(vec![ + decl( + "--tw-inset-ring-shadow", + format!("inset 0 0 0 {width} var(--tw-inset-ring-color, currentcolor)"), + ), + decl("box-shadow", BOX_SHADOW), + ]), + (Some(width), _) if argument.is_some() => Some(vec![ + decl("--tw-ring-offset-width", width), + decl( + "--tw-ring-offset-shadow", + "var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)", + ), + ]), + (Some(_), _) => None, + (None, _) => { + let color = shadow_color(argument?)?; + let property = match root { + "ring" => "--tw-ring-color", + "inset-ring" => "--tw-inset-ring-color", + _ => "--tw-ring-offset-color", + }; + Some(vec![decl(property, color)]) + } + } +} +/// The directions of `bg-linear-*` +fn linear_direction(name: &str) -> Option<&'static str> { + Some(match name { + "to-t" => "to top", + "to-tr" => "to top right", + "to-r" => "to right", + "to-br" => "to bottom right", + "to-b" => "to bottom", + "to-bl" => "to bottom left", + "to-l" => "to left", + "to-tl" => "to top left", + _ => return None, + }) +} + +/// The color space the gradient `modifier` (`/oklch`, `/longer`) interpolates in +fn interpolation(modifier: Option<&str>) -> Option { + let Some(modifier) = modifier else { + return Some(String::from("in oklab")); + }; + if let Some(value) = modifier + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + return Some(decode_arbitrary_value(value)); + } + Some(match modifier { + "longer" | "shorter" | "increasing" | "decreasing" => { + format!("in oklch {modifier} hue") + } + "oklab" | "oklch" | "srgb" | "hsl" => format!("in {modifier}"), + _ => return None, + }) +} + +/// A gradient: its position and the image drawn from the stops +fn gradient(position: String, function: &str, fallback: Option<&str>) -> Vec { + let stops = fallback.map_or_else( + || String::from("var(--tw-gradient-stops)"), + |fallback| format!("var(--tw-gradient-stops,{fallback})"), + ); + vec![ + decl("--tw-gradient-position", position), + decl("background-image", format!("{function}-gradient({stops})")), + ] +} + +/// `bg-linear-*`, `bg-conic-*` and `bg-radial-*` +fn gradient_utility(name: &str, negative: bool) -> Option> { + let rest = name.strip_prefix("bg-")?; + let parts = split_top_level(rest, '/'); + let (&root, modifier) = (parts.first()?, parts.get(1).copied()); + if parts.len() > 2 { + return None; + } + let (kind, argument) = match root.split_once('-') { + Some((kind, argument)) => (kind, Some(argument)), + None => (root, None), + }; + if !matches!(kind, "linear" | "conic" | "radial") { + return None; + } + if let Some(value) = argument.and_then(arbitrary_or_variable) { + if modifier.is_some() || negative { + return None; + } + return Some(gradient(value.clone(), kind, Some(&value))); + } + let method = interpolation(modifier)?; + let angle = |degrees: &str| { + is_positive_integer(degrees).then(|| { + if negative { + format!("calc({degrees}deg * -1)") + } else { + format!("{degrees}deg") + } + }) + }; + let position = match (kind, argument) { + ("linear", Some(argument)) => match linear_direction(argument) { + Some(direction) if !negative => format!("{direction} {method}"), + Some(_) => return None, + None => format!("{} {method}", angle(argument)?), + }, + ("conic", Some(argument)) => format!("from {} {method}", angle(argument)?), + ("conic" | "radial", None) if !negative => method, + _ => return None, + }; + Some(gradient(position, kind, None)) +} + +/// The stops a color stop utility sets +const STOPS: &str = "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))"; + +/// The stops once `via` is set +const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; + +/// A color or a position of a gradient stop: `from-red-500`, `via-10%`, +/// `to-[#fff]` +fn stop_utility(name: &str) -> Option> { + if name == "via-none" { + return Some(vec![decl("--tw-gradient-via-stops", "initial")]); + } + let (stop, rest) = name.split_once('-')?; + if !matches!(stop, "from" | "via" | "to") { + return None; + } + let position = |value: String| { + let property = match stop { + "from" => "--tw-gradient-from-position", + "via" => "--tw-gradient-via-position", + _ => "--tw-gradient-to-position", + }; + Some(vec![decl(property, value)]) + }; + if let Some(percent) = rest.strip_suffix('%') { + return if is_positive_integer(percent) { + position(rest.to_string()) + } else { + None + }; + } + if let Some(value) = rest + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + let value = decode_arbitrary_value(value); + if value_type(None, &value) == Some(ValueType::Length) { + return position(value); + } + } + let color = match arbitrary_color(rest) { + Argument::Named => color_value(rest)?, + Argument::Color(color) => color, + Argument::Invalid => return None, + }; + Some(match stop { + "from" => vec![ + decl("--tw-gradient-from", color), + decl("--tw-gradient-stops", STOPS), + ], + "via" => vec![ + decl("--tw-gradient-via", color), + decl("--tw-gradient-via-stops", VIA_STOPS), + decl("--tw-gradient-stops", "var(--tw-gradient-via-stops)"), + ], + _ => vec![ + decl("--tw-gradient-to", color), + decl("--tw-gradient-stops", STOPS), + ], + }) +} + +/// The declarations of the utilities of this module, `None` for any other +pub fn effect_utility(name: &str, negative: bool) -> Option> { + if let Some(declarations) = filter_utility(name, negative) { + return Some(declarations); + } + if let Some(declarations) = gradient_utility(name, negative) { + return Some(declarations); + } + if negative { + return None; + } + shadow_utility(name) + .or_else(|| ring_utility(name)) + .or_else(|| drop_shadow_utility(name)) + .or_else(|| stop_utility(name)) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use css::theme_tokens::set_theme_token_levels; + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::tailwind::{TailwindClass, declarations_of, parse_class}; + + fn written(class: &str) -> Vec<(String, String)> { + declarations_of(class).unwrap_or_else(|| panic!("{class} stays as written")) + } + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(written(class), expected, "{class}"); + } + + fn filter(variable: &str, value: &str) -> Vec<(String, String)> { + vec![ + (variable.to_string(), value.to_string()), + ("filter".to_string(), FILTER.to_string()), + ] + } + + fn backdrop(variable: &str, value: &str) -> Vec<(String, String)> { + vec![ + (variable.to_string(), value.to_string()), + ( + "-webkit-backdrop-filter".to_string(), + BACKDROP_FILTER.to_string(), + ), + ("backdrop-filter".to_string(), BACKDROP_FILTER.to_string()), + ] + } + + #[rstest] + #[case("blur", "blur(8px)")] + #[case("blur-xs", "blur(4px)")] + #[case("blur-sm", "blur(8px)")] + #[case("blur-md", "blur(12px)")] + #[case("blur-lg", "blur(16px)")] + #[case("blur-xl", "blur(24px)")] + #[case("blur-2xl", "blur(40px)")] + #[case("blur-3xl", "blur(64px)")] + #[case("blur-none", " ")] + #[case("blur-[2px]", "blur(2px)")] + #[case("blur-(--radius)", "blur(var(--radius))")] + fn blur_composes_through_its_variable(#[case] class: &str, #[case] value: &str) { + assert_eq!(written(class), filter("--tw-blur", value)); + } + + #[rstest] + #[case("brightness-50", "--tw-brightness", "brightness(50%)")] + #[case("brightness-125", "--tw-brightness", "brightness(125%)")] + #[case("brightness-[1.75]", "--tw-brightness", "brightness(1.75)")] + #[case("contrast-200", "--tw-contrast", "contrast(200%)")] + #[case("grayscale", "--tw-grayscale", "grayscale(100%)")] + #[case("grayscale-0", "--tw-grayscale", "grayscale(0%)")] + #[case("grayscale-25", "--tw-grayscale", "grayscale(25%)")] + #[case("hue-rotate-90", "--tw-hue-rotate", "hue-rotate(90deg)")] + #[case("-hue-rotate-90", "--tw-hue-rotate", "hue-rotate(calc(90deg * -1))")] + #[case( + "-hue-rotate-[15deg]", + "--tw-hue-rotate", + "hue-rotate(calc(15deg * -1))" + )] + #[case("invert", "--tw-invert", "invert(100%)")] + #[case("invert-0", "--tw-invert", "invert(0%)")] + #[case("saturate-150", "--tw-saturate", "saturate(150%)")] + #[case("sepia", "--tw-sepia", "sepia(100%)")] + #[case("sepia-50", "--tw-sepia", "sepia(50%)")] + fn filter_functions_set_their_own_variable( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + assert_eq!(written(class), filter(variable, value)); + } + + #[rstest] + #[case("backdrop-blur", "--tw-backdrop-blur", "blur(8px)")] + #[case("backdrop-blur-xl", "--tw-backdrop-blur", "blur(24px)")] + #[case("backdrop-blur-none", "--tw-backdrop-blur", " ")] + #[case("backdrop-blur-[2px]", "--tw-backdrop-blur", "blur(2px)")] + #[case( + "backdrop-brightness-75", + "--tw-backdrop-brightness", + "brightness(75%)" + )] + #[case("backdrop-contrast-50", "--tw-backdrop-contrast", "contrast(50%)")] + #[case("backdrop-grayscale", "--tw-backdrop-grayscale", "grayscale(100%)")] + #[case( + "backdrop-hue-rotate-15", + "--tw-backdrop-hue-rotate", + "hue-rotate(15deg)" + )] + #[case( + "-backdrop-hue-rotate-15", + "--tw-backdrop-hue-rotate", + "hue-rotate(calc(15deg * -1))" + )] + #[case("backdrop-invert-25", "--tw-backdrop-invert", "invert(25%)")] + #[case("backdrop-opacity-50", "--tw-backdrop-opacity", "opacity(50%)")] + #[case("backdrop-opacity-12.5", "--tw-backdrop-opacity", "opacity(12.5%)")] + #[case("backdrop-saturate-200", "--tw-backdrop-saturate", "saturate(200%)")] + #[case("backdrop-sepia", "--tw-backdrop-sepia", "sepia(100%)")] + fn backdrop_filter_functions_compose_too( + #[case] class: &str, + #[case] variable: &str, + #[case] value: &str, + ) { + assert_eq!(written(class), backdrop(variable, value)); + } + + #[test] + fn filter_and_backdrop_filter_read_every_function() { + expect("filter", &[("filter", FILTER)]); + expect("filter-none", &[("filter", "none")]); + expect("filter-[url(#a)]", &[("filter", "url(#a)")]); + expect("filter-(--f)", &[("filter", "var(--f)")]); + expect( + "backdrop-filter", + &[ + ("-webkit-backdrop-filter", BACKDROP_FILTER), + ("backdrop-filter", BACKDROP_FILTER), + ], + ); + expect( + "backdrop-filter-none", + &[ + ("-webkit-backdrop-filter", "none"), + ("backdrop-filter", "none"), + ], + ); + expect( + "backdrop-filter-[blur(2px)]", + &[ + ("-webkit-backdrop-filter", "blur(2px)"), + ("backdrop-filter", "blur(2px)"), + ], + ); + assert_eq!(FILTER.matches("var(--tw-").count(), 9); + assert_eq!(BACKDROP_FILTER.matches("var(--tw-backdrop-").count(), 9); + } + + #[test] + fn two_filters_add_up_instead_of_overwriting() { + let blur = written("blur-sm"); + let grayscale = written("grayscale"); + let shared = blur.last().unwrap(); + assert_eq!(shared, grayscale.last().unwrap()); + assert_ne!(blur[0].0, grayscale[0].0); + let rules = [ + parse_class("blur-sm").unwrap(), + parse_class("grayscale").unwrap(), + ] + .iter() + .flat_map(TailwindClass::rules) + .collect::>(); + assert!( + rules + .iter() + .any(|rule| rule == "@property --tw-blur{syntax:\"*\";inherits:false}") + ); + assert!( + rules + .iter() + .any(|rule| rule == "@property --tw-grayscale{syntax:\"*\";inherits:false}") + ); + } + + #[rstest] + #[case("blur-huge")] + #[case("blur-")] + #[case("blur-[]")] + #[case("blur-(radius)")] + #[case("-blur-sm")] + #[case("brightness")] + #[case("brightness-")] + #[case("brightness-05")] + #[case("brightness-1.5")] + #[case("brightness-sm")] + #[case("-brightness-50")] + #[case("hue-rotate")] + #[case("opacity-blur")] + #[case("backdrop-opacity")] + #[case("backdrop-opacity-13.3")] + #[case("backdrop-blur-none/50")] + #[case("filter-blur")] + #[case("filter-[]")] + #[case("-filter")] + #[case("-filter-none")] + #[case("backdrop-")] + #[case("blurry")] + fn unknown_filters_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("shadow-2xs", "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05))")] + #[case("shadow-xs", "0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05))")] + #[case( + "shadow", + "0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-sm", + "0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-md", + "0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-lg", + "0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-xl", + "0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1))" + )] + #[case( + "shadow-2xl", + "0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25))" + )] + #[case("shadow-none", "0 0 #0000")] + fn shadow_sizes_follow_the_default_theme(#[case] class: &str, #[case] shadow: &str) { + expect( + class, + &[("--tw-shadow", shadow), ("box-shadow", BOX_SHADOW)], + ); + } + + #[test] + #[serial] + fn shadow_tokens_are_the_css_variables_of_the_theme() { + set_theme_token_levels( + BTreeMap::new(), + BTreeMap::from([(String::from("card"), vec![0, 3])]), + ); + expect( + "shadow-card", + &[("--tw-shadow", "var(--card)"), ("box-shadow", BOX_SHADOW)], + ); + set_theme_token_levels(BTreeMap::new(), BTreeMap::new()); + assert_eq!(declarations_of("shadow-card"), None); + } + + #[rstest] + #[case( + "shadow-red-500", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-red-500/50", + "color-mix(in oklab, color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-[#fff]", + "color-mix(in oklab, #fff var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-[#fff]/50", + "color-mix(in oklab, color-mix(in oklab, #fff 50%, transparent) var(--tw-shadow-alpha), transparent)" + )] + #[case( + "shadow-current", + "color-mix(in oklab, currentcolor var(--tw-shadow-alpha), transparent)" + )] + fn shadow_colors_set_the_color_the_shadows_read(#[case] class: &str, #[case] color: &str) { + expect(class, &[("--tw-shadow-color", color)]); + } + + #[test] + fn shadow_inherit_and_unknown_shadows() { + expect("shadow-inherit", &[("--tw-shadow-color", "inherit")]); + for class in [ + "shadow-", + "shadow-huge", + "shadow-red-500/13.3", + "shadow-[1px_1px_red]/50", + "shadow-[#fff]/1/2", + ] { + assert_eq!(declarations_of(class), None, "{class}"); + } + } + + #[test] + fn shadow_recoloring_keeps_what_has_no_color() { + assert_eq!(recolored("0 0", "--tw-shadow-color"), "0 0"); + assert_eq!(recolored("inset 0 1px", "--tw-shadow-color"), "inset 0 1px"); + assert_eq!( + recolored("0 0 #0000", "--tw-shadow-color"), + "0 0 var(--tw-shadow-color, #0000)" + ); + assert_eq!( + recolored("1px 1px red", "--tw-shadow-color"), + "1px 1px var(--tw-shadow-color, red)" + ); + assert_eq!( + recolored("0 1px rgb(0 0 0 / 0.05), 0 0 #0000", "--tw-shadow-color"), + "0 1px var(--tw-shadow-color, rgb(0 0 0 / 0.05)), 0 0 var(--tw-shadow-color, #0000)" + ); + } + + #[rstest] + #[case("bg-linear-to-t", "to top in oklab")] + #[case("bg-linear-to-tr", "to top right in oklab")] + #[case("bg-linear-to-r", "to right in oklab")] + #[case("bg-linear-to-br", "to bottom right in oklab")] + #[case("bg-linear-to-b", "to bottom in oklab")] + #[case("bg-linear-to-bl", "to bottom left in oklab")] + #[case("bg-linear-to-l", "to left in oklab")] + #[case("bg-linear-to-tl", "to top left in oklab")] + #[case("bg-linear-45", "45deg in oklab")] + #[case("-bg-linear-45", "calc(45deg * -1) in oklab")] + #[case("bg-linear-0", "0deg in oklab")] + #[case("bg-linear-to-r/srgb", "to right in srgb")] + #[case("bg-linear-to-r/hsl", "to right in hsl")] + #[case("bg-linear-to-r/oklch", "to right in oklch")] + #[case("bg-linear-to-r/oklab", "to right in oklab")] + #[case("bg-linear-to-r/longer", "to right in oklch longer hue")] + #[case("bg-linear-to-r/shorter", "to right in oklch shorter hue")] + #[case("bg-linear-to-r/increasing", "to right in oklch increasing hue")] + #[case("bg-linear-to-r/decreasing", "to right in oklch decreasing hue")] + #[case("bg-linear-90/[in_hsl_longer_hue]", "90deg in hsl longer hue")] + fn linear_gradients_use_the_syntax_of_v4(#[case] class: &str, #[case] position: &str) { + expect( + class, + &[ + ("--tw-gradient-position", position), + ( + "background-image", + "linear-gradient(var(--tw-gradient-stops))", + ), + ], + ); + } + + #[test] + fn conic_and_radial_gradients() { + for (class, position, image) in [ + ( + "bg-conic", + "in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic/srgb", + "in srgb", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic-180", + "from 180deg in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "-bg-conic-180", + "from calc(180deg * -1) in oklab", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-conic-90/longer", + "from 90deg in oklch longer hue", + "conic-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-radial", + "in oklab", + "radial-gradient(var(--tw-gradient-stops))", + ), + ( + "bg-radial/oklch", + "in oklch", + "radial-gradient(var(--tw-gradient-stops))", + ), + ] { + expect( + class, + &[ + ("--tw-gradient-position", position), + ("background-image", image), + ], + ); + } + } + + #[test] + fn arbitrary_gradients_carry_their_own_stops() { + expect( + "bg-linear-[25deg,red_5%,yellow_60%,lime_90%,teal]", + &[ + ( + "--tw-gradient-position", + "25deg,red 5%,yellow 60%,lime 90%,teal", + ), + ( + "background-image", + "linear-gradient(var(--tw-gradient-stops,25deg,red 5%,yellow 60%,lime 90%,teal))", + ), + ], + ); + expect( + "bg-radial-[at_50%_75%,#fff,#000]", + &[ + ("--tw-gradient-position", "at 50% 75%,#fff,#000"), + ( + "background-image", + "radial-gradient(var(--tw-gradient-stops,at 50% 75%,#fff,#000))", + ), + ], + ); + expect( + "bg-conic-(--stops)", + &[ + ("--tw-gradient-position", "var(--stops)"), + ( + "background-image", + "conic-gradient(var(--tw-gradient-stops,var(--stops)))", + ), + ], + ); + } + + #[rstest] + #[case("bg-gradient-to-r")] + #[case("bg-gradient-to-br")] + #[case("bg-linear")] + #[case("bg-linear-")] + #[case("bg-linear-sideways")] + #[case("bg-linear-4.5")] + #[case("bg-linear-045")] + #[case("-bg-linear-to-r")] + #[case("-bg-linear")] + #[case("-bg-radial")] + #[case("-bg-conic")] + #[case("-bg-linear-[10deg,red,blue]")] + #[case("bg-linear-[10deg,red,blue]/oklch")] + #[case("bg-linear-to-r/neon")] + #[case("bg-linear-to-r/oklch/srgb")] + #[case("bg-radial-45")] + #[case("bg-conic-x")] + #[case("bg-sweep-45")] + fn gradients_v4_does_not_define_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + const STOPS: &str = "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))"; + const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; + + #[test] + fn gradient_stops_compose_through_variables() { + expect( + "from-red-500", + &[ + ("--tw-gradient-from", "oklch(63.7% 0.237 25.331)"), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "via-white", + &[ + ("--tw-gradient-via", "#fff"), + ("--tw-gradient-via-stops", VIA_STOPS), + ("--tw-gradient-stops", "var(--tw-gradient-via-stops)"), + ], + ); + expect( + "to-blue-500/50", + &[ + ( + "--tw-gradient-to", + "color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent)", + ), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "from-[#f00]", + &[ + ("--tw-gradient-from", "#f00"), + ("--tw-gradient-stops", STOPS), + ], + ); + expect( + "from-[#f00]/50", + &[ + ( + "--tw-gradient-from", + "color-mix(in oklab, #f00 50%, transparent)", + ), + ("--tw-gradient-stops", STOPS), + ], + ); + expect("via-none", &[("--tw-gradient-via-stops", "initial")]); + } + + #[test] + fn gradient_stop_positions() { + expect("from-10%", &[("--tw-gradient-from-position", "10%")]); + expect("via-30%", &[("--tw-gradient-via-position", "30%")]); + expect("to-90%", &[("--tw-gradient-to-position", "90%")]); + expect("from-[12px]", &[("--tw-gradient-from-position", "12px")]); + expect("to-[40%]", &[("--tw-gradient-to-position", "40%")]); + } + + #[rstest] + #[case("from-")] + #[case("from-1.5%")] + #[case("from-05%")] + #[case("from-%")] + #[case("from-[12px]/50")] + #[case("from-[url(a.png)]")] + #[case("from-[]")] + #[case("from-nothing")] + #[case("from-red-500/")] + #[case("-from-red-500")] + #[case("via-none/50")] + #[case("up-red-500")] + fn unknown_gradient_stops_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[test] + fn gradient_rules_register_the_stop_variables() { + let rules = parse_class("from-red-500").unwrap().rules(); + for name in [ + "--tw-gradient-position", + "--tw-gradient-from", + "--tw-gradient-to", + "--tw-gradient-stops", + "--tw-gradient-via-stops", + "--tw-gradient-from-position", + "--tw-gradient-to-position", + ] { + assert!( + rules + .iter() + .any(|rule| rule.starts_with(&format!("@property {name}{{"))), + "{name}" + ); + } + let rules = parse_class("via-30%").unwrap().rules(); + assert_eq!( + rules, + vec![ + "@property --tw-gradient-via-position{syntax:\"\";inherits:false;initial-value:50%}" + ] + ); + } + + #[test] + fn modifiers_and_variants_apply_to_composed_utilities() { + let class = parse_class("hover:blur-sm").unwrap(); + assert_eq!(class.declarations, written_pairs("blur-sm")); + assert_eq!(class.conditions.len(), 1); + assert_eq!(effect_utility("shadow-md", true), None); + assert_eq!(effect_utility("nothing", false), None); + } + + fn written_pairs( + class: &str, + ) -> Vec<( + std::borrow::Cow<'static, str>, + std::borrow::Cow<'static, str>, + )> { + written(class) + .into_iter() + .map(|(property, value)| (property.into(), value.into())) + .collect() + } + + #[rstest] + #[case("ring", "1px")] + #[case("ring-0", "0px")] + #[case("ring-2", "2px")] + #[case("ring-8", "8px")] + #[case("ring-[3px]", "3px")] + fn ring_widths_draw_the_ring_composed_with_the_shadows( + #[case] class: &str, + #[case] width: &str, + ) { + expect( + class, + &[ + ( + "--tw-ring-shadow", + &format!( + "var(--tw-ring-inset, ) 0 0 0 calc({width} + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor)" + ), + ), + ("box-shadow", BOX_SHADOW), + ], + ); + } + + #[test] + fn ring_colors_inset_rings_and_offsets() { + expect("ring-inset", &[("--tw-ring-inset", "inset")]); + expect( + "ring-red-500", + &[("--tw-ring-color", "oklch(63.7% 0.237 25.331)")], + ); + expect( + "ring-red-500/50", + &[( + "--tw-ring-color", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent)", + )], + ); + expect("ring-[#f00]", &[("--tw-ring-color", "#f00")]); + expect( + "inset-ring", + &[ + ( + "--tw-inset-ring-shadow", + "inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor)", + ), + ("box-shadow", BOX_SHADOW), + ], + ); + expect( + "inset-ring-4", + &[ + ( + "--tw-inset-ring-shadow", + "inset 0 0 0 4px var(--tw-inset-ring-color, currentcolor)", + ), + ("box-shadow", BOX_SHADOW), + ], + ); + expect("inset-ring-white", &[("--tw-inset-ring-color", "#fff")]); + expect( + "ring-offset-2", + &[ + ("--tw-ring-offset-width", "2px"), + ( + "--tw-ring-offset-shadow", + "var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)", + ), + ], + ); + expect( + "ring-offset-[3px]", + &[ + ("--tw-ring-offset-width", "3px"), + ( + "--tw-ring-offset-shadow", + "var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)", + ), + ], + ); + expect("ring-offset-black", &[("--tw-ring-offset-color", "#000")]); + } + + #[rstest] + #[case("ring-")] + #[case("ringo")] + #[case("ring-1.5")] + #[case("ring-02")] + #[case("ring-nothing")] + #[case("ring-[red]/50/1")] + #[case("ring-offset")] + #[case("ring-offset-")] + #[case("ring-offsetx")] + #[case("ring-offset-nothing")] + #[case("inset-ring-nothing")] + #[case("-ring-2")] + fn unknown_rings_stay_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("drop-shadow-xs", "0 1px 1px", "rgb(0 0 0 / 0.05)")] + #[case("drop-shadow-sm", "0 1px 2px", "rgb(0 0 0 / 0.15)")] + #[case("drop-shadow-md", "0 3px 3px", "rgb(0 0 0 / 0.12)")] + #[case("drop-shadow-lg", "0 4px 4px", "rgb(0 0 0 / 0.15)")] + #[case("drop-shadow-xl", "0 9px 7px", "rgb(0 0 0 / 0.1)")] + #[case("drop-shadow-2xl", "0 25px 25px", "rgb(0 0 0 / 0.15)")] + fn drop_shadow_sizes_compose_into_filter( + #[case] class: &str, + #[case] offsets: &str, + #[case] color: &str, + ) { + expect( + class, + &[ + ( + "--tw-drop-shadow-size", + &format!("drop-shadow({offsets} var(--tw-drop-shadow-color, {color}))"), + ), + ( + "--tw-drop-shadow", + &format!("drop-shadow({offsets} {color})"), + ), + ("filter", FILTER), + ], + ); + } + #[test] + fn drop_shadow_without_a_size_none_colors_and_arbitrary_values() { + expect( + "drop-shadow", + &[ + ( + "--tw-drop-shadow-size", + "drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06)))", + ), + ( + "--tw-drop-shadow", + "drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06))", + ), + ("filter", FILTER), + ], + ); + expect( + "drop-shadow-none", + &[("--tw-drop-shadow", " "), ("filter", FILTER)], + ); + expect( + "drop-shadow-inherit", + &[ + ("--tw-drop-shadow-color", "inherit"), + ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), + ], + ); + expect( + "drop-shadow-red-500", + &[ + ( + "--tw-drop-shadow-color", + "color-mix(in oklab, oklch(63.7% 0.237 25.331) var(--tw-drop-shadow-alpha), transparent)", + ), + ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), + ], + ); + expect( + "drop-shadow-[#0003]", + &[ + ( + "--tw-drop-shadow-color", + "color-mix(in oklab, #0003 var(--tw-drop-shadow-alpha), transparent)", + ), + ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), + ], + ); + expect( + "drop-shadow-[0_0_4px_#000]", + &[ + ( + "--tw-drop-shadow-size", + "drop-shadow(0 0 4px var(--tw-drop-shadow-color, #000))", + ), + ("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), + ("filter", FILTER), + ], + ); + for class in [ + "drop-shadow-", + "drop-shadow-huge", + "drop-shadow-md/50", + "drop-shadow-[]", + ] { + 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 new file mode 100644 index 000000000..ff76871ce --- /dev/null +++ b/libs/extractor/src/tailwind_misc.rs @@ -0,0 +1,1208 @@ +//! Utilities of Tailwind CSS v4 that are a fixed set of declarations or one +//! 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, 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)]> { + Some(match name { + "field-sizing-content" => &[("field-sizing", "content")], + "field-sizing-fixed" => &[("field-sizing", "fixed")], + "table-auto" => &[("table-layout", "auto")], + "table-fixed" => &[("table-layout", "fixed")], + "caption-top" => &[("caption-side", "top")], + "caption-bottom" => &[("caption-side", "bottom")], + "transform-flat" => &[("transform-style", "flat")], + "transform-3d" => &[("transform-style", "preserve-3d")], + "transform-none" => &[("transform", "none")], + "backface-visible" => &[("backface-visibility", "visible")], + "backface-hidden" => &[("backface-visibility", "hidden")], + "forced-color-adjust-none" => &[("forced-color-adjust", "none")], + "forced-color-adjust-auto" => &[("forced-color-adjust", "auto")], + "antialiased" => &[ + ("-webkit-font-smoothing", "antialiased"), + ("-moz-osx-font-smoothing", "grayscale"), + ], + "subpixel-antialiased" => &[ + ("-webkit-font-smoothing", "auto"), + ("-moz-osx-font-smoothing", "auto"), + ], + "scheme-normal" => &[("color-scheme", "normal")], + "scheme-dark" => &[("color-scheme", "dark")], + "scheme-light" => &[("color-scheme", "light")], + "scheme-light-dark" => &[("color-scheme", "light dark")], + "scheme-only-dark" => &[("color-scheme", "only dark")], + "scheme-only-light" => &[("color-scheme", "only light")], + "wrap-anywhere" => &[("overflow-wrap", "anywhere")], + "wrap-break-word" => &[("overflow-wrap", "break-word")], + "wrap-normal" => &[("overflow-wrap", "normal")], + "decoration-solid" => &[("text-decoration-style", "solid")], + "decoration-double" => &[("text-decoration-style", "double")], + "decoration-dotted" => &[("text-decoration-style", "dotted")], + "decoration-dashed" => &[("text-decoration-style", "dashed")], + "decoration-wavy" => &[("text-decoration-style", "wavy")], + "decoration-auto" => &[("text-decoration-thickness", "auto")], + "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"), + ("-webkit-box-orient", "horizontal"), + ("-webkit-line-clamp", "unset"), + ], + "outline-solid" => &[("--tw-outline-style", "solid"), ("outline-style", "solid")], + "outline-dashed" => &[ + ("--tw-outline-style", "dashed"), + ("outline-style", "dashed"), + ], + "outline-dotted" => &[ + ("--tw-outline-style", "dotted"), + ("outline-style", "dotted"), + ], + "outline-double" => &[ + ("--tw-outline-style", "double"), + ("outline-style", "double"), + ], + "outline-none" => &[("--tw-outline-style", "none"), ("outline-style", "none")], + "font-stretch-normal" => &[("font-stretch", "normal")], + "font-stretch-ultra-condensed" => &[("font-stretch", "ultra-condensed")], + "font-stretch-extra-condensed" => &[("font-stretch", "extra-condensed")], + "font-stretch-condensed" => &[("font-stretch", "condensed")], + "font-stretch-semi-condensed" => &[("font-stretch", "semi-condensed")], + "font-stretch-semi-expanded" => &[("font-stretch", "semi-expanded")], + "font-stretch-expanded" => &[("font-stretch", "expanded")], + "font-stretch-extra-expanded" => &[("font-stretch", "extra-expanded")], + "font-stretch-ultra-expanded" => &[("font-stretch", "ultra-expanded")], + _ => return None, + }) +} + +/// The border styles `border-dashed` and the like set, which `border-` +/// draws in +const BORDER_STYLES: [&str; 6] = ["solid", "dashed", "dotted", "double", "hidden", "none"]; + +/// The sides of `border--`: the property each side's style and +/// width are in +fn border_side(side: &str) -> Option<(&'static str, &'static str)> { + Some(match side { + "" => ("border-style", "border-width"), + "x" => ("border-inline-style", "border-inline-width"), + "y" => ("border-block-style", "border-block-width"), + "s" => ("border-inline-start-style", "border-inline-start-width"), + "e" => ("border-inline-end-style", "border-inline-end-width"), + "t" => ("border-top-style", "border-top-width"), + "r" => ("border-right-style", "border-right-width"), + "b" => ("border-bottom-style", "border-bottom-width"), + "l" => ("border-left-style", "border-left-width"), + _ => return None, + }) +} + +/// A number of pixels written as a bare number, or an arbitrary value +fn pixels_or_value(argument: &str) -> Option { + if is_positive_integer(argument) { + return Some(format!("{argument}px")); + } + arbitrary_or_variable(argument) +} + +/// A width in pixels written as a bare number, or as an arbitrary length: what +/// `border-[3px]` is, while `border-[red]` is a color +fn pixels(argument: &str) -> Option { + if is_positive_integer(argument) { + return Some(format!("{argument}px")); + } + arbitrary_or_variable(argument) + .filter(|_| argument.starts_with('[')) + .filter(|value| value_type(None, value) == Some(ValueType::Length)) +} + +/// `border`, `border-2`, `border-x-4` and `border-dashed`: a width drawn in the +/// style `--tw-border-style` holds +fn border_utility(name: &str) -> Option> { + let rest = name.strip_prefix("border")?; + let rest = if rest.is_empty() { + rest + } else { + rest.strip_prefix('-')? + }; + if let Some(style) = Some(rest).filter(|style| BORDER_STYLES.contains(style)) { + return Some(vec![ + decl("--tw-border-style", style.to_string()), + decl("border-style", style.to_string()), + ]); + } + let (side, argument) = match rest.split_once('-') { + Some((side, argument)) if border_side(side).is_some() => (side, Some(argument)), + None if border_side(rest).is_some() => (rest, None), + _ => ("", Some(rest)), + }; + let (style, property) = border_side(side)?; + let width = argument.map_or_else(|| Some(String::from("1px")), pixels)?; + Some(vec![ + decl(style, "var(--tw-border-style)"), + decl(property, width), + ]) +} + +/// `outline` and `outline-2`: a width drawn in the style `--tw-outline-style` +/// holds +fn outline_width(name: &str) -> Option> { + let width = match name.strip_prefix("outline")? { + "" => String::from("1px"), + argument => pixels(argument.strip_prefix('-')?)?, + }; + Some(vec![ + decl("outline-style", "var(--tw-outline-style)"), + decl("outline-width", width), + ]) +} + +/// `border-spacing-*`, which sets both axes unless one is named +fn border_spacing(name: &str) -> Option> { + let rest = name.strip_prefix("border-spacing-")?; + let (axes, argument): (&[&str], &str) = match rest.split_once('-') { + Some(("x", argument)) => (&["x"], argument), + Some(("y", argument)) => (&["y"], argument), + _ => (&["x", "y"], rest), + }; + let value = spacing(argument, false)?; + let mut declarations: Vec = axes + .iter() + .map(|axis| decl(format!("--tw-border-spacing-{axis}"), value.clone())) + .collect(); + declarations.push(decl( + "border-spacing", + "var(--tw-border-spacing-x) var(--tw-border-spacing-y)", + )); + Some(declarations) +} + +/// The logical sides of `start-*` and `end-*`, and the margins and paddings of +/// scrolling `scroll-m*` and `scroll-p*` +fn spaced_properties(root: &str) -> Option<&'static [&'static str]> { + Some(match root { + "start" => &["inset-inline-start"], + "end" => &["inset-inline-end"], + "scroll-m" => &["scroll-margin"], + "scroll-mx" => &["scroll-margin-inline"], + "scroll-my" => &["scroll-margin-block"], + "scroll-ms" => &["scroll-margin-inline-start"], + "scroll-me" => &["scroll-margin-inline-end"], + "scroll-mt" => &["scroll-margin-top"], + "scroll-mr" => &["scroll-margin-right"], + "scroll-mb" => &["scroll-margin-bottom"], + "scroll-ml" => &["scroll-margin-left"], + "scroll-p" => &["scroll-padding"], + "scroll-px" => &["scroll-padding-inline"], + "scroll-py" => &["scroll-padding-block"], + "scroll-ps" => &["scroll-padding-inline-start"], + "scroll-pe" => &["scroll-padding-inline-end"], + "scroll-pt" => &["scroll-padding-top"], + "scroll-pr" => &["scroll-padding-right"], + "scroll-pb" => &["scroll-padding-bottom"], + "scroll-pl" => &["scroll-padding-left"], + "indent" => &["text-indent"], + _ => return None, + }) +} + +/// A number of pixels, negated +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 + .iter() + .map(|&(property, value)| decl(property, value)) + .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); + } + three_d(name, false).or_else(|| plain(name)) +} + +/// The properties of a spaced utility such as `scroll-mt-2` and its argument +fn split_spaced(name: &str) -> Option<(&'static [&'static str], &str)> { + name.match_indices('-').find_map(|(index, _)| { + spaced_properties(&name[..index]).map(|properties| (properties, &name[index + 1..])) + }) +} + +/// 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", + negated_pixels(argument)?, + )]); + } + if let Some(argument) = name.strip_prefix("outline-offset-") { + return Some(vec![decl("outline-offset", negated_pixels(argument)?)]); + } + let (properties, argument) = split_spaced(name)?; + let value = spacing(argument, true)?; + Some( + properties + .iter() + .map(|&property| decl(property, value.clone())) + .collect(), + ) +} +/// The functional utilities: `line-clamp-3`, `underline-offset-4`, +/// `decoration-2`, `decoration-`, `outline-offset-2`, `font-stretch-75%` +/// and the spaced ones +fn plain(name: &str) -> Option> { + if let Some(lines) = name.strip_prefix("line-clamp-") { + let lines = if is_positive_integer(lines) { + lines.to_string() + } else { + arbitrary_or_variable(lines)? + }; + return Some(vec![ + decl("overflow", "hidden"), + decl("display", "-webkit-box"), + decl("-webkit-box-orient", "vertical"), + decl("-webkit-line-clamp", lines), + ]); + } + if let Some(argument) = name.strip_prefix("underline-offset-") { + return Some(vec![decl( + "text-underline-offset", + pixels_or_value(argument)?, + )]); + } + if let Some(argument) = name.strip_prefix("outline-offset-") { + return Some(vec![decl("outline-offset", pixels_or_value(argument)?)]); + } + if let Some(argument) = name.strip_prefix("decoration-") { + if let Some(width) = pixels(argument) { + return Some(vec![decl("text-decoration-thickness", width)]); + } + return Some(vec![decl("text-decoration-color", color_value(argument)?)]); + } + if let Some(percent) = name.strip_prefix("font-stretch-") { + let number = percent.strip_suffix('%')?; + let valid = is_positive_integer(number) + && number.parse::().is_ok_and(|n| (50..=200).contains(&n)); + return valid.then(|| vec![decl("font-stretch", percent.to_string())]); + } + let (properties, argument) = split_spaced(name)?; + let value = spacing_scale(argument) + .map(String::from) + .or_else(|| arbitrary_or_variable(argument))?; + Some( + properties + .iter() + .map(|&property| decl(property, value.clone())) + .collect(), + ) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + 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() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + #[rstest] + #[case("field-sizing-content", &[("field-sizing", "content")])] + #[case("table-fixed", &[("table-layout", "fixed")])] + #[case("caption-bottom", &[("caption-side", "bottom")])] + #[case("transform-3d", &[("transform-style", "preserve-3d")])] + #[case("transform-none", &[("transform", "none")])] + #[case("backface-hidden", &[("backface-visibility", "hidden")])] + #[case("forced-color-adjust-none", &[("forced-color-adjust", "none")])] + #[case("antialiased", &[("-webkit-font-smoothing", "antialiased"), ("-moz-osx-font-smoothing", "grayscale")])] + #[case("subpixel-antialiased", &[("-webkit-font-smoothing", "auto"), ("-moz-osx-font-smoothing", "auto")])] + #[case("scheme-light-dark", &[("color-scheme", "light dark")])] + #[case("wrap-anywhere", &[("overflow-wrap", "anywhere")])] + #[case("decoration-wavy", &[("text-decoration-style", "wavy")])] + #[case("decoration-from-font", &[("text-decoration-thickness", "from-font")])] + #[case("underline-offset-auto", &[("text-underline-offset", "auto")])] + #[case("list-image-none", &[("list-style-image", "none")])] + #[case("perspective-near", &[("perspective", "300px")])] + #[case("perspective-none", &[("perspective", "none")])] + #[case("font-stretch-ultra-condensed", &[("font-stretch", "ultra-condensed")])] + #[case("font-stretch-75%", &[("font-stretch", "75%")])] + #[case("outline-solid", &[("--tw-outline-style", "solid"), ("outline-style", "solid")])] + #[case("outline-dashed", &[("--tw-outline-style", "dashed"), ("outline-style", "dashed")])] + #[case("outline-dotted", &[("--tw-outline-style", "dotted"), ("outline-style", "dotted")])] + #[case("outline-double", &[("--tw-outline-style", "double"), ("outline-style", "double")])] + #[case("outline-none", &[("--tw-outline-style", "none"), ("outline-style", "none")])] + #[case("line-clamp-none", &[("overflow", "visible"), ("display", "block"), ("-webkit-box-orient", "horizontal"), ("-webkit-line-clamp", "unset")])] + fn fixed_utilities(#[case] class: &str, #[case] expected: &[(&str, &str)]) { + expect(class, expected); + } + + #[rstest] + #[case("line-clamp-3", "3")] + #[case("line-clamp-(--lines)", "var(--lines)")] + #[case("line-clamp-[calc(1+1)]", "calc(1 + 1)")] + fn line_clamp_clamps_to_a_number_of_lines(#[case] class: &str, #[case] lines: &str) { + expect( + class, + &[ + ("overflow", "hidden"), + ("display", "-webkit-box"), + ("-webkit-box-orient", "vertical"), + ("-webkit-line-clamp", lines), + ], + ); + } + + #[test] + fn text_decoration_offsets_and_outlines() { + expect("underline-offset-4", &[("text-underline-offset", "4px")]); + expect( + "-underline-offset-4", + &[("text-underline-offset", "calc(4px * -1)")], + ); + expect( + "underline-offset-[3px]", + &[("text-underline-offset", "3px")], + ); + expect("decoration-2", &[("text-decoration-thickness", "2px")]); + expect("decoration-[3px]", &[("text-decoration-thickness", "3px")]); + expect( + "decoration-red-500", + &[("text-decoration-color", "oklch(63.7% 0.237 25.331)")], + ); + expect("outline-offset-2", &[("outline-offset", "2px")]); + expect("-outline-offset-2", &[("outline-offset", "calc(2px * -1)")]); + expect("outline-offset-(--o)", &[("outline-offset", "var(--o)")]); + expect( + "outline", + &[ + ("outline-style", "var(--tw-outline-style)"), + ("outline-width", "1px"), + ], + ); + expect( + "outline-4", + &[ + ("outline-style", "var(--tw-outline-style)"), + ("outline-width", "4px"), + ], + ); + expect( + "outline-[3px]", + &[ + ("outline-style", "var(--tw-outline-style)"), + ("outline-width", "3px"), + ], + ); + } + + #[rstest] + #[case("border", "border-style", "border-width", "1px")] + #[case("border-0", "border-style", "border-width", "0px")] + #[case("border-2", "border-style", "border-width", "2px")] + #[case("border-[3px]", "border-style", "border-width", "3px")] + #[case("border-x", "border-inline-style", "border-inline-width", "1px")] + #[case("border-y-4", "border-block-style", "border-block-width", "4px")] + #[case( + "border-s-2", + "border-inline-start-style", + "border-inline-start-width", + "2px" + )] + #[case( + "border-e", + "border-inline-end-style", + "border-inline-end-width", + "1px" + )] + #[case("border-t", "border-top-style", "border-top-width", "1px")] + #[case("border-r-8", "border-right-style", "border-right-width", "8px")] + #[case("border-b-[2px]", "border-bottom-style", "border-bottom-width", "2px")] + #[case("border-l-2", "border-left-style", "border-left-width", "2px")] + fn border_widths_draw_in_the_border_style( + #[case] class: &str, + #[case] style: &str, + #[case] width_property: &str, + #[case] width: &str, + ) { + expect( + class, + &[(style, "var(--tw-border-style)"), (width_property, width)], + ); + } + + #[test] + fn border_styles_set_the_style_the_widths_draw_in() { + for style in ["solid", "dashed", "dotted", "double", "hidden", "none"] { + expect( + &format!("border-{style}"), + &[("--tw-border-style", style), ("border-style", style)], + ); + } + } + + #[test] + fn border_spacing_sets_both_axes_unless_one_is_named() { + expect( + "border-spacing-2", + &[ + ("--tw-border-spacing-x", "0.5rem"), + ("--tw-border-spacing-y", "0.5rem"), + ( + "border-spacing", + "var(--tw-border-spacing-x) var(--tw-border-spacing-y)", + ), + ], + ); + expect( + "border-spacing-x-4", + &[ + ("--tw-border-spacing-x", "1rem"), + ( + "border-spacing", + "var(--tw-border-spacing-x) var(--tw-border-spacing-y)", + ), + ], + ); + expect( + "border-spacing-y-[3px]", + &[ + ("--tw-border-spacing-y", "3px"), + ( + "border-spacing", + "var(--tw-border-spacing-x) var(--tw-border-spacing-y)", + ), + ], + ); + } + + #[test] + fn logical_sides_scroll_spacing_and_indent() { + expect("start-4", &[("inset-inline-start", "1rem")]); + expect("end-0", &[("inset-inline-end", "0px")]); + expect("-start-4", &[("inset-inline-start", "-1rem")]); + expect("scroll-mt-2", &[("scroll-margin-top", "0.5rem")]); + expect("scroll-mx-[3px]", &[("scroll-margin-inline", "3px")]); + expect("-scroll-ms-4", &[("scroll-margin-inline-start", "-1rem")]); + expect("scroll-pb-4", &[("scroll-padding-bottom", "1rem")]); + expect("scroll-p-2", &[("scroll-padding", "0.5rem")]); + expect("indent-8", &[("text-indent", "2rem")]); + expect("-indent-px", &[("text-indent", "-1px")]); + expect("indent-(--i)", &[("text-indent", "var(--i)")]); + for (class, property) in [ + ("scroll-m-1", "scroll-margin"), + ("scroll-my-1", "scroll-margin-block"), + ("scroll-me-1", "scroll-margin-inline-end"), + ("scroll-mr-1", "scroll-margin-right"), + ("scroll-mb-1", "scroll-margin-bottom"), + ("scroll-ml-1", "scroll-margin-left"), + ("scroll-px-1", "scroll-padding-inline"), + ("scroll-py-1", "scroll-padding-block"), + ("scroll-ps-1", "scroll-padding-inline-start"), + ("scroll-pe-1", "scroll-padding-inline-end"), + ("scroll-pt-1", "scroll-padding-top"), + ("scroll-pr-1", "scroll-padding-right"), + ("scroll-pl-1", "scroll-padding-left"), + ] { + expect(class, &[(property, "0.25rem")]); + } + } + + #[rstest] + #[case("borderx-2")] + #[case("border-x-")] + #[case("border-2.5")] + #[case("border-02")] + #[case("border-[url(x)]")] + #[case("border-spacing-")] + #[case("border-spacing-x-")] + #[case("border-spacing-huge")] + #[case("-border-2")] + #[case("outline-")] + #[case("outlinex")] + #[case("outline-nothing")] + #[case("outline-offset-")] + #[case("-outline-offset-huge")] + #[case("underline-offset-")] + #[case("-underline-offset-1.5")] + #[case("line-clamp-")] + #[case("line-clamp-1.5")] + #[case("decoration-")] + #[case("decoration-nothing")] + #[case("font-stretch-49%")] + #[case("font-stretch-201%")] + #[case("font-stretch-75")] + #[case("font-stretch-1.5%")] + #[case("font-stretch-normal-1")] + #[case("start-")] + #[case("start-huge")] + #[case("-start-huge")] + #[case("scroll-mq-4")] + #[case("-scroll-m-")] + #[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}"); + } + + #[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_motion.rs b/libs/extractor/src/tailwind_motion.rs new file mode 100644 index 000000000..7f8ba011c --- /dev/null +++ b/libs/extractor/src/tailwind_motion.rs @@ -0,0 +1,364 @@ +//! Transitions and animations the way Tailwind CSS v4 writes them: the +//! duration and easing a `transition` uses are the custom properties +//! `duration-*` and `ease-*` set, and `animate-*` brings the `@keyframes` it +//! names + +use std::borrow::Cow; + +use css::tailwind_definitions::{theme_keyframes, theme_variable}; + +use crate::tailwind::{Declaration, arbitrary_or_variable, decl, is_positive_integer}; +use crate::tailwind_theme::themed; + +/// The easing a transition has when no `ease-*` sets one +fn timing() -> String { + let default = theme_variable("default-transition-timing-function") + .unwrap_or_else(|| String::from("cubic-bezier(0.4, 0, 0.2, 1)")); + format!("var(--tw-ease, {default})") +} + +/// The duration a transition has when no `duration-*` sets one +fn duration() -> String { + let default = + theme_variable("default-transition-duration").unwrap_or_else(|| String::from("150ms")); + format!("var(--tw-duration, {default})") +} + +/// What `transition` makes transition +const TRANSITION: &str = "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events"; + +/// What `transition-colors` makes transition +const TRANSITION_COLORS: &str = "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to"; + +/// The `@keyframes` of the animations of the default theme +pub static KEYFRAMES: [(&str, &str); 4] = [ + ("spin", "@keyframes spin{to{transform:rotate(360deg)}}"), + ( + "ping", + "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}", + ), + ("pulse", "@keyframes pulse{50%{opacity:0.5}}"), + ( + "bounce", + "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}", + ), +]; + +/// The value of `animate-` for the animations of the default theme +fn animation(name: &str) -> Option<&'static str> { + Some(match name { + "spin" => "spin 1s linear infinite", + "ping" => "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", + "pulse" => "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "bounce" => "bounce 1s infinite", + _ => return None, + }) +} + +/// A transition of `property`, with the default easing and duration +fn transition(property: &str) -> Vec { + vec![ + decl("transition-property", property.to_string()), + decl("transition-timing-function", timing()), + decl("transition-duration", duration()), + ] +} + +/// The theme easings of `ease-` +fn easing(name: &str) -> Option<&'static str> { + Some(match name { + "in" => "cubic-bezier(0.4, 0, 1, 1)", + "out" => "cubic-bezier(0, 0, 0.2, 1)", + "in-out" => "cubic-bezier(0.4, 0, 0.2, 1)", + _ => return None, + }) +} + +/// The declarations of a `transition`, `duration`, `delay`, `ease` or +/// `animate` utility, `None` for any other +pub fn motion_utility(name: &str) -> Option> { + if name == "transition" { + return Some(transition(TRANSITION)); + } + if let Some(argument) = name.strip_prefix("transition-") { + return Some(match argument { + "none" => vec![decl("transition-property", "none")], + "all" => transition("all"), + "colors" => transition(TRANSITION_COLORS), + "opacity" => transition("opacity"), + "shadow" => transition("box-shadow"), + "transform" => transition("transform, translate, scale, rotate"), + "discrete" => vec![decl("transition-behavior", "allow-discrete")], + "normal" => vec![decl("transition-behavior", "normal")], + _ => transition(&arbitrary_or_variable(argument)?), + }); + } + if let Some(argument) = name.strip_prefix("delay-") { + let value = arbitrary_or_variable(argument).or_else(|| milliseconds(argument))?; + return Some(vec![decl("transition-delay", value)]); + } + if let Some(argument) = name.strip_prefix("duration-") { + if argument == "initial" { + return Some(vec![decl("--tw-duration", "initial")]); + } + let value = arbitrary_or_variable(argument).or_else(|| milliseconds(argument))?; + return Some(vec![ + decl("--tw-duration", value.clone()), + decl("transition-duration", value), + ]); + } + if let Some(argument) = name.strip_prefix("ease-") { + if argument == "initial" { + return Some(vec![decl("--tw-ease", "initial")]); + } + let value = if argument == "linear" { + Some(String::from("linear")) + } else { + themed("ease", argument, easing) + .map(Cow::into_owned) + .or_else(|| arbitrary_or_variable(argument)) + }?; + return Some(vec![ + decl("--tw-ease", value.clone()), + decl("transition-timing-function", value), + ]); + } + let argument = name.strip_prefix("animate-")?; + let value = if argument == "none" { + Some(String::from("none")) + } else { + themed("animate", argument, animation) + .map(Cow::into_owned) + .or_else(|| arbitrary_or_variable(argument)) + }?; + Some(vec![decl("animation", value)]) +} + +/// `150` as `150ms` +fn milliseconds(value: &str) -> Option { + is_positive_integer(value).then(|| format!("{value}ms")) +} + +/// The `@keyframes` rules the declarations name in an `animation`: the +/// project's, else those of the default theme +pub fn keyframes_of(declarations: &[Declaration]) -> impl Iterator> + '_ { + declarations + .iter() + .filter(|(property, _)| property == "animation") + .flat_map(|(_, value)| value.split_whitespace()) + .filter_map(|word| { + theme_keyframes(word).map(Cow::Owned).or_else(|| { + KEYFRAMES + .iter() + .find(|(name, _)| *name == word) + .map(|&(_, rule)| Cow::Borrowed(rule)) + }) + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::tailwind::{declarations_of, parse_class}; + + fn expect(class: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + fn transition(property: &str) -> Vec<(String, String)> { + vec![ + ("transition-property".to_string(), property.to_string()), + ("transition-timing-function".to_string(), timing()), + ("transition-duration".to_string(), duration()), + ] + } + + fn expect_owned(class: &str, expected: Vec<(String, String)>) { + assert_eq!(declarations_of(class), Some(expected), "{class}"); + } + + #[test] + fn transitions_read_the_easing_and_duration_utilities_set() { + expect_owned("transition", transition(TRANSITION)); + expect_owned("transition-all", transition("all")); + expect_owned("transition-colors", transition(TRANSITION_COLORS)); + expect_owned("transition-opacity", transition("opacity")); + expect_owned("transition-shadow", transition("box-shadow")); + expect_owned( + "transition-transform", + transition("transform, translate, scale, rotate"), + ); + expect("transition-none", &[("transition-property", "none")]); + expect_owned("transition-[height]", transition("height")); + expect_owned("transition-[height,opacity]", transition("height,opacity")); + expect_owned("transition-(--props)", transition("var(--props)")); + expect( + "transition-discrete", + &[("transition-behavior", "allow-discrete")], + ); + expect("transition-normal", &[("transition-behavior", "normal")]); + assert_eq!(timing(), "var(--tw-ease, cubic-bezier(0.4, 0, 0.2, 1))"); + assert_eq!(duration(), "var(--tw-duration, 150ms)"); + } + + #[test] + fn transitions_cover_what_v4_transitions() { + for property in [ + "outline-color", + "--tw-gradient-from", + "translate", + "scale", + "rotate", + "-webkit-backdrop-filter", + "display", + "content-visibility", + "overlay", + "pointer-events", + ] { + assert!(TRANSITION.contains(property), "{property}"); + } + assert!(TRANSITION_COLORS.contains("outline-color")); + } + + #[rstest] + #[case("duration-0", "0ms")] + #[case("duration-75", "75ms")] + #[case("duration-150", "150ms")] + #[case("duration-250", "250ms")] + #[case("duration-1000", "1000ms")] + #[case("duration-[2s]", "2s")] + #[case("duration-(--speed)", "var(--speed)")] + fn duration_sets_the_variable_a_transition_reads(#[case] class: &str, #[case] value: &str) { + expect( + class, + &[("--tw-duration", value), ("transition-duration", value)], + ); + } + + #[rstest] + #[case("ease-in", "cubic-bezier(0.4, 0, 1, 1)")] + #[case("ease-out", "cubic-bezier(0, 0, 0.2, 1)")] + #[case("ease-in-out", "cubic-bezier(0.4, 0, 0.2, 1)")] + #[case("ease-linear", "linear")] + #[case("ease-[cubic-bezier(0.3,0,0,1)]", "cubic-bezier(0.3,0,0,1)")] + #[case("ease-(--curve)", "var(--curve)")] + fn ease_sets_the_variable_a_transition_reads(#[case] class: &str, #[case] value: &str) { + expect( + class, + &[("--tw-ease", value), ("transition-timing-function", value)], + ); + } + + #[test] + fn initial_and_delay() { + expect("duration-initial", &[("--tw-duration", "initial")]); + expect("ease-initial", &[("--tw-ease", "initial")]); + expect("delay-0", &[("transition-delay", "0ms")]); + expect("delay-150", &[("transition-delay", "150ms")]); + expect("delay-[3s]", &[("transition-delay", "3s")]); + expect("delay-(--wait)", &[("transition-delay", "var(--wait)")]); + } + + #[rstest] + #[case("transition-sideways")] + #[case("transition-[]")] + #[case("transition-")] + #[case("-transition")] + #[case("duration")] + #[case("duration-")] + #[case("duration-1.5")] + #[case("duration-050")] + #[case("duration-fast")] + #[case("-duration-150")] + #[case("delay-1s")] + #[case("delay-")] + #[case("ease")] + #[case("ease-bouncy")] + #[case("animate")] + #[case("animate-wobble")] + #[case("animate-")] + #[case("-animate-spin")] + fn unknown_motion_stays_as_written(#[case] class: &str) { + assert_eq!(declarations_of(class), None, "{class}"); + } + + #[rstest] + #[case("animate-spin", "spin 1s linear infinite", "spin")] + #[case("animate-ping", "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", "ping")] + #[case( + "animate-pulse", + "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + "pulse" + )] + #[case("animate-bounce", "bounce 1s infinite", "bounce")] + fn animations_bring_their_keyframes( + #[case] class: &str, + #[case] animation: &str, + #[case] keyframes: &str, + ) { + expect(class, &[("animation", animation)]); + let rules = parse_class(class).unwrap().rules(); + assert_eq!(rules.len(), 1); + assert!( + rules[0].starts_with(&format!("@keyframes {keyframes}{{")), + "{rules:?}" + ); + } + + #[test] + fn keyframes_match_the_default_theme_of_v4() { + let rule = |name: &str| { + KEYFRAMES + .iter() + .find(|(keyframes, _)| *keyframes == name) + .unwrap() + .1 + }; + assert_eq!( + rule("spin"), + "@keyframes spin{to{transform:rotate(360deg)}}" + ); + assert_eq!( + rule("ping"), + "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}" + ); + assert_eq!(rule("pulse"), "@keyframes pulse{50%{opacity:0.5}}"); + assert_eq!( + rule("bounce"), + "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}" + ); + } + + #[test] + fn arbitrary_animations_bring_the_keyframes_they_name() { + expect("animate-none", &[("animation", "none")]); + expect( + "animate-[wiggle_1s_ease-in-out_infinite]", + &[("animation", "wiggle 1s ease-in-out infinite")], + ); + expect("animate-(--spin)", &[("animation", "var(--spin)")]); + assert_eq!( + parse_class("animate-[wiggle_1s_ease-in-out_infinite]") + .unwrap() + .rules(), + Vec::<&str>::new() + ); + assert_eq!( + parse_class("animate-[spin_3s_linear_infinite]") + .unwrap() + .rules(), + vec!["@keyframes spin{to{transform:rotate(360deg)}}"] + ); + assert_eq!( + parse_class("animate-none").unwrap().rules(), + Vec::<&str>::new() + ); + } +} diff --git a/libs/extractor/src/tailwind_theme.rs b/libs/extractor/src/tailwind_theme.rs new file mode 100644 index 000000000..928609351 --- /dev/null +++ b/libs/extractor/src/tailwind_theme.rs @@ -0,0 +1,449 @@ +//! The theme values Tailwind utilities read: what the project's Tailwind CSS +//! `@theme` defines, else Tailwind CSS v4's defaults + +use std::borrow::Cow; + +use css::tailwind_definitions::{namespace_is_reset, theme_value, theme_variable}; + +/// The value of `---` (`--` for an empty key) that +/// the project defines, else the default, which a project can empty +pub fn themed( + namespace: &str, + key: &str, + default: impl FnOnce(&str) -> Option<&'static str>, +) -> Option> { + let custom = if key.is_empty() { + theme_variable(namespace) + } else { + theme_value(namespace, key) + }; + if let Some(value) = custom { + return Some(Cow::Owned(value)); + } + if namespace_is_reset(namespace) { + return None; + } + default(key).map(Cow::Borrowed) +} + +/// What a class prefix reads from the theme but this build cannot compile +/// from `@theme`, as `(prefix, namespace)` +static UNSUPPORTED: &[(&str, &str)] = &[ + ("text-", "text"), + ("font-", "font"), + ("font-", "font-weight"), + ("leading-", "leading"), + ("tracking-", "tracking"), + ("max-w-", "container"), + ("min-w-", "container"), + ("w-", "container"), + ("inset-shadow-", "inset-shadow"), + ("aspect-", "aspect"), +]; + +/// Whether `name` is a utility whose theme values the project changes in a way +/// this build does not read, which must stay as written +pub fn reads_unsupported_theme(name: &str) -> bool { + UNSUPPORTED.iter().any(|(prefix, namespace)| { + name.strip_prefix(prefix).is_some_and(|key| { + let key = key.split('/').next().unwrap_or(key); + theme_value(namespace, key).is_some() || namespace_is_reset(namespace) + }) + }) +} + +/// `n` times `base`, for a number `n` Tailwind scales the spacing by +pub fn scaled(key: &str, base: &str) -> Option { + let number: f64 = key.parse().ok()?; + if number < 0.0 || number % 0.25 != 0.0 || number.to_string() != key { + return None; + } + let split = base.find(|c: char| !(c.is_ascii_digit() || c == '.'))?; + let (amount, unit) = base.split_at(split); + let amount: f64 = amount.parse().ok()?; + if unit.is_empty() || !unit.bytes().all(|byte| byte.is_ascii_alphabetic()) { + return None; + } + let product = (number * amount * 1e6).round() / 1e6; + Some(if product == 0.0 { + String::from("0px") + } else { + format!("{product}{unit}") + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use css::tailwind_definitions::set_tailwind_css; + use serial_test::serial; + + use super::*; + + #[test] + #[serial] + fn themed_values_come_from_the_project_first() { + let default = |key: &str| (key == "sm").then_some("4px"); + assert_eq!(themed("blur", "sm", default).as_deref(), Some("4px")); + assert_eq!(themed("blur", "xl", default), None); + set_tailwind_css("@theme { --blur-sm: 1px; --blur: 9px; --blur-huge: 99px; }"); + assert_eq!(themed("blur", "sm", default).as_deref(), Some("1px")); + assert_eq!(themed("blur", "huge", default).as_deref(), Some("99px")); + assert_eq!(themed("blur", "", default).as_deref(), Some("9px")); + set_tailwind_css("@theme { --blur-*: initial; }"); + assert_eq!(themed("blur", "sm", default), None); + set_tailwind_css(""); + } + + #[test] + #[serial] + fn utilities_that_read_namespaces_the_build_does_not_read() { + assert!(!reads_unsupported_theme("text-lg")); + set_tailwind_css( + "@theme { --text-lg: 2rem; --font-weight-bold: 800; --container-sm: 20rem; }", + ); + assert!(reads_unsupported_theme("text-lg")); + assert!(reads_unsupported_theme("text-lg/6")); + assert!(!reads_unsupported_theme("text-sm")); + assert!(reads_unsupported_theme("font-bold")); + assert!(!reads_unsupported_theme("font-thin")); + assert!(reads_unsupported_theme("max-w-sm")); + assert!(!reads_unsupported_theme("p-4")); + set_tailwind_css("@theme { --*: initial; }"); + assert!(reads_unsupported_theme("leading-tight")); + assert!(reads_unsupported_theme("w-4")); + set_tailwind_css(""); + assert!(!reads_unsupported_theme("leading-tight")); + } + + #[test] + fn spacing_scales_by_the_number() { + assert_eq!(scaled("13", "0.25rem").as_deref(), Some("3.25rem")); + assert_eq!(scaled("0.5", "4px").as_deref(), Some("2px")); + assert_eq!(scaled("3", "0.3rem").as_deref(), Some("0.9rem")); + assert_eq!(scaled("0", "0.25rem").as_deref(), Some("0px")); + assert_eq!(scaled("1.3", "0.25rem"), None); + assert_eq!(scaled("01", "0.25rem"), None); + assert_eq!(scaled("-1", "0.25rem"), None); + assert_eq!(scaled("x", "0.25rem"), None); + assert_eq!(scaled("4", "2"), None); + assert_eq!(scaled("4", "calc(1px)"), None); + assert_eq!(scaled("4", "var(--x)"), None); + assert_eq!(scaled("4", "rem"), None); + assert_eq!(scaled("4", "1px2"), None); + } + + fn written(class: &str) -> Vec<(String, String)> { + crate::tailwind::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 theme_colors_extend_and_reset_the_palette() { + set_tailwind_css( + "@theme { --color-brand: #0af; --color-mint: var(--color-emerald-500); --color-alias: var(--color-brand); }", + ); + assert_eq!(written("bg-brand"), pairs(&[("background-color", "#0af")])); + assert_eq!(written("bg-alias"), pairs(&[("background-color", "#0af")])); + assert_eq!( + written("bg-mint"), + pairs(&[("background-color", "oklch(69.6% 0.17 162.48)")]) + ); + assert_eq!( + written("text-brand/50"), + pairs(&[("color", "color-mix(in oklab, #0af 50%, transparent)")]) + ); + assert_eq!( + written("bg-red-500"), + pairs(&[("background-color", "oklch(63.7% 0.237 25.331)")]) + ); + set_tailwind_css("@theme { --color-*: initial; --color-brand: #0af; }"); + assert_eq!(written("bg-brand"), pairs(&[("background-color", "#0af")])); + assert_eq!(crate::tailwind::declarations_of("bg-white"), None); + assert_eq!(crate::tailwind::declarations_of("bg-red-500"), None); + assert_eq!( + written("bg-current"), + pairs(&[("background-color", "currentcolor")]) + ); + set_tailwind_css(""); + } + + #[test] + #[serial] + fn theme_spacing_scales_every_step() { + assert_eq!(written("p-13"), pairs(&[("padding", "3.25rem")])); + assert_eq!(written("p-4"), pairs(&[("padding", "1rem")])); + set_tailwind_css("@theme { --spacing: 0.3rem; --spacing-gutter: 3rem; }"); + assert_eq!(written("p-4"), pairs(&[("padding", "1.2rem")])); + assert_eq!(written("mx-0"), pairs(&[("margin-inline", "0px")])); + assert_eq!(written("p-px"), pairs(&[("padding", "1px")])); + assert_eq!(written("gap-gutter"), pairs(&[("gap", "3rem")])); + assert_eq!(written("-mt-2"), pairs(&[("margin-top", "-0.6rem")])); + set_tailwind_css("@theme { --spacing: calc(1px + 1px); }"); + assert_eq!(written("p-4"), pairs(&[("padding", "1rem")])); + set_tailwind_css(""); + } + + #[test] + #[serial] + fn theme_shadows_blurs_radii_and_easings() { + set_tailwind_css( + "@theme { --shadow-soft: 0 2px 8px #0003, inset 0 0 1px red; --blur-huge: 100px; --blur: 5px; --radius-pill: 40px; --radius: 3px; --ease-snappy: cubic-bezier(0.2, 0, 0, 1); }", + ); + assert_eq!( + written("shadow-soft"), + pairs(&[ + ( + "--tw-shadow", + "0 2px 8px var(--tw-shadow-color, #0003), inset 0 0 1px var(--tw-shadow-color, red)" + ), + ( + "box-shadow", + "var(--tw-inset-shadow,0 0 #0000), var(--tw-inset-ring-shadow,0 0 #0000), var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)" + ), + ]) + ); + assert_eq!( + written("blur-huge")[0], + ("--tw-blur".to_string(), "blur(100px)".to_string()) + ); + assert_eq!(written("blur")[0].1, "blur(5px)"); + assert_eq!(written("blur-sm")[0].1, "blur(8px)"); + assert_eq!(written("rounded-pill"), pairs(&[("border-radius", "40px")])); + assert_eq!(written("rounded"), pairs(&[("border-radius", "3px")])); + assert_eq!(written("rounded-lg"), pairs(&[("border-radius", "0.5rem")])); + assert_eq!( + written("ease-snappy"), + pairs(&[ + ("--tw-ease", "cubic-bezier(0.2, 0, 0, 1)"), + ("transition-timing-function", "cubic-bezier(0.2, 0, 0, 1)"), + ]) + ); + set_tailwind_css( + "@theme { --shadow-*: initial; --blur-*: initial; --radius-*: initial; --ease-*: initial; --animate-*: initial; }", + ); + for class in [ + "shadow-md", + "shadow", + "blur-sm", + "blur", + "rounded-lg", + "rounded", + "ease-in", + "animate-spin", + ] { + assert_eq!(crate::tailwind::declarations_of(class), None, "{class}"); + } + assert_eq!( + written("ease-linear"), + pairs(&[ + ("--tw-ease", "linear"), + ("transition-timing-function", "linear") + ]) + ); + assert_eq!(written("animate-none"), pairs(&[("animation", "none")])); + assert_eq!( + written("rounded-full"), + pairs(&[("border-radius", "calc(infinity * 1px)")]) + ); + assert_eq!(written("rounded-none"), pairs(&[("border-radius", "0")])); + set_tailwind_css(""); + } + + #[test] + #[serial] + fn theme_animations_bring_their_keyframes() { + set_tailwind_css( + "@theme { --animate-wiggle: wiggle 1s ease-in-out infinite; @keyframes wiggle { 0%, 100% { transform: rotate(-3deg) } 50% { transform: rotate(3deg) } } --default-transition-duration: 300ms; --default-transition-timing-function: linear; }", + ); + assert_eq!( + written("animate-wiggle"), + pairs(&[("animation", "wiggle 1s ease-in-out infinite")]) + ); + let rules = crate::tailwind::parse_class("animate-wiggle") + .unwrap() + .rules(); + assert_eq!( + rules, + vec!["@keyframes wiggle{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}"] + ); + assert_eq!( + written("transition-opacity")[1..], + pairs(&[ + ("transition-timing-function", "var(--tw-ease, linear)"), + ("transition-duration", "var(--tw-duration, 300ms)"), + ]) + ); + set_tailwind_css(""); + assert_eq!(crate::tailwind::declarations_of("animate-wiggle"), None); + } + + #[test] + #[serial] + fn theme_breakpoints_and_custom_variants() { + set_tailwind_css( + "@theme { --breakpoint-3xl: 120rem; } +@custom-variant theme-midnight (&:where([data-theme=\"midnight\"] *)); +@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant pointer-hover { @media (hover: hover) { @slot; } }", + ); + let conditions = |class: &str| { + crate::tailwind::parse_class(class) + .unwrap() + .conditions + .iter() + .map(|condition| condition.as_ref().map(ToString::to_string)) + .collect::>() + }; + assert_eq!( + conditions("3xl:p-4"), + vec![Some(String::from("@media(min-width:120rem)"))] + ); + assert_eq!( + conditions("theme-midnight:p-4"), + vec![Some(String::from("&:where([data-theme=\"midnight\"] *)"))] + ); + assert_eq!( + conditions("dark:p-4"), + vec![Some(String::from("&:where(.dark, .dark *)"))] + ); + assert_eq!( + conditions("pointer-hover:p-4"), + vec![Some(String::from("@media(hover:hover)"))] + ); + assert_eq!(crate::tailwind::parse_class("midnight:p-4"), None); + set_tailwind_css(""); + assert_eq!(crate::tailwind::parse_class("3xl:p-4"), None); + assert_eq!( + conditions("dark:p-4"), + vec![Some(String::from(":root[data-theme=dark] &"))] + ); + } + + #[test] + #[serial] + fn custom_utilities_are_their_declarations() { + set_tailwind_css( + "@utility content-auto { content-visibility: auto; contain-intrinsic-size: auto 500px; } @utility tab-* { tab-size: --value(integer); }", + ); + assert_eq!( + written("content-auto"), + pairs(&[ + ("content-visibility", "auto"), + ("contain-intrinsic-size", "auto 500px"), + ]) + ); + assert_eq!( + written("content-auto!"), + pairs(&[ + ("content-visibility", "auto !important"), + ("contain-intrinsic-size", "auto 500px !important"), + ]) + ); + assert_eq!(crate::tailwind::declarations_of("-content-auto"), None); + set_tailwind_css(""); + assert_eq!(crate::tailwind::declarations_of("content-auto"), None); + } + + #[test] + #[serial] + fn utilities_of_changed_namespaces_stay_as_written() { + assert!(crate::tailwind::declarations_of("text-lg").is_some()); + set_tailwind_css("@theme { --text-lg: 2rem; --font-weight-bold: 800; }"); + assert_eq!(crate::tailwind::declarations_of("text-lg"), None); + assert_eq!(crate::tailwind::declarations_of("font-bold"), None); + assert!(crate::tailwind::declarations_of("text-sm").is_some()); + 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/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 3f3ea2732..59ed32bac 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -10,7 +10,8 @@ use css::{ style_selector::{ AtRule, AtRuleKind, StyleSelector, get_selector_order, global_selector_order, write_at_rule, }, - theme_tokens::{set_theme_token_levels, set_typography_keys}, + tailwind_definitions::set_tailwind_css, + theme_tokens::{set_color_tokens, set_theme_token_levels, set_typography_keys}, utils::compile_regex, write_merge_selector, }; @@ -481,6 +482,8 @@ impl StyleSheet { theme.get_shadow_token_levels(), ); set_typography_keys(theme.typography.keys().cloned().collect()); + set_color_tokens(theme.get_color_token_names()); + set_tailwind_css(&theme.tailwind_css); self.theme = theme; } @@ -3631,6 +3634,25 @@ mod tests { } } + #[test] + #[serial] + fn test_set_theme_reads_the_tailwind_css() { + let theme: Theme = + serde_json::from_str(r#"{"tailwindCss": "@theme { --color-brand: #0af; }"}"#).unwrap(); + let mut sheet = StyleSheet::default(); + + sheet.set_theme(theme); + assert_eq!( + css::tailwind_definitions::theme_value("color", "brand").as_deref(), + Some("#0af") + ); + + sheet.set_theme(Theme::default()); + assert_eq!( + css::tailwind_definitions::theme_value("color", "brand"), + None + ); + } #[test] #[serial] fn test_global_css_reads_theme_tokens() { diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap index bc58b6e61..d255b5caa 100644 --- a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -38,5 +38,6 @@ StyleSheet { typography: {}, length: {}, shadows: {}, + tailwind_css: "", }, } diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 2aeb60873..a5fc5424f 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -2,7 +2,7 @@ use css::optimize_value::optimize_value; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write as _; /// `ColorEntry` stores both the original key (for TypeScript interface) and CSS key (for CSS variables) @@ -653,6 +653,10 @@ pub struct Theme { deserialize_with = "deserialize_shadow_themes" )] pub shadows: BTreeMap, + /// The text of the project's Tailwind CSS file, whose `@theme`, `@utility` + /// and `@custom-variant` definitions Tailwind classes use + #[serde(default, skip_serializing_if = "String::is_empty")] + pub tailwind_css: String, } /// Deserialize the color variants, naming the variant a token error is in @@ -726,6 +730,7 @@ impl Default for Theme { typography: BTreeMap::new(), length: BTreeMap::new(), shadows: BTreeMap::new(), + tailwind_css: String::new(), } } } @@ -818,6 +823,15 @@ impl Theme { declarations } + /// The CSS names of the colors every theme defines + #[must_use] + pub fn get_color_token_names(&self) -> BTreeSet { + self.colors + .values() + .flat_map(|theme| theme.css_entries().map(|(name, _)| name.clone())) + .collect() + } + #[must_use] pub fn get_length_token_levels(&self) -> BTreeMap> { token_levels(&self.length) @@ -1394,6 +1408,19 @@ mod tests { assert!(!color_theme.contains_key("primary.100")); } + #[test] + fn test_color_token_names_cover_every_theme() { + let theme: Theme = serde_json::from_str( + r##"{"colors": {"light": {"primary": "#100"}, "dark": {"primary": "#200", "text.muted": "#300"}}}"##, + ) + .unwrap(); + + assert_eq!( + theme.get_color_token_names(), + BTreeSet::from([String::from("primary"), String::from("text-muted")]) + ); + } + #[test] fn test_deep_nested_color_should_succeed() { // Deep nesting should be flattened with dashes 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 5fc4b01c9..e156c1a71 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -9,6 +9,8 @@ import { type CustomShorthands, loadDevupConfig, mergeImportAliases, + tailwindCssFiles, + withTailwindCss, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -22,6 +24,7 @@ import { import { type BunPlugin, plugin, type PluginBuilder } from 'bun' import { cssDirName, cssNamespace, resolveCssId } from './css-id' +import { createTailwindWatcher } from './tailwind-watch' const libPackage = '@devup-ui/react' const devupFile = 'devup.json' @@ -48,11 +51,13 @@ export interface DevupUIBunPluginOptions { type SourceLoader = 'tsx' | 'ts' | 'jsx' | 'js' -async function writeDataFiles() { +async function writeDataFiles(): Promise { let theme = {} + let tailwindFiles: string[] = [] try { const config = await loadDevupConfig(devupFile) - theme = config.theme ?? {} + theme = withTailwindCss(config.theme ?? {}, config) + tailwindFiles = tailwindCssFiles(config) } catch { // Error reading devup.json, use empty theme } @@ -69,8 +74,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()) @@ -81,7 +89,7 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { createCompatTypes(importAliases), 'utf-8', ) - await writeDataFiles() + watchTailwindCss(await writeDataFiles()) } const scanners = new Map() 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/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 3f4df0644..667e7e50e 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -156,8 +156,10 @@ describe('devupUILoader', () => { // Test WATCH mode init (lines 55-67) + CSS writing (lines 94-111) it('should initialize watch mode and write css files', async () => { existsSyncSpy.mockReturnValue(true) - readFileSyncSpy.mockReturnValue( - '{"theme": {"colors": {"primary": "#fff"}}}', + readFileSyncSpy.mockImplementation((path: unknown) => + String(path).endsWith('tw.css') + ? '@theme { --color-brand: #0af; }' + : '{"theme": {"colors": {"primary": "#fff"}}, "tailwind": {"css": "tw.css"}}', ) exportSheetSpy.mockReturnValue('sheet') exportClassMapSpy.mockReturnValue('classMap') @@ -207,7 +209,9 @@ describe('devupUILoader', () => { expect(existsSyncSpy).toHaveBeenCalledWith('themeFile') expect(registerThemeSpy).toHaveBeenCalledWith({ colors: { primary: '#fff' }, + tailwindCss: '@theme { --color-brand: #0af; }', }) + expect(t.addDependency).toHaveBeenCalledWith(resolve('tw.css')) // Verify updatedBaseStyle && watch branch (lines 94-99) expect(writeFileSpy).toHaveBeenCalledWith( diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index e11c789b4..7a9b8dc50 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises' import { Agent, request } from 'node:http' import { basename, dirname, join, relative, resolve } from 'node:path' +import { tailwindCssFiles, withTailwindCss } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' import { loadWasm } from './wasm' @@ -221,10 +222,11 @@ const devupUILoader: RawLoaderDefinitionFunction = importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) if (existsSync(fileMapFile)) importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - if (existsSync(themeFile)) - registerTheme( - JSON.parse(readFileSync(themeFile, 'utf-8'))?.theme ?? {}, - ) + if (existsSync(themeFile)) { + const config = JSON.parse(readFileSync(themeFile, 'utf-8')) ?? {} + registerTheme(withTailwindCss(config.theme ?? {}, config)) + for (const file of tailwindCssFiles(config)) this.addDependency(file) + } } else { importFileMap(defaultFileMap) importClassMap(defaultClassMap) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 7905cc7d0..8873752db 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -21,6 +21,7 @@ import { mergeImportAliases, planAtomHoist, type StaticImportGraph, + withTailwindCss, } from '@devup-ui/plugin-utils' import { type NextConfig } from 'next' @@ -251,7 +252,7 @@ export function DevupUI( const devupConfig = loadDevupConfigSync(devupFile) - const theme: any = devupConfig.theme ?? {} + const theme: any = withTailwindCss(devupConfig.theme ?? {}, devupConfig) // Register current theme after importing previous state, // since importSheet replaces the entire sheet including its theme. registerTheme(theme) diff --git a/packages/plugin-utils/src/__tests__/tailwind-css.test.ts b/packages/plugin-utils/src/__tests__/tailwind-css.test.ts new file mode 100644 index 000000000..4be06c515 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/tailwind-css.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + findTailwindCss, + TAILWIND_CSS_CANDIDATES, + tailwindCssFiles, + withTailwindCss, +} from '../tailwind-css' + +let root: string + +function write(file: string, content: string) { + const path = join(root, file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, content) +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'devup-tailwind-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('findTailwindCss', () => { + it('finds the conventional file that imports tailwindcss', () => { + write( + 'src/app/globals.css', + '@import "tailwindcss";\n@theme { --color-a: red; }', + ) + const source = findTailwindCss({}, root) + expect(source?.file).toBe(resolve(root, 'src/app/globals.css')) + expect(source?.css).toContain('--color-a: red') + expect(source?.files).toEqual([resolve(root, 'src/app/globals.css')]) + }) + + it('skips a conventional file that does not import tailwindcss', () => { + write('src/app/globals.css', 'body { margin: 0 }') + write('src/index.css', "@import 'tailwindcss/theme';") + expect(findTailwindCss({}, root)?.file).toBe(resolve(root, 'src/index.css')) + }) + + it('takes the file devup.json names, whatever it holds', () => { + write('src/app/globals.css', '@import "tailwindcss";') + write('styles/tw.css', '@theme { --color-b: blue }') + const source = findTailwindCss({ tailwind: { css: 'styles/tw.css' } }, root) + expect(source?.file).toBe(resolve(root, 'styles/tw.css')) + }) + + it('finds nothing when the named file is missing, when none imports tailwindcss, or when it is switched off', () => { + expect(findTailwindCss({}, root)).toBeUndefined() + expect( + findTailwindCss({ tailwind: { css: 'missing.css' } }, root), + ).toBeUndefined() + write('src/app/globals.css', '@import "tailwindcss";') + expect(findTailwindCss({ tailwind: { css: false } }, root)).toBeUndefined() + }) + + it('reads the local CSS files it imports, where they are imported', () => { + write( + 'src/app/globals.css', + '@import "tailwindcss";\n@import "./theme.css";\n@import "../shared/more.css" layer(base);\n@import "./missing.css";\n@import "https://x.test/a.css";\n.after {}', + ) + write( + 'src/app/theme.css', + '@theme { --color-c: red }\n@import "./theme.css";', + ) + write('src/shared/more.css', '@utility u { a: b }') + const source = findTailwindCss({}, root) + expect(source?.files).toEqual([ + resolve(root, 'src/app/globals.css'), + resolve(root, 'src/app/theme.css'), + resolve(root, 'src/shared/more.css'), + ]) + expect(source?.css).toBe( + '@import "tailwindcss";\n@theme { --color-c: red }\n@import "./theme.css";\n@utility u { a: b }\n@import "./missing.css";\n@import "https://x.test/a.css";\n.after {}', + ) + }) + + it('stops following imports at a depth limit', () => { + write('src/app/globals.css', '@import "tailwindcss";\n@import "./a0.css";') + for (let index = 0; index < 12; index++) { + write( + `src/app/a${index}.css`, + `.a${index} {}\n@import "./a${index + 1}.css";`, + ) + } + const source = findTailwindCss({}, root) + expect(source?.files.length).toBe(9) + }) + + it('lists the conventional files', () => { + expect(TAILWIND_CSS_CANDIDATES[0]).toBe('src/app/globals.css') + }) +}) + +describe('withTailwindCss', () => { + it('adds the text of the Tailwind CSS to the theme', () => { + write('src/index.css', '@import "tailwindcss";') + expect(withTailwindCss({ colors: {} }, {}, root)).toEqual({ + colors: {}, + tailwindCss: '@import "tailwindcss";', + }) + }) + + it('leaves the theme alone without a Tailwind CSS', () => { + const theme = { colors: {} } + expect(withTailwindCss(theme, {}, root)).toBe(theme) + }) +}) + +describe('tailwindCssFiles', () => { + it('lists every file read, none without a Tailwind CSS', () => { + expect(tailwindCssFiles({}, root)).toEqual([]) + write('src/index.css', '@import "tailwindcss";\n@import "./t.css";') + write('src/t.css', '') + expect(tailwindCssFiles({}, root)).toEqual([ + resolve(root, 'src/index.css'), + resolve(root, 'src/t.css'), + ]) + }) +}) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index 385a2dc2b..56290a8b6 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -27,6 +27,13 @@ export { type DevupUIBasePluginOptions, getFileNumByFilename, } from './shared' +export { + findTailwindCss, + TAILWIND_CSS_CANDIDATES, + tailwindCssFiles, + type TailwindCssSource, + withTailwindCss, +} from './tailwind-css' export type { CustomShorthands, DevupConfig, diff --git a/packages/plugin-utils/src/tailwind-css.ts b/packages/plugin-utils/src/tailwind-css.ts new file mode 100644 index 000000000..c4a40d8ab --- /dev/null +++ b/packages/plugin-utils/src/tailwind-css.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +import type { DevupConfig } from './types' + +/** + * Where a project's Tailwind CSS file usually is, in the order they are tried + */ +export const TAILWIND_CSS_CANDIDATES = [ + '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', +] as const + +const TAILWIND_IMPORT = /@import\s+(?:url\()?["']tailwindcss(?:\/[^"']*)?["']/ +const LOCAL_IMPORT = + /@import\s+(?:url\()?["'](\.{1,2}\/[^"']+\.css)["']\)?[^;]*;/g +const MAX_IMPORT_DEPTH = 8 + +/** + * The Tailwind CSS a project defines its theme, utilities and variants in + */ +export interface TailwindCssSource { + /** The entry file, the one that imports `tailwindcss` */ + file: string + /** Every file read: the entry and the local CSS files it imports */ + files: string[] + /** The text of the files, each import where it is written */ + css: string +} + +function read(file: string): string | undefined { + try { + return readFileSync(file, 'utf-8') + } catch { + return undefined + } +} + +/** The text of css, each local import replaced by what it imports */ +function expandImports( + file: string, + css: string, + files: string[], + depth: number, +): string { + files.push(file) + if (depth >= MAX_IMPORT_DEPTH) return css + return css.replace(LOCAL_IMPORT, (statement, path: string) => { + const imported = resolve(dirname(file), path) + const importedCss = files.includes(imported) ? undefined : read(imported) + return importedCss === undefined + ? statement + : expandImports(imported, importedCss, files, depth + 1) + }) +} +/** + * Finds the project's Tailwind CSS: `tailwind.css` of devup.json when it names + * a file, nothing when it is `false`, else the first conventional file that + * imports `tailwindcss` + */ +export function findTailwindCss( + config: DevupConfig, + cwd = process.cwd(), +): TailwindCssSource | undefined { + const configured = config.tailwind?.css + if (configured === false) return undefined + const candidates = + typeof configured === 'string' ? [configured] : TAILWIND_CSS_CANDIDATES + for (const candidate of candidates) { + const file = resolve(cwd, candidate) + const entry = read(file) + if (entry === undefined) continue + if (typeof configured !== 'string' && !TAILWIND_IMPORT.test(entry)) continue + const files: string[] = [] + return { file, files, css: expandImports(file, entry, files, 0) } + } + return undefined +} + +/** + * `theme` with the text of the project's Tailwind CSS, which the build + * reads the definitions of `@theme`, `@utility` and `@custom-variant` from + */ +export function withTailwindCss( + theme: T, + config: DevupConfig, + cwd = process.cwd(), +): T & { tailwindCss?: string } { + const source = findTailwindCss(config, cwd) + return source ? { ...theme, tailwindCss: source.css } : theme +} + +/** + * The files the project's Tailwind CSS is read from, which a rebuild watches + */ +export function tailwindCssFiles( + config: DevupConfig, + cwd = process.cwd(), +): string[] { + return findTailwindCss(config, cwd)?.files ?? [] +} diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2d..2a8f90a3a 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -79,6 +79,18 @@ export interface DevupConfig { * Theme configuration */ theme?: DevupTheme + + /** + * The project's Tailwind CSS, which the build reads the definitions of + * `@theme`, `@utility` and `@custom-variant` from + */ + tailwind?: { + /** + * The CSS file that imports `tailwindcss`. Found among the conventional + * files when omitted; `false` reads none + */ + css?: string | false + } } /** diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 1a4393a97..133e06125 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -390,6 +390,73 @@ 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; }', + ) + readFileSpy.mockResolvedValue( + JSON.stringify({ tailwind: { css: 'tailwind.css' } }), + ) + existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') + + await DevupUI({}).setup(createSetupContext()) + + expect(registerThemeSpy).toHaveBeenCalledWith({ + tailwindCss: '@theme { --color-brand: #0af; }', + }) + readFileSyncSpy.mockRestore() + readFileSpy.mockResolvedValue('{}') + }) it.each( createTestMatrix({ watch: [true, false], @@ -740,7 +807,7 @@ const App = () => `, createSetupContext({ transform, onBeforeBuild, modifyRspackConfig }), ) const config: { plugins?: { apply(compiler: unknown): void }[] } = {} - modifyRspackConfig.mock.calls[0][0](config, { + modifyRspackConfig.mock.calls[1][0](config, { environment: { name: 'web' }, }) const taps: Record unknown> = {} diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 47d4f2f3f..f5de9b050 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, writeFileSync } 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 { @@ -16,6 +16,8 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + tailwindCssFiles, + withTailwindCss, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -73,12 +75,14 @@ async function writeDataFiles( DevupUIRsbuildPluginOptions, 'extractCss' | 'debug' | 'include' >, -) { +): Promise { + let tailwindFiles: string[] = [] try { const config = await loadDevupConfig(options.devupFile) const theme = config.theme ?? {} - registerTheme(theme) + registerTheme(withTailwindCss(theme, config)) + tailwindFiles = tailwindCssFiles(config) const interfaceCode = getThemeInterface( ...createThemeInterfaceArgs(options.package), ) @@ -102,6 +106,7 @@ async function writeDataFiles( ? writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) : Promise.resolve(), ]) + return tailwindFiles } /** @@ -147,7 +152,7 @@ export const DevupUI = ({ 'utf-8', ) - await writeDataFiles({ + let tailwindFiles = await writeDataFiles({ package: libPackage, cssDir, devupFile, @@ -156,6 +161,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 @@ -370,6 +417,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 diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 4e14b5fd8..102c3d901 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' +import * as os from 'node:os' import * as nodePath from 'node:path' import * as pluginUtils from '@devup-ui/plugin-utils' @@ -1088,6 +1089,118 @@ describe('devupUIVitePlugin', () => { expect(send).not.toHaveBeenCalled() }) + describe('Tailwind CSS definitions', () => { + let directory: string + let tailwindFile: string + + beforeEach(() => { + directory = fs.mkdtempSync(join(os.tmpdir(), 'devup-vite-tailwind-')) + tailwindFile = join(directory, 'tailwind.css') + fs.writeFileSync(tailwindFile, '@theme { --color-brand: #0af; }') + existsSyncSpy.mockReturnValue(true) + readFileSpy.mockResolvedValue( + JSON.stringify({ tailwind: { css: tailwindFile } }), + ) + }) + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }) + }) + + it('registers the text of the Tailwind CSS with the theme', async () => { + const plugin = createPlugin({}) + await plugin.configResolved() + expect(registerThemeSpy).toHaveBeenCalledWith({ + tailwindCss: '@theme { --color-brand: #0af; }', + }) + }) + + it('reads it again when the Tailwind CSS changes', async () => { + const plugin = createPlugin({}) + await plugin.configResolved() + registerThemeSpy.mockClear() + fs.writeFileSync(tailwindFile, '@theme { --color-brand: #f00; }') + + await plugin.watchChange(tailwindFile) + + expect(registerThemeSpy).toHaveBeenCalledWith({ + tailwindCss: '@theme { --color-brand: #f00; }', + }) + }) + + it('compiles every module again when the Tailwind CSS changes', async () => { + const plugin = createPlugin({}) + await plugin.configResolved() + const first = {} + const second = {} + const environment = { + ...createHotUpdateEnvironment('client'), + moduleGraph: { + invalidateModule: mock(), + idToModuleMap: new Map([ + ['a', first], + ['b', second], + ]), + }, + } + + const result = await plugin.hotUpdate.call( + { environment }, + { file: tailwindFile, modules: [{}], timestamp: 1 }, + ) + + expect(environment.moduleGraph.invalidateModule).toHaveBeenCalledTimes(2) + expect(environment.moduleGraph.invalidateModule).toHaveBeenCalledWith( + first, + expect.any(Set), + 1, + true, + ) + expect(environment.hot.send).toHaveBeenCalledWith({ type: 'full-reload' }) + expect(result).toEqual([]) + }) + + it('compiles every module again on a Vite 5 hot update', async () => { + const plugin = createPlugin({}) + await plugin.configResolved() + const invalidateModule = mock() + const send = mock() + const module = {} + + const result = await plugin.handleHotUpdate({ + file: tailwindFile, + server: { + moduleGraph: { + invalidateModule, + idToModuleMap: new Map([['a', module]]), + }, + ws: { send }, + }, + modules: [], + timestamp: 2, + }) + + expect(invalidateModule).toHaveBeenCalledWith( + module, + expect.any(Set), + 2, + true, + ) + expect(send).toHaveBeenCalledWith({ type: 'full-reload' }) + expect(result).toEqual([]) + }) + + it('leaves a Tailwind CSS that is gone alone', async () => { + const plugin = createPlugin({}) + await plugin.configResolved() + registerThemeSpy.mockClear() + existsSyncSpy.mockReturnValue(false) + + await plugin.watchChange(tailwindFile) + + expect(registerThemeSpy).not.toHaveBeenCalled() + }) + }) function createHotUpdateEnvironment(consumer: 'client' | 'server') { return { config: { consumer }, diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 91e585045..f6d50ad48 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -16,6 +16,8 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + tailwindCssFiles, + withTailwindCss, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -292,12 +294,14 @@ export interface DevupUIPluginOptions { async function writeDataFiles( options: Omit, -) { +): Promise { + let tailwindFiles: string[] = [] try { const config = await loadDevupConfig(options.devupFile) const theme = config.theme ?? {} - registerTheme(theme) + registerTheme(withTailwindCss(theme, config)) + tailwindFiles = tailwindCssFiles(config) const interfaceCode = getThemeInterface( ...createThemeInterfaceArgs(options.package), ) @@ -321,6 +325,7 @@ async function writeDataFiles( if (!options.singleCss) { await writeFile(join(options.cssDir, 'devup-ui.css'), getCss(null, false)) } + return tailwindFiles } export function DevupUI({ @@ -354,6 +359,13 @@ export function DevupUI({ // module transformed again writes its sheet again, and the reload that // signal causes transforms it once more: signal only a changed sheet. const writtenCss = new Map() + // The files the project's Tailwind CSS is read from, which change what a + // Tailwind class compiles to + let tailwindFiles: string[] = [] + const isTailwindFile = (file: string) => + tailwindFiles.some( + (tailwindFile) => resolve(tailwindFile) === resolve(file), + ) function writeCssFile(fileName: string, css: string): Promise { if (writtenCss.get(fileName) === css) return Promise.resolve() writtenCss.set(fileName, css) @@ -389,7 +401,7 @@ export function DevupUI({ createCompatTypes(importAliases), 'utf-8', ) - await writeDataFiles({ + tailwindFiles = await writeDataFiles({ package: libPackage, cssDir, devupFile, @@ -485,9 +497,12 @@ export function DevupUI({ return true }, async watchChange(id) { - if (resolve(id) === resolve(devupFile) && existsSync(devupFile)) { + if ( + (resolve(id) === resolve(devupFile) && existsSync(devupFile)) || + (isTailwindFile(id) && existsSync(id)) + ) { try { - await writeDataFiles({ + tailwindFiles = await writeDataFiles({ package: libPackage, cssDir, devupFile, @@ -514,11 +529,14 @@ export function DevupUI({ ? [] : undefined } - if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) { + const isDevupFile = + resolve(file) === resolve(devupFile) && existsSync(devupFile) + const isTailwind = isTailwindFile(file) && existsSync(file) + if (!isDevupFile && !isTailwind) { return } - await writeDataFiles({ + tailwindFiles = await writeDataFiles({ package: libPackage, cssDir, devupFile, @@ -527,7 +545,11 @@ export function DevupUI({ }) const invalidatedModules = new Set() - for (const mod of modules) { + // A Tailwind class compiles from the definitions of the Tailwind CSS, so + // every module is compiled again + for (const mod of isTailwind + ? environment.moduleGraph.idToModuleMap.values() + : modules) { environment.moduleGraph.invalidateModule( mod, invalidatedModules, @@ -540,11 +562,14 @@ export function DevupUI({ }, // Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists. async handleHotUpdate({ file, server, modules, timestamp }) { - if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) { + const isDevupFile = + resolve(file) === resolve(devupFile) && existsSync(devupFile) + const isTailwind = isTailwindFile(file) && existsSync(file) + if (!isDevupFile && !isTailwind) { return } - await writeDataFiles({ + tailwindFiles = await writeDataFiles({ package: libPackage, cssDir, devupFile, @@ -553,7 +578,9 @@ export function DevupUI({ }) const invalidatedModules = new Set() - for (const mod of modules) { + for (const mod of isTailwind + ? server.moduleGraph.idToModuleMap.values() + : modules) { server.moduleGraph.invalidateModule( mod, invalidatedModules, diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 524e755bd..ab3a1f617 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -323,6 +323,79 @@ describe('devupUIWebpackPlugin', () => { expect(importFileMapSpy).toHaveBeenCalledWith({}) }) + describe('Tailwind CSS definitions', () => { + const tailwindFile = resolve('styles/tailwind.css') + + function mockTailwindCss() { + existsSyncSpy.mockImplementation( + (path: string) => path === 'devup.json' || path === tailwindFile, + ) + loadDevupConfigSyncSpy.mockReturnValue({ + tailwind: { css: 'styles/tailwind.css' }, + }) + readFileSyncSpy.mockImplementation((path: string) => + path === tailwindFile ? '@theme { --color-brand: #0af; }' : '{}', + ) + } + + it('registers the text of the Tailwind CSS and depends on its files', () => { + mockTailwindCss() + const plugin = new DevupUIWebpackPlugin({}) + const compiler = createCompiler() + + plugin.apply(asCompiler(compiler)) + + expect(registerThemeSpy).toHaveBeenCalledWith({ + tailwindCss: '@theme { --color-brand: #0af; }', + }) + const add = mock() + compiler.hooks.afterCompile.tap.mock.calls[0][1]({ + fileDependencies: { add }, + }) + expect(add).toHaveBeenCalledWith(resolve('devup.json')) + expect(add).toHaveBeenCalledWith(tailwindFile) + }) + + it('reads it again when a file changes', async () => { + mockTailwindCss() + const plugin = new DevupUIWebpackPlugin({ watch: true }) + const compiler = createCompiler() + statSpy.mockResolvedValueOnce(createStats(1)) + statSpy.mockResolvedValueOnce(createStats(1)) + statSpy.mockResolvedValueOnce(createStats(1)) + statSpy.mockResolvedValueOnce(createStats(2)) + plugin.apply(asCompiler(compiler)) + registerThemeSpy.mockClear() + + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + + expect(registerThemeSpy).toHaveBeenCalledWith({ + tailwindCss: '@theme { --color-brand: #0af; }', + }) + }) + + it('takes a Tailwind CSS that is gone as unchanged', async () => { + existsSyncSpy.mockImplementation((path: string) => path === tailwindFile) + loadDevupConfigSyncSpy.mockReturnValue({ + tailwind: { css: 'styles/tailwind.css' }, + }) + readFileSyncSpy.mockImplementation((path: string) => + path === tailwindFile ? '@import "tailwindcss";' : '{}', + ) + const plugin = new DevupUIWebpackPlugin({ + watch: true, + devupFile: 'missing.json', + }) + const compiler = createCompiler() + statSpy.mockRejectedValue(new Error('gone')) + plugin.apply(asCompiler(compiler)) + + await compiler.hooks.watchRun.tapPromise.mock.calls[0][1]() + + expect(plugin.tailwindFiles).toEqual([tailwindFile]) + }) + }) it.each( createTestMatrix({ watch: [true, false], diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 83b2c2264..b96f2e176 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -17,7 +17,9 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + tailwindCssFiles, type WasmImportAliases, + withTailwindCss, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -118,12 +120,16 @@ export class DevupUIWebpackPlugin { this.fileMapFile = join(this.options.distDir, 'fileMap.json') } + /** The files the project's Tailwind CSS is read from */ + tailwindFiles: string[] = [] + writeDataFiles() { try { const config = loadDevupConfigSync(this.options.devupFile) const theme = config.theme ?? {} - registerTheme(theme) + registerTheme(withTailwindCss(theme, config)) + this.tailwindFiles = tailwindCssFiles(config) const interfaceCode = getThemeInterface( ...createThemeInterfaceArgs(this.options.package), ) @@ -297,12 +303,21 @@ export class DevupUIWebpackPlugin { } if (this.options.watch) { - let lastModifiedTime: number | null = null + let lastModifiedTime: string | null = null compiler.hooks.watchRun.tapPromise('DevupUIWebpackPlugin', async () => { - if (existsDevup) { - const stats = await stat(this.options.devupFile) - - const modifiedTime = stats.mtimeMs + const stamps: number[] = [] + if (existsDevup) + stamps.push((await stat(this.options.devupFile)).mtimeMs) + for (const file of this.tailwindFiles) { + stamps.push( + await stat(file).then( + (stats) => stats.mtimeMs, + () => 0, + ), + ) + } + if (stamps.length > 0) { + const modifiedTime = stamps.join() if (lastModifiedTime && lastModifiedTime !== modifiedTime) this.writeDataFiles() @@ -310,11 +325,13 @@ export class DevupUIWebpackPlugin { } }) } - if (existsDevup) + if (existsDevup || this.tailwindFiles.length > 0) compiler.hooks.afterCompile.tap('DevupUIWebpackPlugin', (compilation) => { - compilation.fileDependencies.add(resolve(this.options.devupFile)) + if (existsDevup) + compilation.fileDependencies.add(resolve(this.options.devupFile)) + for (const file of this.tailwindFiles) + compilation.fileDependencies.add(resolve(file)) }) - compiler.options.plugins.push( new compiler.webpack.DefinePlugin({ 'process.env.DEVUP_UI_DEFAULT_THEME': JSON.stringify(getDefaultTheme()),