stylelint-plugin-rhythmguard 2.1.0 → 3.0.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 +49 -0
- package/CONTRIBUTING.md +66 -50
- package/README.md +17 -6
- package/SECURITY.md +2 -2
- package/package.json +27 -27
- package/scales/community/product-decimal-10.json +27 -4
- package/src/audit/args.js +6 -1
- package/src/audit/config.js +15 -5
- package/src/audit/contract.js +20 -0
- package/src/audit/render-markdown.js +13 -0
- package/src/audit/render-text.js +9 -1
- package/src/audit/report.js +105 -8
- package/src/audit/scan.js +67 -10
- package/src/cli/index.js +11 -5
- package/src/cli/init.js +7 -4
- package/src/cli/quickstart.js +207 -0
- package/src/configs/embed.js +24 -0
- package/src/configs/{logical.mjs → embed.mjs} +1 -1
- package/src/index.js +1 -4
- 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 +351 -0
- package/src/utils/token-map.js +4 -2
- package/src/utils/token-packages.json +48 -0
- package/src/utils/token-sources.js +313 -4
- package/types/__checks__/consumer.ts +10 -6
- package/types/audit.d.ts +21 -1
- package/types/index.d.ts +4 -4
- package/types/shared.d.ts +24 -1
- package/src/configs/expanded.js +0 -27
- package/src/configs/expanded.mjs +0 -4
- package/src/configs/logical.js +0 -16
- package/src/configs/migration.js +0 -31
- package/src/configs/migration.mjs +0 -4
- package/src/configs/react-tailwind.js +0 -25
- package/src/configs/react-tailwind.mjs +0 -4
package/src/audit/report.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
3
5
|
const {
|
|
6
|
+
createTokenKindMatcher,
|
|
4
7
|
parseTokenSources,
|
|
5
8
|
} = require('../utils/token-sources');
|
|
6
9
|
const {
|
|
@@ -13,7 +16,9 @@ const {
|
|
|
13
16
|
loadAuditConfig,
|
|
14
17
|
loadIgnorePatterns,
|
|
15
18
|
} = require('./config');
|
|
16
|
-
const { buildReport } = require('./contract');
|
|
19
|
+
const { buildReport, collectTokenDefinitions } = require('./contract');
|
|
20
|
+
const { DEFAULT_SCALE, formatPath } = require('./shared');
|
|
21
|
+
const { discoverTokenPackages, scaleFromDefinitions } = require('../utils/scale-inference');
|
|
17
22
|
const {
|
|
18
23
|
assertDirectory,
|
|
19
24
|
collectCssFindings,
|
|
@@ -35,19 +40,27 @@ async function createAuditReport(options) {
|
|
|
35
40
|
const scanFiles = getScanFiles(resolvedDir, ignorePatterns, parsed);
|
|
36
41
|
|
|
37
42
|
const { cssFiles, scanScope, templateFiles } = scanFiles;
|
|
38
|
-
const lintOptions = {
|
|
39
|
-
baseFontSize: parsed.baseFontSize,
|
|
40
|
-
includeMotion: parsed.includeMotion,
|
|
41
|
-
scale: parsed.scale,
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const cssResults = await runStylelintAudit(cssFiles, lintOptions);
|
|
45
43
|
|
|
46
44
|
const tokenSourceResult = parseTokenSources({
|
|
47
45
|
baseFontSize: parsed.baseFontSize,
|
|
48
46
|
sources: parsed.tokenSources,
|
|
49
47
|
tokenKind: parsed.tokenKind,
|
|
50
48
|
});
|
|
49
|
+
|
|
50
|
+
const scale = resolveAuditScale({
|
|
51
|
+
baseFontSize: parsed.baseFontSize,
|
|
52
|
+
cssFiles,
|
|
53
|
+
requested: parsed.scale,
|
|
54
|
+
tokenSourceResult,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const lintOptions = {
|
|
58
|
+
baseFontSize: parsed.baseFontSize,
|
|
59
|
+
includeMotion: parsed.includeMotion,
|
|
60
|
+
scale: scale.values,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const cssResults = await runStylelintAudit(cssFiles, lintOptions);
|
|
51
64
|
const stylelintFindings = collectCssFindings(cssResults);
|
|
52
65
|
const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
|
|
53
66
|
const motionFindings = [
|
|
@@ -64,7 +77,10 @@ async function createAuditReport(options) {
|
|
|
64
77
|
externalTokenDefinitions: tokenSourceResult.definitions,
|
|
65
78
|
includeMotion: parsed.includeMotion,
|
|
66
79
|
motionFindings,
|
|
80
|
+
scale,
|
|
67
81
|
scanScope,
|
|
82
|
+
scssFiles: cssResults.scssFiles || 0,
|
|
83
|
+
scssSkipped: cssResults.scssSkipped || 0,
|
|
68
84
|
tailwindFindings: collectTailwindFindings(templateFiles, lintOptions),
|
|
69
85
|
templateFiles,
|
|
70
86
|
tokenCandidateMinCount: parsed.tokenCandidateMinCount,
|
|
@@ -84,6 +100,87 @@ async function createAuditReport(options) {
|
|
|
84
100
|
return report;
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
const NON_AUTHORED_SEGMENT = /(^|\/)(test|tests|__tests__|spec|specs|fixtures?|__fixtures__|__snapshots__|vendor|third[-_]?party|storybook-static)(\/|$)/i;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One project-level scale for the whole audit, with provenance.
|
|
107
|
+
* "auto": external token sources, then spacing custom properties across the scanned
|
|
108
|
+
* CSS, then the default scale. First source that yields values wins.
|
|
109
|
+
*/
|
|
110
|
+
function resolveAuditScale({ baseFontSize, cssFiles, requested, tokenSourceResult }) {
|
|
111
|
+
if (Array.isArray(requested)) {
|
|
112
|
+
return {
|
|
113
|
+
files: [],
|
|
114
|
+
source: requested === DEFAULT_SCALE ? 'default' : 'explicit',
|
|
115
|
+
tokenCount: 0,
|
|
116
|
+
values: requested,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (tokenSourceResult.definitions.size > 0) {
|
|
121
|
+
const values = scaleFromDefinitions(tokenSourceResult.definitions, baseFontSize);
|
|
122
|
+
if (values) {
|
|
123
|
+
return {
|
|
124
|
+
files: tokenSourceResult.sources.map((source) => source.file),
|
|
125
|
+
source: 'token-sources',
|
|
126
|
+
tokenCount: tokenSourceResult.definitions.size,
|
|
127
|
+
values,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const definitions = new Map();
|
|
133
|
+
const matchesKind = createTokenKindMatcher('spacing');
|
|
134
|
+
for (const filePath of cssFiles) {
|
|
135
|
+
// Test, fixture and vendored stylesheets often redefine tokens to exercise
|
|
136
|
+
// overrides; they are findings noise and must not shape the inferred scale.
|
|
137
|
+
if (NON_AUTHORED_SEGMENT.test(formatPath(filePath))) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let text;
|
|
141
|
+
try {
|
|
142
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
collectTokenDefinitions(text, formatPath(filePath), definitions, matchesKind, baseFontSize);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (definitions.size > 0) {
|
|
150
|
+
const values = scaleFromDefinitions(definitions, baseFontSize);
|
|
151
|
+
if (values) {
|
|
152
|
+
const files = new Set();
|
|
153
|
+
for (const definition of definitions.values()) {
|
|
154
|
+
for (const file of definition.files) {
|
|
155
|
+
files.add(file);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
files: Array.from(files).sort(),
|
|
160
|
+
source: 'scanned-css',
|
|
161
|
+
tokenCount: definitions.size,
|
|
162
|
+
values,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const packageSources = discoverTokenPackages(process.cwd());
|
|
168
|
+
if (packageSources.length > 0) {
|
|
169
|
+
const parsedPackages = parseTokenSources({ baseFontSize, sources: packageSources, tokenKind: 'spacing' });
|
|
170
|
+
const values = scaleFromDefinitions(parsedPackages.definitions, baseFontSize);
|
|
171
|
+
if (values) {
|
|
172
|
+
return {
|
|
173
|
+
files: parsedPackages.sources.map((source) => source.file),
|
|
174
|
+
source: 'token-package',
|
|
175
|
+
tokenCount: parsedPackages.definitions.size,
|
|
176
|
+
values,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { files: [], source: 'fallback', tokenCount: 0, values: DEFAULT_SCALE };
|
|
182
|
+
}
|
|
183
|
+
|
|
87
184
|
function normalizeCreateAuditOptions(options = {}) {
|
|
88
185
|
const parsed = {
|
|
89
186
|
...createDefaultAuditOptions(),
|
package/src/audit/scan.js
CHANGED
|
@@ -230,8 +230,33 @@ function toPosixRelativePath(rootDir, filePath) {
|
|
|
230
230
|
return path.relative(rootDir, filePath).split(path.sep).join('/');
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
function isScssFile(filePath) {
|
|
234
|
+
return filePath.endsWith('.scss');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// "CSS files" in the audit means authored stylesheets: .css always, .scss when
|
|
238
|
+
// postcss-scss can be resolved (see resolveScssSyntax).
|
|
233
239
|
function isCssFile(filePath) {
|
|
234
|
-
return filePath.endsWith('.css');
|
|
240
|
+
return filePath.endsWith('.css') || isScssFile(filePath);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let scssSyntaxCache;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* postcss-scss is an optional peer. Resolve it from the audited project first,
|
|
247
|
+
* then from this package. Returns null when unavailable; SCSS files are then
|
|
248
|
+
* counted as skipped instead of failing the audit.
|
|
249
|
+
*/
|
|
250
|
+
function resolveScssSyntax() {
|
|
251
|
+
if (scssSyntaxCache !== undefined) {
|
|
252
|
+
return scssSyntaxCache;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
scssSyntaxCache = require.resolve('postcss-scss', { paths: [process.cwd(), __dirname] });
|
|
256
|
+
} catch {
|
|
257
|
+
scssSyntaxCache = null;
|
|
258
|
+
}
|
|
259
|
+
return scssSyntaxCache;
|
|
235
260
|
}
|
|
236
261
|
|
|
237
262
|
function isTemplateFile(filePath) {
|
|
@@ -240,7 +265,10 @@ function isTemplateFile(filePath) {
|
|
|
240
265
|
|
|
241
266
|
async function runStylelintAudit(cssFiles, options) {
|
|
242
267
|
if (cssFiles.length === 0) {
|
|
243
|
-
|
|
268
|
+
const empty = [];
|
|
269
|
+
empty.scssFiles = 0;
|
|
270
|
+
empty.scssSkipped = 0;
|
|
271
|
+
return empty;
|
|
244
272
|
}
|
|
245
273
|
|
|
246
274
|
const { default: stylelint } = await import('stylelint');
|
|
@@ -274,15 +302,42 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
274
302
|
];
|
|
275
303
|
}
|
|
276
304
|
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
305
|
+
const plainFiles = cssFiles.filter((file) => !isScssFile(file));
|
|
306
|
+
const scssFiles = cssFiles.filter(isScssFile);
|
|
307
|
+
const results = [];
|
|
308
|
+
|
|
309
|
+
if (plainFiles.length > 0) {
|
|
310
|
+
const result = await stylelint.lint({
|
|
311
|
+
files: plainFiles,
|
|
312
|
+
config: {
|
|
313
|
+
plugins: [pluginPath],
|
|
314
|
+
rules,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
results.push(...(result.results || []));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let scssSkipped = 0;
|
|
321
|
+
if (scssFiles.length > 0) {
|
|
322
|
+
const scssSyntax = resolveScssSyntax();
|
|
323
|
+
if (scssSyntax) {
|
|
324
|
+
const result = await stylelint.lint({
|
|
325
|
+
files: scssFiles,
|
|
326
|
+
config: {
|
|
327
|
+
customSyntax: scssSyntax,
|
|
328
|
+
plugins: [pluginPath],
|
|
329
|
+
rules,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
results.push(...(result.results || []));
|
|
333
|
+
} else {
|
|
334
|
+
scssSkipped = scssFiles.length;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
284
337
|
|
|
285
|
-
|
|
338
|
+
results.scssFiles = scssFiles.length;
|
|
339
|
+
results.scssSkipped = scssSkipped;
|
|
340
|
+
return results;
|
|
286
341
|
}
|
|
287
342
|
|
|
288
343
|
function collectCssFindings(fileResults) {
|
|
@@ -535,6 +590,8 @@ module.exports = {
|
|
|
535
590
|
globToRegExp,
|
|
536
591
|
hasGlob,
|
|
537
592
|
isCssFile,
|
|
593
|
+
isScssFile,
|
|
594
|
+
resolveScssSyntax,
|
|
538
595
|
isPathInside,
|
|
539
596
|
isTemplateFile,
|
|
540
597
|
offsetToLineColumn,
|
package/src/cli/index.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
const command = process.argv[2];
|
|
5
5
|
|
|
6
|
-
const HELP = `Usage: rhythmguard
|
|
6
|
+
const HELP = `Usage: rhythmguard [command]
|
|
7
|
+
|
|
8
|
+
With no command: zero-config quickstart. Detects your stack and tokens, infers
|
|
9
|
+
the spacing scale, audits the current directory and prints a config to paste.
|
|
7
10
|
|
|
8
11
|
Commands:
|
|
12
|
+
quickstart Same as running with no command
|
|
9
13
|
audit <dir> Report design-system drift across CSS and Tailwind class strings
|
|
10
14
|
init Scaffold a Rhythmguard config for your project
|
|
11
15
|
doctor Validate your Rhythmguard setup
|
|
@@ -14,22 +18,24 @@ Options:
|
|
|
14
18
|
--help Show this help message
|
|
15
19
|
|
|
16
20
|
Examples:
|
|
21
|
+
npx rhythmguard
|
|
17
22
|
npx rhythmguard audit ./src
|
|
18
|
-
npx rhythmguard audit ./src --json
|
|
19
23
|
npx rhythmguard audit ./src --format markdown
|
|
20
24
|
npx rhythmguard init
|
|
21
25
|
npx rhythmguard doctor
|
|
22
26
|
`;
|
|
23
27
|
|
|
24
|
-
if (
|
|
28
|
+
if (command === '--help' || command === '-h') {
|
|
25
29
|
process.stdout.write(HELP);
|
|
26
30
|
process.exit(0);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
if (command === '
|
|
33
|
+
if (!command || command === 'quickstart') {
|
|
34
|
+
require('./quickstart').run();
|
|
35
|
+
} else if (command === 'audit') {
|
|
30
36
|
require('./audit').run();
|
|
31
37
|
} else if (command === 'init') {
|
|
32
|
-
require('./init');
|
|
38
|
+
require('./init').run();
|
|
33
39
|
} else if (command === 'doctor') {
|
|
34
40
|
require('./doctor');
|
|
35
41
|
} else {
|
package/src/cli/init.js
CHANGED
|
@@ -92,9 +92,6 @@ function detect() {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
function selectProfile(stack) {
|
|
95
|
-
if (stack.nextjs && stack.tailwind) {
|
|
96
|
-
return 'react-tailwind';
|
|
97
|
-
}
|
|
98
95
|
if (stack.tailwind) {
|
|
99
96
|
return 'tailwind';
|
|
100
97
|
}
|
|
@@ -140,6 +137,8 @@ async function run() {
|
|
|
140
137
|
|
|
141
138
|
const config = {
|
|
142
139
|
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
140
|
+
// Next.js build output is generated CSS; never lint it.
|
|
141
|
+
...(stack.nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
|
|
143
142
|
};
|
|
144
143
|
|
|
145
144
|
const configPath = path.join(process.cwd(), '.stylelintrc.json');
|
|
@@ -153,4 +152,8 @@ async function run() {
|
|
|
153
152
|
}
|
|
154
153
|
}
|
|
155
154
|
|
|
156
|
-
run
|
|
155
|
+
module.exports = { detect, run, selectProfile };
|
|
156
|
+
|
|
157
|
+
if (require.main === module) {
|
|
158
|
+
run();
|
|
159
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `npx rhythmguard` with no arguments.
|
|
5
|
+
*
|
|
6
|
+
* The first minute, with nothing to configure: detect the stack and any token
|
|
7
|
+
* files, infer the spacing scale from the project's own tokens, audit the
|
|
8
|
+
* current directory, and end with the exact config to paste. Never fails the
|
|
9
|
+
* process on findings; this is a report, not a gate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const { createAuditReport } = require('../audit/report');
|
|
16
|
+
const { detect } = require('./init');
|
|
17
|
+
const { discoverTokenPackages } = require('../utils/scale-inference');
|
|
18
|
+
|
|
19
|
+
const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
|
|
20
|
+
const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
|
|
21
|
+
const MAX_LISTED = 5;
|
|
22
|
+
|
|
23
|
+
function readPackage(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tailwindMajor(pkg) {
|
|
32
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
33
|
+
if (deps['@tailwindcss/postcss'] || deps['@tailwindcss/vite']) {
|
|
34
|
+
return 4;
|
|
35
|
+
}
|
|
36
|
+
const spec = deps.tailwindcss;
|
|
37
|
+
if (!spec) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const match = String(spec).match(/(\d+)/);
|
|
41
|
+
return match ? Number(match[1]) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findTokenFiles(cwd) {
|
|
45
|
+
const found = new Set();
|
|
46
|
+
const consider = (dir) => {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (entry.isFile() && TOKEN_FILE_PATTERN.test(entry.name)) {
|
|
55
|
+
found.add(path.relative(cwd, path.join(dir, entry.name)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
consider(cwd);
|
|
60
|
+
for (const sub of TOKEN_DIRS) {
|
|
61
|
+
consider(path.join(cwd, sub));
|
|
62
|
+
}
|
|
63
|
+
return Array.from(found).sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasRhythmguardConfig(cwd) {
|
|
67
|
+
return fs.existsSync(path.join(cwd, '.rhythmguardrc.json'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toPosix(relativePath) {
|
|
71
|
+
return `./${relativePath.split(path.sep).join('/')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function topEntries(counts, limit = MAX_LISTED) {
|
|
75
|
+
return Object.entries(counts || {})
|
|
76
|
+
.sort((a, b) => b[1] - a[1])
|
|
77
|
+
.slice(0, limit);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function suggestedStylelintConfig({ nextjs = false, profile, tokenFiles }) {
|
|
81
|
+
const ruleOptions = { scale: 'auto' };
|
|
82
|
+
if (tokenFiles.length > 0) {
|
|
83
|
+
ruleOptions.scaleSources = tokenFiles.map(toPosix);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
87
|
+
...(nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
|
|
88
|
+
rules: {
|
|
89
|
+
'rhythmguard/use-scale': [true, ruleOptions],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function eslintSnippet(scaleValues) {
|
|
95
|
+
const scale = scaleValues.filter((value) => typeof value === 'number').join(', ');
|
|
96
|
+
return [
|
|
97
|
+
"// eslint.config.js",
|
|
98
|
+
"import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';",
|
|
99
|
+
'',
|
|
100
|
+
'export default [',
|
|
101
|
+
' {',
|
|
102
|
+
" plugins: { 'rhythmguard-tailwind': rhythmguard },",
|
|
103
|
+
` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
|
|
104
|
+
' },',
|
|
105
|
+
'];',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function describeScale(scale) {
|
|
110
|
+
if (!scale) {
|
|
111
|
+
return 'default';
|
|
112
|
+
}
|
|
113
|
+
const files = scale.files && scale.files.length > 0 ? ` (${scale.files.slice(0, 3).join(', ')}${scale.files.length > 3 ? ', …' : ''})` : '';
|
|
114
|
+
return `${scale.source}${files}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function run() {
|
|
118
|
+
const cwd = process.cwd();
|
|
119
|
+
const pkg = readPackage(cwd);
|
|
120
|
+
const stack = detect();
|
|
121
|
+
const twMajor = stack.tailwind ? tailwindMajor(pkg) : null;
|
|
122
|
+
const tokenFiles = findTokenFiles(cwd);
|
|
123
|
+
const rcPresent = hasRhythmguardConfig(cwd);
|
|
124
|
+
const out = [];
|
|
125
|
+
|
|
126
|
+
out.push('', ' Rhythmguard quickstart', ` ${cwd}`, '');
|
|
127
|
+
out.push(' Detected');
|
|
128
|
+
out.push(` Tailwind ${stack.tailwind ? `v${twMajor || '?'}` : 'no'}`);
|
|
129
|
+
out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
|
|
130
|
+
out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
|
|
131
|
+
out.push(` Token files ${tokenFiles.length > 0 ? tokenFiles.join(', ') : 'none found'}`);
|
|
132
|
+
const tokenPackages = [...new Set(discoverTokenPackages(cwd).map((source) => source.package))];
|
|
133
|
+
out.push(` Token packages ${tokenPackages.length > 0 ? tokenPackages.join(', ') : 'none installed'}`);
|
|
134
|
+
out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
|
|
135
|
+
out.push('');
|
|
136
|
+
|
|
137
|
+
let report;
|
|
138
|
+
try {
|
|
139
|
+
report = await createAuditReport({
|
|
140
|
+
dir: '.',
|
|
141
|
+
scale: 'auto',
|
|
142
|
+
...(tokenFiles.length > 0 && !rcPresent
|
|
143
|
+
? { tokenSources: tokenFiles }
|
|
144
|
+
: {}),
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0) {
|
|
153
|
+
out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
|
|
154
|
+
out.push(' npx rhythmguard audit ./src', '');
|
|
155
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scale = report.scale;
|
|
160
|
+
out.push(' Scale');
|
|
161
|
+
out.push(` Scale ${scale.values.join(', ')}`);
|
|
162
|
+
out.push(` Source ${describeScale(scale)}`);
|
|
163
|
+
if (scale.source === 'fallback') {
|
|
164
|
+
out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
|
|
165
|
+
out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
|
|
166
|
+
}
|
|
167
|
+
out.push('');
|
|
168
|
+
|
|
169
|
+
const offScale = report.findings.css.filter((finding) => finding.type === 'off-scale');
|
|
170
|
+
out.push(' Findings');
|
|
171
|
+
out.push(` CSS files ${report.cssFilesScanned}`);
|
|
172
|
+
out.push(` Template files ${report.templateFilesScanned}`);
|
|
173
|
+
out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
|
|
174
|
+
out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
|
|
175
|
+
out.push(` Cleanliness ${report.scaleCleanliness}%`);
|
|
176
|
+
const topValues = topEntries(report.offScaleValues);
|
|
177
|
+
if (topValues.length > 0) {
|
|
178
|
+
out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
const topFiles = (report.topAffectedFiles || []).slice(0, 3);
|
|
181
|
+
if (topFiles.length > 0) {
|
|
182
|
+
out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
|
|
183
|
+
}
|
|
184
|
+
out.push('');
|
|
185
|
+
|
|
186
|
+
const profile = stack.tailwind ? 'tailwind' : 'recommended';
|
|
187
|
+
out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
|
|
188
|
+
out.push(JSON.stringify(suggestedStylelintConfig({ nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
|
|
189
|
+
out.push('');
|
|
190
|
+
|
|
191
|
+
if (stack.tailwind) {
|
|
192
|
+
out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
|
|
193
|
+
out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
|
|
194
|
+
out.push('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
out.push(' Next');
|
|
198
|
+
out.push(' npx stylelint "**/*.css" lint with the config above');
|
|
199
|
+
out.push(' npx rhythmguard audit . --format markdown PR-ready report');
|
|
200
|
+
out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
|
|
201
|
+
out.push(' npx rhythmguard doctor check the setup');
|
|
202
|
+
out.push('');
|
|
203
|
+
|
|
204
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one-liner for shared-config authors.
|
|
5
|
+
*
|
|
6
|
+
* One rule, warning level, scale inferred from the consuming project's own
|
|
7
|
+
* spacing tokens (scaleSources, .rhythmguardrc.json token sources, the
|
|
8
|
+
* stylesheet's custom properties, a Tailwind config, then rhythmic-4).
|
|
9
|
+
* No `extends`, no dependency beyond this plugin, and the shape is frozen for
|
|
10
|
+
* the life of the 2.x line so a config that embeds it never changes behaviour
|
|
11
|
+
* on a minor bump.
|
|
12
|
+
*/
|
|
13
|
+
module.exports = {
|
|
14
|
+
plugins: ['stylelint-plugin-rhythmguard'],
|
|
15
|
+
rules: {
|
|
16
|
+
'rhythmguard/use-scale': [
|
|
17
|
+
true,
|
|
18
|
+
{
|
|
19
|
+
scale: 'auto',
|
|
20
|
+
severity: 'warning',
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
};
|
package/src/index.js
CHANGED
|
@@ -15,14 +15,11 @@ module.exports.rules = {
|
|
|
15
15
|
[useMotionScale.ruleName]: useMotionScale,
|
|
16
16
|
};
|
|
17
17
|
module.exports.configs = {
|
|
18
|
+
embed: require('./configs/embed'),
|
|
18
19
|
recommended: require('./configs/recommended'),
|
|
19
20
|
strict: require('./configs/strict'),
|
|
20
21
|
tailwind: require('./configs/tailwind'),
|
|
21
|
-
expanded: require('./configs/expanded'),
|
|
22
|
-
logical: require('./configs/logical'),
|
|
23
|
-
migration: require('./configs/migration'),
|
|
24
22
|
motion: require('./configs/motion'),
|
|
25
|
-
'react-tailwind': require('./configs/react-tailwind'),
|
|
26
23
|
};
|
|
27
24
|
module.exports.eslint = require('./eslint');
|
|
28
25
|
module.exports.presets = require('./presets');
|
|
@@ -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 !== '%' &&
|