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.
Files changed (44) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +5 -1
  3. package/package.json +1 -1
  4. package/src/audit/args.js +22 -2
  5. package/src/audit/baseline.js +184 -27
  6. package/src/audit/codemod.js +39 -14
  7. package/src/audit/config.js +25 -31
  8. package/src/audit/contract.js +96 -10
  9. package/src/audit/render-github.js +4 -0
  10. package/src/audit/render-html.js +7 -0
  11. package/src/audit/render-markdown.js +27 -0
  12. package/src/audit/render-text.js +23 -1
  13. package/src/audit/report.js +60 -17
  14. package/src/audit/scan/files.js +154 -27
  15. package/src/audit/scan/stylesheets.js +87 -34
  16. package/src/audit/scan/templates.js +22 -7
  17. package/src/audit/token-chains.js +18 -7
  18. package/src/cli/audit.js +7 -3
  19. package/src/cli/doctor.js +48 -7
  20. package/src/cli/fix.js +56 -15
  21. package/src/cli/init.js +136 -23
  22. package/src/cli/quickstart.js +34 -8
  23. package/src/core/fs-cache.js +6 -0
  24. package/src/core/length.js +15 -0
  25. package/src/core/options.js +8 -1
  26. package/src/core/scale-inference.js +99 -68
  27. package/src/core/tailwind-class-analysis.js +26 -16
  28. package/src/core/tailwind-motion-analysis.js +12 -3
  29. package/src/core/token-index.js +76 -24
  30. package/src/core/token-map.js +23 -10
  31. package/src/core/token-source-entries.js +64 -0
  32. package/src/core/token-sources.js +361 -69
  33. package/src/core/value-nodes.js +280 -54
  34. package/src/eslint/literal-spans.js +319 -0
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
  36. package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
  37. package/src/rules/no-offscale-transform/index.js +35 -14
  38. package/src/rules/prefer-token/index.js +72 -48
  39. package/src/rules/report.js +3 -1
  40. package/src/rules/use-motion-scale/index.js +68 -5
  41. package/src/rules/use-scale/index.js +54 -39
  42. package/types/__checks__/eslint-config.ts +37 -0
  43. package/types/audit.d.ts +10 -2
  44. package/types/shared.d.ts +21 -3
@@ -52,38 +52,43 @@ function isTokenFunction(node, tokenFunctions, tokenRegex) {
52
52
  return tokenRegex.test(firstArg);
53
53
  }
54
54
 
55
- function normalizeWalkContext(context) {
56
- if (!context) {
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
- if (typeof context === 'string') {
64
- return {
65
- parentFunctionArgIndex: null,
66
- parentFunctionName: context,
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 walkRootValueNodes(parsed, walkNode, state) {
83
- const initialContext = normalizeWalkContext(state);
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
- const walkNodes = (nodes, parentFunction) => {
86
- let argIndex = 1;
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 : initialContext.parentFunctionName,
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(parsed.nodes, null);
126
+ return walkNodes;
116
127
  }
117
128
 
118
- function walkTransformTranslateNodes(parsed, walkNode) {
119
- const walkNodes = (nodes, parentFunction) => {
120
- let argIndex = 1;
121
-
122
- for (const node of nodes) {
123
- if (node.type === 'div' && node.value === ',') {
124
- argIndex += 1;
125
- continue;
126
- }
129
+ function normalizeWalkContext(context) {
130
+ if (!context) {
131
+ return {
132
+ parentFunctionArgIndex: null,
133
+ parentFunctionName: null,
134
+ };
135
+ }
127
136
 
128
- const context = {
129
- parentFunctionArgIndex: parentFunction ? argIndex : null,
130
- parentFunctionName: parentFunction ? parentFunction.name : null,
131
- };
137
+ if (typeof context === 'string') {
138
+ return {
139
+ parentFunctionArgIndex: null,
140
+ parentFunctionName: context,
141
+ };
142
+ }
132
143
 
133
- if (node.type === 'function') {
134
- const fnName = node.value.toLowerCase();
135
- const skipChildren = walkNode(node, context);
136
- if (skipChildren) {
137
- continue;
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
- walkNodes(node.nodes, { name: fnName });
141
- continue;
142
- }
156
+ function walkRootValueNodes(parsed, walkNode, state) {
157
+ const initialContext = normalizeWalkContext(state);
158
+ createNodeWalker(walkNode, initialContext.parentFunctionName)(parsed.nodes, null);
159
+ }
143
160
 
144
- walkNode(node, context);
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
  };