stylelint-plugin-rhythmguard 3.2.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 +31 -0
- package/CONTRIBUTING.md +19 -3
- package/README.md +2 -1
- package/package.json +9 -8
- package/src/audit/args.js +103 -307
- package/src/audit/config.js +1 -1
- package/src/audit/contract.js +6 -28
- package/src/audit/index.js +4 -7
- package/src/audit/render-markdown.js +3 -0
- package/src/audit/render-text.js +2 -1
- package/src/audit/report.js +26 -19
- 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 +5 -2
- package/src/{utils → core}/length.js +24 -1
- package/src/{utils → core}/options.js +32 -108
- package/src/core/scale-inference.js +560 -0
- package/src/{utils → core}/token-packages.json +7 -0
- package/src/{utils → core}/token-sources.js +155 -17
- 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/types/audit.d.ts +11 -0
- package/src/audit/scan.js +0 -676
- package/src/utils/scale-inference.js +0 -351
- /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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Findings from stylesheets: run the rules through Stylelint's API, turn each
|
|
5
|
+
* warning into a finding, and recover the CSS property at the warning position
|
|
6
|
+
* from the source, since Stylelint reports positions rather than nodes.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const {
|
|
10
|
+
DEFAULT_AUDIT_TOKEN_PATTERN,
|
|
11
|
+
formatPath,
|
|
12
|
+
pluginPath,
|
|
13
|
+
} = require('../shared');
|
|
14
|
+
const { isScssFile } = require('./files');
|
|
15
|
+
|
|
16
|
+
let scssSyntaxCache;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* postcss-scss is an optional peer. Resolve it from the audited project first,
|
|
20
|
+
* then from this package. Returns null when unavailable; SCSS files are then
|
|
21
|
+
* counted as skipped instead of failing the audit.
|
|
22
|
+
*/
|
|
23
|
+
function resolveScssSyntax() {
|
|
24
|
+
if (scssSyntaxCache !== undefined) {
|
|
25
|
+
return scssSyntaxCache;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
scssSyntaxCache = require.resolve('postcss-scss', { paths: [process.cwd(), __dirname] });
|
|
29
|
+
} catch {
|
|
30
|
+
scssSyntaxCache = null;
|
|
31
|
+
}
|
|
32
|
+
return scssSyntaxCache;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runStylelintAudit(cssFiles, options) {
|
|
36
|
+
if (cssFiles.length === 0) {
|
|
37
|
+
const empty = [];
|
|
38
|
+
empty.scssFiles = 0;
|
|
39
|
+
empty.scssSkipped = 0;
|
|
40
|
+
return empty;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { default: stylelint } = await import('stylelint');
|
|
44
|
+
const rules = {
|
|
45
|
+
'rhythmguard/use-scale': [
|
|
46
|
+
true,
|
|
47
|
+
{
|
|
48
|
+
baseFontSize: options.baseFontSize,
|
|
49
|
+
scale: options.scale,
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
'rhythmguard/prefer-token': [
|
|
54
|
+
true,
|
|
55
|
+
{
|
|
56
|
+
baseFontSize: options.baseFontSize,
|
|
57
|
+
scale: options.scale,
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
tokenMapFromCssCustomProperties: true,
|
|
60
|
+
tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (options.includeMotion) {
|
|
66
|
+
rules['rhythmguard/use-motion-scale'] = [
|
|
67
|
+
true,
|
|
68
|
+
{
|
|
69
|
+
severity: 'warning',
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const plainFiles = cssFiles.filter((file) => !isScssFile(file));
|
|
75
|
+
const scssFiles = cssFiles.filter(isScssFile);
|
|
76
|
+
const results = [];
|
|
77
|
+
|
|
78
|
+
if (plainFiles.length > 0) {
|
|
79
|
+
const result = await stylelint.lint({
|
|
80
|
+
files: plainFiles,
|
|
81
|
+
config: {
|
|
82
|
+
plugins: [pluginPath],
|
|
83
|
+
rules,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
results.push(...(result.results || []));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let scssSkipped = 0;
|
|
90
|
+
if (scssFiles.length > 0) {
|
|
91
|
+
const scssSyntax = resolveScssSyntax();
|
|
92
|
+
if (scssSyntax) {
|
|
93
|
+
const result = await stylelint.lint({
|
|
94
|
+
files: scssFiles,
|
|
95
|
+
config: {
|
|
96
|
+
customSyntax: scssSyntax,
|
|
97
|
+
plugins: [pluginPath],
|
|
98
|
+
rules,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
results.push(...(result.results || []));
|
|
102
|
+
} else {
|
|
103
|
+
scssSkipped = scssFiles.length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
results.scssFiles = scssFiles.length;
|
|
108
|
+
results.scssSkipped = scssSkipped;
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function collectCssFindings(fileResults) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
const sources = new Map();
|
|
115
|
+
|
|
116
|
+
for (const fileResult of fileResults) {
|
|
117
|
+
for (const warning of fileResult.warnings || []) {
|
|
118
|
+
const text = warning.text || '';
|
|
119
|
+
const source = readSourceOnce(sources, fileResult.source);
|
|
120
|
+
const offScaleMatch = text.match(
|
|
121
|
+
/Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
|
|
122
|
+
);
|
|
123
|
+
const tokenMatch = text.match(
|
|
124
|
+
/Unexpected raw scale value "([^"]+)"/,
|
|
125
|
+
);
|
|
126
|
+
const motionDurationMatch = text.match(
|
|
127
|
+
/Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
|
|
128
|
+
);
|
|
129
|
+
const motionEasingMatch = text.match(
|
|
130
|
+
/Unexpected raw motion easing "([^"]+)"/,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
findings.push({
|
|
134
|
+
column: warning.column || 1,
|
|
135
|
+
file: formatPath(fileResult.source),
|
|
136
|
+
line: warning.line || 1,
|
|
137
|
+
property: source === null
|
|
138
|
+
? null
|
|
139
|
+
: findDeclarationProperty(source, warning.line || 1, warning.column || 1),
|
|
140
|
+
rule: warning.rule || 'rhythmguard',
|
|
141
|
+
text,
|
|
142
|
+
type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
|
|
143
|
+
value: getCssFindingValue({
|
|
144
|
+
motionDurationMatch,
|
|
145
|
+
motionEasingMatch,
|
|
146
|
+
offScaleMatch,
|
|
147
|
+
tokenMatch,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return findings;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readSourceOnce(cache, filePath) {
|
|
157
|
+
if (!filePath) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (!cache.has(filePath)) {
|
|
161
|
+
try {
|
|
162
|
+
cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
|
|
163
|
+
} catch {
|
|
164
|
+
cache.set(filePath, null);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return cache.get(filePath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const DECLARATION_BOUNDARY = new Set([';', '{', '}']);
|
|
171
|
+
|
|
172
|
+
const DECLARATION_HEAD_PATTERN = /^\s*(?:(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*)*(--[\w-]+|[a-zA-Z][\w-]*)\s*:/;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stylelint warnings carry a position but not the declaration node. Recover the
|
|
176
|
+
* property by walking from the warning position back to the previous declaration
|
|
177
|
+
* boundary and reading the `property:` head, skipping block and Sass line comments.
|
|
178
|
+
* Sass interpolation is blanked first so `#{...}` braces do not act as boundaries. Returns null when the position is not
|
|
179
|
+
* inside a declaration, for example inside an at-rule.
|
|
180
|
+
*/
|
|
181
|
+
|
|
182
|
+
function findDeclarationProperty(source, line, column) {
|
|
183
|
+
const offset = positionToOffset(source, line, column);
|
|
184
|
+
if (offset === null) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const text = source.replace(/#\{[^}]*\}/g, (match) => ' '.repeat(match.length));
|
|
189
|
+
let start = offset;
|
|
190
|
+
while (start > 0 && !DECLARATION_BOUNDARY.has(text[start - 1])) {
|
|
191
|
+
start -= 1;
|
|
192
|
+
}
|
|
193
|
+
const backward = text.slice(start, offset).match(DECLARATION_HEAD_PATTERN);
|
|
194
|
+
if (backward) {
|
|
195
|
+
return backward[1];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let end = offset;
|
|
199
|
+
while (end < text.length && !DECLARATION_BOUNDARY.has(text[end])) {
|
|
200
|
+
end += 1;
|
|
201
|
+
}
|
|
202
|
+
const forward = text.slice(offset, end).match(DECLARATION_HEAD_PATTERN);
|
|
203
|
+
return forward ? forward[1] : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function positionToOffset(source, line, column) {
|
|
207
|
+
if (!Number.isInteger(line) || line < 1) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
let offset = 0;
|
|
211
|
+
let currentLine = 1;
|
|
212
|
+
while (currentLine < line) {
|
|
213
|
+
const newline = source.indexOf('\n', offset);
|
|
214
|
+
if (newline === -1) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
offset = newline + 1;
|
|
218
|
+
currentLine += 1;
|
|
219
|
+
}
|
|
220
|
+
const lineEnd = source.indexOf('\n', offset);
|
|
221
|
+
const lineLength = (lineEnd === -1 ? source.length : lineEnd) - offset;
|
|
222
|
+
return offset + Math.min(Math.max((column || 1) - 1, 0), lineLength);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getCssFindingValue({
|
|
226
|
+
motionDurationMatch,
|
|
227
|
+
motionEasingMatch,
|
|
228
|
+
offScaleMatch,
|
|
229
|
+
tokenMatch,
|
|
230
|
+
}) {
|
|
231
|
+
if (tokenMatch) {
|
|
232
|
+
return tokenMatch[1];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (offScaleMatch) {
|
|
236
|
+
return offScaleMatch[1];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (motionDurationMatch) {
|
|
240
|
+
return motionDurationMatch[1];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (motionEasingMatch) {
|
|
244
|
+
return motionEasingMatch[1];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
|
|
251
|
+
if (motionDurationMatch) {
|
|
252
|
+
return 'motion-duration';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (motionEasingMatch) {
|
|
256
|
+
return 'motion-easing';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (tokenMatch) {
|
|
260
|
+
return 'token-opportunity';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return 'off-scale';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
collectCssFindings,
|
|
268
|
+
findDeclarationProperty,
|
|
269
|
+
getCssFindingType,
|
|
270
|
+
getCssFindingValue,
|
|
271
|
+
positionToOffset,
|
|
272
|
+
readSourceOnce,
|
|
273
|
+
resolveScssSyntax,
|
|
274
|
+
runStylelintAudit,
|
|
275
|
+
};
|
|
@@ -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')];
|
|
@@ -160,7 +160,10 @@ async function run() {
|
|
|
160
160
|
out.push(' Scale');
|
|
161
161
|
out.push(` Scale ${scale.values.join(', ')}`);
|
|
162
162
|
out.push(` Source ${describeScale(scale)}`);
|
|
163
|
-
if (scale.source === 'fallback') {
|
|
163
|
+
if (scale.source === 'fallback' && scale.rejected) {
|
|
164
|
+
out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the rhythmic-4 preset is being used.`);
|
|
165
|
+
out.push(' Point scaleSources at your real token file, or tighten tokenPattern.');
|
|
166
|
+
} else if (scale.source === 'fallback') {
|
|
164
167
|
out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
|
|
165
168
|
out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
|
|
166
169
|
}
|
|
@@ -69,8 +69,10 @@ function formatNumber(value) {
|
|
|
69
69
|
return '0';
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// String() already prints the shortest form (1.5, not 1.50). The old
|
|
73
|
+
// trailing-zero strip also turned 30 into 3 and 100 into 1.
|
|
72
74
|
const rounded = Math.round(value * 10000) / 10000;
|
|
73
|
-
return String(rounded)
|
|
75
|
+
return String(rounded);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
function formatLength(number, unit) {
|
|
@@ -180,7 +182,28 @@ function nearestScaleValues(target, scale) {
|
|
|
180
182
|
};
|
|
181
183
|
}
|
|
182
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
|
+
|
|
183
205
|
module.exports = {
|
|
206
|
+
fixedLengthValue,
|
|
184
207
|
formatLength,
|
|
185
208
|
fromPx,
|
|
186
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
|
};
|