@trackunit/css-class-variance-utilities 1.14.28 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,65 @@
1
1
  # Trackunit css-class-variance-utilities
2
2
 
3
- The `@trackunit/css-class-variance-utilities` package is the home of Trackunits custom implementation of the CVA and twMerge npm packages.
3
+ The `@trackunit/css-class-variance-utilities` package is a thin wrapper around the upstream [`cva`](https://cva.style/beta) package with [`tailwind-merge`](https://github.com/dcastil/tailwind-merge) wired into its `onComplete` hook, so later conflicting utilities win and callers do not need to call `twMerge` themselves. Use `cva` for a variant table and `cx` from this package to join class strings — both go through that hook.
4
+
5
+ ## Usage
6
+
7
+ Import `cva` and `cx` from this package rather than from `cva` directly:
8
+
9
+ ```ts
10
+ import { cva } from "@trackunit/css-class-variance-utilities";
11
+
12
+ export const cvaPage = cva({
13
+ base: ["grid", "h-full"],
14
+ variants: {
15
+ layout: {
16
+ content: ["grid-rows-1"],
17
+ none: [],
18
+ },
19
+ },
20
+ defaultVariants: { layout: "none" },
21
+ });
22
+ ```
23
+
24
+ Because `cx` goes through the same hook, it resolves conflicts too: `cx("p-2", "p-4")` returns `"p-4"`.
25
+
26
+ Authoring guide (what to write in a `.variants.ts` file, and when to use `cx`): Storybook [Foundations → Styling variants](https://design.iris.trackunit.com/?path=/docs/foundations-styling-variants--docs).
27
+
28
+ To compose variant functions, use the `composes` property on a `cva` config.
29
+
30
+ ## Migrating from `cvaMerge`
31
+
32
+ `cvaMerge` is **gone**. There is no compatibility shim: bumping this package without running the migration is a compile failure, not a deprecation warning.
33
+
34
+ The old call took the base classes as a positional argument; `cva` takes a single config object with a `base` property:
35
+
36
+ ```ts
37
+ // before
38
+ cvaMerge(["grid", "h-full"], { variants: { … } });
39
+ // after
40
+ cva({ base: ["grid", "h-full"], variants: { … } });
41
+ ```
42
+
43
+ Run these two commands in the consuming repo after bumping the dependency:
44
+
45
+ ```bash
46
+ nx g @trackunit/migrations:migrate
47
+ nx g @trackunit/migrations:run-migrations
48
+ ```
49
+
50
+ `migrate` discovers this package's pending migration and writes it to `trackunit-migrations.json`. `run-migrations` applies it. A second run is a no-op — already-applied identities are recorded and skipped.
51
+
52
+ The codemod rewrites the imports and the call shape, preserving comments and formatting. Any call site it can't rewrite with confidence — a base or config built at runtime, or an aliased import — is **left untouched and printed with its file and line**. Fix those by hand; the run never guesses at classnames it wasn't sure about.
53
+
54
+ ### `null` variant props
55
+
56
+ cva 0.7 accepted `null` for a variant prop and suppressed that variant. cva 1.0's types reject `null`, and at runtime `null` falls through to the variant's `defaultVariants` entry. The codemod rewrites variant *definitions*, not their consumers, so these surface as type errors after migrating.
57
+
58
+ `undefined` is the replacement, but the two are only equivalent when that variant key has **no** `defaultVariants` entry. Where a default exists, `undefined` silently applies the default class instead of no class.
59
+
60
+ ### Pinned prerelease
61
+
62
+ This package depends on `cva@1.0.0-beta.8`, pinned exactly (no caret). The bare `cva` npm name's `latest` dist-tag resolves to an unrelated squatted package, and a future stable 1.0.0 may require another migration — the composition primitive in particular has already changed shape between betas.
4
63
 
5
64
  For more info and a full guide on Iris App SDK Development, please visit our [Developer Hub](https://developers.trackunit.com/).
6
65
 
package/index.cjs.js CHANGED
@@ -1,16 +1,25 @@
1
1
  'use strict';
2
2
 
3
- var classVarianceAuthority = require('class-variance-authority');
3
+ var cva$1 = require('cva');
4
4
  var tailwindMerge = require('tailwind-merge');
5
5
 
6
- // This _is_ the wrapper, so it's the only place we need to import directly from class-variance-authority.
6
+ // This _is_ the wrapper, so it's the only place we need to import directly from the cva package.
7
7
  // eslint-disable-next-line no-restricted-imports
8
8
  /**
9
- * A wrapper around CVA that uses tailwind-merge to merge the classes.
9
+ * `cva` and `cx`, bound to a shared config whose `onComplete` hook runs every
10
+ * generated class string through tailwind-merge.
11
+ *
12
+ * Note that because of that hook, this `cx` resolves Tailwind conflicts rather than
13
+ * merely concatenating like the upstream `cx`/`clsx` of the same name: given
14
+ * `cx("p-2", "p-4")` the losing `p-2` is dropped.
15
+ *
16
+ * Composition is available through the `composes` property on a `cva` config;
17
+ * `defineConfig` also returns a `compose` function, but it is deprecated upstream
18
+ * in favour of `composes`, so it is deliberately not re-exported here.
10
19
  */
11
- const cvaMerge = (base, config) => {
12
- const classes = classVarianceAuthority.cva(base, config);
13
- return props => tailwindMerge.twMerge(classes(props));
14
- };
20
+ const { cva, cx } = cva$1.defineConfig({
21
+ hooks: { onComplete: className => tailwindMerge.twMerge(className) },
22
+ });
15
23
 
16
- exports.cvaMerge = cvaMerge;
24
+ exports.cva = cva;
25
+ exports.cx = cx;
package/index.esm.js CHANGED
@@ -1,14 +1,22 @@
1
- import { cva } from 'class-variance-authority';
1
+ import { defineConfig } from 'cva';
2
2
  import { twMerge } from 'tailwind-merge';
3
3
 
4
- // This _is_ the wrapper, so it's the only place we need to import directly from class-variance-authority.
4
+ // This _is_ the wrapper, so it's the only place we need to import directly from the cva package.
5
5
  // eslint-disable-next-line no-restricted-imports
6
6
  /**
7
- * A wrapper around CVA that uses tailwind-merge to merge the classes.
7
+ * `cva` and `cx`, bound to a shared config whose `onComplete` hook runs every
8
+ * generated class string through tailwind-merge.
9
+ *
10
+ * Note that because of that hook, this `cx` resolves Tailwind conflicts rather than
11
+ * merely concatenating like the upstream `cx`/`clsx` of the same name: given
12
+ * `cx("p-2", "p-4")` the losing `p-2` is dropped.
13
+ *
14
+ * Composition is available through the `composes` property on a `cva` config;
15
+ * `defineConfig` also returns a `compose` function, but it is deprecated upstream
16
+ * in favour of `composes`, so it is deliberately not re-exported here.
8
17
  */
9
- const cvaMerge = (base, config) => {
10
- const classes = cva(base, config);
11
- return props => twMerge(classes(props));
12
- };
18
+ const { cva, cx } = defineConfig({
19
+ hooks: { onComplete: className => twMerge(className) },
20
+ });
13
21
 
14
- export { cvaMerge };
22
+ export { cva, cx };
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logSummary = exports.lineOf = exports.applyEdits = exports.hasSyntaxErrors = exports.parseTs = exports.visitTsFiles = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const devkit_1 = require("@nx/devkit");
6
+ const ts = tslib_1.__importStar(require("typescript"));
7
+ /**
8
+ * Migrations are compiled per package (`tsconfig.migrations.json` sets `rootDir: "."`),
9
+ * so these helpers deliberately mirror `libs/react/components/migrations/utils/jsx-utils.ts`
10
+ * rather than importing it across package boundaries.
11
+ */
12
+ /**
13
+ * Plain JavaScript is included because consumer repos running these migrations
14
+ * aren't all TypeScript, and a skipped file is a silent miss rather than a flag.
15
+ */
16
+ const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
17
+ const isTsFile = (filePath) => SOURCE_EXTENSIONS.some(ext => filePath.endsWith(ext));
18
+ const isUnderNodeModules = (filePath) => filePath.includes("/node_modules/") || filePath.startsWith("node_modules/");
19
+ const isUnderDist = (filePath) => filePath.includes("/dist/") || filePath.startsWith("dist/") || filePath.includes("/.nx/");
20
+ /**
21
+ * Visits every TypeScript file under the workspace root and invokes the callback
22
+ * with the file path and its contents. Files not containing the marker substring
23
+ * are skipped, which keeps a ~30k-file workspace cheap to scan.
24
+ */
25
+ const visitTsFiles = (tree, marker, callback) => {
26
+ let touched = 0;
27
+ (0, devkit_1.visitNotIgnoredFiles)(tree, "/", filePath => {
28
+ if (!isTsFile(filePath))
29
+ return;
30
+ if (isUnderNodeModules(filePath) || isUnderDist(filePath))
31
+ return;
32
+ const content = tree.read(filePath, "utf-8");
33
+ if (content === null)
34
+ return;
35
+ if (!content.includes(marker))
36
+ return;
37
+ const updated = callback(filePath, content);
38
+ if (updated !== null && updated !== undefined && updated !== content) {
39
+ tree.write(filePath, updated);
40
+ touched += 1;
41
+ }
42
+ });
43
+ return touched;
44
+ };
45
+ exports.visitTsFiles = visitTsFiles;
46
+ /**
47
+ * `.tsx`/`.jsx` need `ScriptKind.TSX` so JSX is recognised; anything else needs a
48
+ * non-JSX kind, because TSX reinterprets angle brackets. A `.ts` file containing
49
+ * `<TValue>(value: TValue) => …` (a generic arrow) or `<Foo>expr` (an old-style
50
+ * assertion) parses as JSX under TSX instead, which silently produces zero call
51
+ * sites rather than the file this codemod is meant to see.
52
+ */
53
+ const scriptKindOf = (fileName) => {
54
+ if (fileName.endsWith(".tsx") || fileName.endsWith(".jsx"))
55
+ return ts.ScriptKind.TSX;
56
+ if (fileName.endsWith(".js") || fileName.endsWith(".mjs") || fileName.endsWith(".cjs"))
57
+ return ts.ScriptKind.JS;
58
+ return ts.ScriptKind.TS;
59
+ };
60
+ /**
61
+ * Parses `content` for its extension's grammar. `setParentNodes` is on because the
62
+ * codemod relies on `getStart(sourceFile)` for text-range edits.
63
+ */
64
+ const parseTs = (content, fileName = "file.ts") => ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, scriptKindOf(fileName));
65
+ exports.parseTs = parseTs;
66
+ /**
67
+ * True when `content` does not parse cleanly as its extension's grammar. Checked
68
+ * through the public `transpileModule` API -- syntactic diagnostics only, no
69
+ * type-checking -- so a file this codemod cannot safely analyse is flagged instead
70
+ * of silently skipped or, worse, misread as some other file type's grammar.
71
+ */
72
+ const hasSyntaxErrors = (content, fileName) => {
73
+ const { diagnostics } = ts.transpileModule(content, {
74
+ fileName,
75
+ reportDiagnostics: true,
76
+ compilerOptions: { jsx: ts.JsxEmit.ReactJSX },
77
+ });
78
+ return (diagnostics ?? []).length > 0;
79
+ };
80
+ exports.hasSyntaxErrors = hasSyntaxErrors;
81
+ /**
82
+ * Applies edits back onto the original source text, highest offset first so
83
+ * earlier offsets stay valid. Rewriting text ranges rather than re-printing the
84
+ * AST is what preserves comments and formatting outside the edited spans.
85
+ *
86
+ * Overlapping edits are rejected rather than applied: because every replacement is
87
+ * built by slicing the *original* text, applying two edits over the same range
88
+ * corrupts the file silently. A codemod that crashes is recoverable; one that
89
+ * quietly emits broken source is not.
90
+ */
91
+ const applyEdits = (content, edits) => {
92
+ const ordered = [...edits].sort((a, b) => b.start - a.start);
93
+ for (const [index, edit] of ordered.entries()) {
94
+ const next = ordered[index + 1];
95
+ if (next !== undefined && next.end > edit.start) {
96
+ throw new Error(`applyEdits received overlapping edits: [${next.start}, ${next.end}) overlaps [${edit.start}, ${edit.end})`);
97
+ }
98
+ }
99
+ let updated = content;
100
+ for (const { start, end, text } of ordered) {
101
+ updated = updated.slice(0, start) + text + updated.slice(end);
102
+ }
103
+ return updated;
104
+ };
105
+ exports.applyEdits = applyEdits;
106
+ /** Resolves a node offset to a 1-based line number. */
107
+ const lineOf = (sourceFile, position) => sourceFile.getLineAndCharacterOfPosition(position).line + 1;
108
+ exports.lineOf = lineOf;
109
+ /**
110
+ * Logs a one-line summary plus, when present, every flagged call site with a
111
+ * resolvable `file:line` so a developer can work through them by hand.
112
+ */
113
+ const logSummary = (migrationName, touched, flagged) => {
114
+ if (touched === 0) {
115
+ devkit_1.logger.info(` ${migrationName}: no files changed`);
116
+ }
117
+ else {
118
+ devkit_1.logger.info(` ${migrationName}: updated ${touched} file(s)`);
119
+ }
120
+ if (flagged.length === 0)
121
+ return;
122
+ devkit_1.logger.warn(` ${migrationName}: ${flagged.length} call site(s) need manual migration:`);
123
+ for (const site of flagged) {
124
+ devkit_1.logger.warn(` ${site.filePath}:${site.line} — ${site.reason}`);
125
+ devkit_1.logger.warn(` ${site.snippet}`);
126
+ }
127
+ };
128
+ exports.logSummary = logSummary;
129
+ //# sourceMappingURL=ast-utils.js.map
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cvaMergeToCva = exports.collectFlaggedSites = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const ts = tslib_1.__importStar(require("typescript"));
6
+ const ast_utils_1 = require("../utils/ast-utils");
7
+ const MIGRATION_NAME = "v2-0-0-cva-merge-to-cva";
8
+ const MODULE = "@trackunit/css-class-variance-utilities";
9
+ const LEGACY_EXPORT = "cvaMerge";
10
+ const NEXT_EXPORT = "cva";
11
+ const MAX_SNIPPET_LENGTH = 100;
12
+ const findImportUsage = (sourceFile) => {
13
+ let legacySpecifier = null;
14
+ let hasNextExport = false;
15
+ let namedImports = null;
16
+ let unsupportedShape = null;
17
+ const isOwnModule = (moduleSpecifier) => moduleSpecifier !== undefined && ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === MODULE;
18
+ for (const statement of sourceFile.statements) {
19
+ // `export { cvaMerge } from "…"` re-publishes the old name, so rewriting the
20
+ // re-export would break whoever imports it from here.
21
+ if (ts.isExportDeclaration(statement) && isOwnModule(statement.moduleSpecifier)) {
22
+ const clause = statement.exportClause;
23
+ if (clause !== undefined &&
24
+ ts.isNamedExports(clause) &&
25
+ clause.elements.some(element => (element.propertyName?.text ?? element.name.text) === LEGACY_EXPORT)) {
26
+ unsupportedShape ?? (unsupportedShape = {
27
+ reason: `${LEGACY_EXPORT} is re-exported from this file, so removing it would break importers`,
28
+ node: statement,
29
+ });
30
+ }
31
+ continue;
32
+ }
33
+ if (!ts.isImportDeclaration(statement))
34
+ continue;
35
+ if (!isOwnModule(statement.moduleSpecifier))
36
+ continue;
37
+ const bindings = statement.importClause?.namedBindings;
38
+ if (bindings === undefined)
39
+ continue;
40
+ // `import * as utils` then `utils.cvaMerge(...)` — the call sites are property
41
+ // accesses, which this codemod doesn't rewrite.
42
+ if (ts.isNamespaceImport(bindings)) {
43
+ unsupportedShape ?? (unsupportedShape = {
44
+ reason: `${MODULE} is imported as a namespace, so ${LEGACY_EXPORT} call sites were left untouched`,
45
+ node: statement,
46
+ });
47
+ continue;
48
+ }
49
+ if (!ts.isNamedImports(bindings))
50
+ continue;
51
+ for (const element of bindings.elements) {
52
+ const importedName = element.propertyName?.text ?? element.name.text;
53
+ if (importedName === LEGACY_EXPORT) {
54
+ namedImports = bindings;
55
+ if (element.propertyName !== undefined) {
56
+ // `cvaMerge as somethingElse` — call sites don't spell `cvaMerge`, so
57
+ // rewriting them safely would mean tracking the alias everywhere.
58
+ unsupportedShape ?? (unsupportedShape = {
59
+ reason: `${LEGACY_EXPORT} is imported under an alias, so its call sites were left untouched`,
60
+ node: element,
61
+ });
62
+ }
63
+ else {
64
+ legacySpecifier = element;
65
+ }
66
+ }
67
+ if (importedName === NEXT_EXPORT && element.propertyName === undefined) {
68
+ hasNextExport = true;
69
+ }
70
+ }
71
+ }
72
+ return { legacySpecifier, hasNextExport, namedImports, unsupportedShape };
73
+ };
74
+ const findLegacyCalls = (sourceFile) => {
75
+ const calls = [];
76
+ const visit = (node) => {
77
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === LEGACY_EXPORT) {
78
+ calls.push(node);
79
+ }
80
+ ts.forEachChild(node, visit);
81
+ };
82
+ visit(sourceFile);
83
+ return calls;
84
+ };
85
+ /**
86
+ * Every use of the bare identifier, excluding the import specifier that declares it
87
+ * and `x.cvaMerge` property accesses that resolve elsewhere. Used to decide whether
88
+ * retiring the import would leave a dangling reference behind.
89
+ */
90
+ const findIdentifierReferences = (sourceFile, name) => {
91
+ const references = [];
92
+ const visit = (node) => {
93
+ if (ts.isIdentifier(node) && node.text === name) {
94
+ const { parent } = node;
95
+ const declaresTheBinding = ts.isImportSpecifier(parent);
96
+ const isMemberName = ts.isPropertyAccessExpression(parent) && parent.name === node;
97
+ if (!declaresTheBinding && !isMemberName) {
98
+ references.push(node);
99
+ }
100
+ }
101
+ ts.forEachChild(node, visit);
102
+ };
103
+ visit(sourceFile);
104
+ return references;
105
+ };
106
+ /**
107
+ * True when a binding name -- a plain identifier, or a (possibly nested)
108
+ * destructuring pattern -- binds `name` anywhere inside it. `const { cva } = registry`
109
+ * is idiomatic in this package's own `src/cva.ts` (`export const { cva, cx } =
110
+ * defineConfig(...)`), so a plain `ts.isIdentifier` check on the top-level name misses
111
+ * exactly the shape most likely to appear here.
112
+ */
113
+ const bindingNameBinds = (bindingName, name) => {
114
+ if (ts.isIdentifier(bindingName)) {
115
+ return bindingName.text === name;
116
+ }
117
+ return bindingName.elements.some(element => !ts.isOmittedExpression(element) && bindingNameBinds(element.name, name));
118
+ };
119
+ /** True when the file declares `name` itself, which would shadow or collide with an import. */
120
+ const declaresNameLocally = (sourceFile, name) => {
121
+ let found = null;
122
+ const visit = (node) => {
123
+ if (found !== null)
124
+ return;
125
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && bindingNameBinds(node.name, name)) {
126
+ found = node;
127
+ return;
128
+ }
129
+ const isSimpleNamedDeclaration = ts.isFunctionDeclaration(node) ||
130
+ ts.isClassDeclaration(node) ||
131
+ ts.isTypeAliasDeclaration(node) ||
132
+ ts.isInterfaceDeclaration(node) ||
133
+ ts.isEnumDeclaration(node);
134
+ if (isSimpleNamedDeclaration && node.name !== undefined && node.name.text === name) {
135
+ found = node;
136
+ return;
137
+ }
138
+ ts.forEachChild(node, visit);
139
+ };
140
+ visit(sourceFile);
141
+ return found;
142
+ };
143
+ /**
144
+ * Any binding of `name` other than the already-tracked unaliased `cva` import from
145
+ * this library (`hasNextExport`, checked by the caller before this runs).
146
+ *
147
+ * Every import declaration is scanned, including ones from this library: an aliased
148
+ * import such as `import { twMerge as cva, cvaMerge } from MODULE` binds `cva` just as
149
+ * much as one from any other module does, and skipping this library's own imports
150
+ * wholesale used to let exactly that alias through unnoticed.
151
+ */
152
+ const bindsForeignName = (sourceFile, name) => {
153
+ for (const statement of sourceFile.statements) {
154
+ if (!ts.isImportDeclaration(statement))
155
+ continue;
156
+ const clause = statement.importClause;
157
+ if (clause === undefined)
158
+ continue;
159
+ if (clause.name?.text === name)
160
+ return statement;
161
+ const bindings = clause.namedBindings;
162
+ if (bindings === undefined)
163
+ continue;
164
+ if (ts.isNamespaceImport(bindings)) {
165
+ if (bindings.name.text === name)
166
+ return statement;
167
+ continue;
168
+ }
169
+ if (bindings.elements.some(element => element.name.text === name))
170
+ return statement;
171
+ }
172
+ return declaresNameLocally(sourceFile, name);
173
+ };
174
+ /**
175
+ * A base argument is safe to relocate when it is a literal we can move verbatim.
176
+ * Array literals qualify regardless of what their elements are — spreads and call
177
+ * expressions inside are moved along with the literal, never interpreted.
178
+ */
179
+ const isRelocatableBase = (node) => ts.isArrayLiteralExpression(node) || ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
180
+ const isRelocatableConfig = (node) => {
181
+ if (node === undefined)
182
+ return true;
183
+ if (!ts.isObjectLiteralExpression(node))
184
+ return false;
185
+ // A spread could itself carry a `base` key, which would then race with the
186
+ // `base` we are introducing. Not worth guessing at.
187
+ return !node.properties.some(property => ts.isSpreadAssignment(property));
188
+ };
189
+ /**
190
+ * The replacement re-emits the arguments but not the punctuation between them, so
191
+ * a comment living in that punctuation would be dropped silently.
192
+ */
193
+ const hasCommentOutsideArguments = (content, sourceFile, call) => {
194
+ const [base, config] = call.arguments;
195
+ if (base === undefined)
196
+ return false;
197
+ const carriesComment = (from, to) => {
198
+ const between = content.slice(from, to);
199
+ return between.includes("//") || between.includes("/*");
200
+ };
201
+ // Covers the callee → `(` → base span too: `buildReplacement` re-emits only
202
+ // `baseText` and the config interior, so a comment anywhere else in the call
203
+ // -- including here -- would otherwise disappear silently.
204
+ if (carriesComment(call.getStart(sourceFile), base.getStart(sourceFile)))
205
+ return true;
206
+ if (config === undefined)
207
+ return carriesComment(base.getEnd(), call.getEnd());
208
+ return carriesComment(base.getEnd(), config.getStart(sourceFile)) || carriesComment(config.getEnd(), call.getEnd());
209
+ };
210
+ /**
211
+ * Two legacy calls where one encloses the other would produce overlapping edits,
212
+ * because each replacement is built by slicing the *original* text.
213
+ */
214
+ const findOverlappingCalls = (calls, sourceFile) => {
215
+ const overlapping = new Set();
216
+ for (const outer of calls) {
217
+ for (const inner of calls) {
218
+ if (outer === inner)
219
+ continue;
220
+ if (outer.getStart(sourceFile) <= inner.getStart(sourceFile) && outer.getEnd() >= inner.getEnd()) {
221
+ overlapping.add(outer);
222
+ overlapping.add(inner);
223
+ }
224
+ }
225
+ }
226
+ return overlapping;
227
+ };
228
+ /** Why a call site was left for a human, phrased for the migration report. */
229
+ const rejectionReason = (content, sourceFile, call, overlapping) => {
230
+ if (overlapping.has(call)) {
231
+ return `nested ${LEGACY_EXPORT} calls cannot be rewritten as one edit; migrate the innermost call by hand first`;
232
+ }
233
+ if (call.arguments.length > 2) {
234
+ return `unexpected argument count (${call.arguments.length}); ${LEGACY_EXPORT} takes at most 2`;
235
+ }
236
+ const [base, config] = call.arguments;
237
+ if (base !== undefined && !isRelocatableBase(base)) {
238
+ return "base is not an array or string literal, so it cannot be folded into `base:` automatically";
239
+ }
240
+ if (config !== undefined && !isRelocatableConfig(config)) {
241
+ return ts.isObjectLiteralExpression(config)
242
+ ? "config object uses a spread, which may itself define `base`"
243
+ : "config is not an object literal, so its properties cannot be merged into the new config";
244
+ }
245
+ if (hasCommentOutsideArguments(content, sourceFile, call)) {
246
+ return "a comment sits between the arguments, which the rewrite would discard";
247
+ }
248
+ return null;
249
+ };
250
+ const buildReplacement = (content, sourceFile, call) => {
251
+ const [base, config] = call.arguments;
252
+ if (base === undefined)
253
+ return `${NEXT_EXPORT}({})`;
254
+ const baseText = content.slice(base.getStart(sourceFile), base.getEnd());
255
+ let inner = "";
256
+ if (config !== undefined) {
257
+ // Slice between the braces so interior comments and formatting survive.
258
+ inner = content.slice(config.getStart(sourceFile) + 1, config.getEnd() - 1).trim();
259
+ }
260
+ if (inner === "")
261
+ return `${NEXT_EXPORT}({ base: ${baseText} })`;
262
+ // The closing brace goes on its own line: the sliced interior can end in a `//`
263
+ // comment, which would otherwise swallow it. Prettier reflows short configs back
264
+ // onto one line afterwards.
265
+ return `${NEXT_EXPORT}({\n base: ${baseText},\n ${inner}\n})`;
266
+ };
267
+ const COMMENT = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/;
268
+ /**
269
+ * Removes a named import specifier along with the comma that joins it to its
270
+ * neighbour, so `{ cva, cvaMerge }` becomes `{ cva }` rather than `{ cva, }`.
271
+ *
272
+ * When a comment lives in that joining span, the comma is left in place instead —
273
+ * a trailing comma in a named import list is legal, and keeping it is what lets the
274
+ * comment survive.
275
+ */
276
+ const buildSpecifierRemoval = (content, sourceFile, namedImports, specifier) => {
277
+ const elements = namedImports.elements;
278
+ if (elements.length === 1) {
279
+ // Emptying the list would leave `import {} from "…";` behind.
280
+ const declaration = namedImports.parent.parent;
281
+ return { start: declaration.getStart(sourceFile), end: declaration.getEnd(), text: "" };
282
+ }
283
+ const index = elements.indexOf(specifier);
284
+ const previous = index > 0 ? elements[index - 1] : undefined;
285
+ const next = elements[index + 1];
286
+ const start = previous === undefined ? specifier.getStart(sourceFile) : previous.getEnd();
287
+ const end = previous === undefined && next !== undefined ? next.getStart(sourceFile) : specifier.getEnd();
288
+ if (COMMENT.test(content.slice(start, end))) {
289
+ return { start: specifier.getStart(sourceFile), end: specifier.getEnd(), text: "" };
290
+ }
291
+ return { start, end, text: "" };
292
+ };
293
+ const analyzeFile = (filePath, content) => {
294
+ // Checked before parsing for this codemod's own purposes: a file that doesn't
295
+ // parse cleanly can't be trusted to report an accurate call-site list, so it's
296
+ // flagged instead of silently analysed with whatever the parser recovered.
297
+ if ((0, ast_utils_1.hasSyntaxErrors)(content, filePath)) {
298
+ return { updated: null, flagged: [{ filePath, line: 1, reason: "file does not parse cleanly", snippet: "" }] };
299
+ }
300
+ const sourceFile = (0, ast_utils_1.parseTs)(content, filePath);
301
+ const { legacySpecifier, hasNextExport, namedImports, unsupportedShape } = findImportUsage(sourceFile);
302
+ const snippetOf = (node) => {
303
+ const text = content.slice(node.getStart(sourceFile), node.getEnd()).replace(/\s+/g, " ");
304
+ return text.length > MAX_SNIPPET_LENGTH ? `${text.slice(0, MAX_SNIPPET_LENGTH)}…` : text;
305
+ };
306
+ const bail = (node, reason) => ({
307
+ updated: null,
308
+ flagged: [{ filePath, line: (0, ast_utils_1.lineOf)(sourceFile, node.getStart(sourceFile)), reason, snippet: snippetOf(node) }],
309
+ });
310
+ if (unsupportedShape !== null) {
311
+ return bail(unsupportedShape.node, unsupportedShape.reason);
312
+ }
313
+ // Not imported from our module — a same-named local helper, or an unrelated file.
314
+ if (legacySpecifier === null || namedImports === null) {
315
+ return { updated: null, flagged: [] };
316
+ }
317
+ // A local `cvaMerge` shadows the import inside its scope, so a bare `cvaMerge(...)`
318
+ // may not be this library's function at all. Rewriting it would change behaviour
319
+ // without a compile error to catch it.
320
+ const shadow = declaresNameLocally(sourceFile, LEGACY_EXPORT);
321
+ if (shadow !== null) {
322
+ return bail(shadow, `this file also declares its own ${LEGACY_EXPORT}, so call sites are ambiguous`);
323
+ }
324
+ // Introducing `cva` on top of an existing `cva` binding is a duplicate identifier
325
+ // in TypeScript and a silent shadow in plain JavaScript.
326
+ if (!hasNextExport) {
327
+ const collision = bindsForeignName(sourceFile, NEXT_EXPORT);
328
+ if (collision !== null) {
329
+ return bail(collision, `the name ${NEXT_EXPORT} is already bound in this file, so the import cannot be renamed`);
330
+ }
331
+ }
332
+ const calls = findLegacyCalls(sourceFile);
333
+ const overlapping = findOverlappingCalls(calls, sourceFile);
334
+ const edits = [];
335
+ const flagged = [];
336
+ /** Offsets of the callee identifiers this run consumed, keyed by position. */
337
+ const rewrittenCallees = new Set();
338
+ for (const call of calls) {
339
+ const reason = rejectionReason(content, sourceFile, call, overlapping);
340
+ if (reason !== null) {
341
+ flagged.push({
342
+ filePath,
343
+ line: (0, ast_utils_1.lineOf)(sourceFile, call.getStart(sourceFile)),
344
+ reason,
345
+ snippet: snippetOf(call),
346
+ });
347
+ continue;
348
+ }
349
+ edits.push({
350
+ start: call.getStart(sourceFile),
351
+ end: call.getEnd(),
352
+ text: buildReplacement(content, sourceFile, call),
353
+ });
354
+ rewrittenCallees.add(call.expression.getStart(sourceFile));
355
+ }
356
+ if (rewrittenCallees.size === 0) {
357
+ return { updated: null, flagged };
358
+ }
359
+ // The import can only be retired once *every* reference is gone, not just every
360
+ // call: `export { cvaMerge }` and `typeof cvaMerge` keep needing the binding.
361
+ const leftoverReferences = findIdentifierReferences(sourceFile, LEGACY_EXPORT).filter(reference => !rewrittenCallees.has(reference.getStart(sourceFile)));
362
+ for (const reference of leftoverReferences) {
363
+ if (ts.isCallExpression(reference.parent) && reference.parent.expression === reference)
364
+ continue;
365
+ flagged.push({
366
+ filePath,
367
+ line: (0, ast_utils_1.lineOf)(sourceFile, reference.getStart(sourceFile)),
368
+ reason: `${LEGACY_EXPORT} is used here without being called, so the import was kept`,
369
+ snippet: snippetOf(reference.parent),
370
+ });
371
+ }
372
+ if (leftoverReferences.length === 0) {
373
+ edits.push(hasNextExport
374
+ ? buildSpecifierRemoval(content, sourceFile, namedImports, legacySpecifier)
375
+ : {
376
+ start: legacySpecifier.getStart(sourceFile),
377
+ end: legacySpecifier.getEnd(),
378
+ text: NEXT_EXPORT,
379
+ });
380
+ }
381
+ else if (!hasNextExport) {
382
+ edits.push({
383
+ start: legacySpecifier.getStart(sourceFile),
384
+ end: legacySpecifier.getStart(sourceFile),
385
+ text: `${NEXT_EXPORT}, `,
386
+ });
387
+ }
388
+ return { updated: (0, ast_utils_1.applyEdits)(content, edits), flagged };
389
+ };
390
+ /**
391
+ * Reports every `cvaMerge` call site the codemod would decline to rewrite,
392
+ * without modifying the tree. Exported so the bulk cutover can preview the
393
+ * manual work before committing to a run.
394
+ */
395
+ const collectFlaggedSites = (tree) => {
396
+ const flagged = [];
397
+ (0, ast_utils_1.visitTsFiles)(tree, LEGACY_EXPORT, (filePath, content) => {
398
+ flagged.push(...analyzeFile(filePath, content).flagged);
399
+ return null;
400
+ });
401
+ return flagged;
402
+ };
403
+ exports.collectFlaggedSites = collectFlaggedSites;
404
+ /**
405
+ * Rewrites `cvaMerge(base, config)` call sites to cva 1.0's `cva({ base, ...config })`
406
+ * shape and updates the corresponding imports.
407
+ *
408
+ * Call sites that cannot be rewritten with confidence are left exactly as they are
409
+ * and reported instead. A missed rewrite costs one manual edit; a wrong rewrite
410
+ * silently changes rendered classnames, which the upgrade explicitly forbids.
411
+ *
412
+ * Running twice is a no-op: migrated code calls `cva`, which this codemod ignores.
413
+ */
414
+ const cvaMergeToCva = (tree) => {
415
+ const flagged = [];
416
+ const touched = (0, ast_utils_1.visitTsFiles)(tree, LEGACY_EXPORT, (filePath, content) => {
417
+ const analysis = analyzeFile(filePath, content);
418
+ flagged.push(...analysis.flagged);
419
+ return analysis.updated;
420
+ });
421
+ (0, ast_utils_1.logSummary)(MIGRATION_NAME, touched, flagged);
422
+ };
423
+ exports.cvaMergeToCva = cvaMergeToCva;
424
+ exports.default = exports.cvaMergeToCva;
425
+ //# sourceMappingURL=cva-merge-to-cva.js.map
package/migrations.json CHANGED
@@ -1,3 +1,9 @@
1
1
  {
2
- "generators": {}
2
+ "generators": {
3
+ "v2-0-0-cva-merge-to-cva": {
4
+ "version": "2.0.0",
5
+ "description": "Rewrite cvaMerge(base, config) call sites to cva 1.0's cva({ base, ...config }) shape, and report any call site that cannot be rewritten safely.",
6
+ "implementation": "./migrations/v2-0-0/cva-merge-to-cva"
7
+ }
8
+ }
3
9
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@trackunit/css-class-variance-utilities",
3
- "version": "1.14.28",
3
+ "version": "2.0.0",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "class-variance-authority": "^0.7.0",
10
+ "cva": "1.0.0-beta.8",
11
11
  "tailwind-merge": "^2.0.0"
12
12
  },
