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
@@ -14,6 +14,7 @@ const {
14
14
  propertyKindOf,
15
15
  } = require('./css-vocabulary');
16
16
  const { parseLengthToken } = require('./length');
17
+ const { VALID_TOKEN_SOURCE_FORMATS } = require('./token-sources');
17
18
  const {
18
19
  getScalePreset,
19
20
  listScalePresetNames,
@@ -100,7 +101,10 @@ function isScaleSourceEntry(value) {
100
101
  return true;
101
102
  }
102
103
 
103
- return isPlainObject(value) && (isNonEmptyString(value.path) || isNonEmptyString(value.file));
104
+ return isPlainObject(value)
105
+ && (isNonEmptyString(value.path) || isNonEmptyString(value.file))
106
+ // An unknown format is an invalid option, not a throw from the parser (issue #265).
107
+ && (value.format === undefined || VALID_TOKEN_SOURCE_FORMATS.has(String(value.format).trim().toLowerCase()));
104
108
  }
105
109
 
106
110
  function isScaleEntry(value) {
@@ -515,6 +519,8 @@ const USE_SCALE_VALIDATION_SCHEMA = Object.freeze({
515
519
 
516
520
  const NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA = Object.freeze({
517
521
  ...SCALE_VALIDATION_SCHEMA,
522
+ tokenFunctions: USE_SCALE_VALIDATION_SCHEMA.tokenFunctions,
523
+ tokenPattern: USE_SCALE_VALIDATION_SCHEMA.tokenPattern,
518
524
  });
519
525
 
520
526
  const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
@@ -809,5 +815,6 @@ module.exports = {
809
815
  createPropertyScaleResolver,
810
816
  isGroupScalesMap,
811
817
  isPlainObject,
818
+ isScaleEntry,
812
819
  resolvePropertyScale,
813
820
  };
@@ -12,6 +12,7 @@ const {
12
12
  parseTokenSources,
13
13
  parseTokenValueLength,
14
14
  } = require('./token-sources');
15
+ const { normalizeTokenSourceEntry } = require('./token-source-entries');
15
16
  const { getScalePreset } = require('../presets/scales');
16
17
 
17
18
  // Matches the audit default so lint and audit agree on what a spacing token is.
@@ -28,7 +29,6 @@ const RC_FILE = '.rhythmguardrc.json';
28
29
 
29
30
  const { cachedByFiles } = require('./fs-cache');
30
31
 
31
- const sourceCache = new Map();
32
32
  const TOKEN_PACKAGES = require('./token-packages.json').packages;
33
33
 
34
34
  /**
@@ -112,12 +112,17 @@ function discoverTokenPackagesUncached(cwd, consult) {
112
112
  return sources;
113
113
  }
114
114
 
115
+ /** A non-zero number without a unit is a ratio or a multiplier, not a length (issue #268). */
116
+ function isUnitlessNonZero(parsed) {
117
+ return parsed.unit === '' && parsed.number !== 0;
118
+ }
119
+
115
120
  function pxValuesFromKeys(keys, baseFontSize) {
116
121
  const values = new Set([0]);
117
122
 
118
123
  for (const key of keys) {
119
124
  const parsed = parseLengthToken(String(key));
120
- if (!parsed) {
125
+ if (!parsed || isUnitlessNonZero(parsed)) {
121
126
  continue;
122
127
  }
123
128
 
@@ -130,59 +135,53 @@ function pxValuesFromKeys(keys, baseFontSize) {
130
135
  return Array.from(values).sort((a, b) => a - b);
131
136
  }
132
137
 
133
- function normalizeSource(source, baseDir) {
134
- if (typeof source === 'string') {
135
- return { format: 'auto', path: path.resolve(baseDir, source) };
136
- }
137
-
138
- if (source && typeof source === 'object') {
139
- const rawPath = source.path || source.file;
140
- if (typeof rawPath !== 'string') {
141
- return null;
138
+ /**
139
+ * Parse token sources once per (source set, base font size), revalidated by
140
+ * the mtime of every source file. The entry is keyed by the paths alone and
141
+ * replaced when a file changes, so an editor that saves a token file a
142
+ * thousand times keeps one entry, not a thousand (issue #264). Entries that
143
+ * do not normalise (a bad format, no path) are skipped with a warning; they
144
+ * never throw out of a lint.
145
+ */
146
+ function parseSourcesCached(sources, baseFontSize) {
147
+ const normalized = [];
148
+ const warnings = [];
149
+ for (const entry of sources) {
150
+ const { error, source } = normalizeTokenSourceEntry(entry, process.cwd());
151
+ if (error) {
152
+ warnings.push(error);
153
+ } else {
154
+ normalized.push(source);
142
155
  }
143
-
144
- return {
145
- format: typeof source.format === 'string' ? source.format : 'auto',
146
- path: path.resolve(source.baseDir || baseDir, rawPath),
147
- ...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
148
- };
149
156
  }
150
-
151
- return null;
152
- }
153
-
154
- function cacheKey(sources) {
155
- return sources
156
- .map((source) => {
157
- let mtime = 'missing';
158
- try {
159
- mtime = String(fs.statSync(source.path).mtimeMs);
160
- } catch {
161
- // missing file: key still changes when it appears
162
- }
163
- return `${source.path}|${source.format}|${source.tokenPattern || ''}|${mtime}`;
164
- })
165
- .join('\n');
166
- }
167
-
168
- /** Parse token sources once per (sources, base font size, file mtimes). */
169
- function parseSourcesCached(sources, baseFontSize) {
170
- const normalized = sources.map((source) => normalizeSource(source, process.cwd())).filter(Boolean);
171
157
  if (normalized.length === 0) {
172
- return null;
173
- }
174
- const key = `${baseFontSize}\n${cacheKey(normalized)}`;
175
- if (!sourceCache.has(key)) {
176
- sourceCache.set(key, parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' }));
177
- }
178
- return sourceCache.get(key);
158
+ return warnings.length > 0 ? { definitions: new Map(), sources: [], warnings } : null;
159
+ }
160
+ const files = normalized.map((source) => path.resolve(source.baseDir, source.path));
161
+ const key = [
162
+ `token-sources:${baseFontSize}`,
163
+ ...normalized.map((source, index) => `${files[index]}|${source.format}|${source.tokenPattern || ''}`),
164
+ ].join('\n');
165
+ const parsed = cachedByFiles(key, (consult) => {
166
+ for (const file of files) {
167
+ consult(file);
168
+ }
169
+ return parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
170
+ });
171
+ return warnings.length > 0 ? { ...parsed, warnings: [...warnings, ...parsed.warnings] } : parsed;
179
172
  }
180
173
 
181
- function scaleFromSources(sources, baseFontSize) {
174
+ /**
175
+ * The scale a list of token sources defines, or null. Warnings from sources
176
+ * that are missing or do not parse go into `warnings` either way, so a
177
+ * fallback can say which source failed (issue #270).
178
+ */
179
+ function scaleFromSources(sources, baseFontSize, warnings = []) {
182
180
  const parsed = parseSourcesCached(sources, baseFontSize);
183
181
  if (!parsed) {
184
182
  return null;
185
183
  }
184
+ warnings.push(...parsed.warnings);
186
185
  // scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
187
186
  const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
188
187
  return scale
@@ -203,7 +202,7 @@ function scaleFromSources(sources, baseFontSize) {
203
202
  */
204
203
  function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenPattern = null, tokenRegex }) {
205
204
  const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
206
- const sources = [...scaleSources, ...rcTokenSources(process.cwd()), ...discoverTokenPackages(process.cwd())];
205
+ const sources = [...scaleSources, ...rcTokenSources(process.cwd()).sources, ...discoverTokenPackages(process.cwd())];
207
206
  // A rule's own tokenPattern (`^--space-|^--radius-`) also applies to the
208
207
  // files it reads, on top of the spacing names, unless a source sets its own.
209
208
  const withPattern = tokenPattern
@@ -323,7 +322,7 @@ function expandTailwindBase(scale, baseKeys, baseFontSize) {
323
322
  function firstPositivePx(keys, baseFontSize) {
324
323
  for (const key of keys) {
325
324
  const parsed = parseLengthToken(String(key));
326
- if (!parsed) {
325
+ if (!parsed || isUnitlessNonZero(parsed)) {
327
326
  continue;
328
327
  }
329
328
  const px = toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize);
@@ -342,25 +341,38 @@ function rcTokenSources(cwd) {
342
341
  });
343
342
  }
344
343
 
344
+ /**
345
+ * Token sources from `audit.tokenSources` in .rhythmguardrc.json, read through
346
+ * the normaliser the audit uses, so lint and audit read the same files
347
+ * (issue #271). A file that does not parse or an entry that does not
348
+ * normalise is a warning, never a throw (issue #265).
349
+ */
345
350
  function rcTokenSourcesUncached(rcPath) {
346
351
  if (!fs.existsSync(rcPath)) {
347
- return [];
352
+ return { sources: [], warnings: [] };
348
353
  }
349
354
 
350
355
  let config;
351
356
  try {
352
357
  config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
353
- } catch {
354
- return [];
358
+ } catch (err) {
359
+ return { sources: [], warnings: [`${RC_FILE} could not be read: ${err.message}`] };
355
360
  }
356
361
 
357
362
  const audit = config && typeof config === 'object' ? config.audit : null;
358
- const sources = audit && Array.isArray(audit.tokenSources) ? audit.tokenSources : [];
363
+ const entries = audit && Array.isArray(audit.tokenSources) ? audit.tokenSources : [];
359
364
  const baseDir = path.dirname(rcPath);
360
-
361
- return sources
362
- .map((source) => normalizeSource(source, baseDir))
363
- .filter(Boolean);
365
+ const sources = [];
366
+ const warnings = [];
367
+ for (const entry of entries) {
368
+ const { error, source } = normalizeTokenSourceEntry(entry, baseDir);
369
+ if (error) {
370
+ warnings.push(`${RC_FILE}: ${error}`);
371
+ } else {
372
+ sources.push(source);
373
+ }
374
+ }
375
+ return { sources, warnings };
364
376
  }
365
377
 
366
378
  /**
@@ -372,7 +384,10 @@ function rcTokenSourcesUncached(rcPath) {
372
384
  function sassTokensFromRoot(root) {
373
385
  const lines = [];
374
386
  root.walkDecls((decl) => {
375
- if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
387
+ // Only top-level or `!global` variables are the project's; `$spacer: 2px`
388
+ // inside `.compact` is local to that block (issue #266).
389
+ const global = (decl.parent && decl.parent.type === 'root') || /!global\b/.test(String(decl.value));
390
+ if (typeof decl.prop === 'string' && decl.prop.startsWith('$') && global) {
376
391
  lines.push(`${decl.prop}: ${decl.value};`);
377
392
  }
378
393
  });
@@ -408,12 +423,16 @@ function resolveAutoScale({
408
423
  tailwindConfigPath = null,
409
424
  tokenPattern = DEFAULT_AUTO_TOKEN_PATTERN,
410
425
  } = {}) {
411
- const fromOption = scaleFromSources(scaleSources, baseFontSize);
426
+ // Warnings from every source tried, for the fallback note when none yields a scale.
427
+ const warnings = [];
428
+ const fromOption = scaleFromSources(scaleSources, baseFontSize, warnings);
412
429
  if (fromOption) {
413
430
  return { source: 'scaleSources', ...fromOption };
414
431
  }
415
432
 
416
- const fromRc = scaleFromSources(rcTokenSources(process.cwd()), baseFontSize);
433
+ const rc = rcTokenSources(process.cwd());
434
+ warnings.push(...rc.warnings);
435
+ const fromRc = scaleFromSources(rc.sources, baseFontSize, warnings);
417
436
  if (fromRc) {
418
437
  return { source: 'rhythmguardrc', ...fromRc };
419
438
  }
@@ -454,12 +473,12 @@ function resolveAutoScale({
454
473
  }
455
474
  }
456
475
 
457
- const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize);
476
+ const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize, warnings);
458
477
  if (fromPackages) {
459
478
  return { source: 'token-package', ...fromPackages };
460
479
  }
461
480
 
462
- return fallbackInference(rejected);
481
+ return fallbackInference(rejected, warnings);
463
482
  }
464
483
 
465
484
  const TRUSTED_SCALE_SOURCES = new Set(['scaleSources', 'rhythmguardrc', 'token-sources', 'tailwind', 'token-package', 'explicit', 'default']);
@@ -504,7 +523,7 @@ function assessScale({ files = null, source, values = [] } = {}) {
504
523
  return { plausible: reasons.length === 0, reasons };
505
524
  }
506
525
 
507
- function fallbackInference(rejected = null) {
526
+ function fallbackInference(rejected = null, warnings = []) {
508
527
  return {
509
528
  files: [],
510
529
  preset: FALLBACK_PRESET,
@@ -512,7 +531,7 @@ function fallbackInference(rejected = null) {
512
531
  scale: getScalePreset(FALLBACK_PRESET),
513
532
  source: 'fallback',
514
533
  tokenCount: 0,
515
- warnings: [],
534
+ warnings,
516
535
  };
517
536
  }
518
537
 
@@ -545,7 +564,8 @@ function withResolvedScale(options, root) {
545
564
  const GROUP_TOKEN_PATTERNS = Object.freeze({
546
565
  radius: /^--radius-/,
547
566
  size: /^--(?:size|width|height|container)-/,
548
- typography: /^--(?:font-size|text|fs)-/,
567
+ // Tailwind's `--text-sm--line-height` is a sub-token of `--text-sm`, not a font size (issue #262).
568
+ typography: /^--(?:font-size|text|fs)-(?!.*--)/,
549
569
  });
550
570
 
551
571
  /**
@@ -556,13 +576,18 @@ const GROUP_TOKEN_PATTERNS = Object.freeze({
556
576
  */
557
577
  function inferGroupScale(group, options, root) {
558
578
  if (group === 'spacing') {
559
- return options.scaleAuto ? options.scale : resolveAutoScale({
579
+ if (options.scaleAuto) {
580
+ return options.scale;
581
+ }
582
+ const inference = resolveAutoScale({
560
583
  baseFontSize: options.baseFontSize,
561
584
  root,
562
585
  scaleSources: options.scaleSources,
563
586
  tailwindConfigPath: options.tailwindConfigPath,
564
587
  tokenPattern: DEFAULT_AUTO_TOKEN_PATTERN,
565
- }).scale;
588
+ });
589
+ // No spacing tokens: the group uses `scale`, like every other group (issue #261).
590
+ return inference.source === 'fallback' ? null : inference.scale;
566
591
  }
567
592
  const pattern = GROUP_TOKEN_PATTERNS[group];
568
593
  const definitions = collectTokenDefinitions({
@@ -581,10 +606,16 @@ function autoScaleFallbackNote(inference) {
581
606
  return '';
582
607
  }
583
608
  if (inference.rejected) {
584
- return `The spacing tokens found do not form a spacing scale (${inference.rejected.reasons.join(', ')}); using preset "${inference.preset}".`;
609
+ return `The spacing tokens found do not form a spacing scale (${inference.rejected.reasons.join(', ')}); using preset "${inference.preset}".${sourceWarningsNote(inference.warnings)}`;
585
610
  }
586
611
 
587
- return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".`;
612
+ return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".${sourceWarningsNote(inference.warnings)}`;
613
+ }
614
+
615
+ /** Why the token sources gave nothing: a missing file, a parse error, a bad rc entry (issue #270). */
616
+ function sourceWarningsNote(warnings) {
617
+ const unique = Array.from(new Set(warnings || []));
618
+ return unique.map((warning) => ` ${/[.!?]$/.test(warning) ? warning : `${warning}.`}`).join('');
588
619
  }
589
620
 
590
621
  module.exports = {
@@ -6,34 +6,39 @@ const {
6
6
  nearestScaleValues,
7
7
  nearestText,
8
8
  normalizeScale,
9
- numbersEqual,
10
9
  parseLengthToken,
10
+ pxOnScale,
11
11
  toPx,
12
12
  } = require('./length');
13
13
 
14
+ // The ESLint rule's and the audit's default scale. It is not the `rhythmic-4`
15
+ // preset (which continues to 40, 48, 64); unifying the defaults changes
16
+ // reports, so it waits for a major (#246).
14
17
  const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
15
18
  const DEFAULT_UNITS = ['px', 'rem', 'em'];
16
19
  // Tailwind's numeric spacing utilities are multiples of one unit: `--spacing`
17
- // (0.25rem, 4px) in v4, and the same 4px step in the v3 default scale.
18
- const DEFAULT_SPACING_UNIT_PX = 4;
20
+ // (0.25rem) in v4, and the same 0.25rem step in the v3 default scale. In px
21
+ // that is a quarter of the root font size: 4px at 16px, 2.5px at 10px (#247).
22
+ const SPACING_UNIT_REM = 0.25;
19
23
 
20
24
  const ARBITRARY_SPACING_CLASS = /^(?<utility>-?(?:m(?:[trblxy])?|p(?:[trblxy])?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|scroll-(?:m|p)(?:[trblxy])?))-\[(?<rawValue>[^\]]+)\]$/;
21
25
 
22
26
  function normalizeTailwindClassOptions(option = {}) {
27
+ const baseFontSize =
28
+ typeof option.baseFontSize === 'number' &&
29
+ Number.isFinite(option.baseFontSize) &&
30
+ option.baseFontSize > 0
31
+ ? option.baseFontSize
32
+ : 16;
23
33
  return {
24
34
  allowNegative: option.allowNegative !== false,
25
- baseFontSize:
26
- typeof option.baseFontSize === 'number' &&
27
- Number.isFinite(option.baseFontSize) &&
28
- option.baseFontSize > 0
29
- ? option.baseFontSize
30
- : 16,
35
+ baseFontSize,
31
36
  scale: Array.isArray(option.scale) ? option.scale : DEFAULT_SCALE,
32
37
  spacingUnit: option.spacingUnit === false
33
38
  ? null
34
39
  : typeof option.spacingUnit === 'number' && Number.isFinite(option.spacingUnit) && option.spacingUnit > 0
35
40
  ? option.spacingUnit
36
- : DEFAULT_SPACING_UNIT_PX,
41
+ : baseFontSize * SPACING_UNIT_REM,
37
42
  units: Array.isArray(option.units)
38
43
  ? option.units.map((unit) => String(unit).toLowerCase())
39
44
  : DEFAULT_UNITS,
@@ -167,7 +172,10 @@ function analyzeClassToken(token, options, scalePx) {
167
172
  return null;
168
173
  }
169
174
 
170
- if (!options.allowNegative && parsedLength.number < 0) {
175
+ // `-m-[16px]` and `m-[-16px]` are both negative margins (#241).
176
+ const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
177
+
178
+ if (!options.allowNegative && negative) {
171
179
  return {
172
180
  fixedToken: null,
173
181
  nearest: null,
@@ -187,7 +195,7 @@ function analyzeClassToken(token, options, scalePx) {
187
195
  return null;
188
196
  }
189
197
 
190
- const isOnScale = scalePx.some((entry) => numbersEqual(entry, pxValue));
198
+ const isOnScale = pxOnScale(scalePx, pxValue, parsedLength.unit, options.baseFontSize);
191
199
  if (isOnScale) {
192
200
  return null;
193
201
  }
@@ -205,12 +213,14 @@ function analyzeClassToken(token, options, scalePx) {
205
213
  ? signedNearest
206
214
  : signedNearest / options.baseFontSize;
207
215
 
208
- const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
209
216
  const wrap = (candidate) =>
210
217
  `${parsedToken.prefix}${parsedToken.leadingImportant}${candidate}${parsedToken.trailingImportant}`;
211
- const utilityFix = utilityClassFor(match.groups.utility, nearest.nearest, negative, options.spacingUnit);
212
- const lowerUtility = utilityClassFor(match.groups.utility, nearest.lower, negative, options.spacingUnit);
213
- const upperUtility = utilityClassFor(match.groups.utility, nearest.upper, negative, options.spacingUnit);
218
+ // Utility classes are root-relative; an `em` value follows the element's own
219
+ // font size, so it keeps the arbitrary form and its unit (#247).
220
+ const spacingUnit = parsedLength.unit === 'em' ? null : options.spacingUnit;
221
+ const utilityFix = utilityClassFor(match.groups.utility, nearest.nearest, negative, spacingUnit);
222
+ const lowerUtility = utilityClassFor(match.groups.utility, nearest.lower, negative, spacingUnit);
223
+ const upperUtility = utilityClassFor(match.groups.utility, nearest.upper, negative, spacingUnit);
214
224
 
215
225
  const replacementValue = formatLength(replacementNumber, parsedLength.unit);
216
226
  const fixedCandidate = utilityFix || parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
@@ -15,6 +15,9 @@ const {
15
15
  const { findClassSegments } = require('./tailwind-class-analysis');
16
16
 
17
17
  const ARBITRARY_MOTION_CLASS = /^(?<utility>duration|delay|ease)-\[(?<rawValue>[^\]]+)\]$/;
18
+ // The raw curves the Stylelint rule reports; anything else in `ease-[...]`,
19
+ // such as `var(--ease-out)` or `--my-ease`, is already a token (#245).
20
+ const RAW_EASING_CURVE = /^(?:cubic-bezier|linear|steps)\(/i;
18
21
 
19
22
  function normalizeTailwindMotionOptions(option = {}) {
20
23
  return {
@@ -85,6 +88,10 @@ function analyzeMotionToken(token, options) {
85
88
  }
86
89
 
87
90
  if (match.groups.utility === 'ease') {
91
+ if (!RAW_EASING_CURVE.test(match.groups.rawValue.trim())) {
92
+ return null;
93
+ }
94
+
88
95
  return {
89
96
  fixedToken: null,
90
97
  rawValue: match.groups.rawValue,
@@ -98,7 +105,9 @@ function analyzeMotionToken(token, options) {
98
105
  return null;
99
106
  }
100
107
 
101
- if (parsedTime.number < 0) {
108
+ // A negative delay starts the motion partway through and is valid; only a
109
+ // negative duration is (#236). The size of a delay is still checked.
110
+ if (parsedTime.number < 0 && match.groups.utility === 'duration') {
102
111
  return {
103
112
  fixedToken: null,
104
113
  nearest: null,
@@ -112,7 +121,7 @@ function analyzeMotionToken(token, options) {
112
121
  return null;
113
122
  }
114
123
 
115
- const msValue = toMs(parsedTime.number, parsedTime.unit);
124
+ const msValue = toMs(Math.abs(parsedTime.number), parsedTime.unit);
116
125
  if (msValue === null) {
117
126
  return null;
118
127
  }
@@ -132,7 +141,7 @@ function analyzeMotionToken(token, options) {
132
141
  return null;
133
142
  }
134
143
 
135
- const replacementValue = formatTime(converted, parsedTime.unit);
144
+ const replacementValue = formatTime(parsedTime.number < 0 ? -converted : converted, parsedTime.unit);
136
145
  const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
137
146
  const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
138
147
 
@@ -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,47 @@ 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).
138
+ * `avoidTokens` are the var() names a fallback literal falls back from; one
139
+ * of them is never written, the literal is.
101
140
  */
102
- function replacementFor(parsedLength, nearestPx, options, prop = null) {
141
+ function replacementFor(parsedLength, nearest, options, prop = null, avoidTokens = []) {
103
142
  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;
143
+ const unit = parsedLength.unit || 'px';
144
+ const nearestPx = options.unitStrategy === 'exact' ? toPx(Math.abs(nearest), unit, options.baseFontSize) : Math.abs(nearest);
145
+ if (nearestPx !== null) {
146
+ const signedPx = parsedLength.number < 0 ? -nearestPx : nearestPx;
147
+ const { negate, tokens } = signedTokensForLength(options.tokenIndex, signedPx, unit, { prop });
148
+ // A var() fallback never falls back to the token it is the fallback of (issue #240).
149
+ if (tokens.length === 1 && !avoidTokens.includes(tokens[0])) {
150
+ const token = `var(${tokens[0]})`;
151
+ return negate ? negateReplacement(token) : token;
152
+ }
107
153
  }
108
154
  }
109
- return fixedLengthValue(parsedLength, nearestPx, options);
155
+ return fixedLengthValue(parsedLength, nearest, options);
110
156
  }
111
157
 
112
158
  /**
113
159
  * 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.
160
+ * holds the snapped value. Empty when the replacement is a literal. A token
161
+ * written as is for a negative literal holds the negative value.
115
162
  */
116
- function tokenHoldsNote(replacement, formattedNearest) {
163
+ function tokenHoldsNote(replacement, formattedNearest, negativeLiteral = false) {
117
164
  const match = typeof replacement === 'string' ? replacement.match(/var\(--[^)]+\)/) : null;
118
- return match ? `${match[0]} holds ${formattedNearest}.` : '';
165
+ if (!match) {
166
+ return '';
167
+ }
168
+ const holdsNegative = negativeLiteral && replacement === match[0];
169
+ return `${match[0]} holds ${holdsNegative ? `-${formattedNearest}` : formattedNearest}.`;
119
170
  }
120
171
 
121
172
  module.exports = {
122
173
  negateReplacement,
123
174
  replacementFor,
175
+ signedTokensForLength,
124
176
  tokenFitsProperty,
125
177
  tokenHoldsNote,
126
178
  tokenForLength,