@trackunit/css-class-variance-utilities 1.14.29 → 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 +60 -1
- package/index.cjs.js +17 -8
- package/index.esm.js +16 -8
- package/migrations/utils/ast-utils.js +129 -0
- package/migrations/v2-0-0/cva-merge-to-cva.js +425 -0
- package/migrations.json +7 -1
- package/package.json +2 -2
- package/src/cva.d.ts +12 -3
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
};
|
|
20
|
+
const { cva, cx } = cva$1.defineConfig({
|
|
21
|
+
hooks: { onComplete: className => tailwindMerge.twMerge(className) },
|
|
22
|
+
});
|
|
15
23
|
|
|
16
|
-
exports.
|
|
24
|
+
exports.cva = cva;
|
|
25
|
+
exports.cx = cx;
|
package/index.esm.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
*
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
};
|
|
18
|
+
const { cva, cx } = defineConfig({
|
|
19
|
+
hooks: { onComplete: className => twMerge(className) },
|
|
20
|
+
});
|
|
13
21
|
|
|
14
|
-
export {
|
|
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": "
|
|
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
|
-
"
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
17
|
+
export declare const cva: import("cva").CVA, cx: import("cva").CX;
|