diff --git a/package.json b/package.json index 866db34..bc4c729 100644 --- a/package.json +++ b/package.json @@ -93,5 +93,10 @@ "esbuild", "unrs-resolver" ] + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b938f2e..4576ac4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,16 @@ settings: importers: .: + dependencies: + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) devDependencies: '@babel/cli': specifier: ^7.28.3 @@ -1983,6 +1993,9 @@ packages: cjs-module-lexer@2.1.1: resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2002,6 +2015,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3971,6 +3988,22 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + + tailwind-variants@3.2.2: + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + terser@5.44.1: resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} @@ -6592,6 +6625,10 @@ snapshots: cjs-module-lexer@2.1.1: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -6612,6 +6649,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -9119,6 +9158,16 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tailwind-merge@3.4.0: {} + + tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18): + dependencies: + tailwindcss: 4.1.18 + optionalDependencies: + tailwind-merge: 3.4.0 + + tailwindcss@4.1.18: {} + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 diff --git a/src/babel/plugin.ts b/src/babel/plugin.ts index ea1e39d..53fdb99 100644 --- a/src/babel/plugin.ts +++ b/src/babel/plugin.ts @@ -11,6 +11,11 @@ import { jsxAttributeVisitor } from "./plugin/visitors/className.js"; import { importDeclarationVisitor } from "./plugin/visitors/imports.js"; import { programEnter, programExit } from "./plugin/visitors/program.js"; import { callExpressionVisitor, taggedTemplateVisitor } from "./plugin/visitors/tw.js"; +import { + variantCallVisitor, + variantDefinitionVisitor, + variantImportVisitor, +} from "./plugin/visitors/variants.js"; // Re-export PluginOptions for external use export type { PluginOptions }; @@ -56,6 +61,13 @@ export default function reactNativeTailwindBabelPlugin( ImportDeclaration(path, state) { importDeclarationVisitor(path, state, t); + // Also track variant imports (tv/cva) + variantImportVisitor(path, state, t); + }, + + VariableDeclarator(path, state) { + // Process variant function definitions (const button = tv({...})) + variantDefinitionVisitor(path, state, t); }, TaggedTemplateExpression(path, state) { @@ -63,6 +75,9 @@ export default function reactNativeTailwindBabelPlugin( }, CallExpression(path, state) { + // First check if this is a variant function call + variantCallVisitor(path, state, t); + // Then check for tw/twStyle calls callExpressionVisitor(path, state, t); }, diff --git a/src/babel/plugin/state.ts b/src/babel/plugin/state.ts index c5b546e..e3c462d 100644 --- a/src/babel/plugin/state.ts +++ b/src/babel/plugin/state.ts @@ -9,6 +9,44 @@ import type { StyleObject } from "../../types/core.js"; import type { CustomTheme } from "../config-loader.js"; import { extractCustomTheme } from "../config-loader.js"; import { DEFAULT_CLASS_ATTRIBUTES, buildAttributeMatchers } from "../utils/attributeMatchers.js"; +import type { VariantFunctionEntry } from "../utils/variantProcessing.js"; + +/** + * Types of class utilities we track and transform + * + * - "tv" / "cva": Variant function creators (create functions called later) + * - "twMerge": Class merger with conflict resolution + * - "twJoin" / "cx": Class joiners without conflict resolution + */ +export type ClassUtilityType = "tv" | "cva" | "twMerge" | "twJoin" | "cx"; + +/** + * Tracked class utility import info + */ +export type TrackedClassUtility = { + type: ClassUtilityType; + originalName: string; // The actual import name (e.g., 'tv', 'twMerge') +}; + +/** + * Configuration for class utility imports to track + * Maps package name -> { exportName -> utilityType } + * + * Adding a new library or export is just one line here! + */ +export const CLASS_UTILITY_CONFIG: Record> = { + "tailwind-variants": { + tv: "tv", + }, + "class-variance-authority": { + cva: "cva", + cx: "cx", // Re-export of clsx for class concatenation + }, + "tailwind-merge": { + twMerge: "twMerge", // Merge with conflict resolution + twJoin: "twJoin", // Join without conflict resolution (faster) + }, +}; /** * Plugin options @@ -121,6 +159,11 @@ export type PluginState = PluginPass & { functionComponentsNeedingColorScheme: Set>; // Track function components that need windowDimensions hook injection functionComponentsNeedingWindowDimensions: Set>; + // Track class utility imports (tv, cva, twMerge, etc.) + // Maps local name -> { type, originalName } + classUtilityImports: Map; + variantFunctions: Map; + hasClassUtilityTransformations: boolean; }; // Default identifier for the generated StyleSheet constant @@ -181,5 +224,9 @@ export function createInitialState( reactNativeImportPath: undefined, functionComponentsNeedingColorScheme: new Set(), functionComponentsNeedingWindowDimensions: new Set(), + // Class utility support (tv, cva, twMerge) + classUtilityImports: new Map(), + variantFunctions: new Map(), + hasClassUtilityTransformations: false, }; } diff --git a/src/babel/plugin/visitors/program.ts b/src/babel/plugin/visitors/program.ts index 71b5a10..d04ae93 100644 --- a/src/babel/plugin/visitors/program.ts +++ b/src/babel/plugin/visitors/program.ts @@ -17,6 +17,7 @@ import { } from "../../utils/styleInjection.js"; import { removeTwImports } from "../../utils/twProcessing.js"; import type { PluginState } from "../state.js"; +import { removeVariantDefinitions, removeVariantImports } from "./variants.js"; /** * Program enter visitor - initialize state for each file @@ -41,9 +42,16 @@ export function programExit( removeTwImports(path, t); } - // If no classNames were found and no hooks/imports needed, skip processing + // Remove class utility imports and definitions if they were processed + if (state.hasClassUtilityTransformations) { + removeVariantImports(path, state, t); + removeVariantDefinitions(path, state, t); + } + + // If no classNames/utilities were found and no hooks/imports needed, skip processing if ( !state.hasClassNames && + !state.hasClassUtilityTransformations && !state.needsWindowDimensionsImport && !state.needsColorSchemeImport && !state.needsI18nManagerImport diff --git a/src/babel/plugin/visitors/variants.test.ts b/src/babel/plugin/visitors/variants.test.ts new file mode 100644 index 0000000..10dcc87 --- /dev/null +++ b/src/babel/plugin/visitors/variants.test.ts @@ -0,0 +1,624 @@ +import { describe, expect, it } from "vitest"; +import { transform } from "../../../../test/helpers/babelTransform.js"; + +describe("variants visitor - tv()", () => { + describe("import tracking", () => { + it("should track tv import from tailwind-variants", () => { + const code = ` + import { tv } from 'tailwind-variants'; + + const button = tv({ + base: 'font-semibold rounded-lg', + variants: { + color: { + primary: 'bg-blue-500', + secondary: 'bg-gray-500', + }, + }, + }); + + function Button() { + return