tailwind-canonical 0.8.0 → 0.9.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 +80 -8
- package/dist/cli/cli.js +47 -5
- package/dist/cli/index.js +1 -1
- package/dist/cli/watch.d.ts +12 -2
- package/dist/cli/watch.js +144 -24
- package/dist/core/class-strings.js +547 -49
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +4 -1
- package/dist/core/consistency.d.ts +5 -0
- package/dist/core/consistency.js +22 -15
- package/dist/core/deduplicator.js +4 -0
- package/dist/core/merger.d.ts +1 -0
- package/dist/core/merger.js +5 -2
- package/dist/core/rules.d.ts +2 -0
- package/dist/core/rules.js +24 -2
- package/dist/core/scanner.d.ts +0 -1
- package/dist/core/scanner.js +1 -50
- package/dist/eslint/plugin.d.ts +1 -1
- package/dist/eslint/plugin.js +105 -28
- package/dist/io/config.d.ts +4 -1
- package/dist/io/config.js +45 -4
- package/dist/io/consistency.js +4 -6
- package/dist/io/scanner.js +26 -10
- package/package.json +8 -6
package/dist/core/config.js
CHANGED
|
@@ -7,6 +7,7 @@ export const CONFIG_FILENAMES = [
|
|
|
7
7
|
'tailwind-canonical.config.mjs',
|
|
8
8
|
];
|
|
9
9
|
const KNOWN_KEYS = [
|
|
10
|
+
'useDefaultTheme',
|
|
10
11
|
'customTextTokens',
|
|
11
12
|
'customSpacingTokens',
|
|
12
13
|
'ignorePatterns',
|
|
@@ -141,6 +142,8 @@ export function validateConfig(input, filename = CONFIG_FILENAME) {
|
|
|
141
142
|
}
|
|
142
143
|
const cfg = input;
|
|
143
144
|
assertKnownKeys(cfg, KNOWN_KEYS, 'unknown key', filename);
|
|
145
|
+
if ('useDefaultTheme' in cfg && typeof cfg.useDefaultTheme !== 'boolean')
|
|
146
|
+
fail(filename, 'useDefaultTheme must be a boolean');
|
|
144
147
|
if ('customTextTokens' in cfg)
|
|
145
148
|
assertPxTokenMap(cfg.customTextTokens, 'customTextTokens', filename);
|
|
146
149
|
if ('customSpacingTokens' in cfg)
|
|
@@ -176,4 +179,4 @@ export function validateConfig(input, filename = CONFIG_FILENAME) {
|
|
|
176
179
|
}
|
|
177
180
|
return cfg;
|
|
178
181
|
}
|
|
179
|
-
export { loadConfig, resolveInitFilename } from '../io/config.js';
|
|
182
|
+
export { findConfigPath, loadConfig, resolveInitFilename, } from '../io/config.js';
|
|
@@ -2,7 +2,12 @@ import { type ClassStringOpts } from './class-strings.js';
|
|
|
2
2
|
import type { Config } from './rules.js';
|
|
3
3
|
export type FileClasses = {
|
|
4
4
|
file: string;
|
|
5
|
+
/** All class occurrences, used for color and scale statistics. */
|
|
5
6
|
classes: string[];
|
|
7
|
+
/** Individual class strings, used for repeated-pattern detection.
|
|
8
|
+
* When omitted, `classes` is treated as one class string for compatibility.
|
|
9
|
+
*/
|
|
10
|
+
classLists?: string[][];
|
|
6
11
|
};
|
|
7
12
|
export type ColorVariant = {
|
|
8
13
|
token: string;
|
package/dist/core/consistency.js
CHANGED
|
@@ -9,7 +9,11 @@ function parseColor(cls, families, knownColors) {
|
|
|
9
9
|
return null;
|
|
10
10
|
// A known color without an explicit family forms its own family, so colors
|
|
11
11
|
// newly added to the palette are grouped rather than silently dropped.
|
|
12
|
-
const family =
|
|
12
|
+
const family = Object.hasOwn(families, color)
|
|
13
|
+
? families[color]
|
|
14
|
+
: knownColors.has(color)
|
|
15
|
+
? color
|
|
16
|
+
: null;
|
|
13
17
|
if (!family)
|
|
14
18
|
return null;
|
|
15
19
|
return { property, family, token: `${color}-${shade}` };
|
|
@@ -22,7 +26,8 @@ function parseScale(cls, scaleProperties) {
|
|
|
22
26
|
const base = stripVariants(cls);
|
|
23
27
|
const negative = base.startsWith('-');
|
|
24
28
|
const body = negative ? base.slice(1) : base;
|
|
25
|
-
const
|
|
29
|
+
const bracket = body.indexOf('[');
|
|
30
|
+
const dash = body.lastIndexOf('-', bracket === -1 ? body.length : bracket);
|
|
26
31
|
if (dash === -1)
|
|
27
32
|
return null;
|
|
28
33
|
const property = body.slice(0, dash);
|
|
@@ -149,20 +154,22 @@ function detectRareScaleValues(inconsistencies, minPropertyOccurrences, maxFiles
|
|
|
149
154
|
// frequent sub-patterns (no k-subset itemset mining).
|
|
150
155
|
function detectCombinations(input, minSize, minFiles, max) {
|
|
151
156
|
const combos = new Map();
|
|
152
|
-
for (const { file, classes } of input) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
for (const { file, classes: allClasses, classLists } of input) {
|
|
158
|
+
for (const classes of classLists ?? [allClasses]) {
|
|
159
|
+
if (classes.length < minSize)
|
|
160
|
+
continue;
|
|
161
|
+
const unique = [...new Set(classes)].sort();
|
|
162
|
+
if (unique.length < minSize)
|
|
163
|
+
continue;
|
|
164
|
+
const key = unique.join(' ');
|
|
165
|
+
let entry = combos.get(key);
|
|
166
|
+
if (!entry) {
|
|
167
|
+
entry = { classes: unique, count: 0, files: new Set() };
|
|
168
|
+
combos.set(key, entry);
|
|
169
|
+
}
|
|
170
|
+
entry.count++;
|
|
171
|
+
entry.files.add(file);
|
|
163
172
|
}
|
|
164
|
-
entry.count++;
|
|
165
|
-
entry.files.add(file);
|
|
166
173
|
}
|
|
167
174
|
return [...combos.values()]
|
|
168
175
|
.filter((c) => c.files.size >= minFiles)
|
|
@@ -186,6 +186,8 @@ const CORNER_MAP = {
|
|
|
186
186
|
br: ['br'],
|
|
187
187
|
};
|
|
188
188
|
function parseRoundedCorner(cls) {
|
|
189
|
+
if (/^rounded-(?:s|e|ss|se|es|ee)(?:-|$)/.test(cls))
|
|
190
|
+
return null;
|
|
189
191
|
const m = cls.match(/^rounded-(tl|tr|bl|br|t|b|l|r)(?:-(.+))?$/) ??
|
|
190
192
|
cls.match(/^rounded()(?:-(.+))?$/);
|
|
191
193
|
if (!m)
|
|
@@ -321,6 +323,8 @@ function keepLastOf(slots, indices) {
|
|
|
321
323
|
slots[i] = [];
|
|
322
324
|
}
|
|
323
325
|
export function deduplicateClasses(classStr) {
|
|
326
|
+
if (classStr.includes('${'))
|
|
327
|
+
return classStr;
|
|
324
328
|
const original = classStr.split(/\s+/).filter(Boolean);
|
|
325
329
|
if (original.length <= 1)
|
|
326
330
|
return classStr;
|
package/dist/core/merger.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ClassStringOpts } from './class-strings.js';
|
|
2
|
+
export declare function mergeClasses(classes: string, twMerge: (classes: string) => string, opts?: Pick<ClassStringOpts, 'customTextTokens'>): string;
|
|
2
3
|
export declare function mergeContent(content: string, twMerge: (classes: string) => string, opts?: ClassStringOpts): {
|
|
3
4
|
result: string;
|
|
4
5
|
count: number;
|
package/dist/core/merger.js
CHANGED
|
@@ -84,9 +84,12 @@ function restoreLeadingForUnsafeTextSizes(original, merged, customTextClasses) {
|
|
|
84
84
|
}
|
|
85
85
|
return restored.join(' ');
|
|
86
86
|
}
|
|
87
|
-
export function
|
|
87
|
+
export function mergeClasses(classes, twMerge, opts = {}) {
|
|
88
88
|
const customTextClasses = new Set(Object.values(opts.customTextTokens ?? {}).map((token) => `text-${token}`));
|
|
89
|
-
return
|
|
89
|
+
return restoreLeadingForUnsafeTextSizes(classes, twMerge(classes), customTextClasses);
|
|
90
|
+
}
|
|
91
|
+
export function mergeContent(content, twMerge, opts = {}) {
|
|
92
|
+
return replaceClassStrings(content, (classes) => mergeClasses(classes, twMerge, opts), {
|
|
90
93
|
...opts,
|
|
91
94
|
isSuppressed: makeLineSuppressor(content),
|
|
92
95
|
});
|
package/dist/core/rules.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ type DefaultCommandConfig = {
|
|
|
26
26
|
targets?: string[];
|
|
27
27
|
};
|
|
28
28
|
export type Config = {
|
|
29
|
+
/** Set false for custom themes: only explicitly configured px mappings apply. */
|
|
30
|
+
useDefaultTheme?: boolean;
|
|
29
31
|
customTextTokens?: Record<number, string>;
|
|
30
32
|
customSpacingTokens?: Record<number, string>;
|
|
31
33
|
ignorePatterns?: RegExp[];
|
package/dist/core/rules.js
CHANGED
|
@@ -13,7 +13,6 @@ const TEXT_SIZE_MAP = {
|
|
|
13
13
|
96: '8xl',
|
|
14
14
|
128: '9xl',
|
|
15
15
|
};
|
|
16
|
-
const BUILT_IN_TEXT_PX = new Set(Object.keys(TEXT_SIZE_MAP).map(Number));
|
|
17
16
|
/**
|
|
18
17
|
* Radius px → token suffix. v4 renamed the small end (`rounded-sm` → `-xs`,
|
|
19
18
|
* bare `rounded` → `-sm`) and added `4xl`; `''` is the bare v3 `rounded`.
|
|
@@ -343,7 +342,7 @@ function textSuggestion(cls, px, textTokens) {
|
|
|
343
342
|
return {
|
|
344
343
|
original: cls,
|
|
345
344
|
canonical: `text-${token}`,
|
|
346
|
-
isCustomToken:
|
|
345
|
+
isCustomToken: textTokens[px] !== TEXT_SIZE_MAP[px],
|
|
347
346
|
};
|
|
348
347
|
}
|
|
349
348
|
function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
|
|
@@ -466,6 +465,29 @@ export function suggestCanonical(cls, config = {}) {
|
|
|
466
465
|
};
|
|
467
466
|
}
|
|
468
467
|
function suggestUtility(cls, config) {
|
|
468
|
+
if (config.useDefaultTheme === false) {
|
|
469
|
+
const text = cls.match(/^text-\[(\d+)px\]$/);
|
|
470
|
+
if (text) {
|
|
471
|
+
const token = config.customTextTokens?.[Number(text[1])];
|
|
472
|
+
return token
|
|
473
|
+
? { original: cls, canonical: `text-${token}`, isCustomToken: true }
|
|
474
|
+
: null;
|
|
475
|
+
}
|
|
476
|
+
const spacing = cls.match(SPACING_PX_RE);
|
|
477
|
+
if (spacing) {
|
|
478
|
+
const token = config.customSpacingTokens?.[Number(spacing[2])];
|
|
479
|
+
return token
|
|
480
|
+
? {
|
|
481
|
+
original: cls,
|
|
482
|
+
canonical: `${spacing[1]}-${token}`,
|
|
483
|
+
isCustomToken: true,
|
|
484
|
+
}
|
|
485
|
+
: null;
|
|
486
|
+
}
|
|
487
|
+
// Even rem-to-px equivalences depend on the consumer's root font size.
|
|
488
|
+
// A custom theme must opt in through explicit mappings, never inference.
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
469
491
|
const textTokens = { ...TEXT_SIZE_MAP, ...config.customTextTokens };
|
|
470
492
|
const spacingTokens = config.customSpacingTokens ?? {};
|
|
471
493
|
const v3 = config.tailwindVersion === 3;
|
package/dist/core/scanner.d.ts
CHANGED
package/dist/core/scanner.js
CHANGED
|
@@ -15,55 +15,6 @@ export const DEFAULT_IGNORE = [
|
|
|
15
15
|
'coverage',
|
|
16
16
|
];
|
|
17
17
|
export function isGlob(target) {
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
function escapeRegex(text) {
|
|
21
|
-
return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
22
|
-
}
|
|
23
|
-
function wildcardToRegex(text) {
|
|
24
|
-
return text
|
|
25
|
-
.split('*')
|
|
26
|
-
.map((part) => part.split('?').map(escapeRegex).join('[^/]'))
|
|
27
|
-
.join('[^/]*');
|
|
28
|
-
}
|
|
29
|
-
export function globToRegex(pattern) {
|
|
30
|
-
let i = 0;
|
|
31
|
-
const anchored = pattern.startsWith('/') || /^[A-Za-z]:/.test(pattern);
|
|
32
|
-
let re = anchored ? '^' : '(?:^|/)';
|
|
33
|
-
while (i < pattern.length) {
|
|
34
|
-
const c = pattern[i];
|
|
35
|
-
if (c === '*' && pattern[i + 1] === '*') {
|
|
36
|
-
i += 2;
|
|
37
|
-
if (pattern[i] === '/') {
|
|
38
|
-
re += '(?:[^/]+/)*';
|
|
39
|
-
i++;
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
re += '.*';
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
else if (c === '*') {
|
|
46
|
-
re += '[^/]*';
|
|
47
|
-
i++;
|
|
48
|
-
}
|
|
49
|
-
else if (c === '?') {
|
|
50
|
-
re += '[^/]';
|
|
51
|
-
i++;
|
|
52
|
-
}
|
|
53
|
-
else if (c === '{' && pattern.indexOf('}', i) !== -1) {
|
|
54
|
-
const close = pattern.indexOf('}', i);
|
|
55
|
-
const alts = pattern
|
|
56
|
-
.slice(i + 1, close)
|
|
57
|
-
.split(',')
|
|
58
|
-
.map(wildcardToRegex);
|
|
59
|
-
re += `(?:${alts.join('|')})`;
|
|
60
|
-
i = close + 1;
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
re += escapeRegex(c);
|
|
64
|
-
i++;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return new RegExp(`${re}$`);
|
|
18
|
+
return /[*?{[(]/.test(target);
|
|
68
19
|
}
|
|
69
20
|
export { resolveTargets, scanFiles } from '../io/scanner.js';
|
package/dist/eslint/plugin.d.ts
CHANGED
package/dist/eslint/plugin.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
+
import { validateConfig } from '../core/config.js';
|
|
3
|
+
import { mergeClasses } from '../core/merger.js';
|
|
2
4
|
import { suggestCanonical, } from '../core/rules.js';
|
|
3
5
|
const _require = createRequire(import.meta.url);
|
|
4
6
|
/**
|
|
@@ -13,31 +15,97 @@ function quasiTextRange(quasi) {
|
|
|
13
15
|
const start = quasi.range[0] + 1;
|
|
14
16
|
return [start, start + quasi.value.raw.length];
|
|
15
17
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
/** Only inspect strings whose syntax establishes that they contain classes. */
|
|
19
|
+
function isClassContext(node, config) {
|
|
20
|
+
const attributes = config.attributeNames?.length
|
|
21
|
+
? config.attributeNames
|
|
22
|
+
: ['className'];
|
|
23
|
+
const functions = config.functionNames ?? ['cn', 'clsx', 'cva'];
|
|
24
|
+
const properties = [];
|
|
25
|
+
let current = node;
|
|
26
|
+
while (current.parent) {
|
|
27
|
+
const parent = current.parent;
|
|
28
|
+
if (parent.type === 'JSXAttribute') {
|
|
29
|
+
const name = parent.name;
|
|
30
|
+
return (properties.length === 0 &&
|
|
31
|
+
typeof name === 'object' &&
|
|
32
|
+
attributes.includes(name.name ?? ''));
|
|
33
|
+
}
|
|
34
|
+
if (parent.type === 'CallExpression') {
|
|
35
|
+
if (parent.callee?.type !== 'Identifier')
|
|
36
|
+
return false;
|
|
37
|
+
const name = String(parent.callee.name);
|
|
38
|
+
if (!functions.includes(name))
|
|
39
|
+
return false;
|
|
40
|
+
if (properties.length === 0)
|
|
41
|
+
return true;
|
|
42
|
+
if (name === 'cva') {
|
|
43
|
+
if (properties.some((property) => property.isKey))
|
|
44
|
+
return false;
|
|
45
|
+
return ((properties.length === 3 && properties[2].key === 'variants') ||
|
|
46
|
+
(properties.length === 2 &&
|
|
47
|
+
properties[1].key === 'compoundVariants' &&
|
|
48
|
+
['class', 'className'].includes(properties[0].key)));
|
|
49
|
+
}
|
|
50
|
+
// clsx-style object arguments emit their keys; values are predicates.
|
|
51
|
+
return properties.every((property) => property.isKey);
|
|
52
|
+
}
|
|
53
|
+
if (parent.type === 'Property') {
|
|
54
|
+
properties.push({
|
|
55
|
+
key: String(parent.key?.name ?? parent.key?.value ?? ''),
|
|
56
|
+
isKey: current === parent.key,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (parent.type === 'LogicalExpression' &&
|
|
60
|
+
parent.operator === '&&' &&
|
|
61
|
+
current === parent.left)
|
|
62
|
+
return false;
|
|
63
|
+
if (parent.type === 'ConditionalExpression' &&
|
|
64
|
+
current !== parent.consequent &&
|
|
65
|
+
current !== parent.alternate)
|
|
66
|
+
return false;
|
|
67
|
+
if (![
|
|
68
|
+
'JSXExpressionContainer',
|
|
69
|
+
'ArrayExpression',
|
|
70
|
+
'ObjectExpression',
|
|
71
|
+
'Property',
|
|
72
|
+
'ConditionalExpression',
|
|
73
|
+
'LogicalExpression',
|
|
74
|
+
].includes(parent.type))
|
|
75
|
+
return false;
|
|
76
|
+
current = parent;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function makeStringRuleVisitor(checkLiteral, config = {}) {
|
|
24
81
|
return {
|
|
25
|
-
Literal: (node) =>
|
|
82
|
+
Literal: (node) => {
|
|
83
|
+
if (isClassContext(node, config)) {
|
|
84
|
+
checkLiteral(node);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
26
87
|
TemplateLiteral(node) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
for (const quasi of node.quasis) {
|
|
30
|
-
const value =
|
|
31
|
-
|
|
32
|
-
|
|
88
|
+
if (!isClassContext(node, config))
|
|
89
|
+
return;
|
|
90
|
+
for (const [index, quasi] of node.quasis.entries()) {
|
|
91
|
+
const value = quasi.value.cooked ?? quasi.value.raw;
|
|
92
|
+
// A token touching an interpolation is incomplete: e.g.
|
|
93
|
+
// `text-[12px]${suffix}` must not become `text-xs${suffix}`.
|
|
94
|
+
const start = value.match(index > 0 ? /^\S*\s*/ : /^\s*/)?.[0].length ?? 0;
|
|
95
|
+
const trailing = value.match(index < node.quasis.length - 1 ? /\s*\S*$/ : /\s*$/)?.[0]
|
|
96
|
+
.length ?? 0;
|
|
97
|
+
const end = Math.max(start, value.length - trailing);
|
|
98
|
+
const inner = value.slice(start, end);
|
|
33
99
|
checkLiteral({
|
|
34
100
|
type: 'Literal',
|
|
35
|
-
value,
|
|
101
|
+
value: inner,
|
|
36
102
|
raw: quasi.value.raw,
|
|
37
103
|
range: quasi.range,
|
|
38
104
|
loc: quasi.loc,
|
|
39
105
|
quasiRange: quasiTextRange(quasi),
|
|
40
|
-
valueIsRaw:
|
|
106
|
+
valueIsRaw: quasi.value.cooked === null,
|
|
107
|
+
templatePrefix: value.slice(0, start),
|
|
108
|
+
templateSuffix: value.slice(end),
|
|
41
109
|
});
|
|
42
110
|
}
|
|
43
111
|
},
|
|
@@ -70,7 +138,8 @@ function escapeTemplateText(value) {
|
|
|
70
138
|
}
|
|
71
139
|
function replacementText(node, value) {
|
|
72
140
|
if (node.quasiRange) {
|
|
73
|
-
|
|
141
|
+
const full = (node.templatePrefix ?? '') + value + (node.templateSuffix ?? '');
|
|
142
|
+
return node.valueIsRaw ? full : escapeTemplateText(full);
|
|
74
143
|
}
|
|
75
144
|
return quoteReplacement(node, value);
|
|
76
145
|
}
|
|
@@ -88,8 +157,8 @@ const noArbitraryCanonical = {
|
|
|
88
157
|
// config object can be passed as rule options without schema errors (#42).
|
|
89
158
|
// Honored by this rule (via suggestCanonical): customTextTokens,
|
|
90
159
|
// customSpacingTokens, ignorePatterns, tailwindVersion.
|
|
91
|
-
//
|
|
92
|
-
//
|
|
160
|
+
// functionNames and attributeNames restrict class string detection.
|
|
161
|
+
// Other CLI-only keys are accepted for shared config objects.
|
|
93
162
|
schema: [
|
|
94
163
|
{
|
|
95
164
|
type: 'object',
|
|
@@ -98,9 +167,18 @@ const noArbitraryCanonical = {
|
|
|
98
167
|
customSpacingTokens: { type: 'object' },
|
|
99
168
|
ignorePatterns: { type: 'array' },
|
|
100
169
|
tailwindVersion: { enum: [3, 4] },
|
|
170
|
+
useDefaultTheme: { type: 'boolean' },
|
|
101
171
|
functionNames: { type: 'array', items: { type: 'string' } },
|
|
102
172
|
attributeNames: { type: 'array', items: { type: 'string' } },
|
|
103
173
|
sortOrder: { type: 'array', items: { type: 'string' } },
|
|
174
|
+
extraColorFamilies: { type: 'object' },
|
|
175
|
+
extraScaleProperties: { type: 'array', items: { type: 'string' } },
|
|
176
|
+
extraColors: { type: 'array', items: { type: 'string' } },
|
|
177
|
+
analyze: { type: 'object' },
|
|
178
|
+
minRareScalePropertyOccurrences: { type: 'number' },
|
|
179
|
+
rareScaleMaxFiles: { type: 'number' },
|
|
180
|
+
rareScaleMaxCount: { type: 'number' },
|
|
181
|
+
defaultCommand: { type: 'object' },
|
|
104
182
|
},
|
|
105
183
|
additionalProperties: false,
|
|
106
184
|
},
|
|
@@ -110,7 +188,7 @@ const noArbitraryCanonical = {
|
|
|
110
188
|
},
|
|
111
189
|
},
|
|
112
190
|
create(context) {
|
|
113
|
-
const config = context.options[0]
|
|
191
|
+
const config = validateConfig(context.options[0], 'ESLint rule options');
|
|
114
192
|
function checkLiteral(node) {
|
|
115
193
|
if (typeof node.value !== 'string')
|
|
116
194
|
return;
|
|
@@ -133,7 +211,7 @@ const noArbitraryCanonical = {
|
|
|
133
211
|
fix: (fixer) => replaceString(fixer, node, corrected),
|
|
134
212
|
});
|
|
135
213
|
}
|
|
136
|
-
return makeStringRuleVisitor(checkLiteral);
|
|
214
|
+
return makeStringRuleVisitor(checkLiteral, config);
|
|
137
215
|
},
|
|
138
216
|
};
|
|
139
217
|
function loadTwMerge() {
|
|
@@ -141,26 +219,25 @@ function loadTwMerge() {
|
|
|
141
219
|
return _require('tailwind-merge').twMerge;
|
|
142
220
|
}
|
|
143
221
|
catch {
|
|
144
|
-
|
|
222
|
+
throw new Error('The no-conflicting-classes rule requires the optional peer dependency "tailwind-merge". Install it with: pnpm add -D tailwind-merge');
|
|
145
223
|
}
|
|
146
224
|
}
|
|
147
225
|
const noConflictingClasses = {
|
|
148
226
|
meta: {
|
|
149
227
|
type: 'suggestion',
|
|
150
228
|
fixable: 'code',
|
|
151
|
-
schema: [],
|
|
229
|
+
schema: noArbitraryCanonical.meta?.schema ?? [],
|
|
152
230
|
messages: {
|
|
153
231
|
conflicting: "Conflicting Tailwind classes detected. Use '{{merged}}' instead.",
|
|
154
232
|
},
|
|
155
233
|
},
|
|
156
234
|
create(context) {
|
|
157
235
|
const twMerge = loadTwMerge();
|
|
236
|
+
const config = validateConfig(context.options[0], 'ESLint rule options');
|
|
158
237
|
function checkLiteral(node) {
|
|
159
|
-
if (!twMerge)
|
|
160
|
-
return;
|
|
161
238
|
if (typeof node.value !== 'string')
|
|
162
239
|
return;
|
|
163
|
-
const merged =
|
|
240
|
+
const merged = mergeClasses(node.value, twMerge, config);
|
|
164
241
|
if (merged === node.value)
|
|
165
242
|
return;
|
|
166
243
|
context.report({
|
|
@@ -169,7 +246,7 @@ const noConflictingClasses = {
|
|
|
169
246
|
fix: (fixer) => replaceString(fixer, node, merged),
|
|
170
247
|
});
|
|
171
248
|
}
|
|
172
|
-
return makeStringRuleVisitor(checkLiteral);
|
|
249
|
+
return makeStringRuleVisitor(checkLiteral, config);
|
|
173
250
|
},
|
|
174
251
|
};
|
|
175
252
|
const plugin = {
|
package/dist/io/config.d.ts
CHANGED
|
@@ -8,4 +8,7 @@ import type { Config } from '../core/rules.js';
|
|
|
8
8
|
* nothing.
|
|
9
9
|
*/
|
|
10
10
|
export declare function resolveInitFilename(cwd: string): string;
|
|
11
|
-
export declare function
|
|
11
|
+
export declare function findConfigPath(cwd: string): string | undefined;
|
|
12
|
+
export declare function loadConfig(cwd: string, options?: {
|
|
13
|
+
reload?: boolean;
|
|
14
|
+
}): Promise<Config>;
|
package/dist/io/config.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
2
4
|
import { basename, dirname, join } from 'node:path';
|
|
3
5
|
import { pathToFileURL } from 'node:url';
|
|
4
6
|
import { CONFIG_FILENAMES, validateConfig } from '../core/config.js';
|
|
@@ -51,7 +53,7 @@ export function resolveInitFilename(cwd) {
|
|
|
51
53
|
? 'tailwind-canonical.config.js'
|
|
52
54
|
: 'tailwind-canonical.config.mjs';
|
|
53
55
|
}
|
|
54
|
-
function findConfigPath(cwd) {
|
|
56
|
+
export function findConfigPath(cwd) {
|
|
55
57
|
let dir = cwd;
|
|
56
58
|
while (true) {
|
|
57
59
|
const filename = CONFIG_FILENAMES.find((name) => existsSync(join(dir, name)));
|
|
@@ -65,14 +67,50 @@ function findConfigPath(cwd) {
|
|
|
65
67
|
dir = parent;
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
|
-
|
|
70
|
+
const reloadCache = new Map();
|
|
71
|
+
export async function loadConfig(cwd, options = {}) {
|
|
69
72
|
const path = findConfigPath(cwd);
|
|
70
73
|
if (!path)
|
|
71
74
|
return {};
|
|
72
75
|
const filename = basename(path);
|
|
73
76
|
let mod;
|
|
77
|
+
let hash;
|
|
74
78
|
try {
|
|
75
|
-
|
|
79
|
+
const url = pathToFileURL(path);
|
|
80
|
+
if (options.reload) {
|
|
81
|
+
hash = createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
82
|
+
const cached = reloadCache.get(path);
|
|
83
|
+
if (cached?.hash === hash)
|
|
84
|
+
return cached.config;
|
|
85
|
+
// Content-based URLs avoid another ESM instance on unchanged source saves.
|
|
86
|
+
url.searchParams.set('version', hash);
|
|
87
|
+
}
|
|
88
|
+
if (options.reload &&
|
|
89
|
+
/\.(?:js|ts)$/.test(filename) &&
|
|
90
|
+
nearestPackageType(dirname(path)) === 'commonjs') {
|
|
91
|
+
// CJS has an additional require cache: query parameters only invalidate
|
|
92
|
+
// its ESM namespace wrapper. Read it via require after clearing that cache.
|
|
93
|
+
const require = createRequire(import.meta.url);
|
|
94
|
+
delete require.cache[require.resolve(path)];
|
|
95
|
+
try {
|
|
96
|
+
const loaded = require(path);
|
|
97
|
+
mod =
|
|
98
|
+
typeof loaded === 'object' &&
|
|
99
|
+
loaded !== null &&
|
|
100
|
+
'__esModule' in loaded &&
|
|
101
|
+
'default' in loaded
|
|
102
|
+
? loaded
|
|
103
|
+
: { default: loaded };
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!['ERR_REQUIRE_ESM', 'ERR_REQUIRE_ASYNC_MODULE'].includes(error.code ?? ''))
|
|
107
|
+
throw error;
|
|
108
|
+
mod = await import(url.href);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
mod = await import(url.href);
|
|
113
|
+
}
|
|
76
114
|
}
|
|
77
115
|
catch (err) {
|
|
78
116
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -95,5 +133,8 @@ export async function loadConfig(cwd) {
|
|
|
95
133
|
}
|
|
96
134
|
throw err;
|
|
97
135
|
}
|
|
98
|
-
|
|
136
|
+
const config = validateConfig(mod.default, filename);
|
|
137
|
+
if (hash)
|
|
138
|
+
reloadCache.set(path, { hash, config });
|
|
139
|
+
return config;
|
|
99
140
|
}
|
package/dist/io/consistency.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { toClassStringOpts } from '../core/class-strings.js';
|
|
3
|
-
import { analyzeConsistency,
|
|
2
|
+
import { extractClassStrings, SINGLE_CLASS_REGEX, toClassStringOpts, } from '../core/class-strings.js';
|
|
3
|
+
import { analyzeConsistency, } from '../core/consistency.js';
|
|
4
4
|
export function analyzeConsistencyFiles(filePaths, config = {}, options = {}, onError) {
|
|
5
5
|
const opts = toClassStringOpts(config);
|
|
6
6
|
const input = [];
|
|
7
7
|
for (const file of filePaths) {
|
|
8
8
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
classes: collectClasses(readFileSync(file, 'utf8'), opts),
|
|
12
|
-
});
|
|
9
|
+
const classLists = extractClassStrings(readFileSync(file, 'utf8'), opts).map(({ value }) => [...value.matchAll(SINGLE_CLASS_REGEX)].map((match) => match[0]));
|
|
10
|
+
input.push({ file, classes: classLists.flat(), classLists });
|
|
13
11
|
}
|
|
14
12
|
catch (err) {
|
|
15
13
|
if (onError)
|