stylelint-plugin-rhythmguard 3.3.0 → 3.4.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 +15 -0
- package/CONTRIBUTING.md +19 -3
- package/README.md +2 -1
- package/package.json +7 -7
- package/src/audit/args.js +103 -307
- package/src/audit/config.js +1 -1
- package/src/audit/contract.js +5 -28
- package/src/audit/index.js +4 -7
- package/src/audit/report.js +14 -14
- package/src/audit/scan/files.js +262 -0
- package/src/audit/scan/stylesheets.js +275 -0
- package/src/audit/scan/templates.js +175 -0
- package/src/cli/doctor.js +1 -1
- package/src/cli/quickstart.js +1 -1
- package/src/{utils → core}/length.js +21 -0
- package/src/{utils → core}/options.js +32 -108
- package/src/{utils → core}/scale-inference.js +151 -15
- package/src/{utils → core}/token-sources.js +121 -12
- package/src/{utils/value-utils.js → core/value-nodes.js} +1 -17
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -2
- package/src/eslint/rules/tailwind-class-use-scale.js +2 -2
- package/src/rules/no-offscale-transform/index.js +22 -91
- package/src/rules/prefer-token/index.js +15 -73
- package/src/rules/report.js +75 -0
- package/src/rules/use-motion-scale/index.js +25 -42
- package/src/rules/use-scale/index.js +21 -94
- package/src/rules/validate.js +132 -0
- package/src/audit/scan.js +0 -676
- /package/src/{utils/constants.js → core/css-vocabulary.js} +0 -0
- /package/src/{utils → core}/tailwind-class-analysis.js +0 -0
- /package/src/{utils → core}/tailwind-motion-analysis.js +0 -0
- /package/src/{utils → core}/time.js +0 -0
- /package/src/{utils → core}/token-map.js +0 -0
- /package/src/{utils → core}/token-packages.json +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Findings from template files: Tailwind class strings inside string literals,
|
|
5
|
+
* for spacing and (opt-in) motion, with line and column recovered from offsets.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const { formatLength } = require('../../core/length');
|
|
9
|
+
const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
|
|
10
|
+
const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
|
|
11
|
+
const { formatTime } = require('../../core/time');
|
|
12
|
+
const {
|
|
13
|
+
formatPath,
|
|
14
|
+
} = require('../shared');
|
|
15
|
+
|
|
16
|
+
function collectTailwindFindings(templateFiles, options) {
|
|
17
|
+
const analyzer = createTailwindClassAnalyzer(options);
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
for (const filePath of templateFiles) {
|
|
21
|
+
let source = '';
|
|
22
|
+
try {
|
|
23
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
24
|
+
} catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lineStarts = getLineStarts(source);
|
|
29
|
+
|
|
30
|
+
for (const literal of findStringLiterals(source)) {
|
|
31
|
+
for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
|
|
32
|
+
const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
|
|
33
|
+
findings.push({
|
|
34
|
+
column: position.column,
|
|
35
|
+
file: formatPath(filePath),
|
|
36
|
+
fixedToken: analysis.fixedToken,
|
|
37
|
+
line: position.line,
|
|
38
|
+
nearest: analysis.nearest
|
|
39
|
+
? {
|
|
40
|
+
lower: formatLength(analysis.nearest.lower, 'px'),
|
|
41
|
+
upper: formatLength(analysis.nearest.upper, 'px'),
|
|
42
|
+
}
|
|
43
|
+
: null,
|
|
44
|
+
rawValue: analysis.rawValue,
|
|
45
|
+
rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
|
|
46
|
+
text: analysis.reason === 'negative'
|
|
47
|
+
? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
|
|
48
|
+
: `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
|
|
49
|
+
token: segment.token,
|
|
50
|
+
type: 'tailwind-arbitrary-spacing',
|
|
51
|
+
utility: analysis.utility,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function collectTailwindMotionFindings(templateFiles, options) {
|
|
61
|
+
if (!options.includeMotion) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const analyzer = createTailwindMotionAnalyzer(options);
|
|
66
|
+
const findings = [];
|
|
67
|
+
|
|
68
|
+
for (const filePath of templateFiles) {
|
|
69
|
+
let source = '';
|
|
70
|
+
try {
|
|
71
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lineStarts = getLineStarts(source);
|
|
77
|
+
|
|
78
|
+
for (const literal of findStringLiterals(source)) {
|
|
79
|
+
for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
|
|
80
|
+
const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
|
|
81
|
+
findings.push({
|
|
82
|
+
column: position.column,
|
|
83
|
+
file: formatPath(filePath),
|
|
84
|
+
fixedToken: analysis.fixedToken,
|
|
85
|
+
line: position.line,
|
|
86
|
+
nearest: analysis.nearest
|
|
87
|
+
? {
|
|
88
|
+
lower: formatTime(analysis.nearest.lower, 'ms'),
|
|
89
|
+
upper: formatTime(analysis.nearest.upper, 'ms'),
|
|
90
|
+
}
|
|
91
|
+
: null,
|
|
92
|
+
rawValue: analysis.rawValue,
|
|
93
|
+
rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
|
|
94
|
+
text: buildTailwindMotionFindingText(segment.token, analysis),
|
|
95
|
+
token: segment.token,
|
|
96
|
+
type: analysis.reason === 'easing'
|
|
97
|
+
? 'tailwind-motion-easing'
|
|
98
|
+
: 'tailwind-motion-duration',
|
|
99
|
+
utility: analysis.utility,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildTailwindMotionFindingText(token, analysis) {
|
|
109
|
+
if (analysis.reason === 'easing') {
|
|
110
|
+
return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (analysis.reason === 'negative') {
|
|
114
|
+
return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findStringLiterals(source) {
|
|
121
|
+
const literals = [];
|
|
122
|
+
const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
|
|
123
|
+
let match;
|
|
124
|
+
|
|
125
|
+
while ((match = literalPattern.exec(source)) !== null) {
|
|
126
|
+
literals.push({
|
|
127
|
+
quote: match[1],
|
|
128
|
+
value: match[2],
|
|
129
|
+
valueStart: match.index + 1,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return literals;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getLineStarts(source) {
|
|
137
|
+
const starts = [0];
|
|
138
|
+
|
|
139
|
+
for (let index = 0; index < source.length; index++) {
|
|
140
|
+
if (source[index] === '\n') {
|
|
141
|
+
starts.push(index + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return starts;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function offsetToLineColumn(lineStarts, offset) {
|
|
149
|
+
let low = 0;
|
|
150
|
+
let high = lineStarts.length - 1;
|
|
151
|
+
|
|
152
|
+
while (low <= high) {
|
|
153
|
+
const mid = Math.floor((low + high) / 2);
|
|
154
|
+
if (lineStarts[mid] <= offset) {
|
|
155
|
+
low = mid + 1;
|
|
156
|
+
} else {
|
|
157
|
+
high = mid - 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lineIndex = Math.max(0, high);
|
|
162
|
+
return {
|
|
163
|
+
column: offset - lineStarts[lineIndex] + 1,
|
|
164
|
+
line: lineIndex + 1,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
buildTailwindMotionFindingText,
|
|
170
|
+
collectTailwindFindings,
|
|
171
|
+
collectTailwindMotionFindings,
|
|
172
|
+
findStringLiterals,
|
|
173
|
+
getLineStarts,
|
|
174
|
+
offsetToLineColumn,
|
|
175
|
+
};
|
package/src/cli/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
-
const { normalizeTokenSourceFormat } = require('../
|
|
5
|
+
const { normalizeTokenSourceFormat } = require('../core/token-sources');
|
|
6
6
|
|
|
7
7
|
const cwd = process.cwd();
|
|
8
8
|
let issues = 0;
|
package/src/cli/quickstart.js
CHANGED
|
@@ -14,7 +14,7 @@ const path = require('node:path');
|
|
|
14
14
|
|
|
15
15
|
const { createAuditReport } = require('../audit/report');
|
|
16
16
|
const { detect } = require('./init');
|
|
17
|
-
const { discoverTokenPackages } = require('../
|
|
17
|
+
const { discoverTokenPackages } = require('../core/scale-inference');
|
|
18
18
|
|
|
19
19
|
const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
|
|
20
20
|
const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
|
|
@@ -182,7 +182,28 @@ function nearestScaleValues(target, scale) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The replacement text for an off-scale length, or null when the value's unit
|
|
187
|
+
* is not one the rule may rewrite. Keeps the sign, and with the `exact` unit
|
|
188
|
+
* strategy keeps the unit as written instead of converting through px.
|
|
189
|
+
*/
|
|
190
|
+
function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy, units }) {
|
|
191
|
+
const unit = parsedLength.unit || 'px';
|
|
192
|
+
if (unit === '%' || !units.includes(unit)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const signedNearest = parsedLength.number < 0 ? -Math.abs(nearestPx) : nearestPx;
|
|
197
|
+
if (unitStrategy === 'exact') {
|
|
198
|
+
return formatLength(signedNearest, unit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const converted = fromPx(signedNearest, unit, baseFontSize);
|
|
202
|
+
return converted === null ? null : formatLength(converted, unit);
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
module.exports = {
|
|
206
|
+
fixedLengthValue,
|
|
186
207
|
formatLength,
|
|
187
208
|
fromPx,
|
|
188
209
|
isHairlineLength,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { normalizeScale, normalizeScaleByUnit } = require('./length');
|
|
4
|
+
|
|
4
5
|
const { all: knownCssProperties = [] } = require('known-css-properties');
|
|
5
6
|
const {
|
|
6
7
|
DEFAULT_IGNORE_KEYWORDS,
|
|
@@ -10,7 +11,7 @@ const {
|
|
|
10
11
|
PROPERTY_GROUP_PATTERNS,
|
|
11
12
|
SPACING_PROPERTY_PATTERNS,
|
|
12
13
|
SUPPORTED_SCALE_UNITS,
|
|
13
|
-
} = require('./
|
|
14
|
+
} = require('./css-vocabulary');
|
|
14
15
|
const { parseLengthToken } = require('./length');
|
|
15
16
|
const {
|
|
16
17
|
getScalePreset,
|
|
@@ -236,57 +237,6 @@ function isPropertyScaleMap(value) {
|
|
|
236
237
|
});
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
function validateSecondaryOptionShapes(result, ruleName, secondaryOptions, schema) {
|
|
240
|
-
if (secondaryOptions === undefined || secondaryOptions === null) {
|
|
241
|
-
return true;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (!isPlainObject(secondaryOptions)) {
|
|
245
|
-
return true;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
let valid = true;
|
|
249
|
-
|
|
250
|
-
for (const [optionName, descriptor] of Object.entries(schema)) {
|
|
251
|
-
const optionValue = secondaryOptions[optionName];
|
|
252
|
-
if (optionValue === undefined) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const literalAllowed = Array.isArray(descriptor.allowLiterals)
|
|
257
|
-
&& descriptor.allowLiterals.includes(optionValue);
|
|
258
|
-
|
|
259
|
-
if (descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) {
|
|
260
|
-
valid = false;
|
|
261
|
-
result.warn(
|
|
262
|
-
`Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
|
|
263
|
-
{ stylelintType: 'invalidOption' },
|
|
264
|
-
);
|
|
265
|
-
result.stylelint.stylelintError = true;
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (descriptor.expectsObject && !isPlainObject(optionValue)) {
|
|
270
|
-
valid = false;
|
|
271
|
-
result.warn(
|
|
272
|
-
`Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
|
|
273
|
-
{ stylelintType: 'invalidOption' },
|
|
274
|
-
);
|
|
275
|
-
result.stylelint.stylelintError = true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return valid;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function stringifyOptionValue(value) {
|
|
283
|
-
if (typeof value === 'string') {
|
|
284
|
-
return `"${value}"`;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return `"${JSON.stringify(value)}"`;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
240
|
function buildPossibleOptionMap(schema) {
|
|
291
241
|
return Object.fromEntries(
|
|
292
242
|
Object.entries(schema).map(([optionName, descriptor]) => [
|
|
@@ -296,28 +246,6 @@ function buildPossibleOptionMap(schema) {
|
|
|
296
246
|
);
|
|
297
247
|
}
|
|
298
248
|
|
|
299
|
-
function validateSecondaryOptions({
|
|
300
|
-
result,
|
|
301
|
-
ruleName,
|
|
302
|
-
secondaryOptions,
|
|
303
|
-
schema,
|
|
304
|
-
possibleOptionMap,
|
|
305
|
-
}) {
|
|
306
|
-
const validOptions = stylelint.utils.validateOptions(result, ruleName, {
|
|
307
|
-
actual: secondaryOptions,
|
|
308
|
-
optional: true,
|
|
309
|
-
possible: possibleOptionMap,
|
|
310
|
-
});
|
|
311
|
-
const validShapes = validateSecondaryOptionShapes(
|
|
312
|
-
result,
|
|
313
|
-
ruleName,
|
|
314
|
-
secondaryOptions,
|
|
315
|
-
schema,
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
return validOptions && validShapes;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
249
|
function normalizePropertyGroups(rawGroups) {
|
|
322
250
|
const source = Array.isArray(rawGroups) && rawGroups.length > 0
|
|
323
251
|
? rawGroups
|
|
@@ -713,6 +641,27 @@ function buildTokenOptions(rawOptions) {
|
|
|
713
641
|
};
|
|
714
642
|
}
|
|
715
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Per-property scale lookup with a cache, because a stylesheet repeats the
|
|
646
|
+
* same few properties thousands of times and normalising a scale is not free.
|
|
647
|
+
*/
|
|
648
|
+
function createPropertyScaleResolver(options) {
|
|
649
|
+
const cache = new Map();
|
|
650
|
+
return (prop) => {
|
|
651
|
+
const cached = cache.get(prop);
|
|
652
|
+
if (cached) {
|
|
653
|
+
return cached;
|
|
654
|
+
}
|
|
655
|
+
const selectedScale = resolvePropertyScale(prop, options);
|
|
656
|
+
const state = {
|
|
657
|
+
scaleByUnit: normalizeScaleByUnit(selectedScale),
|
|
658
|
+
scalePx: normalizeScale(selectedScale, options.baseFontSize),
|
|
659
|
+
};
|
|
660
|
+
cache.set(prop, state);
|
|
661
|
+
return state;
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
716
665
|
function resolvePropertyScale(prop, options) {
|
|
717
666
|
if (!Array.isArray(options.propertyScaleOverrides)) {
|
|
718
667
|
return options.scale;
|
|
@@ -739,41 +688,16 @@ function resolvePropertyScale(prop, options) {
|
|
|
739
688
|
return options.scale;
|
|
740
689
|
}
|
|
741
690
|
|
|
742
|
-
function validateUseScaleSecondaryOptions(result, ruleName, secondaryOptions) {
|
|
743
|
-
return validateSecondaryOptions({
|
|
744
|
-
result,
|
|
745
|
-
ruleName,
|
|
746
|
-
secondaryOptions,
|
|
747
|
-
schema: USE_SCALE_VALIDATION_SCHEMA,
|
|
748
|
-
possibleOptionMap: USE_SCALE_POSSIBLE_OPTIONS,
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function validateNoOffscaleTransformSecondaryOptions(result, ruleName, secondaryOptions) {
|
|
753
|
-
return validateSecondaryOptions({
|
|
754
|
-
result,
|
|
755
|
-
ruleName,
|
|
756
|
-
secondaryOptions,
|
|
757
|
-
schema: NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
|
|
758
|
-
possibleOptionMap: NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function validatePreferTokenSecondaryOptions(result, ruleName, secondaryOptions) {
|
|
763
|
-
return validateSecondaryOptions({
|
|
764
|
-
result,
|
|
765
|
-
ruleName,
|
|
766
|
-
secondaryOptions,
|
|
767
|
-
schema: PREFER_TOKEN_VALIDATION_SCHEMA,
|
|
768
|
-
possibleOptionMap: PREFER_TOKEN_POSSIBLE_OPTIONS,
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
|
|
772
691
|
module.exports = {
|
|
692
|
+
NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
|
|
693
|
+
NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
|
|
694
|
+
PREFER_TOKEN_POSSIBLE_OPTIONS,
|
|
695
|
+
PREFER_TOKEN_VALIDATION_SCHEMA,
|
|
696
|
+
USE_SCALE_POSSIBLE_OPTIONS,
|
|
697
|
+
USE_SCALE_VALIDATION_SCHEMA,
|
|
773
698
|
buildScaleOptions,
|
|
774
699
|
buildTokenOptions,
|
|
700
|
+
createPropertyScaleResolver,
|
|
701
|
+
isPlainObject,
|
|
775
702
|
resolvePropertyScale,
|
|
776
|
-
validateNoOffscaleTransformSecondaryOptions,
|
|
777
|
-
validatePreferTokenSecondaryOptions,
|
|
778
|
-
validateUseScaleSecondaryOptions,
|
|
779
703
|
};
|
|
@@ -5,7 +5,13 @@ const path = require('node:path');
|
|
|
5
5
|
|
|
6
6
|
const { parseLengthToken, toPx } = require('./length');
|
|
7
7
|
const { buildEffectiveTokenMap } = require('./token-map');
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
addDefinition,
|
|
10
|
+
collectScssTokens,
|
|
11
|
+
createTokenKindMatcher,
|
|
12
|
+
parseTokenSources,
|
|
13
|
+
parseTokenValueLength,
|
|
14
|
+
} = require('./token-sources');
|
|
9
15
|
const { getScalePreset } = require('../presets/scales');
|
|
10
16
|
|
|
11
17
|
// Matches the audit default so lint and audit agree on what a spacing token is.
|
|
@@ -47,10 +53,40 @@ function readDirectDependencies(dir) {
|
|
|
47
53
|
* installs are found by walking up; a stray global node_modules is never
|
|
48
54
|
* consulted because the walk stops at the repository.
|
|
49
55
|
*/
|
|
50
|
-
|
|
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
|
+
function projectRoots(cwd, consult = () => {}) {
|
|
51
86
|
const roots = [];
|
|
52
87
|
let current = path.resolve(cwd);
|
|
53
88
|
for (;;) {
|
|
89
|
+
consult(path.join(current, 'package.json'));
|
|
54
90
|
const direct = readDirectDependencies(current);
|
|
55
91
|
if (direct) {
|
|
56
92
|
roots.push({ dir: current, direct });
|
|
@@ -65,8 +101,12 @@ function projectRoots(cwd) {
|
|
|
65
101
|
}
|
|
66
102
|
|
|
67
103
|
function discoverTokenPackages(cwd = process.cwd()) {
|
|
104
|
+
return cachedByFiles(`packages:${path.resolve(cwd)}`, (consult) => discoverTokenPackagesUncached(cwd, consult));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function discoverTokenPackagesUncached(cwd, consult) {
|
|
68
108
|
const sources = [];
|
|
69
|
-
const roots = projectRoots(cwd);
|
|
109
|
+
const roots = projectRoots(cwd, consult);
|
|
70
110
|
for (const entry of TOKEN_PACKAGES) {
|
|
71
111
|
// Only packages the project depends on directly count. A transitive
|
|
72
112
|
// tailwindcss (for example via stylelint-config-tailwindcss) must not hand
|
|
@@ -75,14 +115,17 @@ function discoverTokenPackages(cwd = process.cwd()) {
|
|
|
75
115
|
if (!owner) {
|
|
76
116
|
continue;
|
|
77
117
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
118
|
+
const candidates = roots.map((candidate) => path.join(candidate.dir, 'node_modules', entry.name));
|
|
119
|
+
for (const dir of candidates) {
|
|
120
|
+
consult(path.join(dir, 'package.json'));
|
|
121
|
+
}
|
|
122
|
+
const root = candidates.find((dir) => fs.existsSync(path.join(dir, 'package.json')));
|
|
81
123
|
if (!root) {
|
|
82
124
|
continue;
|
|
83
125
|
}
|
|
84
126
|
for (const file of entry.files) {
|
|
85
127
|
const resolved = path.join(root, file);
|
|
128
|
+
consult(resolved);
|
|
86
129
|
if (fs.existsSync(resolved)) {
|
|
87
130
|
sources.push({
|
|
88
131
|
format: 'auto',
|
|
@@ -176,8 +219,38 @@ function scaleFromSources(sources, baseFontSize) {
|
|
|
176
219
|
return outcome;
|
|
177
220
|
}
|
|
178
221
|
|
|
179
|
-
/**
|
|
222
|
+
/**
|
|
223
|
+
* Build a px scale from token definitions. Root-level declarations win: when
|
|
224
|
+
* the definitions declared in `:root`, `html` or `@theme` form a scale on
|
|
225
|
+
* their own, component-local variables (`--chip-spacing: 3px` inside `.chip`)
|
|
226
|
+
* are left out, because they are a component's parameters, not the project's
|
|
227
|
+
* scale (issue #54). When the root does not carry a scale, everything counts
|
|
228
|
+
* and the plausibility check is the backstop. Returns the values and the
|
|
229
|
+
* definitions that produced them, so provenance can name only those files.
|
|
230
|
+
*/
|
|
231
|
+
function inferScaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
232
|
+
const rootOnly = new Map();
|
|
233
|
+
for (const [token, definition] of definitions) {
|
|
234
|
+
if (!definition.scopes || definition.scopes.has('root')) {
|
|
235
|
+
rootOnly.set(token, definition);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (rootOnly.size > 0 && rootOnly.size < definitions.size) {
|
|
239
|
+
const fromRoot = scaleFromAllDefinitions(rootOnly, baseFontSize);
|
|
240
|
+
if (fromRoot) {
|
|
241
|
+
return { definitions: rootOnly, values: fromRoot };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const values = scaleFromAllDefinitions(definitions, baseFontSize);
|
|
245
|
+
return values ? { definitions, values } : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
180
248
|
function scaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
249
|
+
const inferred = inferScaleFromDefinitions(definitions, baseFontSize);
|
|
250
|
+
return inferred ? inferred.values : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function scaleFromAllDefinitions(definitions, baseFontSize) {
|
|
181
254
|
const keys = [];
|
|
182
255
|
const baseKeys = [];
|
|
183
256
|
for (const definition of definitions.values()) {
|
|
@@ -191,6 +264,44 @@ function scaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
|
191
264
|
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
192
265
|
}
|
|
193
266
|
|
|
267
|
+
/** The scope of a declaration node: the AST-side twin of customPropertyDeclarations. */
|
|
268
|
+
function scopeOfNode(decl) {
|
|
269
|
+
for (let node = decl.parent; node; node = node.parent) {
|
|
270
|
+
if (node.type === 'rule') {
|
|
271
|
+
return String(node.selector).split(',').every((selector) => ROOT_SELECTOR.test(selector.trim())) ? 'root' : 'component';
|
|
272
|
+
}
|
|
273
|
+
if (node.type === 'atrule') {
|
|
274
|
+
const name = String(node.name).toLowerCase();
|
|
275
|
+
if (name === 'theme') return 'root';
|
|
276
|
+
if (!TRANSPARENT_AT_RULES.has(name)) return 'component';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return 'root';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const ROOT_SELECTOR = /^(?::root|html|:host|:(?:where|is)\(\s*(?::root|html)\s*\))$/i;
|
|
283
|
+
const TRANSPARENT_AT_RULES = new Set(['media', 'supports', 'layer', 'container', 'scope', 'document']);
|
|
284
|
+
|
|
285
|
+
/** Token definitions declared in the linted stylesheet itself: custom properties with their scope, plus Sass variables and maps. */
|
|
286
|
+
function stylesheetDefinitions(root, tokenRegex, baseFontSize) {
|
|
287
|
+
const definitions = new Map();
|
|
288
|
+
root.walkDecls((decl) => {
|
|
289
|
+
const prop = decl.prop.toLowerCase();
|
|
290
|
+
if (!prop.startsWith('--') || !tokenRegex.test(prop)) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const parsed = parseTokenValueLength(decl.value);
|
|
294
|
+
if (!parsed || parsed.number === 0) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
addDefinition(definitions, { baseFontSize, file: 'stylesheet', scope: scopeOfNode(decl), source: 'stylesheet', token: decl.prop, value: decl.value });
|
|
298
|
+
});
|
|
299
|
+
for (const sassToken of sassTokensFromRoot(root)) {
|
|
300
|
+
addDefinition(definitions, { baseFontSize, file: 'stylesheet', scope: 'root', source: 'stylesheet', token: sassToken.token, value: sassToken.value });
|
|
301
|
+
}
|
|
302
|
+
return definitions;
|
|
303
|
+
}
|
|
304
|
+
|
|
194
305
|
/**
|
|
195
306
|
* Merge Tailwind-style base multiples into a scale when a bare --spacing/--space
|
|
196
307
|
* base is defined. Only the first base found is expanded: a project that ships
|
|
@@ -225,6 +336,13 @@ function firstPositivePx(keys, baseFontSize) {
|
|
|
225
336
|
|
|
226
337
|
function rcTokenSources(cwd) {
|
|
227
338
|
const rcPath = path.join(cwd, RC_FILE);
|
|
339
|
+
return cachedByFiles(`rc:${rcPath}`, (consult) => {
|
|
340
|
+
consult(rcPath);
|
|
341
|
+
return rcTokenSourcesUncached(rcPath);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function rcTokenSourcesUncached(rcPath) {
|
|
228
346
|
if (!fs.existsSync(rcPath)) {
|
|
229
347
|
return [];
|
|
230
348
|
}
|
|
@@ -251,7 +369,7 @@ function rcTokenSources(cwd) {
|
|
|
251
369
|
* same collector the audit uses; component variables such as $dropdown-spacer
|
|
252
370
|
* are excluded by the anchored name rule.
|
|
253
371
|
*/
|
|
254
|
-
function
|
|
372
|
+
function sassTokensFromRoot(root) {
|
|
255
373
|
const lines = [];
|
|
256
374
|
root.walkDecls((decl) => {
|
|
257
375
|
if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
|
|
@@ -261,7 +379,7 @@ function sassValuesFromRoot(root) {
|
|
|
261
379
|
if (lines.length === 0) {
|
|
262
380
|
return [];
|
|
263
381
|
}
|
|
264
|
-
return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing'))
|
|
382
|
+
return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing'));
|
|
265
383
|
}
|
|
266
384
|
|
|
267
385
|
function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
|
|
@@ -309,12 +427,7 @@ function resolveAutoScale({
|
|
|
309
427
|
|
|
310
428
|
let rejected = null;
|
|
311
429
|
if (root) {
|
|
312
|
-
const
|
|
313
|
-
options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
|
|
314
|
-
root,
|
|
315
|
-
tokenRegex,
|
|
316
|
-
});
|
|
317
|
-
const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
|
|
430
|
+
const scale = scaleFromDefinitions(stylesheetDefinitions(root, tokenRegex, baseFontSize), baseFontSize);
|
|
318
431
|
if (scale) {
|
|
319
432
|
const assessment = assessScale({ source: 'stylesheet', values: scale });
|
|
320
433
|
if (assessment.plausible) {
|
|
@@ -403,6 +516,27 @@ function fallbackInference(rejected = null) {
|
|
|
403
516
|
};
|
|
404
517
|
}
|
|
405
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Apply `scale: "auto"` to built rule options: the inferred scale replaces the
|
|
521
|
+
* placeholder and the inference is kept for the fallback note. Options with
|
|
522
|
+
* an explicit scale pass through untouched.
|
|
523
|
+
*/
|
|
524
|
+
function withResolvedScale(options, root) {
|
|
525
|
+
if (!options.scaleAuto) {
|
|
526
|
+
return options;
|
|
527
|
+
}
|
|
528
|
+
const inference = resolveAutoScale({
|
|
529
|
+
baseFontSize: options.baseFontSize,
|
|
530
|
+
root,
|
|
531
|
+
scaleSources: options.scaleSources,
|
|
532
|
+
tailwindConfigPath: options.tailwindConfigPath,
|
|
533
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
|
|
534
|
+
});
|
|
535
|
+
options.scale = inference.scale;
|
|
536
|
+
options.scaleInference = inference;
|
|
537
|
+
return options;
|
|
538
|
+
}
|
|
539
|
+
|
|
406
540
|
function autoScaleFallbackNote(inference) {
|
|
407
541
|
if (!inference || inference.source !== 'fallback') {
|
|
408
542
|
return '';
|
|
@@ -419,6 +553,8 @@ module.exports = {
|
|
|
419
553
|
assessScale,
|
|
420
554
|
autoScaleFallbackNote,
|
|
421
555
|
discoverTokenPackages,
|
|
556
|
+
inferScaleFromDefinitions,
|
|
422
557
|
resolveAutoScale,
|
|
423
558
|
scaleFromDefinitions,
|
|
559
|
+
withResolvedScale,
|
|
424
560
|
};
|