stylelint-plugin-rhythmguard 3.10.2 → 3.11.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/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/codemod.js +19 -8
- package/src/audit/config.js +17 -2
- package/src/audit/report.js +2 -6
- package/src/audit/scan/files.js +36 -21
- package/src/audit/scan/stylesheets.js +20 -0
- package/src/cli/audit.js +7 -3
- package/src/cli/fix.js +50 -6
- package/src/core/length.js +15 -0
- package/src/core/options.js +1 -0
- package/src/core/tailwind-class-analysis.js +2 -2
- package/src/core/token-index.js +73 -24
- package/src/core/token-map.js +23 -10
- package/src/eslint/literal-spans.js +234 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +9 -32
- package/src/rules/no-offscale-transform/index.js +3 -2
- package/src/rules/prefer-token/index.js +33 -30
- package/src/rules/report.js +3 -1
- package/src/rules/use-scale/index.js +4 -3
package/src/core/token-index.js
CHANGED
|
@@ -35,24 +35,42 @@ function tokenFitsProperty(token, prop) {
|
|
|
35
35
|
return !group || !kind || ACCEPTED_KINDS[group].includes(kind);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The lengths one definition holds, each with its signed px value, or null
|
|
40
|
+
* when the token holds more than one value. A token redefined under a media
|
|
41
|
+
* query, a theme or a data attribute (`--space-3: 12px` at the root and 20px
|
|
42
|
+
* from 768px up) renders differently there, so no single length stands for it
|
|
43
|
+
* (issue #231). A value that is not a plain length counts as another value.
|
|
44
|
+
*/
|
|
45
|
+
function singleValuedLengths(definition, baseFontSize) {
|
|
46
|
+
const lengths = [];
|
|
47
|
+
for (const raw of definition.values) {
|
|
48
|
+
const parsed = parseTokenValueLength(String(raw).trim());
|
|
49
|
+
if (!parsed) return definition.values.size > 1 ? null : [];
|
|
50
|
+
const unit = parsed.unit || 'px';
|
|
51
|
+
const px = toPx(parsed.number, unit, baseFontSize);
|
|
52
|
+
if (px === null || !Number.isFinite(px)) return definition.values.size > 1 ? null : [];
|
|
53
|
+
lengths.push({ px, raw: String(raw).trim(), unit });
|
|
54
|
+
}
|
|
55
|
+
return lengths.every((length) => numbersEqual(length.px, lengths[0].px)) ? lengths : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
/**
|
|
39
59
|
* Which token a fix may write for a length. Strict on purpose: the token must
|
|
40
|
-
* hold the same px value in the same unit as the literal
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* that
|
|
60
|
+
* hold the same px value, with the same sign, in the same unit as the literal
|
|
61
|
+
* being fixed, it must hold that one value everywhere, it must be a custom
|
|
62
|
+
* property (a Sass variable is not valid in CSS output), and it must be the
|
|
63
|
+
* only candidate. A fix that guesses a token is worse than a fix that writes
|
|
64
|
+
* a number, so anything ambiguous falls back to the literal.
|
|
44
65
|
*/
|
|
45
66
|
function tokenIndexFromDefinitions(definitions, baseFontSize = 16) {
|
|
46
67
|
const index = [];
|
|
47
68
|
for (const definition of definitions.values()) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
const unit = parsed.unit || 'px';
|
|
52
|
-
const px = toPx(Math.abs(parsed.number), unit, baseFontSize);
|
|
53
|
-
if (px === null || !Number.isFinite(px) || px === 0) continue;
|
|
69
|
+
const lengths = singleValuedLengths(definition, baseFontSize);
|
|
70
|
+
for (const { px, raw, unit } of lengths || []) {
|
|
71
|
+
if (px === 0) continue;
|
|
54
72
|
const file = definition.files ? [...definition.files][0] : null;
|
|
55
|
-
index.push({ file: file && file !== 'stylesheet' ? file : null, px, raw
|
|
73
|
+
index.push({ file: file && file !== 'stylesheet' ? file : null, px, raw, token: definition.token, unit });
|
|
56
74
|
}
|
|
57
75
|
}
|
|
58
76
|
return index;
|
|
@@ -61,16 +79,17 @@ function tokenIndexFromDefinitions(definitions, baseFontSize = 16) {
|
|
|
61
79
|
/**
|
|
62
80
|
* Every custom property that holds a length, for one property. The shared
|
|
63
81
|
* resolver behind use-scale's `fixWith: "token"` and prefer-token, so the two
|
|
64
|
-
* rules always know the same tokens.
|
|
65
|
-
*
|
|
66
|
-
* unit
|
|
82
|
+
* rules always know the same tokens. The px value is signed: 8 finds the
|
|
83
|
+
* tokens that hold 8px, -8 the tokens that hold -8px. Same unit first; with
|
|
84
|
+
* `anyUnit`, a token in another unit that holds the same px value counts when
|
|
85
|
+
* none matches the unit. Sorted, so messages are stable.
|
|
67
86
|
*/
|
|
68
87
|
function tokensForLength(index, px, unit, { anyUnit = false, prop = null } = {}) {
|
|
69
88
|
const wanted = unit || 'px';
|
|
70
89
|
const sameUnit = new Set();
|
|
71
90
|
const otherUnit = new Set();
|
|
72
91
|
for (const entry of index) {
|
|
73
|
-
if (!entry.token.startsWith('--') || !numbersEqual(entry.px,
|
|
92
|
+
if (!entry.token.startsWith('--') || !numbersEqual(entry.px, px) || !tokenFitsProperty(entry.token, prop)) {
|
|
74
93
|
continue;
|
|
75
94
|
}
|
|
76
95
|
(entry.unit === wanted ? sameUnit : otherUnit).add(entry.token);
|
|
@@ -79,6 +98,22 @@ function tokensForLength(index, px, unit, { anyUnit = false, prop = null } = {})
|
|
|
79
98
|
return [...found].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
80
99
|
}
|
|
81
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The tokens for a signed length, and whether a fix negates them. A negative
|
|
103
|
+
* value takes the tokens that hold it, written as they are, and otherwise the
|
|
104
|
+
* tokens that hold its magnitude, negated. A positive value never gets a
|
|
105
|
+
* negative token (issue #221).
|
|
106
|
+
*/
|
|
107
|
+
function signedTokensForLength(index, px, unit, options = {}) {
|
|
108
|
+
if (px < 0) {
|
|
109
|
+
const negative = tokensForLength(index, px, unit, options);
|
|
110
|
+
if (negative.length > 0) {
|
|
111
|
+
return { negate: false, tokens: negative };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { negate: px < 0, tokens: tokensForLength(index, Math.abs(px), unit, options) };
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
function tokenForLength(index, px, unit, prop = null) {
|
|
83
118
|
const candidates = tokensForLength(index, px, unit, { prop });
|
|
84
119
|
return candidates.length === 1 ? `var(${candidates[0]})` : null;
|
|
@@ -97,30 +132,44 @@ function negateReplacement(replacement) {
|
|
|
97
132
|
|
|
98
133
|
/**
|
|
99
134
|
* The replacement text a length rule writes: the matching token when the
|
|
100
|
-
* options ask for one and exactly one qualifies, otherwise the literal.
|
|
135
|
+
* options ask for one and exactly one qualifies, otherwise the literal. The
|
|
136
|
+
* nearest value is in px, or in the literal's unit with `unitStrategy:
|
|
137
|
+
* "exact"`; tokens are indexed in px, so the lookup converts it (issue #223).
|
|
101
138
|
*/
|
|
102
|
-
function replacementFor(parsedLength,
|
|
139
|
+
function replacementFor(parsedLength, nearest, options, prop = null) {
|
|
103
140
|
if (options.fixWith === 'token' && options.tokenIndex) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
const unit = parsedLength.unit || 'px';
|
|
142
|
+
const nearestPx = options.unitStrategy === 'exact' ? toPx(Math.abs(nearest), unit, options.baseFontSize) : Math.abs(nearest);
|
|
143
|
+
if (nearestPx !== null) {
|
|
144
|
+
const signedPx = parsedLength.number < 0 ? -nearestPx : nearestPx;
|
|
145
|
+
const { negate, tokens } = signedTokensForLength(options.tokenIndex, signedPx, unit, { prop });
|
|
146
|
+
if (tokens.length === 1) {
|
|
147
|
+
const token = `var(${tokens[0]})`;
|
|
148
|
+
return negate ? negateReplacement(token) : token;
|
|
149
|
+
}
|
|
107
150
|
}
|
|
108
151
|
}
|
|
109
|
-
return fixedLengthValue(parsedLength,
|
|
152
|
+
return fixedLengthValue(parsedLength, nearest, options);
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
/**
|
|
113
156
|
* The sentence a length rule appends when its fix writes a token: which token
|
|
114
|
-
* holds the snapped value. Empty when the replacement is a literal.
|
|
157
|
+
* holds the snapped value. Empty when the replacement is a literal. A token
|
|
158
|
+
* written as is for a negative literal holds the negative value.
|
|
115
159
|
*/
|
|
116
|
-
function tokenHoldsNote(replacement, formattedNearest) {
|
|
160
|
+
function tokenHoldsNote(replacement, formattedNearest, negativeLiteral = false) {
|
|
117
161
|
const match = typeof replacement === 'string' ? replacement.match(/var\(--[^)]+\)/) : null;
|
|
118
|
-
|
|
162
|
+
if (!match) {
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
const holdsNegative = negativeLiteral && replacement === match[0];
|
|
166
|
+
return `${match[0]} holds ${holdsNegative ? `-${formattedNearest}` : formattedNearest}.`;
|
|
119
167
|
}
|
|
120
168
|
|
|
121
169
|
module.exports = {
|
|
122
170
|
negateReplacement,
|
|
123
171
|
replacementFor,
|
|
172
|
+
signedTokensForLength,
|
|
124
173
|
tokenFitsProperty,
|
|
125
174
|
tokenHoldsNote,
|
|
126
175
|
tokenForLength,
|
package/src/core/token-map.js
CHANGED
|
@@ -71,11 +71,12 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize, ori
|
|
|
71
71
|
origins[token] = origin;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Keyed by the signed value: a -8px token stands for -8px, never for 8px
|
|
75
|
+
// (issue #221). prefer-token negates a positive token for a negative literal.
|
|
76
|
+
const normalizedRaw = formatLength(parsed.number, parsed.unit || 'px');
|
|
76
77
|
map[normalizedRaw] = token;
|
|
77
78
|
|
|
78
|
-
const px = toPx(
|
|
79
|
+
const px = toPx(parsed.number, parsed.unit, baseFontSize);
|
|
79
80
|
if (px !== null) {
|
|
80
81
|
map[`${px}px`] = token;
|
|
81
82
|
}
|
|
@@ -211,23 +212,36 @@ function mergeTokenMapFromCssCustomProperties({
|
|
|
211
212
|
};
|
|
212
213
|
const origin = displayOrigin(root && root.source && root.source.input ? root.source.input.file : null);
|
|
213
214
|
|
|
215
|
+
// Every value each custom property is given in the file. A property given
|
|
216
|
+
// two values (at the root and under a media query, a theme or a data
|
|
217
|
+
// attribute) renders differently there, so no fix writes it (issue #231).
|
|
218
|
+
const valuesByProp = new Map();
|
|
214
219
|
root.walkDecls((decl) => {
|
|
215
220
|
const prop = decl.prop.toLowerCase();
|
|
216
|
-
if (!prop.startsWith('--')) {
|
|
221
|
+
if (!prop.startsWith('--') || !tokenRegex.test(prop)) {
|
|
217
222
|
return;
|
|
218
223
|
}
|
|
224
|
+
const parsed = parseTokenValueLength(decl.value);
|
|
225
|
+
const px = parsed ? toPx(parsed.number, parsed.unit, baseFontSize) : null;
|
|
226
|
+
const key = px === null ? `raw:${decl.value.trim()}` : `px:${formatLength(px, 'px')}`;
|
|
227
|
+
if (!valuesByProp.has(decl.prop)) {
|
|
228
|
+
valuesByProp.set(decl.prop, { decl, keys: new Set() });
|
|
229
|
+
}
|
|
230
|
+
valuesByProp.get(decl.prop).keys.add(key);
|
|
231
|
+
});
|
|
219
232
|
|
|
220
|
-
|
|
221
|
-
|
|
233
|
+
for (const { decl, keys } of valuesByProp.values()) {
|
|
234
|
+
if (keys.size > 1) {
|
|
235
|
+
continue;
|
|
222
236
|
}
|
|
223
237
|
|
|
224
238
|
const parsed = parseTokenValueLength(decl.value);
|
|
225
239
|
if (!parsed || parsed.number === 0) {
|
|
226
|
-
|
|
240
|
+
continue;
|
|
227
241
|
}
|
|
228
242
|
|
|
229
243
|
addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize, origins, origin);
|
|
230
|
-
}
|
|
244
|
+
}
|
|
231
245
|
|
|
232
246
|
return nextMap;
|
|
233
247
|
}
|
|
@@ -375,8 +389,7 @@ function mergeTokenMapFromTailwindSpacing({
|
|
|
375
389
|
continue;
|
|
376
390
|
}
|
|
377
391
|
|
|
378
|
-
const
|
|
379
|
-
const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
|
|
392
|
+
const normalizedRaw = formatLength(parsed.number, parsed.unit || 'px');
|
|
380
393
|
nextMap[normalizedRaw] = `theme(spacing.${key})`;
|
|
381
394
|
if (origins && !origins[nextMap[normalizedRaw]]) {
|
|
382
395
|
origins[nextMap[normalizedRaw]] = displayOrigin(resolvedPath);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps the decoded value of a string literal back to its raw source text, so
|
|
5
|
+
* an autofix can replace one class token in place and leave every escape
|
|
6
|
+
* sequence (`\n`, `\\`, `\'`, `\u00e9`, a line continuation) and every JSX
|
|
7
|
+
* entity (`"`, `A`) around it byte for byte (#225).
|
|
8
|
+
*
|
|
9
|
+
* Each UTF-16 unit of the decoded value gets the raw span that produced it.
|
|
10
|
+
* A token is only replaceable when its raw span is exactly the token text, so
|
|
11
|
+
* a class written with an escape inside it (`p-[13\x70x]`) is never fixed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SINGLE_CHARACTER_ESCAPES = {
|
|
15
|
+
b: '\b',
|
|
16
|
+
f: '\f',
|
|
17
|
+
n: '\n',
|
|
18
|
+
r: '\r',
|
|
19
|
+
t: '\t',
|
|
20
|
+
v: '\v',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const HEX_2 = /^[0-9a-fA-F]{2}$/;
|
|
24
|
+
const HEX_4 = /^[0-9a-fA-F]{4}$/;
|
|
25
|
+
const HEX_ANY = /^[0-9a-fA-F]+$/;
|
|
26
|
+
const OCTAL_DIGIT = /^[0-7]$/;
|
|
27
|
+
const JSX_ENTITY = /^&(?:#[xX]([0-9a-fA-F]+)|#([0-9]+)|[A-Za-z][A-Za-z0-9]*);/;
|
|
28
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
29
|
+
|
|
30
|
+
function decodeJsStringBody(body) {
|
|
31
|
+
const pieces = [];
|
|
32
|
+
let index = 0;
|
|
33
|
+
|
|
34
|
+
while (index < body.length) {
|
|
35
|
+
const char = body[index];
|
|
36
|
+
if (char !== '\\') {
|
|
37
|
+
pieces.push({ end: index + 1, start: index, text: char });
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const next = body[index + 1];
|
|
43
|
+
if (next === undefined) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let end = index + 2;
|
|
48
|
+
let text;
|
|
49
|
+
|
|
50
|
+
if (next === '\r') {
|
|
51
|
+
end = body[index + 2] === '\n' ? index + 3 : index + 2;
|
|
52
|
+
text = '';
|
|
53
|
+
} else if (next === '\n' || next === '\u2028' || next === '\u2029') {
|
|
54
|
+
text = '';
|
|
55
|
+
} else if (next === 'x') {
|
|
56
|
+
const hex = body.slice(index + 2, index + 4);
|
|
57
|
+
if (!HEX_2.test(hex)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
end = index + 4;
|
|
61
|
+
text = String.fromCharCode(Number.parseInt(hex, 16));
|
|
62
|
+
} else if (next === 'u' && body[index + 2] === '{') {
|
|
63
|
+
const close = body.indexOf('}', index + 3);
|
|
64
|
+
const hex = close === -1 ? '' : body.slice(index + 3, close);
|
|
65
|
+
const codePoint = HEX_ANY.test(hex) ? Number.parseInt(hex, 16) : Number.NaN;
|
|
66
|
+
if (!(codePoint <= 0x10ffff)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
end = close + 1;
|
|
70
|
+
text = String.fromCodePoint(codePoint);
|
|
71
|
+
} else if (next === 'u') {
|
|
72
|
+
const hex = body.slice(index + 2, index + 6);
|
|
73
|
+
if (!HEX_4.test(hex)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
end = index + 6;
|
|
77
|
+
text = String.fromCharCode(Number.parseInt(hex, 16));
|
|
78
|
+
} else if (OCTAL_DIGIT.test(next)) {
|
|
79
|
+
// Legacy octal escape (sloppy mode): up to three digits, at most \377.
|
|
80
|
+
let digits = next;
|
|
81
|
+
if (OCTAL_DIGIT.test(body[end] || '')) {
|
|
82
|
+
digits += body[end];
|
|
83
|
+
end += 1;
|
|
84
|
+
if (next <= '3' && OCTAL_DIGIT.test(body[end] || '')) {
|
|
85
|
+
digits += body[end];
|
|
86
|
+
end += 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
text = String.fromCharCode(Number.parseInt(digits, 8));
|
|
90
|
+
} else {
|
|
91
|
+
text = SINGLE_CHARACTER_ESCAPES[next] ?? next;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pieces.push({ end, start: index, text });
|
|
95
|
+
index = end;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return pieces;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function alignJsxAttributeBody(body, value) {
|
|
102
|
+
const pieces = [];
|
|
103
|
+
let rawIndex = 0;
|
|
104
|
+
let valueIndex = 0;
|
|
105
|
+
|
|
106
|
+
while (rawIndex < body.length) {
|
|
107
|
+
const entity = body[rawIndex] === '&'
|
|
108
|
+
? JSX_ENTITY.exec(body.slice(rawIndex, rawIndex + 12))
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
if (!entity) {
|
|
112
|
+
if (body[rawIndex] !== value[valueIndex]) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
pieces.push({ end: rawIndex + 1, start: rawIndex, text: body[rawIndex] });
|
|
116
|
+
rawIndex += 1;
|
|
117
|
+
valueIndex += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entityText = entity[0];
|
|
122
|
+
let text;
|
|
123
|
+
if (value.startsWith(entityText, valueIndex)) {
|
|
124
|
+
// Unknown names are kept as written by the JSX parser.
|
|
125
|
+
text = entityText;
|
|
126
|
+
} else if (entity[1] || entity[2]) {
|
|
127
|
+
const codePoint = Number.parseInt(entity[1] || entity[2], entity[1] ? 16 : 10);
|
|
128
|
+
const candidates = codePoint <= 0x10ffff
|
|
129
|
+
? [String.fromCodePoint(codePoint), String.fromCharCode(codePoint)]
|
|
130
|
+
: [String.fromCharCode(codePoint)];
|
|
131
|
+
text = candidates.find((candidate) => value.startsWith(candidate, valueIndex));
|
|
132
|
+
} else {
|
|
133
|
+
// Every named XHTML entity decodes to one UTF-16 unit.
|
|
134
|
+
text = value[valueIndex];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (text === undefined || valueIndex + text.length > value.length) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pieces.push({ end: rawIndex + entityText.length, start: rawIndex, text });
|
|
142
|
+
rawIndex += entityText.length;
|
|
143
|
+
valueIndex += text.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return valueIndex === value.length ? pieces : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isJsxAttributeValue(node) {
|
|
150
|
+
return Boolean(node.parent && node.parent.type === 'JSXAttribute' && node.parent.value === node);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns `replace(start, token, replacement)` for a string Literal node,
|
|
155
|
+
* which gives an ESLint fix replacing only that token's raw span, or null
|
|
156
|
+
* when the token cannot be located safely. Returns null for any other node.
|
|
157
|
+
*/
|
|
158
|
+
function createLiteralTokenFixer(node, sourceCode) {
|
|
159
|
+
if (node.type !== 'Literal' || typeof node.value !== 'string' || !Array.isArray(node.range)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const rawText = sourceCode.getText(node);
|
|
164
|
+
const quote = rawText[0];
|
|
165
|
+
if ((quote !== '"' && quote !== "'") || rawText.length < 2 || rawText[rawText.length - 1] !== quote) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const body = rawText.slice(1, -1);
|
|
170
|
+
const jsx = isJsxAttributeValue(node);
|
|
171
|
+
const pieces = jsx ? alignJsxAttributeBody(body, node.value) : decodeJsStringBody(body);
|
|
172
|
+
if (!pieces || pieces.map((piece) => piece.text).join('') !== node.value) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rawStarts = [];
|
|
177
|
+
const rawEnds = [];
|
|
178
|
+
for (const piece of pieces) {
|
|
179
|
+
for (let unit = 0; unit < piece.text.length; unit += 1) {
|
|
180
|
+
rawStarts.push(piece.start);
|
|
181
|
+
rawEnds.push(piece.end);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const bodyOffset = node.range[0] + 1;
|
|
186
|
+
|
|
187
|
+
return function replace(start, token, replacement) {
|
|
188
|
+
if (typeof replacement !== 'string' || replacement === token || token.length === 0) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
replacement.includes(quote)
|
|
193
|
+
|| replacement.includes('\\')
|
|
194
|
+
|| LINE_TERMINATOR.test(replacement)
|
|
195
|
+
|| (jsx && replacement.includes('&'))
|
|
196
|
+
) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const end = start + token.length;
|
|
201
|
+
if (start < 0 || end > rawStarts.length) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const rawStart = rawStarts[start];
|
|
206
|
+
const rawEnd = rawEnds[end - 1];
|
|
207
|
+
if (body.slice(rawStart, rawEnd) !== token) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const range = [bodyOffset + rawStart, bodyOffset + rawEnd];
|
|
212
|
+
return (fixer) => fixer.replaceTextRange(range, replacement);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The text a class string rule should analyze: the decoded value of a string
|
|
218
|
+
* Literal, or the cooked value of a TemplateElement (its raw text when the
|
|
219
|
+
* cooked value is null, as in a tagged template with an invalid escape).
|
|
220
|
+
*/
|
|
221
|
+
function classStringValue(node) {
|
|
222
|
+
if (node.type === 'Literal') {
|
|
223
|
+
return typeof node.value === 'string' ? node.value : null;
|
|
224
|
+
}
|
|
225
|
+
if (node.type === 'TemplateElement' && node.value) {
|
|
226
|
+
return typeof node.value.cooked === 'string' ? node.value.cooked : node.value.raw;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
classStringValue,
|
|
233
|
+
createLiteralTokenFixer,
|
|
234
|
+
};
|
|
@@ -4,24 +4,13 @@ const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options
|
|
|
4
4
|
const { nearestText } = require('../../core/length');
|
|
5
5
|
const { formatTime } = require('../../core/time');
|
|
6
6
|
const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
|
|
7
|
+
const { classStringValue, createLiteralTokenFixer } = require('../literal-spans');
|
|
7
8
|
|
|
8
9
|
const RULE_NAME = 'tailwind-class-use-motion-scale';
|
|
9
10
|
|
|
10
11
|
function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
let quote = null;
|
|
14
|
-
|
|
15
|
-
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
16
|
-
value = node.value;
|
|
17
|
-
quote = rawText[0] === '"' || rawText[0] === "'" ? rawText[0] : '"';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (node.type === 'TemplateElement') {
|
|
21
|
-
value = node.value.raw;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (!value || typeof value !== 'string') {
|
|
12
|
+
const value = classStringValue(node);
|
|
13
|
+
if (!value) {
|
|
25
14
|
return;
|
|
26
15
|
}
|
|
27
16
|
|
|
@@ -30,20 +19,9 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
|
30
19
|
return;
|
|
31
20
|
}
|
|
32
21
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
for (const { analysis, segment } of findings) {
|
|
37
|
-
if (allowFix && analysis.reason === 'duration' && node.type === 'Literal') {
|
|
38
|
-
const replacementStart = segment.start + fixedValueOffset;
|
|
39
|
-
fixedValue = `${fixedValue.slice(0, replacementStart)}${analysis.fixedToken}${fixedValue.slice(replacementStart + segment.token.length)}`;
|
|
40
|
-
fixedValueOffset += analysis.fixedToken.length - segment.token.length;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const fixedText = allowFix && node.type === 'Literal' && fixedValue !== value
|
|
45
|
-
? `${quote}${fixedValue.replace(new RegExp(quote, 'g'), `\\${quote}`)}${quote}`
|
|
46
|
-
: null;
|
|
22
|
+
// Fixes replace only the raw span of the class token, so escapes and JSX
|
|
23
|
+
// entities elsewhere in the literal are kept as written (#225).
|
|
24
|
+
const replaceToken = allowFix ? createLiteralTokenFixer(node, sourceCode) : null;
|
|
47
25
|
|
|
48
26
|
for (const { analysis, segment } of findings) {
|
|
49
27
|
const lower = analysis.nearest
|
|
@@ -55,10 +33,9 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
|
55
33
|
context.report({
|
|
56
34
|
message: withNote(buildMessage(analysis, segment, lower, upper), analyzer.note),
|
|
57
35
|
node,
|
|
58
|
-
fix:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: null,
|
|
36
|
+
fix: replaceToken && analysis.reason === 'duration'
|
|
37
|
+
? replaceToken(segment.start, segment.token, analysis.fixedToken)
|
|
38
|
+
: null,
|
|
62
39
|
});
|
|
63
40
|
}
|
|
64
41
|
}
|
|
@@ -2,24 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
|
|
4
4
|
const { createTailwindClassAnalyzer, offScaleClassMessage } = require('../../core/tailwind-class-analysis');
|
|
5
|
+
const { classStringValue, createLiteralTokenFixer } = require('../literal-spans');
|
|
5
6
|
|
|
6
7
|
const RULE_NAME = 'tailwind-class-use-scale';
|
|
7
8
|
|
|
8
9
|
function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
let quote = null;
|
|
12
|
-
|
|
13
|
-
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
14
|
-
value = node.value;
|
|
15
|
-
quote = rawText[0] === '"' || rawText[0] === "'" ? rawText[0] : '"';
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (node.type === 'TemplateElement') {
|
|
19
|
-
value = node.value.raw;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (!value || typeof value !== 'string') {
|
|
10
|
+
const value = classStringValue(node);
|
|
11
|
+
if (!value) {
|
|
23
12
|
return;
|
|
24
13
|
}
|
|
25
14
|
|
|
@@ -28,29 +17,17 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
|
28
17
|
return;
|
|
29
18
|
}
|
|
30
19
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
for (const { analysis, segment } of findings) {
|
|
35
|
-
if (allowFix && analysis.reason !== 'negative' && node.type === 'Literal') {
|
|
36
|
-
const replacementStart = segment.start + fixedValueOffset;
|
|
37
|
-
fixedValue = `${fixedValue.slice(0, replacementStart)}${analysis.fixedToken}${fixedValue.slice(replacementStart + segment.token.length)}`;
|
|
38
|
-
fixedValueOffset += analysis.fixedToken.length - segment.token.length;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const fixedText = allowFix && node.type === 'Literal' && fixedValue !== value
|
|
43
|
-
? `${quote}${fixedValue.replace(new RegExp(quote, 'g'), `\\${quote}`)}${quote}`
|
|
44
|
-
: null;
|
|
20
|
+
// Fixes replace only the raw span of the class token, so escapes and JSX
|
|
21
|
+
// entities elsewhere in the literal are kept as written (#225).
|
|
22
|
+
const replaceToken = allowFix ? createLiteralTokenFixer(node, sourceCode) : null;
|
|
45
23
|
|
|
46
24
|
for (const { analysis, segment } of findings) {
|
|
47
25
|
context.report({
|
|
48
26
|
message: withNote(offScaleClassMessage(segment.token, analysis), analyzer.note),
|
|
49
27
|
node,
|
|
50
|
-
fix:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
: null,
|
|
28
|
+
fix: replaceToken && analysis.reason !== 'negative'
|
|
29
|
+
? replaceToken(segment.start, segment.token, analysis.fixedToken)
|
|
30
|
+
: null,
|
|
54
31
|
});
|
|
55
32
|
}
|
|
56
33
|
}
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
nearestText,
|
|
10
10
|
numbersEqual,
|
|
11
11
|
parseLengthToken,
|
|
12
|
+
pxOnScale,
|
|
12
13
|
toPx,
|
|
13
14
|
} = require('../../core/length');
|
|
14
15
|
const {
|
|
@@ -100,7 +101,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
100
101
|
node.value,
|
|
101
102
|
formatLength(nearest.lower, nearestUnit),
|
|
102
103
|
formatLength(nearest.upper, nearestUnit),
|
|
103
|
-
[tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)), options.note].filter(Boolean).join(' '),
|
|
104
|
+
[tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), node.value.trim().startsWith('-')), options.note].filter(Boolean).join(' '),
|
|
104
105
|
),
|
|
105
106
|
node,
|
|
106
107
|
replacement: fixedValue,
|
|
@@ -177,7 +178,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
177
178
|
return;
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
const isOnScale = scalePx
|
|
181
|
+
const isOnScale = pxOnScale(scalePx, pxValue, parsedLength.unit, options.baseFontSize);
|
|
181
182
|
if (isOnScale) {
|
|
182
183
|
return;
|
|
183
184
|
}
|