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
package/src/core/value-nodes.js
CHANGED
|
@@ -52,38 +52,43 @@ function isTokenFunction(node, tokenFunctions, tokenRegex) {
|
|
|
52
52
|
return tokenRegex.test(firstArg);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
parentFunctionArgIndex: null,
|
|
59
|
-
parentFunctionName: null,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
55
|
+
// What a walker's callback returns for a token var(): skip the name, walk the fallback.
|
|
56
|
+
const WALK_TOKEN_FALLBACK = 'fallback';
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
/**
|
|
59
|
+
* How a length rule walks a token function (issue #240). The name of a token
|
|
60
|
+
* var() is the token and is skipped, but its fallback is a value like any
|
|
61
|
+
* other and is walked, so `var(--space-1, 7px)` checks the 7px. Any other
|
|
62
|
+
* token function (theme(), token()) is skipped whole. False for a function
|
|
63
|
+
* that is not a token.
|
|
64
|
+
*/
|
|
65
|
+
function skipTokenFunction(node, tokenFunctions, tokenRegex) {
|
|
66
|
+
if (!isTokenFunction(node, tokenFunctions, tokenRegex)) {
|
|
67
|
+
return false;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
return
|
|
71
|
-
parentFunctionArgIndex:
|
|
72
|
-
Number.isInteger(context.parentFunctionArgIndex) && context.parentFunctionArgIndex > 0
|
|
73
|
-
? context.parentFunctionArgIndex
|
|
74
|
-
: null,
|
|
75
|
-
parentFunctionName:
|
|
76
|
-
typeof context.parentFunctionName === 'string' && context.parentFunctionName.length > 0
|
|
77
|
-
? context.parentFunctionName
|
|
78
|
-
: null,
|
|
79
|
-
};
|
|
70
|
+
return node.value.toLowerCase() === 'var' ? WALK_TOKEN_FALLBACK : true;
|
|
80
71
|
}
|
|
81
72
|
|
|
82
|
-
function
|
|
83
|
-
const
|
|
73
|
+
function varName(node) {
|
|
74
|
+
const comma = node.nodes.findIndex((child) => child.type === 'div' && child.value === ',');
|
|
75
|
+
return valueParser.stringify(comma === -1 ? node.nodes : node.nodes.slice(0, comma)).trim();
|
|
76
|
+
}
|
|
84
77
|
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
function varFallbackNodes(node) {
|
|
79
|
+
const comma = node.nodes.findIndex((child) => child.type === 'div' && child.value === ',');
|
|
80
|
+
return comma === -1 ? [] : node.nodes.slice(comma + 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Walk value nodes for a length rule. `walkNode` returns true to skip a
|
|
85
|
+
* function's children, or WALK_TOKEN_FALLBACK to walk only the fallback of a
|
|
86
|
+
* var(). Nodes inside a fallback carry `tokenFallbackOf`, the names of the
|
|
87
|
+
* var()s they fall back from, outermost first.
|
|
88
|
+
*/
|
|
89
|
+
function createNodeWalker(walkNode, rootFunctionName = null) {
|
|
90
|
+
const walkNodes = (nodes, parentFunction, firstArgIndex = 1, tokenFallbackOf = []) => {
|
|
91
|
+
let argIndex = firstArgIndex;
|
|
87
92
|
|
|
88
93
|
for (const node of nodes) {
|
|
89
94
|
if (node.type === 'div' && node.value === ',') {
|
|
@@ -93,18 +98,24 @@ function walkRootValueNodes(parsed, walkNode, state) {
|
|
|
93
98
|
|
|
94
99
|
const context = {
|
|
95
100
|
parentFunctionArgIndex: parentFunction ? argIndex : null,
|
|
96
|
-
parentFunctionName: parentFunction ? parentFunction.name :
|
|
101
|
+
parentFunctionName: parentFunction ? parentFunction.name : rootFunctionName,
|
|
102
|
+
tokenFallbackOf,
|
|
97
103
|
};
|
|
98
104
|
|
|
99
105
|
if (node.type === 'function') {
|
|
100
106
|
const fnName = node.value.toLowerCase();
|
|
101
107
|
const skipChildren = walkNode(node, context);
|
|
102
108
|
|
|
109
|
+
if (skipChildren === WALK_TOKEN_FALLBACK) {
|
|
110
|
+
walkNodes(varFallbackNodes(node), { name: fnName }, 2, [...tokenFallbackOf, varName(node)]);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
if (skipChildren) {
|
|
104
115
|
continue;
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
walkNodes(node.nodes, { name: fnName });
|
|
118
|
+
walkNodes(node.nodes, { name: fnName }, 1, tokenFallbackOf);
|
|
108
119
|
continue;
|
|
109
120
|
}
|
|
110
121
|
|
|
@@ -112,38 +123,43 @@ function walkRootValueNodes(parsed, walkNode, state) {
|
|
|
112
123
|
}
|
|
113
124
|
};
|
|
114
125
|
|
|
115
|
-
walkNodes
|
|
126
|
+
return walkNodes;
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
129
|
+
function normalizeWalkContext(context) {
|
|
130
|
+
if (!context) {
|
|
131
|
+
return {
|
|
132
|
+
parentFunctionArgIndex: null,
|
|
133
|
+
parentFunctionName: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
127
136
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
if (typeof context === 'string') {
|
|
138
|
+
return {
|
|
139
|
+
parentFunctionArgIndex: null,
|
|
140
|
+
parentFunctionName: context,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
132
143
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
return {
|
|
145
|
+
parentFunctionArgIndex:
|
|
146
|
+
Number.isInteger(context.parentFunctionArgIndex) && context.parentFunctionArgIndex > 0
|
|
147
|
+
? context.parentFunctionArgIndex
|
|
148
|
+
: null,
|
|
149
|
+
parentFunctionName:
|
|
150
|
+
typeof context.parentFunctionName === 'string' && context.parentFunctionName.length > 0
|
|
151
|
+
? context.parentFunctionName
|
|
152
|
+
: null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
function walkRootValueNodes(parsed, walkNode, state) {
|
|
157
|
+
const initialContext = normalizeWalkContext(state);
|
|
158
|
+
createNodeWalker(walkNode, initialContext.parentFunctionName)(parsed.nodes, null);
|
|
159
|
+
}
|
|
143
160
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
161
|
+
function walkTransformTranslateNodes(parsed, walkNode) {
|
|
162
|
+
const walkNodes = createNodeWalker(walkNode);
|
|
147
163
|
|
|
148
164
|
for (const node of parsed.nodes) {
|
|
149
165
|
if (node.type !== 'function') {
|
|
@@ -240,21 +256,231 @@ function collectMathBounds(fnNode) {
|
|
|
240
256
|
return bounds;
|
|
241
257
|
}
|
|
242
258
|
|
|
259
|
+
const SASS_OPERATORS = new Set(['*', '+', '-', '%']);
|
|
260
|
+
|
|
261
|
+
function isSassOperator(node) {
|
|
262
|
+
return Boolean(node) && (
|
|
263
|
+
(node.type === 'word' && SASS_OPERATORS.has(node.value)) ||
|
|
264
|
+
(node.type === 'div' && node.value === '/')
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The words of a Sass value that are operands of an expression (issue #234):
|
|
270
|
+
* next to `*`, `/`, `+`, `-` or `%`, or an argument of a Sass module function
|
|
271
|
+
* such as math.div(). Rewriting one changes what the expression computes, so
|
|
272
|
+
* a rule may report such a literal but never fix it. Empty for a value
|
|
273
|
+
* without `$`, which is not a Sass expression.
|
|
274
|
+
*/
|
|
275
|
+
function sassOperandNodes(parsed, value) {
|
|
276
|
+
const operands = new Set();
|
|
277
|
+
if (!String(value).includes('$')) {
|
|
278
|
+
return operands;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const visit = (nodes, inModuleFunction) => {
|
|
282
|
+
const meaningful = nodes.filter((node) => node.type !== 'space' && node.type !== 'comment');
|
|
283
|
+
meaningful.forEach((node, index) => {
|
|
284
|
+
if (node.type === 'function') {
|
|
285
|
+
visit(node.nodes, inModuleFunction || node.value.includes('.'));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (node.type !== 'word' || isSassOperator(node)) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (inModuleFunction || isSassOperator(meaningful[index - 1]) || isSassOperator(meaningful[index + 1])) {
|
|
292
|
+
operands.add(node);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
visit(parsed.nodes, false);
|
|
297
|
+
return operands;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Where the value starts inside the declaration as written: after the
|
|
302
|
+
* property and the colon with its spacing.
|
|
303
|
+
*/
|
|
243
304
|
function declarationValueIndex(decl) {
|
|
305
|
+
const between = decl.raws && typeof decl.raws.between === 'string' ? decl.raws.between : null;
|
|
306
|
+
if (between !== null && typeof decl.prop === 'string') {
|
|
307
|
+
return decl.prop.length + between.length;
|
|
308
|
+
}
|
|
244
309
|
const declarationText = decl.toString();
|
|
245
310
|
const idx = declarationText.indexOf(decl.value);
|
|
246
311
|
return idx === -1 ? 0 : idx;
|
|
247
312
|
}
|
|
248
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Blank the Sass inline comments of `scss` (the value as written) with spaces
|
|
316
|
+
* of the same length, so a parser never reads `// was 13px` as a value and
|
|
317
|
+
* every offset still points into the written text. postcss-scss keeps the
|
|
318
|
+
* value with each inline comment turned into a block comment in `raw`; the
|
|
319
|
+
* two are walked side by side so only real inline comments are blanked. Null
|
|
320
|
+
* when they do not line up.
|
|
321
|
+
*/
|
|
322
|
+
function blankScssInlineComments(scss, raw) {
|
|
323
|
+
let masked = '';
|
|
324
|
+
const comments = [];
|
|
325
|
+
let i = 0;
|
|
326
|
+
let j = 0;
|
|
327
|
+
while (j < scss.length) {
|
|
328
|
+
if (scss.startsWith('//', j) && raw.startsWith('/*', i)) {
|
|
329
|
+
let end = j + 2;
|
|
330
|
+
while (end < scss.length && !/[\n\r\f]/.test(scss[end])) end += 1;
|
|
331
|
+
const converted = `/*${scss.slice(j + 2, end).replace(/(\*\/|\/\*)/g, '*//*')}*/`;
|
|
332
|
+
if (raw.startsWith(converted, i)) {
|
|
333
|
+
comments.push({ end, start: j });
|
|
334
|
+
masked += ' '.repeat(end - j);
|
|
335
|
+
i += converted.length;
|
|
336
|
+
j = end;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (scss[j] !== raw[i]) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
masked += scss[j];
|
|
344
|
+
i += 1;
|
|
345
|
+
j += 1;
|
|
346
|
+
}
|
|
347
|
+
return i === raw.length ? { comments, masked } : null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The value of a declaration as written in the source (issue #233). PostCSS
|
|
352
|
+
* strips the comments from `decl.value` and keeps the written value in
|
|
353
|
+
* `raws.value.raw` (and postcss-scss keeps `//` comments in `raws.value.scss`),
|
|
354
|
+
* so offsets into `decl.value` point at the wrong character and writing
|
|
355
|
+
* `decl.value` drops the comments. Rules parse `parseText`, whose offsets are
|
|
356
|
+
* offsets into `text`, and write fixes back with writeDeclarationValue().
|
|
357
|
+
*/
|
|
358
|
+
function declarationValueSource(decl) {
|
|
359
|
+
const raws = decl.raws && decl.raws.value;
|
|
360
|
+
if (!raws || typeof raws.raw !== 'string' || raws.value !== decl.value) {
|
|
361
|
+
return { kind: 'value', parseText: decl.value, text: decl.value };
|
|
362
|
+
}
|
|
363
|
+
if (typeof raws.scss === 'string') {
|
|
364
|
+
const blanked = blankScssInlineComments(raws.scss, raws.raw);
|
|
365
|
+
if (blanked) {
|
|
366
|
+
return { comments: blanked.comments, kind: 'scss', parseText: blanked.masked, text: raws.scss };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { kind: 'raw', parseText: raws.raw, text: raws.raw };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `text` with every block comment blanked with spaces of the same length, for
|
|
374
|
+
* code that reads the value as an expression and cannot skip comments.
|
|
375
|
+
*/
|
|
376
|
+
function blankBlockComments(text) {
|
|
377
|
+
const parsed = valueParser(text);
|
|
378
|
+
let blanked = text;
|
|
379
|
+
parsed.walk((node) => {
|
|
380
|
+
if (node.type === 'comment') {
|
|
381
|
+
const end = node.sourceEndIndex;
|
|
382
|
+
blanked = `${blanked.slice(0, node.sourceIndex)}${' '.repeat(end - node.sourceIndex)}${blanked.slice(end)}`;
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
return blanked;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The edits a walk made to the parsed value, as spans of the parsed text. */
|
|
389
|
+
function collectValueEdits(nodes, parseText, edits) {
|
|
390
|
+
for (const node of nodes) {
|
|
391
|
+
const original = parseText.slice(node.sourceIndex, node.sourceEndIndex);
|
|
392
|
+
const current = valueParser.stringify(node);
|
|
393
|
+
if (current === original) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (node.type === 'function' && Array.isArray(node.nodes) && original.startsWith(`${node.value}(`)) {
|
|
397
|
+
collectValueEdits(node.nodes, parseText, edits);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
edits.push({ end: node.sourceEndIndex, start: node.sourceIndex, text: current });
|
|
401
|
+
}
|
|
402
|
+
return edits;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The value PostCSS keeps in `decl.value`: a comment next to whitespace or an edge is dropped. */
|
|
406
|
+
function cleanValue(raw) {
|
|
407
|
+
const drops = [];
|
|
408
|
+
valueParser(raw).walk((node) => {
|
|
409
|
+
if (node.type !== 'comment') return;
|
|
410
|
+
const before = raw[node.sourceIndex - 1];
|
|
411
|
+
const after = raw[node.sourceEndIndex];
|
|
412
|
+
if (before === undefined || after === undefined || /\s/.test(before) || /\s/.test(after)) {
|
|
413
|
+
drops.push(node);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
let clean = raw;
|
|
417
|
+
for (const node of drops.reverse()) {
|
|
418
|
+
clean = `${clean.slice(0, node.sourceIndex)}${clean.slice(node.sourceEndIndex)}`;
|
|
419
|
+
}
|
|
420
|
+
return clean;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Write edits ({ start, end, text } spans of `source.text`) into the
|
|
425
|
+
* declaration and keep the comments of the value as written (issue #233).
|
|
426
|
+
* Returns whether anything changed.
|
|
427
|
+
*/
|
|
428
|
+
function writeValueEdits(decl, source, edits) {
|
|
429
|
+
if (edits.length === 0) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const ordered = [...edits].sort((a, b) => b.start - a.start);
|
|
434
|
+
let text = source.text;
|
|
435
|
+
for (const edit of ordered) {
|
|
436
|
+
text = `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (source.kind === 'value') {
|
|
440
|
+
decl.value = text;
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let raw = text;
|
|
445
|
+
if (source.kind === 'scss') {
|
|
446
|
+
// The inline comments moved by the edits before them; turn each back into
|
|
447
|
+
// the block comment postcss-scss keeps in `raw`.
|
|
448
|
+
const shift = (offset) => ordered.reduce((sum, edit) => (edit.end <= offset ? sum + edit.text.length - (edit.end - edit.start) : sum), 0);
|
|
449
|
+
for (const comment of [...source.comments].reverse()) {
|
|
450
|
+
const start = comment.start + shift(comment.start);
|
|
451
|
+
const end = comment.end + shift(comment.start);
|
|
452
|
+
const body = text.slice(start + 2, end).replace(/(\*\/|\/\*)/g, '*//*');
|
|
453
|
+
raw = `${raw.slice(0, start)}/*${body}*/${raw.slice(end)}`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const clean = cleanValue(raw);
|
|
458
|
+
decl.value = clean;
|
|
459
|
+
decl.raws.value = source.kind === 'scss' ? { raw, scss: text, value: clean } : { raw, value: clean };
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Write the nodes a walk changed in `parsed` (parsed from `source.parseText`) back into the declaration. */
|
|
464
|
+
function writeDeclarationValue(decl, source, parsed) {
|
|
465
|
+
return writeValueEdits(decl, source, collectValueEdits(parsed.nodes, source.parseText, []));
|
|
466
|
+
}
|
|
467
|
+
|
|
249
468
|
module.exports = {
|
|
469
|
+
WALK_TOKEN_FALLBACK,
|
|
470
|
+
blankBlockComments,
|
|
250
471
|
collectMathBounds,
|
|
251
472
|
declarationValueIndex,
|
|
473
|
+
declarationValueSource,
|
|
252
474
|
isBoundFunction,
|
|
253
475
|
isKeyword,
|
|
254
476
|
isMathFunction,
|
|
255
477
|
isTokenFunction,
|
|
256
478
|
propertyMatches,
|
|
479
|
+
sassOperandNodes,
|
|
257
480
|
shouldLintMathArgument,
|
|
481
|
+
skipTokenFunction,
|
|
258
482
|
walkRootValueNodes,
|
|
259
483
|
walkTransformTranslateNodes,
|
|
484
|
+
writeDeclarationValue,
|
|
485
|
+
writeValueEdits,
|
|
260
486
|
};
|