stylelint-plugin-rhythmguard 2.1.0 → 2.2.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 +21 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +13 -2
- package/package.json +7 -1
- package/src/audit/args.js +6 -1
- package/src/audit/config.js +15 -5
- package/src/audit/contract.js +5 -0
- package/src/audit/render-markdown.js +11 -0
- package/src/audit/render-text.js +7 -1
- package/src/audit/report.js +82 -8
- package/src/cli/index.js +11 -5
- package/src/cli/init.js +5 -1
- package/src/cli/quickstart.js +203 -0
- package/src/configs/embed.js +24 -0
- package/src/configs/embed.mjs +4 -0
- package/src/index.js +1 -0
- package/src/rules/no-offscale-transform/index.js +22 -0
- package/src/rules/prefer-token/index.js +28 -0
- package/src/rules/use-scale/index.js +34 -4
- package/src/utils/length.js +16 -0
- package/src/utils/options.js +53 -3
- package/src/utils/scale-inference.js +255 -0
- package/src/utils/token-map.js +4 -2
- package/src/utils/token-sources.js +32 -2
- package/types/__checks__/consumer.ts +10 -0
- package/types/audit.d.ts +16 -1
- package/types/index.d.ts +4 -0
- package/types/shared.d.ts +22 -1
|
@@ -5,6 +5,7 @@ const valueParser = require('postcss-value-parser');
|
|
|
5
5
|
const {
|
|
6
6
|
formatLength,
|
|
7
7
|
fromPx,
|
|
8
|
+
isHairlineLength,
|
|
8
9
|
nearestScaleValues,
|
|
9
10
|
normalizeScale,
|
|
10
11
|
normalizeScaleByUnit,
|
|
@@ -25,6 +26,11 @@ const {
|
|
|
25
26
|
walkTransformTranslateNodes,
|
|
26
27
|
} = require('../../utils/value-utils');
|
|
27
28
|
|
|
29
|
+
const {
|
|
30
|
+
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
31
|
+
resolveAutoScale,
|
|
32
|
+
} = require('../../utils/scale-inference');
|
|
33
|
+
|
|
28
34
|
const ruleName = 'rhythmguard/no-offscale-transform';
|
|
29
35
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
30
36
|
invalidPreset: (presetName, presetNames) =>
|
|
@@ -84,6 +90,18 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
84
90
|
});
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
if (options.scaleAuto) {
|
|
94
|
+
const inference = resolveAutoScale({
|
|
95
|
+
baseFontSize: options.baseFontSize,
|
|
96
|
+
root,
|
|
97
|
+
scaleSources: options.scaleSources,
|
|
98
|
+
tailwindConfigPath: options.tailwindConfigPath,
|
|
99
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
|
|
100
|
+
});
|
|
101
|
+
options.scale = inference.scale;
|
|
102
|
+
options.scaleInference = inference;
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
const scaleCache = new Map();
|
|
88
106
|
const getScaleStateForProperty = (prop) => {
|
|
89
107
|
const cached = scaleCache.get(prop);
|
|
@@ -152,6 +170,10 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
152
170
|
return;
|
|
153
171
|
}
|
|
154
172
|
|
|
173
|
+
if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
if (!options.allowNegative && parsedLength.number < 0) {
|
|
156
178
|
return;
|
|
157
179
|
}
|
|
@@ -4,6 +4,7 @@ const stylelint = require('stylelint');
|
|
|
4
4
|
const valueParser = require('postcss-value-parser');
|
|
5
5
|
const {
|
|
6
6
|
formatLength,
|
|
7
|
+
isHairlineLength,
|
|
7
8
|
normalizeScale,
|
|
8
9
|
normalizeScaleByUnit,
|
|
9
10
|
numbersEqual,
|
|
@@ -28,6 +29,11 @@ const {
|
|
|
28
29
|
} = require('../../utils/value-utils');
|
|
29
30
|
const { buildEffectiveTokenMap } = require('../../utils/token-map');
|
|
30
31
|
|
|
32
|
+
const {
|
|
33
|
+
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
34
|
+
resolveAutoScale,
|
|
35
|
+
} = require('../../utils/scale-inference');
|
|
36
|
+
|
|
31
37
|
const ruleName = 'rhythmguard/prefer-token';
|
|
32
38
|
|
|
33
39
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
@@ -112,6 +118,18 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
112
118
|
});
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
if (options.scaleAuto) {
|
|
122
|
+
const inference = resolveAutoScale({
|
|
123
|
+
baseFontSize: options.baseFontSize,
|
|
124
|
+
root,
|
|
125
|
+
scaleSources: options.scaleSources,
|
|
126
|
+
tailwindConfigPath: options.tailwindConfigPath,
|
|
127
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
|
|
128
|
+
});
|
|
129
|
+
options.scale = inference.scale;
|
|
130
|
+
options.scaleInference = inference;
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
|
|
116
134
|
const tokenMap = buildEffectiveTokenMap({
|
|
117
135
|
options,
|
|
@@ -196,6 +214,16 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
196
214
|
return false;
|
|
197
215
|
}
|
|
198
216
|
|
|
217
|
+
// Percentages are relative to the container or the element itself; they are
|
|
218
|
+
// never spacing-token candidates (translate(-50%, -50%) centering, inset: 100%).
|
|
219
|
+
if (parsedLength.unit === '%') {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
199
227
|
if (
|
|
200
228
|
parsedLength.unit &&
|
|
201
229
|
parsedLength.unit !== '%' &&
|
|
@@ -5,6 +5,7 @@ const valueParser = require('postcss-value-parser');
|
|
|
5
5
|
const {
|
|
6
6
|
formatLength,
|
|
7
7
|
fromPx,
|
|
8
|
+
isHairlineLength,
|
|
8
9
|
nearestScaleValues,
|
|
9
10
|
normalizeScale,
|
|
10
11
|
normalizeScaleByUnit,
|
|
@@ -29,13 +30,19 @@ const {
|
|
|
29
30
|
walkTransformTranslateNodes,
|
|
30
31
|
} = require('../../utils/value-utils');
|
|
31
32
|
|
|
33
|
+
const {
|
|
34
|
+
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
35
|
+
autoScaleFallbackNote,
|
|
36
|
+
resolveAutoScale,
|
|
37
|
+
} = require('../../utils/scale-inference');
|
|
38
|
+
|
|
32
39
|
const ruleName = 'rhythmguard/use-scale';
|
|
33
40
|
|
|
34
41
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
35
42
|
invalidPreset: (presetName, presetNames) =>
|
|
36
43
|
`Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
|
|
37
|
-
rejected: (value, lower, upper) =>
|
|
38
|
-
`Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper})
|
|
44
|
+
rejected: (value, lower, upper, note = '') =>
|
|
45
|
+
`Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
|
|
39
46
|
});
|
|
40
47
|
|
|
41
48
|
function getFixedNodeValue(parsedLength, nearestPx, options) {
|
|
@@ -82,7 +89,16 @@ function checkLengthValue({
|
|
|
82
89
|
return false;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
if (parsedLength.unit === '%'
|
|
92
|
+
if (parsedLength.unit === '%') {
|
|
93
|
+
if (options.allowPercentages) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
report(node.value, decl, node, null, null, '%');
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
|
|
86
102
|
return false;
|
|
87
103
|
}
|
|
88
104
|
|
|
@@ -180,8 +196,21 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
180
196
|
});
|
|
181
197
|
}
|
|
182
198
|
|
|
199
|
+
if (options.scaleAuto) {
|
|
200
|
+
const inference = resolveAutoScale({
|
|
201
|
+
baseFontSize: options.baseFontSize,
|
|
202
|
+
root,
|
|
203
|
+
scaleSources: options.scaleSources,
|
|
204
|
+
tailwindConfigPath: options.tailwindConfigPath,
|
|
205
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
|
|
206
|
+
});
|
|
207
|
+
options.scale = inference.scale;
|
|
208
|
+
options.scaleInference = inference;
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
|
|
184
212
|
const scaleCache = new Map();
|
|
213
|
+
let fallbackNote = autoScaleFallbackNote(options.scaleInference);
|
|
185
214
|
|
|
186
215
|
const getScaleStateForProperty = (prop) => {
|
|
187
216
|
const cached = scaleCache.get(prop);
|
|
@@ -208,7 +237,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
208
237
|
const payload = {
|
|
209
238
|
endIndex,
|
|
210
239
|
index,
|
|
211
|
-
message: messages.rejected(value, lower, upper),
|
|
240
|
+
message: messages.rejected(value, lower, upper, fallbackNote),
|
|
212
241
|
node: decl,
|
|
213
242
|
result,
|
|
214
243
|
ruleName,
|
|
@@ -221,6 +250,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
221
250
|
};
|
|
222
251
|
}
|
|
223
252
|
|
|
253
|
+
fallbackNote = '';
|
|
224
254
|
stylelint.utils.report(payload);
|
|
225
255
|
};
|
|
226
256
|
|
package/src/utils/length.js
CHANGED
|
@@ -25,6 +25,21 @@ function parseLengthToken(rawValue) {
|
|
|
25
25
|
return { number, raw: value, unit };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A hairline is a non-zero length that resolves to one CSS pixel or less:
|
|
30
|
+
* 1px, -1px, 0.5px, 0.0625rem. Such values compensate for a border width or a
|
|
31
|
+
* rendering quirk; they are not spacing decisions, so the scale rules exempt
|
|
32
|
+
* them by default (`allowHairlines`).
|
|
33
|
+
*/
|
|
34
|
+
function isHairlineLength(parsedLength, baseFontSize) {
|
|
35
|
+
if (!parsedLength || parsedLength.number === 0 || parsedLength.unit === '%') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', baseFontSize);
|
|
40
|
+
return px !== null && px > 0 && px <= 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
function toPx(number, unit, baseFontSize) {
|
|
29
44
|
if (unit === '' || unit === 'px') {
|
|
30
45
|
return number;
|
|
@@ -168,6 +183,7 @@ function nearestScaleValues(target, scale) {
|
|
|
168
183
|
module.exports = {
|
|
169
184
|
formatLength,
|
|
170
185
|
fromPx,
|
|
186
|
+
isHairlineLength,
|
|
171
187
|
nearestScaleValues,
|
|
172
188
|
normalizeScale,
|
|
173
189
|
normalizeScaleByUnit,
|
package/src/utils/options.js
CHANGED
|
@@ -65,6 +65,22 @@ function isUnitStrategy(value) {
|
|
|
65
65
|
return value === 'convert' || value === 'exact';
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function isAutoScaleLiteral(value) {
|
|
69
|
+
return value === 'auto';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isScaleEntryOrAuto(value) {
|
|
73
|
+
return isAutoScaleLiteral(value) || isScaleEntry(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isScaleSourceEntry(value) {
|
|
77
|
+
if (isNonEmptyString(value)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return isPlainObject(value) && (isNonEmptyString(value.path) || isNonEmptyString(value.file));
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
function isScaleEntry(value) {
|
|
69
85
|
if (typeof value === 'number') {
|
|
70
86
|
return Number.isFinite(value) && value >= 0;
|
|
@@ -237,7 +253,10 @@ function validateSecondaryOptionShapes(result, ruleName, secondaryOptions, schem
|
|
|
237
253
|
continue;
|
|
238
254
|
}
|
|
239
255
|
|
|
240
|
-
|
|
256
|
+
const literalAllowed = Array.isArray(descriptor.allowLiterals)
|
|
257
|
+
&& descriptor.allowLiterals.includes(optionValue);
|
|
258
|
+
|
|
259
|
+
if (descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) {
|
|
241
260
|
valid = false;
|
|
242
261
|
result.warn(
|
|
243
262
|
`Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
|
|
@@ -412,6 +431,9 @@ function resolveUnits(options) {
|
|
|
412
431
|
}
|
|
413
432
|
|
|
414
433
|
const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
434
|
+
allowHairlines: Object.freeze({
|
|
435
|
+
entryValidator: isBoolean,
|
|
436
|
+
}),
|
|
415
437
|
allowNegative: Object.freeze({
|
|
416
438
|
entryValidator: isBoolean,
|
|
417
439
|
}),
|
|
@@ -443,9 +465,17 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
|
443
465
|
entryValidator: isNonEmptyString,
|
|
444
466
|
}),
|
|
445
467
|
scale: Object.freeze({
|
|
446
|
-
|
|
468
|
+
allowLiterals: ['auto'],
|
|
469
|
+
entryValidator: isScaleEntryOrAuto,
|
|
447
470
|
expectsArray: true,
|
|
448
471
|
}),
|
|
472
|
+
scaleSources: Object.freeze({
|
|
473
|
+
entryValidator: isScaleSourceEntry,
|
|
474
|
+
expectsArray: true,
|
|
475
|
+
}),
|
|
476
|
+
tailwindConfigPath: Object.freeze({
|
|
477
|
+
entryValidator: isNonEmptyString,
|
|
478
|
+
}),
|
|
449
479
|
unitStrategy: Object.freeze({
|
|
450
480
|
entryValidator: isUnitStrategy,
|
|
451
481
|
}),
|
|
@@ -487,6 +517,9 @@ const NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA = Object.freeze({
|
|
|
487
517
|
});
|
|
488
518
|
|
|
489
519
|
const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
|
|
520
|
+
allowHairlines: Object.freeze({
|
|
521
|
+
entryValidator: isBoolean,
|
|
522
|
+
}),
|
|
490
523
|
allowNumericScale: Object.freeze({
|
|
491
524
|
entryValidator: isBoolean,
|
|
492
525
|
}),
|
|
@@ -528,7 +561,12 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
|
|
|
528
561
|
expectsObject: true,
|
|
529
562
|
}),
|
|
530
563
|
scale: Object.freeze({
|
|
531
|
-
|
|
564
|
+
allowLiterals: ['auto'],
|
|
565
|
+
entryValidator: isScaleEntryOrAuto,
|
|
566
|
+
expectsArray: true,
|
|
567
|
+
}),
|
|
568
|
+
scaleSources: Object.freeze({
|
|
569
|
+
entryValidator: isScaleSourceEntry,
|
|
532
570
|
expectsArray: true,
|
|
533
571
|
}),
|
|
534
572
|
tailwindConfigPath: Object.freeze({
|
|
@@ -578,6 +616,7 @@ function buildScaleOptions(rawOptions) {
|
|
|
578
616
|
const scaleSelection = resolveScaleSelection(options, DEFAULT_SCALE);
|
|
579
617
|
|
|
580
618
|
return {
|
|
619
|
+
allowHairlines: options.allowHairlines !== false,
|
|
581
620
|
allowNegative: options.allowNegative !== false,
|
|
582
621
|
allowPercentages: options.allowPercentages !== false,
|
|
583
622
|
baseFontSize:
|
|
@@ -600,6 +639,13 @@ function buildScaleOptions(rawOptions) {
|
|
|
600
639
|
propertyGroups: normalizePropertyGroups(options.propertyGroups),
|
|
601
640
|
propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
|
|
602
641
|
scale: scaleSelection.scale,
|
|
642
|
+
scaleAuto: isAutoScaleLiteral(options.scale),
|
|
643
|
+
scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
|
|
644
|
+
tailwindConfigPath:
|
|
645
|
+
typeof options.tailwindConfigPath === 'string' && options.tailwindConfigPath.length > 0
|
|
646
|
+
? options.tailwindConfigPath
|
|
647
|
+
: null,
|
|
648
|
+
tokenPatternExplicit: typeof options.tokenPattern === 'string' && options.tokenPattern.length > 0,
|
|
603
649
|
tokenFunctions: Array.isArray(options.tokenFunctions)
|
|
604
650
|
? options.tokenFunctions.map((value) => String(value).toLowerCase())
|
|
605
651
|
: ['var', 'theme', 'token'],
|
|
@@ -617,6 +663,7 @@ function buildTokenOptions(rawOptions) {
|
|
|
617
663
|
const scaleSelection = resolveScaleSelection(options, DEFAULT_SCALE);
|
|
618
664
|
|
|
619
665
|
return {
|
|
666
|
+
allowHairlines: options.allowHairlines !== false,
|
|
620
667
|
allowNumericScale: options.allowNumericScale === true,
|
|
621
668
|
baseFontSize:
|
|
622
669
|
typeof options.baseFontSize === 'number' &&
|
|
@@ -637,6 +684,9 @@ function buildTokenOptions(rawOptions) {
|
|
|
637
684
|
propertyGroups: normalizePropertyGroups(options.propertyGroups),
|
|
638
685
|
propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
|
|
639
686
|
scale: scaleSelection.scale,
|
|
687
|
+
scaleAuto: isAutoScaleLiteral(options.scale),
|
|
688
|
+
scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
|
|
689
|
+
tokenPatternExplicit: typeof options.tokenPattern === 'string' && options.tokenPattern.length > 0,
|
|
640
690
|
tailwindConfigPath:
|
|
641
691
|
typeof options.tailwindConfigPath === 'string' && options.tailwindConfigPath.length > 0
|
|
642
692
|
? options.tailwindConfigPath
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { parseLengthToken, toPx } = require('./length');
|
|
7
|
+
const { buildEffectiveTokenMap } = require('./token-map');
|
|
8
|
+
const { parseTokenSources } = require('./token-sources');
|
|
9
|
+
const { getScalePreset } = require('../presets/scales');
|
|
10
|
+
|
|
11
|
+
// Matches the audit default so lint and audit agree on what a spacing token is.
|
|
12
|
+
const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing)(-|$)';
|
|
13
|
+
// Tailwind v4 defines one base (`--spacing: 0.25rem`) and derives utilities by multiplying it.
|
|
14
|
+
const TAILWIND_BASE_TOKENS = new Set(['--spacing', '--space']);
|
|
15
|
+
const TAILWIND_SPACING_MULTIPLIERS = [
|
|
16
|
+
0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
|
|
17
|
+
];
|
|
18
|
+
const FALLBACK_PRESET = 'rhythmic-4';
|
|
19
|
+
// Zero plus at least three distinct token values; a one- or two-token scale is worse than the default.
|
|
20
|
+
const MIN_INFERRED_SCALE_LENGTH = 4;
|
|
21
|
+
const RC_FILE = '.rhythmguardrc.json';
|
|
22
|
+
|
|
23
|
+
const sourceCache = new Map();
|
|
24
|
+
|
|
25
|
+
function pxValuesFromKeys(keys, baseFontSize) {
|
|
26
|
+
const values = new Set([0]);
|
|
27
|
+
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
const parsed = parseLengthToken(String(key));
|
|
30
|
+
if (!parsed) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const px = toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize);
|
|
35
|
+
if (px !== null && Number.isFinite(px)) {
|
|
36
|
+
values.add(px);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeSource(source, baseDir) {
|
|
44
|
+
if (typeof source === 'string') {
|
|
45
|
+
return { format: 'auto', path: path.resolve(baseDir, source) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (source && typeof source === 'object') {
|
|
49
|
+
const rawPath = source.path || source.file;
|
|
50
|
+
if (typeof rawPath !== 'string') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
format: typeof source.format === 'string' ? source.format : 'auto',
|
|
56
|
+
path: path.resolve(source.baseDir || baseDir, rawPath),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cacheKey(sources) {
|
|
64
|
+
return sources
|
|
65
|
+
.map((source) => {
|
|
66
|
+
let mtime = 'missing';
|
|
67
|
+
try {
|
|
68
|
+
mtime = String(fs.statSync(source.path).mtimeMs);
|
|
69
|
+
} catch {
|
|
70
|
+
// missing file: key still changes when it appears
|
|
71
|
+
}
|
|
72
|
+
return `${source.path}|${source.format}|${mtime}`;
|
|
73
|
+
})
|
|
74
|
+
.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scaleFromSources(sources, baseFontSize) {
|
|
78
|
+
const normalized = sources.map((source) => normalizeSource(source, process.cwd())).filter(Boolean);
|
|
79
|
+
if (normalized.length === 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const key = `${baseFontSize}\n${cacheKey(normalized)}`;
|
|
84
|
+
if (sourceCache.has(key)) {
|
|
85
|
+
return sourceCache.get(key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parsed = parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
|
|
89
|
+
const keys = [];
|
|
90
|
+
for (const definition of parsed.definitions.values()) {
|
|
91
|
+
keys.push(...definition.normalizedValues);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const scale = pxValuesFromKeys(keys, baseFontSize);
|
|
95
|
+
const outcome = scale.length >= MIN_INFERRED_SCALE_LENGTH
|
|
96
|
+
? {
|
|
97
|
+
files: parsed.sources.map((source) => source.file),
|
|
98
|
+
scale,
|
|
99
|
+
tokenCount: parsed.definitions.size,
|
|
100
|
+
warnings: parsed.warnings,
|
|
101
|
+
}
|
|
102
|
+
: null;
|
|
103
|
+
|
|
104
|
+
sourceCache.set(key, outcome);
|
|
105
|
+
return outcome;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build a px scale from token definitions as produced by token-sources.js / contract.js. */
|
|
109
|
+
function scaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
110
|
+
const keys = [];
|
|
111
|
+
const baseKeys = [];
|
|
112
|
+
for (const definition of definitions.values()) {
|
|
113
|
+
if (TAILWIND_BASE_TOKENS.has(definition.token)) {
|
|
114
|
+
baseKeys.push(...definition.normalizedValues);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
keys.push(...definition.normalizedValues);
|
|
118
|
+
}
|
|
119
|
+
const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
|
|
120
|
+
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Merge Tailwind-style base multiples into a scale when a bare --spacing/--space base is defined. */
|
|
124
|
+
function expandTailwindBase(scale, baseKeys, baseFontSize) {
|
|
125
|
+
const bases = pxValuesFromKeys(baseKeys, baseFontSize).filter((value) => value > 0);
|
|
126
|
+
if (bases.length === 0) {
|
|
127
|
+
return scale;
|
|
128
|
+
}
|
|
129
|
+
const values = new Set(scale);
|
|
130
|
+
for (const base of bases) {
|
|
131
|
+
for (const multiplier of TAILWIND_SPACING_MULTIPLIERS) {
|
|
132
|
+
values.add(Math.round(base * multiplier * 1000) / 1000);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function rcTokenSources(cwd) {
|
|
139
|
+
const rcPath = path.join(cwd, RC_FILE);
|
|
140
|
+
if (!fs.existsSync(rcPath)) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let config;
|
|
145
|
+
try {
|
|
146
|
+
config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const audit = config && typeof config === 'object' ? config.audit : null;
|
|
152
|
+
const sources = audit && Array.isArray(audit.tokenSources) ? audit.tokenSources : [];
|
|
153
|
+
const baseDir = path.dirname(rcPath);
|
|
154
|
+
|
|
155
|
+
return sources
|
|
156
|
+
.map((source) => normalizeSource(source, baseDir))
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scaleFromTokenMap(map, baseFontSize) {
|
|
161
|
+
const keys = [];
|
|
162
|
+
const baseKeys = [];
|
|
163
|
+
for (const [key, reference] of Object.entries(map)) {
|
|
164
|
+
const name = String(reference).match(/^var\((--[\w-]+)\)$/);
|
|
165
|
+
if (name && TAILWIND_BASE_TOKENS.has(name[1])) {
|
|
166
|
+
baseKeys.push(key);
|
|
167
|
+
} else {
|
|
168
|
+
keys.push(key);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
|
|
172
|
+
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve `scale: "auto"`. First matching source wins; sources are not merged so
|
|
177
|
+
* the provenance is a single file list or the stylesheet.
|
|
178
|
+
*/
|
|
179
|
+
function resolveAutoScale({
|
|
180
|
+
baseFontSize = 16,
|
|
181
|
+
root,
|
|
182
|
+
scaleSources = [],
|
|
183
|
+
tailwindConfigPath = null,
|
|
184
|
+
tokenPattern = DEFAULT_AUTO_TOKEN_PATTERN,
|
|
185
|
+
} = {}) {
|
|
186
|
+
const fromOption = scaleFromSources(scaleSources, baseFontSize);
|
|
187
|
+
if (fromOption) {
|
|
188
|
+
return { source: 'scaleSources', ...fromOption };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fromRc = scaleFromSources(rcTokenSources(process.cwd()), baseFontSize);
|
|
192
|
+
if (fromRc) {
|
|
193
|
+
return { source: 'rhythmguardrc', ...fromRc };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let tokenRegex;
|
|
197
|
+
try {
|
|
198
|
+
tokenRegex = new RegExp(tokenPattern);
|
|
199
|
+
} catch {
|
|
200
|
+
tokenRegex = new RegExp(DEFAULT_AUTO_TOKEN_PATTERN);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (root) {
|
|
204
|
+
const stylesheetMap = buildEffectiveTokenMap({
|
|
205
|
+
options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
|
|
206
|
+
root,
|
|
207
|
+
tokenRegex,
|
|
208
|
+
});
|
|
209
|
+
const scale = scaleFromTokenMap(stylesheetMap, baseFontSize);
|
|
210
|
+
if (scale) {
|
|
211
|
+
return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (tailwindConfigPath) {
|
|
216
|
+
const tailwindMap = buildEffectiveTokenMap({
|
|
217
|
+
options: {
|
|
218
|
+
baseFontSize,
|
|
219
|
+
tailwindConfigPath,
|
|
220
|
+
tokenMap: {},
|
|
221
|
+
tokenMapFromTailwindSpacing: true,
|
|
222
|
+
},
|
|
223
|
+
root,
|
|
224
|
+
tokenRegex,
|
|
225
|
+
});
|
|
226
|
+
const scale = scaleFromTokenMap(tailwindMap, baseFontSize);
|
|
227
|
+
if (scale) {
|
|
228
|
+
return { files: [tailwindConfigPath], scale, source: 'tailwind', tokenCount: scale.length - 1, warnings: [] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
files: [],
|
|
234
|
+
preset: FALLBACK_PRESET,
|
|
235
|
+
scale: getScalePreset(FALLBACK_PRESET),
|
|
236
|
+
source: 'fallback',
|
|
237
|
+
tokenCount: 0,
|
|
238
|
+
warnings: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function autoScaleFallbackNote(inference) {
|
|
243
|
+
if (!inference || inference.source !== 'fallback') {
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
252
|
+
autoScaleFallbackNote,
|
|
253
|
+
resolveAutoScale,
|
|
254
|
+
scaleFromDefinitions,
|
|
255
|
+
};
|
package/src/utils/token-map.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { parseTokenValueLength } = require('./token-sources');
|
|
4
|
+
|
|
3
5
|
const { spawnSync } = require('node:child_process');
|
|
4
6
|
const fs = require('node:fs');
|
|
5
7
|
const path = require('node:path');
|
|
@@ -43,7 +45,7 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
|
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
const parsed =
|
|
48
|
+
const parsed = parseTokenValueLength(rawLength);
|
|
47
49
|
if (!parsed) {
|
|
48
50
|
return;
|
|
49
51
|
}
|
|
@@ -196,7 +198,7 @@ function mergeTokenMapFromCssCustomProperties({
|
|
|
196
198
|
return;
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
const parsed =
|
|
201
|
+
const parsed = parseTokenValueLength(decl.value);
|
|
200
202
|
if (!parsed || parsed.number === 0) {
|
|
201
203
|
return;
|
|
202
204
|
}
|
|
@@ -31,7 +31,8 @@ const TOKEN_KIND_PATTERNS = Object.freeze({
|
|
|
31
31
|
radius: /^--radius-/,
|
|
32
32
|
motion: /^--(?:motion|duration|delay|ease|easing)-/,
|
|
33
33
|
size: /^--(?:size|width|height|container)-/,
|
|
34
|
-
|
|
34
|
+
// Anchored or prefixed (--spacing-4, --lb-spacing-md, bare Tailwind v4 --spacing), never letter-/word-spacing.
|
|
35
|
+
spacing: /(?:^--|-)(?<!letter-)(?<!word-)(?:space|spacing)(?:-|$)/,
|
|
35
36
|
typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
|
|
36
37
|
});
|
|
37
38
|
|
|
@@ -377,6 +378,34 @@ function addDefinition(definitions, {
|
|
|
377
378
|
definitions.set(token, entry);
|
|
378
379
|
}
|
|
379
380
|
|
|
381
|
+
const CALC_LENGTH_TIMES_VAR = /^calc\(\s*(-?[\d.]+(?:px|rem|em)?)\s*\*\s*var\([^()]*\)\s*\)$/i;
|
|
382
|
+
const CALC_VAR_TIMES_LENGTH = /^calc\(\s*var\([^()]*\)\s*\*\s*(-?[\d.]+(?:px|rem|em)?)\s*\)$/i;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Parse the length a token value carries. Accepts plain lengths and the
|
|
386
|
+
* `calc(<length> * var(--factor))` form design systems use for scaling
|
|
387
|
+
* (Radix Themes: `--space-1: calc(4px * var(--scaling))`).
|
|
388
|
+
*/
|
|
389
|
+
function parseTokenValueLength(value) {
|
|
390
|
+
if (typeof value !== 'string') {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const trimmed = value.trim();
|
|
395
|
+
const direct = parseLengthToken(trimmed);
|
|
396
|
+
if (direct) {
|
|
397
|
+
return direct;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const match = trimmed.match(CALC_LENGTH_TIMES_VAR) || trimmed.match(CALC_VAR_TIMES_LENGTH);
|
|
401
|
+
if (!match) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const inner = parseLengthToken(match[1]);
|
|
406
|
+
return inner ? { ...inner, raw: trimmed } : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
380
409
|
function getNormalizedValueKeys(value, baseFontSize = 16) {
|
|
381
410
|
if (value === null || value === undefined) {
|
|
382
411
|
return [];
|
|
@@ -388,7 +417,7 @@ function getNormalizedValueKeys(value, baseFontSize = 16) {
|
|
|
388
417
|
}
|
|
389
418
|
|
|
390
419
|
const keys = new Set([raw]);
|
|
391
|
-
const parsed =
|
|
420
|
+
const parsed = parseTokenValueLength(raw);
|
|
392
421
|
if (!parsed) {
|
|
393
422
|
return Array.from(keys);
|
|
394
423
|
}
|
|
@@ -417,4 +446,5 @@ module.exports = {
|
|
|
417
446
|
normalizeTokenKind,
|
|
418
447
|
normalizeTokenSourceFormat,
|
|
419
448
|
parseTokenSources,
|
|
449
|
+
parseTokenValueLength,
|
|
420
450
|
};
|