tailwind-canonical 0.7.0 → 0.7.1
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 +12 -3
- package/dist/cli/cli.d.ts +2 -11
- package/dist/cli/cli.js +176 -471
- package/dist/cli/format.d.ts +3 -0
- package/dist/cli/format.js +11 -0
- package/dist/cli/reporters.d.ts +69 -0
- package/dist/cli/reporters.js +333 -0
- package/dist/cli/types.d.ts +16 -0
- package/dist/cli/types.js +1 -0
- package/dist/cli/watch.d.ts +11 -0
- package/dist/cli/watch.js +33 -0
- package/dist/core/analyzer.js +3 -2
- package/dist/core/class-strings.js +13 -7
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +25 -42
- package/dist/core/consistency.d.ts +1 -1
- package/dist/core/deduplicator.js +110 -170
- package/dist/core/merger.js +9 -3
- package/dist/core/rules.js +38 -60
- package/dist/core/scanner.js +23 -20
- package/dist/core/sorter.js +80 -7
- package/dist/core/suppressions.d.ts +1 -2
- package/dist/core/suppressions.js +17 -13
- package/dist/core/typos.js +3 -2
- package/dist/eslint/plugin.js +62 -28
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
package/dist/core/scanner.js
CHANGED
|
@@ -17,6 +17,15 @@ export const DEFAULT_IGNORE = [
|
|
|
17
17
|
export function isGlob(target) {
|
|
18
18
|
return target.includes('*') || target.includes('?') || target.includes('{');
|
|
19
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
|
+
}
|
|
20
29
|
export function globToRegex(pattern) {
|
|
21
30
|
let i = 0;
|
|
22
31
|
const anchored = pattern.startsWith('/') || /^[A-Za-z]:/.test(pattern);
|
|
@@ -24,10 +33,14 @@ export function globToRegex(pattern) {
|
|
|
24
33
|
while (i < pattern.length) {
|
|
25
34
|
const c = pattern[i];
|
|
26
35
|
if (c === '*' && pattern[i + 1] === '*') {
|
|
27
|
-
re += '.*';
|
|
28
36
|
i += 2;
|
|
29
|
-
if (pattern[i] === '/')
|
|
37
|
+
if (pattern[i] === '/') {
|
|
38
|
+
re += '(?:[^/]+/)*';
|
|
30
39
|
i++;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
re += '.*';
|
|
43
|
+
}
|
|
31
44
|
}
|
|
32
45
|
else if (c === '*') {
|
|
33
46
|
re += '[^/]*';
|
|
@@ -37,27 +50,17 @@ export function globToRegex(pattern) {
|
|
|
37
50
|
re += '[^/]';
|
|
38
51
|
i++;
|
|
39
52
|
}
|
|
40
|
-
else if (c === '{') {
|
|
53
|
+
else if (c === '{' && pattern.indexOf('}', i) !== -1) {
|
|
41
54
|
const close = pattern.indexOf('}', i);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.slice(i + 1, close)
|
|
49
|
-
.split(',')
|
|
50
|
-
.map((a) => a.replace(/[.+^${}()|[\]\\]/g, '\\$&'));
|
|
51
|
-
re += `(?:${alts.join('|')})`;
|
|
52
|
-
i = close + 1;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
else if (/[.+^${}()|[\]\\]/.test(c)) {
|
|
56
|
-
re += `\\${c}`;
|
|
57
|
-
i++;
|
|
55
|
+
const alts = pattern
|
|
56
|
+
.slice(i + 1, close)
|
|
57
|
+
.split(',')
|
|
58
|
+
.map(wildcardToRegex);
|
|
59
|
+
re += `(?:${alts.join('|')})`;
|
|
60
|
+
i = close + 1;
|
|
58
61
|
}
|
|
59
62
|
else {
|
|
60
|
-
re += c;
|
|
63
|
+
re += escapeRegex(c);
|
|
61
64
|
i++;
|
|
62
65
|
}
|
|
63
66
|
}
|
package/dist/core/sorter.js
CHANGED
|
@@ -135,11 +135,61 @@ function getVariantOrder(cls) {
|
|
|
135
135
|
return 2;
|
|
136
136
|
return 3;
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
const RE_ARBITRARY = /\[[^\]]*\]/g;
|
|
139
|
+
const RE_IMPLAUSIBLE_CLASS = /^:|:$|["'`{}?]|^[&|]+$/;
|
|
140
|
+
function isPlausibleClass(token) {
|
|
141
|
+
return !RE_IMPLAUSIBLE_CLASS.test(token.replace(RE_ARBITRARY, '[]'));
|
|
142
|
+
}
|
|
143
|
+
function interpolationEnd(str, from) {
|
|
144
|
+
let depth = 0;
|
|
145
|
+
let quote = null;
|
|
146
|
+
for (let j = from + 1; j < str.length; j++) {
|
|
147
|
+
const ch = str[j];
|
|
148
|
+
if (quote) {
|
|
149
|
+
if (ch === '\\')
|
|
150
|
+
j++;
|
|
151
|
+
else if (ch === quote)
|
|
152
|
+
quote = null;
|
|
153
|
+
}
|
|
154
|
+
else if (ch === '"' || ch === "'" || ch === '`')
|
|
155
|
+
quote = ch;
|
|
156
|
+
else if (ch === '{')
|
|
157
|
+
depth++;
|
|
158
|
+
else if (ch === '}' && --depth === 0)
|
|
159
|
+
return j + 1;
|
|
160
|
+
}
|
|
161
|
+
return str.length;
|
|
162
|
+
}
|
|
163
|
+
function splitAtoms(str) {
|
|
164
|
+
const atoms = [];
|
|
165
|
+
let i = 0;
|
|
166
|
+
while (i < str.length) {
|
|
167
|
+
if (/\s/.test(str[i])) {
|
|
168
|
+
i++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
let j = i;
|
|
172
|
+
let interpolated = false;
|
|
173
|
+
while (j < str.length && !/\s/.test(str[j])) {
|
|
174
|
+
if (str.startsWith('${', j)) {
|
|
175
|
+
interpolated = true;
|
|
176
|
+
j = interpolationEnd(str, j);
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
j++;
|
|
180
|
+
}
|
|
181
|
+
const text = str.slice(i, j);
|
|
182
|
+
atoms.push({
|
|
183
|
+
text,
|
|
184
|
+
start: i,
|
|
185
|
+
end: j,
|
|
186
|
+
fixed: interpolated || !isPlausibleClass(text),
|
|
187
|
+
});
|
|
188
|
+
i = j;
|
|
189
|
+
}
|
|
190
|
+
return atoms;
|
|
191
|
+
}
|
|
192
|
+
function sortSegment(classes, rank) {
|
|
143
193
|
return classes
|
|
144
194
|
.map((cls, i) => ({
|
|
145
195
|
cls,
|
|
@@ -156,8 +206,31 @@ export function sortClasses(classStr, sortOrder) {
|
|
|
156
206
|
return cd;
|
|
157
207
|
return a.i - b.i;
|
|
158
208
|
})
|
|
159
|
-
.map((x) => x.cls)
|
|
160
|
-
|
|
209
|
+
.map((x) => x.cls);
|
|
210
|
+
}
|
|
211
|
+
export function sortClasses(classStr, sortOrder) {
|
|
212
|
+
const atoms = splitAtoms(classStr);
|
|
213
|
+
if (atoms.length <= 1)
|
|
214
|
+
return classStr;
|
|
215
|
+
const rank = buildRank(sortOrder?.length ? sortOrder : DEFAULT_SORT_ORDER);
|
|
216
|
+
if (!atoms.some((a) => a.fixed))
|
|
217
|
+
return sortSegment(atoms.map((a) => a.text), rank).join(' ');
|
|
218
|
+
const texts = atoms.map((a) => a.text);
|
|
219
|
+
let start = 0;
|
|
220
|
+
for (let k = 0; k <= atoms.length; k++) {
|
|
221
|
+
if (k < atoms.length && !atoms[k].fixed)
|
|
222
|
+
continue;
|
|
223
|
+
const sorted = sortSegment(texts.slice(start, k), rank);
|
|
224
|
+
texts.splice(start, sorted.length, ...sorted);
|
|
225
|
+
start = k + 1;
|
|
226
|
+
}
|
|
227
|
+
let out = '';
|
|
228
|
+
let cursor = 0;
|
|
229
|
+
atoms.forEach((atom, k) => {
|
|
230
|
+
out += classStr.slice(cursor, atom.start) + texts[k];
|
|
231
|
+
cursor = atom.end;
|
|
232
|
+
});
|
|
233
|
+
return out + classStr.slice(cursor);
|
|
161
234
|
}
|
|
162
235
|
export function sortContent(content, opts = {}, sortOrder) {
|
|
163
236
|
return replaceClassStrings(content, (s) => sortClasses(s, sortOrder), {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export declare function getSuppressedLines(content: string): Set<number>;
|
|
2
2
|
export declare function makeLineSuppressor(content: string): (line: number) => boolean;
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function createPositionLookup(content: string): (index: number) => {
|
|
4
4
|
line: number;
|
|
5
5
|
col: number;
|
|
6
6
|
};
|
|
7
|
-
export declare function lineAt(content: string, offset: number): number;
|
|
@@ -26,18 +26,22 @@ export function makeLineSuppressor(content) {
|
|
|
26
26
|
const suppressed = getSuppressedLines(content);
|
|
27
27
|
return (line) => suppressed.has(line);
|
|
28
28
|
}
|
|
29
|
-
export function
|
|
30
|
-
|
|
31
|
-
let
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (content[i] === '\n') {
|
|
35
|
-
line++;
|
|
36
|
-
lastNewline = i;
|
|
37
|
-
}
|
|
29
|
+
export function createPositionLookup(content) {
|
|
30
|
+
const lineStarts = [0];
|
|
31
|
+
for (let i = 0; i < content.length; i++) {
|
|
32
|
+
if (content[i] === '\n')
|
|
33
|
+
lineStarts.push(i + 1);
|
|
38
34
|
}
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
return (index) => {
|
|
36
|
+
let low = 0;
|
|
37
|
+
let high = lineStarts.length;
|
|
38
|
+
while (low + 1 < high) {
|
|
39
|
+
const mid = Math.floor((low + high) / 2);
|
|
40
|
+
if (lineStarts[mid] <= index)
|
|
41
|
+
low = mid;
|
|
42
|
+
else
|
|
43
|
+
high = mid;
|
|
44
|
+
}
|
|
45
|
+
return { line: low + 1, col: index - lineStarts[low] + 1 };
|
|
46
|
+
};
|
|
43
47
|
}
|
package/dist/core/typos.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { extractClassStrings, SINGLE_CLASS_REGEX, toClassStringOpts, } from './class-strings.js';
|
|
2
2
|
import { parseColorClass, TAILWIND_COLORS } from './lexicon.js';
|
|
3
|
-
import {
|
|
3
|
+
import { createPositionLookup, getSuppressedLines } from './suppressions.js';
|
|
4
4
|
const MIN_COLOR_LEN = 3;
|
|
5
5
|
function levenshtein(a, b) {
|
|
6
6
|
const m = a.length;
|
|
@@ -45,6 +45,7 @@ export function detectTypo(cls, extraColors = new Set()) {
|
|
|
45
45
|
export function analyzeTyposContent(filePath, content, config = {}) {
|
|
46
46
|
const findings = [];
|
|
47
47
|
const suppressed = getSuppressedLines(content);
|
|
48
|
+
const positionAt = createPositionLookup(content);
|
|
48
49
|
const opts = toClassStringOpts(config);
|
|
49
50
|
const extraColors = new Set(config.extraColors ?? []);
|
|
50
51
|
for (const { value, start } of extractClassStrings(content, opts)) {
|
|
@@ -53,7 +54,7 @@ export function analyzeTyposContent(filePath, content, config = {}) {
|
|
|
53
54
|
if (!typo)
|
|
54
55
|
continue;
|
|
55
56
|
const index = start + (clsMatch.index ?? 0);
|
|
56
|
-
const { line, col } =
|
|
57
|
+
const { line, col } = positionAt(index);
|
|
57
58
|
if (suppressed.has(line))
|
|
58
59
|
continue;
|
|
59
60
|
findings.push({
|
package/dist/eslint/plugin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { suggestCanonical } from '../core/rules.js';
|
|
2
|
+
import { suggestCanonical, } from '../core/rules.js';
|
|
3
3
|
const _require = createRequire(import.meta.url);
|
|
4
4
|
/**
|
|
5
5
|
* Inner-text range of a `TemplateElement`. In espree, `quasi.range` spans the
|
|
@@ -24,14 +24,20 @@ function makeStringRuleVisitor(checkLiteral) {
|
|
|
24
24
|
return {
|
|
25
25
|
Literal: (node) => checkLiteral(node),
|
|
26
26
|
TemplateLiteral(node) {
|
|
27
|
+
const isTagged = node.parent
|
|
28
|
+
?.type === 'TaggedTemplateExpression';
|
|
27
29
|
for (const quasi of node.quasis) {
|
|
30
|
+
const value = isTagged
|
|
31
|
+
? quasi.value.raw
|
|
32
|
+
: (quasi.value.cooked ?? quasi.value.raw);
|
|
28
33
|
checkLiteral({
|
|
29
34
|
type: 'Literal',
|
|
30
|
-
value
|
|
35
|
+
value,
|
|
31
36
|
raw: quasi.value.raw,
|
|
32
37
|
range: quasi.range,
|
|
33
38
|
loc: quasi.loc,
|
|
34
39
|
quasiRange: quasiTextRange(quasi),
|
|
40
|
+
valueIsRaw: isTagged || quasi.value.cooked === null,
|
|
35
41
|
});
|
|
36
42
|
}
|
|
37
43
|
},
|
|
@@ -39,7 +45,40 @@ function makeStringRuleVisitor(checkLiteral) {
|
|
|
39
45
|
}
|
|
40
46
|
function quoteReplacement(node, value) {
|
|
41
47
|
const quote = node.raw?.startsWith('"') ? '"' : "'";
|
|
42
|
-
|
|
48
|
+
if (node.parent?.type === 'JSXAttribute') {
|
|
49
|
+
return `${quote}${escapeJsxAttribute(value, quote)}${quote}`;
|
|
50
|
+
}
|
|
51
|
+
return `${quote}${escapeString(value, quote)}${quote}`;
|
|
52
|
+
}
|
|
53
|
+
function escapeString(value, quote) {
|
|
54
|
+
const escaped = JSON.stringify(value).slice(1, -1);
|
|
55
|
+
return quote === '"'
|
|
56
|
+
? escaped
|
|
57
|
+
: escaped.replace(/\\"/g, '"').replace(/'/g, "\\'");
|
|
58
|
+
}
|
|
59
|
+
function escapeJsxAttribute(value, quote) {
|
|
60
|
+
return value
|
|
61
|
+
.replace(/&/g, '&')
|
|
62
|
+
.replace(quote === '"' ? /"/g : /'/g, quote === '"' ? '"' : ''');
|
|
63
|
+
}
|
|
64
|
+
function escapeTemplateText(value) {
|
|
65
|
+
return value
|
|
66
|
+
.replace(/\\/g, '\\\\')
|
|
67
|
+
.replace(/`/g, '\\`')
|
|
68
|
+
.replace(/\$\{/g, '\\${')
|
|
69
|
+
.replace(/\r/g, '\\r');
|
|
70
|
+
}
|
|
71
|
+
function replacementText(node, value) {
|
|
72
|
+
if (node.quasiRange) {
|
|
73
|
+
return node.valueIsRaw ? value : escapeTemplateText(value);
|
|
74
|
+
}
|
|
75
|
+
return quoteReplacement(node, value);
|
|
76
|
+
}
|
|
77
|
+
function replaceString(fixer, node, value) {
|
|
78
|
+
const text = replacementText(node, value);
|
|
79
|
+
return node.quasiRange
|
|
80
|
+
? fixer.replaceTextRange(node.quasiRange, text)
|
|
81
|
+
: fixer.replaceText(node, text);
|
|
43
82
|
}
|
|
44
83
|
const noArbitraryCanonical = {
|
|
45
84
|
meta: {
|
|
@@ -75,29 +114,36 @@ const noArbitraryCanonical = {
|
|
|
75
114
|
function checkLiteral(node) {
|
|
76
115
|
if (typeof node.value !== 'string')
|
|
77
116
|
return;
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
117
|
+
const suggestions = [];
|
|
118
|
+
const corrected = node.value.replace(/\S+/g, (token) => {
|
|
119
|
+
const suggestion = suggestCanonical(token, config);
|
|
120
|
+
if (!suggestion)
|
|
121
|
+
return token;
|
|
122
|
+
suggestions.push(suggestion);
|
|
123
|
+
return suggestion.canonical;
|
|
124
|
+
});
|
|
82
125
|
if (suggestions.length === 0)
|
|
83
126
|
return;
|
|
84
|
-
const corrected = value.replace(/\S+/g, (token) => suggestCanonical(token, config)?.canonical ?? token);
|
|
85
127
|
const message = suggestions.length === 1
|
|
86
128
|
? `Use canonical class '${suggestions[0].canonical}' instead of '${suggestions[0].original}'`
|
|
87
129
|
: `Use canonical classes '${suggestions.map((s) => s.canonical).join(' ')}' instead of '${suggestions.map((s) => s.original).join(' ')}'`;
|
|
88
130
|
context.report({
|
|
89
131
|
node,
|
|
90
132
|
message,
|
|
91
|
-
fix(fixer)
|
|
92
|
-
if (node.quasiRange)
|
|
93
|
-
return fixer.replaceTextRange(node.quasiRange, corrected);
|
|
94
|
-
return fixer.replaceText(node, quoteReplacement(node, corrected));
|
|
95
|
-
},
|
|
133
|
+
fix: (fixer) => replaceString(fixer, node, corrected),
|
|
96
134
|
});
|
|
97
135
|
}
|
|
98
136
|
return makeStringRuleVisitor(checkLiteral);
|
|
99
137
|
},
|
|
100
138
|
};
|
|
139
|
+
function loadTwMerge() {
|
|
140
|
+
try {
|
|
141
|
+
return _require('tailwind-merge').twMerge;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
101
147
|
const noConflictingClasses = {
|
|
102
148
|
meta: {
|
|
103
149
|
type: 'suggestion',
|
|
@@ -108,17 +154,9 @@ const noConflictingClasses = {
|
|
|
108
154
|
},
|
|
109
155
|
},
|
|
110
156
|
create(context) {
|
|
111
|
-
|
|
112
|
-
let peerMissing = false;
|
|
113
|
-
try {
|
|
114
|
-
const mod = _require('tailwind-merge');
|
|
115
|
-
twMerge = mod.twMerge;
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
peerMissing = true;
|
|
119
|
-
}
|
|
157
|
+
const twMerge = loadTwMerge();
|
|
120
158
|
function checkLiteral(node) {
|
|
121
|
-
if (
|
|
159
|
+
if (!twMerge)
|
|
122
160
|
return;
|
|
123
161
|
if (typeof node.value !== 'string')
|
|
124
162
|
return;
|
|
@@ -128,11 +166,7 @@ const noConflictingClasses = {
|
|
|
128
166
|
context.report({
|
|
129
167
|
node,
|
|
130
168
|
message: `Conflicting Tailwind classes detected. Use '${merged}' instead.`,
|
|
131
|
-
fix(fixer)
|
|
132
|
-
if (node.quasiRange)
|
|
133
|
-
return fixer.replaceTextRange(node.quasiRange, merged);
|
|
134
|
-
return fixer.replaceText(node, quoteReplacement(node, merged));
|
|
135
|
-
},
|
|
169
|
+
fix: (fixer) => replaceString(fixer, node, merged),
|
|
136
170
|
});
|
|
137
171
|
}
|
|
138
172
|
return makeStringRuleVisitor(checkLiteral);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { Finding } from './core/analyzer.js';
|
|
2
2
|
export { analyzeFile } from './core/analyzer.js';
|
|
3
3
|
export { loadConfig } from './core/config.js';
|
|
4
|
-
export type { ClassCombination, ColorVariant, ColorVariantGroup, ConsistencyOptions, ConsistencyReport, FileClasses, ScaleInconsistency, ScaleValue, } from './core/consistency.js';
|
|
4
|
+
export type { ClassCombination, ColorVariant, ColorVariantGroup, ConsistencyOptions, ConsistencyReport, FileClasses, RareScaleValue, ScaleInconsistency, ScaleValue, } from './core/consistency.js';
|
|
5
5
|
export { analyzeConsistency } from './core/consistency.js';
|
|
6
6
|
export { dedupeFile, deduplicateClasses } from './core/deduplicator.js';
|
|
7
7
|
export { fixFile } from './core/fixer.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// This barrel is the package's stable surface. Internal plumbing
|
|
4
4
|
// (class-string extraction, suppression predicates, config validation,
|
|
5
5
|
// file-class collection, consistency file readers) is intentionally NOT
|
|
6
|
-
//
|
|
6
|
+
// exposed through package exports.
|
|
7
7
|
export { analyzeFile } from './core/analyzer.js';
|
|
8
8
|
// Config loading
|
|
9
9
|
export { loadConfig } from './core/config.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-canonical",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Lint and auto-fix Tailwind CSS arbitrary values that have canonical equivalents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"lefthook": "^2.1.12",
|
|
62
62
|
"tailwind-merge": "^3.6.0",
|
|
63
63
|
"tsx": "^4.23.13",
|
|
64
|
-
"typescript": "^
|
|
64
|
+
"typescript": "^7.0.2"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"eslint": ">=8.0.0",
|