stylelint-plugin-rhythmguard 3.5.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/CONTRIBUTING.md +8 -5
- package/README.md +8 -10
- package/SECURITY.md +3 -2
- package/agents/claude-code/SKILL.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/rhythmguard.mdc +1 -1
- package/package.json +2 -1
- package/src/audit/args.js +1 -0
- package/src/audit/baseline.js +55 -11
- package/src/audit/codemod.js +129 -0
- package/src/audit/config.js +5 -0
- package/src/audit/contract.js +1 -0
- package/src/audit/decisions.js +105 -0
- package/src/audit/render-github.js +5 -2
- package/src/audit/render-markdown.js +31 -0
- package/src/audit/render-text.js +11 -0
- package/src/audit/report.js +30 -2
- package/src/audit/scan/stylesheets.js +1 -0
- package/src/audit/scan/templates.js +2 -4
- package/src/audit/shared.js +2 -0
- package/src/audit/token-chains.js +157 -0
- package/src/cli/audit.js +5 -0
- package/src/cli/fix.js +151 -0
- package/src/cli/index.js +4 -0
- package/src/core/decisions.js +121 -0
- package/src/core/fs-cache.js +36 -0
- package/src/core/length.js +1 -0
- package/src/core/options.js +40 -0
- package/src/core/scale-inference.js +34 -37
- package/src/core/tailwind-class-analysis.js +59 -3
- package/src/core/token-index.js +82 -0
- package/src/core/token-map.js +50 -13
- package/src/core/token-sources.js +3 -2
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +6 -2
- package/src/eslint/rules/tailwind-class-use-scale.js +13 -13
- package/src/rules/no-offscale-transform/index.js +27 -6
- package/src/rules/prefer-token/index.js +20 -29
- package/src/rules/report.js +6 -0
- package/src/rules/use-motion-scale/index.js +12 -8
- package/src/rules/use-scale/index.js +35 -6
- package/types/audit.d.ts +31 -0
- package/types/shared.d.ts +6 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Editors and pre-commit hooks lint one file at a time, and each lint asks the
|
|
7
|
+
* filesystem the same questions: which package.json files sit between cwd and
|
|
8
|
+
* the repository, what .rhythmguardrc.json says, whether a token package is
|
|
9
|
+
* installed. The answers change only when one of those files changes, so a
|
|
10
|
+
* result is cached per key and revalidated by the mtime of every file the
|
|
11
|
+
* computation consulted: a stat per file instead of a read, a parse and a walk.
|
|
12
|
+
*/
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
|
|
15
|
+
function fileStamp(file) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.statSync(file).mtimeMs;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cachedByFiles(cacheKey, compute) {
|
|
24
|
+
const cached = cache.get(cacheKey);
|
|
25
|
+
if (cached && cached.stamps.every(([file, stamp]) => fileStamp(file) === stamp)) {
|
|
26
|
+
return cached.value;
|
|
27
|
+
}
|
|
28
|
+
const consulted = [];
|
|
29
|
+
const value = compute((file) => consulted.push([file, fileStamp(file)]));
|
|
30
|
+
cache.set(cacheKey, { stamps: consulted, value });
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
cachedByFiles,
|
|
36
|
+
};
|
package/src/core/length.js
CHANGED
package/src/core/options.js
CHANGED
|
@@ -54,6 +54,22 @@ function isNonEmptyString(value) {
|
|
|
54
54
|
return typeof value === 'string' && value.trim().length > 0;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const MAX_NOTE_LENGTH = 200;
|
|
58
|
+
|
|
59
|
+
/** A `note` is appended to every finding, so it stays a sentence: non-empty, at most 200 characters. */
|
|
60
|
+
function isNote(value) {
|
|
61
|
+
return isNonEmptyString(value) && value.trim().length <= MAX_NOTE_LENGTH;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeNote(value) {
|
|
65
|
+
return isNote(value) ? value.trim() : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The message with the configured note after it, or the message alone. */
|
|
69
|
+
function withNote(message, note) {
|
|
70
|
+
return note ? `${message} ${note}` : message;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
function isSupportedUnit(value) {
|
|
58
74
|
if (!isNonEmptyString(value)) {
|
|
59
75
|
return false;
|
|
@@ -62,6 +78,10 @@ function isSupportedUnit(value) {
|
|
|
62
78
|
return SUPPORTED_SCALE_UNITS.has(value.trim().toLowerCase());
|
|
63
79
|
}
|
|
64
80
|
|
|
81
|
+
function isFixWith(value) {
|
|
82
|
+
return value === 'value' || value === 'token';
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
function isUnitStrategy(value) {
|
|
66
86
|
return value === 'convert' || value === 'exact';
|
|
67
87
|
}
|
|
@@ -359,6 +379,9 @@ function resolveUnits(options) {
|
|
|
359
379
|
}
|
|
360
380
|
|
|
361
381
|
const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
382
|
+
decisions: Object.freeze({
|
|
383
|
+
entryValidator: isBoolean,
|
|
384
|
+
}),
|
|
362
385
|
allowHairlines: Object.freeze({
|
|
363
386
|
entryValidator: isBoolean,
|
|
364
387
|
}),
|
|
@@ -378,6 +401,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
|
378
401
|
enforceInsideMathFunctions: Object.freeze({
|
|
379
402
|
entryValidator: isBoolean,
|
|
380
403
|
}),
|
|
404
|
+
fixWith: Object.freeze({
|
|
405
|
+
entryValidator: isFixWith,
|
|
406
|
+
}),
|
|
381
407
|
fixToScale: Object.freeze({
|
|
382
408
|
entryValidator: isBoolean,
|
|
383
409
|
}),
|
|
@@ -389,6 +415,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
|
389
415
|
entryValidator: isMathFunctionArgumentMap,
|
|
390
416
|
expectsObject: true,
|
|
391
417
|
}),
|
|
418
|
+
note: Object.freeze({
|
|
419
|
+
entryValidator: isNote,
|
|
420
|
+
}),
|
|
392
421
|
preset: Object.freeze({
|
|
393
422
|
entryValidator: isNonEmptyString,
|
|
394
423
|
}),
|
|
@@ -473,6 +502,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
|
|
|
473
502
|
entryValidator: isMathFunctionArgumentMap,
|
|
474
503
|
expectsObject: true,
|
|
475
504
|
}),
|
|
505
|
+
note: Object.freeze({
|
|
506
|
+
entryValidator: isNote,
|
|
507
|
+
}),
|
|
476
508
|
preset: Object.freeze({
|
|
477
509
|
entryValidator: isNonEmptyString,
|
|
478
510
|
}),
|
|
@@ -545,6 +577,7 @@ function buildScaleOptions(rawOptions) {
|
|
|
545
577
|
|
|
546
578
|
return {
|
|
547
579
|
allowHairlines: options.allowHairlines !== false,
|
|
580
|
+
readDecisions: options.decisions !== false,
|
|
548
581
|
allowNegative: options.allowNegative !== false,
|
|
549
582
|
allowPercentages: options.allowPercentages !== false,
|
|
550
583
|
baseFontSize:
|
|
@@ -555,12 +588,14 @@ function buildScaleOptions(rawOptions) {
|
|
|
555
588
|
: 16,
|
|
556
589
|
enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
|
|
557
590
|
fixToScale: options.fixToScale !== false,
|
|
591
|
+
fixWith: options.fixWith === 'token' ? 'token' : 'value',
|
|
558
592
|
ignoreMathFunctionArguments: normalizeMathFunctionArgumentMap(options.ignoreMathFunctionArguments),
|
|
559
593
|
ignoreValues: Array.isArray(options.ignoreValues)
|
|
560
594
|
? options.ignoreValues.map((value) => String(value).toLowerCase())
|
|
561
595
|
: DEFAULT_IGNORE_KEYWORDS,
|
|
562
596
|
invalidPreset: scaleSelection.invalidPreset,
|
|
563
597
|
mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
|
|
598
|
+
note: normalizeNote(options.note),
|
|
564
599
|
preset: scaleSelection.selectedPreset,
|
|
565
600
|
presetNames: listScalePresetNames(),
|
|
566
601
|
properties: resolvePropertyPatterns(options),
|
|
@@ -606,6 +641,7 @@ function buildTokenOptions(rawOptions) {
|
|
|
606
641
|
: DEFAULT_IGNORE_KEYWORDS,
|
|
607
642
|
invalidPreset: scaleSelection.invalidPreset,
|
|
608
643
|
mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
|
|
644
|
+
note: normalizeNote(options.note),
|
|
609
645
|
preset: scaleSelection.selectedPreset,
|
|
610
646
|
presetNames: listScalePresetNames(),
|
|
611
647
|
properties: resolvePropertyPatterns(options),
|
|
@@ -689,6 +725,10 @@ function resolvePropertyScale(prop, options) {
|
|
|
689
725
|
}
|
|
690
726
|
|
|
691
727
|
module.exports = {
|
|
728
|
+
MAX_NOTE_LENGTH,
|
|
729
|
+
isNote,
|
|
730
|
+
normalizeNote,
|
|
731
|
+
withNote,
|
|
692
732
|
NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
|
|
693
733
|
NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
|
|
694
734
|
PREFER_TOKEN_POSSIBLE_OPTIONS,
|
|
@@ -26,6 +26,8 @@ const FALLBACK_PRESET = 'rhythmic-4';
|
|
|
26
26
|
const MIN_INFERRED_SCALE_LENGTH = 4;
|
|
27
27
|
const RC_FILE = '.rhythmguardrc.json';
|
|
28
28
|
|
|
29
|
+
const { cachedByFiles } = require('./fs-cache');
|
|
30
|
+
|
|
29
31
|
const sourceCache = new Map();
|
|
30
32
|
const TOKEN_PACKAGES = require('./token-packages.json').packages;
|
|
31
33
|
|
|
@@ -53,35 +55,6 @@ function readDirectDependencies(dir) {
|
|
|
53
55
|
* installs are found by walking up; a stray global node_modules is never
|
|
54
56
|
* consulted because the walk stops at the repository.
|
|
55
57
|
*/
|
|
56
|
-
/**
|
|
57
|
-
* Editors and pre-commit hooks lint one file at a time, and each lint asked
|
|
58
|
-
* the filesystem the same questions: which package.json files sit between
|
|
59
|
-
* cwd and the repository, what they declare, and whether a token package is
|
|
60
|
-
* installed. The answers change only when one of those files changes, so
|
|
61
|
-
* the result is cached per cwd and revalidated by mtime, which costs a stat
|
|
62
|
-
* per file instead of a read, a JSON parse and a directory walk.
|
|
63
|
-
*/
|
|
64
|
-
const discoveryCache = new Map();
|
|
65
|
-
|
|
66
|
-
function fileStamp(file) {
|
|
67
|
-
try {
|
|
68
|
-
return fs.statSync(file).mtimeMs;
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function cachedByFiles(cacheKey, compute) {
|
|
75
|
-
const cached = discoveryCache.get(cacheKey);
|
|
76
|
-
if (cached && cached.stamps.every(([file, stamp]) => fileStamp(file) === stamp)) {
|
|
77
|
-
return cached.value;
|
|
78
|
-
}
|
|
79
|
-
const consulted = [];
|
|
80
|
-
const value = compute((file) => consulted.push([file, fileStamp(file)]));
|
|
81
|
-
discoveryCache.set(cacheKey, { stamps: consulted, value });
|
|
82
|
-
return value;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
58
|
function projectRoots(cwd, consult = () => {}) {
|
|
86
59
|
const roots = [];
|
|
87
60
|
let current = path.resolve(cwd);
|
|
@@ -192,21 +165,27 @@ function cacheKey(sources) {
|
|
|
192
165
|
.join('\n');
|
|
193
166
|
}
|
|
194
167
|
|
|
195
|
-
|
|
168
|
+
/** Parse token sources once per (sources, base font size, file mtimes). */
|
|
169
|
+
function parseSourcesCached(sources, baseFontSize) {
|
|
196
170
|
const normalized = sources.map((source) => normalizeSource(source, process.cwd())).filter(Boolean);
|
|
197
171
|
if (normalized.length === 0) {
|
|
198
172
|
return null;
|
|
199
173
|
}
|
|
200
|
-
|
|
201
174
|
const key = `${baseFontSize}\n${cacheKey(normalized)}`;
|
|
202
|
-
if (sourceCache.has(key)) {
|
|
203
|
-
|
|
175
|
+
if (!sourceCache.has(key)) {
|
|
176
|
+
sourceCache.set(key, parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' }));
|
|
204
177
|
}
|
|
178
|
+
return sourceCache.get(key);
|
|
179
|
+
}
|
|
205
180
|
|
|
206
|
-
|
|
181
|
+
function scaleFromSources(sources, baseFontSize) {
|
|
182
|
+
const parsed = parseSourcesCached(sources, baseFontSize);
|
|
183
|
+
if (!parsed) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
207
186
|
// scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
|
|
208
187
|
const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
|
|
209
|
-
|
|
188
|
+
return scale
|
|
210
189
|
? {
|
|
211
190
|
files: parsed.sources.map((source) => source.file),
|
|
212
191
|
scale,
|
|
@@ -214,9 +193,26 @@ function scaleFromSources(sources, baseFontSize) {
|
|
|
214
193
|
warnings: parsed.warnings,
|
|
215
194
|
}
|
|
216
195
|
: null;
|
|
196
|
+
}
|
|
217
197
|
|
|
218
|
-
|
|
219
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Every spacing token definition visible from a stylesheet, for fixes that
|
|
200
|
+
* write tokens: the stylesheet's own declarations, then `scaleSources`,
|
|
201
|
+
* `.rhythmguardrc.json` token sources and installed token packages. The same
|
|
202
|
+
* places scale inference reads, in the same order.
|
|
203
|
+
*/
|
|
204
|
+
function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenRegex }) {
|
|
205
|
+
const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
|
|
206
|
+
const sources = [...scaleSources, ...rcTokenSources(process.cwd()), ...discoverTokenPackages(process.cwd())];
|
|
207
|
+
const parsed = parseSourcesCached(sources, baseFontSize);
|
|
208
|
+
if (parsed) {
|
|
209
|
+
for (const [token, definition] of parsed.definitions) {
|
|
210
|
+
if (!definitions.has(token)) {
|
|
211
|
+
definitions.set(token, definition);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return definitions;
|
|
220
216
|
}
|
|
221
217
|
|
|
222
218
|
/**
|
|
@@ -552,6 +548,7 @@ module.exports = {
|
|
|
552
548
|
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
553
549
|
assessScale,
|
|
554
550
|
autoScaleFallbackNote,
|
|
551
|
+
collectTokenDefinitions,
|
|
555
552
|
discoverTokenPackages,
|
|
556
553
|
inferScaleFromDefinitions,
|
|
557
554
|
resolveAutoScale,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
formatLength,
|
|
5
|
+
formatNumber,
|
|
5
6
|
nearestScaleValues,
|
|
6
7
|
normalizeScale,
|
|
7
8
|
numbersEqual,
|
|
@@ -11,6 +12,9 @@ const {
|
|
|
11
12
|
|
|
12
13
|
const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
|
|
13
14
|
const DEFAULT_UNITS = ['px', 'rem', 'em'];
|
|
15
|
+
// Tailwind's numeric spacing utilities are multiples of one unit: `--spacing`
|
|
16
|
+
// (0.25rem, 4px) in v4, and the same 4px step in the v3 default scale.
|
|
17
|
+
const DEFAULT_SPACING_UNIT_PX = 4;
|
|
14
18
|
|
|
15
19
|
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>[^\]]+)\]$/;
|
|
16
20
|
|
|
@@ -24,6 +28,11 @@ function normalizeTailwindClassOptions(option = {}) {
|
|
|
24
28
|
? option.baseFontSize
|
|
25
29
|
: 16,
|
|
26
30
|
scale: Array.isArray(option.scale) ? option.scale : DEFAULT_SCALE,
|
|
31
|
+
spacingUnit: option.spacingUnit === false
|
|
32
|
+
? null
|
|
33
|
+
: typeof option.spacingUnit === 'number' && Number.isFinite(option.spacingUnit) && option.spacingUnit > 0
|
|
34
|
+
? option.spacingUnit
|
|
35
|
+
: DEFAULT_SPACING_UNIT_PX,
|
|
27
36
|
units: Array.isArray(option.units)
|
|
28
37
|
? option.units.map((unit) => String(unit).toLowerCase())
|
|
29
38
|
: DEFAULT_UNITS,
|
|
@@ -121,6 +130,26 @@ function hasInvalidVariantPrefix(prefix) {
|
|
|
121
130
|
return false;
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The utility class Tailwind generates for a px value, or null when the value
|
|
135
|
+
* is not a quarter step of the spacing unit (v4 generates any quarter step;
|
|
136
|
+
* v3 generates the quarter steps of its default scale). `p-3` for 12px on a
|
|
137
|
+
* 4px unit; `-m-3` when the class or the value is negative.
|
|
138
|
+
*/
|
|
139
|
+
function utilityClassFor(utility, px, negative, spacingUnit) {
|
|
140
|
+
if (!spacingUnit) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const steps = Math.abs(px) / spacingUnit;
|
|
144
|
+
const quarters = steps * 4;
|
|
145
|
+
if (Math.abs(quarters - Math.round(quarters)) > 1e-9) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const name = utility.startsWith('-') ? utility.slice(1) : utility;
|
|
149
|
+
const sign = negative && steps !== 0 ? '-' : '';
|
|
150
|
+
return `${sign}${name}-${formatNumber(Math.round(quarters) / 4)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
function analyzeClassToken(token, options, scalePx) {
|
|
125
154
|
const parsedToken = parseClassToken(token);
|
|
126
155
|
if (parsedToken.prefix && hasInvalidVariantPrefix(parsedToken.prefix)) {
|
|
@@ -175,13 +204,22 @@ function analyzeClassToken(token, options, scalePx) {
|
|
|
175
204
|
? signedNearest
|
|
176
205
|
: signedNearest / options.baseFontSize;
|
|
177
206
|
|
|
207
|
+
const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
|
|
208
|
+
const wrap = (candidate) =>
|
|
209
|
+
`${parsedToken.prefix}${parsedToken.leadingImportant}${candidate}${parsedToken.trailingImportant}`;
|
|
210
|
+
const utilityFix = utilityClassFor(match.groups.utility, nearest.nearest, negative, options.spacingUnit);
|
|
211
|
+
const lowerUtility = utilityClassFor(match.groups.utility, nearest.lower, negative, options.spacingUnit);
|
|
212
|
+
const upperUtility = utilityClassFor(match.groups.utility, nearest.upper, negative, options.spacingUnit);
|
|
213
|
+
|
|
178
214
|
const replacementValue = formatLength(replacementNumber, parsedLength.unit);
|
|
179
|
-
const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
|
|
180
|
-
const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
|
|
215
|
+
const fixedCandidate = utilityFix || parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
|
|
181
216
|
|
|
182
217
|
return {
|
|
183
|
-
fixedToken,
|
|
218
|
+
fixedToken: wrap(fixedCandidate),
|
|
184
219
|
nearest,
|
|
220
|
+
// The utility classes for the two nearest steps, when both are quarter
|
|
221
|
+
// steps of the spacing unit; messages name these instead of px values.
|
|
222
|
+
nearestUtilities: lowerUtility && upperUtility ? { lower: lowerUtility, upper: upperUtility } : null,
|
|
185
223
|
parsedLength,
|
|
186
224
|
reason: 'off-scale',
|
|
187
225
|
rawValue: match.groups.rawValue,
|
|
@@ -214,8 +252,26 @@ function createTailwindClassAnalyzer(option = {}) {
|
|
|
214
252
|
};
|
|
215
253
|
}
|
|
216
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The message text for an off-scale class: the two nearest utility classes
|
|
257
|
+
* with their px values when the unit makes them, the px steps otherwise.
|
|
258
|
+
*/
|
|
259
|
+
function offScaleClassMessage(token, analysis) {
|
|
260
|
+
if (analysis.reason === 'negative') {
|
|
261
|
+
return `Unexpected Tailwind arbitrary spacing value "${token}". Negative values are disabled for this rule.`;
|
|
262
|
+
}
|
|
263
|
+
const lowerPx = analysis.nearest ? formatLength(analysis.nearest.lower, 'px') : 'n/a';
|
|
264
|
+
const upperPx = analysis.nearest ? formatLength(analysis.nearest.upper, 'px') : 'n/a';
|
|
265
|
+
if (analysis.nearestUtilities) {
|
|
266
|
+
const { lower, upper } = analysis.nearestUtilities;
|
|
267
|
+
return `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}) or "${upper}" (${upperPx}).`;
|
|
268
|
+
}
|
|
269
|
+
return `Unexpected Tailwind arbitrary spacing value "${token}". Use scale values (nearest: ${lowerPx} or ${upperPx}).`;
|
|
270
|
+
}
|
|
271
|
+
|
|
217
272
|
module.exports = {
|
|
218
273
|
createTailwindClassAnalyzer,
|
|
219
274
|
findClassSegments,
|
|
220
275
|
normalizeTailwindClassOptions,
|
|
276
|
+
offScaleClassMessage,
|
|
221
277
|
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fixedLengthValue, numbersEqual, toPx } = require('./length');
|
|
4
|
+
const { parseTokenValueLength } = require('./token-sources');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which token a fix may write for a length. Strict on purpose: the token must
|
|
8
|
+
* hold the same px value in the same unit as the literal being fixed, it must
|
|
9
|
+
* be a custom property (a Sass variable is not valid in CSS output), and it
|
|
10
|
+
* must be the only candidate. A fix that guesses a token is worse than a fix
|
|
11
|
+
* that writes a number, so anything ambiguous falls back to the literal.
|
|
12
|
+
*/
|
|
13
|
+
function tokenIndexFromDefinitions(definitions, baseFontSize = 16) {
|
|
14
|
+
const index = [];
|
|
15
|
+
for (const definition of definitions.values()) {
|
|
16
|
+
for (const raw of definition.values) {
|
|
17
|
+
const parsed = parseTokenValueLength(String(raw).trim());
|
|
18
|
+
if (!parsed) continue;
|
|
19
|
+
const unit = parsed.unit || 'px';
|
|
20
|
+
const px = toPx(Math.abs(parsed.number), unit, baseFontSize);
|
|
21
|
+
if (px === null || !Number.isFinite(px) || px === 0) continue;
|
|
22
|
+
index.push({ px, raw: String(raw).trim(), token: definition.token, unit });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return index;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tokenForLength(index, px, unit) {
|
|
29
|
+
const wanted = unit || 'px';
|
|
30
|
+
const candidates = new Set();
|
|
31
|
+
for (const entry of index) {
|
|
32
|
+
if (entry.token.startsWith('--') && entry.unit === wanted && numbersEqual(entry.px, Math.abs(px))) {
|
|
33
|
+
candidates.add(entry.token);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (candidates.size !== 1) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return `var(${[...candidates][0]})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A negative token reference that is still valid where it is written. */
|
|
43
|
+
function negateReplacement(replacement) {
|
|
44
|
+
if (!replacement || replacement.startsWith('-')) {
|
|
45
|
+
return replacement;
|
|
46
|
+
}
|
|
47
|
+
if (replacement.startsWith('$') || replacement.startsWith('@')) {
|
|
48
|
+
return `-${replacement}`;
|
|
49
|
+
}
|
|
50
|
+
return `calc(-1 * ${replacement})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The replacement text a length rule writes: the matching token when the
|
|
55
|
+
* options ask for one and exactly one qualifies, otherwise the literal.
|
|
56
|
+
*/
|
|
57
|
+
function replacementFor(parsedLength, nearestPx, options) {
|
|
58
|
+
if (options.fixWith === 'token' && options.tokenIndex) {
|
|
59
|
+
const token = tokenForLength(options.tokenIndex, nearestPx, parsedLength.unit || 'px');
|
|
60
|
+
if (token) {
|
|
61
|
+
return parsedLength.number < 0 ? negateReplacement(token) : token;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return fixedLengthValue(parsedLength, nearestPx, options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The sentence a length rule appends when its fix writes a token: which token
|
|
69
|
+
* holds the snapped value. Empty when the replacement is a literal.
|
|
70
|
+
*/
|
|
71
|
+
function tokenHoldsNote(replacement, formattedNearest) {
|
|
72
|
+
const match = typeof replacement === 'string' ? replacement.match(/var\(--[^)]+\)/) : null;
|
|
73
|
+
return match ? `${match[0]} holds ${formattedNearest}.` : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
negateReplacement,
|
|
78
|
+
replacementFor,
|
|
79
|
+
tokenHoldsNote,
|
|
80
|
+
tokenForLength,
|
|
81
|
+
tokenIndexFromDefinitions,
|
|
82
|
+
};
|
package/src/core/token-map.js
CHANGED
|
@@ -40,7 +40,19 @@ function normalizeTokenReference(tokenReference) {
|
|
|
40
40
|
return `var(--${trimmed})`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Where a token came from, for messages: the path relative to the working
|
|
45
|
+
* directory when it is inside it, the absolute path otherwise.
|
|
46
|
+
*/
|
|
47
|
+
function displayOrigin(file) {
|
|
48
|
+
if (!file) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const relative = path.relative(process.cwd(), file);
|
|
52
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize, origins = null, origin = null) {
|
|
44
56
|
if (typeof rawLength !== 'string') {
|
|
45
57
|
return;
|
|
46
58
|
}
|
|
@@ -55,6 +67,10 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
|
|
|
55
67
|
return;
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
if (origins && origin && !origins[token]) {
|
|
71
|
+
origins[token] = origin;
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
const absolute = Math.abs(parsed.number);
|
|
59
75
|
const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
|
|
60
76
|
map[normalizedRaw] = token;
|
|
@@ -81,7 +97,7 @@ function mergeExplicitTokenMap(target, source) {
|
|
|
81
97
|
return target;
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
function walkTokenGroup(map, group, prefix, baseFontSize) {
|
|
100
|
+
function walkTokenGroup(map, group, prefix, baseFontSize, origins, origin) {
|
|
85
101
|
for (const [key, value] of Object.entries(group)) {
|
|
86
102
|
const tokenName = `${prefix}-${key}`;
|
|
87
103
|
|
|
@@ -91,24 +107,25 @@ function walkTokenGroup(map, group, prefix, baseFontSize) {
|
|
|
91
107
|
|
|
92
108
|
// Leaf node with $value (DTCG)
|
|
93
109
|
if (typeof value.$value === 'string') {
|
|
94
|
-
addLengthValueMapping(map, value.$value, tokenName, baseFontSize);
|
|
110
|
+
addLengthValueMapping(map, value.$value, tokenName, baseFontSize, origins, origin);
|
|
95
111
|
continue;
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
// Leaf node with value (Style Dictionary)
|
|
99
115
|
if (typeof value.value === 'string') {
|
|
100
|
-
addLengthValueMapping(map, value.value, tokenName, baseFontSize);
|
|
116
|
+
addLengthValueMapping(map, value.value, tokenName, baseFontSize, origins, origin);
|
|
101
117
|
continue;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
// Nested group
|
|
105
|
-
walkTokenGroup(map, value, tokenName, baseFontSize);
|
|
120
|
+
// Nested group: recurse deeper
|
|
121
|
+
walkTokenGroup(map, value, tokenName, baseFontSize, origins, origin);
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
124
|
|
|
109
125
|
function mergeTokenMapFromFile({
|
|
110
126
|
baseFontSize,
|
|
111
127
|
currentMap,
|
|
128
|
+
origins = null,
|
|
112
129
|
tokenMapFile,
|
|
113
130
|
}) {
|
|
114
131
|
if (!tokenMapFile) {
|
|
@@ -134,6 +151,7 @@ function mergeTokenMapFromFile({
|
|
|
134
151
|
const nextMap = {
|
|
135
152
|
...currentMap,
|
|
136
153
|
};
|
|
154
|
+
const origin = displayOrigin(resolvedPath);
|
|
137
155
|
|
|
138
156
|
for (const [entryKey, entryValue] of Object.entries(parsed)) {
|
|
139
157
|
if (typeof entryValue === 'string') {
|
|
@@ -142,36 +160,39 @@ function mergeTokenMapFromFile({
|
|
|
142
160
|
|
|
143
161
|
if (keyAsLength) {
|
|
144
162
|
nextMap[entryKey] = entryValue;
|
|
163
|
+
if (origins && !origins[entryValue]) {
|
|
164
|
+
origins[entryValue] = origin;
|
|
165
|
+
}
|
|
145
166
|
continue;
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
if (valueAsLength) {
|
|
149
|
-
addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize);
|
|
170
|
+
addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
continue;
|
|
153
174
|
}
|
|
154
175
|
|
|
155
176
|
if (typeof entryValue === 'number') {
|
|
156
|
-
addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize);
|
|
177
|
+
addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize, origins, origin);
|
|
157
178
|
continue;
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
if (isPlainObject(entryValue)) {
|
|
161
182
|
// Style Dictionary format: { value: "16px" }
|
|
162
183
|
if (typeof entryValue.value === 'string') {
|
|
163
|
-
addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
|
|
184
|
+
addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize, origins, origin);
|
|
164
185
|
continue;
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
// W3C DTCG format: { $value: "16px", $type: "dimension" }
|
|
168
189
|
if (typeof entryValue.$value === 'string') {
|
|
169
|
-
addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize);
|
|
190
|
+
addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize, origins, origin);
|
|
170
191
|
continue;
|
|
171
192
|
}
|
|
172
193
|
|
|
173
|
-
// Nested group
|
|
174
|
-
walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize);
|
|
194
|
+
// Nested group: recurse (e.g. { spacing: { 4: { $value: "16px" } } })
|
|
195
|
+
walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
|
|
175
196
|
}
|
|
176
197
|
}
|
|
177
198
|
|
|
@@ -181,12 +202,14 @@ function mergeTokenMapFromFile({
|
|
|
181
202
|
function mergeTokenMapFromCssCustomProperties({
|
|
182
203
|
baseFontSize,
|
|
183
204
|
currentMap,
|
|
205
|
+
origins = null,
|
|
184
206
|
root,
|
|
185
207
|
tokenRegex,
|
|
186
208
|
}) {
|
|
187
209
|
const nextMap = {
|
|
188
210
|
...currentMap,
|
|
189
211
|
};
|
|
212
|
+
const origin = displayOrigin(root && root.source && root.source.input ? root.source.input.file : null);
|
|
190
213
|
|
|
191
214
|
root.walkDecls((decl) => {
|
|
192
215
|
const prop = decl.prop.toLowerCase();
|
|
@@ -203,7 +226,7 @@ function mergeTokenMapFromCssCustomProperties({
|
|
|
203
226
|
return;
|
|
204
227
|
}
|
|
205
228
|
|
|
206
|
-
addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize);
|
|
229
|
+
addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize, origins, origin);
|
|
207
230
|
});
|
|
208
231
|
|
|
209
232
|
return nextMap;
|
|
@@ -321,6 +344,7 @@ function loadTailwindSpacing(resolvedPath) {
|
|
|
321
344
|
|
|
322
345
|
function mergeTokenMapFromTailwindSpacing({
|
|
323
346
|
currentMap,
|
|
347
|
+
origins = null,
|
|
324
348
|
tailwindConfigPath,
|
|
325
349
|
}) {
|
|
326
350
|
if (!tailwindConfigPath) {
|
|
@@ -354,13 +378,23 @@ function mergeTokenMapFromTailwindSpacing({
|
|
|
354
378
|
const absolute = Math.abs(parsed.number);
|
|
355
379
|
const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
|
|
356
380
|
nextMap[normalizedRaw] = `theme(spacing.${key})`;
|
|
381
|
+
if (origins && !origins[nextMap[normalizedRaw]]) {
|
|
382
|
+
origins[nextMap[normalizedRaw]] = displayOrigin(resolvedPath);
|
|
383
|
+
}
|
|
357
384
|
}
|
|
358
385
|
|
|
359
386
|
return nextMap;
|
|
360
387
|
}
|
|
361
388
|
|
|
389
|
+
/**
|
|
390
|
+
* The raw-length to token-reference map a rule fixes with. Pass `origins` to
|
|
391
|
+
* also learn where each token reference came from (the token map file, the
|
|
392
|
+
* stylesheet, the Tailwind config), keyed by the reference; an explicit
|
|
393
|
+
* `tokenMap` entry has no origin.
|
|
394
|
+
*/
|
|
362
395
|
function buildEffectiveTokenMap({
|
|
363
396
|
options,
|
|
397
|
+
origins = null,
|
|
364
398
|
root,
|
|
365
399
|
tokenRegex,
|
|
366
400
|
}) {
|
|
@@ -370,6 +404,7 @@ function buildEffectiveTokenMap({
|
|
|
370
404
|
tokenMap = mergeTokenMapFromFile({
|
|
371
405
|
baseFontSize: options.baseFontSize,
|
|
372
406
|
currentMap: tokenMap,
|
|
407
|
+
origins,
|
|
373
408
|
tokenMapFile: options.tokenMapFile,
|
|
374
409
|
});
|
|
375
410
|
}
|
|
@@ -378,6 +413,7 @@ function buildEffectiveTokenMap({
|
|
|
378
413
|
tokenMap = mergeTokenMapFromCssCustomProperties({
|
|
379
414
|
baseFontSize: options.baseFontSize,
|
|
380
415
|
currentMap: tokenMap,
|
|
416
|
+
origins,
|
|
381
417
|
root,
|
|
382
418
|
tokenRegex,
|
|
383
419
|
});
|
|
@@ -386,6 +422,7 @@ function buildEffectiveTokenMap({
|
|
|
386
422
|
if (options.tokenMapFromTailwindSpacing && options.tailwindConfigPath) {
|
|
387
423
|
tokenMap = mergeTokenMapFromTailwindSpacing({
|
|
388
424
|
currentMap: tokenMap,
|
|
425
|
+
origins,
|
|
389
426
|
tailwindConfigPath: options.tailwindConfigPath,
|
|
390
427
|
});
|
|
391
428
|
}
|
|
@@ -792,8 +792,9 @@ function addDefinition(definitions, {
|
|
|
792
792
|
definitions.set(token, entry);
|
|
793
793
|
}
|
|
794
794
|
|
|
795
|
-
|
|
796
|
-
const
|
|
795
|
+
// The factor must carry a unit: `calc(4px * var(--scaling))` is a length, `calc(var(--x) * 2)` is a multiplier.
|
|
796
|
+
const CALC_LENGTH_TIMES_VAR = /^calc\(\s*(-?[\d.]+(?:px|rem|em))\s*\*\s*var\([^()]*\)\s*\)$/i;
|
|
797
|
+
const CALC_VAR_TIMES_LENGTH = /^calc\(\s*var\([^()]*\)\s*\*\s*(-?[\d.]+(?:px|rem|em))\s*\)$/i;
|
|
797
798
|
|
|
798
799
|
/**
|
|
799
800
|
* Parse the length a token value carries. Accepts plain lengths and the
|