stylelint-plugin-rhythmguard 3.10.2 → 3.12.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 +77 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/baseline.js +184 -27
- package/src/audit/codemod.js +39 -14
- package/src/audit/config.js +25 -31
- package/src/audit/contract.js +96 -10
- package/src/audit/render-github.js +4 -0
- package/src/audit/render-html.js +7 -0
- package/src/audit/render-markdown.js +27 -0
- package/src/audit/render-text.js +23 -1
- package/src/audit/report.js +60 -17
- package/src/audit/scan/files.js +154 -27
- package/src/audit/scan/stylesheets.js +87 -34
- package/src/audit/scan/templates.js +22 -7
- package/src/audit/token-chains.js +18 -7
- package/src/cli/audit.js +7 -3
- package/src/cli/doctor.js +48 -7
- package/src/cli/fix.js +56 -15
- package/src/cli/init.js +136 -23
- package/src/cli/quickstart.js +34 -8
- package/src/core/fs-cache.js +6 -0
- package/src/core/length.js +15 -0
- package/src/core/options.js +8 -1
- package/src/core/scale-inference.js +99 -68
- package/src/core/tailwind-class-analysis.js +26 -16
- package/src/core/tailwind-motion-analysis.js +12 -3
- package/src/core/token-index.js +76 -24
- package/src/core/token-map.js +23 -10
- package/src/core/token-source-entries.js +64 -0
- package/src/core/token-sources.js +361 -69
- package/src/core/value-nodes.js +280 -54
- package/src/eslint/literal-spans.js +319 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
- package/src/rules/no-offscale-transform/index.js +35 -14
- package/src/rules/prefer-token/index.js +72 -48
- package/src/rules/report.js +3 -1
- package/src/rules/use-motion-scale/index.js +68 -5
- package/src/rules/use-scale/index.js +54 -39
- package/types/__checks__/eslint-config.ts +37 -0
- package/types/audit.d.ts +10 -2
- package/types/shared.d.ts +21 -3
|
@@ -0,0 +1,319 @@
|
|
|
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 isUnsafe = (replacement) => replacement.includes(quote)
|
|
177
|
+
|| replacement.includes('\\')
|
|
178
|
+
|| LINE_TERMINATOR.test(replacement)
|
|
179
|
+
|| (jsx && replacement.includes('&'));
|
|
180
|
+
|
|
181
|
+
return spanReplacer(body, node.range[0] + 1, pieces, isUnsafe);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Characters a replacement may not contain inside a markup attribute value,
|
|
185
|
+
// quoted or not: quotes, entity and tag syntax, Svelte mustaches, whitespace.
|
|
186
|
+
const UNSAFE_IN_MARKUP = /["'`&<>{}=\\\s]/;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Returns `replace(start, token, replacement)` for the static text of a Vue
|
|
190
|
+
* (`VLiteral`, vue-eslint-parser) or Svelte (`SvelteLiteral`,
|
|
191
|
+
* svelte-eslint-parser) attribute value, or null when the source text cannot
|
|
192
|
+
* be aligned with the node's value. The node's range may or may not include
|
|
193
|
+
* the quotes; both are tried. Entities are kept as written, and a text that
|
|
194
|
+
* does not align exactly with the value gets no fix rather than a guess (#244).
|
|
195
|
+
*/
|
|
196
|
+
function createMarkupTextFixer(node, sourceCode) {
|
|
197
|
+
if (typeof node.value !== 'string' || !Array.isArray(node.range) || typeof sourceCode.text !== 'string') {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const text = sourceCode.text.slice(node.range[0], node.range[1]);
|
|
202
|
+
const candidates = [{ body: text, offset: node.range[0] }];
|
|
203
|
+
const quote = text[0];
|
|
204
|
+
if ((quote === '"' || quote === "'") && text.length >= 2 && text[text.length - 1] === quote) {
|
|
205
|
+
candidates.push({ body: text.slice(1, -1), offset: node.range[0] + 1 });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const { body, offset } of candidates) {
|
|
209
|
+
const pieces = alignJsxAttributeBody(body, node.value);
|
|
210
|
+
if (pieces && pieces.map((piece) => piece.text).join('') === node.value) {
|
|
211
|
+
return spanReplacer(body, offset, pieces, (replacement) => UNSAFE_IN_MARKUP.test(replacement));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The shared part of the fixers: maps a token at a decoded offset back to its
|
|
220
|
+
* raw span and replaces it only when that span is exactly the token text.
|
|
221
|
+
*/
|
|
222
|
+
function spanReplacer(body, bodyOffset, pieces, isUnsafe) {
|
|
223
|
+
const rawStarts = [];
|
|
224
|
+
const rawEnds = [];
|
|
225
|
+
for (const piece of pieces) {
|
|
226
|
+
for (let unit = 0; unit < piece.text.length; unit += 1) {
|
|
227
|
+
rawStarts.push(piece.start);
|
|
228
|
+
rawEnds.push(piece.end);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return function replace(start, token, replacement) {
|
|
233
|
+
if (typeof replacement !== 'string' || replacement === token || token.length === 0) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
if (isUnsafe(replacement)) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const end = start + token.length;
|
|
241
|
+
if (start < 0 || end > rawStarts.length) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const rawStart = rawStarts[start];
|
|
246
|
+
const rawEnd = rawEnds[end - 1];
|
|
247
|
+
if (body.slice(rawStart, rawEnd) !== token) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const range = [bodyOffset + rawStart, bodyOffset + rawEnd];
|
|
252
|
+
return (fixer) => fixer.replaceTextRange(range, replacement);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The token fixer for any node a class string rule visits: string literals,
|
|
258
|
+
* and the static attribute text of Vue and Svelte templates.
|
|
259
|
+
*/
|
|
260
|
+
function createTokenFixer(node, sourceCode) {
|
|
261
|
+
if (node.type === 'VLiteral' || node.type === 'SvelteLiteral') {
|
|
262
|
+
return createMarkupTextFixer(node, sourceCode);
|
|
263
|
+
}
|
|
264
|
+
return createLiteralTokenFixer(node, sourceCode);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* True for the static text of a `class` attribute in a Vue template
|
|
269
|
+
* (`<div class="p-[13px]">`, a `VLiteral` in a non-directive `VAttribute`) or
|
|
270
|
+
* a Svelte component (`<div class="p-[13px] {x}">`, a `SvelteLiteral` in a
|
|
271
|
+
* `SvelteAttribute`). Bound classes (`:class="..."`, `class={...}`) are
|
|
272
|
+
* expressions, and their strings are ordinary literals.
|
|
273
|
+
*/
|
|
274
|
+
function isClassAttributeText(node) {
|
|
275
|
+
const attribute = node.parent;
|
|
276
|
+
if (!attribute || !attribute.key) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (node.type === 'VLiteral') {
|
|
280
|
+
return attribute.type === 'VAttribute'
|
|
281
|
+
&& attribute.directive === false
|
|
282
|
+
&& attribute.value === node
|
|
283
|
+
&& String(attribute.key.name).toLowerCase() === 'class';
|
|
284
|
+
}
|
|
285
|
+
if (node.type === 'SvelteLiteral') {
|
|
286
|
+
return attribute.type === 'SvelteAttribute'
|
|
287
|
+
&& Array.isArray(attribute.value)
|
|
288
|
+
&& attribute.value.includes(node)
|
|
289
|
+
&& attribute.key.name === 'class';
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The text a class string rule should analyze: the decoded value of a string
|
|
296
|
+
* Literal, or the cooked value of a TemplateElement (its raw text when the
|
|
297
|
+
* cooked value is null, as in a tagged template with an invalid escape), or
|
|
298
|
+
* the value of a Vue `VLiteral` or Svelte `SvelteLiteral`.
|
|
299
|
+
*/
|
|
300
|
+
function classStringValue(node) {
|
|
301
|
+
if (node.type === 'Literal') {
|
|
302
|
+
return typeof node.value === 'string' ? node.value : null;
|
|
303
|
+
}
|
|
304
|
+
if (node.type === 'TemplateElement' && node.value) {
|
|
305
|
+
return typeof node.value.cooked === 'string' ? node.value.cooked : node.value.raw;
|
|
306
|
+
}
|
|
307
|
+
if (node.type === 'VLiteral' || node.type === 'SvelteLiteral') {
|
|
308
|
+
return typeof node.value === 'string' ? node.value : null;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
module.exports = {
|
|
314
|
+
classStringValue,
|
|
315
|
+
createLiteralTokenFixer,
|
|
316
|
+
createMarkupTextFixer,
|
|
317
|
+
createTokenFixer,
|
|
318
|
+
isClassAttributeText,
|
|
319
|
+
};
|
|
@@ -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, createTokenFixer, isClassAttributeText } = 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
|
+
// or HTML entities elsewhere in the literal are kept as written (#225).
|
|
22
|
+
const replaceToken = allowFix ? createTokenFixer(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
|
}
|
|
@@ -99,14 +76,42 @@ module.exports = {
|
|
|
99
76
|
analyzer.note = normalizeNote(option.note);
|
|
100
77
|
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
101
78
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
79
|
+
const Literal = (node) => maybeCheckNodeText(node, sourceCode, context, analyzer, true);
|
|
80
|
+
const TemplateElement = (node) => maybeCheckNodeText(node, sourceCode, context, analyzer, false);
|
|
81
|
+
const scriptVisitor = { Literal, TemplateElement };
|
|
82
|
+
const parserServices = sourceCode.parserServices || context.parserServices;
|
|
83
|
+
|
|
84
|
+
// svelte-eslint-parser: the static text of a `class` attribute value is a
|
|
85
|
+
// `SvelteLiteral` (#244). The listener is only added when the parser
|
|
86
|
+
// declares the node type, so hosts that reject unknown node types (such
|
|
87
|
+
// as other linters' JS plugin runtimes) never see it.
|
|
88
|
+
if ((sourceCode.visitorKeys && sourceCode.visitorKeys.SvelteLiteral) || (parserServices && parserServices.isSvelte)) {
|
|
89
|
+
scriptVisitor.SvelteLiteral = (node) => {
|
|
90
|
+
if (isClassAttributeText(node)) {
|
|
91
|
+
Literal(node);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// vue-eslint-parser keeps `<template>` in a separate tree that only
|
|
97
|
+
// `defineTemplateBodyVisitor` walks (#244). No runtime dependency: the
|
|
98
|
+
// service is used when the parser provides it.
|
|
99
|
+
if (parserServices && typeof parserServices.defineTemplateBodyVisitor === 'function') {
|
|
100
|
+
return parserServices.defineTemplateBodyVisitor(
|
|
101
|
+
{
|
|
102
|
+
Literal,
|
|
103
|
+
TemplateElement,
|
|
104
|
+
VLiteral(node) {
|
|
105
|
+
if (isClassAttributeText(node)) {
|
|
106
|
+
Literal(node);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
scriptVisitor,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return scriptVisitor;
|
|
110
115
|
},
|
|
111
116
|
ruleName: RULE_NAME,
|
|
112
117
|
};
|
|
@@ -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 {
|
|
@@ -16,10 +17,13 @@ const {
|
|
|
16
17
|
createPropertyScaleResolver,
|
|
17
18
|
} = require('../../core/options');
|
|
18
19
|
const {
|
|
20
|
+
declarationValueSource,
|
|
19
21
|
isMathFunction,
|
|
20
22
|
shouldLintMathArgument,
|
|
23
|
+
skipTokenFunction,
|
|
21
24
|
walkRootValueNodes,
|
|
22
25
|
walkTransformTranslateNodes,
|
|
26
|
+
writeDeclarationValue,
|
|
23
27
|
} = require('../../core/value-nodes');
|
|
24
28
|
|
|
25
29
|
const {
|
|
@@ -29,7 +33,7 @@ const {
|
|
|
29
33
|
|
|
30
34
|
const { validatePrimary, validateNoOffscaleTransformSecondaryOptions } = require('../validate');
|
|
31
35
|
|
|
32
|
-
const { reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
|
|
36
|
+
const { createTokenRegex, reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
|
|
33
37
|
const { decisionFor, loadRcDecisions } = require('../../core/decisions');
|
|
34
38
|
const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
|
|
35
39
|
|
|
@@ -68,6 +72,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
withResolvedScale(options, root);
|
|
75
|
+
const tokenRegex = createTokenRegex(options.tokenPattern, { result, root, ruleName });
|
|
71
76
|
if (options.fixWith === 'token') {
|
|
72
77
|
options.tokenIndex = tokenIndexFromDefinitions(
|
|
73
78
|
collectTokenDefinitions({
|
|
@@ -75,7 +80,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
75
80
|
root,
|
|
76
81
|
scaleSources: options.scaleSources,
|
|
77
82
|
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
|
|
78
|
-
tokenRegex
|
|
83
|
+
tokenRegex,
|
|
79
84
|
}),
|
|
80
85
|
options.baseFontSize,
|
|
81
86
|
);
|
|
@@ -89,18 +94,20 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
89
94
|
return;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
const
|
|
97
|
+
const source = declarationValueSource(decl);
|
|
98
|
+
const parsed = valueParser(source.parseText);
|
|
93
99
|
const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
|
|
94
100
|
let changed = false;
|
|
95
101
|
|
|
102
|
+
// `nearest` is null for a value the options forbid outright, as in use-scale.
|
|
96
103
|
const report = (node, nearest, nearestUnit, fixedValue = null) => {
|
|
97
104
|
reportValueNode({
|
|
98
105
|
decl,
|
|
99
106
|
message: messages.rejected(
|
|
100
107
|
node.value,
|
|
101
|
-
formatLength(nearest.lower, nearestUnit),
|
|
102
|
-
formatLength(nearest.upper, nearestUnit),
|
|
103
|
-
[tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)), options.note].filter(Boolean).join(' '),
|
|
108
|
+
nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a',
|
|
109
|
+
nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a',
|
|
110
|
+
[nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit), node.value.trim().startsWith('-')) : '', options.note].filter(Boolean).join(' '),
|
|
104
111
|
),
|
|
105
112
|
node,
|
|
106
113
|
replacement: fixedValue,
|
|
@@ -109,7 +116,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
109
116
|
});
|
|
110
117
|
};
|
|
111
118
|
|
|
112
|
-
const checkNode = (node) => {
|
|
119
|
+
const checkNode = (node, avoidTokens = []) => {
|
|
113
120
|
const parsedLength = parseLengthToken(node.value);
|
|
114
121
|
if (!parsedLength || parsedLength.number === 0) {
|
|
115
122
|
return;
|
|
@@ -119,7 +126,10 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
119
126
|
return;
|
|
120
127
|
}
|
|
121
128
|
|
|
122
|
-
if (parsedLength.unit === '%'
|
|
129
|
+
if (parsedLength.unit === '%') {
|
|
130
|
+
if (!options.allowPercentages) {
|
|
131
|
+
report(node, null, '%');
|
|
132
|
+
}
|
|
123
133
|
return;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -128,6 +138,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
if (!options.allowNegative && parsedLength.number < 0) {
|
|
141
|
+
report(node, null, parsedLength.unit);
|
|
131
142
|
return;
|
|
132
143
|
}
|
|
133
144
|
|
|
@@ -164,7 +175,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
const fixedValue = options.fixToScale
|
|
167
|
-
? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
|
|
178
|
+
? replacementFor(parsedLength, nearest.nearest, options, decl.prop, avoidTokens)
|
|
168
179
|
: null;
|
|
169
180
|
|
|
170
181
|
report(node, nearest, unit, fixedValue);
|
|
@@ -177,7 +188,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
177
188
|
return;
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
const isOnScale = scalePx
|
|
191
|
+
const isOnScale = pxOnScale(scalePx, pxValue, parsedLength.unit, options.baseFontSize);
|
|
181
192
|
if (isOnScale) {
|
|
182
193
|
return;
|
|
183
194
|
}
|
|
@@ -188,7 +199,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
const fixedValue = options.fixToScale
|
|
191
|
-
? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
|
|
202
|
+
? replacementFor(parsedLength, nearest.nearest, options, decl.prop, avoidTokens)
|
|
192
203
|
: null;
|
|
193
204
|
|
|
194
205
|
report(node, nearest, 'px', fixedValue);
|
|
@@ -198,6 +209,11 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
198
209
|
if (prop === 'transform') {
|
|
199
210
|
walkTransformTranslateNodes(parsed, (node, context) => {
|
|
200
211
|
if (node.type === 'function') {
|
|
212
|
+
const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
|
|
213
|
+
if (skipToken) {
|
|
214
|
+
return skipToken;
|
|
215
|
+
}
|
|
216
|
+
|
|
201
217
|
if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
|
|
202
218
|
return true;
|
|
203
219
|
}
|
|
@@ -213,12 +229,17 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
213
229
|
return false;
|
|
214
230
|
}
|
|
215
231
|
|
|
216
|
-
checkNode(node);
|
|
232
|
+
checkNode(node, context.tokenFallbackOf);
|
|
217
233
|
return false;
|
|
218
234
|
});
|
|
219
235
|
} else {
|
|
220
236
|
walkRootValueNodes(parsed, (node, context) => {
|
|
221
237
|
if (node.type === 'function') {
|
|
238
|
+
const skipToken = skipTokenFunction(node, options.tokenFunctions, tokenRegex);
|
|
239
|
+
if (skipToken) {
|
|
240
|
+
return skipToken;
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
|
|
223
244
|
return true;
|
|
224
245
|
}
|
|
@@ -234,13 +255,13 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
234
255
|
return false;
|
|
235
256
|
}
|
|
236
257
|
|
|
237
|
-
checkNode(node);
|
|
258
|
+
checkNode(node, context.tokenFallbackOf);
|
|
238
259
|
return false;
|
|
239
260
|
});
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
if (changed) {
|
|
243
|
-
decl
|
|
264
|
+
writeDeclarationValue(decl, source, parsed);
|
|
244
265
|
}
|
|
245
266
|
});
|
|
246
267
|
};
|