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.
- package/CHANGELOG.md +77 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/baseline.js +184 -27
- package/src/audit/codemod.js +39 -14
- package/src/audit/config.js +25 -31
- package/src/audit/contract.js +96 -10
- package/src/audit/render-github.js +4 -0
- package/src/audit/render-html.js +7 -0
- package/src/audit/render-markdown.js +27 -0
- package/src/audit/render-text.js +23 -1
- package/src/audit/report.js +60 -17
- package/src/audit/scan/files.js +154 -27
- package/src/audit/scan/stylesheets.js +87 -34
- package/src/audit/scan/templates.js +22 -7
- package/src/audit/token-chains.js +18 -7
- package/src/cli/audit.js +7 -3
- package/src/cli/doctor.js +48 -7
- package/src/cli/fix.js +56 -15
- package/src/cli/init.js +136 -23
- package/src/cli/quickstart.js +34 -8
- package/src/core/fs-cache.js +6 -0
- package/src/core/length.js +15 -0
- package/src/core/options.js +8 -1
- package/src/core/scale-inference.js +99 -68
- package/src/core/tailwind-class-analysis.js +26 -16
- package/src/core/tailwind-motion-analysis.js +12 -3
- package/src/core/token-index.js +76 -24
- package/src/core/token-map.js +23 -10
- package/src/core/token-source-entries.js +64 -0
- package/src/core/token-sources.js +361 -69
- package/src/core/value-nodes.js +280 -54
- package/src/eslint/literal-spans.js +319 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
- package/src/rules/no-offscale-transform/index.js +35 -14
- package/src/rules/prefer-token/index.js +72 -48
- package/src/rules/report.js +3 -1
- package/src/rules/use-motion-scale/index.js +68 -5
- package/src/rules/use-scale/index.js +54 -39
- package/types/__checks__/eslint-config.ts +37 -0
- package/types/audit.d.ts +10 -2
- package/types/shared.d.ts +21 -3
package/src/core/options.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
363
|
+
const entries = audit && Array.isArray(audit.tokenSources) ? audit.tokenSources : [];
|
|
359
364
|
const baseDir = path.dirname(rcPath);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
|
18
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
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
|
-
|
|
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
|
|
package/src/core/token-index.js
CHANGED
|
@@ -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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* that
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
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
|
|
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.
|
|
65
|
-
*
|
|
66
|
-
* unit
|
|
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,
|
|
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,
|
|
141
|
+
function replacementFor(parsedLength, nearest, options, prop = null, avoidTokens = []) {
|
|
103
142
|
if (options.fixWith === 'token' && options.tokenIndex) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|