13
13
  "migrations": "./migrations.json",
package/src/cva.d.ts CHANGED
@@ -1,8 +1,17 @@
1
- import { cva as OriginalCva, type VariantProps as OriginalVariantProps } from "class-variance-authority";
1
+ import { type VariantProps as OriginalVariantProps } from "cva";
2
2
  export type VariantProps<TComponent extends (...args: any) => any> = OriginalVariantProps<TComponent> & {
3
3
  className: string;
4
4
  };
5
5
  /**
6
- * A wrapper around CVA that uses tailwind-merge to merge the classes.
6
+ * `cva` and `cx`, bound to a shared config whose `onComplete` hook runs every
7
+ * generated class string through tailwind-merge.
8
+ *
9
+ * Note that because of that hook, this `cx` resolves Tailwind conflicts rather than
10
+ * merely concatenating like the upstream `cx`/`clsx` of the same name: given
11
+ * `cx("p-2", "p-4")` the losing `p-2` is dropped.
12
+ *
13
+ * Composition is available through the `composes` property on a `cva` config;
14
+ * `defineConfig` also returns a `compose` function, but it is deprecated upstream
15
+ * in favour of `composes`, so it is deliberately not re-exported here.
7
16
  */
8
- export declare const cvaMerge: typeof OriginalCva;
17
+ export declare const cva: import("cva").CVA, cx: import("cva").CX;