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
|
@@ -1,351 +0,0 @@
|
|
|
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 { collectScssTokens, createTokenKindMatcher, 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
|
-
const TOKEN_PACKAGES = require('./token-packages.json').packages;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Installed design-token packages that ship a spacing scale (allowlist in
|
|
28
|
-
* token-packages.json). Resolved from the project, so only what the project
|
|
29
|
-
* actually depends on is read. Returns token-source entries.
|
|
30
|
-
*/
|
|
31
|
-
function readDirectDependencies(dir) {
|
|
32
|
-
try {
|
|
33
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
34
|
-
return new Set([
|
|
35
|
-
...Object.keys(pkg.dependencies || {}),
|
|
36
|
-
...Object.keys(pkg.devDependencies || {}),
|
|
37
|
-
...Object.keys(pkg.peerDependencies || {}),
|
|
38
|
-
]);
|
|
39
|
-
} catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Project roots from cwd up to the repository boundary (first directory holding
|
|
46
|
-
* .git), each with the dependencies it declares directly. Hoisted monorepo
|
|
47
|
-
* installs are found by walking up; a stray global node_modules is never
|
|
48
|
-
* consulted because the walk stops at the repository.
|
|
49
|
-
*/
|
|
50
|
-
function projectRoots(cwd) {
|
|
51
|
-
const roots = [];
|
|
52
|
-
let current = path.resolve(cwd);
|
|
53
|
-
for (;;) {
|
|
54
|
-
const direct = readDirectDependencies(current);
|
|
55
|
-
if (direct) {
|
|
56
|
-
roots.push({ dir: current, direct });
|
|
57
|
-
}
|
|
58
|
-
const parent = path.dirname(current);
|
|
59
|
-
if (fs.existsSync(path.join(current, '.git')) || parent === current) {
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
current = parent;
|
|
63
|
-
}
|
|
64
|
-
return roots;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function discoverTokenPackages(cwd = process.cwd()) {
|
|
68
|
-
const sources = [];
|
|
69
|
-
const roots = projectRoots(cwd);
|
|
70
|
-
for (const entry of TOKEN_PACKAGES) {
|
|
71
|
-
// Only packages the project depends on directly count. A transitive
|
|
72
|
-
// tailwindcss (for example via stylelint-config-tailwindcss) must not hand
|
|
73
|
-
// a non-Tailwind project the Tailwind scale.
|
|
74
|
-
const owner = roots.find((root) => root.direct.has(entry.name));
|
|
75
|
-
if (!owner) {
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
const root = roots
|
|
79
|
-
.map((candidate) => path.join(candidate.dir, 'node_modules', entry.name))
|
|
80
|
-
.find((dir) => fs.existsSync(path.join(dir, 'package.json')));
|
|
81
|
-
if (!root) {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
for (const file of entry.files) {
|
|
85
|
-
const resolved = path.join(root, file);
|
|
86
|
-
if (fs.existsSync(resolved)) {
|
|
87
|
-
sources.push({
|
|
88
|
-
format: 'auto',
|
|
89
|
-
package: entry.name,
|
|
90
|
-
path: resolved,
|
|
91
|
-
...(entry.tokenPattern ? { tokenPattern: entry.tokenPattern } : {}),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return sources;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function pxValuesFromKeys(keys, baseFontSize) {
|
|
100
|
-
const values = new Set([0]);
|
|
101
|
-
|
|
102
|
-
for (const key of keys) {
|
|
103
|
-
const parsed = parseLengthToken(String(key));
|
|
104
|
-
if (!parsed) {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const px = toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize);
|
|
109
|
-
if (px !== null && Number.isFinite(px)) {
|
|
110
|
-
values.add(px);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return Array.from(values).sort((a, b) => a - b);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function normalizeSource(source, baseDir) {
|
|
118
|
-
if (typeof source === 'string') {
|
|
119
|
-
return { format: 'auto', path: path.resolve(baseDir, source) };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (source && typeof source === 'object') {
|
|
123
|
-
const rawPath = source.path || source.file;
|
|
124
|
-
if (typeof rawPath !== 'string') {
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
format: typeof source.format === 'string' ? source.format : 'auto',
|
|
130
|
-
path: path.resolve(source.baseDir || baseDir, rawPath),
|
|
131
|
-
...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function cacheKey(sources) {
|
|
139
|
-
return sources
|
|
140
|
-
.map((source) => {
|
|
141
|
-
let mtime = 'missing';
|
|
142
|
-
try {
|
|
143
|
-
mtime = String(fs.statSync(source.path).mtimeMs);
|
|
144
|
-
} catch {
|
|
145
|
-
// missing file: key still changes when it appears
|
|
146
|
-
}
|
|
147
|
-
return `${source.path}|${source.format}|${source.tokenPattern || ''}|${mtime}`;
|
|
148
|
-
})
|
|
149
|
-
.join('\n');
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function scaleFromSources(sources, baseFontSize) {
|
|
153
|
-
const normalized = sources.map((source) => normalizeSource(source, process.cwd())).filter(Boolean);
|
|
154
|
-
if (normalized.length === 0) {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const key = `${baseFontSize}\n${cacheKey(normalized)}`;
|
|
159
|
-
if (sourceCache.has(key)) {
|
|
160
|
-
return sourceCache.get(key);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const parsed = parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
|
|
164
|
-
// scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
|
|
165
|
-
const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
|
|
166
|
-
const outcome = scale
|
|
167
|
-
? {
|
|
168
|
-
files: parsed.sources.map((source) => source.file),
|
|
169
|
-
scale,
|
|
170
|
-
tokenCount: parsed.definitions.size,
|
|
171
|
-
warnings: parsed.warnings,
|
|
172
|
-
}
|
|
173
|
-
: null;
|
|
174
|
-
|
|
175
|
-
sourceCache.set(key, outcome);
|
|
176
|
-
return outcome;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Build a px scale from token definitions as produced by token-sources.js / contract.js. */
|
|
180
|
-
function scaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
181
|
-
const keys = [];
|
|
182
|
-
const baseKeys = [];
|
|
183
|
-
for (const definition of definitions.values()) {
|
|
184
|
-
if (TAILWIND_BASE_TOKENS.has(definition.token)) {
|
|
185
|
-
baseKeys.push(...definition.normalizedValues);
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
keys.push(...definition.normalizedValues);
|
|
189
|
-
}
|
|
190
|
-
const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
|
|
191
|
-
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Merge Tailwind-style base multiples into a scale when a bare --spacing/--space base is defined. */
|
|
195
|
-
function expandTailwindBase(scale, baseKeys, baseFontSize) {
|
|
196
|
-
const bases = pxValuesFromKeys(baseKeys, baseFontSize).filter((value) => value > 0);
|
|
197
|
-
if (bases.length === 0) {
|
|
198
|
-
return scale;
|
|
199
|
-
}
|
|
200
|
-
const values = new Set(scale);
|
|
201
|
-
for (const base of bases) {
|
|
202
|
-
for (const multiplier of TAILWIND_SPACING_MULTIPLIERS) {
|
|
203
|
-
values.add(Math.round(base * multiplier * 1000) / 1000);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return Array.from(values).sort((a, b) => a - b);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function rcTokenSources(cwd) {
|
|
210
|
-
const rcPath = path.join(cwd, RC_FILE);
|
|
211
|
-
if (!fs.existsSync(rcPath)) {
|
|
212
|
-
return [];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
let config;
|
|
216
|
-
try {
|
|
217
|
-
config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
|
|
218
|
-
} catch {
|
|
219
|
-
return [];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const audit = config && typeof config === 'object' ? config.audit : null;
|
|
223
|
-
const sources = audit && Array.isArray(audit.tokenSources) ? audit.tokenSources : [];
|
|
224
|
-
const baseDir = path.dirname(rcPath);
|
|
225
|
-
|
|
226
|
-
return sources
|
|
227
|
-
.map((source) => normalizeSource(source, baseDir))
|
|
228
|
-
.filter(Boolean);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Sass variables and maps declared in the linted stylesheet itself (postcss-scss
|
|
233
|
-
* exposes them as declarations whose prop starts with `$`). Evaluated with the
|
|
234
|
-
* same collector the audit uses; component variables such as $dropdown-spacer
|
|
235
|
-
* are excluded by the anchored name rule.
|
|
236
|
-
*/
|
|
237
|
-
function sassValuesFromRoot(root) {
|
|
238
|
-
const lines = [];
|
|
239
|
-
root.walkDecls((decl) => {
|
|
240
|
-
if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
|
|
241
|
-
lines.push(`${decl.prop}: ${decl.value};`);
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
if (lines.length === 0) {
|
|
245
|
-
return [];
|
|
246
|
-
}
|
|
247
|
-
return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing')).map((token) => token.value);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
|
|
251
|
-
const keys = [...extraKeys];
|
|
252
|
-
const baseKeys = [];
|
|
253
|
-
for (const [key, reference] of Object.entries(map)) {
|
|
254
|
-
const name = String(reference).match(/^var\((--[\w-]+)\)$/);
|
|
255
|
-
if (name && TAILWIND_BASE_TOKENS.has(name[1])) {
|
|
256
|
-
baseKeys.push(key);
|
|
257
|
-
} else {
|
|
258
|
-
keys.push(key);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
|
|
262
|
-
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Resolve `scale: "auto"`. First matching source wins; sources are not merged so
|
|
267
|
-
* the provenance is a single file list or the stylesheet.
|
|
268
|
-
*/
|
|
269
|
-
function resolveAutoScale({
|
|
270
|
-
baseFontSize = 16,
|
|
271
|
-
root,
|
|
272
|
-
scaleSources = [],
|
|
273
|
-
tailwindConfigPath = null,
|
|
274
|
-
tokenPattern = DEFAULT_AUTO_TOKEN_PATTERN,
|
|
275
|
-
} = {}) {
|
|
276
|
-
const fromOption = scaleFromSources(scaleSources, baseFontSize);
|
|
277
|
-
if (fromOption) {
|
|
278
|
-
return { source: 'scaleSources', ...fromOption };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const fromRc = scaleFromSources(rcTokenSources(process.cwd()), baseFontSize);
|
|
282
|
-
if (fromRc) {
|
|
283
|
-
return { source: 'rhythmguardrc', ...fromRc };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
let tokenRegex;
|
|
287
|
-
try {
|
|
288
|
-
tokenRegex = new RegExp(tokenPattern);
|
|
289
|
-
} catch {
|
|
290
|
-
tokenRegex = new RegExp(DEFAULT_AUTO_TOKEN_PATTERN);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (root) {
|
|
294
|
-
const stylesheetMap = buildEffectiveTokenMap({
|
|
295
|
-
options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
|
|
296
|
-
root,
|
|
297
|
-
tokenRegex,
|
|
298
|
-
});
|
|
299
|
-
const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
|
|
300
|
-
if (scale) {
|
|
301
|
-
return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (tailwindConfigPath) {
|
|
306
|
-
const tailwindMap = buildEffectiveTokenMap({
|
|
307
|
-
options: {
|
|
308
|
-
baseFontSize,
|
|
309
|
-
tailwindConfigPath,
|
|
310
|
-
tokenMap: {},
|
|
311
|
-
tokenMapFromTailwindSpacing: true,
|
|
312
|
-
},
|
|
313
|
-
root,
|
|
314
|
-
tokenRegex,
|
|
315
|
-
});
|
|
316
|
-
const scale = scaleFromTokenMap(tailwindMap, baseFontSize);
|
|
317
|
-
if (scale) {
|
|
318
|
-
return { files: [tailwindConfigPath], scale, source: 'tailwind', tokenCount: scale.length - 1, warnings: [] };
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize);
|
|
323
|
-
if (fromPackages) {
|
|
324
|
-
return { source: 'token-package', ...fromPackages };
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
files: [],
|
|
329
|
-
preset: FALLBACK_PRESET,
|
|
330
|
-
scale: getScalePreset(FALLBACK_PRESET),
|
|
331
|
-
source: 'fallback',
|
|
332
|
-
tokenCount: 0,
|
|
333
|
-
warnings: [],
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function autoScaleFallbackNote(inference) {
|
|
338
|
-
if (!inference || inference.source !== 'fallback') {
|
|
339
|
-
return '';
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".`;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
module.exports = {
|
|
346
|
-
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
347
|
-
autoScaleFallbackNote,
|
|
348
|
-
discoverTokenPackages,
|
|
349
|
-
resolveAutoScale,
|
|
350
|
-
scaleFromDefinitions,
|
|
351
|
-
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|