stylelint-plugin-rhythmguard 3.10.2 → 3.11.0

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