@trackunit/css-classname-utils 0.0.2

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 ADDED
@@ -0,0 +1,19 @@
1
+ # Trackunit css-classname-utils
2
+
3
+ The `@trackunit/css-classname-utils` package is the home of Trackunit's Tailwind/classname detection utilities.
4
+ It has no dependency on `eslint` or `@typescript-eslint/utils`, so it can be consumed by any TypeScript project,
5
+ not just ESLint plugins.
6
+
7
+ For more info and a full guide on Iris App SDK Development, please visit our [Developer Hub](https://developers.trackunit.com/).
8
+
9
+ ## Development
10
+
11
+ At this point this library is only developed by Trackunit Employees.
12
+ For development related information see the [development readme](https://github.com/Trackunit/manager/blob/master/libs/react/components/DEVELOPMENT.md).
13
+
14
+ ## Trackunit
15
+
16
+ This package was developed by Trackunit ApS.
17
+ Trackunit is the leading SaaS-based IoT solution for the construction industry, offering an ecosystem of hardware, fleet management software & telematics.
18
+
19
+ ![The Trackunit logo](https://trackunit.com/wp-content/uploads/2022/03/top-logo.svg)
package/index.cjs.js ADDED
@@ -0,0 +1,554 @@
1
+ 'use strict';
2
+
3
+ var types = require('@typescript-eslint/types');
4
+
5
+ // =============================================================================
6
+ // Constants
7
+ // =============================================================================
8
+ /**
9
+ * Function names that accept Tailwind class strings as arguments.
10
+ * These are commonly used for class merging and conditional class application.
11
+ *
12
+ * Kept in sync with Prettier's Tailwind CSS plugin config by
13
+ * `classname-config-sync.spec.ts`.
14
+ */
15
+ const TAILWIND_FUNCTIONS = [
16
+ "cva",
17
+ "cvaMerge",
18
+ "tw",
19
+ "twx",
20
+ "tws",
21
+ "twMerge",
22
+ "twJoin",
23
+ "cn",
24
+ "clsx",
25
+ "classNames",
26
+ ];
27
+ /**
28
+ * Variable name suffixes that indicate a classname value.
29
+ * Matches variables like: containerClass, buttonClassName, rootClasses
30
+ *
31
+ * Kept in sync with the VS Code `tailwindCSS.experimental.classRegex` setting by
32
+ * `classname-config-sync.spec.ts`, since Prettier has no equivalent option to check against.
33
+ */
34
+ const CLASSNAME_VARIABLE_SUFFIXES = ["Class", "ClassName", "Classes"];
35
+ /**
36
+ * Variable name patterns that indicate a classname value, derived from CLASSNAME_VARIABLE_SUFFIXES.
37
+ */
38
+ const CLASSNAME_VARIABLE_PATTERNS = CLASSNAME_VARIABLE_SUFFIXES.map(suffix => new RegExp(`${suffix}$`));
39
+ /**
40
+ * JSX attribute names that contain classname values
41
+ */
42
+ const CLASSNAME_ATTRIBUTES = ["className", "class"];
43
+ // =============================================================================
44
+ // Detection Helpers
45
+ // =============================================================================
46
+ /**
47
+ * Check if a function name is a known Tailwind utility function
48
+ */
49
+ const isTailwindFunction = (name) => {
50
+ return TAILWIND_FUNCTIONS.some(fn => fn === name);
51
+ };
52
+ /**
53
+ * Check if a variable name indicates it contains classnames
54
+ */
55
+ const isClassnameVariable = (name) => {
56
+ return CLASSNAME_VARIABLE_PATTERNS.some(pattern => pattern.test(name));
57
+ };
58
+ /**
59
+ * Check if a JSX attribute name is a classname attribute
60
+ */
61
+ const isClassnameAttribute = (name) => {
62
+ return CLASSNAME_ATTRIBUTES.some(attr => attr === name);
63
+ };
64
+ /**
65
+ * Get the function name from a CallExpression callee
66
+ */
67
+ const getCalleeName = (callee) => {
68
+ if (callee.type === types.AST_NODE_TYPES.Identifier) {
69
+ return callee.name;
70
+ }
71
+ if (callee.type === types.AST_NODE_TYPES.MemberExpression && callee.property.type === types.AST_NODE_TYPES.Identifier) {
72
+ return callee.property.name;
73
+ }
74
+ return null;
75
+ };
76
+ // =============================================================================
77
+ // String Extraction
78
+ // =============================================================================
79
+ /**
80
+ * Extract a string value from a Literal node
81
+ */
82
+ const extractFromLiteral = (node) => {
83
+ if (typeof node.value === "string") {
84
+ return { value: node.value, node };
85
+ }
86
+ return null;
87
+ };
88
+ /**
89
+ * Extract the static parts of a TemplateLiteral as a single string.
90
+ * Template expressions are replaced with a single space to preserve word boundaries.
91
+ */
92
+ const extractFromTemplateLiteral = (node) => {
93
+ // Join static parts with spaces (representing where expressions would be)
94
+ const value = node.quasis.map(quasi => quasi.value.raw).join(" ");
95
+ return { value, node };
96
+ };
97
+ /**
98
+ * Extract string value from a node that might contain a classname string.
99
+ * Handles Literal and TemplateLiteral nodes.
100
+ */
101
+ const extractStringValue = (node) => {
102
+ switch (node.type) {
103
+ case types.AST_NODE_TYPES.Literal:
104
+ return extractFromLiteral(node);
105
+ case types.AST_NODE_TYPES.TemplateLiteral:
106
+ return extractFromTemplateLiteral(node);
107
+ default:
108
+ return null;
109
+ }
110
+ };
111
+ // =============================================================================
112
+ // Array Traversal
113
+ // =============================================================================
114
+ /**
115
+ * Extract classname locations from an ArrayExpression.
116
+ * Handles arrays like: ["flex", "items-center", "bg-primary-500"]
117
+ */
118
+ const extractFromArray = (node, parentContext) => {
119
+ const locations = [];
120
+ node.elements.forEach((element, index) => {
121
+ if (!element || element.type === types.AST_NODE_TYPES.SpreadElement) {
122
+ return;
123
+ }
124
+ const extracted = extractStringValue(element);
125
+ if (extracted) {
126
+ locations.push({
127
+ value: extracted.value,
128
+ reportNode: element,
129
+ fixNode: extracted.node,
130
+ context: { type: "array-element", parentContext, elementIndex: index },
131
+ });
132
+ }
133
+ });
134
+ return locations;
135
+ };
136
+ // =============================================================================
137
+ // CVA/CVAMerge Object Traversal
138
+ // =============================================================================
139
+ /**
140
+ * Recursively extract classname locations from a CVA options object.
141
+ *
142
+ * CVA structure:
143
+ * ```
144
+ * cva(baseClasses, {
145
+ * variants: {
146
+ * variantName: {
147
+ * variantValue: "classnames here",
148
+ * }
149
+ * },
150
+ * compoundVariants: [
151
+ * { variantName: "value", className: "classnames" }
152
+ * ],
153
+ * defaultVariants: { ... }
154
+ * })
155
+ * ```
156
+ */
157
+ const extractFromCvaObject = (node, functionName, currentPath = []) => {
158
+ const locations = [];
159
+ for (const property of node.properties) {
160
+ if (property.type !== types.AST_NODE_TYPES.Property) {
161
+ continue;
162
+ }
163
+ const keyName = getPropertyKeyName(property.key);
164
+ if (!keyName) {
165
+ continue;
166
+ }
167
+ const propertyPath = [...currentPath, keyName];
168
+ // Skip defaultVariants as it doesn't contain classnames
169
+ if (keyName === "defaultVariants") {
170
+ continue;
171
+ }
172
+ // Recurse into nested objects (variants, compoundVariants entries, etc.)
173
+ if (property.value.type === types.AST_NODE_TYPES.ObjectExpression) {
174
+ locations.push(...extractFromCvaObject(property.value, functionName, propertyPath));
175
+ continue;
176
+ }
177
+ // Handle arrays (compoundVariants array, or array of class strings)
178
+ if (property.value.type === types.AST_NODE_TYPES.ArrayExpression) {
179
+ // Check if this is an array of objects (like compoundVariants)
180
+ const hasObjectElements = property.value.elements.some(el => el?.type === types.AST_NODE_TYPES.ObjectExpression);
181
+ if (hasObjectElements) {
182
+ // Recurse into each object in the array
183
+ property.value.elements.forEach((element, index) => {
184
+ if (element?.type === types.AST_NODE_TYPES.ObjectExpression) {
185
+ locations.push(...extractFromCvaObject(element, functionName, [...propertyPath, `[${index}]`]));
186
+ }
187
+ });
188
+ }
189
+ else {
190
+ // This is an array of class strings
191
+ const arrayContext = {
192
+ type: "cva-variant",
193
+ functionName,
194
+ variantPath: propertyPath,
195
+ };
196
+ locations.push(...extractFromArray(property.value, arrayContext));
197
+ }
198
+ continue;
199
+ }
200
+ // Extract string values at leaf nodes
201
+ const extracted = extractStringValue(property.value);
202
+ if (extracted) {
203
+ locations.push({
204
+ value: extracted.value,
205
+ reportNode: property.value,
206
+ fixNode: extracted.node,
207
+ context: {
208
+ type: "cva-variant",
209
+ functionName,
210
+ variantPath: propertyPath,
211
+ },
212
+ });
213
+ }
214
+ }
215
+ return locations;
216
+ };
217
+ /**
218
+ * Get the name of an object property key
219
+ */
220
+ const getPropertyKeyName = (key) => {
221
+ if (key.type === types.AST_NODE_TYPES.Identifier) {
222
+ return key.name;
223
+ }
224
+ if (key.type === types.AST_NODE_TYPES.Literal && typeof key.value === "string") {
225
+ return key.value;
226
+ }
227
+ return null;
228
+ };
229
+ // =============================================================================
230
+ // Function Call Extraction
231
+ // =============================================================================
232
+ /**
233
+ * Extract classname locations from a Tailwind function call.
234
+ *
235
+ * Handles various argument patterns:
236
+ * - String literals: twMerge("flex items-center")
237
+ * - Template literals: twMerge(`flex ${condition && "hidden"}`)
238
+ * - Arrays: cvaMerge(["flex", "items-center"], options)
239
+ * - CVA options objects: cva(base, { variants: { ... } })
240
+ */
241
+ const extractFromFunctionCall = (node) => {
242
+ const calleeName = getCalleeName(node.callee);
243
+ if (!calleeName || !isTailwindFunction(calleeName)) {
244
+ return [];
245
+ }
246
+ const locations = [];
247
+ const isCvaStyle = calleeName === "cva" || calleeName === "cvaMerge";
248
+ node.arguments.forEach((arg, argIndex) => {
249
+ const baseContext = {
250
+ type: "function-call",
251
+ functionName: calleeName,
252
+ argumentIndex: argIndex,
253
+ };
254
+ // Handle string literals
255
+ const extracted = extractStringValue(arg);
256
+ if (extracted) {
257
+ locations.push({
258
+ value: extracted.value,
259
+ reportNode: arg,
260
+ fixNode: extracted.node,
261
+ context: baseContext,
262
+ });
263
+ return;
264
+ }
265
+ // Handle array arguments
266
+ if (arg.type === types.AST_NODE_TYPES.ArrayExpression) {
267
+ locations.push(...extractFromArray(arg, baseContext));
268
+ return;
269
+ }
270
+ // Handle CVA options object (typically second argument)
271
+ if (isCvaStyle && arg.type === types.AST_NODE_TYPES.ObjectExpression) {
272
+ locations.push(...extractFromCvaObject(arg, calleeName));
273
+ }
274
+ });
275
+ return locations;
276
+ };
277
+ // =============================================================================
278
+ // JSX Attribute Extraction
279
+ // =============================================================================
280
+ /**
281
+ * Extract classname locations from a JSX attribute (className or class).
282
+ *
283
+ * Handles various value patterns:
284
+ * - String literals: className="flex items-center"
285
+ * - Expression with string: className={"flex items-center"}
286
+ * - Template literals: className={`flex ${condition && "hidden"}`}
287
+ */
288
+ const extractFromJsxAttribute = (node) => {
289
+ if (node.name.type !== types.AST_NODE_TYPES.JSXIdentifier) {
290
+ return [];
291
+ }
292
+ const attrName = node.name.name;
293
+ if (!isClassnameAttribute(attrName)) {
294
+ return [];
295
+ }
296
+ const attrValue = node.value;
297
+ if (!attrValue) {
298
+ return [];
299
+ }
300
+ const context = {
301
+ type: "jsx-attribute",
302
+ attributeName: attrName,
303
+ };
304
+ // Direct string literal: className="flex items-center"
305
+ if (attrValue.type === types.AST_NODE_TYPES.Literal) {
306
+ const extracted = extractFromLiteral(attrValue);
307
+ if (extracted) {
308
+ return [
309
+ {
310
+ value: extracted.value,
311
+ reportNode: node,
312
+ fixNode: extracted.node,
313
+ context,
314
+ },
315
+ ];
316
+ }
317
+ }
318
+ // JSX Expression: className={...}
319
+ if (attrValue.type === types.AST_NODE_TYPES.JSXExpressionContainer) {
320
+ const expression = attrValue.expression;
321
+ // Skip empty expressions
322
+ if (expression.type === types.AST_NODE_TYPES.JSXEmptyExpression) {
323
+ return [];
324
+ }
325
+ // String literal in expression: className={"flex items-center"}
326
+ const extracted = extractStringValue(expression);
327
+ if (extracted) {
328
+ return [
329
+ {
330
+ value: extracted.value,
331
+ reportNode: node,
332
+ fixNode: extracted.node,
333
+ context,
334
+ },
335
+ ];
336
+ }
337
+ // Note: We don't recurse into function calls here as they're handled
338
+ // separately by the CallExpression handler in the rule
339
+ }
340
+ return [];
341
+ };
342
+ // =============================================================================
343
+ // Variable Declaration Extraction
344
+ // =============================================================================
345
+ /**
346
+ * Extract classname locations from a variable declaration with a classname-like name.
347
+ *
348
+ * Example: const buttonClass = "flex items-center";
349
+ */
350
+ const extractFromVariableDeclarator = (node) => {
351
+ if (node.id.type !== types.AST_NODE_TYPES.Identifier) {
352
+ return [];
353
+ }
354
+ const varName = node.id.name;
355
+ if (!isClassnameVariable(varName)) {
356
+ return [];
357
+ }
358
+ if (!node.init) {
359
+ return [];
360
+ }
361
+ const context = {
362
+ type: "variable",
363
+ variableName: varName,
364
+ };
365
+ // Handle string literal
366
+ const extracted = extractStringValue(node.init);
367
+ if (extracted) {
368
+ return [
369
+ {
370
+ value: extracted.value,
371
+ reportNode: node.init,
372
+ fixNode: extracted.node,
373
+ context,
374
+ },
375
+ ];
376
+ }
377
+ // Handle array of strings
378
+ if (node.init.type === types.AST_NODE_TYPES.ArrayExpression) {
379
+ return extractFromArray(node.init, context);
380
+ }
381
+ // Note: Function calls in variable initializers are handled separately
382
+ // by the CallExpression handler
383
+ return [];
384
+ };
385
+ // =============================================================================
386
+ // Unified Entry Points
387
+ // =============================================================================
388
+ /**
389
+ * Find all classname string locations in a CallExpression node.
390
+ *
391
+ * Use this in your ESLint rule's CallExpression handler to process
392
+ * Tailwind utility function calls like twMerge, cvaMerge, etc.
393
+ *
394
+ * @example
395
+ * ```typescript
396
+ * CallExpression(node) {
397
+ * const locations = findClassnameStringsInCall(node);
398
+ * for (const location of locations) {
399
+ * if (hasBannedPattern(location.value)) {
400
+ * context.report({ node: location.reportNode, ... });
401
+ * }
402
+ * }
403
+ * }
404
+ * ```
405
+ */
406
+ const findClassnameStringsInCall = (node) => {
407
+ return extractFromFunctionCall(node);
408
+ };
409
+ /**
410
+ * Find all classname string locations in a JSXAttribute node.
411
+ *
412
+ * Use this in your ESLint rule's JSXAttribute handler to process
413
+ * className and class attributes.
414
+ *
415
+ * @example
416
+ * ```typescript
417
+ * JSXAttribute(node) {
418
+ * const locations = findClassnameStringsInAttribute(node);
419
+ * for (const location of locations) {
420
+ * if (hasBannedPattern(location.value)) {
421
+ * context.report({ node: location.reportNode, ... });
422
+ * }
423
+ * }
424
+ * }
425
+ * ```
426
+ */
427
+ const findClassnameStringsInAttribute = (node) => {
428
+ return extractFromJsxAttribute(node);
429
+ };
430
+ /**
431
+ * Find all classname string locations in a VariableDeclarator node.
432
+ *
433
+ * Use this in your ESLint rule's VariableDeclarator handler to process
434
+ * variables with classname-like names.
435
+ *
436
+ * @example
437
+ * ```typescript
438
+ * VariableDeclarator(node) {
439
+ * const locations = findClassnameStringsInVariable(node);
440
+ * for (const location of locations) {
441
+ * if (hasBannedPattern(location.value)) {
442
+ * context.report({ node: location.reportNode, ... });
443
+ * }
444
+ * }
445
+ * }
446
+ * ```
447
+ */
448
+ const findClassnameStringsInVariable = (node) => {
449
+ return extractFromVariableDeclarator(node);
450
+ };
451
+ // =============================================================================
452
+ // Utility Helpers for Fixing
453
+ // =============================================================================
454
+ /**
455
+ * Split a classname string into individual class names.
456
+ * Handles whitespace-separated classes.
457
+ */
458
+ const splitClasses = (value) => {
459
+ return value.split(/\s+/).filter(Boolean);
460
+ };
461
+ /**
462
+ * Join class names into a single string.
463
+ */
464
+ const joinClasses = (classes) => {
465
+ return classes.join(" ");
466
+ };
467
+ /**
468
+ * Create a JSON-formatted array string from an array of class names.
469
+ * Useful for auto-fix operations.
470
+ */
471
+ const formatAsArray = (classes) => {
472
+ return JSON.stringify(classes);
473
+ };
474
+ /**
475
+ * Get the quote character used in a string literal.
476
+ */
477
+ const getQuoteChar = (node) => {
478
+ if (typeof node.value === "string" && node.raw) {
479
+ return node.raw.charAt(0);
480
+ }
481
+ return '"';
482
+ };
483
+ /**
484
+ * Create a quoted string with the same quote style as the original.
485
+ */
486
+ const quoteString = (value, originalNode) => {
487
+ const quote = getQuoteChar(originalNode);
488
+ return `${quote}${value}${quote}`;
489
+ };
490
+
491
+ /**
492
+ * Matches zero or more chained Tailwind variant prefixes (e.g. `hover:`, `sm:`,
493
+ * `dark:hover:`) that may precede a class token. Kept generic (word chars/hyphens
494
+ * followed by a colon) rather than an enumerated variant list, so it stays in sync
495
+ * with any variant Tailwind supports without needing updates here.
496
+ */
497
+ const VARIANT_PREFIX = "(?:[\\w-]+:)*";
498
+ /**
499
+ * Replace a class token in a whitespace-separated classname string, word-boundary safe.
500
+ *
501
+ * Handles both prefix-based patterns (e.g., `bg-red-500` -> `bg-danger-500`) and exact
502
+ * token matches (e.g., `red` -> `blue`).
503
+ *
504
+ * When `replacement` is the empty string, the whole matched token is deleted and the
505
+ * surrounding whitespace is collapsed so no double space (or leading/trailing space) is
506
+ * left behind.
507
+ */
508
+ function replaceClassToken(value, pattern, replacement, options) {
509
+ const prefixes = options?.prefixes;
510
+ const hasPrefixes = prefixes !== undefined && prefixes.length > 0;
511
+ if (replacement === "") {
512
+ return deleteToken(value, pattern, hasPrefixes ? prefixes : undefined);
513
+ }
514
+ if (hasPrefixes) {
515
+ // With prefixes, replace -pattern- with -replacement-. Any variant prefix (e.g.
516
+ // "hover:bg-") is untouched since we only match starting from the hyphen.
517
+ const prefixRegex = new RegExp(`-${pattern}(-|$|/)`, "g");
518
+ return value.replace(prefixRegex, `-${replacement}$1`);
519
+ }
520
+ // Without prefixes, replace exact word boundaries, preserving any variant prefix
521
+ // chain (e.g. "sm:red" keeps "sm:" and only swaps "red" for the replacement).
522
+ const exactRegex = new RegExp(`(^|\\s)(${VARIANT_PREFIX})${pattern}($|\\s)`, "g");
523
+ return value.replace(exactRegex, `$1$2${replacement}$3`);
524
+ }
525
+ /**
526
+ * Remove a class token entirely, collapsing surrounding whitespace.
527
+ *
528
+ * - Token flanked by whitespace on both sides: one side of whitespace is kept as the
529
+ * separator between its neighbors.
530
+ * - Token at the very start or end of the string: the single boundary whitespace is
531
+ * removed along with the token.
532
+ */
533
+ function deleteToken(value, pattern, prefixes) {
534
+ const regex = prefixes !== undefined
535
+ ? new RegExp(`(^|\\s)${VARIANT_PREFIX}(?:${prefixes.join("|")})-${pattern}(?:-[a-zA-Z0-9]+)?(?:/\\d+)?($|\\s)`, "g")
536
+ : new RegExp(`(^|\\s)${VARIANT_PREFIX}${pattern}($|\\s)`, "g");
537
+ return value.replace(regex, (_fullMatch, before, after) => (before && after ? before : ""));
538
+ }
539
+
540
+ exports.CLASSNAME_ATTRIBUTES = CLASSNAME_ATTRIBUTES;
541
+ exports.CLASSNAME_VARIABLE_SUFFIXES = CLASSNAME_VARIABLE_SUFFIXES;
542
+ exports.TAILWIND_FUNCTIONS = TAILWIND_FUNCTIONS;
543
+ exports.findClassnameStringsInAttribute = findClassnameStringsInAttribute;
544
+ exports.findClassnameStringsInCall = findClassnameStringsInCall;
545
+ exports.findClassnameStringsInVariable = findClassnameStringsInVariable;
546
+ exports.formatAsArray = formatAsArray;
547
+ exports.getCalleeName = getCalleeName;
548
+ exports.isClassnameAttribute = isClassnameAttribute;
549
+ exports.isClassnameVariable = isClassnameVariable;
550
+ exports.isTailwindFunction = isTailwindFunction;
551
+ exports.joinClasses = joinClasses;
552
+ exports.quoteString = quoteString;
553
+ exports.replaceClassToken = replaceClassToken;
554
+ exports.splitClasses = splitClasses;
package/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/index";
package/index.esm.js ADDED
@@ -0,0 +1,538 @@
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/types';
2
+
3
+ // =============================================================================
4
+ // Constants
5
+ // =============================================================================
6
+ /**
7
+ * Function names that accept Tailwind class strings as arguments.
8
+ * These are commonly used for class merging and conditional class application.
9
+ *
10
+ * Kept in sync with Prettier's Tailwind CSS plugin config by
11
+ * `classname-config-sync.spec.ts`.
12
+ */
13
+ const TAILWIND_FUNCTIONS = [
14
+ "cva",
15
+ "cvaMerge",
16
+ "tw",
17
+ "twx",
18
+ "tws",
19
+ "twMerge",
20
+ "twJoin",
21
+ "cn",
22
+ "clsx",
23
+ "classNames",
24
+ ];
25
+ /**
26
+ * Variable name suffixes that indicate a classname value.
27
+ * Matches variables like: containerClass, buttonClassName, rootClasses
28
+ *
29
+ * Kept in sync with the VS Code `tailwindCSS.experimental.classRegex` setting by
30
+ * `classname-config-sync.spec.ts`, since Prettier has no equivalent option to check against.
31
+ */
32
+ const CLASSNAME_VARIABLE_SUFFIXES = ["Class", "ClassName", "Classes"];
33
+ /**
34
+ * Variable name patterns that indicate a classname value, derived from CLASSNAME_VARIABLE_SUFFIXES.
35
+ */
36
+ const CLASSNAME_VARIABLE_PATTERNS = CLASSNAME_VARIABLE_SUFFIXES.map(suffix => new RegExp(`${suffix}$`));
37
+ /**
38
+ * JSX attribute names that contain classname values
39
+ */
40
+ const CLASSNAME_ATTRIBUTES = ["className", "class"];
41
+ // =============================================================================
42
+ // Detection Helpers
43
+ // =============================================================================
44
+ /**
45
+ * Check if a function name is a known Tailwind utility function
46
+ */
47
+ const isTailwindFunction = (name) => {
48
+ return TAILWIND_FUNCTIONS.some(fn => fn === name);
49
+ };
50
+ /**
51
+ * Check if a variable name indicates it contains classnames
52
+ */
53
+ const isClassnameVariable = (name) => {
54
+ return CLASSNAME_VARIABLE_PATTERNS.some(pattern => pattern.test(name));
55
+ };
56
+ /**
57
+ * Check if a JSX attribute name is a classname attribute
58
+ */
59
+ const isClassnameAttribute = (name) => {
60
+ return CLASSNAME_ATTRIBUTES.some(attr => attr === name);
61
+ };
62
+ /**
63
+ * Get the function name from a CallExpression callee
64
+ */
65
+ const getCalleeName = (callee) => {
66
+ if (callee.type === AST_NODE_TYPES.Identifier) {
67
+ return callee.name;
68
+ }
69
+ if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) {
70
+ return callee.property.name;
71
+ }
72
+ return null;
73
+ };
74
+ // =============================================================================
75
+ // String Extraction
76
+ // =============================================================================
77
+ /**
78
+ * Extract a string value from a Literal node
79
+ */
80
+ const extractFromLiteral = (node) => {
81
+ if (typeof node.value === "string") {
82
+ return { value: node.value, node };
83
+ }
84
+ return null;
85
+ };
86
+ /**
87
+ * Extract the static parts of a TemplateLiteral as a single string.
88
+ * Template expressions are replaced with a single space to preserve word boundaries.
89
+ */
90
+ const extractFromTemplateLiteral = (node) => {
91
+ // Join static parts with spaces (representing where expressions would be)
92
+ const value = node.quasis.map(quasi => quasi.value.raw).join(" ");
93
+ return { value, node };
94
+ };
95
+ /**
96
+ * Extract string value from a node that might contain a classname string.
97
+ * Handles Literal and TemplateLiteral nodes.
98
+ */
99
+ const extractStringValue = (node) => {
100
+ switch (node.type) {
101
+ case AST_NODE_TYPES.Literal:
102
+ return extractFromLiteral(node);
103
+ case AST_NODE_TYPES.TemplateLiteral:
104
+ return extractFromTemplateLiteral(node);
105
+ default:
106
+ return null;
107
+ }
108
+ };
109
+ // =============================================================================
110
+ // Array Traversal
111
+ // =============================================================================
112
+ /**
113
+ * Extract classname locations from an ArrayExpression.
114
+ * Handles arrays like: ["flex", "items-center", "bg-primary-500"]
115
+ */
116
+ const extractFromArray = (node, parentContext) => {
117
+ const locations = [];
118
+ node.elements.forEach((element, index) => {
119
+ if (!element || element.type === AST_NODE_TYPES.SpreadElement) {
120
+ return;
121
+ }
122
+ const extracted = extractStringValue(element);
123
+ if (extracted) {
124
+ locations.push({
125
+ value: extracted.value,
126
+ reportNode: element,
127
+ fixNode: extracted.node,
128
+ context: { type: "array-element", parentContext, elementIndex: index },
129
+ });
130
+ }
131
+ });
132
+ return locations;
133
+ };
134
+ // =============================================================================
135
+ // CVA/CVAMerge Object Traversal
136
+ // =============================================================================
137
+ /**
138
+ * Recursively extract classname locations from a CVA options object.
139
+ *
140
+ * CVA structure:
141
+ * ```
142
+ * cva(baseClasses, {
143
+ * variants: {
144
+ * variantName: {
145
+ * variantValue: "classnames here",
146
+ * }
147
+ * },
148
+ * compoundVariants: [
149
+ * { variantName: "value", className: "classnames" }
150
+ * ],
151
+ * defaultVariants: { ... }
152
+ * })
153
+ * ```
154
+ */
155
+ const extractFromCvaObject = (node, functionName, currentPath = []) => {
156
+ const locations = [];
157
+ for (const property of node.properties) {
158
+ if (property.type !== AST_NODE_TYPES.Property) {
159
+ continue;
160
+ }
161
+ const keyName = getPropertyKeyName(property.key);
162
+ if (!keyName) {
163
+ continue;
164
+ }
165
+ const propertyPath = [...currentPath, keyName];
166
+ // Skip defaultVariants as it doesn't contain classnames
167
+ if (keyName === "defaultVariants") {
168
+ continue;
169
+ }
170
+ // Recurse into nested objects (variants, compoundVariants entries, etc.)
171
+ if (property.value.type === AST_NODE_TYPES.ObjectExpression) {
172
+ locations.push(...extractFromCvaObject(property.value, functionName, propertyPath));
173
+ continue;
174
+ }
175
+ // Handle arrays (compoundVariants array, or array of class strings)
176
+ if (property.value.type === AST_NODE_TYPES.ArrayExpression) {
177
+ // Check if this is an array of objects (like compoundVariants)
178
+ const hasObjectElements = property.value.elements.some(el => el?.type === AST_NODE_TYPES.ObjectExpression);
179
+ if (hasObjectElements) {
180
+ // Recurse into each object in the array
181
+ property.value.elements.forEach((element, index) => {
182
+ if (element?.type === AST_NODE_TYPES.ObjectExpression) {
183
+ locations.push(...extractFromCvaObject(element, functionName, [...propertyPath, `[${index}]`]));
184
+ }
185
+ });
186
+ }
187
+ else {
188
+ // This is an array of class strings
189
+ const arrayContext = {
190
+ type: "cva-variant",
191
+ functionName,
192
+ variantPath: propertyPath,
193
+ };
194
+ locations.push(...extractFromArray(property.value, arrayContext));
195
+ }
196
+ continue;
197
+ }
198
+ // Extract string values at leaf nodes
199
+ const extracted = extractStringValue(property.value);
200
+ if (extracted) {
201
+ locations.push({
202
+ value: extracted.value,
203
+ reportNode: property.value,
204
+ fixNode: extracted.node,
205
+ context: {
206
+ type: "cva-variant",
207
+ functionName,
208
+ variantPath: propertyPath,
209
+ },
210
+ });
211
+ }
212
+ }
213
+ return locations;
214
+ };
215
+ /**
216
+ * Get the name of an object property key
217
+ */
218
+ const getPropertyKeyName = (key) => {
219
+ if (key.type === AST_NODE_TYPES.Identifier) {
220
+ return key.name;
221
+ }
222
+ if (key.type === AST_NODE_TYPES.Literal && typeof key.value === "string") {
223
+ return key.value;
224
+ }
225
+ return null;
226
+ };
227
+ // =============================================================================
228
+ // Function Call Extraction
229
+ // =============================================================================
230
+ /**
231
+ * Extract classname locations from a Tailwind function call.
232
+ *
233
+ * Handles various argument patterns:
234
+ * - String literals: twMerge("flex items-center")
235
+ * - Template literals: twMerge(`flex ${condition && "hidden"}`)
236
+ * - Arrays: cvaMerge(["flex", "items-center"], options)
237
+ * - CVA options objects: cva(base, { variants: { ... } })
238
+ */
239
+ const extractFromFunctionCall = (node) => {
240
+ const calleeName = getCalleeName(node.callee);
241
+ if (!calleeName || !isTailwindFunction(calleeName)) {
242
+ return [];
243
+ }
244
+ const locations = [];
245
+ const isCvaStyle = calleeName === "cva" || calleeName === "cvaMerge";
246
+ node.arguments.forEach((arg, argIndex) => {
247
+ const baseContext = {
248
+ type: "function-call",
249
+ functionName: calleeName,
250
+ argumentIndex: argIndex,
251
+ };
252
+ // Handle string literals
253
+ const extracted = extractStringValue(arg);
254
+ if (extracted) {
255
+ locations.push({
256
+ value: extracted.value,
257
+ reportNode: arg,
258
+ fixNode: extracted.node,
259
+ context: baseContext,
260
+ });
261
+ return;
262
+ }
263
+ // Handle array arguments
264
+ if (arg.type === AST_NODE_TYPES.ArrayExpression) {
265
+ locations.push(...extractFromArray(arg, baseContext));
266
+ return;
267
+ }
268
+ // Handle CVA options object (typically second argument)
269
+ if (isCvaStyle && arg.type === AST_NODE_TYPES.ObjectExpression) {
270
+ locations.push(...extractFromCvaObject(arg, calleeName));
271
+ }
272
+ });
273
+ return locations;
274
+ };
275
+ // =============================================================================
276
+ // JSX Attribute Extraction
277
+ // =============================================================================
278
+ /**
279
+ * Extract classname locations from a JSX attribute (className or class).
280
+ *
281
+ * Handles various value patterns:
282
+ * - String literals: className="flex items-center"
283
+ * - Expression with string: className={"flex items-center"}
284
+ * - Template literals: className={`flex ${condition && "hidden"}`}
285
+ */
286
+ const extractFromJsxAttribute = (node) => {
287
+ if (node.name.type !== AST_NODE_TYPES.JSXIdentifier) {
288
+ return [];
289
+ }
290
+ const attrName = node.name.name;
291
+ if (!isClassnameAttribute(attrName)) {
292
+ return [];
293
+ }
294
+ const attrValue = node.value;
295
+ if (!attrValue) {
296
+ return [];
297
+ }
298
+ const context = {
299
+ type: "jsx-attribute",
300
+ attributeName: attrName,
301
+ };
302
+ // Direct string literal: className="flex items-center"
303
+ if (attrValue.type === AST_NODE_TYPES.Literal) {
304
+ const extracted = extractFromLiteral(attrValue);
305
+ if (extracted) {
306
+ return [
307
+ {
308
+ value: extracted.value,
309
+ reportNode: node,
310
+ fixNode: extracted.node,
311
+ context,
312
+ },
313
+ ];
314
+ }
315
+ }
316
+ // JSX Expression: className={...}
317
+ if (attrValue.type === AST_NODE_TYPES.JSXExpressionContainer) {
318
+ const expression = attrValue.expression;
319
+ // Skip empty expressions
320
+ if (expression.type === AST_NODE_TYPES.JSXEmptyExpression) {
321
+ return [];
322
+ }
323
+ // String literal in expression: className={"flex items-center"}
324
+ const extracted = extractStringValue(expression);
325
+ if (extracted) {
326
+ return [
327
+ {
328
+ value: extracted.value,
329
+ reportNode: node,
330
+ fixNode: extracted.node,
331
+ context,
332
+ },
333
+ ];
334
+ }
335
+ // Note: We don't recurse into function calls here as they're handled
336
+ // separately by the CallExpression handler in the rule
337
+ }
338
+ return [];
339
+ };
340
+ // =============================================================================
341
+ // Variable Declaration Extraction
342
+ // =============================================================================
343
+ /**
344
+ * Extract classname locations from a variable declaration with a classname-like name.
345
+ *
346
+ * Example: const buttonClass = "flex items-center";
347
+ */
348
+ const extractFromVariableDeclarator = (node) => {
349
+ if (node.id.type !== AST_NODE_TYPES.Identifier) {
350
+ return [];
351
+ }
352
+ const varName = node.id.name;
353
+ if (!isClassnameVariable(varName)) {
354
+ return [];
355
+ }
356
+ if (!node.init) {
357
+ return [];
358
+ }
359
+ const context = {
360
+ type: "variable",
361
+ variableName: varName,
362
+ };
363
+ // Handle string literal
364
+ const extracted = extractStringValue(node.init);
365
+ if (extracted) {
366
+ return [
367
+ {
368
+ value: extracted.value,
369
+ reportNode: node.init,
370
+ fixNode: extracted.node,
371
+ context,
372
+ },
373
+ ];
374
+ }
375
+ // Handle array of strings
376
+ if (node.init.type === AST_NODE_TYPES.ArrayExpression) {
377
+ return extractFromArray(node.init, context);
378
+ }
379
+ // Note: Function calls in variable initializers are handled separately
380
+ // by the CallExpression handler
381
+ return [];
382
+ };
383
+ // =============================================================================
384
+ // Unified Entry Points
385
+ // =============================================================================
386
+ /**
387
+ * Find all classname string locations in a CallExpression node.
388
+ *
389
+ * Use this in your ESLint rule's CallExpression handler to process
390
+ * Tailwind utility function calls like twMerge, cvaMerge, etc.
391
+ *
392
+ * @example
393
+ * ```typescript
394
+ * CallExpression(node) {
395
+ * const locations = findClassnameStringsInCall(node);
396
+ * for (const location of locations) {
397
+ * if (hasBannedPattern(location.value)) {
398
+ * context.report({ node: location.reportNode, ... });
399
+ * }
400
+ * }
401
+ * }
402
+ * ```
403
+ */
404
+ const findClassnameStringsInCall = (node) => {
405
+ return extractFromFunctionCall(node);
406
+ };
407
+ /**
408
+ * Find all classname string locations in a JSXAttribute node.
409
+ *
410
+ * Use this in your ESLint rule's JSXAttribute handler to process
411
+ * className and class attributes.
412
+ *
413
+ * @example
414
+ * ```typescript
415
+ * JSXAttribute(node) {
416
+ * const locations = findClassnameStringsInAttribute(node);
417
+ * for (const location of locations) {
418
+ * if (hasBannedPattern(location.value)) {
419
+ * context.report({ node: location.reportNode, ... });
420
+ * }
421
+ * }
422
+ * }
423
+ * ```
424
+ */
425
+ const findClassnameStringsInAttribute = (node) => {
426
+ return extractFromJsxAttribute(node);
427
+ };
428
+ /**
429
+ * Find all classname string locations in a VariableDeclarator node.
430
+ *
431
+ * Use this in your ESLint rule's VariableDeclarator handler to process
432
+ * variables with classname-like names.
433
+ *
434
+ * @example
435
+ * ```typescript
436
+ * VariableDeclarator(node) {
437
+ * const locations = findClassnameStringsInVariable(node);
438
+ * for (const location of locations) {
439
+ * if (hasBannedPattern(location.value)) {
440
+ * context.report({ node: location.reportNode, ... });
441
+ * }
442
+ * }
443
+ * }
444
+ * ```
445
+ */
446
+ const findClassnameStringsInVariable = (node) => {
447
+ return extractFromVariableDeclarator(node);
448
+ };
449
+ // =============================================================================
450
+ // Utility Helpers for Fixing
451
+ // =============================================================================
452
+ /**
453
+ * Split a classname string into individual class names.
454
+ * Handles whitespace-separated classes.
455
+ */
456
+ const splitClasses = (value) => {
457
+ return value.split(/\s+/).filter(Boolean);
458
+ };
459
+ /**
460
+ * Join class names into a single string.
461
+ */
462
+ const joinClasses = (classes) => {
463
+ return classes.join(" ");
464
+ };
465
+ /**
466
+ * Create a JSON-formatted array string from an array of class names.
467
+ * Useful for auto-fix operations.
468
+ */
469
+ const formatAsArray = (classes) => {
470
+ return JSON.stringify(classes);
471
+ };
472
+ /**
473
+ * Get the quote character used in a string literal.
474
+ */
475
+ const getQuoteChar = (node) => {
476
+ if (typeof node.value === "string" && node.raw) {
477
+ return node.raw.charAt(0);
478
+ }
479
+ return '"';
480
+ };
481
+ /**
482
+ * Create a quoted string with the same quote style as the original.
483
+ */
484
+ const quoteString = (value, originalNode) => {
485
+ const quote = getQuoteChar(originalNode);
486
+ return `${quote}${value}${quote}`;
487
+ };
488
+
489
+ /**
490
+ * Matches zero or more chained Tailwind variant prefixes (e.g. `hover:`, `sm:`,
491
+ * `dark:hover:`) that may precede a class token. Kept generic (word chars/hyphens
492
+ * followed by a colon) rather than an enumerated variant list, so it stays in sync
493
+ * with any variant Tailwind supports without needing updates here.
494
+ */
495
+ const VARIANT_PREFIX = "(?:[\\w-]+:)*";
496
+ /**
497
+ * Replace a class token in a whitespace-separated classname string, word-boundary safe.
498
+ *
499
+ * Handles both prefix-based patterns (e.g., `bg-red-500` -> `bg-danger-500`) and exact
500
+ * token matches (e.g., `red` -> `blue`).
501
+ *
502
+ * When `replacement` is the empty string, the whole matched token is deleted and the
503
+ * surrounding whitespace is collapsed so no double space (or leading/trailing space) is
504
+ * left behind.
505
+ */
506
+ function replaceClassToken(value, pattern, replacement, options) {
507
+ const prefixes = options?.prefixes;
508
+ const hasPrefixes = prefixes !== undefined && prefixes.length > 0;
509
+ if (replacement === "") {
510
+ return deleteToken(value, pattern, hasPrefixes ? prefixes : undefined);
511
+ }
512
+ if (hasPrefixes) {
513
+ // With prefixes, replace -pattern- with -replacement-. Any variant prefix (e.g.
514
+ // "hover:bg-") is untouched since we only match starting from the hyphen.
515
+ const prefixRegex = new RegExp(`-${pattern}(-|$|/)`, "g");
516
+ return value.replace(prefixRegex, `-${replacement}$1`);
517
+ }
518
+ // Without prefixes, replace exact word boundaries, preserving any variant prefix
519
+ // chain (e.g. "sm:red" keeps "sm:" and only swaps "red" for the replacement).
520
+ const exactRegex = new RegExp(`(^|\\s)(${VARIANT_PREFIX})${pattern}($|\\s)`, "g");
521
+ return value.replace(exactRegex, `$1$2${replacement}$3`);
522
+ }
523
+ /**
524
+ * Remove a class token entirely, collapsing surrounding whitespace.
525
+ *
526
+ * - Token flanked by whitespace on both sides: one side of whitespace is kept as the
527
+ * separator between its neighbors.
528
+ * - Token at the very start or end of the string: the single boundary whitespace is
529
+ * removed along with the token.
530
+ */
531
+ function deleteToken(value, pattern, prefixes) {
532
+ const regex = prefixes !== undefined
533
+ ? new RegExp(`(^|\\s)${VARIANT_PREFIX}(?:${prefixes.join("|")})-${pattern}(?:-[a-zA-Z0-9]+)?(?:/\\d+)?($|\\s)`, "g")
534
+ : new RegExp(`(^|\\s)${VARIANT_PREFIX}${pattern}($|\\s)`, "g");
535
+ return value.replace(regex, (_fullMatch, before, after) => (before && after ? before : ""));
536
+ }
537
+
538
+ export { CLASSNAME_ATTRIBUTES, CLASSNAME_VARIABLE_SUFFIXES, TAILWIND_FUNCTIONS, findClassnameStringsInAttribute, findClassnameStringsInCall, findClassnameStringsInVariable, formatAsArray, getCalleeName, isClassnameAttribute, isClassnameVariable, isTailwindFunction, joinClasses, quoteString, replaceClassToken, splitClasses };
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=entry.js.map
@@ -0,0 +1,3 @@
1
+ {
2
+ "generators": {}
3
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@trackunit/css-classname-utils",
3
+ "version": "0.0.2",
4
+ "repository": "https://github.com/Trackunit/manager",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "engines": {
7
+ "node": ">=24.x"
8
+ },
9
+ "dependencies": {
10
+ "@typescript-eslint/types": "8.58.1"
11
+ },
12
+ "migrations": "./migrations.json",
13
+ "module": "./index.esm.js",
14
+ "main": "./index.cjs.js",
15
+ "types": "./index.d.ts"
16
+ }
@@ -0,0 +1,192 @@
1
+ import { TSESTree } from "@typescript-eslint/types";
2
+ /**
3
+ * Utility functions for finding and analyzing Tailwind CSS classnames.
4
+ *
5
+ * This module lives in `@trackunit/css-classname-utils`, a dependency-light package
6
+ * (its only dependency is `@typescript-eslint/types`, with no runtime dependency on
7
+ * `eslint` or `@typescript-eslint/utils`) so it can be imported by any TypeScript
8
+ * project, not just ESLint plugins. It has two consumer categories:
9
+ *
10
+ * - **ESLint rules** in `plugin-trackunit` use these helpers to find and report on
11
+ * classname usage (see "Usage in ESLint Rules" below).
12
+ * - **Nx migrations/codemods** import this package directly, with no ESLint context
13
+ * needed, to detect and rewrite classnames across the codebase (e.g. via
14
+ * `replaceClassToken` from `./replace-class-token`).
15
+ *
16
+ * This module provides a centralized way to detect classname usage patterns across:
17
+ * - JSX `className` and `class` attributes
18
+ * - Variables named with `...Class` or `...ClassName` suffixes
19
+ * - Tailwind utility functions: `cvaMerge`, `twMerge`, `cva`, `tw`, `twx`, `tws`, `cn`, `clsx`, `classNames`, `twJoin`
20
+ *
21
+ * ## Core Concepts
22
+ *
23
+ * ### ClassnameLocation
24
+ * Represents a single location where a classname string value is found.
25
+ * Contains the string value, the AST node for reporting/fixing, and metadata.
26
+ *
27
+ * ### ClassnameContext
28
+ * Describes where a classname was found (JSX attribute, function call, variable, etc.)
29
+ *
30
+ * ## Usage in ESLint Rules
31
+ *
32
+ * ```typescript
33
+ * import { findClassnameStrings, TAILWIND_FUNCTIONS, isClassnameVariable } from "./classname-utils";
34
+ *
35
+ * // In a CallExpression handler:
36
+ * CallExpression(node) {
37
+ * const locations = findClassnameStrings(node);
38
+ * for (const location of locations) {
39
+ * // Check location.value for banned patterns
40
+ * // Report on location.reportNode
41
+ * // Fix using location.fixNode
42
+ * }
43
+ * }
44
+ * ```
45
+ */
46
+ /**
47
+ * Context describing where a classname was found
48
+ */
49
+ export type ClassnameContext = {
50
+ type: "jsx-attribute";
51
+ attributeName: string;
52
+ } | {
53
+ type: "function-call";
54
+ functionName: string;
55
+ argumentIndex: number;
56
+ } | {
57
+ type: "cva-variant";
58
+ functionName: string;
59
+ variantPath: Array<string>;
60
+ } | {
61
+ type: "variable";
62
+ variableName: string;
63
+ } | {
64
+ type: "array-element";
65
+ parentContext: ClassnameContext;
66
+ elementIndex: number;
67
+ };
68
+ /**
69
+ * Represents a location where a classname string value is found
70
+ */
71
+ export type ClassnameLocation = {
72
+ /** The extracted string value (for template literals, only static parts) */
73
+ value: string;
74
+ /** The AST node to use when reporting errors */
75
+ reportNode: TSESTree.Node;
76
+ /** The AST node to use when creating fixes (string literal or template literal) */
77
+ fixNode: TSESTree.Literal | TSESTree.TemplateLiteral;
78
+ /** Context describing where this classname was found */
79
+ context: ClassnameContext;
80
+ };
81
+ /**
82
+ * Function names that accept Tailwind class strings as arguments.
83
+ * These are commonly used for class merging and conditional class application.
84
+ *
85
+ * Kept in sync with Prettier's Tailwind CSS plugin config by
86
+ * `classname-config-sync.spec.ts`.
87
+ */
88
+ export declare const TAILWIND_FUNCTIONS: readonly ["cva", "cvaMerge", "tw", "twx", "tws", "twMerge", "twJoin", "cn", "clsx", "classNames"];
89
+ export type TailwindFunction = (typeof TAILWIND_FUNCTIONS)[number];
90
+ /**
91
+ * Variable name suffixes that indicate a classname value.
92
+ * Matches variables like: containerClass, buttonClassName, rootClasses
93
+ *
94
+ * Kept in sync with the VS Code `tailwindCSS.experimental.classRegex` setting by
95
+ * `classname-config-sync.spec.ts`, since Prettier has no equivalent option to check against.
96
+ */
97
+ export declare const CLASSNAME_VARIABLE_SUFFIXES: readonly ["Class", "ClassName", "Classes"];
98
+ /**
99
+ * JSX attribute names that contain classname values
100
+ */
101
+ export declare const CLASSNAME_ATTRIBUTES: readonly ["className", "class"];
102
+ /**
103
+ * Check if a function name is a known Tailwind utility function
104
+ */
105
+ export declare const isTailwindFunction: (name: string) => name is TailwindFunction;
106
+ /**
107
+ * Check if a variable name indicates it contains classnames
108
+ */
109
+ export declare const isClassnameVariable: (name: string) => boolean;
110
+ /**
111
+ * Check if a JSX attribute name is a classname attribute
112
+ */
113
+ export declare const isClassnameAttribute: (name: string) => boolean;
114
+ /**
115
+ * Get the function name from a CallExpression callee
116
+ */
117
+ export declare const getCalleeName: (callee: TSESTree.CallExpression["callee"]) => string | null;
118
+ /**
119
+ * Find all classname string locations in a CallExpression node.
120
+ *
121
+ * Use this in your ESLint rule's CallExpression handler to process
122
+ * Tailwind utility function calls like twMerge, cvaMerge, etc.
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * CallExpression(node) {
127
+ * const locations = findClassnameStringsInCall(node);
128
+ * for (const location of locations) {
129
+ * if (hasBannedPattern(location.value)) {
130
+ * context.report({ node: location.reportNode, ... });
131
+ * }
132
+ * }
133
+ * }
134
+ * ```
135
+ */
136
+ export declare const findClassnameStringsInCall: (node: TSESTree.CallExpression) => Array<ClassnameLocation>;
137
+ /**
138
+ * Find all classname string locations in a JSXAttribute node.
139
+ *
140
+ * Use this in your ESLint rule's JSXAttribute handler to process
141
+ * className and class attributes.
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * JSXAttribute(node) {
146
+ * const locations = findClassnameStringsInAttribute(node);
147
+ * for (const location of locations) {
148
+ * if (hasBannedPattern(location.value)) {
149
+ * context.report({ node: location.reportNode, ... });
150
+ * }
151
+ * }
152
+ * }
153
+ * ```
154
+ */
155
+ export declare const findClassnameStringsInAttribute: (node: TSESTree.JSXAttribute) => Array<ClassnameLocation>;
156
+ /**
157
+ * Find all classname string locations in a VariableDeclarator node.
158
+ *
159
+ * Use this in your ESLint rule's VariableDeclarator handler to process
160
+ * variables with classname-like names.
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * VariableDeclarator(node) {
165
+ * const locations = findClassnameStringsInVariable(node);
166
+ * for (const location of locations) {
167
+ * if (hasBannedPattern(location.value)) {
168
+ * context.report({ node: location.reportNode, ... });
169
+ * }
170
+ * }
171
+ * }
172
+ * ```
173
+ */
174
+ export declare const findClassnameStringsInVariable: (node: TSESTree.VariableDeclarator) => Array<ClassnameLocation>;
175
+ /**
176
+ * Split a classname string into individual class names.
177
+ * Handles whitespace-separated classes.
178
+ */
179
+ export declare const splitClasses: (value: string) => Array<string>;
180
+ /**
181
+ * Join class names into a single string.
182
+ */
183
+ export declare const joinClasses: (classes: Array<string>) => string;
184
+ /**
185
+ * Create a JSON-formatted array string from an array of class names.
186
+ * Useful for auto-fix operations.
187
+ */
188
+ export declare const formatAsArray: (classes: Array<string>) => string;
189
+ /**
190
+ * Create a quoted string with the same quote style as the original.
191
+ */
192
+ export declare const quoteString: (value: string, originalNode: TSESTree.Literal) => string;
package/src/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./classname-utils";
2
+ export * from "./replace-class-token";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Options for {@link replaceClassToken}.
3
+ */
4
+ export type ReplaceClassTokenOptions = {
5
+ /**
6
+ * Optional prefixes to match (e.g., "bg", "text", "border"). If not provided,
7
+ * `pattern` is matched as an exact, standalone class token.
8
+ */
9
+ prefixes?: Array<string>;
10
+ };
11
+ /**
12
+ * Replace a class token in a whitespace-separated classname string, word-boundary safe.
13
+ *
14
+ * Handles both prefix-based patterns (e.g., `bg-red-500` -> `bg-danger-500`) and exact
15
+ * token matches (e.g., `red` -> `blue`).
16
+ *
17
+ * When `replacement` is the empty string, the whole matched token is deleted and the
18
+ * surrounding whitespace is collapsed so no double space (or leading/trailing space) is
19
+ * left behind.
20
+ */
21
+ export declare function replaceClassToken(value: string, pattern: string, replacement: string, options?: ReplaceClassTokenOptions): string;