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
@@ -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 rawText = sourceCode.getText(node);
12
- let value = null;
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
- let fixedValue = value;
34
- let fixedValueOffset = 0;
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
- fixedText && analysis.reason === 'duration'
60
- ? (fixer) => fixer.replaceText(node, fixedText)
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 rawText = sourceCode.getText(node);
10
- let value = null;
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
- let fixedValue = value;
32
- let fixedValueOffset = 0;
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
- fixedText && analysis.reason !== 'negative'
52
- ? (fixer) => fixer.replaceText(node, fixedText)
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
- return {
103
- Literal(node) {
104
- maybeCheckNodeText(node, sourceCode, context, analyzer, true);
105
- },
106
- TemplateElement(node) {
107
- maybeCheckNodeText(node, sourceCode, context, analyzer, false);
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: new RegExp(options.tokenPattern),
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 parsed = valueParser(decl.value);
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 === '%' && options.allowPercentages) {
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.some((entry) => numbersEqual(entry, pxValue));
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.value = parsed.toString();
264
+ writeDeclarationValue(decl, source, parsed);
244
265
  }
245
266
  });
246
267
  };